package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

// ---------------------------------------------------------------------------
// Retention
//
// The policy selects clips older than a stated age and MOVES them into an
// archive directory. It is a dry run unless --apply is given, it never removes
// a clip from the machine, and every run - dry or applied - appends one JSON
// line to the ledger.
// ---------------------------------------------------------------------------

type retainClip struct {
	Path       string  `json:"path"`
	SHA256     string  `json:"sha256"`
	SizeBytes  int64   `json:"size_bytes"`
	SizeHuman  string  `json:"size_human"`
	RecordedAt string  `json:"recorded_at"`
	AgeSource  string  `json:"age_source"`
	AgeDays    float64 `json:"age_days"`
	Action     string  `json:"action"` // keep | would-archive | archived | error
	Dest       string  `json:"dest,omitempty"`
	Method     string  `json:"method,omitempty"` // rename | copy+verify
	Error      string  `json:"error,omitempty"`
}

type retainReport struct {
	Tool        string       `json:"tool"`
	Version     string       `json:"version"`
	TS          string       `json:"ts"`
	Action      string       `json:"action"`
	Library     string       `json:"library"`
	Archive     string       `json:"archive"`
	OlderThan   string       `json:"older_than"`
	CutoffUTC   string       `json:"cutoff_utc"`
	Applied     bool         `json:"applied"`
	Scanned     int          `json:"clips_scanned"`
	Kept        int          `json:"clips_kept"`
	Selected    int          `json:"clips_selected"`
	Moved       int          `json:"clips_moved"`
	Errors      int          `json:"errors"`
	BytesMoved  int64        `json:"bytes_moved"`
	BytesSelect int64        `json:"bytes_selected"`
	Ledger      string       `json:"ledger"`
	Clips       []retainClip `json:"clips"`
}

// parseAge accepts 90d, 12h, 6w, 1y, 30m, 45s. A unit is mandatory: a bare
// number would be ambiguous, and guessing wrong here moves the wrong files.
func parseAge(s string) (time.Duration, error) {
	t := strings.ToLower(strings.TrimSpace(s))
	if t == "" {
		return 0, errors.New("empty age")
	}
	i := 0
	for i < len(t) && (t[i] >= '0' && t[i] <= '9' || t[i] == '.') {
		i++
	}
	num, unit := t[:i], strings.TrimSpace(t[i:])
	if num == "" {
		return 0, fmt.Errorf("invalid age %q: expected a number and a unit, e.g. 90d", s)
	}
	n, err := strconv.ParseFloat(num, 64)
	if err != nil || n < 0 {
		return 0, fmt.Errorf("invalid age %q", s)
	}
	var mult time.Duration
	switch unit {
	case "s", "sec", "secs", "second", "seconds":
		mult = time.Second
	case "m", "min", "mins", "minute", "minutes":
		mult = time.Minute
	case "h", "hr", "hrs", "hour", "hours":
		mult = time.Hour
	case "d", "day", "days":
		mult = 24 * time.Hour
	case "w", "wk", "wks", "week", "weeks":
		mult = 7 * 24 * time.Hour
	case "y", "yr", "yrs", "year", "years":
		mult = 365 * 24 * time.Hour
	case "":
		return 0, fmt.Errorf("age %q needs a unit: 90d, 12h, 6w, 1y", s)
	default:
		return 0, fmt.Errorf("unknown age unit %q in %q (use s, m, h, d, w, y)", unit, s)
	}
	return time.Duration(n * float64(mult)), nil
}

