// MirrorFlow - two-way folder sync with three-way conflict detection.
// Part of the Techlosoft Sync Reliability Suite (Pro).
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

const (
	appName      = "mirrorflow"
	appVersion   = "1.0.0"
	trashDirName = ".mirrorflow-trash"
	partSuffix   = ".part"
	stateVersion = 1
)

// Exit codes.
const (
	exitOK       = 0
	exitUsage    = 1
	exitConflict = 2
)

// Classification results.
const (
	stUnchanged  = "unchanged"
	stNewA       = "new-on-A"
	stNewB       = "new-on-B"
	stChangedA   = "changed-on-A"
	stChangedB   = "changed-on-B"
	stDeletedA   = "deleted-on-A"
	stDeletedB   = "deleted-on-B"
	stConflict   = "conflict"
	stStale      = "stale-state-entry"
	sideA        = "A"
	sideB        = "B"
	policyManual = "manual"
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the suite).
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------

type fileRec struct {
	Hash string
	Size int64
	Mod  time.Time
	Mode fs.FileMode
}

type stateEntry struct {
	Hash string    `json:"hash"`
	Size int64     `json:"size"`
	ModA time.Time `json:"modA"`
	ModB time.Time `json:"modB"`
}

type syncState struct {
	Version   int                   `json:"version"`
	Tool      string                `json:"tool"`
	UpdatedAt time.Time             `json:"updatedAt"`
	DirA      string                `json:"dirA"`
	DirB      string                `json:"dirB"`
	Files     map[string]stateEntry `json:"files"`
}

type entry struct {
	Path       string    `json:"path"`
	Status     string    `json:"status"`
	Detail     string    `json:"detail,omitempty"`
	Resolution string    `json:"resolution,omitempty"`
	InA        bool      `json:"inA"`
	InB        bool      `json:"inB"`
	InState    bool      `json:"inState"`
	HashA      string    `json:"hashA,omitempty"`
	HashB      string    `json:"hashB,omitempty"`
	HashState  string    `json:"hashState,omitempty"`
	SizeA      int64     `json:"sizeA"`
	SizeB      int64     `json:"sizeB"`
	ModA       time.Time `json:"modA,omitempty"`
	ModB       time.Time `json:"modB,omitempty"`
}

type op struct {
	Kind  string `json:"kind"` // copy | trash | move
	Path  string `json:"path"`
	Dest  string `json:"dest,omitempty"`
	From  string `json:"from,omitempty"`
	To    string `json:"to,omitempty"`
	Side  string `json:"side,omitempty"`
	Bytes int64  `json:"bytes"`
	Desc  string `json:"desc"`
}

