package handlers

import (
	"context"
	"errors"
	"fmt"
	"mime/multipart"
	"net/http"
	"strconv"
	"strings"
	"time"

	"warpbox.dev/backend/libs/helpers"
	"warpbox.dev/backend/libs/jobs"
	"warpbox.dev/backend/libs/services"
)

func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
	user, loggedIn, authErr := a.currentUserWithAuthError(r)
	if authErr != nil {
		a.logger.Warn("upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4010)...)
		helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
		return
	}
	isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
	settings, err := a.settingsService.UploadPolicy()
	if err != nil {
		a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5005, "error", err.Error())
		helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
		return
	}
	if !loggedIn && !settings.AnonymousUploadsEnabled {
		a.logger.Warn("anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4012)...)
		helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
		return
	}
	effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
	rateKey := uploadRateKey(r, user, loggedIn)
	if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
		a.logger.Warn("upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4290, "user_id", user.ID)...)
		helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
		return
	}

	parseLimit := uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize())
	if !isAdminUpload && parseLimit > 0 {
		r.Body = http.MaxBytesReader(w, r.Body, parseLimit)
	}
	if isAdminUpload {
		parseLimit = 32 << 20
	} else if parseLimit <= 0 {
		parseLimit = 32 << 20
	}
	if err := r.ParseMultipartForm(parseLimit); err != nil {
		a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...)
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, "upload exceeds the configured upload limit")
			return
		}
		helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
		return
	}

	files := uploadFiles(r)
	totalBytes := totalUploadBytes(files)
	var ownerID string
	var collectionID string
	if loggedIn {
		ownerID = user.ID
		collectionID = r.FormValue("collection_id")
		if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
			a.logger.Warn("upload rejected invalid collection", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)...)
			helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
			return
		}
	}
	if !isAdminUpload {
		if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
			a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))...)
			helpers.WriteJSONError(w, status, message)
			return
		}
	}
	// Unlimited expiry: admins, or users whose effective MaxDays is negative.
	unlimitedExpiry := isAdminUpload || effectivePolicy.MaxDays < 0

	rawMaxDays := parseInt(r.FormValue("max_days"))
	maxDays := rawMaxDays
	if maxDays <= 0 {
		maxDays = 7
		if effectivePolicy.MaxDays > 0 && effectivePolicy.MaxDays < maxDays {
			maxDays = effectivePolicy.MaxDays
		}
	}
	if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
		a.logger.Warn("upload rejected expiration days", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4131, "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)...)
		helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
		return
	}

	expiresMinutes := parseInt(r.FormValue("expires_minutes"))
	// A negative expires_minutes (or max_days) is the "never expires" request.
	// Only honour it for unlimited uploaders; otherwise it's an invalid value.
	if expiresMinutes < 0 || rawMaxDays < 0 {
		if !unlimitedExpiry {
			a.logger.Warn("upload rejected unlimited expiration", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4133, "user_id", user.ID)...)
			helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
			return
		}
		expiresMinutes = -1
	} else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 {
		a.logger.Warn("upload rejected expiration minutes", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4132, "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)...)
		helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
		return
	}
	opts := services.UploadOptions{
		MaxDays:           maxDays,
		ExpiresInMinutes:  expiresMinutes,
		MaxDownloads:      parseInt(r.FormValue("max_downloads")),
		Password:          r.FormValue("password"),
		ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
		OwnerID:           ownerID,
		CollectionID:      collectionID,
		SkipSizeLimit:     isAdminUpload || effectivePolicy.MaxUploadMB < 0,
		CreatorIP:         uploadClientIP(r),
		StorageBackendID:  effectivePolicy.StorageBackendID,
	}
	result, boxesAdded, status, policyMessage, err := a.createOrAppendBox(r, user, loggedIn, effectivePolicy, files, opts, !isAdminUpload)
	if policyMessage != "" {
		a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))...)
		helpers.WriteJSONError(w, status, policyMessage)
		return
	}
	if err != nil {
		a.logger.Warn("upload failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4001, "user_id", user.ID, "error", err.Error())...)
		helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
		return
	}
	if !isAdminUpload {
		if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, boxesAdded); err != nil {
			a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
		}
		if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
			a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4403, "error", err.Error())
		}
	}
	jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
	a.logger.Info("box uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2001, "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)

	if wantsJSON(r) {
		helpers.WriteJSON(w, http.StatusCreated, result)
		return
	}

	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	_, _ = fmt.Fprintln(w, result.BoxURL)
}

