package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// ---------------------------------------------------------------------------
// Plan classification
// ---------------------------------------------------------------------------

// The four classes every source item falls into.
const (
	classNew       = "new"
	classIdentical = "already-present-identical"
	classCollision = "name-collision-different-content"
	classDupSource = "duplicate-within-source"
)

// collisionMarker is the documented disambiguating infix. An incoming file that
// would land on an existing name holding DIFFERENT content is written as
//
//	NAME.movephone-<first 8 hex of its SHA-256><.EXT>
//
// The existing file is never touched.
const collisionMarker = ".movephone-"

// PlanItem is one source file and what will happen to it.
type PlanItem struct {
	Rel        string `json:"rel"`
	DestRel    string `json:"dest_rel"`
	Bytes      int64  `json:"bytes"`
	SHA256     string `json:"sha256"`
	Kind       string `json:"kind"`
	Category   string `json:"category"`
	DetectedBy string `json:"detected_by"`
	Class      string `json:"class"`
	Action     string `json:"action"`           // "transfer" or "skip"
	Owned      bool   `json:"owned,omitempty"`  // this ledger recorded us writing DestRel
	Repair     bool   `json:"repair,omitempty"` // owned, but the destination no longer matches
	Note       string `json:"note,omitempty"`
}

// Estimate is a transfer-time projection built from measured throughput, never
// from a hard-coded constant.
type Estimate struct {
	ReadRate      float64 `json:"read_hash_bytes_per_sec"`
	ReadSample    int64   `json:"read_sample_bytes"`
	WriteRate     float64 `json:"write_sync_bytes_per_sec"`
	WriteSample   int64   `json:"write_probe_bytes"`
	WriteProbedAt string  `json:"write_probe_dir,omitempty"`
	WriteNote     string  `json:"write_probe_note,omitempty"`
	Effective     float64 `json:"effective_bytes_per_sec"`
	Seconds       float64 `json:"estimated_seconds"`
	Human         string  `json:"estimated_human"`
	Basis         string  `json:"basis"`
}

// Plan is the whole comparison of one source tree against one destination.
type Plan struct {
	Source        string     `json:"source"`
	Dest          string     `json:"dest"`
	DestExists    bool       `json:"dest_exists"`
	SourceFiles   int        `json:"source_files"`
	SourceBytes   int64      `json:"source_bytes"`
	DestFiles     int        `json:"dest_files"`
	DestBytes     int64      `json:"dest_bytes"`
	New           int        `json:"new"`
	Identical     int        `json:"already_present_identical"`
	Collisions    int        `json:"name_collisions"`
	Duplicates    int        `json:"duplicates_within_source"`
	MoveFiles     int        `json:"files_to_move"`
	MoveBytes     int64      `json:"bytes_to_move"`
	MoveHuman     string     `json:"bytes_to_move_human"`
	SkipBytes     int64      `json:"bytes_already_present"`
	RedundantMove int64      `json:"redundant_bytes_in_move"`
	ResumeSkips   int        `json:"resume_skips"`
	ResumeBytes   int64      `json:"resume_skip_bytes"`
	Repairs       int        `json:"repairs"`
	Estimate      Estimate   `json:"estimate"`
	Items         []PlanItem `json:"items"`
}

// ownedFromLedger indexes, by SOURCE relative path, the destination files this
// exact src->dst pair already recorded as verified. Those files are OUR output:
// on a resume they are ours to skip or to repair, and they are emphatically not
// third-party name collisions.
func ownedFromLedger(entries []LedgerEntry, srcRoot, dstRoot string) map[string]LedgerEntry {
	owned := map[string]LedgerEntry{}
	for _, e := range entries {
		if e.Status != statusVerified || e.Src != srcRoot || e.Dst != dstRoot {
			continue
		}
		owned[e.Rel] = e // a later entry supersedes an earlier one
	}
	return owned
}

