// Command folderforge is a rule-based automatic file organizer.
//
// It reads a JSON rules file mapping filename glob patterns to destination
// subfolders, scans a flat "inbox"-style source directory, and moves every
// matching file into its correct destination in one collision-safe pass.
//
// This is distinct from the sibling tool FilePilot, which performs manual,
// one-shot bulk RENAME operations. FolderForge instead performs automatic,
// rule-driven ORGANIZATION: define the rules once, run it against a messy
// folder (e.g. Downloads), and files are auto-sorted into destination
// folders according to the first matching rule.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// reorderFlags moves recognized flags (and their values, for flags listed in
// valueFlags) to the front of the argument list and positional arguments to
// the end, so that Go's flag package -- which stops parsing at the first
// non-flag argument -- can correctly parse flags regardless of where the
// user placed them on the command 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 usage() {
	fmt.Fprint(os.Stderr, `usage: folderforge <command> [flags]

commands:
  organize <source-dir> --rules <rules.json> [--dest-root <dir>] [--apply]
      Scan <source-dir> (non-recursive) and move files into destination
      folders according to the first matching rule in the rules file.
      Without --apply, performs a dry run only (default). Use -h with the
      command for full details.

  preview <source-dir> --rules <rules.json> [--json]
      Read-only equivalent of "organize" without --apply: shows the plan
      (matches, unmatched files, collisions) and touches nothing.

  help
      Show this message.

Run "folderforge <command> -h" for command-specific help.
`)
	os.Exit(1)
}

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()
	}
	cmd := os.Args[1]
	switch cmd {
	case "-h", "--help", "help":
		printTopHelp()
		return
	case "organize":
		cmdOrganize(os.Args[2:])
	case "preview":
		cmdPreview(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "folderforge: unknown command %q\n\n", cmd)
		usage()
	}
}

func printTopHelp() {
	fmt.Print(`folderforge - rule-based automatic file organizer

usage: folderforge <command> [flags]

commands:
  organize <source-dir> --rules <rules.json> [--dest-root <dir>] [--apply]
      Scan <source-dir> (files directly inside it, non-recursive) and, for
      each file, find the first rule in the rules file whose pattern
      matches its name. Files matching no rule are left in place and
      reported as "unmatched" (not an error).

      Before moving anything, the full batch is validated for collisions:
      two different source files that would land at the exact same
      destination path, or a destination path that already exists as a
      file outside this batch. If ANY collision is found, ALL collisions
      are reported and the ENTIRE BATCH is aborted -- nothing is moved.

      Without --apply: dry run. Prints the full plan (file -> rule ->
      destination), the unmatched files, and any collisions. Touches
      nothing on disk.

      With --apply (and zero collisions): performs every move via
      os.Rename, creating destination directories as needed.

      --dest-root <dir>   Root directory under which rule "dest" folders
                           are created. Defaults to <source-dir> itself
                           if omitted.

  preview <source-dir> --rules <rules.json> [--json]
      Read-only shortcut: exactly the dry-run view of "organize" (no
      --apply flag exists for preview; it can never move files). Add
      --json to print the plan as machine-readable JSON instead of text.

  help
      Show this message.

rules file format (JSON):
  {
    "rules": [
      { "name": "images", "match": "*.png,*.jpg,*.jpeg", "dest": "Images" },
      { "name": "documents", "match": "*.pdf,*.docx", "dest": "Documents" }
    ]
  }

  match: comma-separated list of filename glob patterns (shell-style, via
         Go's filepath.Match). Matching is case-insensitive: both the
         filename and each pattern are lowercased before matching, so
         "*.PNG" and "*.png" behave identically.
  dest:  folder name (or relative path) created under --dest-root (or
         under the source directory if --dest-root is omitted).

  Rules are evaluated in file order. The FIRST rule whose pattern matches
  a given file wins; a file matching multiple rules' patterns is only
  ever planned to move once, per its first match.

Run "folderforge organize -h" or "folderforge preview -h" for flag details.
`)
}

// ---------------------------------------------------------------------
// Rules

// Rule is one entry in the rules JSON file: a comma-separated glob pattern
// list ("match") mapped to a destination folder ("dest").
type Rule struct {
	Name  string `json:"name"`
	Match string `json:"match"`
	Dest  string `json:"dest"`
}

