// Command actionforge is a multi-rule, config-file-driven automation daemon.
//
// Unlike its sibling tool MacroDeck (which watches exactly one folder with
// one rule passed as CLI flags), ActionForge loads many independent named
// automation rules from a single JSON config file and runs them all
// simultaneously as one process. Each rule watches its own folder, has its
// own extension filter, and its own shell command — and a failure in one
// rule (e.g. its watch directory does not exist yet) never affects the
// others.
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"sync"
	"time"
)

// ---------------------------------------------------------------------
// Config types
// ---------------------------------------------------------------------

// Rule describes one independent watch-and-run automation.
type Rule struct {
	Name       string   `json:"name"`
	WatchDir   string   `json:"watch_dir"`
	Extensions []string `json:"extensions,omitempty"`
	Run        string   `json:"run"`
}

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

func loadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("config file not found: %s", path)
		}
		return nil, fmt.Errorf("could not read config file %s: %w", path, err)
	}
	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("invalid JSON in config file %s: %w", path, err)
	}
	return &cfg, nil
}

// validateConfig returns a list of human-readable problems. An empty slice
// means the config is valid.
func validateConfig(cfg *Config) []string {
	var problems []string

	if len(cfg.Rules) == 0 {
		problems = append(problems, "config has no rules defined")
		return problems
	}

	seen := make(map[string]int) // name -> count
	for i, r := range cfg.Rules {
		label := fmt.Sprintf("rule[%d]", i)
		if strings.TrimSpace(r.Name) == "" {
			problems = append(problems, fmt.Sprintf("%s: missing \"name\"", label))
		} else {
			label = fmt.Sprintf("rule %q", r.Name)
			seen[r.Name]++
		}
		if strings.TrimSpace(r.WatchDir) == "" {
			problems = append(problems, fmt.Sprintf("%s: missing \"watch_dir\"", label))
		}
		if strings.TrimSpace(r.Run) == "" {
			problems = append(problems, fmt.Sprintf("%s: missing \"run\" command", label))
		}
	}

	var dupNames []string
	for name, count := range seen {
		if count > 1 {
			dupNames = append(dupNames, name)
		}
	}
	sort.Strings(dupNames)
	for _, name := range dupNames {
		problems = append(problems, fmt.Sprintf("duplicate rule name %q (appears %d times)", name, seen[name]))
	}

	return problems
}

// ---------------------------------------------------------------------
// Rule engine state
// ---------------------------------------------------------------------

// ruleState tracks per-rule "known files" so that rules never interfere
// with one another.
type ruleState struct {
	known       map[string]time.Time // absolute path -> last seen mtime
	initialized bool                 // has a first successful scan happened?
	dirWasOK    bool                 // was the watch dir reachable on the previous poll?
}

func newRuleState() *ruleState {
	return &ruleState{known: make(map[string]time.Time)}
}

// triggerEvent describes one rule firing on one file.
type triggerEvent struct {
	RuleName  string    `json:"rule_name"`
	File      string    `json:"file"`
	Command   string    `json:"command"`
	Timestamp time.Time `json:"timestamp"`
}

// matchesExtension reports whether name passes the rule's extension filter.
// An empty filter list matches everything.
func matchesExtension(exts []string, name string) bool {
	if len(exts) == 0 {
		return true
	}
	ext := strings.ToLower(filepath.Ext(name))
	for _, e := range exts {
		if strings.ToLower(e) == ext {
			return true
		}
	}
	return false
}

