package main

import (
	"fmt"
	"path/filepath"
	"sort"
)

// ---------------------------------------------------------------------------
// The classification vocabulary
// ---------------------------------------------------------------------------

// Class is the result of the three-way decision table. Every path in the union
// of (baseline, side A, side B) resolves to exactly one of these.
type Class string

const (
	ClassIdentical  Class = "identical"
	ClassNewA       Class = "new-on-A"
	ClassNewB       Class = "new-on-B"
	ClassChangedA   Class = "changed-on-A"
	ClassChangedB   Class = "changed-on-B"
	ClassConverged  Class = "changed-on-both-same-content"
	ClassConflict   Class = "changed-on-both-different"
	ClassDeletedA   Class = "deleted-on-A"
	ClassDeletedB   Class = "deleted-on-B"
	ClassDeletedAll Class = "deleted-on-both"
)

// AllClasses is the closed set, in report order.
var AllClasses = []Class{
	ClassIdentical, ClassNewA, ClassNewB, ClassChangedA, ClassChangedB,
	ClassConverged, ClassConflict, ClassDeletedA, ClassDeletedB, ClassDeletedAll,
}

// Conflict policies. There is no default: PolicyNone reports and skips.
const (
	PolicyNone     = ""
	PolicyKeepBoth = "keep-both"
	PolicyPreferA  = "prefer-a"
	PolicyPreferB  = "prefer-b"
)

// ---------------------------------------------------------------------------
// The decision table
// ---------------------------------------------------------------------------

// classify is the whole three-way merge decision, in one place. base, a and b
// are the recorded state of one path in the baseline, on side A and on side B;
// a nil pointer means the path is absent there.
//
// otherChanged is true only for the delete/modify divergence: the file was
// removed on one side while the surviving side edited it. The class is still
// the deletion (a deletion is the fact that matters, and it is never
// propagated), but the divergence is flagged so it can be reported as needing
// a human decision.
func classify(base, a, b *FileMeta) (cls Class, otherChanged bool) {
	switch {
	case a == nil && b == nil:
		if base == nil {
			// Present nowhere: not a real path. Nothing to do.
			return ClassIdentical, false
		}
		return ClassDeletedAll, false

	case base == nil:
		switch {
		case a != nil && b == nil:
			return ClassNewA, false
		case a == nil && b != nil:
			return ClassNewB, false
		case a.SHA256 == b.SHA256:
			// Added independently on both sides with the same bytes.
			return ClassIdentical, false
		default:
			return ClassConflict, false
		}

	default: // base != nil
		switch {
		case a != nil && b == nil:
			return ClassDeletedB, a.SHA256 != base.SHA256
		case a == nil && b != nil:
			return ClassDeletedA, b.SHA256 != base.SHA256
		case a.SHA256 == b.SHA256:
			if a.SHA256 == base.SHA256 {
				return ClassIdentical, false
			}
			return ClassConverged, false
		case a.SHA256 == base.SHA256:
			return ClassChangedB, false
		case b.SHA256 == base.SHA256:
			return ClassChangedA, false
		default:
			return ClassConflict, false
		}
	}
}

// ---------------------------------------------------------------------------
// Plan data model
// ---------------------------------------------------------------------------

// Record is one path's three-way verdict.
type Record struct {
	Path         string `json:"path"`
	Class        Class  `json:"class"`
	InBaseline   bool   `json:"in_baseline"`
	OnA          bool   `json:"on_a"`
	OnB          bool   `json:"on_b"`
	BaseSHA256   string `json:"base_sha256,omitempty"`
	ASHA256      string `json:"a_sha256,omitempty"`
	BSHA256      string `json:"b_sha256,omitempty"`
	ASize        int64  `json:"a_size,omitempty"`
	BSize        int64  `json:"b_size,omitempty"`
	OtherChanged bool   `json:"other_side_changed,omitempty"`
	Note         string `json:"note,omitempty"`
}

// Phases. Every version that is about to be replaced is safely on disk under
// its own name before anything is replaced, so an ordering mistake cannot cost
// you a file.
const (
	PhasePreserve = 0 // write a losing version somewhere safe
	PhaseAdd      = 1 // add a file that is not there yet
	PhaseReplace  = 2 // replace a file whose current content the plan verified
)

