package services

import (
	"archive/zip"
	"bytes"
	"context"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"mime/multipart"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"go.etcd.io/bbolt"
	"warpbox.dev/backend/libs/helpers"
)

var boxesBucket = []byte("boxes")

type UploadService struct {
	maxUploadSize int64
	baseURL       string
	dataDir       string
	filesDir      string
	db            *bbolt.DB
	logger        *slog.Logger
	storage       *StorageService
}

type UploadOptions struct {
	MaxDays           int
	ExpiresInMinutes  int
	MaxDownloads      int
	Password          string
	PasswordSalt      string
	PasswordHash      string
	ObfuscateMetadata bool
	OwnerID           string
	CollectionID      string
	SkipSizeLimit     bool
	CreatorIP         string
	StorageBackendID  string
}

type IncomingFile interface {
	Name() string
	Size() int64
	ContentType() string
	Open() (io.ReadCloser, error)
}

type multipartIncomingFile struct {
	header *multipart.FileHeader
}

func (f multipartIncomingFile) Name() string {
	return f.header.Filename
}

func (f multipartIncomingFile) Size() int64 {
	return f.header.Size
}

func (f multipartIncomingFile) ContentType() string {
	return f.header.Header.Get("Content-Type")
}

func (f multipartIncomingFile) Open() (io.ReadCloser, error) {
	return f.header.Open()
}

type StagedUploadFile struct {
	Filename string
	FileSize int64
	MIMEType string
	Path     string
}

func (f StagedUploadFile) Name() string {
	return f.Filename
}

func (f StagedUploadFile) Size() int64 {
	return f.FileSize
}

func (f StagedUploadFile) ContentType() string {
	return f.MIMEType
}

func (f StagedUploadFile) Open() (io.ReadCloser, error) {
	return os.Open(f.Path)
}

type Box struct {
	ID               string    `json:"id"`
	OwnerID          string    `json:"ownerId,omitempty"`
	CollectionID     string    `json:"collectionId,omitempty"`
	Title            string    `json:"title,omitempty"`
	CreatedAt        time.Time `json:"createdAt"`
	ExpiresAt        time.Time `json:"expiresAt"`
	MaxDownloads     int       `json:"maxDownloads"`
	DownloadCount    int       `json:"downloadCount"`
	PasswordSalt     string    `json:"passwordSalt,omitempty"`
	PasswordHash     string    `json:"passwordHash,omitempty"`
	DeleteTokenHash  string    `json:"deleteTokenHash,omitempty"`
	Obfuscate        bool      `json:"obfuscate"`
	CreatorIP        string    `json:"creatorIp,omitempty"`
	StorageBackendID string    `json:"storageBackendId,omitempty"`
	Files            []File    `json:"files"`
}

type File struct {
	ID                      string    `json:"id"`
	Name                    string    `json:"name"`
	StoredName              string    `json:"storedName"`
	Size                    int64     `json:"size"`
	ContentType             string    `json:"contentType"`
	PreviewKind             string    `json:"previewKind"`
	Thumbnail               string    `json:"thumbnail,omitempty"`
	SceneThumbnail          string    `json:"sceneThumbnail,omitempty"`
	ObjectKey               string    `json:"objectKey,omitempty"`
	ThumbnailObjectKey      string    `json:"thumbnailObjectKey,omitempty"`
	SceneThumbnailObjectKey string    `json:"sceneThumbnailObjectKey,omitempty"`
	Processing              bool      `json:"processing,omitempty"`
	ProcessingError         string    `json:"processingError,omitempty"`
	UploadedAt              time.Time `json:"uploadedAt"`
}

type UploadResult struct {
	BoxID        string       `json:"boxId"`
	BoxURL       string       `json:"boxUrl"`
	ZipURL       string       `json:"zipUrl"`
	ThumbnailURL string       `json:"thumbnailUrl"`
	ManageURL    string       `json:"manageUrl"`
	DeleteURL    string       `json:"deleteUrl"`
	ExpiresAt    string       `json:"expiresAt"`
	Files        []ResultFile `json:"files"`
}

type ResultFile struct {
	ID           string `json:"id"`
	Name         string `json:"name"`
	Size         string `json:"size"`
	URL          string `json:"url"`
	ThumbnailURL string `json:"thumbnailUrl"`
	Processing   bool   `json:"processing,omitempty"`
}

