// MoveGuard — checksum-verified file MOVE: copy, hash-verify, and only
// then delete the source. Resumable, parallel.
//
// Usage:
//
//	moveguard move <src> <dst> [--workers N] [--resume] [--dry-run]
//
// MoveGuard is the higher-stakes sibling of CopySure: where CopySure only
// ever copies (source is always left alone), MoveGuard's entire job is to
// relocate files — which means it must never delete a source file until
// the copy at the destination has been independently re-read from disk
// and proven byte-identical by SHA-256. If verification ever fails for a
// file, that file's source is left completely untouched and an error is
// reported for it; every other file in the batch is still processed.
package main

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

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 "move":
		cmdMove(os.Args[2:])
	case "-h", "--help", "help":
		usage()
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `MoveGuard — checksum-verified move (copy + verify, only then delete source)

Usage:
  moveguard move <src> <dst> [--workers N] [--resume] [--dry-run]

A source file is ONLY ever deleted after its destination copy has been
re-read from disk and hash-verified byte-identical. If verification
fails for a file, that file's source is left completely untouched.

Flags:
  --dry-run     List every file that would move, its size and where it
                would go, and report the problems the real run would hit
                — an unreadable source, a name already taken at the
                destination, a destination that cannot be written to.
                Copies nothing, moves nothing, deletes nothing. Exits 2
                if it found anything you should look at first.
  --resume      Skip re-copying files already verified at the destination.
                Works with --dry-run, which then shows what would be
                skipped instead of re-copied.
  --workers N   Files handled in parallel (default 4). Works with
                --dry-run too.
`)
}

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

type job struct {
	relPath string
	srcPath string
	dstPath string
}

type result struct {
	relPath string
	status  string // moved | verify-failed | error
	detail  string
	bytes   int64
}

func cmdMove(args []string) {
	fs := flag.NewFlagSet("move", flag.ExitOnError)
	workers := fs.Int("workers", 4, "parallel workers")
	resume := fs.Bool("resume", false, "resume a partially completed move: skip re-copying files already verified at the destination")
	dryRun := fs.Bool("dry-run", false, "list what would move and report the problems the real run would hit, without copying, moving or deleting anything")
	fs.Parse(reorderFlags(args, map[string]bool{"workers": true}))
	pos := fs.Args()
	if len(pos) != 2 {
		fmt.Fprintln(os.Stderr, "usage: moveguard move <src> <dst> [--workers N] [--resume] [--dry-run]")
		os.Exit(1)
	}
	src, dst := pos[0], pos[1]

	jobs, err := planJobs(src, dst)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	if len(jobs) == 0 {
		fmt.Println("Nothing to move — source has no regular files.")
		return
	}

	// The dry run branches after planning, not before, so it walks the source
	// with the very same planJobs() the move uses and reports the identical
	// error if the source cannot be read at all. A preview that surveyed the
	// tree its own way could disagree with the move about what is even there.
	if *dryRun {
		if code := dryRunMove(jobs, *workers, *resume); code != 0 {
			os.Exit(code)
		}
		return
	}

	jobCh := make(chan job)
	resCh := make(chan result)
	var wg sync.WaitGroup
	for i := 0; i < *workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobCh {
				resCh <- moveVerify(j, *resume)
			}
		}()
	}
	go func() {
		for _, j := range jobs {
			jobCh <- j
		}
		close(jobCh)
		wg.Wait()
		close(resCh)
	}()

	var moved, verifyFailed, failed int32
	var movedBytes int64
	var mu sync.Mutex
	for r := range resCh {
		switch r.status {
		case "moved":
			atomic.AddInt32(&moved, 1)
			mu.Lock()
			movedBytes += r.bytes
			mu.Unlock()
			fmt.Printf("  moved                %s (%s)\n", r.relPath, humanBytes(r.bytes))
		case "verify-failed":
			atomic.AddInt32(&verifyFailed, 1)
			fmt.Printf("  VERIFY-FAILED         %s — %s — source preserved\n", r.relPath, r.detail)
		case "error":
			atomic.AddInt32(&failed, 1)
			fmt.Printf("  ERROR                 %s — %s\n", r.relPath, r.detail)
		}
	}

	fmt.Printf("\n%d files moved (%s), %d verify failures (source preserved), %d other errors\n",
		moved, humanBytes(movedBytes), verifyFailed, failed)
	if verifyFailed > 0 || failed > 0 {
		os.Exit(2)
	}
}