// buildPlan classifies every source item against the destination inventory.
// owned may be nil; when it is not, it is the ledger's record of what a previous
// run of this same src->dst pair already put in place.
//
// Precedence, in order:
//
//  0. The ledger records that we already wrote this source item, with this
//     content, to a known destination name. That file is ours. If it still
//     hashes correctly the item is already-present-identical and is skipped on
//     resume; if it is gone or has drifted it is REPAIRED at the same name.
//     Repairing our own recorded output is the one case where an existing
//     destination file is replaced, and it is only ever reached through the
//     ledger.
//
//  1. Something else already occupies the same relative path in the destination.
//     Same hash -> already-present-identical (nothing to do).
//     Different hash -> name-collision-different-content. The incoming file is
//     given a disambiguating name; the existing file is never overwritten. If
//     the disambiguated name ALREADY holds this exact content (because a
//     previous run put it there) the item is reported as already-present
//     instead, which is what makes re-planning idempotent.
//
//  2. Otherwise, if this exact content already appeared at an EARLIER relative
//     path in the source, the item is duplicate-within-source. It is still
//     transferred - it is a real file at a real path on the old phone - but it
//     is counted separately so you can see how much of the move is redundant.
//
//  3. Otherwise the item is new.
func buildPlan(srcInv, dstInv *Inventory, owned map[string]LedgerEntry) *Plan {
	p := &Plan{
		Source:      srcInv.Root,
		Dest:        dstInv.Root,
		SourceFiles: srcInv.Files,
		SourceBytes: srcInv.Bytes,
		DestFiles:   dstInv.Files,
		DestBytes:   dstInv.Bytes,
	}
	destByRel := dstInv.byRel()
	// taken tracks destination names claimed by this plan, so two colliding
	// source files cannot be routed to the same disambiguated name.
	taken := map[string]bool{}
	for rel := range destByRel {
		taken[rel] = true
	}
	firstSeen := map[string]string{}

	for _, it := range srcInv.Items {
		pi := PlanItem{
			Rel:        it.Rel,
			DestRel:    it.Rel,
			Bytes:      it.Bytes,
			SHA256:     it.SHA256,
			Kind:       it.Kind,
			Category:   it.Category,
			DetectedBy: it.DetectedBy,
		}
		prior, isOwned := owned[it.Rel]
		switch existing, occupied := destByRel[it.Rel]; {
		case isOwned && prior.SHA256 == it.SHA256:
			// Step 0: our own recorded output. Judge it by re-hashing, which
			// the destination inventory has already done for us.
			pi.Owned = true
			pi.DestRel = prior.DestRel
			landed, present := destByRel[prior.DestRel]
			switch {
			case present && landed.SHA256 == it.SHA256:
				pi.Class = classIdentical
				pi.Action = "skip"
				pi.Note = "transferred by an earlier run and the destination still hashes correctly"
			case present:
				pi.Class = classNew
				pi.Action = "transfer"
				pi.Repair = true
				pi.Note = fmt.Sprintf("the ledger recorded this as verified, but %s now hashes %s instead of %s; repairing in place",
					prior.DestRel, short(landed.SHA256), short(it.SHA256))
			default:
				pi.Class = classNew
				pi.Action = "transfer"
				pi.Repair = true
				pi.Note = "the ledger recorded this as verified, but " + prior.DestRel + " is no longer there; re-transferring"
			}
			taken[pi.DestRel] = true
		case occupied && existing.SHA256 == it.SHA256:
			pi.Class = classIdentical
			pi.Action = "skip"
			pi.Note = "destination already holds identical content"
		case occupied:
			alt := disambiguate(it.Rel, it.SHA256)
			if prev, ok := destByRel[alt]; ok && prev.SHA256 == it.SHA256 {
				pi.Class = classIdentical
				pi.DestRel = alt
				pi.Action = "skip"
				pi.Note = "already present under the disambiguated name " + alt
				break
			}
			alt = freeName(alt, it.SHA256, destByRel, taken)
			taken[alt] = true
			pi.Class = classCollision
			pi.DestRel = alt
			pi.Action = "transfer"
			pi.Note = fmt.Sprintf("destination %s holds different content (%s); writing to %s instead, original untouched",
				it.Rel, short(existing.SHA256), alt)
		default:
			taken[it.Rel] = true
			if prev, dup := firstSeen[it.SHA256]; dup {
				pi.Class = classDupSource
				pi.Action = "transfer"
				pi.Note = "byte-identical to " + prev + " in the source"
			} else {
				pi.Class = classNew
				pi.Action = "transfer"
			}
		}
		if _, ok := firstSeen[it.SHA256]; !ok {
			firstSeen[it.SHA256] = it.Rel
		}

		switch pi.Class {
		case classNew:
			p.New++
		case classIdentical:
			p.Identical++
		case classCollision:
			p.Collisions++
		case classDupSource:
			p.Duplicates++
			p.RedundantMove += pi.Bytes
		}
		if pi.Repair {
			p.Repairs++
		}
		switch {
		case pi.Action == "transfer":
			p.MoveFiles++
			p.MoveBytes += pi.Bytes
		case pi.Owned:
			p.ResumeSkips++
			p.ResumeBytes += pi.Bytes
		default:
			p.SkipBytes += pi.Bytes
		}
		p.Items = append(p.Items, pi)
	}
	p.MoveHuman = humanBytes(p.MoveBytes)
	return p
}