// Op is a single file write. Nothing else writes to disk.
type Op struct {
	Kind       string `json:"kind"` // "copy" (cross-side) or "preserve" (within one side)
	Phase      int    `json:"phase"`
	SrcSide    string `json:"src_side"`
	SrcPath    string `json:"src_path"`
	DstSide    string `json:"dst_side"`
	DstPath    string `json:"dst_path"`
	SHA256     string `json:"sha256"`
	Size       int64  `json:"size"`
	ExpectDest string `json:"expect_dest_sha256,omitempty"` // "" means the destination must not exist
	Reason     string `json:"reason"`
}

// Direction is the human form, e.g. "A->B".
func (o Op) Direction() string { return o.SrcSide + "->" + o.DstSide }

// Rename is a content identity spotted under two different names.
type Rename struct {
	Kind    string `json:"kind"` // "rename-on-A", "rename-on-B", "relocated"
	From    string `json:"from"`
	To      string `json:"to"`
	SHA256  string `json:"sha256"`
	Size    int64  `json:"size"`
	Comment string `json:"comment"`
}

// Conflict is a path changed on both sides to different content.
type Conflict struct {
	Path       string   `json:"path"`
	BaseSHA256 string   `json:"base_sha256,omitempty"`
	ASHA256    string   `json:"a_sha256"`
	BSHA256    string   `json:"b_sha256"`
	ASize      int64    `json:"a_size"`
	BSize      int64    `json:"b_size"`
	Policy     string   `json:"policy"`
	Resolution string   `json:"resolution"`
	KeptAs     []string `json:"kept_as,omitempty"`
}

// Deletion is a path that vanished from at least one side.
type Deletion struct {
	Path         string `json:"path"`
	MissingOn    string `json:"missing_on"` // "A", "B" or "both"
	OtherChanged bool   `json:"other_side_changed,omitempty"`
	SHA256       string `json:"surviving_sha256,omitempty"`
	Size         int64  `json:"surviving_size,omitempty"`
	Note         string `json:"note"`
}

// Counts is the tally used by every report.
type Counts struct {
	Paths        int   `json:"paths"`
	Identical    int   `json:"identical"`
	NewA         int   `json:"new_on_a"`
	NewB         int   `json:"new_on_b"`
	ChangedA     int   `json:"changed_on_a"`
	ChangedB     int   `json:"changed_on_b"`
	Converged    int   `json:"changed_on_both_same_content"`
	Conflicts    int   `json:"changed_on_both_different"`
	DeletedA     int   `json:"deleted_on_a"`
	DeletedB     int   `json:"deleted_on_b"`
	DeletedBoth  int   `json:"deleted_on_both"`
	RenamesA     int   `json:"renames_on_a"`
	RenamesB     int   `json:"renames_on_b"`
	Relocated    int   `json:"relocated"`
	CopyOps      int   `json:"copy_ops"`
	PreserveOps  int   `json:"preserve_ops"`
	CopyAToB     int   `json:"copy_a_to_b"`
	CopyBToA     int   `json:"copy_b_to_a"`
	BytesAToB    int64 `json:"bytes_a_to_b"`
	BytesBToA    int64 `json:"bytes_b_to_a"`
	NeedDecision int   `json:"need_decision"`
}

// Plan is the complete result of a reconciliation. Building one never writes
// to disk.
type Plan struct {
	RootA         string
	RootB         string
	NameA         string
	NameB         string
	BaselinePath  string
	BaselineState string // "none", "missing" or "loaded"
	Policy        string
	TreeA         *Tree
	TreeB         *Tree
	Base          *Manifest
	Records       []Record
	Ops           []Op
	Renames       []Rename
	Conflicts     []Conflict
	Deletions     []Deletion
	Counts        Counts
}

func (p *Plan) rootFor(side string) string {
	if side == "A" {
		return p.RootA
	}
	return p.RootB
}

func (p *Plan) treeFor(side string) *Tree {
	if side == "A" {
		return p.TreeA
	}
	return p.TreeB
}