func planJobs(src, dst string) ([]job, error) {
	info, err := os.Stat(src)
	if err != nil {
		return nil, err
	}
	var jobs []job
	if !info.IsDir() {
		jobs = append(jobs, job{relPath: filepath.Base(src), srcPath: src, dstPath: dst})
		return jobs, nil
	}
	err = filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
		if err != nil || fi.IsDir() || !fi.Mode().IsRegular() {
			return nil
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return nil
		}
		jobs = append(jobs, job{relPath: rel, srcPath: path, dstPath: filepath.Join(dst, rel)})
		return nil
	})
	return jobs, err
}

// moveVerify performs the copy-verify-delete sequence for a single file.
// The safety invariant: os.Remove(j.srcPath) is reachable from exactly one
// place in this function, and only after dstHash == srcHash has been
// confirmed by independently re-reading the destination from disk.
func moveVerify(j job, resume bool) result {
	srcInfo, err := os.Stat(j.srcPath)
	if err != nil {
		return result{relPath: j.relPath, status: "error", detail: "reading source: " + err.Error()}
	}
	srcHash, err := hashFile(j.srcPath)
	if err != nil {
		return result{relPath: j.relPath, status: "error", detail: "hashing source: " + err.Error()}
	}

	alreadyCopied := false
	if resume {
		if dstHash, err := hashFile(j.dstPath); err == nil && dstHash == srcHash {
			// A prior run already produced a verified-matching copy at
			// the final destination path. Do not re-copy; fall through
			// straight to the verify-then-delete step below (the
			// verify there is effectively free since we just matched).
			alreadyCopied = true
		}
	}

	if !alreadyCopied {
		if err := os.MkdirAll(filepath.Dir(j.dstPath), 0o755); err != nil {
			return result{relPath: j.relPath, status: "error", detail: "creating destination dir: " + err.Error()}
		}
		tmp := j.dstPath + ".moveguard-tmp"
		if err := copyToTemp(j.srcPath, tmp); err != nil {
			os.Remove(tmp)
			return result{relPath: j.relPath, status: "error", detail: "copy failed: " + err.Error()}
		}

		tmpHash, err := hashFile(tmp)
		if err != nil {
			return result{relPath: j.relPath, status: "error", detail: "re-reading destination: " + err.Error()}
		}
		if tmpHash != srcHash {
			// Verification failed: the temp copy at the destination is
			// left in place as evidence (still under its .moveguard-tmp
			// name, never renamed to the final path), and the source is
			// left completely untouched — no rename, no delete.
			return result{relPath: j.relPath, status: "verify-failed",
				detail: fmt.Sprintf("hash mismatch after copy (src=%s dst=%s), corrupted copy left at %s", srcHash[:12], tmpHash[:12], tmp)}
		}
		if err := os.Rename(tmp, j.dstPath); err != nil {
			return result{relPath: j.relPath, status: "error", detail: "renaming verified copy into place: " + err.Error()}
		}
	} else {
		// Destination already verified from a prior run; re-confirm here
		// too so a race between resume-check and delete can never delete
		// a source whose destination isn't actually intact right now.
		dstHash, err := hashFile(j.dstPath)
		if err != nil || dstHash != srcHash {
			return result{relPath: j.relPath, status: "error", detail: "resume: destination changed since check, refusing to delete source"}
		}
	}

	if err := os.Remove(j.srcPath); err != nil {
		return result{relPath: j.relPath, status: "error", detail: "verified copy but failed to delete source: " + err.Error()}
	}
	return result{relPath: j.relPath, status: "moved", bytes: srcInfo.Size()}
}

func copyToTemp(src, tmp string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.Create(tmp)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}

func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}