func short(hash string) string {
	if len(hash) > 8 {
		return hash[:8]
	}
	return hash
}

// disambiguate builds the collision-safe destination name, keeping the original
// extension so the file still opens on the new phone.
//
//	DCIM/IMG_0001.JPG + hash 3a7f2b91... -> DCIM/IMG_0001.movephone-3a7f2b91.JPG
func disambiguate(rel, hash string) string {
	dir := ""
	base := rel
	if i := strings.LastIndex(rel, "/"); i >= 0 {
		dir, base = rel[:i+1], rel[i+1:]
	}
	ext := filepath.Ext(base)
	stem := strings.TrimSuffix(base, ext)
	return dir + stem + collisionMarker + short(hash) + ext
}

// freeName walks -2, -3, ... until the name is unclaimed. It is only reached if
// two different contents produce the same first-8-hex prefix, which is why the
// suffix is deterministic first and numbered only as a fallback.
func freeName(alt, hash string, dest map[string]Item, taken map[string]bool) string {
	if !taken[alt] {
		return alt
	}
	ext := filepath.Ext(alt)
	stem := strings.TrimSuffix(alt, ext)
	for n := 2; ; n++ {
		cand := fmt.Sprintf("%s-%d%s", stem, n, ext)
		if existing, ok := dest[cand]; ok && existing.SHA256 == hash {
			return cand
		}
		if !taken[cand] {
			return cand
		}
	}
}

// ---------------------------------------------------------------------------
// Measured throughput
// ---------------------------------------------------------------------------

// probeBytes is the size of the write probe. Large enough to get past the first
// write syscall and an fsync, small enough to be free on any real device.
const probeBytes = 8 << 20

// measureWrite writes a probe file into the destination directory (or, if the
// destination does not exist yet, into its nearest existing ancestor), fsyncs
// it, times the whole thing, and then REMOVES it. Nothing of the probe survives
// the call, which is what lets `plan` measure real write throughput while still
// leaving the destination unchanged.
func measureWrite(dst string) (rate float64, where string, note string) {
	dir := nearestExisting(dst)
	if dir == "" {
		return 0, "", "no existing directory to probe"
	}
	f, err := os.CreateTemp(dir, ".movephone-probe-*.tmp")
	if err != nil {
		return 0, dir, fmt.Sprintf("destination not writable (%v); estimate falls back to read speed alone", err)
	}
	name := f.Name()
	defer os.Remove(name)

	buf := make([]byte, 1<<20)
	for i := range buf {
		buf[i] = byte(i * 31)
	}
	start := time.Now()
	var written int64
	for written < probeBytes {
		n, err := f.Write(buf)
		written += int64(n)
		if err != nil {
			f.Close()
			return 0, dir, fmt.Sprintf("probe write failed (%v); estimate falls back to read speed alone", err)
		}
	}
	if err := f.Sync(); err != nil {
		f.Close()
		return 0, dir, fmt.Sprintf("probe fsync failed (%v); estimate falls back to read speed alone", err)
	}
	elapsed := time.Since(start).Seconds()
	f.Close()
	if elapsed <= 0 {
		return 0, dir, "probe completed too fast to time"
	}
	return float64(written) / elapsed, dir, ""
}

