// Command pocketsync reconciles two phone libraries that have BOTH been used
// since they last matched. It performs a three-way merge of side A, side B and
// a recorded baseline, copies what is genuinely new in each direction, reports
// conflicts, and never propagates a deletion.
package main

import (
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

const appName = "pocketsync"

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (verbatim across the tool line)
// ---------------------------------------------------------------------------

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])
}

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

func usage(w io.Writer) {
	fmt.Fprintf(w, `%s - three-way reconciliation for two phone libraries (Techlosoft Phone Bridge)

USAGE
  %s status   --a <dir> --b <dir> [--baseline <file.json>] [--json]
  %s plan     --a <dir> --b <dir> [--baseline <file.json>]
              [--conflict keep-both|prefer-a|prefer-b] [--json]
  %s sync     --a <dir> --b <dir> [--baseline <file.json>]
              [--conflict keep-both|prefer-a|prefer-b] --apply [--json]
  %s baseline --a <dir> --b <dir> --baseline <file.json> [--json]
  %s help | -h | --help

COMMANDS
  status     One short paragraph: how many files are new on each side, how many
             need a decision, how many were renamed. Reads only.
  plan       The full three-way classification and the exact copy operations
             that WOULD run. Changes nothing on disk. This is the default view.
  sync       The same computation, then --apply actually performs the copies.
             Without --apply, sync is identical to plan and touches nothing.
  baseline   Record the agreed-identical state of the two sides: every path
             that exists on BOTH sides with byte-identical content. Paths that
             differ or exist on one side only are deliberately left out.

FLAGS
  --a <dir>          Side A: a mounted phone library, or any directory tree.
  --b <dir>          Side B: the other library.
  --baseline <file>  JSON manifest of the last agreed-identical state. Optional
                     for status/plan/sync: without it every path looks new and
                     no deletion can be detected (which is safe, since nothing
                     is ever deleted anyway).
  --conflict <pol>   How to resolve a file changed on BOTH sides to different
                     content. There is NO default: with no policy, conflicts are
                     reported and skipped, so nothing is ever silently lost.
                       keep-both  write BOTH versions to BOTH sides as
                                  <name>.pocketsync-a<ext> / <name>.pocketsync-b<ext>
                       prefer-a   side A's version wins at the original path;
                                  side B's version is preserved as
                                  <name>.pocketsync-b<ext> on both sides first
                       prefer-b   the mirror image of prefer-a
  --apply            Required by sync to write anything. Copies are written to
                     a .part file, hash-verified, then renamed into place.
  --name-a <text>    Human label for side A in reports (default "side A").
  --name-b <text>    Human label for side B in reports (default "side B").
  --json             Machine-readable JSON output (status, plan, sync, baseline).

DELETIONS
  A deletion on one side is NEVER propagated to the other side. It is reported,
  the surviving copy is left exactly where it is, and no code path in this
  program unlinks a file of yours.

EXAMPLES
  %s baseline --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM --baseline base.json
  %s status   --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM --baseline base.json
  %s plan     --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM --baseline base.json
  %s sync     --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM --baseline base.json \
              --conflict keep-both --apply

Flags may appear before or after positional arguments. Two bare positional
arguments are read as side A and side B.
`, appName, appName, appName, appName, appName, appName, appName, appName, appName, appName)
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", appName, fmt.Sprintf(format, args...))
	os.Exit(1)
}