// scanRule performs a single poll of one rule's watch directory, updates
// its state, and returns the files that should trigger this pass.
//
// triggerBaseline controls whether files present during this rule's very
// first successful scan should be treated as triggers (--run-existing /
// --once semantics) or merely recorded as the starting baseline.
func scanRule(r Rule, st *ruleState, triggerBaseline bool) ([]string, error) {
	entries, err := os.ReadDir(r.WatchDir)
	if err != nil {
		st.dirWasOK = false
		return nil, err
	}

	isFirstScan := !st.initialized
	// If the directory just came back after being missing, treat this
	// scan as a fresh baseline for the rule (but do not touch
	// st.initialized semantics for triggerBaseline decisions beyond the
	// true first scan, to keep behavior predictable and documented).
	recoveredFromMissing := st.initialized && !st.dirWasOK

	var triggered []string
	seenNow := make(map[string]bool)

	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if !matchesExtension(r.Extensions, e.Name()) {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		full := filepath.Join(r.WatchDir, e.Name())
		abs, err := filepath.Abs(full)
		if err != nil {
			abs = full
		}
		seenNow[abs] = true

		prev, known := st.known[abs]
		mtime := info.ModTime()

		switch {
		case !known:
			// New file. Trigger unless this is the baseline scan and the
			// caller did not ask for existing files to fire.
			if !isFirstScan && !recoveredFromMissing {
				triggered = append(triggered, abs)
			} else if triggerBaseline {
				triggered = append(triggered, abs)
			}
		case !mtime.Equal(prev):
			// Existing file changed since we last saw it.
			triggered = append(triggered, abs)
		}
		st.known[abs] = mtime
	}

	// Files removed from the directory drop out of the known set so that
	// if they reappear later they are treated as new again.
	for path := range st.known {
		if !seenNow[path] {
			delete(st.known, path)
		}
	}

	st.initialized = true
	st.dirWasOK = true
	sort.Strings(triggered)
	return triggered, nil
}

// ---------------------------------------------------------------------
// Command execution
// ---------------------------------------------------------------------

func expandCommand(tmpl, file string) string {
	return strings.ReplaceAll(tmpl, "{file}", file)
}

func runShell(cmdStr string) error {
	var cmd *exec.Cmd
	if runtime.GOOS == "windows" {
		cmd = exec.Command("cmd", "/C", cmdStr)
	} else {
		cmd = exec.Command("/bin/sh", "-c", cmdStr)
	}
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

// ---------------------------------------------------------------------
// Daemon loop
// ---------------------------------------------------------------------

type runOptions struct {
	configPath  string
	interval    time.Duration
	once        bool
	runExisting bool
	logPath     string
}

func doRun(opts runOptions) error {
	cfg, err := loadConfig(opts.configPath)
	if err != nil {
		return err
	}
	if problems := validateConfig(cfg); len(problems) > 0 {
		fmt.Fprintln(os.Stderr, "config has problems:")
		for _, p := range problems {
			fmt.Fprintf(os.Stderr, "  - %s\n", p)
		}
		return errors.New("refusing to run with an invalid config (see above; run 'actionforge validate' for details)")
	}

	var logMu sync.Mutex
	var logFile *os.File
	if opts.logPath != "" {
		f, err := os.OpenFile(opts.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err != nil {
			return fmt.Errorf("could not open log file %s: %w", opts.logPath, err)
		}
		defer f.Close()
		logFile = f
	}

	states := make(map[string]*ruleState, len(cfg.Rules))
	for _, r := range cfg.Rules {
		states[r.Name] = newRuleState()
	}

	writeLog := func(ev triggerEvent) {
		if logFile == nil {
			return
		}
		logMu.Lock()
		defer logMu.Unlock()
		enc := json.NewEncoder(logFile)
		_ = enc.Encode(ev)
	}

	handleFire := func(r Rule, file string) {
		cmd := expandCommand(r.Run, file)
		fmt.Printf("[%s] %s -> %s\n", r.Name, file, cmd)
		writeLog(triggerEvent{
			RuleName:  r.Name,
			File:      file,
			Command:   cmd,
			Timestamp: time.Now().UTC(),
		})
		if err := runShell(cmd); err != nil {
			fmt.Fprintf(os.Stderr, "[%s] command error: %v\n", r.Name, err)
		}
	}

	pollAll := func(triggerBaseline bool) {
		for _, r := range cfg.Rules {
			st := states[r.Name]
			files, err := scanRule(r, st, triggerBaseline)
			if err != nil {
				fmt.Fprintf(os.Stderr, "[%s] error: watch dir not available (%v) - will retry\n", r.Name, err)
				continue
			}
			for _, f := range files {
				handleFire(r, f)
			}
		}
	}

	if opts.once {
		// A single pass: everything present counts as a trigger, same as
		// --run-existing applied implicitly for this one pass.
		pollAll(true)
		return nil
	}

	// Continuous daemon mode.
	pollAll(opts.runExisting)
	ticker := time.NewTicker(opts.interval)
	defer ticker.Stop()
	for range ticker.C {
		pollAll(opts.runExisting)
	}
	return nil
}

// ---------------------------------------------------------------------
// CLI plumbing
// ---------------------------------------------------------------------

func usage() {
	fmt.Fprint(os.Stderr, `ActionForge - multi-rule, config-file-driven automation daemon

Usage:
  actionforge run --config <rules.json> [--interval 2s] [--once] [--run-existing] [--log events.log]
  actionforge validate --config <rules.json>
  actionforge -h | --help

Commands:
  run        Watch every rule in the config file concurrently and run each
             rule's command when matching files appear or change.
  validate   Parse and sanity-check a config file without watching anything.

Flags for 'run':
  --config <path>     Path to the JSON rules file (required)
  --interval <dur>    Poll interval, e.g. 500ms, 2s, 1m (default 2s)
  --once              Do a single pass over all rules' directories and exit
  --run-existing       Treat files already present at startup as triggers
  --log <path>        Append a JSON-lines event record per trigger

Flags for 'validate':
  --config <path>     Path to the JSON rules file (required)

Config file format:
  {
    "rules": [
      {
        "name": "screenshots-to-archive",
        "watch_dir": "/tmp/incoming",
        "extensions": [".png", ".jpg"],
        "run": "cp {file} /tmp/archive/"
      }
    ]
  }

  "extensions" is optional; omit or leave empty to match every file in
  that rule's watch_dir. "{file}" in "run" is replaced with the absolute
  path of the triggering file.
`)
}

// reorderFlags moves all "-flag value" / "-flag" pairs before any
// positional arguments, working around flag.Parse's behavior of stopping
// at the first non-flag argument.
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 isHelp(arg string) bool {
	return arg == "-h" || arg == "--help" || arg == "help"
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// 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)
	}
	if isHelp(args[0]) {
		usage()
		os.Exit(0)
	}

	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if isHelp(a) {
			usage()
			os.Exit(0)
		}
	}

	switch cmd {
	case "run":
		cmdRun(rest)
	case "validate":
		cmdValidate(rest)
	default:
		fmt.Fprintf(os.Stderr, "actionforge: unknown command %q\n\n", cmd)
		usage()
		os.Exit(1)
	}
}