func nearestExisting(p string) string {
	for {
		if info, err := os.Stat(p); err == nil && info.IsDir() {
			return p
		}
		parent := filepath.Dir(p)
		if parent == p {
			return ""
		}
		p = parent
	}
}

// estimateFor derives a transfer time from two measurements: how fast the
// source actually read and hashed during the inventory just performed, and how
// fast a probe wrote and fsynced into the destination. A verified copy does
// both in series per file, so the rates compose as resistances:
//
//	1/effective = 1/read + 1/write
func estimateFor(bytes int64, srcInv *Inventory, dst string) Estimate {
	e := Estimate{
		ReadRate:    srcInv.ReadRate,
		ReadSample:  srcInv.Bytes,
		WriteSample: probeBytes,
	}
	rate, where, note := measureWrite(dst)
	e.WriteRate, e.WriteProbedAt, e.WriteNote = rate, where, note
	if rate <= 0 {
		e.WriteSample = 0
	}

	switch {
	case e.ReadRate > 0 && e.WriteRate > 0:
		e.Effective = 1 / (1/e.ReadRate + 1/e.WriteRate)
		e.Basis = "measured read+hash of the source and a measured write+fsync probe in the destination"
	case e.ReadRate > 0:
		e.Effective = e.ReadRate
		e.Basis = "measured read+hash of the source only; the destination could not be probed, so the write side is unaccounted for and the estimate is optimistic"
	default:
		e.Basis = "not enough measured data to estimate"
		return e
	}
	e.Seconds = float64(bytes) / e.Effective
	e.Human = humanDuration(time.Duration(e.Seconds * float64(time.Second)))
	return e
}

// ---------------------------------------------------------------------------
// plan command
// ---------------------------------------------------------------------------

func cmdPlan(argv []string) {
	fs := newFlagSet("plan")
	src := fs.String("src", "", "old phone tree")
	fs.StringVar(src, "s", "", "shorthand for --src")
	dst := fs.String("dst", "", "new phone tree")
	fs.StringVar(dst, "d", "", "shorthand for --dst")
	ledger := fs.String("ledger", "", "optional ledger, to plan a resume")
	fs.StringVar(ledger, "l", "", "shorthand for --ledger")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	fillPositional(fs.Args(), src, dst)
	if *src == "" || *dst == "" {
		usageErr("plan needs --src <old-phone> and --dst <new-phone>")
	}

	srcRoot, dstRoot, srcInv, dstInv := loadPair(*src, *dst)
	var owned map[string]LedgerEntry
	if *ledger != "" {
		abs, err := filepath.Abs(*ledger)
		if err != nil {
			fail("cannot resolve ledger %q: %v", *ledger, err)
		}
		entries, err := loadLedger(abs)
		if err != nil && !errors.Is(err, errNoLedger) {
			fail("%v", err)
		}
		owned = ownedFromLedger(entries, srcRoot, dstRoot)
	}
	p := buildPlan(srcInv, dstInv, owned)
	p.DestExists = dirExists(dstRoot)
	p.Estimate = estimateFor(p.MoveBytes, srcInv, dstRoot)

	if *asJSON {
		emitJSON(p)
		return
	}
	printPlan(p, "MovePhone transfer plan")
	fmt.Println("Nothing has been written. Run `" + appName + " transfer --src ... --dst ... --ledger l.jsonl --apply` to execute.")
}

// fillPositional maps bare positional arguments onto --src then --dst.
func fillPositional(args []string, src, dst *string) {
	for _, a := range args {
		switch {
		case *src == "":
			*src = a
		case *dst == "":
			*dst = a
		}
	}
}

func dirExists(p string) bool {
	info, err := os.Stat(p)
	return err == nil && info.IsDir()
}

