// FilePilot (CLI prototype)
//
// FilePilot's full product concept is a dual-pane file manager with
// instant launcher/search, previews, and rename rules. This prototype
// scopes down to the one piece that is genuinely CLI-native and useful:
// a bulk rename engine. Dual-pane browsing, previews, and rule chains
// are NOT implemented here and remain on the roadmap (see ../plan.md).
// Instant launcher/search is covered by the sibling tool "FindPilot".
//
// Two subcommands are provided:
//
//	filepilot rename <dir> --match "REGEXP" --replace "TEMPLATE" [--recursive] [--apply]
//	filepilot sequence <dir> --pattern "photo-{n}.jpg" [--start 1] [--pad 3] [--sort name|mtime] [--apply]
//
// Both default to a safe dry run that only prints the planned renames.
// Nothing touches disk until --apply is passed, and both commands
// validate the entire rename batch for collisions before renaming a
// single file: if any two sources would land on the same destination,
// or a destination path already exists outside the batch, the whole
// batch is aborted and NOTHING is renamed.
package main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
)

// newFlagSet returns a flag.FlagSet configured to print usageFn's output
// on parse errors instead of flag's default usage message, and to
// suppress flag's own error text (we print our own).
func newFlagSet(name string, usageFn func()) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.Usage = usageFn
	fs.SetOutput(os.Stderr)
	return fs
}

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

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

func usage() {
	fmt.Fprint(os.Stderr, `FilePilot - bulk rename engine (CLI prototype)

USAGE:
  filepilot rename <dir> --match "REGEXP" --replace "TEMPLATE" [--recursive] [--apply]
  filepilot sequence <dir> --pattern "photo-{n}.jpg" [--start 1] [--pad 3] [--sort name|mtime] [--apply]
  filepilot help

COMMANDS:
  rename     Regex find/replace on file basenames.
  sequence   Renumber a batch of files sequentially using a pattern.

Both commands default to a dry run: they print the planned renames but
change nothing on disk. Pass --apply to actually perform the renames.
Run "filepilot rename -h" or "filepilot sequence -h" for command-specific
flag details.
`)
}

// reorderFlags moves all flag tokens (and their values, for flags listed
// in valueFlags) to the front of args and positional arguments to the
// back. This works around Go's flag package stopping at the first
// positional argument, which matters here because directory arguments
// legitimately appear before flags on the command line, e.g.:
//
//	filepilot rename /some/dir --apply
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...)
}

// renamePlan is one planned (old path -> new path) rename.
type renamePlan struct {
	OldPath string
	NewPath string
}

// validateCollisions checks a batch of planned renames for two kinds of
// conflicts before anything is renamed:
//
//  1. two different source files planned to produce the same new path
//     (an in-batch collision)
//  2. a planned new path that already exists on disk as a file that is
//     NOT itself one of the batch's source files (an on-disk collision)
//
// It returns a human-readable description of every collision found, or
// "" if the batch is clean.
func validateCollisions(plans []renamePlan) string {
	var sb strings.Builder

	oldPaths := make(map[string]bool, len(plans))
	for _, p := range plans {
		oldPaths[p.OldPath] = true
	}

	newPathSources := make(map[string][]string, len(plans))
	for _, p := range plans {
		newPathSources[p.NewPath] = append(newPathSources[p.NewPath], p.OldPath)
	}

	// Report in a stable order (by plan slice order) rather than map
	// iteration order.
	reported := make(map[string]bool)
	for _, p := range plans {
		if reported[p.NewPath] {
			continue
		}
		srcs := newPathSources[p.NewPath]
		if len(srcs) > 1 {
			reported[p.NewPath] = true
			fmt.Fprintf(&sb, "  COLLISION: %d sources would rename to %q:\n", len(srcs), p.NewPath)
			for _, s := range srcs {
				fmt.Fprintf(&sb, "    - %s\n", s)
			}
		}
	}

	for _, p := range plans {
		if reported[p.NewPath] {
			// Already reported as an in-batch collision above; don't
			// also report the on-disk check for it.
			continue
		}
		if _, err := os.Lstat(p.NewPath); err == nil {
			if !oldPaths[p.NewPath] {
				reported[p.NewPath] = true
				fmt.Fprintf(&sb, "  COLLISION: target %q already exists and is not part of this rename batch (source: %s)\n", p.NewPath, p.OldPath)
			}
		}
	}

	return sb.String()
}