func (p *Plan) nameFor(side string) string {
	if side == "A" {
		return p.NameA
	}
	return p.NameB
}

// ---------------------------------------------------------------------------
// Plan construction
// ---------------------------------------------------------------------------

// sortOps puts the plan in a stable, deterministic order: preservation first,
// then additions, then the replacements. A version that is about to be
// replaced has therefore already been written somewhere else, and no operation
// can read a source another operation has already overwritten.
func sortOps(ops []Op) {
	sort.SliceStable(ops, func(i, j int) bool {
		x, y := ops[i], ops[j]
		if x.Phase != y.Phase {
			return x.Phase < y.Phase
		}
		if x.DstPath != y.DstPath {
			return x.DstPath < y.DstPath
		}
		if x.DstSide != y.DstSide {
			return x.DstSide < y.DstSide
		}
		if x.SrcSide != y.SrcSide {
			return x.SrcSide < y.SrcSide
		}
		return x.SrcPath < y.SrcPath
	})
}

// buildPlan runs the three-way merge. It is pure: it reads the two scanned
// trees and the baseline and produces the verdicts and the operations.
func buildPlan(a, b *Tree, base *Manifest, policy string) *Plan {
	p := &Plan{
		RootA: a.Root, RootB: b.Root,
		NameA: "side A", NameB: "side B",
		Policy: policy,
		TreeA:  a, TreeB: b, Base: base,
	}

	// 1. Classify the union of all known paths.
	seen := map[string]bool{}
	var paths []string
	add := func(p string) {
		if !seen[p] {
			seen[p] = true
			paths = append(paths, p)
		}
	}
	if base != nil {
		for k := range base.Files {
			add(k)
		}
	}
	for k := range a.Files {
		add(k)
	}
	for k := range b.Files {
		add(k)
	}
	sort.Strings(paths)

	byPath := map[string]*Record{}
	for _, path := range paths {
		fb, fa, fbb := base.get(path), a.get(path), b.get(path)
		cls, other := classify(fb, fa, fbb)
		r := Record{
			Path:         path,
			Class:        cls,
			InBaseline:   fb != nil,
			OnA:          fa != nil,
			OnB:          fbb != nil,
			OtherChanged: other,
		}
		if fb != nil {
			r.BaseSHA256 = fb.SHA256
		}
		if fa != nil {
			r.ASHA256, r.ASize = fa.SHA256, fa.Size
		}
		if fbb != nil {
			r.BSHA256, r.BSize = fbb.SHA256, fbb.Size
		}
		p.Records = append(p.Records, r)
	}
	for i := range p.Records {
		byPath[p.Records[i].Path] = &p.Records[i]
	}

	// 2. Content identity: the same bytes under a different name.
	suppress := detectRenames(p, byPath)

	// 3. Operations.
	for i := range p.Records {
		r := &p.Records[i]
		switch r.Class {
		case ClassNewA:
			if suppress["A:"+r.Path] {
				continue
			}
			p.Ops = append(p.Ops, Op{
				Kind: "copy", Phase: PhaseAdd,
				SrcSide: "A", SrcPath: r.Path, DstSide: "B", DstPath: r.Path,
				SHA256: r.ASHA256, Size: r.ASize, Reason: string(ClassNewA),
			})
		case ClassNewB:
			if suppress["B:"+r.Path] {
				continue
			}
			p.Ops = append(p.Ops, Op{
				Kind: "copy", Phase: PhaseAdd,
				SrcSide: "B", SrcPath: r.Path, DstSide: "A", DstPath: r.Path,
				SHA256: r.BSHA256, Size: r.BSize, Reason: string(ClassNewB),
			})
		case ClassChangedA:
			p.Ops = append(p.Ops, Op{
				Kind: "copy", Phase: PhaseReplace,
				SrcSide: "A", SrcPath: r.Path, DstSide: "B", DstPath: r.Path,
				SHA256: r.ASHA256, Size: r.ASize, ExpectDest: r.BaseSHA256,
				Reason: string(ClassChangedA),
			})
		case ClassChangedB:
			p.Ops = append(p.Ops, Op{
				Kind: "copy", Phase: PhaseReplace,
				SrcSide: "B", SrcPath: r.Path, DstSide: "A", DstPath: r.Path,
				SHA256: r.BSHA256, Size: r.BSize, ExpectDest: r.BaseSHA256,
				Reason: string(ClassChangedB),
			})
		case ClassConflict:
			p.resolveConflict(r)
		case ClassDeletedA, ClassDeletedB, ClassDeletedAll:
			p.recordDeletion(r)
		}
	}
	sortOps(p.Ops)

	// 4. Tally.
	p.Counts = tally(p)
	return p
}