type report struct {
	Tool       string         `json:"tool"`
	Version    string         `json:"version"`
	Command    string         `json:"command"`
	DirA       string         `json:"dirA"`
	DirB       string         `json:"dirB"`
	StateFile  string         `json:"stateFile"`
	Policy     string         `json:"policy,omitempty"`
	Apply      bool           `json:"apply"`
	DryRun     bool           `json:"dryRun"`
	Entries    []entry        `json:"entries"`
	Actions    []op           `json:"actions"`
	Summary    map[string]int `json:"summary"`
	Conflicts  int            `json:"conflicts"`
	Unresolved int            `json:"unresolved"`
	BytesMoved int64          `json:"bytesMoved"`
	Refused    bool           `json:"refused"`
	State      bool           `json:"stateUpdated"`
	Errors     []string       `json:"errors,omitempty"`
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

const usageText = `mirrorflow ` + appVersion + ` - two-way folder sync with real conflict detection
Techlosoft Sync Reliability Suite (Pro)

USAGE
  mirrorflow init   <dirA> <dirB> --state <state.json>
  mirrorflow status <dirA> <dirB> --state <state.json> [--json]
  mirrorflow sync   <dirA> <dirB> --state <state.json> [--policy P] [--apply] [--json]
  mirrorflow help | -h | --help

COMMANDS
  init     Record the current contents of both folders as the sync baseline.
           Nothing is copied, moved or deleted.
  status   Read-only three-way comparison (A vs B vs baseline). No changes.
  sync     Propagate one-sided changes in the correct direction and handle
           genuine conflicts according to --policy.

FLAGS
  --state <file>   Path to the persistent sync-state database (required).
  --policy <name>  Conflict resolution policy for "sync" (default: manual)
                     manual     do not resolve; report and exit non-zero
                     newest     the more recently modified side wins
                     larger     the larger file wins
                     keep-both  keep both, renaming the loser to
                                <name>.conflict-<side>-<timestamp><ext>
  --apply          Actually perform the changes. WITHOUT THIS FLAG MIRRORFLOW
                   IS A DRY RUN and writes nothing at all.
  --json           Emit a machine-readable JSON report on stdout.

HOW IT CLASSIFIES
  Every file is compared by SHA-256 content hash on both sides and against the
  hash recorded at the last successful sync, so a file whose mtime changed but
  whose bytes did not is correctly reported as "unchanged".

    unchanged      identical on both sides
    new-on-A/B     added on one side since the baseline
    changed-on-A/B modified on exactly one side
    deleted-on-A/B removed on one side, untouched on the other
    conflict       both sides changed since the baseline, or one side deleted
                   a file the other side modified

SAFETY
  * Dry run is the default; --apply is required to touch anything.
  * Files are written as <name>.part and renamed into place.
  * Nothing is ever hard-deleted. Files that must go are moved into
    ` + trashDirName + `/ inside the destination root, preserving relative paths.
  * The state file is rewritten only after a successful --apply run.

EXIT CODES
  0 success   1 usage/IO error   2 unresolved conflicts
`

func usage(w io.Writer) {
	fmt.Fprint(w, usageText)
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, appName+": "+format+"\n", args...)
	os.Exit(exitUsage)
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions the program needs and stay on screen. Printing usage and
		// exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage(os.Stderr)
		os.Exit(exitUsage)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage(os.Stdout)
		os.Exit(exitOK)
	case "-v", "--version", "version":
		fmt.Println(appName + " " + appVersion)
		os.Exit(exitOK)
	}

	cmd := args[0]
	if strings.HasPrefix(cmd, "-") {
		fmt.Fprintf(os.Stderr, "%s: the command must come first, e.g. %s status <dirA> <dirB> --state <file> %s\n\n", appName, appName, cmd)
		usage(os.Stderr)
		os.Exit(exitUsage)
	}
	rest := reorderFlags(args[1:], map[string]bool{"state": true, "policy": true})

	fsFlags := flag.NewFlagSet(cmd, flag.ContinueOnError)
	fsFlags.SetOutput(io.Discard)
	statePath := fsFlags.String("state", "", "path to sync state file")
	policy := fsFlags.String("policy", policyManual, "conflict policy")
	apply := fsFlags.Bool("apply", false, "perform changes")
	asJSON := fsFlags.Bool("json", false, "JSON output")
	help := fsFlags.Bool("help", false, "show help")
	h := fsFlags.Bool("h", false, "show help")

	if err := fsFlags.Parse(rest); err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", appName, err)
		usage(os.Stderr)
		os.Exit(exitUsage)
	}
	if *help || *h {
		usage(os.Stdout)
		os.Exit(exitOK)
	}

	switch cmd {
	case "init", "status", "sync":
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n\n", appName, cmd)
		usage(os.Stderr)
		os.Exit(exitUsage)
	}

	pos := fsFlags.Args()
	if len(pos) != 2 {
		fmt.Fprintf(os.Stderr, "%s: %s needs exactly two directories (got %d)\n\n", appName, cmd, len(pos))
		usage(os.Stderr)
		os.Exit(exitUsage)
	}
	if strings.TrimSpace(*statePath) == "" {
		fmt.Fprintf(os.Stderr, "%s: --state <file> is required\n\n", appName)
		usage(os.Stderr)
		os.Exit(exitUsage)
	}
	switch *policy {
	case policyManual, "newest", "larger", "keep-both":
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown --policy %q (want manual, newest, larger or keep-both)\n\n", appName, *policy)
		usage(os.Stderr)
		os.Exit(exitUsage)
	}

	dirA, err := resolveDir(pos[0], sideA)
	if err != nil {
		fail("%v", err)
	}
	dirB, err := resolveDir(pos[1], sideB)
	if err != nil {
		fail("%v", err)
	}
	if dirA == dirB {
		fail("dirA and dirB are the same directory (%s)", dirA)
	}
	if isInside(dirA, dirB) || isInside(dirB, dirA) {
		fail("dirA and dirB must not be nested inside each other")
	}

	switch cmd {
	case "init":
		os.Exit(runInit(dirA, dirB, *statePath, *asJSON))
	case "status":
		os.Exit(runCompare("status", dirA, dirB, *statePath, *policy, false, *asJSON))
	default:
		os.Exit(runCompare("sync", dirA, dirB, *statePath, *policy, *apply, *asJSON))
	}
}