// RulesConfig is the top-level shape of the rules JSON file.
type RulesConfig struct {
	Rules []Rule `json:"rules"`
}

func loadRules(path string) (*RulesConfig, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("reading rules file: %w", err)
	}
	var cfg RulesConfig
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parsing rules file %s: %w", path, err)
	}
	return &cfg, nil
}

// matchesRule reports whether filename matches any of rule's comma-separated
// glob patterns. Matching is case-insensitive: both filename and pattern are
// lowercased before comparison via filepath.Match.
func matchesRule(filename string, rule Rule) bool {
	for _, p := range strings.Split(rule.Match, ",") {
		p = strings.ToLower(strings.TrimSpace(p))
		if p == "" {
			continue
		}
		if ok, err := filepath.Match(p, strings.ToLower(filename)); err == nil && ok {
			return true
		}
	}
	return false
}

// ---------------------------------------------------------------------
// Planning

// PlanEntry describes one file that matched a rule and where it would move.
type PlanEntry struct {
	SourcePath string `json:"source"`
	Filename   string `json:"filename"`
	RuleName   string `json:"rule"`
	DestPath   string `json:"dest"`
}

// Collision describes a destination path that more than one planned move
// would target, or that already exists on disk outside this batch.
type Collision struct {
	Dest         string   `json:"dest"`
	Sources      []string `json:"sources"`
	ExistingFile bool     `json:"existing_file"`
}

// buildPlan scans sourceDir (non-recursive) and, for every regular file,
// finds the first matching rule (in rule order). Matched files get a
// PlanEntry; files matching no rule are returned in unmatched.
func buildPlan(sourceDir, destRoot string, cfg *RulesConfig) (matched []PlanEntry, unmatched []string, err error) {
	entries, err := os.ReadDir(sourceDir)
	if err != nil {
		return nil, nil, fmt.Errorf("reading source directory: %w", err)
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		var chosen *Rule
		for i := range cfg.Rules {
			if matchesRule(name, cfg.Rules[i]) {
				chosen = &cfg.Rules[i]
				break
			}
		}
		if chosen == nil {
			unmatched = append(unmatched, name)
			continue
		}
		destPath := filepath.Join(destRoot, chosen.Dest, name)
		matched = append(matched, PlanEntry{
			SourcePath: filepath.Join(sourceDir, name),
			Filename:   name,
			RuleName:   chosen.Name,
			DestPath:   destPath,
		})
	}
	sort.Slice(matched, func(i, j int) bool { return matched[i].Filename < matched[j].Filename })
	sort.Strings(unmatched)
	return matched, unmatched, nil
}

// detectCollisions validates a planned batch of moves. A collision is
// either: (a) two or more matched entries that resolve to the identical
// destination path, or (b) a destination path that already exists on disk
// as a file that is not itself one of the batch's own source files.
func detectCollisions(matched []PlanEntry) []Collision {
	byDest := map[string][]string{}
	for _, m := range matched {
		byDest[m.DestPath] = append(byDest[m.DestPath], m.SourcePath)
	}

	var collisions []Collision
	for dest, sources := range byDest {
		existing := false
		if info, err := os.Lstat(dest); err == nil && !info.IsDir() {
			existing = true
			// If the "existing" file is actually one of this batch's own
			// sources (same path resolved absolutely), that's not a real
			// collision -- it would just be a no-op self-move.
			for _, s := range sources {
				if samePath(s, dest) {
					existing = false
					break
				}
			}
		}
		if len(sources) > 1 || existing {
			sorted := append([]string(nil), sources...)
			sort.Strings(sorted)
			collisions = append(collisions, Collision{Dest: dest, Sources: sorted, ExistingFile: existing})
		}
	}
	sort.Slice(collisions, func(i, j int) bool { return collisions[i].Dest < collisions[j].Dest })
	return collisions
}

func samePath(a, b string) bool {
	absA, errA := filepath.Abs(a)
	absB, errB := filepath.Abs(b)
	if errA != nil || errB != nil {
		return false
	}
	return absA == absB
}