// applyRenames performs every planned rename. It assumes the batch has
// already passed validateCollisions with zero collisions. Renaming is
// done in two phases (source -> unique temp name, then temp name ->
// final destination) so that batches which happen to rename file A to
// file B's old path (and vice versa, or in a chain) never clobber a
// not-yet-renamed sibling in the same batch.
func applyRenames(plans []renamePlan) error {
	type tempPlan struct {
		Temp  string
		Final string
	}
	tempPlans := make([]tempPlan, 0, len(plans))

	for i, p := range plans {
		dir := filepath.Dir(p.OldPath)
		tmp := filepath.Join(dir, fmt.Sprintf(".filepilot-tmp-%d-%d-%d", os.Getpid(), time.Now().UnixNano(), i))
		if err := os.Rename(p.OldPath, tmp); err != nil {
			return fmt.Errorf("renaming %q to temp name: %w", p.OldPath, err)
		}
		tempPlans = append(tempPlans, tempPlan{Temp: tmp, Final: p.NewPath})
	}

	for _, tp := range tempPlans {
		if err := os.Rename(tp.Temp, tp.Final); err != nil {
			return fmt.Errorf("renaming temp file to %q: %w", tp.Final, err)
		}
	}

	return nil
}

func printPlan(plans []renamePlan, skipped int) {
	if len(plans) == 0 {
		fmt.Println("No files match; nothing to rename.")
	} else {
		fmt.Printf("DRY RUN - %d rename(s) planned (no --apply given, nothing changed on disk):\n\n", len(plans))
		for _, p := range plans {
			fmt.Printf("  %s -> %s\n", p.OldPath, filepath.Base(p.NewPath))
		}
	}
	if skipped > 0 {
		fmt.Printf("\n(%d file(s) matched but new name == old name; skipped as no-ops)\n", skipped)
	}
	fmt.Println("\nRe-run with --apply to perform these renames.")
}

// listDirEntries returns files (not directories) directly inside dir,
// or (if recursive) every file anywhere in dir's subtree.
func listDirEntries(dir string, recursive bool) ([]string, error) {
	var files []string
	if recursive {
		err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if d.IsDir() {
				return nil
			}
			files = append(files, path)
			return nil
		})
		if err != nil {
			return nil, err
		}
		return files, nil
	}

	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		files = append(files, filepath.Join(dir, e.Name()))
	}
	return files, nil
}

// ---------------------------------------------------------------------
// rename subcommand
// ---------------------------------------------------------------------

func renameUsage() {
	fmt.Fprint(os.Stderr, `filepilot rename - regex find/replace on file basenames

USAGE:
  filepilot rename <dir> --match "REGEXP" --replace "TEMPLATE" [--recursive] [--apply]

FLAGS:
  --match STRING     Go regexp matched against each file's BASE NAME
                      (not full path).
  --replace STRING   Replacement template using Go's regexp
                      ReplaceAllString syntax: $1, $2, ${name} refer to
                      capture groups from --match.
  --recursive        Walk the entire subtree of <dir> instead of just
                      its immediate children.
  --apply            Actually perform the renames. Without this flag,
                      filepilot only prints the planned renames.

Files whose computed new name is identical to their old name are
skipped as no-ops. Before renaming anything, the full batch of planned
renames is validated for collisions (two sources mapping to the same
destination, or a destination that already exists outside the batch).
If any collision is found, the ENTIRE batch is aborted and nothing is
renamed.
`)
}