func resolveDir(p, side string) (string, error) {
	abs, err := filepath.Abs(p)
	if err != nil {
		return "", fmt.Errorf("dir%s %q: %v", side, p, err)
	}
	st, err := os.Stat(abs)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return "", fmt.Errorf("dir%s does not exist: %s", side, abs)
		}
		return "", fmt.Errorf("dir%s %s: %v", side, abs, err)
	}
	if !st.IsDir() {
		return "", fmt.Errorf("dir%s is not a directory: %s", side, abs)
	}
	return abs, nil
}

func isInside(child, parent string) bool {
	rel, err := filepath.Rel(parent, child)
	if err != nil {
		return false
	}
	return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "."
}

// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------

func scanTree(root string) (map[string]fileRec, []string, error) {
	out := make(map[string]fileRec)
	var skipped []string
	err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, rerr := filepath.Rel(root, p)
		if rerr != nil {
			return rerr
		}
		if rel == "." {
			return nil
		}
		if d.IsDir() {
			if d.Name() == trashDirName {
				return fs.SkipDir
			}
			return nil
		}
		if !d.Type().IsRegular() {
			skipped = append(skipped, filepath.ToSlash(rel))
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			return ierr
		}
		sum, herr := hashFile(p)
		if herr != nil {
			return herr
		}
		out[filepath.ToSlash(rel)] = fileRec{
			Hash: sum,
			Size: info.Size(),
			Mod:  info.ModTime(),
			Mode: info.Mode(),
		}
		return nil
	})
	if err != nil {
		return nil, nil, err
	}
	sort.Strings(skipped)
	return out, skipped, nil
}

func hashFile(p string) (string, error) {
	f, err := os.Open(p)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// ---------------------------------------------------------------------------
// State persistence
// ---------------------------------------------------------------------------

func loadState(p string) (*syncState, error) {
	b, err := os.ReadFile(p)
	if err != nil {
		return nil, err
	}
	var s syncState
	if err := json.Unmarshal(b, &s); err != nil {
		return nil, fmt.Errorf("state file %s is not valid mirrorflow JSON: %v", p, err)
	}
	if s.Files == nil {
		s.Files = make(map[string]stateEntry)
	}
	return &s, nil
}

func saveState(p string, s *syncState) error {
	if dir := filepath.Dir(p); dir != "" {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
	}
	b, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		return err
	}
	b = append(b, '\n')
	tmp := p + partSuffix
	if err := os.WriteFile(tmp, b, 0o644); err != nil {
		return err
	}
	if err := os.Rename(tmp, p); err != nil {
		os.Remove(tmp)
		return err
	}
	return nil
}

// buildState records, as the new baseline, every path whose content is
// currently identical on both sides. Paths that still differ are deliberately
// left out so the next run rediscovers them.
func buildState(dirA, dirB string, a, b map[string]fileRec) *syncState {
	s := &syncState{
		Version:   stateVersion,
		Tool:      appName + " " + appVersion,
		UpdatedAt: time.Now().UTC(),
		DirA:      dirA,
		DirB:      dirB,
		Files:     make(map[string]stateEntry),
	}
	for path, ra := range a {
		rb, ok := b[path]
		if !ok || rb.Hash != ra.Hash {
			continue
		}
		s.Files[path] = stateEntry{
			Hash: ra.Hash,
			Size: ra.Size,
			ModA: ra.Mod.UTC(),
			ModB: rb.Mod.UTC(),
		}
	}
	return s
}

// ---------------------------------------------------------------------------
// init
// ---------------------------------------------------------------------------

