// FolderSync is a versioned one-way folder sync tool. Unlike a plain
// mirror, FolderSync preserves the previous content of any destination
// file it overwrites or deletes in a version history directory, so a
// bad sync can be undone with `restore`.
package main

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

// ---------------------------------------------------------------------
// shared helpers (conventions shared across the FolderSync/SyncGuard
// tool family)
// ---------------------------------------------------------------------

// reorderFlags works around a quirk of Go's flag package: it stops
// parsing flags at the first positional argument. This walks the raw
// args and moves all recognized flags (and their values, for flags
// that take one) to the front so fs.Parse sees them all.
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...)
}

// humanBytes renders a byte count in human-readable form (KiB, MiB, ...).
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])
}

func isHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}

// ---------------------------------------------------------------------
// usage / help text
// ---------------------------------------------------------------------

func usage() {
	fmt.Fprint(os.Stderr, `FolderSync - versioned one-way folder sync with undo history

Usage:
  foldersync sync <src> <dst> --versions <versionsdir> [--delete] [--apply]
  foldersync versions <dst-relative-path> --versions <versionsdir> [--json]
  foldersync restore <dst-relative-path> <dst-root> --versions <versionsdir> [--at <timestamp-or-latest>] [--apply]
  foldersync help

Run "foldersync <command> -h" for details on a specific command.

FolderSync never overwrites or deletes an existing destination file
without first archiving its current content into the version history
directory, so any sync can be undone with "restore".
`)
}

func syncUsage() {
	fmt.Fprint(os.Stderr, `foldersync sync - one-way sync from <src> to <dst>, with automatic
versioning of anything it overwrites or deletes

Usage:
  foldersync sync <src> <dst> --versions <versionsdir> [--delete] [--apply]

Flags:
  --versions <dir>   Directory to store version history in (required)
  --delete           Also delete dst files that no longer exist in src
                      (the deleted file's last content is versioned first)
  --apply            Actually perform the sync. Without this flag, sync
                      only prints a dry-run plan and touches nothing.

Behavior:
  - Files present in <src> but not <dst> are copied. New files have no
    prior destination content, so nothing is versioned for them.
  - Files present in both but with different content are updated in
    <dst>; the file's PRE-update content is versioned first.
  - With --delete, files present in <dst> but not <src> are removed;
    the file's last content is versioned first.
`)
}

func versionsUsage() {
	fmt.Fprint(os.Stderr, `foldersync versions - list stored historical versions of a file

Usage:
  foldersync versions <dst-relative-path> --versions <versionsdir> [--json]

Flags:
  --versions <dir>   Directory version history is stored in (required)
  --json             Print machine-readable JSON instead of a table

Lists every version FolderSync has archived for the given
destination-relative path, newest first, with its timestamp and size.
`)
}

func restoreUsage() {
	fmt.Fprint(os.Stderr, `foldersync restore - restore a historical version of a file

Usage:
  foldersync restore <dst-relative-path> <dst-root> --versions <versionsdir> [--at <timestamp-or-latest>] [--apply]

Flags:
  --versions <dir>   Directory version history is stored in (required)
  --at <value>       Which version to restore: "latest" (default) or an
                      exact timestamp as printed by "versions"
  --apply            Actually perform the restore. Without this flag,
                      restore only prints what it would do and touches
                      nothing.

Safety property: restoring is itself undo-able. Whatever currently sits
at <dst-root>/<dst-relative-path> (if anything) is versioned BEFORE it
is overwritten by the restored content, exactly like a normal sync
overwrite. So restoring an older version never destroys the newer
content without a trace - it just becomes another entry in the file's
version history, restorable in turn.
`)
}

// ---------------------------------------------------------------------
// file scanning + hashing
// ---------------------------------------------------------------------

type fileInfo struct {
	relPath string
	size    int64
}

// scanDir walks root and returns all regular files keyed by their path
// relative to root (using "/" separators regardless of host OS).
func scanDir(root string) (map[string]fileInfo, error) {
	out := map[string]fileInfo{}
	root = filepath.Clean(root)
	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		if !info.Mode().IsRegular() {
			return nil
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		rel = filepath.ToSlash(rel)
		out[rel] = fileInfo{relPath: rel, size: info.Size()}
		return nil
	})
	if err != nil {
		if os.IsNotExist(err) {
			return out, nil
		}
		return nil, err
	}
	return out, nil
}

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
}

