package main

import (
	"encoding/json"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
	"time"
)

// deletionRule is printed by every report, every time. It is the one promise
// this tool makes loudest.
const deletionRule = "A deletion on one side is NEVER propagated to the other side. " +
	"Files are only ever added or preserved; nothing is removed from either library."

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fail("%v", err)
	}
}

func plural(n int, one, many string) string {
	if n == 1 {
		return one
	}
	return many
}

func wrapNote(w io.Writer, indent, text string) {
	const width = 74
	words := strings.Fields(text)
	line := indent
	first := true
	for _, word := range words {
		if !first && len(line)+1+len(word) > width {
			fmt.Fprintln(w, line)
			line = indent + word
			continue
		}
		if first {
			line += word
			first = false
		} else {
			line += " " + word
		}
	}
	if strings.TrimSpace(line) != "" {
		fmt.Fprintln(w, line)
	}
}

// headlineNew discounts the paths that are really renames, so "new photos"
// means new photos.
func headlineNew(c Counts) (int, int) {
	a := c.NewA - c.RenamesA - c.Relocated
	b := c.NewB - c.RenamesB - c.Relocated
	if a < 0 {
		a = 0
	}
	if b < 0 {
		b = 0
	}
	return a, b
}

// headlineDeletions discounts the old name of a renamed file, which is a
// deletion in the table but not a deletion to a human.
func headlineDeletions(c Counts) int {
	n := c.DeletedA + c.DeletedB + c.DeletedBoth - c.RenamesA - c.RenamesB
	if n < 0 {
		return 0
	}
	return n
}

// summarySentence is the short human answer: "12 new photos on the old phone,
// 3 on the new one, 1 needs your decision."
func summarySentence(p *Plan) string {
	c := p.Counts
	na, nb := headlineNew(c)
	s := fmt.Sprintf("%d new %s on %s, %d on %s, %d %s your decision.",
		na, plural(na, "file", "files"), p.NameA,
		nb, p.NameB,
		c.NeedDecision, plural(c.NeedDecision, "needs", "need"))
	var extra []string
	if c.ChangedA+c.ChangedB > 0 {
		extra = append(extra, fmt.Sprintf("%d %s edited on %s and %d on %s",
			c.ChangedA, plural(c.ChangedA, "file", "files"), p.NameA, c.ChangedB, p.NameB))
	}
	if c.RenamesA+c.RenamesB+c.Relocated > 0 {
		n := c.RenamesA + c.RenamesB + c.Relocated
		extra = append(extra, fmt.Sprintf("%d %s renamed rather than added", n, plural(n, "file was", "files were")))
	}
	if n := headlineDeletions(c); n > 0 {
		extra = append(extra, fmt.Sprintf("%d %s gone from at least one side (never propagated)",
			n, plural(n, "file is", "files are")))
	}
	if len(extra) > 0 {
		s += " " + strings.ToUpper(extra[0][:1]) + extra[0][1:]
		for _, e := range extra[1:] {
			s += "; " + e
		}
		s += "."
	}
	return s
}

func printHeader(w io.Writer, p *Plan) {
	fmt.Fprintf(w, "PocketSync - three-way reconciliation\n")
	fmt.Fprintf(w, "side A   : %s  (%s)\n", p.RootA, p.NameA)
	fmt.Fprintf(w, "           %d files, %s\n", len(p.TreeA.Files), humanBytes(p.TreeA.Bytes))
	fmt.Fprintf(w, "side B   : %s  (%s)\n", p.RootB, p.NameB)
	fmt.Fprintf(w, "           %d files, %s\n", len(p.TreeB.Files), humanBytes(p.TreeB.Bytes))
	switch p.BaselineState {
	case "loaded":
		fmt.Fprintf(w, "baseline : %s (%d paths, recorded %s)\n",
			p.BaselinePath, len(p.Base.Files), p.Base.Created.Format(time.RFC3339))
	case "missing":
		fmt.Fprintf(w, "baseline : %s (does not exist yet - treated as empty)\n", p.BaselinePath)
	default:
		fmt.Fprintf(w, "baseline : none given\n")
	}
	if p.BaselineState != "loaded" {
		wrapNote(w, "           ", "Without a baseline nothing can be told apart from new, so no deletion can be detected. That is safe: nothing is deleted either way.")
	}
	policy := p.Policy
	if policy == PolicyNone {
		policy = "none - conflicts are reported and skipped"
	}
	fmt.Fprintf(w, "conflicts: %s\n", policy)
	fmt.Fprintln(w)
}