// recordDeletion states plainly what happens to a deletion: nothing.
func (p *Plan) recordDeletion(r *Record) {
	d := Deletion{Path: r.Path, OtherChanged: r.OtherChanged}
	switch r.Class {
	case ClassDeletedA:
		d.MissingOn = "A"
		d.SHA256, d.Size = r.BSHA256, r.BSize
		d.Note = "gone from side A, still on side B; the deletion is NOT propagated and side B's copy is left untouched"
		if r.OtherChanged {
			d.Note = "gone from side A while side B edited it; nothing is deleted - decide by hand which you want"
		}
	case ClassDeletedB:
		d.MissingOn = "B"
		d.SHA256, d.Size = r.ASHA256, r.ASize
		d.Note = "gone from side B, still on side A; the deletion is NOT propagated and side A's copy is left untouched"
		if r.OtherChanged {
			d.Note = "gone from side B while side A edited it; nothing is deleted - decide by hand which you want"
		}
	case ClassDeletedAll:
		d.MissingOn = "both"
		d.Note = "gone from both sides since the baseline; recorded for the report only, nothing to do"
	}
	if r.Note != "" {
		// A rename explains the disappearance; say so instead of alarming anyone.
		d.Note = r.Note + " - the bytes are still on both sides, nothing was removed"
	}
	p.Deletions = append(p.Deletions, d)
}

// resolveConflict applies the stated policy to a changed-on-both-different
// path. No policy means: report it and skip it. No policy silently loses data.
func (p *Plan) resolveConflict(r *Record) {
	c := Conflict{
		Path: r.Path, BaseSHA256: r.BaseSHA256,
		ASHA256: r.ASHA256, BSHA256: r.BSHA256,
		ASize: r.ASize, BSize: r.BSize,
		Policy: p.Policy,
	}
	switch p.Policy {
	case PolicyNone:
		c.Resolution = "skipped - no --conflict policy given, so both versions stay exactly where they are"

	case PolicyKeepBoth:
		nameA := p.pickSuffixName(r.Path, "a", r.ASHA256)
		nameB := p.pickSuffixName(r.Path, "b", r.BSHA256)
		c.KeptAs = []string{nameA, nameB}
		c.Resolution = fmt.Sprintf("both versions written to both sides as %s and %s; the original path is left untouched on each side", nameA, nameB)
		p.Ops = append(p.Ops,
			Op{Kind: "preserve", Phase: PhasePreserve,
				SrcSide: "A", SrcPath: r.Path, DstSide: "A", DstPath: nameA,
				SHA256: r.ASHA256, Size: r.ASize, Reason: "conflict:keep-both"},
			Op{Kind: "copy", Phase: PhasePreserve,
				SrcSide: "A", SrcPath: r.Path, DstSide: "B", DstPath: nameA,
				SHA256: r.ASHA256, Size: r.ASize, Reason: "conflict:keep-both"},
			Op{Kind: "preserve", Phase: PhasePreserve,
				SrcSide: "B", SrcPath: r.Path, DstSide: "B", DstPath: nameB,
				SHA256: r.BSHA256, Size: r.BSize, Reason: "conflict:keep-both"},
			Op{Kind: "copy", Phase: PhasePreserve,
				SrcSide: "B", SrcPath: r.Path, DstSide: "A", DstPath: nameB,
				SHA256: r.BSHA256, Size: r.BSize, Reason: "conflict:keep-both"},
		)

	case PolicyPreferA, PolicyPreferB:
		winner, loser := "A", "B"
		winHash, winSize := r.ASHA256, r.ASize
		loseHash, loseSize := r.BSHA256, r.BSize
		tag := "b"
		if p.Policy == PolicyPreferB {
			winner, loser = "B", "A"
			winHash, winSize = r.BSHA256, r.BSize
			loseHash, loseSize = r.ASHA256, r.ASize
			tag = "a"
		}
		kept := p.pickSuffixName(r.Path, tag, loseHash)
		c.KeptAs = []string{kept}
		c.Resolution = fmt.Sprintf("side %s wins at %s; side %s's version is preserved first on both sides as %s, then overwritten",
			winner, r.Path, loser, kept)
		p.Ops = append(p.Ops,
			// Preserve the losing version on its own side and on the other one,
			// BEFORE anything is overwritten. The phases guarantee that order,
			// so the losing bytes are never read from a file that has already
			// been replaced.
			Op{Kind: "preserve", Phase: PhasePreserve,
				SrcSide: loser, SrcPath: r.Path, DstSide: loser, DstPath: kept,
				SHA256: loseHash, Size: loseSize, Reason: "conflict:" + p.Policy + " (preserve losing version)"},
			Op{Kind: "copy", Phase: PhasePreserve,
				SrcSide: loser, SrcPath: r.Path, DstSide: winner, DstPath: kept,
				SHA256: loseHash, Size: loseSize, Reason: "conflict:" + p.Policy + " (preserve losing version)"},
			// Only then does the winner take the original path on the losing side.
			Op{Kind: "copy", Phase: PhaseReplace,
				SrcSide: winner, SrcPath: r.Path, DstSide: loser, DstPath: r.Path,
				SHA256: winHash, Size: winSize, ExpectDest: loseHash,
				Reason: "conflict:" + p.Policy},
		)
	}
	p.Conflicts = append(p.Conflicts, c)
}