// filesEqual reports whether two existing files have identical content,
// using a checksum comparison (cheap size check first, then sha256).
func filesEqual(a, b string) (bool, error) {
	ai, err := os.Stat(a)
	if err != nil {
		return false, err
	}
	bi, err := os.Stat(b)
	if err != nil {
		return false, err
	}
	if ai.Size() != bi.Size() {
		return false, nil
	}
	ha, err := hashFile(a)
	if err != nil {
		return false, err
	}
	hb, err := hashFile(b)
	if err != nil {
		return false, err
	}
	return ha == hb, nil
}

func copyFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	tmp := dst + ".foldersync-tmp"
	out, err := os.Create(tmp)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		os.Remove(tmp)
		return err
	}
	if err := out.Close(); err != nil {
		os.Remove(tmp)
		return err
	}
	return os.Rename(tmp, dst)
}

// ---------------------------------------------------------------------
// versioning
// ---------------------------------------------------------------------

const versionTimeLayout = "20060102T150405.000000000Z"

// versionDirFor returns the directory that holds all historical
// versions of dst-relative path relPath: <versionsDir>/<relPath>/
func versionDirFor(versionsDir, relPath string) string {
	return filepath.Join(versionsDir, filepath.FromSlash(relPath))
}

// versionFile archives the current content at currentPath into
// versionsDir under relPath, named by a UTC timestamp. Returns the
// timestamp string used. If two versions would land on the exact same
// nanosecond (only possible within a single process running very
// fast), a numeric suffix is appended to keep filenames unique while
// preserving sort order for same-second versions.
func versionFile(versionsDir, relPath, currentPath string) (string, error) {
	dir := versionDirFor(versionsDir, relPath)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", err
	}
	base := time.Now().UTC().Format(versionTimeLayout)
	stamp := base
	for i := 1; ; i++ {
		if _, err := os.Stat(filepath.Join(dir, stamp)); os.IsNotExist(err) {
			break
		}
		stamp = fmt.Sprintf("%s.%d", base, i)
	}
	if err := copyFile(currentPath, filepath.Join(dir, stamp)); err != nil {
		return "", err
	}
	return stamp, nil
}

type versionEntry struct {
	Timestamp string `json:"timestamp"`
	Size      int64  `json:"size"`
}

func listVersions(versionsDir, relPath string) ([]versionEntry, error) {
	dir := versionDirFor(versionsDir, relPath)
	entries, err := os.ReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var out []versionEntry
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		info, err := e.Info()
		if err != nil {
			return nil, err
		}
		out = append(out, versionEntry{Timestamp: e.Name(), Size: info.Size()})
	}
	// Filenames are zero-padded UTC timestamps, so lexicographic sort
	// is chronological sort. Newest first.
	sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
	return out, nil
}

// ---------------------------------------------------------------------
// sync
// ---------------------------------------------------------------------

type actionKind int

const (
	actionCopy actionKind = iota
	actionUpdate
	actionDelete
)

func (k actionKind) String() string {
	switch k {
	case actionCopy:
		return "copy"
	case actionUpdate:
		return "update"
	case actionDelete:
		return "delete"
	}
	return "?"
}

type syncAction struct {
	kind    actionKind
	relPath string
}

func buildSyncPlan(srcRoot, dstRoot string, withDelete bool) ([]syncAction, error) {
	srcFiles, err := scanDir(srcRoot)
	if err != nil {
		return nil, fmt.Errorf("scanning src: %w", err)
	}
	dstFiles, err := scanDir(dstRoot)
	if err != nil {
		return nil, fmt.Errorf("scanning dst: %w", err)
	}

	var rels []string
	for rel := range srcFiles {
		rels = append(rels, rel)
	}
	sort.Strings(rels)

	var plan []syncAction
	for _, rel := range rels {
		if _, ok := dstFiles[rel]; !ok {
			plan = append(plan, syncAction{kind: actionCopy, relPath: rel})
			continue
		}
		equal, err := filesEqual(filepath.Join(srcRoot, filepath.FromSlash(rel)), filepath.Join(dstRoot, filepath.FromSlash(rel)))
		if err != nil {
			return nil, fmt.Errorf("comparing %s: %w", rel, err)
		}
		if !equal {
			plan = append(plan, syncAction{kind: actionUpdate, relPath: rel})
		}
	}

	if withDelete {
		var delRels []string
		for rel := range dstFiles {
			if _, ok := srcFiles[rel]; !ok {
				delRels = append(delRels, rel)
			}
		}
		sort.Strings(delRels)
		for _, rel := range delRels {
			plan = append(plan, syncAction{kind: actionDelete, relPath: rel})
		}
	}

	return plan, nil
}