// createOrAppendBox creates a new box. It only ever appends to an existing box
// when the request opts in via the X-Warpbox-Batch header: requests sharing the
// same batch value (per account, or per IP for anonymous) within
// uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
	batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
	if batch == "" {
		if enforceBoxLimits {
			if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
				return services.UploadResult{}, 0, status, message, nil
			}
		}
		result, err := a.uploadService.CreateBox(files, opts)
		if err != nil {
			return services.UploadResult{}, 0, 0, "", err
		}
		return result, 1, 0, "", nil
	}

	// Group key is scoped to the uploader so batches never cross accounts/IPs.
	identity := "ip:" + uploadClientIP(r)
	if loggedIn {
		identity = "user:" + user.ID
	}
	entry := a.uploadGroups.entryFor(identity + "|" + batch)

	// Hold the per-key lock across the whole create/append so concurrent batched
	// uploads serialise into the same box instead of racing.
	entry.mu.Lock()
	defer entry.mu.Unlock()

	if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
		if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
			if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
				// Re-attach the manage/delete URLs from the box's creation so every
				// upload in the batch returns a working deletion URL.
				result.ManageURL = entry.manageURL
				result.DeleteURL = entry.deleteURL
				entry.at = time.Now()
				return result, 0, 0, "", nil
			}
		}
	}

	if enforceBoxLimits {
		if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
			return services.UploadResult{}, 0, status, message, nil
		}
	}
	result, err := a.uploadService.CreateBox(files, opts)
	if err != nil {
		return services.UploadResult{}, 0, 0, "", err
	}
	entry.boxID = result.BoxID
	entry.manageURL = result.ManageURL
	entry.deleteURL = result.DeleteURL
	entry.at = time.Now()
	return result, 1, 0, "", nil
}

// batchBoxMatches guards that a batched append only ever touches a box owned by
// the same uploader (account for logged-in users, creator IP for anonymous).
func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn bool, r *http.Request) bool {
	if loggedIn {
		return box.OwnerID == user.ID
	}
	return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
}

func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
	if len(files) == 0 {
		return 0, ""
	}
	sizes := make([]int64, 0, len(files))
	for _, file := range files {
		sizes = append(sizes, file.Size)
	}
	return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
}

func (a *App) checkUploadPolicyForSizes(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, fileSizes []int64, totalBytes int64) (int, string) {
	if len(fileSizes) == 0 {
		return 0, ""
	}
	now := time.Now().UTC()
	if policy.MaxUploadMB > 0 {
		maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
		for _, fileSize := range fileSizes {
			if fileSize > maxBytes {
				return http.StatusRequestEntityTooLarge, "file exceeds upload size limit of " + services.FormatMegabytesLabel(policy.MaxUploadMB)
			}
		}
	}
	if !loggedIn {
		usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
		if err != nil {
			return http.StatusInternalServerError, "upload usage could not be checked"
		}
		if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
			return http.StatusTooManyRequests, "anonymous daily upload limit reached"
		}
		if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
			return status, message
		}
		return 0, ""
	}

	usage, err := a.settingsService.UsageForUser(user.ID, now)
	if err != nil {
		return http.StatusInternalServerError, "upload usage could not be checked"
	}
	if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
		return http.StatusTooManyRequests, "daily upload limit reached"
	}
	activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
	if err != nil {
		return http.StatusInternalServerError, "storage quota could not be checked"
	}
	if policy.StorageQuotaSet && activeStorage+totalBytes > services.MegabytesToBytes(policy.StorageQuotaMB) {
		return http.StatusRequestEntityTooLarge, "storage quota reached"
	}
	if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
		return status, message
	}
	return 0, ""
}

