package main

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

// ---------------------------------------------------------------------------
// Ledger
//
// One JSON object per line, appended and fsynced as each item completes. This
// is the whole resume mechanism: a run that is killed leaves a ledger whose
// last line is the last item that genuinely landed and verified.
// ---------------------------------------------------------------------------

// Ledger statuses.
const (
	statusVerified = "verified"
	statusFailed   = "failed"
)

// LedgerEntry is one completed item.
type LedgerEntry struct {
	TS      time.Time `json:"ts"`
	Src     string    `json:"src"`
	Dst     string    `json:"dst"`
	Rel     string    `json:"rel"`
	DestRel string    `json:"dest_rel"`
	Bytes   int64     `json:"bytes"`
	SHA256  string    `json:"sha256"`
	Class   string    `json:"class"`
	Status  string    `json:"status"`
	Detail  string    `json:"detail,omitempty"`
}

// Ledger is an append-only writer that fsyncs every line.
type Ledger struct {
	path string
	f    *os.File
}

func openLedger(path string) (*Ledger, error) {
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return nil, fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return nil, fmt.Errorf("cannot open ledger %s: %w", path, err)
	}
	return &Ledger{path: path, f: f}, nil
}

// append writes one line and fsyncs it. The fsync is the point: without it a
// killed process can lose the record of work that actually completed, and the
// resume would redo it or, worse, believe an unfinished item was done.
func (l *Ledger) append(e LedgerEntry) error {
	line, err := json.Marshal(e)
	if err != nil {
		return fmt.Errorf("cannot encode ledger entry: %w", err)
	}
	if _, err := l.f.Write(append(line, '\n')); err != nil {
		return fmt.Errorf("cannot append to ledger %s: %w", l.path, err)
	}
	return l.f.Sync()
}

func (l *Ledger) Close() error { return l.f.Close() }

var errNoLedger = errors.New("no ledger file")

func loadLedger(path string) ([]LedgerEntry, error) {
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, errNoLedger
		}
		return nil, fmt.Errorf("cannot read ledger %s: %w", path, err)
	}
	defer f.Close()

	var lines []string
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
	for sc.Scan() {
		lines = append(lines, strings.TrimSpace(sc.Text()))
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("cannot read ledger %s: %w", path, err)
	}

	var out []LedgerEntry
	for i, text := range lines {
		if text == "" {
			continue
		}
		var e LedgerEntry
		if err := json.Unmarshal([]byte(text), &e); err != nil {
			// A process killed mid-write can leave a torn FINAL line. That one
			// is discarded so the run can still resume; a corrupt line anywhere
			// else means the file is not what we think it is, and we refuse.
			if i == len(lines)-1 {
				break
			}
			return nil, fmt.Errorf("ledger %s line %d is not valid JSON: %w", path, i+1, err)
		}
		out = append(out, e)
	}
	return out, nil
}

// ---------------------------------------------------------------------------
// Verified copy
// ---------------------------------------------------------------------------

var errHashMismatch = errors.New("hash mismatch")

// copyVerified streams src to dst through a SHA-256 hash, fsyncs, compares the
// result against want, and only then renames the temporary NAME.part into
// place. On mismatch the partial file is removed and errHashMismatch is
// returned; nothing is ever written over an existing destination file here,
// because the caller has already routed collisions to a free name.
//
// src is opened read-only. This function contains the only writes movephone
// ever performs to file contents, and every one of them targets dst.
func copyVerified(src, dst, want string, mtime time.Time) (int64, string, error) {
	in, err := os.Open(src) // read-only, always
	if err != nil {
		return 0, "", err
	}
	defer in.Close()

	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return 0, "", err
	}
	part := dst + ".part"
	out, err := os.OpenFile(part, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
	if err != nil {
		return 0, "", err
	}

	h := sha256.New()
	n, err := io.Copy(io.MultiWriter(out, h), in)
	if err != nil {
		out.Close()
		os.Remove(part)
		return n, "", err
	}
	if err := out.Sync(); err != nil {
		out.Close()
		os.Remove(part)
		return n, "", err
	}
	if err := out.Close(); err != nil {
		os.Remove(part)
		return n, "", err
	}

	got := hex.EncodeToString(h.Sum(nil))
	if got != want {
		os.Remove(part)
		return n, got, fmt.Errorf("%w: source hashed %s, copy hashed %s", errHashMismatch, short(want), short(got))
	}

	if err := os.Rename(part, dst); err != nil {
		os.Remove(part)
		return n, got, err
	}
	if !mtime.IsZero() {
		// Best effort: a destination filesystem that refuses timestamps must
		// not fail an otherwise verified transfer.
		_ = os.Chtimes(dst, mtime, mtime)
	}
	syncDir(filepath.Dir(dst))
	return n, got, nil
}