func runSync(args []string) {
	if isHelp(args) {
		syncUsage()
		return
	}
	valueFlags := map[string]bool{"versions": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("sync", flag.ExitOnError)
	fs.Usage = syncUsage
	versionsDir := fs.String("versions", "", "directory to store version history in (required)")
	withDelete := fs.Bool("delete", false, "also delete extraneous dst files")
	apply := fs.Bool("apply", false, "actually perform the sync")
	fs.Parse(args)

	rest := fs.Args()
	if len(rest) != 2 || *versionsDir == "" {
		syncUsage()
		os.Exit(1)
	}
	src, dst := rest[0], rest[1]

	if _, err := os.Stat(src); err != nil {
		fmt.Fprintf(os.Stderr, "foldersync: src error: %v\n", err)
		os.Exit(1)
	}
	if err := os.MkdirAll(dst, 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "foldersync: cannot create dst: %v\n", err)
		os.Exit(1)
	}

	plan, err := buildSyncPlan(src, dst, *withDelete)
	if err != nil {
		fmt.Fprintf(os.Stderr, "foldersync: %v\n", err)
		os.Exit(1)
	}

	if len(plan) == 0 {
		fmt.Println("foldersync: nothing to do, dst already matches src")
		return
	}

	if !*apply {
		fmt.Println("foldersync sync: dry run (pass --apply to perform these changes)")
		for _, a := range plan {
			switch a.kind {
			case actionCopy:
				fmt.Printf("  copy    %s  (new file, nothing to version)\n", a.relPath)
			case actionUpdate:
				fmt.Printf("  update  %s  (will version pre-change content first)\n", a.relPath)
			case actionDelete:
				fmt.Printf("  delete  %s  (will version pre-delete content first)\n", a.relPath)
			}
		}
		fmt.Printf("%d change(s) planned; no files or versions were touched.\n", len(plan))
		return
	}

	var copied, updated, deleted, versioned int
	for _, a := range plan {
		dstPath := filepath.Join(dst, filepath.FromSlash(a.relPath))
		srcPath := filepath.Join(src, filepath.FromSlash(a.relPath))
		switch a.kind {
		case actionCopy:
			if err := copyFile(srcPath, dstPath); err != nil {
				fmt.Fprintf(os.Stderr, "foldersync: copy %s: %v\n", a.relPath, err)
				os.Exit(1)
			}
			fmt.Printf("copy    %s\n", a.relPath)
			copied++
		case actionUpdate:
			stamp, err := versionFile(*versionsDir, a.relPath, dstPath)
			if err != nil {
				fmt.Fprintf(os.Stderr, "foldersync: versioning %s: %v\n", a.relPath, err)
				os.Exit(1)
			}
			versioned++
			if err := copyFile(srcPath, dstPath); err != nil {
				fmt.Fprintf(os.Stderr, "foldersync: update %s: %v\n", a.relPath, err)
				os.Exit(1)
			}
			fmt.Printf("update  %s  (versioned pre-change content as %s)\n", a.relPath, stamp)
			updated++
		case actionDelete:
			stamp, err := versionFile(*versionsDir, a.relPath, dstPath)
			if err != nil {
				fmt.Fprintf(os.Stderr, "foldersync: versioning %s: %v\n", a.relPath, err)
				os.Exit(1)
			}
			versioned++
			if err := os.Remove(dstPath); err != nil {
				fmt.Fprintf(os.Stderr, "foldersync: delete %s: %v\n", a.relPath, err)
				os.Exit(1)
			}
			fmt.Printf("delete  %s  (versioned pre-delete content as %s)\n", a.relPath, stamp)
			deleted++
		}
	}
	fmt.Printf("done: %d copied, %d updated, %d deleted, %d version(s) archived.\n", copied, updated, deleted, versioned)
}

// ---------------------------------------------------------------------
// versions
// ---------------------------------------------------------------------