func printCounts(w io.Writer, p *Plan) {
	c := p.Counts
	rows := []struct {
		label string
		n     int
	}{
		{"identical", c.Identical},
		{"new-on-A", c.NewA},
		{"new-on-B", c.NewB},
		{"changed-on-A", c.ChangedA},
		{"changed-on-B", c.ChangedB},
		{"changed-on-both-same-content", c.Converged},
		{"changed-on-both-different", c.Conflicts},
		{"deleted-on-A", c.DeletedA},
		{"deleted-on-B", c.DeletedB},
		{"deleted-on-both", c.DeletedBoth},
	}
	fmt.Fprintf(w, "CLASSIFICATION (%d paths)\n", c.Paths)
	for _, r := range rows {
		fmt.Fprintf(w, "  %-30s %6d\n", r.label, r.n)
	}
	if c.RenamesA+c.RenamesB+c.Relocated > 0 {
		fmt.Fprintf(w, "  %-30s %6d  (of the new/deleted counts above)\n",
			"renames/relocations detected", c.RenamesA+c.RenamesB+c.Relocated)
	}
	fmt.Fprintln(w)
}

func printPlan(w io.Writer, p *Plan, viaSync bool) {
	printHeader(w, p)
	fmt.Fprintf(w, "SUMMARY\n")
	wrapNote(w, "  ", summarySentence(p))
	fmt.Fprintln(w)
	printCounts(w, p)
	printOps(w, p)
	printRenames(w, p)
	printConflicts(w, p)
	printDeletions(w, p)

	fmt.Fprintf(w, "NOTHING HAS BEEN CHANGED\n")
	if viaSync {
		wrapNote(w, "  ", "This was a dry run: sync writes only with --apply. Re-run the same command with --apply to perform the operations above.")
	} else {
		wrapNote(w, "  ", "plan never writes. To perform the operations above, run the same command as `sync ... --apply`.")
	}
	wrapNote(w, "  ", deletionRule)
	fmt.Fprintln(w)
}

func printOps(w io.Writer, p *Plan) {
	c := p.Counts
	fmt.Fprintf(w, "OPERATIONS (%d copies, %d local preserves)\n", c.CopyOps, c.PreserveOps)
	if len(p.Ops) == 0 {
		fmt.Fprintf(w, "  (none - the two libraries already agree on everything that can be merged)\n\n")
		return
	}
	fmt.Fprintf(w, "  %s -> %s : %d %s, %s\n", p.NameA, p.NameB, c.CopyAToB,
		plural(c.CopyAToB, "file", "files"), humanBytes(c.BytesAToB))
	fmt.Fprintf(w, "  %s -> %s : %d %s, %s\n", p.NameB, p.NameA, c.CopyBToA,
		plural(c.CopyBToA, "file", "files"), humanBytes(c.BytesBToA))
	fmt.Fprintln(w)
	for _, op := range p.Ops {
		fmt.Fprintf(w, "  %-5s %-9s %s\n", op.Direction(), op.Kind, op.DstPath)
		fmt.Fprintf(w, "        from %s:%s  %s  %s\n", op.SrcSide, op.SrcPath, humanBytes(op.Size), short(op.SHA256))
		if op.ExpectDest != "" {
			fmt.Fprintf(w, "        replaces the baseline copy %s (verified again before writing)\n", short(op.ExpectDest))
		}
		fmt.Fprintf(w, "        reason %s\n", op.Reason)
	}
	fmt.Fprintln(w)
}