type AdminStats struct {
	TotalBoxes     int
	TotalFiles     int
	TotalSize      int64
	UploadsLast24H int
	ExpiredBoxes   int
	ProtectedBoxes int
	TotalDownloads int
	TotalSizeLabel string
}

type AdminBox struct {
	ID             string
	OwnerID        string
	CreatedAt      time.Time
	ExpiresAt      time.Time
	FileCount      int
	TotalSize      int64
	TotalSizeLabel string
	DownloadCount  int
	MaxDownloads   int
	Protected      bool
	Expired        bool
}

type UserBox struct {
	Box            Box
	CollectionName string
	TotalSizeLabel string
}

func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
	filesDir := filepath.Join(dataDir, "files")
	dbDir := filepath.Join(dataDir, "db")
	if err := os.MkdirAll(dbDir, 0o755); err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
		return nil, err
	}

	db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
	if err != nil {
		return nil, err
	}

	if err := db.Update(func(tx *bbolt.Tx) error {
		_, err := tx.CreateBucketIfNotExists(boxesBucket)
		return err
	}); err != nil {
		db.Close()
		return nil, err
	}
	storage, err := NewStorageService(db, dataDir)
	if err != nil {
		db.Close()
		return nil, err
	}

	return &UploadService{
		maxUploadSize: maxUploadSize,
		baseURL:       strings.TrimRight(baseURL, "/"),
		dataDir:       dataDir,
		filesDir:      filesDir,
		db:            db,
		logger:        logger,
		storage:       storage,
	}, nil
}

func (s *UploadService) Close() error {
	return s.db.Close()
}

func (s *UploadService) DB() *bbolt.DB {
	return s.db
}

func (s *UploadService) MaxUploadSize() int64 {
	return s.maxUploadSize
}

func (s *UploadService) MaxUploadSizeLabel() string {
	return helpers.FormatBytes(s.maxUploadSize)
}

func (s *UploadService) Storage() *StorageService {
	return s.storage
}

func (s *UploadService) ValidateSize(size int64) error {
	if size > s.maxUploadSize {
		return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
	}
	return nil
}

func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
	return s.CreateBoxFromIncoming(multipartIncomingFiles(files), opts)
}

func (s *UploadService) CreateBoxFromIncoming(files []IncomingFile, opts UploadOptions) (UploadResult, error) {
	return s.CreateBoxFromIncomingContext(context.Background(), files, opts)
}

func (s *UploadService) CreateBoxFromIncomingContext(ctx context.Context, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
	if len(files) == 0 {
		return UploadResult{}, fmt.Errorf("no files were uploaded")
	}
	now := time.Now().UTC()
	var expiresAt time.Time
	switch {
	case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0:
		// "Forever" — a date far enough out that the box effectively never
		// expires. No schema change; CanDownload/cleanup keep working as-is.
		expiresAt = now.AddDate(100, 0, 0)
	case opts.ExpiresInMinutes > 0:
		expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
	default:
		days := opts.MaxDays
		if days <= 0 {
			days = 7
		}
		expiresAt = now.Add(time.Duration(days) * 24 * time.Hour)
	}

	box := Box{
		ID:               randomID(10),
		OwnerID:          strings.TrimSpace(opts.OwnerID),
		CollectionID:     strings.TrimSpace(opts.CollectionID),
		CreatorIP:        strings.TrimSpace(opts.CreatorIP),
		StorageBackendID: normalizeBackendID(opts.StorageBackendID),
		CreatedAt:        now,
		ExpiresAt:        expiresAt,
		MaxDownloads:     opts.MaxDownloads,
		Obfuscate:        opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
		Files:            make([]File, 0, len(files)),
	}
	deleteToken := randomID(32)
	box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
	if strings.TrimSpace(opts.PasswordHash) != "" {
		box.PasswordSalt = opts.PasswordSalt
		box.PasswordHash = opts.PasswordHash
	} else if strings.TrimSpace(opts.Password) != "" {
		salt, hash := hashPassword(opts.Password)
		box.PasswordSalt = salt
		box.PasswordHash = hash
	}

	if err := s.writeIncomingFilesToBox(ctx, &box, files, opts); err != nil {
		return UploadResult{}, err
	}

	if err := s.SaveBox(box); err != nil {
		return UploadResult{}, err
	}

	s.logger.Info("upload complete",
		"source", "user-upload",
		"severity", "user_activity",
		"code", 2001,
		"box_id", box.ID,
		"file_count", len(box.Files),
	)

	return s.resultForBox(box, deleteToken), nil
}