func runVersions(args []string) {
	if isHelp(args) {
		versionsUsage()
		return
	}
	valueFlags := map[string]bool{"versions": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("versions", flag.ExitOnError)
	fs.Usage = versionsUsage
	versionsDir := fs.String("versions", "", "directory version history is stored in (required)")
	asJSON := fs.Bool("json", false, "print JSON")
	fs.Parse(args)

	rest := fs.Args()
	if len(rest) != 1 || *versionsDir == "" {
		versionsUsage()
		os.Exit(1)
	}
	relPath := filepath.ToSlash(rest[0])

	entries, err := listVersions(*versionsDir, relPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "foldersync: %v\n", err)
		os.Exit(1)
	}

	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(entries); err != nil {
			fmt.Fprintf(os.Stderr, "foldersync: %v\n", err)
			os.Exit(1)
		}
		return
	}

	if len(entries) == 0 {
		fmt.Printf("no versions stored for %s\n", relPath)
		return
	}
	fmt.Printf("versions of %s (newest first):\n", relPath)
	for _, v := range entries {
		fmt.Printf("  %s  %8s\n", v.Timestamp, humanBytes(v.Size))
	}
}

// ---------------------------------------------------------------------
// restore
// ---------------------------------------------------------------------

func runRestore(args []string) {
	if isHelp(args) {
		restoreUsage()
		return
	}
	valueFlags := map[string]bool{"versions": true, "at": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("restore", flag.ExitOnError)
	fs.Usage = restoreUsage
	versionsDir := fs.String("versions", "", "directory version history is stored in (required)")
	at := fs.String("at", "latest", `version to restore: "latest" or an exact timestamp`)
	apply := fs.Bool("apply", false, "actually perform the restore")
	fs.Parse(args)

	rest := fs.Args()
	if len(rest) != 2 || *versionsDir == "" {
		restoreUsage()
		os.Exit(1)
	}
	relPath := filepath.ToSlash(rest[0])
	dstRoot := rest[1]

	entries, err := listVersions(*versionsDir, relPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "foldersync: %v\n", err)
		os.Exit(1)
	}
	if len(entries) == 0 {
		fmt.Fprintf(os.Stderr, "foldersync: no versions stored for %s\n", relPath)
		os.Exit(1)
	}

	var chosen *versionEntry
	if *at == "" || *at == "latest" {
		chosen = &entries[0] // already sorted newest first
	} else {
		for i := range entries {
			if entries[i].Timestamp == *at {
				chosen = &entries[i]
				break
			}
		}
		if chosen == nil {
			fmt.Fprintf(os.Stderr, "foldersync: no version %q stored for %s (run \"foldersync versions %s --versions %s\" to list them)\n", *at, relPath, relPath, *versionsDir)
			os.Exit(1)
		}
	}

	versionPath := filepath.Join(versionDirFor(*versionsDir, relPath), chosen.Timestamp)
	dstPath := filepath.Join(dstRoot, filepath.FromSlash(relPath))

	_, statErr := os.Stat(dstPath)
	dstExists := statErr == nil

	if !*apply {
		fmt.Printf("foldersync restore: dry run (pass --apply to perform this change)\n")
		fmt.Printf("  restore %s\n", relPath)
		fmt.Printf("  from version %s (%s)\n", chosen.Timestamp, humanBytes(chosen.Size))
		fmt.Printf("  into    %s\n", dstPath)
		if dstExists {
			fmt.Printf("  current content at destination would first be versioned (restore is itself undo-able)\n")
		} else {
			fmt.Printf("  destination does not currently exist; it would be created\n")
		}
		fmt.Println("no files or versions were touched.")
		return
	}

	if dstExists {
		stamp, err := versionFile(*versionsDir, relPath, dstPath)
		if err != nil {
			fmt.Fprintf(os.Stderr, "foldersync: versioning current content of %s: %v\n", relPath, err)
			os.Exit(1)
		}
		fmt.Printf("versioned current content of %s as %s (restore is itself undo-able)\n", relPath, stamp)
	}

	if err := copyFile(versionPath, dstPath); err != nil {
		fmt.Fprintf(os.Stderr, "foldersync: restore %s: %v\n", relPath, err)
		os.Exit(1)
	}
	fmt.Printf("restored %s from version %s into %s\n", relPath, chosen.Timestamp, dstPath)
}

// ---------------------------------------------------------------------
// main
// ---------------------------------------------------------------------

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions 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)
	}

	cmd := os.Args[1]
	rest := os.Args[2:]

	switch cmd {
	case "sync":
		runSync(rest)
	case "versions":
		runVersions(rest)
	case "restore":
		runRestore(rest)
	case "-h", "--help", "help":
		usage()
	default:
		fmt.Fprintf(os.Stderr, "foldersync: unknown command %q\n\n", cmd)
		usage()
		os.Exit(1)
	}
}