func printRenames(w io.Writer, p *Plan) {
	if len(p.Renames) == 0 {
		return
	}
	fmt.Fprintf(w, "RENAMES AND RELOCATIONS (%d)\n", len(p.Renames))
	wrapNote(w, "  ", "The same bytes under a different name. These are reported, not acted on: the content is already on both sides, and matching the names would mean removing a file.")
	for _, r := range p.Renames {
		fmt.Fprintf(w, "  %-12s %s\n", r.Kind, r.From)
		fmt.Fprintf(w, "               -> %s  (%s, %s)\n", r.To, humanBytes(r.Size), short(r.SHA256))
	}
	fmt.Fprintln(w)
}

func printConflicts(w io.Writer, p *Plan) {
	if len(p.Conflicts) == 0 {
		return
	}
	fmt.Fprintf(w, "CONFLICTS (%d - changed on both sides to different content)\n", len(p.Conflicts))
	for _, c := range p.Conflicts {
		fmt.Fprintf(w, "  %s\n", c.Path)
		fmt.Fprintf(w, "        %s: %s %s\n", p.NameA, humanBytes(c.ASize), short(c.ASHA256))
		fmt.Fprintf(w, "        %s: %s %s\n", p.NameB, humanBytes(c.BSize), short(c.BSHA256))
		if c.BaseSHA256 == "" {
			fmt.Fprintf(w, "        not in the baseline: it was added on both sides independently\n")
		}
		wrapNote(w, "        ", "policy "+policyLabel(c.Policy)+": "+c.Resolution)
	}
	fmt.Fprintln(w)
}

func policyLabel(p string) string {
	if p == PolicyNone {
		return "none"
	}
	return p
}

func printDeletions(w io.Writer, p *Plan) {
	if len(p.Deletions) == 0 {
		return
	}
	fmt.Fprintf(w, "DELETIONS (%d)\n", len(p.Deletions))
	wrapNote(w, "  ", deletionRule)
	for _, d := range p.Deletions {
		fmt.Fprintf(w, "  missing on %-4s %s\n", d.MissingOn, d.Path)
		wrapNote(w, "        ", d.Note)
	}
	fmt.Fprintln(w)
}

func short(sum string) string {
	if len(sum) <= 12 {
		return sum
	}
	return sum[:12]
}

func printStatus(w io.Writer, p *Plan) {
	c := p.Counts
	printHeader(w, p)
	wrapNote(w, "  ", summarySentence(p))
	fmt.Fprintln(w)
	na, nb := headlineNew(c)
	rows := [][2]string{
		{fmt.Sprintf("to copy %s -> %s", p.NameA, p.NameB),
			fmt.Sprintf("%d %s (%s)", c.CopyAToB, plural(c.CopyAToB, "file", "files"), humanBytes(c.BytesAToB))},
		{fmt.Sprintf("to copy %s -> %s", p.NameB, p.NameA),
			fmt.Sprintf("%d %s (%s)", c.CopyBToA, plural(c.CopyBToA, "file", "files"), humanBytes(c.BytesBToA))},
		{"new on " + p.NameA, fmt.Sprintf("%d", na)},
		{"new on " + p.NameB, fmt.Sprintf("%d", nb)},
		{"conflicts", fmt.Sprintf("%d", c.Conflicts)},
		{"renames/relocations", fmt.Sprintf("%d", c.RenamesA+c.RenamesB+c.Relocated)},
		{"deletions (reported, never propagated)", fmt.Sprintf("%d", c.DeletedA+c.DeletedB+c.DeletedBoth)},
		{"already identical", fmt.Sprintf("%d", c.Identical)},
	}
	width := 0
	for _, r := range rows {
		if len(r[0]) > width {
			width = len(r[0])
		}
	}
	for _, r := range rows {
		fmt.Fprintf(w, "  %-*s : %s\n", width, r[0], r[1])
	}
	fmt.Fprintln(w)
	wrapNote(w, "  ", deletionRule)
	fmt.Fprintln(w)
	fmt.Fprintf(w, "  Run `%s plan` for the detail, `%s sync --apply` to carry it out.\n", appName, appName)
}