func cmdRetain(argv []string) {
	fs := newFlagSet("retain")
	lib := fs.String("lib", "", "clip library directory")
	fs.StringVar(lib, "l", "", "shorthand for --lib")
	olderThan := fs.String("older-than", "", "retention age, e.g. 90d")
	archive := fs.String("archive", "", "archive directory (default <lib>/_archive)")
	ledger := fs.String("ledger", "", "append-only JSON-lines audit ledger")
	apply := fs.Bool("apply", false, "actually move the selected clips")
	asJSON := fs.Bool("json", false, "machine-readable JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *lib == "" && fs.NArg() > 0 {
		*lib = fs.Arg(0)
	}
	if *lib == "" {
		usageErr("retain needs --lib <dir>")
	}
	if *olderThan == "" {
		usageErr("retain needs --older-than <age>, e.g. --older-than 90d")
	}
	if *ledger == "" {
		usageErr("retain needs --ledger <file.jsonl> - every retention run is audited")
	}
	age, err := parseAge(*olderThan)
	if err != nil {
		usageErr("%v", err)
	}

	idx, err := buildIndex(*lib)
	if err != nil {
		fail("%v", err)
	}
	archiveDir := *archive
	if archiveDir == "" {
		archiveDir = filepath.Join(idx.Library, archiveDirName)
	}
	archiveAbs, err := filepath.Abs(archiveDir)
	if err != nil {
		fail("cannot resolve archive %s: %v", archiveDir, err)
	}

	now := nowUTC()
	cutoff := now.Add(-age)
	rep := retainReport{
		Tool: appName, Version: appVersion, TS: now.Format(rfc3339), Action: "retain",
		Library: idx.Library, Archive: archiveAbs, OlderThan: *olderThan,
		CutoffUTC: cutoff.Format(rfc3339), Applied: *apply, Ledger: *ledger,
		Clips: []retainClip{},
	}

	for _, ce := range idx.Clips {
		when, src := ce.recordedAt()
		rc := retainClip{
			Path:       ce.Path,
			SHA256:     ce.SHA256,
			SizeBytes:  ce.SizeBytes,
			SizeHuman:  humanBytes(ce.SizeBytes),
			RecordedAt: when.Format(rfc3339),
			AgeSource:  src,
			AgeDays:    round3(now.Sub(when).Hours() / 24),
			Action:     "keep",
		}
		rep.Scanned++
		if !when.Before(cutoff) {
			rep.Kept++
			rep.Clips = append(rep.Clips, rc)
			continue
		}
		rep.Selected++
		rep.BytesSelect += ce.SizeBytes
		dest := archiveDest(archiveAbs, ce.Path)
		rc.Dest = dest
		if !*apply {
			rc.Action = "would-archive"
			rep.Clips = append(rep.Clips, rc)
			continue
		}
		method, err := moveFile(ce.AbsPath, dest)
		if err != nil {
			rc.Action = "error"
			rc.Error = err.Error()
			rep.Errors++
		} else {
			rc.Action = "archived"
			rc.Method = method
			rep.Moved++
			rep.BytesMoved += ce.SizeBytes
		}
		rep.Clips = append(rep.Clips, rc)
	}
	sort.SliceStable(rep.Clips, func(i, j int) bool { return rep.Clips[i].Path < rep.Clips[j].Path })

	if err := appendLedger(*ledger, rep); err != nil {
		fail("%v", err)
	}

	if *asJSON {
		emitJSON(rep)
		if rep.Errors > 0 {
			os.Exit(1)
		}
		return
	}

	mode := "DRY RUN - nothing was moved"
	if *apply {
		mode = "APPLIED - selected clips were moved"
	}
	fmt.Printf("ClipStudio retention policy\n")
	fmt.Printf("library    : %s\n", rep.Library)
	fmt.Printf("archive    : %s\n", rep.Archive)
	fmt.Printf("older than : %s (cutoff %s)\n", rep.OlderThan, rep.CutoffUTC)
	fmt.Printf("ledger     : %s\n", rep.Ledger)
	fmt.Printf("mode       : %s\n", mode)
	fmt.Println()
	fmt.Printf("%-34s %10s %9s %-14s %s\n", "CLIP", "SIZE", "AGE(d)", "ACTION", "RECORDED (SOURCE)")
	for _, c := range rep.Clips {
		fmt.Printf("%-34s %10s %9.2f %-14s %s (%s)\n",
			trunc(c.Path, 34), c.SizeHuman, c.AgeDays, c.Action, c.RecordedAt, c.AgeSource)
		if c.Error != "" {
			fmt.Printf("%-34s ERROR: %s\n", "", c.Error)
		}
	}
	fmt.Println()
	fmt.Printf("scanned %d, keep %d, selected %d (%s)", rep.Scanned, rep.Kept, rep.Selected, humanBytes(rep.BytesSelect))
	if *apply {
		fmt.Printf(", moved %d (%s), errors %d", rep.Moved, humanBytes(rep.BytesMoved), rep.Errors)
	}
	fmt.Println()
	if !*apply && rep.Selected > 0 {
		fmt.Printf("\nThis was a dry run. Re-run with --apply to move those %d clips into\n%s.\n",
			rep.Selected, rep.Archive)
	}
	fmt.Printf("\nArchiving MOVES files. ClipStudio never deletes a clip.\n")
	if rep.Errors > 0 {
		os.Exit(1)
	}
}