func runInit(dirA, dirB, statePath string, asJSON bool) int {
	a, skipA, err := scanTree(dirA)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: scanning dirA: %v\n", appName, err)
		return exitUsage
	}
	b, skipB, err := scanTree(dirB)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: scanning dirB: %v\n", appName, err)
		return exitUsage
	}
	s := buildState(dirA, dirB, a, b)
	if err := saveState(statePath, s); err != nil {
		fmt.Fprintf(os.Stderr, "%s: writing state: %v\n", appName, err)
		return exitUsage
	}

	entries := classify(a, b, nil)
	pending := 0
	for _, e := range entries {
		if e.Status != stUnchanged {
			pending++
		}
	}
	if asJSON {
		rep := report{
			Tool: appName, Version: appVersion, Command: "init",
			DirA: dirA, DirB: dirB, StateFile: statePath,
			Entries: entries, Actions: []op{},
			Summary: summarize(entries), State: true,
		}
		emitJSON(rep)
		return exitOK
	}
	fmt.Printf("mirrorflow init\n")
	fmt.Printf("  A: %s (%d files)\n", dirA, len(a))
	fmt.Printf("  B: %s (%d files)\n", dirB, len(b))
	fmt.Printf("  baseline written: %s (%d paths recorded as in-sync)\n", statePath, len(s.Files))
	if pending > 0 {
		fmt.Printf("  %d path(s) differ between A and B right now and were NOT recorded;\n", pending)
		fmt.Printf("  they will show up on the next 'status' or 'sync' run.\n")
	}
	for _, p := range append(skipA, skipB...) {
		fmt.Printf("  skipped (not a regular file): %s\n", p)
	}
	fmt.Printf("Nothing was copied, moved or deleted.\n")
	return exitOK
}

// ---------------------------------------------------------------------------
// Classification (the three-way compare)
// ---------------------------------------------------------------------------

func classify(a, b map[string]fileRec, st *syncState) []entry {
	paths := make(map[string]bool)
	for p := range a {
		paths[p] = true
	}
	for p := range b {
		paths[p] = true
	}
	if st != nil {
		for p := range st.Files {
			paths[p] = true
		}
	}
	list := make([]string, 0, len(paths))
	for p := range paths {
		list = append(list, p)
	}
	sort.Strings(list)

	out := make([]entry, 0, len(list))
	for _, p := range list {
		ra, inA := a[p]
		rb, inB := b[p]
		var base string
		inState := false
		if st != nil {
			if se, ok := st.Files[p]; ok {
				base, inState = se.Hash, true
			}
		}
		e := entry{
			Path: p, InA: inA, InB: inB, InState: inState,
			HashState: base,
		}
		if inA {
			e.HashA, e.SizeA, e.ModA = ra.Hash, ra.Size, ra.Mod.UTC()
		}
		if inB {
			e.HashB, e.SizeB, e.ModB = rb.Hash, rb.Size, rb.Mod.UTC()
		}

		switch {
		case inA && inB && ra.Hash == rb.Hash:
			e.Status = stUnchanged
			if !inState {
				e.Detail = "identical on both sides (adopted into baseline)"
			}
		case inA && inB:
			switch {
			case !inState:
				e.Status = stConflict
				e.Detail = "created on both sides with different content"
			case base == ra.Hash:
				e.Status = stChangedB
				e.Detail = "modified on B since the last sync"
			case base == rb.Hash:
				e.Status = stChangedA
				e.Detail = "modified on A since the last sync"
			default:
				e.Status = stConflict
				e.Detail = "modified on both sides since the last sync"
			}
		case inA && !inB:
			switch {
			case !inState:
				e.Status = stNewA
				e.Detail = "new on A"
			case base == ra.Hash:
				e.Status = stDeletedB
				e.Detail = "deleted on B, unchanged on A"
			default:
				e.Status = stConflict
				e.Detail = "deleted on B but modified on A since the last sync"
			}
		case !inA && inB:
			switch {
			case !inState:
				e.Status = stNewB
				e.Detail = "new on B"
			case base == rb.Hash:
				e.Status = stDeletedA
				e.Detail = "deleted on A, unchanged on B"
			default:
				e.Status = stConflict
				e.Detail = "deleted on A but modified on B since the last sync"
			}
		default:
			e.Status = stStale
			e.Detail = "gone from both sides; baseline entry will be dropped"
		}
		out = append(out, e)
	}
	return out
}

