// Command transferforge copies a shoot folder to a destination with
// checksum verification and produces a client-deliverable JSON transfer
// manifest grouped by media type (RAW/JPEG/Video/Audio/Other).
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"
)

// ---------------------------------------------------------------------
// Shared helpers (match conventions used across the sibling tool suite)
// ---------------------------------------------------------------------

// reorderFlags works around the stdlib flag package stopping at the first
// positional argument by moving all flags (and their values) to the front.
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])
}

// ---------------------------------------------------------------------
// Media type classification
// ---------------------------------------------------------------------

var (
	rawExts   = map[string]bool{".raw": true, ".cr2": true, ".cr3": true, ".nef": true, ".arw": true, ".dng": true, ".orf": true, ".rw2": true}
	jpegExts  = map[string]bool{".jpg": true, ".jpeg": true}
	videoExts = map[string]bool{".mp4": true, ".mov": true, ".mxf": true, ".avi": true, ".mkv": true, ".braw": true}
	audioExts = map[string]bool{".wav": true, ".mp3": true, ".aac": true, ".m4a": true}
)

// groupOrder is the canonical, stable ordering used in reports.
var groupOrder = []string{"RAW", "JPEG", "Video", "Audio", "Other"}

func classify(path string) string {
	ext := strings.ToLower(filepath.Ext(path))
	switch {
	case rawExts[ext]:
		return "RAW"
	case jpegExts[ext]:
		return "JPEG"
	case videoExts[ext]:
		return "Video"
	case audioExts[ext]:
		return "Audio"
	default:
		return "Other"
	}
}

// ---------------------------------------------------------------------
// Report data model
// ---------------------------------------------------------------------

type groupStat struct {
	FileCount  int   `json:"file_count"`
	TotalBytes int64 `json:"total_bytes"`
}

type groupReport struct {
	Type       string `json:"type"`
	FileCount  int    `json:"file_count"`
	TotalBytes int64  `json:"total_bytes"`
}

type fileRecord struct {
	Path   string `json:"path"`
	Type   string `json:"type"`
	Size   int64  `json:"size"`
	SHA256 string `json:"sha256"`
	Status string `json:"status"` // copied | skipped | failed
}

type manifest struct {
	GeneratedAtUTC        string        `json:"generated_at_utc"`
	Client                string        `json:"client"`
	Job                   string        `json:"job"`
	Source                string        `json:"source"`
	Destination           string        `json:"destination"`
	Groups                []groupReport `json:"groups"`
	TotalFiles            int           `json:"total_files"`
	TotalBytes            int64         `json:"total_bytes"`
	VerifiedOK            int           `json:"verified_ok"`
	VerifyFailures        int           `json:"verify_failures"`
	SkippedAlreadyPresent int           `json:"skipped_already_present"`
	Files                 []fileRecord  `json:"files"`
}

// ---------------------------------------------------------------------
// Copy + verify engine (same technique as sibling CopySure/MoveGuard):
// copy to a temp name, re-read+re-hash the freshly written destination,
// compare against the source hash, only rename to the final name if the
// hashes match. Already-matching destination files are skipped.
// ---------------------------------------------------------------------

func hashFile(path string) (sum string, size int64, err 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 "", 0, err
	}
	return hex.EncodeToString(h.Sum(nil)), n, nil
}

func copyBytes(srcPath, dstPath string) error {
	in, err := os.Open(srcPath)
	if err != nil {
		return err
	}
	defer in.Close()

	if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
		return err
	}

	out, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}

// debugCorruptTarget is a test-only hook: when set (via the
// TRANSFERFORGE_DEBUG_CORRUPT environment variable) to a file's relative
// path, that one file's freshly-written temp copy is corrupted before
// verification, so a genuine verify failure can be reproduced on demand.
// It is empty and inert unless the operator explicitly sets the env var,
// which is not part of normal operation.
func debugCorruptTarget() string {
	return os.Getenv("TRANSFERFORGE_DEBUG_CORRUPT")
}