// AppendFiles adds files to an existing box (used to group a ShareX multi-file
// selection into a single box). The box keeps its original expiry, password and
// other settings; only the new files are written.
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
	return s.AppendIncomingFiles(boxID, multipartIncomingFiles(files), opts)
}

func (s *UploadService) AppendIncomingFiles(boxID string, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
	if len(files) == 0 {
		return UploadResult{}, fmt.Errorf("no files were uploaded")
	}
	box, err := s.GetBox(boxID)
	if err != nil {
		return UploadResult{}, err
	}
	if err := s.writeIncomingFilesToBox(context.Background(), &box, files, opts); err != nil {
		return UploadResult{}, err
	}
	if err := s.SaveBox(box); err != nil {
		return UploadResult{}, err
	}
	s.logger.Info("upload appended",
		"source", "user-upload",
		"severity", "user_activity",
		"code", 2001,
		"box_id", box.ID,
		"added", len(files),
		"file_count", len(box.Files),
	)
	return s.resultForBox(box, ""), nil
}

// writeFilesToBox streams each uploaded file into the box's storage backend and
// appends the file metadata to box.Files. The box's StorageBackendID determines
// where files land, so it works for both new and existing boxes.
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
	return s.writeIncomingFilesToBox(context.Background(), box, multipartIncomingFiles(files), opts)
}

func multipartIncomingFiles(files []*multipart.FileHeader) []IncomingFile {
	incoming := make([]IncomingFile, 0, len(files))
	for _, file := range files {
		incoming = append(incoming, multipartIncomingFile{header: file})
	}
	return incoming
}

func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, files []IncomingFile, opts UploadOptions) error {
	backend, err := s.storage.Backend(box.StorageBackendID)
	if err != nil {
		return err
	}

	for _, incoming := range files {
		if !opts.SkipSizeLimit {
			if err := s.ValidateSize(incoming.Size()); err != nil {
				return err
			}
		}

		maxSize := s.maxUploadSize
		if opts.SkipSizeLimit {
			maxSize = 0
		}

		file, err := incoming.Open()
		if err != nil {
			return err
		}

		fileID := randomID(8)
		storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
		objectKey := boxObjectKey(box.ID, storedName)
		contentType := incoming.ContentType()
		if contentType == "" || contentType == "application/octet-stream" {
			buffer := make([]byte, 512)
			n, _ := file.Read(buffer)
			contentType = http.DetectContentType(buffer[:n])
			if seeker, ok := file.(io.Seeker); ok {
				_, _ = seeker.Seek(0, io.SeekStart)
			}
		}

		if err := s.writeUploadedObject(ctx, backend, objectKey, file, incoming.Size(), maxSize, contentType); err != nil {
			file.Close()
			_ = backend.Delete(context.Background(), objectKey)
			return err
		}
		file.Close()

		box.Files = append(box.Files, File{
			ID:          fileID,
			Name:        filepath.Base(incoming.Name()),
			StoredName:  storedName,
			Size:        incoming.Size(),
			ContentType: contentType,
			PreviewKind: previewKind(contentType),
			ObjectKey:   objectKey,
			UploadedAt:  time.Now().UTC(),
		})
	}
	return nil
}

func (s *UploadService) GetBox(id string) (Box, error) {
	var box Box
	err := s.db.View(func(tx *bbolt.Tx) error {
		data := tx.Bucket(boxesBucket).Get([]byte(id))
		if data == nil {
			return os.ErrNotExist
		}
		return json.Unmarshal(data, &box)
	})
	if err != nil {
		return Box{}, err
	}
	return box, nil
}

func (s *UploadService) ListBoxes(limit int) ([]Box, error) {
	boxes := make([]Box, 0)
	err := s.db.View(func(tx *bbolt.Tx) error {
		cursor := tx.Bucket(boxesBucket).Cursor()
		for key, value := cursor.Last(); key != nil; key, value = cursor.Prev() {
			var box Box
			if err := json.Unmarshal(value, &box); err != nil {
				return err
			}
			boxes = append(boxes, box)
			if limit > 0 && len(boxes) >= limit {
				break
			}
		}
		return nil
	})
	return boxes, err
}

func (s *UploadService) ActiveBoxCountForUser(userID string) (int, error) {
	return s.activeBoxCount(func(box Box) bool { return box.OwnerID == userID })
}