func summarize(entries []entry) map[string]int {
	m := map[string]int{}
	for _, e := range entries {
		m[e.Status]++
	}
	return m
}

// ---------------------------------------------------------------------------
// Planning
// ---------------------------------------------------------------------------

type planResult struct {
	ops        []op
	unresolved int
	conflicts  int
}

func conflictName(rel, side string, ts time.Time) string {
	ext := filepath.Ext(rel)
	stem := strings.TrimSuffix(rel, ext)
	return fmt.Sprintf("%s.conflict-%s-%s%s", stem, side, ts.UTC().Format("20060102T150405Z"), ext)
}

func plan(entries []entry, policy string, now time.Time) planResult {
	var res planResult
	for i := range entries {
		e := &entries[i]
		switch e.Status {
		case stUnchanged, stStale:
			continue
		case stNewA, stChangedA:
			res.ops = append(res.ops, op{
				Kind: "copy", Path: e.Path, From: sideA, To: sideB, Bytes: e.SizeA,
				Desc: "copy A -> B (" + e.Detail + ")",
			})
		case stNewB, stChangedB:
			res.ops = append(res.ops, op{
				Kind: "copy", Path: e.Path, From: sideB, To: sideA, Bytes: e.SizeB,
				Desc: "copy B -> A (" + e.Detail + ")",
			})
		case stDeletedA:
			res.ops = append(res.ops, op{
				Kind: "trash", Path: e.Path, Side: sideB, Bytes: e.SizeB,
				Desc: "propagate delete to B (move into " + trashDirName + ")",
			})
		case stDeletedB:
			res.ops = append(res.ops, op{
				Kind: "trash", Path: e.Path, Side: sideA, Bytes: e.SizeA,
				Desc: "propagate delete to A (move into " + trashDirName + ")",
			})
		case stConflict:
			res.conflicts++
			if policy == policyManual {
				res.unresolved++
				e.Resolution = "unresolved (policy=manual)"
				continue
			}
			ops := resolveConflict(e, policy, now)
			if len(ops) == 0 {
				// The policy could not separate the two versions.
				res.unresolved++
				continue
			}
			res.ops = append(res.ops, ops...)
		}
	}
	return res
}

// resolveConflict turns one conflicting entry into concrete operations.
// A delete-vs-modify conflict always resolves in favour of the surviving
// content: deleting is not recoverable from the other side's bytes, so every
// non-manual policy restores the modified file rather than honouring the
// delete.
func resolveConflict(e *entry, policy string, now time.Time) []op {
	if e.InA != e.InB { // delete-vs-modify
		from, to := sideA, sideB
		size := e.SizeA
		if e.InB {
			from, to, size = sideB, sideA, e.SizeB
		}
		e.Resolution = fmt.Sprintf("policy=%s: keep the modified file, restore %s -> %s", policy, from, to)
		return []op{{
			Kind: "copy", Path: e.Path, From: from, To: to, Bytes: size,
			Desc: "restore modified file " + from + " -> " + to + " (delete-vs-modify conflict)",
		}}
	}

	winner, why := pickWinner(e, policy)
	if winner == "" {
		e.Resolution = fmt.Sprintf("unresolved (policy=%s: %s)", policy, why)
		return nil
	}
	loser := sideB
	if winner == sideB {
		loser = sideA
	}
	winSize, loseSize := e.SizeA, e.SizeB
	if winner == sideB {
		winSize, loseSize = e.SizeB, e.SizeA
	}

	if policy == "keep-both" {
		cn := conflictName(e.Path, loser, now)
		e.Resolution = fmt.Sprintf("policy=keep-both: %s wins the name (%s), %s kept as %s", winner, why, loser, cn)
		return []op{
			{Kind: "move", Path: e.Path, Dest: cn, Side: loser, Bytes: loseSize,
				Desc: "rename losing copy on " + loser + " to " + cn},
			{Kind: "copy", Path: e.Path, From: winner, To: loser, Bytes: winSize,
				Desc: "copy winning content " + winner + " -> " + loser},
			{Kind: "copy", Path: cn, From: loser, To: winner, Bytes: loseSize,
				Desc: "copy preserved losing content " + loser + " -> " + winner},
		}
	}

	e.Resolution = fmt.Sprintf("policy=%s: %s wins (%s)", policy, winner, why)
	return []op{
		{Kind: "trash", Path: e.Path, Side: loser, Bytes: loseSize,
			Desc: "move losing copy on " + loser + " into " + trashDirName},
		{Kind: "copy", Path: e.Path, From: winner, To: loser, Bytes: winSize,
			Desc: "copy winning content " + winner + " -> " + loser},
	}
}

