package main

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"
)

type heapConvertBody struct {
	TargetFolder string `json:"targetFolder"`
	Mode         string `json:"mode"`      // "move" or "copy"
	Subfolder    string `json:"subfolder"` // optional, sanitized to a single segment
	DeleteHeap   bool   `json:"deleteHeap"`
}

type heapPhoto struct {
	UID   string   `json:"UID"`
	Files []ppFile `json:"Files"`
}

type heapErr struct {
	UID    string `json:"uid"`
	Reason string `json:"reason"`
}

// copyFile is the os.Rename fallback for cross-device moves and the
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
// memory; preserves mode bits, sets the modification time to now (we're
// creating a new inode either way).
func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	st, err := in.Stat()
	if err != nil {
		return err
	}
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		os.Remove(dst)
		return err
	}
	return out.Close()
}

func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
	return func(c *gin.Context) {
		token := ctxToken(c)
		albumUID := c.Param("uid")

		var body heapConvertBody
		if err := c.ShouldBindJSON(&body); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
			return
		}
		mode := body.Mode
		if mode != "copy" {
			mode = "move"
		}
		deleteHeap := mode == "move" && body.DeleteHeap

		var subfolder string
		if body.Subfolder != "" {
			s, ok := sanitizeFilename(body.Subfolder)
			if !ok {
				c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
				return
			}
			subfolder = s
		}

		// Resolve destination. resolveUnderRoot ensures the target lives
		// inside ORIGINALS_ROOT and that its parent is a real directory.
		// Empty / "/" / "." are valid here — they mean "drop these into
		// originals/ itself" (the modal's "Root" option). resolveUnderRoot
		// rejects those for safety, so handle the root case explicitly.
		var targetAbs string
		trimmed := strings.Trim(body.TargetFolder, "/")
		if trimmed == "" || trimmed == "." {
			targetAbs = cfg.OriginalsRoot
		} else {
			abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
			if err != nil {
				c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
				return
			}
			targetAbs = abs
		}
		destAbs := targetAbs
		if subfolder != "" {
			destAbs = filepath.Join(targetAbs, subfolder)
			if err := os.MkdirAll(destAbs, 0o755); err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
		}

		// Pull the heap's photos via the q=album:UID query. count=1000 covers
		// every realistic heap; merged=true expands stacked variants so we
		// move the JPG/HEIC sibling alongside the primary.
		q := url.QueryEscape("album:" + albumUID)
		listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
		resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
		if err != nil {
			c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
			return
		}
		if !resp.OK {
			c.JSON(resp.Status, gin.H{"error": "list photos failed"})
			return
		}
		var photos []heapPhoto
		if err := json.Unmarshal(resp.Body, &photos); err != nil {
			c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
			return
		}

		sourceParents := map[string]struct{}{}
		errs := []heapErr{}
		moved, copied := 0, 0

		for _, photo := range photos {
			// Pick the file to physically move. PhotoPrism's "primary" file
			// for a HEIC photo is the generated `.HEIC.jpg` preview that
			// lives in storage/sidecar (Root=="sidecar"), not in originals
			// — moving that path would fail "file missing on disk" every
			// time. Prefer the primary that lives in originals (Root=="/")
			// and fall back to the first originals-rooted file. PhotoPrism
			// regenerates sidecars on reindex, so they don't need to follow.
			var file ppFile
			found := false
			for _, f := range photo.Files {
				if f.Root == "/" && f.Primary {
					file, found = f, true
					break
				}
			}
			if !found {
				for _, f := range photo.Files {
					if f.Root == "/" {
						file, found = f, true
						break
					}
				}
			}
			if !found {
				errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
				continue
			}
			srcRel := file.Name
			srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
			if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
				errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
				continue
			}
			st, err := os.Stat(srcAbs)
			if err != nil || !st.Mode().IsRegular() {
				errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
				continue
			}
			if filepath.Dir(srcAbs) == destAbs {
				errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
				continue
			}
			_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
			if !ok {
				errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
				continue
			}
			dstAbs := filepath.Join(destAbs, name)
			if mode == "move" {
				if err := os.Rename(srcAbs, dstAbs); err != nil {
					// Cross-device renames fail with EXDEV — fall back to
					// copy+remove so a library that spans filesystems still
					// works.
					if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
						errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
						continue
					}
					if err2 := os.Remove(srcAbs); err2 != nil {
						errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
						continue
					}
				}
				moved++
			} else {
				if err := copyFile(srcAbs, dstAbs); err != nil {
					errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
					continue
				}
				copied++
			}
			sourceParents[filepath.Dir(srcRel)] = struct{}{}
		}

		// Reindex the destination + every source parent so PhotoPrism's
		// DB catches up. We block on these so the response only goes out
		// after the index reflects the move — callers (the frontend's
		// invalidateQueries refetch in particular) need the next /photos
		// fetch to return the moved files, otherwise the folder view
		// looks unchanged. PhotoPrism's index endpoint serialises calls
		// internally; running them sequentially matches that contract
		// without surprising the server.
		destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
		paths := map[string]struct{}{destRel: {}}
		for p := range sourceParents {
			paths[p] = struct{}{}
		}
		if subfolder != "" {
			parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
			paths[parent] = struct{}{}
		}
		for p := range paths {
			reindex := "/"
			if p != "" && p != "." {
				reindex = "/" + p
			}
			fireReindex(cfg, pp, token, reindex)
		}

		heapDeleted := false
		if deleteHeap {
			r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
			if err == nil && r.OK {
				heapDeleted = true
			} else if err != nil {
				errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
			} else {
				errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
			}
		}

		slog.Info("heap.convert",
			"album", albumUID,
			"mode", mode,
			"moved", moved,
			"copied", copied,
			"errors", len(errs),
			"heap_deleted", heapDeleted,
		)
		c.JSON(http.StatusOK, gin.H{
			"moved":        moved,
			"copied":       copied,
			"errors":       errs,
			"heap_deleted": heapDeleted,
		})
	}
}