func (s *UploadService) ActiveBoxCountForIP(ip string) (int, error) {
	return s.activeBoxCount(func(box Box) bool { return box.OwnerID == "" && box.CreatorIP == ip })
}

func (s *UploadService) activeBoxCount(match func(Box) bool) (int, error) {
	boxes, err := s.ListBoxes(0)
	if err != nil {
		return 0, err
	}
	now := time.Now().UTC()
	count := 0
	for _, box := range boxes {
		if match(box) && box.ExpiresAt.After(now) {
			count++
		}
	}
	return count, nil
}

func (s *UploadService) AdminStats() (AdminStats, error) {
	boxes, err := s.ListBoxes(0)
	if err != nil {
		return AdminStats{}, err
	}

	var stats AdminStats
	cutoff := time.Now().UTC().Add(-24 * time.Hour)
	now := time.Now().UTC()
	for _, box := range boxes {
		stats.TotalBoxes++
		stats.TotalDownloads += box.DownloadCount
		if box.CreatedAt.After(cutoff) {
			stats.UploadsLast24H++
		}
		if box.ExpiresAt.Before(now) {
			stats.ExpiredBoxes++
		}
		if s.IsProtected(box) {
			stats.ProtectedBoxes++
		}
		for _, file := range box.Files {
			stats.TotalFiles++
			stats.TotalSize += file.Size
		}
	}
	stats.TotalSizeLabel = helpers.FormatBytes(stats.TotalSize)
	return stats, nil
}

func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
	boxes, err := s.ListBoxes(limit)
	if err != nil {
		return nil, err
	}

	now := time.Now().UTC()
	rows := make([]AdminBox, 0, len(boxes))
	for _, box := range boxes {
		var size int64
		for _, file := range box.Files {
			size += file.Size
		}
		rows = append(rows, AdminBox{
			ID:             box.ID,
			OwnerID:        box.OwnerID,
			CreatedAt:      box.CreatedAt,
			ExpiresAt:      box.ExpiresAt,
			FileCount:      len(box.Files),
			TotalSize:      size,
			TotalSizeLabel: helpers.FormatBytes(size),
			DownloadCount:  box.DownloadCount,
			MaxDownloads:   box.MaxDownloads,
			Protected:      s.IsProtected(box),
			Expired:        box.ExpiresAt.Before(now),
		})
	}
	return rows, nil
}

func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) {
	boxes, err := s.ListBoxes(0)
	if err != nil {
		return nil, err
	}

	rows := make([]UserBox, 0)
	for _, box := range boxes {
		if box.OwnerID != userID {
			continue
		}
		var size int64
		for _, file := range box.Files {
			size += file.Size
		}
		rows = append(rows, UserBox{
			Box:            box,
			CollectionName: collectionNames[box.CollectionID],
			TotalSizeLabel: helpers.FormatBytes(size),
		})
	}
	sort.Slice(rows, func(i, j int) bool {
		return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt)
	})
	return rows, nil
}

func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
	return s.userStorageUsed(userID, false)
}

func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
	return s.userStorageUsed(userID, true)
}

func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
	boxes, err := s.ListBoxes(0)
	if err != nil {
		return 0, err
	}
	var total int64
	now := time.Now().UTC()
	for _, box := range boxes {
		if box.OwnerID != userID {
			continue
		}
		if activeOnly && !box.ExpiresAt.After(now) {
			continue
		}
		for _, file := range box.Files {
			total += file.Size
		}
	}
	return total, nil
}

func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error {
	box, err := s.GetBox(boxID)
	if err != nil {
		return err
	}
	if box.OwnerID != userID {
		return os.ErrPermission
	}
	box.Title = strings.TrimSpace(title)
	return s.SaveBox(box)
}

func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error {
	box, err := s.GetBox(boxID)
	if err != nil {
		return err
	}
	if box.OwnerID != userID {
		return os.ErrPermission
	}
	box.CollectionID = strings.TrimSpace(collectionID)
	return s.SaveBox(box)
}

func (s *UploadService) DeleteOwnedBox(boxID, userID string) error {
	box, err := s.GetBox(boxID)
	if err != nil {
		return err
	}
	if box.OwnerID != userID {
		return os.ErrPermission
	}
	return s.DeleteBoxWithSource(boxID, "user-delete")
}

func (s *UploadService) DeleteBox(boxID string) error {
	return s.DeleteBoxWithSource(boxID, "admin")
}