// ---------------------------------------------------------------------
// organize command

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

	valueFlags := map[string]bool{"rules": true, "dest-root": true}
	reordered := reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("organize", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	rulesPath := fs.String("rules", "", "path to rules JSON file (required)")
	destRoot := fs.String("dest-root", "", "root directory for destination folders (default: source-dir)")
	apply := fs.Bool("apply", false, "perform the moves (default: dry run)")
	if err := fs.Parse(reordered); err != nil {
		fmt.Fprintf(os.Stderr, "folderforge organize: %v\n\n", err)
		printOrganizeHelp()
		os.Exit(1)
	}

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "folderforge organize: missing <source-dir>")
		printOrganizeHelp()
		os.Exit(1)
	}
	sourceDir := positional[0]
	if *rulesPath == "" {
		fmt.Fprintln(os.Stderr, "folderforge organize: --rules is required")
		os.Exit(1)
	}

	root := *destRoot
	if root == "" {
		root = sourceDir
	}

	runOrganize(sourceDir, *rulesPath, root, *apply, false)
}

func printOrganizeHelp() {
	fmt.Print(`usage: folderforge organize <source-dir> --rules <rules.json> [--dest-root <dir>] [--apply]

Scans <source-dir> (files directly inside it, non-recursive) and moves each
file into the destination folder of the first rule whose pattern matches
its name. Files matching no rule are left untouched and reported as
"unmatched".

Before moving anything, the full batch is checked for collisions (two
source files resolving to the same destination path, or a destination
path already occupied by an unrelated existing file). If any collision is
found, ALL of them are reported and NOTHING is moved.

flags:
  --rules <file>       Path to rules JSON file (required).
  --dest-root <dir>    Root directory for rule destination folders.
                        Defaults to <source-dir> if omitted.
  --apply              Actually perform the moves. Without this flag,
                        organize is a dry run that changes nothing.
`)
}

// ---------------------------------------------------------------------
// preview command

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

	valueFlags := map[string]bool{"rules": true, "dest-root": true}
	reordered := reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("preview", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	rulesPath := fs.String("rules", "", "path to rules JSON file (required)")
	destRoot := fs.String("dest-root", "", "root directory for destination folders (default: source-dir)")
	asJSON := fs.Bool("json", false, "print the plan as JSON")
	if err := fs.Parse(reordered); err != nil {
		fmt.Fprintf(os.Stderr, "folderforge preview: %v\n\n", err)
		printPreviewHelp()
		os.Exit(1)
	}

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "folderforge preview: missing <source-dir>")
		printPreviewHelp()
		os.Exit(1)
	}
	sourceDir := positional[0]
	if *rulesPath == "" {
		fmt.Fprintln(os.Stderr, "folderforge preview: --rules is required")
		os.Exit(1)
	}

	root := *destRoot
	if root == "" {
		root = sourceDir
	}

	// preview is exactly organize's dry-run view: --apply is never used.
	runOrganize(sourceDir, *rulesPath, root, false, *asJSON)
}

func printPreviewHelp() {
	fmt.Print(`usage: folderforge preview <source-dir> --rules <rules.json> [--dest-root <dir>] [--json]

Read-only equivalent of "folderforge organize" without --apply: shows the
plan (file -> rule -> destination), unmatched files, and any collisions.
Never moves or modifies anything.

flags:
  --rules <file>       Path to rules JSON file (required).
  --dest-root <dir>    Root directory for rule destination folders.
                        Defaults to <source-dir> if omitted.
  --json               Print the plan as machine-readable JSON.
`)
}

// ---------------------------------------------------------------------
// shared execution

type jsonReport struct {
	SourceDir  string      `json:"source_dir"`
	DestRoot   string      `json:"dest_root"`
	Matched    []PlanEntry `json:"matched"`
	Unmatched  []string    `json:"unmatched"`
	Collisions []Collision `json:"collisions"`
	Applied    bool        `json:"applied"`
}