func usageErr(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n\n", appName, fmt.Sprintf(format, args...))
	usage(os.Stderr)
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// 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(1)
	}
	switch args[0] {
	case "help", "-h", "--help":
		usage(os.Stdout)
		os.Exit(0)
	}
	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usage(os.Stdout)
			os.Exit(0)
		}
	}
	switch cmd {
	case "status":
		cmdStatus(rest)
	case "plan":
		cmdPlan(rest)
	case "sync":
		cmdSync(rest)
	case "baseline":
		cmdBaseline(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

// ---------------------------------------------------------------------------
// Flag plumbing
// ---------------------------------------------------------------------------

var valueFlags = map[string]bool{
	"a": true, "b": true,
	"baseline": true, "f": true,
	"conflict": true, "c": true,
	"name-a": true, "name-b": true,
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = func() { usage(os.Stderr) }
	return fs
}

// commonOpts is the flag set shared by every subcommand that compares trees.
type commonOpts struct {
	fs       *flag.FlagSet
	a        *string
	b        *string
	baseline *string
	conflict *string
	nameA    *string
	nameB    *string
	asJSON   *bool
	apply    *bool
}

func bindCommon(name string, withConflict, withApply bool) *commonOpts {
	fs := newFlagSet(name)
	o := &commonOpts{fs: fs}
	o.a = fs.String("a", "", "side A directory")
	o.b = fs.String("b", "", "side B directory")
	o.baseline = fs.String("baseline", "", "baseline manifest file")
	fs.StringVar(o.baseline, "f", "", "shorthand for --baseline")
	o.nameA = fs.String("name-a", "side A", "label for side A in reports")
	o.nameB = fs.String("name-b", "side B", "label for side B in reports")
	o.asJSON = fs.Bool("json", false, "JSON output")
	if withConflict {
		o.conflict = fs.String("conflict", "", "conflict policy: keep-both|prefer-a|prefer-b")
		fs.StringVar(o.conflict, "c", "", "shorthand for --conflict")
	}
	if withApply {
		o.apply = fs.Bool("apply", false, "actually perform the copies")
	}
	return o
}

// parse resolves flags, accepts two bare positional arguments as A and B, and
// validates everything a comparing subcommand needs.
func (o *commonOpts) parse(argv []string, needBaseline bool) {
	if err := o.fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	pos := o.fs.Args()
	if *o.a == "" && len(pos) > 0 {
		*o.a = pos[0]
		pos = pos[1:]
	}
	if *o.b == "" && len(pos) > 0 {
		*o.b = pos[0]
		pos = pos[1:]
	}
	if len(pos) > 0 {
		usageErr("unexpected extra argument %q", pos[0])
	}
	if *o.a == "" || *o.b == "" {
		usageErr("%s needs --a <dir> and --b <dir>", o.fs.Name())
	}
	if needBaseline && *o.baseline == "" {
		usageErr("%s needs --baseline <file.json>", o.fs.Name())
	}
	if o.conflict != nil {
		switch *o.conflict {
		case PolicyNone, PolicyKeepBoth, PolicyPreferA, PolicyPreferB:
		default:
			usageErr("unknown --conflict policy %q (want keep-both, prefer-a or prefer-b)", *o.conflict)
		}
	}
	absA, err := filepath.Abs(*o.a)
	if err != nil {
		fail("cannot resolve %q: %v", *o.a, err)
	}
	absB, err := filepath.Abs(*o.b)
	if err != nil {
		fail("cannot resolve %q: %v", *o.b, err)
	}
	*o.a, *o.b = absA, absB
	mustBeDir(*o.a, "side A")
	mustBeDir(*o.b, "side B")
	if *o.a == *o.b {
		usageErr("side A and side B are the same directory (%s)", *o.a)
	}
	if *o.baseline != "" {
		abs, err := filepath.Abs(*o.baseline)
		if err != nil {
			fail("cannot resolve %q: %v", *o.baseline, err)
		}
		*o.baseline = abs
	}
}

func mustBeDir(path, what string) {
	info, err := os.Stat(path)
	if err != nil {
		if os.IsNotExist(err) {
			fail("%s %q does not exist", what, path)
		}
		fail("cannot stat %s %q: %v", what, path, err)
	}
	if !info.IsDir() {
		fail("%s %q is not a directory", what, path)
	}
}

// loadSides scans both trees and the baseline, and builds the reconciliation.
func loadSides(o *commonOpts) *Plan {
	exclude := map[string]bool{}
	if *o.baseline != "" {
		exclude[*o.baseline] = true
		exclude[*o.baseline+partSuffix] = true
	}
	treeA, err := scanTree(*o.a, exclude)
	if err != nil {
		fail("%v", err)
	}
	treeB, err := scanTree(*o.b, exclude)
	if err != nil {
		fail("%v", err)
	}
	var base *Manifest
	baselineState := "none"
	if *o.baseline != "" {
		base, err = loadManifest(*o.baseline)
		if err != nil {
			if errors.Is(err, errNoBaseline) {
				baselineState = "missing"
				base = newManifest(*o.a, *o.b)
			} else {
				fail("%v", err)
			}
		} else {
			baselineState = "loaded"
		}
	} else {
		base = newManifest(*o.a, *o.b)
	}

	policy := PolicyNone
	if o.conflict != nil {
		policy = *o.conflict
	}
	p := buildPlan(treeA, treeB, base, policy)
	p.BaselinePath = *o.baseline
	p.BaselineState = baselineState
	p.NameA, p.NameB = *o.nameA, *o.nameB
	return p
}

// ---------------------------------------------------------------------------
// status / plan / sync / baseline
// ---------------------------------------------------------------------------

func cmdStatus(argv []string) {
	o := bindCommon("status", false, false)
	o.parse(argv, false)
	p := loadSides(o)
	if *o.asJSON {
		emitJSON(statusJSON(p))
		return
	}
	printStatus(os.Stdout, p)
}

func cmdPlan(argv []string) {
	o := bindCommon("plan", true, false)
	o.parse(argv, false)
	p := loadSides(o)
	if *o.asJSON {
		emitJSON(planJSON(p, false))
		return
	}
	printPlan(os.Stdout, p, false)
}

func cmdSync(argv []string) {
	o := bindCommon("sync", true, true)
	o.parse(argv, false)
	p := loadSides(o)

	if !*o.apply {
		if *o.asJSON {
			emitJSON(planJSON(p, true))
			return
		}
		printPlan(os.Stdout, p, true)
		return
	}

	res, err := applyPlan(p)
	if err != nil {
		fail("%v", err)
	}
	if *o.baseline != "" {
		adv, err := advanceBaseline(*o.baseline, p.RootA, p.RootB)
		if err != nil {
			res.BaselineError = err.Error()
		} else {
			res.Baseline = adv
		}
	}
	if *o.asJSON {
		emitJSON(applyJSON(p, res))
	} else {
		printApply(os.Stdout, p, res)
	}
	if len(res.Failed) > 0 || res.BaselineError != "" {
		os.Exit(1)
	}
}

func cmdBaseline(argv []string) {
	o := bindCommon("baseline", false, false)
	o.parse(argv, true)

	exclude := map[string]bool{*o.baseline: true, *o.baseline + partSuffix: true}
	treeA, err := scanTree(*o.a, exclude)
	if err != nil {
		fail("%v", err)
	}
	treeB, err := scanTree(*o.b, exclude)
	if err != nil {
		fail("%v", err)
	}
	old, err := loadManifest(*o.baseline)
	if err != nil {
		if !errors.Is(err, errNoBaseline) {
			fail("%v", err)
		}
		old = nil
	}
	m, st := mergeBaseline(old, treeA, treeB)
	prev, err := writeManifest(*o.baseline, m)
	if err != nil {
		fail("%v", err)
	}
	if *o.asJSON {
		emitJSON(baselineJSON(*o.baseline, prev, m, treeA, treeB, st))
		return
	}
	printBaseline(os.Stdout, *o.baseline, prev, treeA, treeB, m, st)
}