func (s *UploadService) DeleteBoxesForStorageBackend(backendID, source string) (int, error) {
	backendID = normalizeBackendID(backendID)
	if backendID == StorageBackendLocal {
		return 0, fmt.Errorf("local storage cannot be deleted")
	}
	boxes, err := s.ListBoxes(0)
	if err != nil {
		return 0, err
	}
	deleted := 0
	for _, box := range boxes {
		if s.BoxStorageBackendID(box) != backendID {
			continue
		}
		if err := s.DeleteBoxWithSource(box.ID, source); err != nil {
			return deleted, err
		}
		deleted++
	}
	return deleted, nil
}

func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
	box, err := s.GetBox(boxID)
	if err != nil {
		return err
	}
	if !s.VerifyDeleteToken(box, token) {
		return os.ErrPermission
	}
	return s.DeleteBoxWithSource(boxID, "anonymous-delete")
}

func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
	box, _ := s.GetBox(boxID)
	if err := s.db.Update(func(tx *bbolt.Tx) error {
		return tx.Bucket(boxesBucket).Delete([]byte(boxID))
	}); err != nil {
		return err
	}
	if box.ID != "" {
		backendID := s.BoxStorageBackendID(box)
		backend, err := s.storage.Backend(backendID)
		if err != nil {
			backend, err = s.storage.BackendForMaintenance(backendID)
		}
		if err == nil {
			if err := backend.DeletePrefix(context.Background(), box.ID); err != nil {
				return err
			}
		}
	} else {
		if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
			return err
		}
	}
	s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID)
	return nil
}

// RemoveFileFromBox deletes a single file's stored objects (and thumbnail) and
// removes it from the box. If it was the box's last file, the whole box is
// deleted. Returns whether the box itself was removed.
func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
	box, err := s.GetBox(boxID)
	if err != nil {
		return false, err
	}
	index := -1
	for i, file := range box.Files {
		if file.ID == fileID {
			index = i
			break
		}
	}
	if index < 0 {
		return false, os.ErrNotExist
	}
	file := box.Files[index]

	backendID := s.BoxStorageBackendID(box)
	backend, err := s.storage.Backend(backendID)
	if err != nil {
		backend, err = s.storage.BackendForMaintenance(backendID)
	}
	if err == nil {
		if key := s.FileObjectKey(box, file); key != "" {
			_ = backend.Delete(context.Background(), key)
		}
		if key := s.ThumbnailObjectKey(box, file); key != "" {
			_ = backend.Delete(context.Background(), key)
		}
		if key := s.SceneThumbnailObjectKey(box, file); key != "" {
			_ = backend.Delete(context.Background(), key)
		}
	}

	box.Files = append(box.Files[:index], box.Files[index+1:]...)
	if len(box.Files) == 0 {
		if err := s.DeleteBoxWithSource(box.ID, "admin"); err != nil {
			return false, err
		}
		return true, nil
	}
	if err := s.SaveBox(box); err != nil {
		return false, err
	}
	s.logger.Info("admin removed file", "source", "admin", "severity", "user_activity", "code", 2305, "box_id", box.ID, "file_id", fileID)
	return false, nil
}

// AdminUpdateBox lets an admin change a box's expiry, download limit, and
// optionally clear password protection.
func (s *UploadService) AdminUpdateBox(boxID string, expiresAt time.Time, maxDownloads int, removePassword bool) error {
	box, err := s.GetBox(boxID)
	if err != nil {
		return err
	}
	if !expiresAt.IsZero() {
		box.ExpiresAt = expiresAt.UTC()
	}
	if maxDownloads < 0 {
		maxDownloads = 0
	}
	box.MaxDownloads = maxDownloads
	if removePassword {
		box.PasswordHash = ""
		box.PasswordSalt = ""
		box.Obfuscate = false
	}
	if err := s.SaveBox(box); err != nil {
		return err
	}
	s.logger.Info("admin updated box", "source", "admin", "severity", "user_activity", "code", 2306, "box_id", box.ID)
	return nil
}

func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
	for _, file := range box.Files {
		if file.ID == fileID {
			return file, nil
		}
	}
	return File{}, os.ErrNotExist
}

func (s *UploadService) FilePath(box Box, file File) string {
	return filepath.Join(s.filesDir, box.ID, file.StoredName)
}

func (s *UploadService) ThumbnailPath(box Box, file File) string {
	if file.Thumbnail == "" {
		return ""
	}
	return filepath.Join(s.filesDir, box.ID, file.Thumbnail)
}