func printApply(w io.Writer, p *Plan, res *ApplyResult) {
	printHeader(w, p)
	fmt.Fprintf(w, "APPLIED\n")
	fmt.Fprintf(w, "  operations planned : %d\n", len(p.Ops))
	fmt.Fprintf(w, "  performed          : %d (%s)\n", res.Done, humanBytes(res.BytesWritten))
	fmt.Fprintf(w, "  skipped            : %d\n", res.Skipped)
	fmt.Fprintf(w, "  failed             : %d\n", len(res.Failed))
	fmt.Fprintln(w)
	for _, r := range res.Results {
		fmt.Fprintf(w, "  %-30s %-5s %s\n", r.Status, r.Op.Direction(), r.Op.DstPath)
		if r.Detail != "" {
			wrapNote(w, "        ", r.Detail)
		}
	}
	if len(res.Results) > 0 {
		fmt.Fprintln(w)
	}
	printRenames(w, p)
	printConflicts(w, p)
	printDeletions(w, p)
	if res.Baseline != nil {
		fmt.Fprintf(w, "BASELINE\n")
		fmt.Fprintf(w, "  advanced : %s (%d paths)\n", res.Baseline.Path, res.Baseline.Files)
		if res.Baseline.PreviousKept != "" {
			fmt.Fprintf(w, "  previous : kept as %s (never overwritten in place)\n", res.Baseline.PreviousKept)
		} else {
			fmt.Fprintf(w, "  previous : none (this is the first baseline)\n")
		}
		fmt.Fprintf(w, "  recorded : %d agreed, %d carried forward as known-divergent, %d dropped (gone from both sides)\n",
			res.Baseline.Stats.Agreed, res.Baseline.Stats.Carried, res.Baseline.Stats.DroppedDeleted)
		if n := len(res.Baseline.Stats.LeftOut); n > 0 {
			wrapNote(w, "  ", fmt.Sprintf("%d paths still do not agree between the two sides (conflicts left for you, renames, and one-sided files). The ones the baseline already knew about keep their old entry, so a deletion stays a deletion and is never quietly copied back.", n))
		}
		fmt.Fprintln(w)
	} else if res.BaselineError != "" {
		fmt.Fprintf(w, "BASELINE\n  NOT advanced: %s\n\n", res.BaselineError)
	} else {
		fmt.Fprintf(w, "BASELINE\n")
		wrapNote(w, "  ", "No --baseline was given, so no baseline was written. Without one the next run cannot tell a deletion from a file you never had.")
		fmt.Fprintln(w)
	}
	wrapNote(w, "  ", deletionRule)
	fmt.Fprintln(w)
}

func printBaseline(w io.Writer, path, prev string, a, b *Tree, m *Manifest, st BaselineStats) {
	fmt.Fprintf(w, "PocketSync baseline\n")
	fmt.Fprintf(w, "side A   : %s (%d files, %s)\n", a.Root, len(a.Files), humanBytes(a.Bytes))
	fmt.Fprintf(w, "side B   : %s (%d files, %s)\n", b.Root, len(b.Files), humanBytes(b.Bytes))
	fmt.Fprintf(w, "written  : %s\n", path)
	if prev != "" {
		fmt.Fprintf(w, "previous : kept as %s (never overwritten in place)\n", prev)
	} else {
		fmt.Fprintf(w, "previous : none (first baseline)\n")
	}
	fmt.Fprintln(w)
	fmt.Fprintf(w, "  agreed identical paths recorded : %d\n", st.Agreed)
	fmt.Fprintf(w, "  carried forward as divergent    : %d\n", st.Carried)
	fmt.Fprintf(w, "  dropped (gone from both sides)  : %d\n", st.DroppedDeleted)
	fmt.Fprintf(w, "  paths in the baseline           : %d\n", len(m.Files))
	fmt.Fprintln(w)
	wrapNote(w, "  ", "A baseline is the state the two libraries agree on: every path that exists on both sides with byte-identical content. Anything that differs, or exists on one side only, is left out so the next comparison sees it as new or changed - except for paths the previous baseline already knew about, which keep their old entry so a deletion stays a deletion.")
	fmt.Fprintln(w)
	if len(st.LeftOut) > 0 {
		fmt.Fprintf(w, "  NOT RECORDED AS AGREED (%d)\n", len(st.LeftOut))
		for _, s := range st.LeftOut {
			fmt.Fprintf(w, "    %s\n", s)
		}
		fmt.Fprintln(w)
	}
}