func cmdRun(args []string) {
	valueFlags := map[string]bool{"config": true, "interval": true, "log": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("run", flag.ExitOnError)
	configPath := fs.String("config", "", "path to rules JSON config file")
	interval := fs.Duration("interval", 2*time.Second, "poll interval")
	once := fs.Bool("once", false, "single pass over all rules, then exit")
	runExisting := fs.Bool("run-existing", false, "treat pre-existing files as triggers")
	logPath := fs.String("log", "", "path to JSON-lines event log")

	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}

	if *configPath == "" {
		fmt.Fprintln(os.Stderr, "actionforge run: --config is required")
		usage()
		os.Exit(1)
	}

	err := doRun(runOptions{
		configPath:  *configPath,
		interval:    *interval,
		once:        *once,
		runExisting: *runExisting,
		logPath:     *logPath,
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "actionforge run: %v\n", err)
		os.Exit(1)
	}
}

func cmdValidate(args []string) {
	valueFlags := map[string]bool{"config": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("validate", flag.ExitOnError)
	configPath := fs.String("config", "", "path to rules JSON config file")
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}

	if *configPath == "" {
		fmt.Fprintln(os.Stderr, "actionforge validate: --config is required")
		usage()
		os.Exit(1)
	}

	cfg, err := loadConfig(*configPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "actionforge validate: %v\n", err)
		os.Exit(1)
	}

	problems := validateConfig(cfg)
	if len(problems) == 0 {
		fmt.Printf("OK: %s is valid (%d rule(s))\n", *configPath, len(cfg.Rules))
		return
	}

	fmt.Printf("INVALID: %s has %d problem(s):\n", *configPath, len(problems))
	for _, p := range problems {
		fmt.Printf("  - %s\n", p)
	}
	os.Exit(1)
}