func (s *UploadService) BoxMetadataPath(box Box) string {
	return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json")
}

func (s *UploadService) BoxStorageBackendID(box Box) string {
	return normalizeBackendID(box.StorageBackendID)
}

func (s *UploadService) FileObjectKey(box Box, file File) string {
	if file.ObjectKey != "" {
		return file.ObjectKey
	}
	return boxObjectKey(box.ID, file.StoredName)
}

func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
	if file.ThumbnailObjectKey != "" {
		return file.ThumbnailObjectKey
	}
	if file.Thumbnail == "" {
		return ""
	}
	return boxObjectKey(box.ID, file.Thumbnail)
}

func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string {
	if file.SceneThumbnailObjectKey != "" {
		return file.SceneThumbnailObjectKey
	}
	if file.SceneThumbnail == "" {
		return ""
	}
	return boxObjectKey(box.ID, file.SceneThumbnail)
}

func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
	if file.Processing {
		return StorageObject{}, fmt.Errorf("file is still processing")
	}
	backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
	if err != nil {
		return StorageObject{}, err
	}
	return backend.Get(ctx, s.FileObjectKey(box, file))
}

func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
	key := s.ThumbnailObjectKey(box, file)
	if key == "" {
		return StorageObject{}, os.ErrNotExist
	}
	backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
	if err != nil {
		return StorageObject{}, err
	}
	return backend.Get(ctx, key)
}

func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
	key := s.SceneThumbnailObjectKey(box, file)
	if key == "" {
		return StorageObject{}, os.ErrNotExist
	}
	backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
	if err != nil {
		return StorageObject{}, err
	}
	return backend.Get(ctx, key)
}

func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
	backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
	if err != nil {
		return "", err
	}
	key := boxObjectKey(box.ID, name)
	return key, backend.Put(ctx, key, body, size, contentType)
}

func (s *UploadService) IsProtected(box Box) bool {
	return box.PasswordHash != "" && box.PasswordSalt != ""
}

func (s *UploadService) VerifyPassword(box Box, password string) bool {
	if !s.IsProtected(box) {
		return true
	}
	hash := passwordHash(box.PasswordSalt, password)
	return subtle.ConstantTimeCompare([]byte(hash), []byte(box.PasswordHash)) == 1
}

func (s *UploadService) UnlockToken(box Box) string {
	sum := sha256.Sum256([]byte(box.ID + ":" + box.PasswordHash))
	return hex.EncodeToString(sum[:])
}

func (s *UploadService) VerifyDeleteToken(box Box, token string) bool {
	if box.DeleteTokenHash == "" || strings.TrimSpace(token) == "" {
		return false
	}
	hash := deleteTokenHash(box.ID, token)
	return subtle.ConstantTimeCompare([]byte(hash), []byte(box.DeleteTokenHash)) == 1
}

func (s *UploadService) CanDownload(box Box) error {
	if time.Now().UTC().After(box.ExpiresAt) {
		return fmt.Errorf("box has expired")
	}
	if box.MaxDownloads > 0 && box.DownloadCount >= box.MaxDownloads {
		return fmt.Errorf("download limit reached")
	}
	return nil
}

func (s *UploadService) RecordDownload(boxID string) error {
	return s.db.Update(func(tx *bbolt.Tx) error {
		bucket := tx.Bucket(boxesBucket)
		data := bucket.Get([]byte(boxID))
		if data == nil {
			return os.ErrNotExist
		}

		var box Box
		if err := json.Unmarshal(data, &box); err != nil {
			return err
		}
		box.DownloadCount++

		next, err := json.Marshal(box)
		if err != nil {
			return err
		}
		if err := bucket.Put([]byte(boxID), next); err != nil {
			return err
		}
		return s.writeBoxMetadata(box)
	})
}

func (s *UploadService) WriteZip(w io.Writer, box Box) error {
	archive := zip.NewWriter(w)
	defer archive.Close()

	for _, file := range box.Files {
		object, err := s.OpenFileObject(context.Background(), box, file)
		if err != nil {
			return err
		}
		source := object.Body

		header := &zip.FileHeader{
			Name:     file.Name,
			Method:   zip.Deflate,
			Modified: file.UploadedAt,
		}
		target, err := archive.CreateHeader(header)
		if err != nil {
			source.Close()
			return err
		}

		if _, err := io.Copy(target, source); err != nil {
			source.Close()
			return err
		}
		source.Close()
	}

	return nil
}