func maybeCorrupt(relPath, tempPath string) error {
	target := debugCorruptTarget()
	if target == "" || target != relPath {
		return nil
	}
	f, err := os.OpenFile(tempPath, os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	if _, err := f.WriteAt([]byte{0xFF}, 0); err != nil {
		return err
	}
	return nil
}

// transferOne copies (or skips) a single file and returns its record.
func transferOne(srcRoot, dstRoot, relPath string) fileRecord {
	srcPath := filepath.Join(srcRoot, relPath)
	dstPath := filepath.Join(dstRoot, relPath)
	group := classify(relPath)

	srcHash, srcSize, err := hashFile(srcPath)
	if err != nil {
		return fileRecord{Path: relPath, Type: group, Status: "failed"}
	}

	// Already present at destination with a matching hash? Skip the copy.
	if st, statErr := os.Stat(dstPath); statErr == nil && st.Size() == srcSize {
		if destHash, _, hErr := hashFile(dstPath); hErr == nil && destHash == srcHash {
			return fileRecord{Path: relPath, Type: group, Size: srcSize, SHA256: srcHash, Status: "skipped"}
		}
	}

	tempPath := dstPath + fmt.Sprintf(".transferforge-tmp-%d", time.Now().UnixNano())
	if err := copyBytes(srcPath, tempPath); err != nil {
		os.Remove(tempPath)
		return fileRecord{Path: relPath, Type: group, Size: srcSize, SHA256: srcHash, Status: "failed"}
	}

	if err := maybeCorrupt(relPath, tempPath); err != nil {
		os.Remove(tempPath)
		return fileRecord{Path: relPath, Type: group, Size: srcSize, SHA256: srcHash, Status: "failed"}
	}

	destHash, destSize, err := hashFile(tempPath)
	if err != nil || destHash != srcHash || destSize != srcSize {
		os.Remove(tempPath)
		return fileRecord{Path: relPath, Type: group, Size: srcSize, SHA256: srcHash, Status: "failed"}
	}

	if err := os.Rename(tempPath, dstPath); err != nil {
		os.Remove(tempPath)
		return fileRecord{Path: relPath, Type: group, Size: srcSize, SHA256: srcHash, Status: "failed"}
	}

	return fileRecord{Path: relPath, Type: group, Size: srcSize, SHA256: srcHash, Status: "copied"}
}

// ---------------------------------------------------------------------
// Walk + parallel dispatch
// ---------------------------------------------------------------------

func listFiles(root string) ([]string, error) {
	var rels []string
	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		rels = append(rels, filepath.ToSlash(rel))
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Strings(rels)
	return rels, nil
}

type accumulator struct {
	mu             sync.Mutex
	groups         map[string]*groupStat
	totalFiles     int
	totalBytes     int64
	verifiedOK     int
	verifyFailures int
	skipped        int
	files          []fileRecord
}

func newAccumulator() *accumulator {
	a := &accumulator{groups: make(map[string]*groupStat)}
	for _, g := range groupOrder {
		a.groups[g] = &groupStat{}
	}
	return a
}

func (a *accumulator) add(rec fileRecord) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.totalFiles++
	a.totalBytes += rec.Size
	gs := a.groups[rec.Type]
	if gs == nil {
		gs = &groupStat{}
		a.groups[rec.Type] = gs
	}
	gs.FileCount++
	gs.TotalBytes += rec.Size
	switch rec.Status {
	case "copied", "skipped":
		a.verifiedOK++
	case "failed":
		a.verifyFailures++
	}
	if rec.Status == "skipped" {
		a.skipped++
	}
	a.files = append(a.files, rec)
}

func runTransfer(srcRoot, dstRoot string, workers int) *accumulator {
	rels, err := listFiles(srcRoot)
	if err != nil {
		fmt.Fprintf(os.Stderr, "transferforge: failed to walk source: %v\n", err)
		os.Exit(1)
	}

	acc := newAccumulator()
	jobs := make(chan string)
	var wg sync.WaitGroup

	var printMu sync.Mutex
	report := func(rec fileRecord) {
		printMu.Lock()
		defer printMu.Unlock()
		tag := map[string]string{"copied": "copied", "skipped": "skip  ", "failed": "FAIL  "}[rec.Status]
		fmt.Printf("[%s] %-5s %-50s %s\n", tag, rec.Type, rec.Path, humanBytes(rec.Size))
	}

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for rel := range jobs {
				rec := transferOne(srcRoot, dstRoot, rel)
				acc.add(rec)
				report(rec)
			}
		}()
	}

	for _, rel := range rels {
		jobs <- rel
	}
	close(jobs)
	wg.Wait()

	return acc
}

// ---------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------

func usage() {
	fmt.Fprint(os.Stderr, `transferforge - checksum-verified, resumable, parallel shoot-folder transfer
with a client-deliverable JSON manifest grouped by media type.

Usage:
  transferforge transfer <src> <dst> --report report.json [--workers N] [--client NAME] [--job NAME]
  transferforge help

Flags for "transfer":
  --report PATH   Path to write the JSON transfer manifest (required)
  --workers N     Number of parallel copy workers (default 4)
  --client TEXT   Free-text client name, carried through into the report
  --job TEXT      Free-text job/shoot name, carried through into the report
`)
}

