package main

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sync"

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

// pathParam pulls the URL-encoded :rel out of the Gin context and
// unescapes it. UseRawPath is on at the router level (see main.go) so the
// raw value still carries `%2F` for nested paths; we decode here.
func pathParam(c *gin.Context, key string) (string, bool) {
	raw := c.Param(key)
	if raw == "" {
		return "", false
	}
	dec, err := url.PathUnescape(raw)
	if err != nil {
		return "", false
	}
	return dec, true
}

type folderCreateBody struct {
	Path string `json:"path"`
}

func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
	return func(c *gin.Context) {
		token := ctxToken(c)
		var body folderCreateBody
		if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
			return
		}
		abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
			return
		}
		if _, err := os.Stat(abs); err == nil {
			c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
			return
		} else if !errors.Is(err, os.ErrNotExist) {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if err := os.Mkdir(abs, 0o755); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
		slog.Info("folder.create", "path", rel)
		go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
		c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
	}
}

type folderRenameBody struct {
	NewName string `json:"newName"`
}

func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
	return func(c *gin.Context) {
		token := ctxToken(c)
		rel, ok := pathParam(c, "rel")
		if !ok {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
			return
		}
		var body folderRenameBody
		if err := c.ShouldBindJSON(&body); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
			return
		}
		newName, ok := sanitizeFilename(body.NewName)
		if !ok {
			c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
			return
		}
		oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
			return
		}
		st, err := os.Stat(oldAbs)
		if err != nil || !st.IsDir() {
			c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
			return
		}
		newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
		if _, err := os.Stat(newAbs); err == nil {
			c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
			return
		} else if !errors.Is(err, os.ErrNotExist) {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
			return
		}
		if err := os.Rename(oldAbs, newAbs); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
		newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
		slog.Info("folder.rename", "from", oldRel, "to", newRel)
		go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
		c.JSON(http.StatusOK, gin.H{
			"ok":      true,
			"oldPath": oldRel,
			"newPath": newRel,
		})
	}
}

func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
	return func(c *gin.Context) {
		token := ctxToken(c)
		rel, ok := pathParam(c, "rel")
		if !ok {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
			return
		}
		abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
			return
		}
		if abs == cfg.OriginalsRoot {
			c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
			return
		}
		st, err := os.Stat(abs)
		if err != nil || !st.IsDir() {
			c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
			return
		}
		entries, err := os.ReadDir(abs)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if len(entries) > 0 {
			c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
			return
		}
		if err := os.Remove(abs); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		slog.Info("folder.delete", "path", rel)
		go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
		c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
	}
}

type folderCountsBody struct {
	Paths []string `json:"paths"`
}

// folderCountsRow is the minimal PhotoPrism photo projection the handler
// needs — just UID, so dedupe-by-UID survives `merged=false` (which
// expands one photo into one row per File on disk). PhotoPrism returns a
// JSON array of much richer objects; unmarshalling into this small
// shape ignores everything we don't care about.
type folderCountsRow struct {
	UID string `json:"UID"`
}

// handleFolderCounts returns photo counts for each PhotoPrism folder
// path in one round-trip. The web client used to fire one
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
// folder × N folders) to populate the left-sidebar tree. Moving the
// fan-out into the sidecar keeps the same correctness profile — same
// q-DSL, same `merged=false` UID dedupe, same 1000-row server cap —
// but the wire payload back to the browser collapses to a single small
// JSON object (`{path: count}`).
//
// We bounce off PhotoPrism with `count=1000` and dedupe UIDs server-
// side rather than trusting a count header: PhotoPrism's `/photos`
// X-Count is the *per-page* row count (per existing front-end
// comment), not the total-match count, so we'd silently undercount if
// we used it. Lifting the 1000 cap would mean either iterating offsets
// or growing PhotoPrism's response cap — both out of scope here.
//
// Bounded concurrency caps the fan-out so a library with hundreds of
// folders doesn't open hundreds of connections to PhotoPrism at once.
// Errors per-folder degrade to count=0 rather than failing the whole
// batch — the sidebar would rather show a missing badge for one folder
// than nothing for any.
func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
	return func(c *gin.Context) {
		token := ctxToken(c)
		var body folderCountsBody
		if err := c.ShouldBindJSON(&body); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
			return
		}
		if len(body.Paths) == 0 {
			c.JSON(http.StatusOK, gin.H{})
			return
		}

		const maxInFlight = 8
		var (
			wg     sync.WaitGroup
			sem    = make(chan struct{}, maxInFlight)
			mu     sync.Mutex
			counts = make(map[string]int, len(body.Paths))
		)
		// Seed every input key so the response always carries the same
		// shape the client posted, even for paths whose lookup failed.
		for _, p := range body.Paths {
			counts[p] = 0
		}
		for _, p := range body.Paths {
			path := p
			wg.Add(1)
			sem <- struct{}{}
			go func() {
				defer wg.Done()
				defer func() { <-sem }()
				// `path:` is non-recursive in PhotoPrism's q-DSL: matches
				// direct children only. `merged=false` returns one row per
				// File on disk, so HEIC + companion JPG count twice unless
				// we dedupe by UID — which is what the old client-side
				// code did, and what we keep doing here.
				q := url.QueryEscape("path:" + path)
				resp, err := pp.call(c.Request.Context(), http.MethodGet,
					"/api/v1/photos?count=1000&offset=0&merged=false&q="+q, token, nil)
				if err != nil || !resp.OK {
					slog.Warn("folder.counts: pp call failed",
						"path", path,
						"err", err,
						"status", func() int {
							if resp != nil {
								return resp.Status
							}
							return 0
						}())
					return
				}
				var rows []folderCountsRow
				if err := json.Unmarshal(resp.Body, &rows); err != nil {
					slog.Warn("folder.counts: parse failed", "path", path, "err", err)
					return
				}
				seen := make(map[string]struct{}, len(rows))
				for _, r := range rows {
					if r.UID == "" {
						continue
					}
					seen[r.UID] = struct{}{}
				}
				mu.Lock()
				counts[path] = len(seen)
				mu.Unlock()
			}()
		}
		wg.Wait()
		c.JSON(http.StatusOK, counts)
	}
}

// fireReindex wraps pp.reindex with logging and a detached context so
// it can run in a goroutine after the response has gone out. The Node
// prototype kicks reindex with `void reindex(...)` and never awaits;
// matching that here keeps the apparent latency of mutating endpoints
// low (PhotoPrism's index can take seconds on a big folder).
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
	// pp.call's client already enforces a 60s timeout, so the parent
	// context can be detached from the request — the handler has long
	// since written its response.
	if err := pp.reindex(context.Background(), token, parentRel); err != nil {
		slog.Warn("reindex failed", "path", parentRel, "err", err)
	}
}