// suffixName turns "trip/IMG_1.jpg" into "trip/IMG_1.pocketsync-a.jpg".
func suffixName(rel, tag string, n int) string {
	ext := filepath.Ext(rel)
	stem := rel[:len(rel)-len(ext)]
	if n <= 1 {
		return fmt.Sprintf("%s.%s-%s%s", stem, appName, tag, ext)
	}
	return fmt.Sprintf("%s.%s-%s-%d%s", stem, appName, tag, n, ext)
}

// pickSuffixName finds a suffixed name that is free on both sides, or already
// holds exactly the content we intend to write there (which makes a second run
// a no-op instead of a new name).
func (p *Plan) pickSuffixName(rel, tag, wantHash string) string {
	free := func(t *Tree, name string) bool {
		m := t.get(name)
		return m == nil || m.SHA256 == wantHash
	}
	for n := 1; ; n++ {
		cand := suffixName(rel, tag, n)
		if free(p.TreeA, cand) && free(p.TreeB, cand) {
			return cand
		}
	}
}

// ---------------------------------------------------------------------------
// Content identity: renames and relocations
// ---------------------------------------------------------------------------

// detectRenames pairs identical content that appears under different names, so
// a rename is reported as a rename instead of as a delete plus an add. It
// returns the set of "side:path" copies that should be suppressed because the
// content already exists on the other side under another name.
//
// Two shapes are detected:
//
//	rename-on-A   the baseline path is gone from A and its exact bytes turned
//	              up at a new path on A (side B still has the old name)
//	relocated     the same bytes are new on both sides under different names
//
// Neither shape produces a copy: the content is already safe on both sides,
// and renaming the other side would mean removing a file, which this program
// never does.
func detectRenames(p *Plan, byPath map[string]*Record) map[string]bool {
	suppress := map[string]bool{}

	newBy := func(cls Class, hashOf func(*Record) string) map[string][]string {
		m := map[string][]string{}
		for i := range p.Records {
			r := &p.Records[i]
			if r.Class == cls {
				m[hashOf(r)] = append(m[hashOf(r)], r.Path)
			}
		}
		for k := range m {
			sort.Strings(m[k])
		}
		return m
	}
	aHash := func(r *Record) string { return r.ASHA256 }
	bHash := func(r *Record) string { return r.BSHA256 }

	newOnA := newBy(ClassNewA, aHash)
	newOnB := newBy(ClassNewB, bHash)
	used := map[string]bool{}

	take := func(pool map[string][]string, hash string) (string, bool) {
		for _, cand := range pool[hash] {
			if !used[cand] {
				used[cand] = true
				return cand, true
			}
		}
		return "", false
	}

	// Same-side renames. The file is gone from ONE side (so it classifies as
	// deleted-on-that-side) and its exact bytes turned up at a new path on that
	// same side (so that path classifies as new-on-that-side).
	pairSameSide := func(delClass Class, pool map[string][]string, side string) {
		for i := range p.Records {
			r := &p.Records[i]
			if r.Class != delClass {
				continue
			}
			to, ok := take(pool, r.BaseSHA256)
			if !ok {
				continue
			}
			suppress[side+":"+to] = true
			size := int64(0)
			if rr := byPath[to]; rr != nil {
				if side == "A" {
					size = rr.ASize
				} else {
					size = rr.BSize
				}
				rr.Note = "renamed on side " + side + " from " + r.Path
			}
			other := "B"
			if side == "B" {
				other = "A"
			}
			p.Renames = append(p.Renames, Rename{
				Kind: "rename-on-" + side, From: r.Path, To: to,
				SHA256: r.BaseSHA256, Size: size,
				Comment: "renamed on side " + side + "; side " + other +
					" still has the old name, and nothing is removed from either side",
			})
			r.Note = "renamed on side " + side + " to " + to
		}
	}
	pairSameSide(ClassDeletedA, newOnA, "A")
	pairSameSide(ClassDeletedB, newOnB, "B")

	// Relocations: the same bytes are new on both sides under different names.
	for i := range p.Records {
		r := &p.Records[i]
		if r.Class != ClassNewA || used[r.Path] {
			continue
		}
		to, ok := take(newOnB, r.ASHA256)
		if !ok {
			continue
		}
		used[r.Path] = true
		suppress["A:"+r.Path] = true
		suppress["B:"+to] = true
		p.Renames = append(p.Renames, Rename{
			Kind: "relocated", From: r.Path, To: to,
			SHA256: r.ASHA256, Size: r.ASize,
			Comment: "same file, different name on each side; both sides already hold the content, so nothing is copied and nothing is removed",
		})
		r.Note = "same content as " + to + " on side B"
		if rr := byPath[to]; rr != nil {
			rr.Note = "same content as " + r.Path + " on side A"
		}
	}

	sort.SliceStable(p.Renames, func(i, j int) bool {
		if p.Renames[i].From != p.Renames[j].From {
			return p.Renames[i].From < p.Renames[j].From
		}
		return p.Renames[i].To < p.Renames[j].To
	})
	return suppress
}