// runOrganize builds the plan, checks for collisions, prints a report, and
// -- only if apply is true and there are zero collisions -- performs the
// moves. Used by both the "organize" and "preview" commands.
func runOrganize(sourceDir, rulesPath, destRoot string, apply bool, asJSON bool) {
	if info, err := os.Stat(sourceDir); err != nil || !info.IsDir() {
		fmt.Fprintf(os.Stderr, "folderforge: source directory not found: %s\n", sourceDir)
		os.Exit(1)
	}

	cfg, err := loadRules(rulesPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "folderforge: %v\n", err)
		os.Exit(1)
	}

	matched, unmatched, err := buildPlan(sourceDir, destRoot, cfg)
	if err != nil {
		fmt.Fprintf(os.Stderr, "folderforge: %v\n", err)
		os.Exit(1)
	}

	collisions := detectCollisions(matched)

	if asJSON {
		rep := jsonReport{
			SourceDir:  sourceDir,
			DestRoot:   destRoot,
			Matched:    matched,
			Unmatched:  unmatched,
			Collisions: collisions,
			Applied:    apply && len(collisions) == 0,
		}
		if rep.Matched == nil {
			rep.Matched = []PlanEntry{}
		}
		if rep.Unmatched == nil {
			rep.Unmatched = []string{}
		}
		if rep.Collisions == nil {
			rep.Collisions = []Collision{}
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		enc.Encode(rep)
		if len(collisions) > 0 {
			os.Exit(1)
		}
		return
	}

	printReport(sourceDir, destRoot, matched, unmatched, collisions, apply, len(cfg.Rules))

	if len(collisions) > 0 {
		fmt.Println()
		fmt.Println("ABORTED: collisions detected. No files were moved.")
		os.Exit(1)
	}

	if !apply {
		fmt.Println()
		fmt.Println("Dry run only. Nothing was moved. Re-run with --apply to perform these moves.")
		return
	}

	moved, err := applyMoves(matched)
	fmt.Println()
	if err != nil {
		fmt.Fprintf(os.Stderr, "folderforge: move failed after %d file(s) moved: %v\n", moved, err)
		os.Exit(1)
	}
	fmt.Printf("Applied: moved %d file(s).\n", moved)
}

func printReport(sourceDir, destRoot string, matched []PlanEntry, unmatched []string, collisions []Collision, apply bool, ruleCount int) {
	fmt.Printf("source:    %s\n", sourceDir)
	fmt.Printf("dest-root: %s\n", destRoot)
	fmt.Printf("rules:     %d loaded\n", ruleCount)
	fmt.Println()

	if len(matched) == 0 && len(unmatched) == 0 {
		fmt.Println("No files found in source directory.")
		return
	}

	if len(matched) > 0 {
		fmt.Printf("Matched (%d):\n", len(matched))
		for _, m := range matched {
			fmt.Printf("  %s  --[%s]-->  %s\n", m.Filename, m.RuleName, m.DestPath)
		}
	} else {
		fmt.Println("Matched: none")
	}

	fmt.Println()
	if len(unmatched) > 0 {
		fmt.Printf("Unmatched (%d, left in place):\n", len(unmatched))
		for _, u := range unmatched {
			fmt.Printf("  %s\n", u)
		}
	} else {
		fmt.Println("Unmatched: none")
	}

	if len(collisions) > 0 {
		fmt.Println()
		fmt.Printf("COLLISIONS (%d):\n", len(collisions))
		for _, c := range collisions {
			if c.ExistingFile && len(c.Sources) == 1 {
				fmt.Printf("  %s  <-- already exists on disk, would be overwritten by: %s\n", c.Dest, c.Sources[0])
			} else if c.ExistingFile {
				fmt.Printf("  %s  <-- already exists on disk AND targeted by %d sources: %s\n", c.Dest, len(c.Sources), strings.Join(c.Sources, ", "))
			} else {
				fmt.Printf("  %s  <-- targeted by %d sources: %s\n", c.Dest, len(c.Sources), strings.Join(c.Sources, ", "))
			}
		}
	}
}

// applyMoves performs every planned move via os.Rename, creating
// destination directories as needed. Returns the count of files
// successfully moved before any error (there should be none, since
// callers only invoke this after detectCollisions found zero collisions).
func applyMoves(matched []PlanEntry) (int, error) {
	moved := 0
	for _, m := range matched {
		destDir := filepath.Dir(m.DestPath)
		if err := os.MkdirAll(destDir, 0o755); err != nil {
			return moved, fmt.Errorf("creating destination directory %s: %w", destDir, err)
		}
		// Belt-and-suspenders re-check: never silently overwrite, even if
		// something changed on disk between planning and applying.
		if _, err := os.Lstat(m.DestPath); err == nil && !samePath(m.SourcePath, m.DestPath) {
			return moved, fmt.Errorf("refusing to overwrite existing file at %s", m.DestPath)
		}
		if err := os.Rename(m.SourcePath, m.DestPath); err != nil {
			return moved, fmt.Errorf("moving %s to %s: %w", m.SourcePath, m.DestPath, err)
		}
		moved++
	}
	return moved, nil
}