// syncDir flushes a directory entry so a completed rename survives power loss.
// Not every platform or filesystem supports it; failure is not an error.
func syncDir(dir string) {
	d, err := os.Open(dir)
	if err != nil {
		return
	}
	_ = d.Sync()
	d.Close()
}

// hashFile computes the SHA-256 of an existing file.
func hashFile(path string) (string, int64, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0, err
	}
	defer f.Close()
	h := sha256.New()
	n, err := io.Copy(h, f)
	if err != nil {
		return "", n, err
	}
	return hex.EncodeToString(h.Sum(nil)), n, nil
}

// ---------------------------------------------------------------------------
// Transfer
// ---------------------------------------------------------------------------

// Outcome is what actually happened to one planned item.
type Outcome struct {
	Rel     string `json:"rel"`
	DestRel string `json:"dest_rel"`
	Bytes   int64  `json:"bytes"`
	SHA256  string `json:"sha256"`
	Class   string `json:"class"`
	Result  string `json:"result"`
	Detail  string `json:"detail,omitempty"`
}

// Outcome results.
const (
	resTransferred = "transferred"
	resSkipIdent   = "skipped-identical"
	resResumed     = "skipped-resumed"
	resFailed      = "failed"
	resWould       = "would-transfer"
)

// TransferReport is the result of a transfer run, dry or applied.
type TransferReport struct {
	Source         string    `json:"source"`
	Dest           string    `json:"dest"`
	LedgerPath     string    `json:"ledger"`
	Applied        bool      `json:"applied"`
	DryRun         bool      `json:"dry_run"`
	Planned        int       `json:"planned_transfers"`
	PlannedBytes   int64     `json:"planned_bytes"`
	Transferred    int       `json:"transferred"`
	TransferBytes  int64     `json:"transferred_bytes"`
	SkipIdentical  int       `json:"skipped_identical"`
	Resumed        int       `json:"skipped_resumed"`
	ResumedBytes   int64     `json:"skipped_resumed_bytes"`
	Collisions     int       `json:"collisions_handled"`
	Duplicates     int       `json:"duplicates_within_source"`
	Failed         int       `json:"failed"`
	LedgerDrift    int       `json:"ledger_drift_retransferred"`
	Seconds        float64   `json:"elapsed_seconds"`
	Rate           float64   `json:"achieved_bytes_per_sec"`
	Estimate       *Estimate `json:"estimate,omitempty"`
	Outcomes       []Outcome `json:"items"`
	LedgerPrevious int       `json:"ledger_entries_before_run"`
}