// archiveDest mirrors the library layout under the archive root and never
// overwrites an existing archived clip - it suffixes instead.
func archiveDest(archiveRoot, rel string) string {
	dest := filepath.Join(archiveRoot, filepath.FromSlash(rel))
	if _, err := os.Stat(dest); err != nil {
		return dest
	}
	ext := filepath.Ext(dest)
	stem := strings.TrimSuffix(dest, ext)
	for n := 1; n < 10000; n++ {
		cand := fmt.Sprintf("%s-%d%s", stem, n, ext)
		if _, err := os.Stat(cand); err != nil {
			return cand
		}
	}
	return dest
}

// moveFile relocates src to dst. The fast path is a rename, which is atomic
// and never has the file in only one place. The fallback, used when the
// archive is on a different filesystem, copies first and only unlinks the
// source after the destination's SHA-256 has been verified to match - so the
// content exists in two places before it exists in one.
func moveFile(src, dst string) (string, error) {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return "", fmt.Errorf("cannot create archive directory: %w", err)
	}
	if err := os.Rename(src, dst); err == nil {
		return "rename", nil
	}

	srcSum, _, err := hashFile(src)
	if err != nil {
		return "", err
	}
	in, err := os.Open(src)
	if err != nil {
		return "", err
	}
	defer in.Close()
	tmp, err := os.CreateTemp(filepath.Dir(dst), ".clipstudio-arc-*.tmp")
	if err != nil {
		return "", err
	}
	tmpName := tmp.Name()
	if _, err := io.Copy(tmp, in); err != nil {
		tmp.Close()
		os.Remove(tmpName)
		return "", err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		os.Remove(tmpName)
		return "", err
	}
	if err := tmp.Close(); err != nil {
		os.Remove(tmpName)
		return "", err
	}
	if err := os.Rename(tmpName, dst); err != nil {
		os.Remove(tmpName)
		return "", err
	}
	dstSum, _, err := hashFile(dst)
	if err != nil {
		return "", err
	}
	if dstSum != srcSum {
		return "", fmt.Errorf("archive copy of %s does not match the original checksum; original left in place", src)
	}
	if err := os.Remove(src); err != nil {
		return "", fmt.Errorf("copied to %s but could not remove the original: %w", dst, err)
	}
	return "copy+verify", nil
}

func appendLedger(path string, rep retainReport) error {
	line, err := json.Marshal(rep)
	if err != nil {
		return fmt.Errorf("cannot encode ledger record: %w", err)
	}
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("cannot open ledger %s: %w", path, err)
	}
	defer f.Close()
	if _, err := f.Write(append(line, '\n')); err != nil {
		return fmt.Errorf("cannot append to ledger %s: %w", path, err)
	}
	return f.Close()
}