// ---------------------------------------------------------------------------
// JSON shapes
// ---------------------------------------------------------------------------

func treeJSON(t *Tree) map[string]any {
	return map[string]any{
		"root":         t.Root,
		"files":        len(t.Files),
		"bytes":        t.Bytes,
		"bytes_human":  humanBytes(t.Bytes),
		"dirs":         t.Dirs,
		"skipped":      t.Skipped,
		"read_errors":  t.Errors,
		"part_ignored": t.Parts,
	}
}

func baseJSON(p *Plan) map[string]any {
	return map[string]any{
		"tool":           appName,
		"generated_at":   time.Now().UTC().Format(time.RFC3339),
		"side_a":         treeJSON(p.TreeA),
		"side_b":         treeJSON(p.TreeB),
		"name_a":         p.NameA,
		"name_b":         p.NameB,
		"baseline_path":  p.BaselinePath,
		"baseline_state": p.BaselineState,
		"baseline_paths": len(p.Base.Files),
		"conflict_policy": func() string {
			if p.Policy == PolicyNone {
				return "none"
			}
			return p.Policy
		}(),
		"counts":             p.Counts,
		"summary":            summarySentence(p),
		"deletion_guarantee": deletionRule,
	}
}

func statusJSON(p *Plan) map[string]any {
	out := baseJSON(p)
	na, nb := headlineNew(p.Counts)
	out["new_on_a_excluding_renames"] = na
	out["new_on_b_excluding_renames"] = nb
	out["conflicts"] = p.Conflicts
	out["renames"] = p.Renames
	return out
}

func planJSON(p *Plan, viaSync bool) map[string]any {
	out := baseJSON(p)
	out["applied"] = false
	out["dry_run"] = true
	if viaSync {
		out["note"] = "dry run: sync writes only with --apply"
	} else {
		out["note"] = "plan never writes"
	}
	recs := append([]Record(nil), p.Records...)
	sort.SliceStable(recs, func(i, j int) bool { return recs[i].Path < recs[j].Path })
	out["records"] = recs
	out["operations"] = nonNilOps(p.Ops)
	out["renames"] = nonNilRenames(p.Renames)
	out["conflicts"] = nonNilConflicts(p.Conflicts)
	out["deletions"] = nonNilDeletions(p.Deletions)
	return out
}

func applyJSON(p *Plan, res *ApplyResult) map[string]any {
	out := baseJSON(p)
	out["applied"] = true
	out["dry_run"] = false
	out["operations"] = nonNilOps(p.Ops)
	out["renames"] = nonNilRenames(p.Renames)
	out["conflicts"] = nonNilConflicts(p.Conflicts)
	out["deletions"] = nonNilDeletions(p.Deletions)
	out["result"] = res
	return out
}

func baselineJSON(path, prev string, m *Manifest, a, b *Tree, st BaselineStats) map[string]any {
	if st.LeftOut == nil {
		st.LeftOut = []string{}
	}
	return map[string]any{
		"tool":             appName,
		"generated_at":     time.Now().UTC().Format(time.RFC3339),
		"baseline_path":    path,
		"previous_kept_as": prev,
		"side_a":           treeJSON(a),
		"side_b":           treeJSON(b),
		"stats":            st,
		"recorded_files":   len(m.Files),
		"note": "the baseline records paths present on BOTH sides with identical content, " +
			"plus previously known paths that no longer agree so a deletion is never undone",
	}
}

func nonNilOps(v []Op) []Op {
	if v == nil {
		return []Op{}
	}
	return v
}

func nonNilRenames(v []Rename) []Rename {
	if v == nil {
		return []Rename{}
	}
	return v
}

func nonNilConflicts(v []Conflict) []Conflict {
	if v == nil {
		return []Conflict{}
	}
	return v
}

func nonNilDeletions(v []Deletion) []Deletion {
	if v == nil {
		return []Deletion{}
	}
	return v
}