// pickWinner returns the winning side and the reason, or "" when the policy
// cannot separate the two versions.
func pickWinner(e *entry, policy string) (string, string) {
	switch policy {
	case "larger":
		switch {
		case e.SizeA > e.SizeB:
			return sideA, fmt.Sprintf("larger: %s > %s", humanBytes(e.SizeA), humanBytes(e.SizeB))
		case e.SizeB > e.SizeA:
			return sideB, fmt.Sprintf("larger: %s > %s", humanBytes(e.SizeB), humanBytes(e.SizeA))
		}
		return "", "both versions are the same size"
	default: // newest, and the tie-breaker for keep-both
		switch {
		case e.ModA.After(e.ModB):
			return sideA, "newest: " + e.ModA.Format(time.RFC3339) + " > " + e.ModB.Format(time.RFC3339)
		case e.ModB.After(e.ModA):
			return sideB, "newest: " + e.ModB.Format(time.RFC3339) + " > " + e.ModA.Format(time.RFC3339)
		}
		if policy == "keep-both" {
			return sideA, "identical mtimes, A keeps the original name"
		}
		return "", "both versions have the same modification time"
	}
}

// ---------------------------------------------------------------------------
// Execution
// ---------------------------------------------------------------------------

func copyFile(src, dst string) (int64, error) {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return 0, err
	}
	in, err := os.Open(src)
	if err != nil {
		return 0, err
	}
	defer in.Close()
	st, err := in.Stat()
	if err != nil {
		return 0, err
	}
	tmp := dst + partSuffix
	out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
	if err != nil {
		return 0, err
	}
	n, err := io.Copy(out, in)
	if err != nil {
		out.Close()
		os.Remove(tmp)
		return 0, err
	}
	if err := out.Sync(); err != nil {
		out.Close()
		os.Remove(tmp)
		return 0, err
	}
	if err := out.Close(); err != nil {
		os.Remove(tmp)
		return 0, err
	}
	if err := os.Chmod(tmp, st.Mode().Perm()); err != nil {
		os.Remove(tmp)
		return 0, err
	}
	if err := os.Rename(tmp, dst); err != nil {
		os.Remove(tmp)
		return 0, err
	}
	// Keep mtimes aligned so the next run sees the two sides as identical.
	if err := os.Chtimes(dst, time.Now(), st.ModTime()); err != nil {
		return n, err
	}
	return n, nil
}

func movePath(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	if err := os.Rename(src, dst); err == nil {
		return nil
	}
	// Fall back to copy+remove for cross-device moves.
	if _, err := copyFile(src, dst); err != nil {
		return err
	}
	return os.Remove(src)
}

func trashPath(root, rel string, now time.Time) string {
	dst := filepath.Join(root, trashDirName, filepath.FromSlash(rel))
	if _, err := os.Lstat(dst); err == nil {
		ext := filepath.Ext(dst)
		dst = strings.TrimSuffix(dst, ext) + "." + now.UTC().Format("20060102T150405Z") + ext
	}
	return dst
}

func execute(ops []op, dirA, dirB string, now time.Time) (int64, []string) {
	var moved int64
	var errs []string
	root := func(side string) string {
		if side == sideA {
			return dirA
		}
		return dirB
	}
	for _, o := range ops {
		switch o.Kind {
		case "copy":
			src := filepath.Join(root(o.From), filepath.FromSlash(o.Path))
			dst := filepath.Join(root(o.To), filepath.FromSlash(o.Path))
			n, err := copyFile(src, dst)
			if err != nil {
				errs = append(errs, fmt.Sprintf("copy %s (%s->%s): %v", o.Path, o.From, o.To, err))
				continue
			}
			moved += n
		case "trash":
			src := filepath.Join(root(o.Side), filepath.FromSlash(o.Path))
			dst := trashPath(root(o.Side), o.Path, now)
			if err := movePath(src, dst); err != nil {
				errs = append(errs, fmt.Sprintf("trash %s on %s: %v", o.Path, o.Side, err))
				continue
			}
			moved += o.Bytes
		case "move":
			src := filepath.Join(root(o.Side), filepath.FromSlash(o.Path))
			dst := filepath.Join(root(o.Side), filepath.FromSlash(o.Dest))
			if err := movePath(src, dst); err != nil {
				errs = append(errs, fmt.Sprintf("rename %s on %s: %v", o.Path, o.Side, err))
				continue
			}
		}
	}
	return moved, errs
}