func (s *UploadService) SaveBox(box Box) error {
	if err := s.saveBoxRecord(box); err != nil {
		return err
	}
	return s.writeBoxMetadata(box)
}

func (s *UploadService) saveBoxRecord(box Box) error {
	if box.StorageBackendID == "" {
		box.StorageBackendID = StorageBackendLocal
	}
	data, err := json.Marshal(box)
	if err != nil {
		return err
	}

	return s.db.Update(func(tx *bbolt.Tx) error {
		return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
	})
}

func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
	files := make([]ResultFile, 0, len(box.Files))
	for _, file := range box.Files {
		files = append(files, ResultFile{
			ID:           file.ID,
			Name:         file.Name,
			Size:         helpers.FormatBytes(file.Size),
			URL:          fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
			ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
			Processing:   file.Processing,
		})
	}

	// The box-level thumbnail points at the most recently added file, so a
	// per-file ShareX upload previews the file it just sent.
	thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID)
	if len(files) > 0 {
		thumbnailURL = files[len(files)-1].ThumbnailURL
	}

	result := UploadResult{
		BoxID:        box.ID,
		BoxURL:       fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
		ZipURL:       fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
		ThumbnailURL: thumbnailURL,
		ExpiresAt:    box.ExpiresAt.Format(time.RFC3339),
		Files:        files,
	}
	if deleteToken != "" {
		result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
		result.DeleteURL = fmt.Sprintf("%s/d/%s/manage/%s/delete", s.baseURL, box.ID, deleteToken)
	}
	return result
}

func writeUploadedFile(path string, source multipart.File, maxSize int64) error {
	target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer target.Close()

	var written int64
	if maxSize <= 0 {
		written, err = io.Copy(target, source)
	} else {
		written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
	}
	if err != nil {
		os.Remove(path)
		return err
	}
	if maxSize > 0 && written > maxSize {
		os.Remove(path)
		return fmt.Errorf("file exceeds max upload size")
	}
	return nil
}

func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source io.Reader, size, maxSize int64, contentType string) error {
	var reader io.Reader = source
	putSize := size
	if maxSize > 0 {
		if size > maxSize {
			return fmt.Errorf("file exceeds max upload size")
		}
		reader = io.LimitReader(source, maxSize)
		putSize = size
	}
	if ctx != nil {
		reader = contextReader{ctx: ctx, reader: reader}
	}
	return backend.Put(ctx, key, reader, putSize, contentType)
}

type contextReader struct {
	ctx    context.Context
	reader io.Reader
}

func (r contextReader) Read(p []byte) (int, error) {
	select {
	case <-r.ctx.Done():
		return 0, r.ctx.Err()
	default:
		return r.reader.Read(p)
	}
}

func boxObjectKey(boxID, name string) string {
	return filepath.ToSlash(filepath.Join(boxID, name))
}

func randomID(byteCount int) string {
	data := make([]byte, byteCount)
	if _, err := rand.Read(data); err != nil {
		return fmt.Sprintf("%d", time.Now().UnixNano())
	}
	return base64.RawURLEncoding.EncodeToString(data)
}

func RandomPublicToken(byteCount int) string {
	return randomID(byteCount)
}

func hashPassword(password string) (string, string) {
	salt := randomID(18)
	return salt, passwordHash(salt, password)
}

func passwordHash(salt, password string) string {
	sum := sha256.Sum256([]byte(salt + ":" + password))
	return hex.EncodeToString(sum[:])
}

func deleteTokenHash(boxID, token string) string {
	sum := sha256.Sum256([]byte("warpbox-delete:" + boxID + ":" + token))
	return hex.EncodeToString(sum[:])
}

func previewKind(contentType string) string {
	switch {
	case strings.HasPrefix(contentType, "image/"):
		return "image"
	case strings.HasPrefix(contentType, "video/"):
		return "video"
	case strings.HasPrefix(contentType, "audio/"):
		return "audio"
	default:
		return "file"
	}
}

func (s *UploadService) writeBoxMetadata(box Box) error {
	data, err := json.MarshalIndent(box, "", "  ")
	if err != nil {
		return err
	}
	backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
	if err != nil {
		return err
	}
	return backend.Put(context.Background(), boxObjectKey(box.ID, ".warpbox.box.json"), bytes.NewReader(data), int64(len(data)), "application/json")
}