// cmdRename reports the exit code it wants rather than calling os.Exit itself.
// "these names would collide" is a RESULT, not a crash, and the guided session
// run by a double-click has to be able to print such a result and then keep the
// window open. main turns the returned code back into the same exit status the
// command has always produced.
func cmdRename(args []string) int {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			renameUsage()
			return 0
		}
	}

	valueFlags := map[string]bool{"match": true, "replace": true}
	args = reorderFlags(args, valueFlags)

	fs := newFlagSet("rename", renameUsage)
	match := fs.String("match", "", "Go regexp matched against each file's base name")
	replace := fs.String("replace", "", "replacement template (regexp.ReplaceAllString syntax)")
	recursive := fs.Bool("recursive", false, "walk the entire subtree instead of just immediate children")
	apply := fs.Bool("apply", false, "actually perform the renames (default: dry run)")
	if err := fs.Parse(args); err != nil {
		return 1
	}

	positional := fs.Args()
	if len(positional) != 1 {
		fmt.Fprintln(os.Stderr, "filepilot rename: exactly one <dir> argument is required")
		renameUsage()
		return 1
	}
	dir := positional[0]

	if *match == "" {
		fmt.Fprintln(os.Stderr, "filepilot rename: --match is required")
		return 1
	}
	if *replace == "" {
		fmt.Fprintln(os.Stderr, "filepilot rename: --replace is required")
		return 1
	}

	info, err := os.Stat(dir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "filepilot rename: %v\n", err)
		return 1
	}
	if !info.IsDir() {
		fmt.Fprintf(os.Stderr, "filepilot rename: %q is not a directory\n", dir)
		return 1
	}

	re, err := regexp.Compile(*match)
	if err != nil {
		fmt.Fprintf(os.Stderr, "filepilot rename: invalid --match regexp: %v\n", err)
		return 1
	}

	files, err := listDirEntries(dir, *recursive)
	if err != nil {
		fmt.Fprintf(os.Stderr, "filepilot rename: %v\n", err)
		return 1
	}
	sort.Strings(files)

	var plans []renamePlan
	skipped := 0
	for _, path := range files {
		base := filepath.Base(path)
		if !re.MatchString(base) {
			continue
		}
		newBase := re.ReplaceAllString(base, *replace)
		if newBase == base {
			skipped++
			continue
		}
		newPath := filepath.Join(filepath.Dir(path), newBase)
		plans = append(plans, renamePlan{OldPath: path, NewPath: newPath})
	}

	if collisions := validateCollisions(plans); collisions != "" {
		fmt.Fprintln(os.Stderr, "filepilot rename: aborting, collisions detected (NOTHING was renamed):")
		fmt.Fprint(os.Stderr, collisions)
		return 1
	}

	if !*apply {
		printPlan(plans, skipped)
		return 0
	}

	if len(plans) == 0 {
		fmt.Println("No files match; nothing to rename.")
		return 0
	}

	if err := applyRenames(plans); err != nil {
		fmt.Fprintf(os.Stderr, "filepilot rename: %v\n", err)
		return 1
	}
	fmt.Printf("Renamed %d file(s).\n", len(plans))
	if skipped > 0 {
		fmt.Printf("(%d file(s) matched but new name == old name; skipped as no-ops)\n", skipped)
	}
	return 0
}

// ---------------------------------------------------------------------
// sequence subcommand
// ---------------------------------------------------------------------