// runTransfer executes (or dry-runs) a plan. The plan must already have been
// built with the ledger (see ownedFromLedger), which is what tells resumed items
// apart from files that merely happen to match.
func runTransfer(p *Plan, srcRoot, dstRoot, ledgerPath string, priorEntries int, apply bool) (*TransferReport, error) {
	rep := &TransferReport{
		Source:         srcRoot,
		Dest:           dstRoot,
		LedgerPath:     ledgerPath,
		Applied:        apply,
		DryRun:         !apply,
		Planned:        p.MoveFiles,
		LedgerPrevious: priorEntries,
	}

	var lg *Ledger
	if apply {
		if err := os.MkdirAll(dstRoot, 0o755); err != nil {
			return nil, fmt.Errorf("cannot create destination %s: %w", dstRoot, err)
		}
		var err error
		lg, err = openLedger(ledgerPath)
		if err != nil {
			return nil, err
		}
		defer lg.Close()
	}

	start := time.Now()
	for _, it := range p.Items {
		switch it.Class {
		case classCollision:
			rep.Collisions++
		case classDupSource:
			rep.Duplicates++
		}
		if it.Repair {
			rep.LedgerDrift++
		}
		if it.Action == "skip" {
			// An owned skip is a resume: the ledger recorded it and the
			// destination inventory just re-hashed it and agreed.
			if it.Owned {
				rep.Resumed++
				rep.ResumedBytes += it.Bytes
				rep.Outcomes = append(rep.Outcomes, Outcome{
					Rel: it.Rel, DestRel: it.DestRel, Bytes: it.Bytes, SHA256: it.SHA256,
					Class: it.Class, Result: resResumed, Detail: it.Note,
				})
				continue
			}
			rep.SkipIdentical++
			rep.Outcomes = append(rep.Outcomes, Outcome{
				Rel: it.Rel, DestRel: it.DestRel, Bytes: it.Bytes, SHA256: it.SHA256,
				Class: it.Class, Result: resSkipIdent, Detail: it.Note,
			})
			continue
		}
		rep.PlannedBytes += it.Bytes

		if !apply {
			rep.Outcomes = append(rep.Outcomes, Outcome{
				Rel: it.Rel, DestRel: it.DestRel, Bytes: it.Bytes, SHA256: it.SHA256,
				Class: it.Class, Result: resWould, Detail: it.Note,
			})
			continue
		}

		srcPath := filepath.Join(srcRoot, filepath.FromSlash(it.Rel))
		dstPath := filepath.Join(dstRoot, filepath.FromSlash(it.DestRel))
		var mtime time.Time
		if info, serr := os.Stat(srcPath); serr == nil {
			mtime = info.ModTime()
		}
		n, got, cerr := copyVerified(srcPath, dstPath, it.SHA256, mtime)
		if cerr != nil {
			rep.Failed++
			oc := Outcome{
				Rel: it.Rel, DestRel: it.DestRel, Bytes: it.Bytes, SHA256: it.SHA256,
				Class: it.Class, Result: resFailed, Detail: cerr.Error(),
			}
			rep.Outcomes = append(rep.Outcomes, oc)
			if lerr := lg.append(LedgerEntry{
				TS: time.Now().UTC(), Src: srcRoot, Dst: dstRoot, Rel: it.Rel,
				DestRel: it.DestRel, Bytes: n, SHA256: got, Class: it.Class,
				Status: statusFailed, Detail: cerr.Error(),
			}); lerr != nil {
				return rep, lerr
			}
			continue // one bad file does not stop the move
		}
		rep.Transferred++
		rep.TransferBytes += n
		rep.Outcomes = append(rep.Outcomes, Outcome{
			Rel: it.Rel, DestRel: it.DestRel, Bytes: n, SHA256: got,
			Class: it.Class, Result: resTransferred, Detail: it.Note,
		})
		if lerr := lg.append(LedgerEntry{
			TS: time.Now().UTC(), Src: srcRoot, Dst: dstRoot, Rel: it.Rel,
			DestRel: it.DestRel, Bytes: n, SHA256: got, Class: it.Class,
			Status: statusVerified,
		}); lerr != nil {
			return rep, lerr
		}
	}
	rep.Seconds = time.Since(start).Seconds()
	if apply && rep.Seconds > 0 && rep.TransferBytes > 0 {
		rep.Rate = float64(rep.TransferBytes) / rep.Seconds
	}
	return rep, nil
}

// ---------------------------------------------------------------------------
// transfer command
// ---------------------------------------------------------------------------