// ---------------------------------------------------------------------------
// Tally
// ---------------------------------------------------------------------------

func tally(p *Plan) Counts {
	var c Counts
	c.Paths = len(p.Records)
	for _, r := range p.Records {
		switch r.Class {
		case ClassIdentical:
			c.Identical++
		case ClassNewA:
			c.NewA++
		case ClassNewB:
			c.NewB++
		case ClassChangedA:
			c.ChangedA++
		case ClassChangedB:
			c.ChangedB++
		case ClassConverged:
			c.Converged++
		case ClassConflict:
			c.Conflicts++
		case ClassDeletedA:
			c.DeletedA++
		case ClassDeletedB:
			c.DeletedB++
		case ClassDeletedAll:
			c.DeletedBoth++
		}
		if r.OtherChanged {
			c.NeedDecision++
		}
	}
	c.NeedDecision += c.Conflicts
	for _, rn := range p.Renames {
		switch rn.Kind {
		case "rename-on-A":
			c.RenamesA++
		case "rename-on-B":
			c.RenamesB++
		case "relocated":
			c.Relocated++
		}
	}
	for _, op := range p.Ops {
		if op.Kind == "preserve" {
			c.PreserveOps++
			continue
		}
		c.CopyOps++
		if op.DstSide == "B" {
			c.CopyAToB++
			c.BytesAToB += op.Size
		} else {
			c.CopyBToA++
			c.BytesBToA += op.Size
		}
	}
	return c
}
