// Command movephone inventories, plans, transfers and verifies a phone-to-phone
// content migration between two mounted directory trees.
package main

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

const appName = "movephone"

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

// humanRate renders a measured throughput. Zero or negative means "unknown".
func humanRate(bytesPerSec float64) string {
	if bytesPerSec <= 0 {
		return "unknown"
	}
	return humanBytes(int64(bytesPerSec)) + "/s"
}

func humanDuration(d time.Duration) string {
	if d < 0 {
		d = -d
	}
	if d < time.Second {
		return fmt.Sprintf("%d ms", d.Milliseconds())
	}
	total := int64(d.Seconds() + 0.5)
	days := total / 86400
	hours := (total % 86400) / 3600
	mins := (total % 3600) / 60
	secs := total % 60
	var parts []string
	if days > 0 {
		parts = append(parts, fmt.Sprintf("%dd", days))
	}
	if hours > 0 {
		parts = append(parts, fmt.Sprintf("%dh", hours))
	}
	if mins > 0 {
		parts = append(parts, fmt.Sprintf("%dm", mins))
	}
	if secs > 0 || len(parts) == 0 {
		parts = append(parts, fmt.Sprintf("%ds", secs))
	}
	return strings.Join(parts, " ")
}

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

// usage writes the help text to w. Explicit help goes to stdout and exits 0;
// a bad invocation sends the identical text to stderr and exits 1.
func usage(w io.Writer) {
	fmt.Fprintf(w, `%s - verified content migration between two mounted phone trees (Techlosoft Device Migration Kit)

USAGE
  %s inventory --src <dir> [--json]
  %s plan      --src <old-phone> --dst <new-phone> [--ledger <file.jsonl>] [--json]
  %s transfer  --src <old-phone> --dst <new-phone> --ledger <file.jsonl> [--apply] [--json]
  %s verify    --ledger <file.jsonl> [--json]
  %s help | -h | --help

COMMANDS
  inventory  Walk the source tree, identify every file by MAGIC BYTES (falling
             back to the filename extension only when sniffing is inconclusive,
             and saying which was used), hash it with SHA-256, and report totals
             per content class plus the dedup-adjusted size.
  plan       Inventory both trees and classify every source item as
             new / already-present-identical / name-collision-different-content /
             duplicate-within-source. Prints bytes to move and a time estimate
             derived from a MEASURED throughput sample. Changes nothing.
  transfer   Execute the plan. DRY RUN BY DEFAULT: without --apply it prints
             exactly what it would do and writes nothing. Each file is streamed
             to NAME.part while being hashed, fsynced, compared against the
             source hash, and only then renamed into place. Every completed item
             is appended to the ledger, so an interrupted run resumes.
  verify     Re-hash everything the ledger claims was transferred and report
             drift (missing, resized or altered destination files).

FLAGS
  --src <dir>      The old phone: a mounted directory tree. Opened READ-ONLY.
  --dst <dir>      The new phone: a mounted directory tree. Written only under
                   --apply.
  --ledger <file>  Append-only JSON-lines record of completed items. Required by
                   transfer and verify, optional on plan (where it shows what a
                   resume would skip). Created on first use.
  --apply          Actually perform the transfer. Without it, transfer is a dry
                   run and the destination is not touched.
  --json           Machine-readable JSON output (all four reporting commands).

Short forms -s, -d and -l are accepted for --src, --dst and --ledger.
Flags may appear before or after positional arguments; either order works.
Bare positional arguments are read as <src> then <dst>.

EXAMPLES
  %s inventory --src /mnt/oldphone
  %s plan --src /mnt/oldphone --dst /mnt/newphone
  %s transfer --src /mnt/oldphone --dst /mnt/newphone --ledger move.jsonl
  %s transfer --src /mnt/oldphone --dst /mnt/newphone --ledger move.jsonl --apply
  %s verify --ledger move.jsonl --json

EXIT CODES
  0  Success. Collisions and skips are legitimate answers, not failures.
  1  Bad invocation, or an I/O error that stopped the command.
  2  The command ran to completion but reported failures: a hash mismatch during
     transfer, or drift found by verify.
`, appName, 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
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		fmt.Fprintf(os.Stderr, "%s: no command given\n\n", appName)
		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 "inventory":
		cmdInventory(rest)
	case "plan":
		cmdPlan(rest)
	case "transfer":
		cmdTransfer(rest)
	case "verify":
		cmdVerify(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

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

var valueFlags = map[string]bool{
	"src": true, "s": true,
	"dst": true, "d": true,
	"ledger": true, "l": 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
}

// resolveDir turns a user-supplied path into an absolute path and insists it is
// an existing directory.
func resolveDir(what, p string) string {
	abs, err := filepath.Abs(p)
	if err != nil {
		fail("cannot resolve %s %q: %v", what, p, err)
	}
	info, err := os.Stat(abs)
	if err != nil {
		if os.IsNotExist(err) {
			fail("%s %q does not exist", what, p)
		}
		fail("cannot stat %s %q: %v", what, p, err)
	}
	if !info.IsDir() {
		fail("%s %q is not a directory", what, p)
	}
	return abs
}

var errOverlap = errors.New("source and destination overlap")

// checkRoots refuses source/destination pairs that overlap. A destination
// nested inside the source (or the reverse) would let a transfer feed on its
// own output, and would make the read-only guarantee on the source impossible
// to honour.
func checkRoots(src, dst string) error {
	if src == dst {
		return fmt.Errorf("%w: they are the same directory (%s)", errOverlap, src)
	}
	if isUnder(dst, src) {
		return fmt.Errorf("%w: destination %s is inside source %s", errOverlap, dst, src)
	}
	if isUnder(src, dst) {
		return fmt.Errorf("%w: source %s is inside destination %s", errOverlap, src, dst)
	}
	return nil
}

func isUnder(child, parent string) bool {
	rel, err := filepath.Rel(parent, child)
	if err != nil {
		return false
	}
	if rel == "." {
		return true
	}
	return !strings.HasPrefix(rel, "..")
}