// loadPair resolves both roots and inventories them. The destination is allowed
// not to exist yet; it is then treated as an empty tree.
func loadPair(src, dst string) (string, string, *Inventory, *Inventory) {
	srcRoot := resolveDir("source", src)
	dstRoot, err := filepath.Abs(dst)
	if err != nil {
		fail("cannot resolve destination %q: %v", dst, err)
	}
	if err := checkRoots(srcRoot, dstRoot); err != nil {
		fail("%v", err)
	}
	if info, err := os.Stat(dstRoot); err == nil && !info.IsDir() {
		fail("destination %q is not a directory", dst)
	}
	srcInv, err := scanTree(srcRoot)
	if err != nil {
		fail("%v", err)
	}
	dstInv := &Inventory{Root: dstRoot}
	if dirExists(dstRoot) {
		dstInv, err = scanTree(dstRoot)
		if err != nil {
			fail("%v", err)
		}
	}
	return srcRoot, dstRoot, srcInv, dstInv
}

func printPlan(p *Plan, title string) {
	fmt.Printf("%s\n", title)
	fmt.Printf("source    : %s  (%d files, %s)\n", p.Source, p.SourceFiles, humanBytes(p.SourceBytes))
	if p.DestExists {
		fmt.Printf("dest      : %s  (%d files, %s)\n", p.Dest, p.DestFiles, humanBytes(p.DestBytes))
	} else {
		fmt.Printf("dest      : %s  (does not exist yet; it will be created)\n", p.Dest)
	}
	fmt.Println()
	fmt.Println("CLASSIFICATION")
	fmt.Printf("  %-34s %6d\n", classNew, p.New)
	fmt.Printf("  %-34s %6d\n", classIdentical, p.Identical)
	fmt.Printf("  %-34s %6d\n", classCollision, p.Collisions)
	fmt.Printf("  %-34s %6d\n", classDupSource, p.Duplicates)
	fmt.Println()
	fmt.Printf("to move   : %d files, %s (%d bytes)\n", p.MoveFiles, p.MoveHuman, p.MoveBytes)
	fmt.Printf("to skip   : %d files, %s already identical at the destination\n",
		p.Identical-p.ResumeSkips, humanBytes(p.SkipBytes))
	if p.ResumeSkips > 0 {
		fmt.Printf("resume    : %d files, %s already recorded verified in the ledger and still correct\n",
			p.ResumeSkips, humanBytes(p.ResumeBytes))
	}
	if p.Repairs > 0 {
		fmt.Printf("repairs   : %d files this ledger recorded transferring no longer match and will be rewritten\n", p.Repairs)
	}
	if p.RedundantMove > 0 {
		fmt.Printf("redundant : %s of the move is duplicate content within the source\n", humanBytes(p.RedundantMove))
	}
	fmt.Println()
	printEstimate(p.Estimate)

	if p.Collisions > 0 {
		fmt.Printf("NAME COLLISIONS (%d) - the destination file is NEVER overwritten\n", p.Collisions)
		for _, it := range p.Items {
			if it.Class == classCollision {
				fmt.Printf("  %s\n      -> %s\n", it.Rel, it.DestRel)
			}
		}
		fmt.Println()
	}
	if p.Duplicates > 0 {
		fmt.Printf("DUPLICATES WITHIN SOURCE (%d) - transferred, but redundant\n", p.Duplicates)
		for _, it := range p.Items {
			if it.Class == classDupSource {
				fmt.Printf("  %-50s %s\n", it.Rel, it.Note)
			}
		}
		fmt.Println()
	}
}

func printEstimate(e Estimate) {
	fmt.Println("TIME ESTIMATE (from measurement, not a guess)")
	fmt.Printf("  read+hash   : %s (measured over %s of source)\n", humanRate(e.ReadRate), humanBytes(e.ReadSample))
	if e.WriteRate > 0 {
		fmt.Printf("  write+fsync : %s (measured with an %s probe written to and removed from %s)\n",
			humanRate(e.WriteRate), humanBytes(e.WriteSample), e.WriteProbedAt)
	} else {
		fmt.Printf("  write+fsync : unavailable - %s\n", e.WriteNote)
	}
	fmt.Printf("  effective   : %s\n", humanRate(e.Effective))
	if e.Effective > 0 {
		fmt.Printf("  estimate    : %s (%.2f s)\n", e.Human, e.Seconds)
	} else {
		fmt.Printf("  estimate    : unavailable\n")
	}
	fmt.Printf("  basis       : %s\n", e.Basis)
	fmt.Println()
}