func main() {
	if len(os.Args) < 2 {
		// 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
		}
		usage()
		os.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "transfer":
		cmdTransfer(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "transferforge: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func cmdTransfer(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			usage()
			return
		}
	}

	valueFlags := map[string]bool{"report": true, "workers": true, "client": true, "job": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("transfer", flag.ExitOnError)
	report := fs.String("report", "", "path to write the JSON transfer manifest (required)")
	workers := fs.Int("workers", 4, "number of parallel copy workers")
	client := fs.String("client", "", "free-text client name")
	job := fs.String("job", "", "free-text job/shoot name")
	fs.Usage = usage
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}

	positional := fs.Args()
	if len(positional) < 2 {
		fmt.Fprintln(os.Stderr, "transferforge: transfer requires <src> and <dst>")
		usage()
		os.Exit(1)
	}
	if *report == "" {
		fmt.Fprintln(os.Stderr, "transferforge: --report is required")
		usage()
		os.Exit(1)
	}
	if *workers < 1 {
		*workers = 1
	}

	src, dst := positional[0], positional[1]

	srcInfo, err := os.Stat(src)
	if err != nil || !srcInfo.IsDir() {
		fmt.Fprintf(os.Stderr, "transferforge: source %q is not a directory: %v\n", src, err)
		os.Exit(1)
	}
	if err := os.MkdirAll(dst, 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "transferforge: cannot create destination %q: %v\n", dst, err)
		os.Exit(1)
	}

	start := time.Now()
	fmt.Printf("TransferForge: copying %q -> %q (%d workers)\n", src, dst, *workers)
	acc := runTransfer(src, dst, *workers)
	elapsed := time.Since(start)

	// Sort file records for a deterministic, readable report.
	sort.Slice(acc.files, func(i, j int) bool { return acc.files[i].Path < acc.files[j].Path })

	var groups []groupReport
	for _, g := range groupOrder {
		gs := acc.groups[g]
		groups = append(groups, groupReport{Type: g, FileCount: gs.FileCount, TotalBytes: gs.TotalBytes})
	}

	man := manifest{
		GeneratedAtUTC:        time.Now().UTC().Format(time.RFC3339),
		Client:                *client,
		Job:                   *job,
		Source:                src,
		Destination:           dst,
		Groups:                groups,
		TotalFiles:            acc.totalFiles,
		TotalBytes:            acc.totalBytes,
		VerifiedOK:            acc.verifiedOK,
		VerifyFailures:        acc.verifyFailures,
		SkippedAlreadyPresent: acc.skipped,
		Files:                 acc.files,
	}

	data, err := json.MarshalIndent(man, "", "  ")
	if err != nil {
		fmt.Fprintf(os.Stderr, "transferforge: failed to marshal report: %v\n", err)
		os.Exit(1)
	}
	if err := os.WriteFile(*report, data, 0o644); err != nil {
		fmt.Fprintf(os.Stderr, "transferforge: failed to write report: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("\nTransfer complete in %s\n", elapsed.Round(time.Millisecond))
	if man.Client != "" {
		fmt.Printf("Client: %s\n", man.Client)
	}
	if man.Job != "" {
		fmt.Printf("Job:    %s\n", man.Job)
	}
	fmt.Println("\nBy media type:")
	for _, g := range groups {
		if g.FileCount == 0 {
			continue
		}
		fmt.Printf("  %-6s %4d files   %s\n", g.Type, g.FileCount, humanBytes(g.TotalBytes))
	}
	fmt.Printf("\nTotal:   %d files, %s\n", man.TotalFiles, humanBytes(man.TotalBytes))
	fmt.Printf("Verified OK: %d   Skipped (already present): %d   Verify failures: %d\n",
		man.VerifiedOK, man.SkippedAlreadyPresent, man.VerifyFailures)
	if man.VerifyFailures > 0 {
		fmt.Println("\nFAILED files (verification mismatch, kept out of destination):")
		for _, f := range man.Files {
			if f.Status == "failed" {
				fmt.Printf("  - %s\n", f.Path)
			}
		}
	}
	fmt.Printf("\nReport written to %s\n", *report)

	if man.VerifyFailures > 0 {
		os.Exit(2)
	}
}