func sequenceUsage() {
	fmt.Fprint(os.Stderr, `filepilot sequence - renumber a batch of files sequentially

USAGE:
  filepilot sequence <dir> --pattern "photo-{n}.jpg" [--start 1] [--pad 3] [--sort name|mtime] [--apply]

FLAGS:
  --pattern STRING   Output name template. The literal token "{n}" is
                      replaced with the zero-padded counter for each
                      file; everything else in the pattern is kept
                      as-is. The file extension is NOT auto-appended -
                      include it explicitly in the pattern, e.g.
                      "photo-{n}.jpg".
  --start INT        Starting counter value (default 1).
  --pad INT           Zero-pad width for the counter (default 3, so
                      counter 7 becomes "007").
  --sort STRING       Order files are numbered in: "name" (alphabetical
                      basename, default) or "mtime" (modification time,
                      ascending).
  --apply             Actually perform the renames. Without this flag,
                      filepilot only prints the planned renames.

<dir>'s immediate children only are considered (non-recursive).
Before renaming anything, the full batch of planned renames is
validated for collisions (a pre-existing file outside the batch at a
planned destination path). If any collision is found, the ENTIRE batch
is aborted and nothing is renamed.
`)
}

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

	valueFlags := map[string]bool{"pattern": true, "start": true, "pad": true, "sort": true}
	args = reorderFlags(args, valueFlags)

	fs := newFlagSet("sequence", sequenceUsage)
	pattern := fs.String("pattern", "", `output name template, e.g. "photo-{n}.jpg"`)
	start := fs.Int("start", 1, "starting counter value")
	pad := fs.Int("pad", 3, "zero-pad width for the counter")
	sortBy := fs.String("sort", "name", `sort order: "name" or "mtime"`)
	apply := fs.Bool("apply", false, "actually perform the renames (default: dry run)")
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}

	positional := fs.Args()
	if len(positional) != 1 {
		fmt.Fprintln(os.Stderr, "filepilot sequence: exactly one <dir> argument is required")
		sequenceUsage()
		os.Exit(1)
	}
	dir := positional[0]

	if *pattern == "" {
		fmt.Fprintln(os.Stderr, "filepilot sequence: --pattern is required")
		os.Exit(1)
	}
	if !strings.Contains(*pattern, "{n}") {
		fmt.Fprintln(os.Stderr, `filepilot sequence: --pattern must contain the literal token "{n}"`)
		os.Exit(1)
	}
	if *sortBy != "name" && *sortBy != "mtime" {
		fmt.Fprintf(os.Stderr, "filepilot sequence: --sort must be \"name\" or \"mtime\", got %q\n", *sortBy)
		os.Exit(1)
	}
	if *pad < 0 {
		fmt.Fprintln(os.Stderr, "filepilot sequence: --pad must be >= 0")
		os.Exit(1)
	}

	info, err := os.Stat(dir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "filepilot sequence: %v\n", err)
		os.Exit(1)
	}
	if !info.IsDir() {
		fmt.Fprintf(os.Stderr, "filepilot sequence: %q is not a directory\n", dir)
		os.Exit(1)
	}

	files, err := listDirEntries(dir, false)
	if err != nil {
		fmt.Fprintf(os.Stderr, "filepilot sequence: %v\n", err)
		os.Exit(1)
	}

	switch *sortBy {
	case "name":
		sort.Slice(files, func(i, j int) bool {
			return filepath.Base(files[i]) < filepath.Base(files[j])
		})
	case "mtime":
		type fileMTime struct {
			path  string
			mtime time.Time
		}
		fm := make([]fileMTime, 0, len(files))
		for _, f := range files {
			st, err := os.Stat(f)
			if err != nil {
				fmt.Fprintf(os.Stderr, "filepilot sequence: %v\n", err)
				os.Exit(1)
			}
			fm = append(fm, fileMTime{path: f, mtime: st.ModTime()})
		}
		sort.Slice(fm, func(i, j int) bool {
			if fm[i].mtime.Equal(fm[j].mtime) {
				return filepath.Base(fm[i].path) < filepath.Base(fm[j].path)
			}
			return fm[i].mtime.Before(fm[j].mtime)
		})
		files = files[:0]
		for _, x := range fm {
			files = append(files, x.path)
		}
	}

	var plans []renamePlan
	counter := *start
	for _, path := range files {
		numStr := strconv.Itoa(counter)
		if *pad > 0 && len(numStr) < *pad {
			numStr = strings.Repeat("0", *pad-len(numStr)) + numStr
		}
		newBase := strings.ReplaceAll(*pattern, "{n}", numStr)
		newPath := filepath.Join(filepath.Dir(path), newBase)
		plans = append(plans, renamePlan{OldPath: path, NewPath: newPath})
		counter++
	}

	if collisions := validateCollisions(plans); collisions != "" {
		fmt.Fprintln(os.Stderr, "filepilot sequence: aborting, collisions detected (NOTHING was renamed):")
		fmt.Fprint(os.Stderr, collisions)
		os.Exit(1)
	}

	if !*apply {
		printPlan(plans, 0)
		return
	}

	if len(plans) == 0 {
		fmt.Println("No files found; nothing to rename.")
		return
	}

	if err := applyRenames(plans); err != nil {
		fmt.Fprintf(os.Stderr, "filepilot sequence: %v\n", err)
		os.Exit(1)
	}
	fmt.Printf("Renamed %d file(s).\n", len(plans))
}