// ---------------------------------------------------------------------------
// status / sync
// ---------------------------------------------------------------------------

func runCompare(cmd, dirA, dirB, statePath, policy string, apply, asJSON bool) int {
	st, err := loadState(statePath)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			fmt.Fprintf(os.Stderr, "%s: no sync state at %s\n", appName, statePath)
			fmt.Fprintf(os.Stderr, "%s: MirrorFlow needs a baseline before it can tell a change from a conflict.\n", appName)
			fmt.Fprintf(os.Stderr, "%s: run:  %s init %s %s --state %s\n", appName, appName, dirA, dirB, statePath)
			return exitUsage
		}
		fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err)
		return exitUsage
	}

	a, skipA, err := scanTree(dirA)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: scanning dirA: %v\n", appName, err)
		return exitUsage
	}
	b, skipB, err := scanTree(dirB)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: scanning dirB: %v\n", appName, err)
		return exitUsage
	}

	now := time.Now()
	entries := classify(a, b, st)

	effPolicy := policy
	if cmd == "status" {
		effPolicy = policyManual
	}
	pr := plan(entries, effPolicy, now)

	rep := report{
		Tool: appName, Version: appVersion, Command: cmd,
		DirA: dirA, DirB: dirB, StateFile: statePath,
		Policy: policy, Apply: apply, DryRun: !apply,
		Entries: entries, Actions: pr.ops, Summary: summarize(entries),
		Conflicts: pr.conflicts, Unresolved: pr.unresolved,
	}
	if rep.Actions == nil {
		rep.Actions = []op{}
	}

	if cmd == "status" {
		rep.Apply, rep.DryRun, rep.Policy = false, false, ""
		if asJSON {
			emitJSON(rep)
		} else {
			printText(rep, skipA, skipB)
		}
		return exitOK
	}

	// sync
	if pr.unresolved > 0 {
		rep.Actions = []op{}
		rep.Refused = true
		rep.Errors = append(rep.Errors, fmt.Sprintf("%d unresolved conflict(s); nothing was changed", pr.unresolved))
		if asJSON {
			emitJSON(rep)
		} else {
			printText(rep, skipA, skipB)
			fmt.Fprintf(os.Stderr, "\n%s: %d unresolved conflict(s) under --policy %s.\n", appName, pr.unresolved, policy)
			fmt.Fprintf(os.Stderr, "%s: refusing to touch anything - both versions of every file are untouched.\n", appName)
			if policy == policyManual {
				fmt.Fprintf(os.Stderr, "%s: resolve the files by hand, or re-run with --policy newest|larger|keep-both.\n", appName)
			} else {
				fmt.Fprintf(os.Stderr, "%s: --policy %s could not separate these versions; try --policy keep-both or fix them by hand.\n", appName, policy)
			}
		}
		return exitConflict
	}

	if !apply {
		if asJSON {
			emitJSON(rep)
		} else {
			printText(rep, skipA, skipB)
		}
		return exitOK
	}

	moved, execErrs := execute(pr.ops, dirA, dirB, now)
	rep.BytesMoved = moved
	rep.Errors = append(rep.Errors, execErrs...)
	if len(execErrs) > 0 {
		if asJSON {
			emitJSON(rep)
		} else {
			printText(rep, skipA, skipB)
			for _, e := range execErrs {
				fmt.Fprintf(os.Stderr, "%s: %s\n", appName, e)
			}
			fmt.Fprintf(os.Stderr, "%s: state file NOT updated because some operations failed.\n", appName)
		}
		return exitUsage
	}

	// Re-scan and record the new baseline only after a clean apply.
	a2, _, err := scanTree(dirA)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: rescanning dirA: %v\n", appName, err)
		return exitUsage
	}
	b2, _, err := scanTree(dirB)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: rescanning dirB: %v\n", appName, err)
		return exitUsage
	}
	newState := buildState(dirA, dirB, a2, b2)
	if err := saveState(statePath, newState); err != nil {
		fmt.Fprintf(os.Stderr, "%s: writing state: %v\n", appName, err)
		return exitUsage
	}
	rep.State = true

	if asJSON {
		emitJSON(rep)
	} else {
		printText(rep, skipA, skipB)
	}
	return exitOK
}

// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------

func emitJSON(rep report) {
	if rep.Entries == nil {
		rep.Entries = []entry{}
	}
	if rep.Actions == nil {
		rep.Actions = []op{}
	}
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(rep); err != nil {
		fmt.Fprintf(os.Stderr, "%s: encoding JSON: %v\n", appName, err)
	}
}

func printText(rep report, skipA, skipB []string) {
	mode := "dry run (no changes written; add --apply)"
	switch {
	case rep.Command == "status":
		mode = "read-only"
	case rep.Refused:
		mode = "refused - unresolved conflicts, nothing changed"
	case rep.Apply:
		mode = "applying changes"
	}
	fmt.Printf("mirrorflow %s  [%s]\n", rep.Command, mode)
	fmt.Printf("  A: %s\n", rep.DirA)
	fmt.Printf("  B: %s\n", rep.DirB)
	fmt.Printf("  state: %s\n", rep.StateFile)
	if rep.Policy != "" {
		fmt.Printf("  policy: %s\n", rep.Policy)
	}
	fmt.Println()

	width := 4
	for _, e := range rep.Entries {
		if len(e.Status) > width {
			width = len(e.Status)
		}
	}
	fmt.Printf("%-*s  %s\n", width, "STATUS", "PATH")
	for _, e := range rep.Entries {
		fmt.Printf("%-*s  %s\n", width, e.Status, e.Path)
		if e.Status != stUnchanged && e.Detail != "" {
			fmt.Printf("%-*s    %s\n", width, "", e.Detail)
		}
		if e.Resolution != "" {
			fmt.Printf("%-*s    -> %s\n", width, "", e.Resolution)
		}
	}
	if len(rep.Entries) == 0 {
		fmt.Println("(both folders are empty)")
	}

	for _, p := range append(append([]string{}, skipA...), skipB...) {
		fmt.Printf("skipped (not a regular file): %s\n", p)
	}

	fmt.Println()
	fmt.Print("SUMMARY  ")
	keys := make([]string, 0, len(rep.Summary))
	for k := range rep.Summary {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, fmt.Sprintf("%s=%d", k, rep.Summary[k]))
	}
	if len(parts) == 0 {
		parts = append(parts, "no files")
	}
	fmt.Println(strings.Join(parts, "  "))

	if rep.Command == "status" {
		fmt.Printf("%d action(s) would be needed to bring A and B together.\n", actionCount(rep))
		return
	}

	if rep.Refused {
		fmt.Printf("ACTIONS  0 (refused: %d unresolved conflict(s))\n", rep.Unresolved)
		fmt.Printf("Nothing was written on either side and the state file was not updated.\n")
		return
	}
	fmt.Printf("ACTIONS  %d\n", len(rep.Actions))
	for _, o := range rep.Actions {
		p := o.Path
		if o.Dest != "" {
			p += " -> " + o.Dest
		}
		fmt.Printf("  %-6s %-6s %s  (%s)\n", o.Kind, sideLabel(o), p, o.Desc)
	}
	if rep.Apply {
		fmt.Printf("Applied %d action(s), %s moved. State updated: %v\n",
			len(rep.Actions), humanBytes(rep.BytesMoved), rep.State)
	} else if len(rep.Actions) > 0 {
		fmt.Printf("Dry run: nothing was written and the state file was not updated. Re-run with --apply.\n")
	} else {
		fmt.Printf("Nothing to do: A and B are already in sync.\n")
	}
}

func sideLabel(o op) string {
	if o.Kind == "copy" {
		return o.From + "->" + o.To
	}
	return o.Side
}

func actionCount(rep report) int {
	n := len(rep.Actions)
	for _, e := range rep.Entries {
		if e.Status == stConflict {
			n++
		}
	}
	return n
}