func cmdTransfer(argv []string) {
	fs := newFlagSet("transfer")
	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", "", "append-only JSON-lines ledger")
	fs.StringVar(ledger, "l", "", "shorthand for --ledger")
	apply := fs.Bool("apply", false, "actually perform the transfer")
	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("transfer needs --src <old-phone> and --dst <new-phone>")
	}
	if *ledger == "" {
		usageErr("transfer needs --ledger <file.jsonl> so the run can be resumed and verified")
	}

	srcRoot, dstRoot, srcInv, dstInv := loadPair(*src, *dst)
	ledgerAbs, err := filepath.Abs(*ledger)
	if err != nil {
		fail("cannot resolve ledger %q: %v", *ledger, err)
	}
	if isUnder(ledgerAbs, srcRoot) {
		fail("ledger %s is inside the source tree; the source is never written to", ledgerAbs)
	}

	prior, err := loadLedger(ledgerAbs)
	if err != nil && !errors.Is(err, errNoLedger) {
		fail("%v", err)
	}
	p := buildPlan(srcInv, dstInv, ownedFromLedger(prior, srcRoot, dstRoot))
	p.DestExists = dirExists(dstRoot)
	if !*apply {
		p.Estimate = estimateFor(p.MoveBytes, srcInv, dstRoot)
	}

	rep, err := runTransfer(p, srcRoot, dstRoot, ledgerAbs, len(prior), *apply)
	if err != nil {
		fail("%v", err)
	}
	if !*apply {
		e := p.Estimate
		rep.Estimate = &e
	}

	if *asJSON {
		emitJSON(rep)
	} else if *apply {
		printTransfer(rep)
	} else {
		printPlan(p, "MovePhone transfer - DRY RUN")
		printTransfer(rep)
	}
	if rep.Failed > 0 {
		os.Exit(2)
	}
}

func printTransfer(rep *TransferReport) {
	if rep.DryRun {
		fmt.Printf("DRY RUN - nothing was written to %s\n", rep.Dest)
		fmt.Printf("  would transfer      : %d files, %s\n", rep.Planned-rep.Resumed, humanBytes(rep.PlannedBytes-rep.ResumedBytes))
		if rep.LedgerPrevious > 0 {
			fmt.Printf("  ledger              : %s (%d entries)\n", rep.LedgerPath, rep.LedgerPrevious)
			fmt.Printf("  already verified    : %d files, %s would be skipped on resume\n", rep.Resumed, humanBytes(rep.ResumedBytes))
		}
		fmt.Printf("  already identical   : %d files\n", rep.SkipIdentical)
		fmt.Printf("  collisions to route : %d\n", rep.Collisions)
		fmt.Println()
		fmt.Printf("Re-run with --apply to execute.\n")
		return
	}

	fmt.Printf("MovePhone transfer complete\n")
	fmt.Printf("source    : %s\n", rep.Source)
	fmt.Printf("dest      : %s\n", rep.Dest)
	fmt.Printf("ledger    : %s (%d entries before this run)\n", rep.LedgerPath, rep.LedgerPrevious)
	fmt.Println()
	fmt.Printf("transferred      : %d files, %s (%d bytes), all hash-verified\n",
		rep.Transferred, humanBytes(rep.TransferBytes), rep.TransferBytes)
	fmt.Printf("skipped (resumed): %d files, %s already recorded verified in the ledger\n",
		rep.Resumed, humanBytes(rep.ResumedBytes))
	fmt.Printf("skipped (same)   : %d files already identical at the destination\n", rep.SkipIdentical)
	fmt.Printf("collisions       : %d renamed, 0 overwritten\n", rep.Collisions)
	fmt.Printf("duplicates       : %d source files were byte-identical to an earlier one\n", rep.Duplicates)
	fmt.Printf("ledger drift     : %d re-transferred because the destination no longer matched\n", rep.LedgerDrift)
	fmt.Printf("failed           : %d\n", rep.Failed)
	fmt.Printf("elapsed          : %s (%s achieved)\n",
		humanDuration(time.Duration(rep.Seconds*float64(time.Second))), humanRate(rep.Rate))
	fmt.Println()

	if rep.Collisions > 0 {
		fmt.Println("COLLISIONS HANDLED")
		for _, o := range rep.Outcomes {
			if o.Class == classCollision {
				fmt.Printf("  %s\n      -> %s\n", o.Rel, o.DestRel)
			}
		}
		fmt.Println()
	}
	if rep.Failed > 0 {
		fmt.Printf("FAILURES (%d) - these files did NOT land; the source is untouched\n", rep.Failed)
		for _, o := range rep.Outcomes {
			if o.Result == resFailed {
				fmt.Printf("  %s\n      %s\n", o.Rel, o.Detail)
			}
		}
		fmt.Println()
	}
}