func (a *App) checkBoxCreationPolicy(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy) (int, string) {
	now := time.Now().UTC()
	if !loggedIn {
		usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
		if err != nil {
			return http.StatusInternalServerError, "upload usage could not be checked"
		}
		if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
			return http.StatusTooManyRequests, "anonymous daily box limit reached"
		}
		activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
		if err != nil {
			return http.StatusInternalServerError, "active box limit could not be checked"
		}
		if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
			return http.StatusTooManyRequests, "anonymous active box limit reached"
		}
		return 0, ""
	}
	usage, err := a.settingsService.UsageForUser(user.ID, now)
	if err != nil {
		return http.StatusInternalServerError, "upload usage could not be checked"
	}
	if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
		return http.StatusTooManyRequests, "daily box limit reached"
	}
	activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
	if err != nil {
		return http.StatusInternalServerError, "active box limit could not be checked"
	}
	if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
		return http.StatusTooManyRequests, "active box limit reached"
	}
	return 0, ""
}

func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
	now := time.Now().UTC()
	if loggedIn {
		return a.settingsService.AddUploadUsage("user", user.ID, totalBytes, boxes, now)
	}
	return a.settingsService.AddUploadUsage("ip", uploadClientIP(r), totalBytes, boxes, now)
}

func (a *App) effectiveUploadPolicy(settings services.UploadPolicySettings, user services.User, loggedIn bool) services.EffectiveUploadPolicy {
	if loggedIn {
		return a.settingsService.EffectivePolicyForUser(settings, user)
	}
	return a.settingsService.EffectivePolicyForAnonymous(settings)
}

func (a *App) checkStorageBackendCapacity(backendID string, settings services.UploadPolicySettings, totalBytes int64) (int, string) {
	if backendID != services.StorageBackendLocal {
		return 0, ""
	}
	backend, err := a.uploadService.Storage().Backend(services.StorageBackendLocal)
	if err != nil {
		return http.StatusInternalServerError, "storage backend could not be checked"
	}
	used, err := backend.Usage(context.Background())
	if err != nil {
		return http.StatusInternalServerError, "storage backend usage could not be checked"
	}
	if used+totalBytes > services.GigabytesToBytes(settings.LocalStorageMaxGB) {
		return http.StatusRequestEntityTooLarge, "local storage limit reached"
	}
	return 0, ""
}

func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 {
	if policy.MaxUploadMB < 0 {
		return -1
	}
	if loggedIn && policy.MaxUploadMB <= 0 {
		return fallback * 8
	}
	if policy.MaxUploadMB > 0 {
		return services.MegabytesToBytes(policy.MaxUploadMB) * 8
	}
	return fallback * 8
}

func uploadClientIP(r *http.Request) string {
	if ip, ok := services.ClientIPFromContext(r); ok {
		return ip
	}
	return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
}

func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
	if loggedIn {
		return "user:" + user.ID
	}
	return "ip:" + uploadClientIP(r)
}

func totalUploadBytes(files []*multipart.FileHeader) int64 {
	var total int64
	for _, file := range files {
		total += file.Size
	}
	return total
}

func parseInt(value string) int {
	if value == "" {
		return 0
	}
	parsed, err := strconv.Atoi(value)
	if err != nil {
		return 0
	}
	return parsed
}

func statusForDownloadError(err error) int {
	if errors.Is(err, http.ErrMissingFile) {
		return http.StatusNotFound
	}
	return http.StatusForbidden
}

func uploadFiles(r *http.Request) []*multipart.FileHeader {
	if r.MultipartForm == nil {
		return nil
	}
	files := make([]*multipart.FileHeader, 0)
	files = append(files, r.MultipartForm.File["file"]...)
	files = append(files, r.MultipartForm.File["sharex"]...)
	return files
}

func wantsJSON(r *http.Request) bool {
	return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json")
}
