// Command windowdeck is the keyboard-shortcut authority for a team: it
// normalises everyone's hotkey binding sets, finds every conflict between them,
// the operating system and a team standard, and merges them into one
// conflict-free deck.
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"time"
)

const (
	appName    = "windowdeck"
	appVersion = "1.0.0"
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (verbatim across the tool 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...)
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

// usage writes the manual to stderr; usageTo writes it anywhere. Explicit help
// goes to stdout and exits 0, a bad invocation goes to stderr and exits 1.
func usage() { usageTo(os.Stderr) }

func usageTo(w io.Writer) {
	fmt.Fprintf(w, `%s - keyboard binding conflict authority (Techlosoft Workspace Center)

USAGE
  %s check   --set <file.json> [--set <file.json> ...] [--base <file.json>] [--strict] [--json]
  %s merge   --set <file.json> [--set <file.json> ...] [--base <file.json>] --out <deck.json> [--force] [--json]
  %s explain <chord> --set <file.json> [...] [--base <file.json>] [--json]
  %s export  --set <file.json> [...] [--base <file.json>] --format json|csv|md [--out <file>]
  %s help | -h | --help

COMMANDS
  check      Normalise every binding in every set and report every conflict:
             one chord claimed by two actions, the same action bound
             differently in different sets, multi-chord sequences where one is
             a strict prefix of another, collisions that only appear after
             normalisation, and collisions with the built-in Windows and macOS
             reserved-chord tables.
  merge      Resolve the conflicts with a documented precedence and write a
             single conflict-free deck to a NEW file. Every decision is
             explained. The result is order-independent: merging the same sets
             in a different order produces an identical deck.
  explain    Show who binds one chord, everywhere: which sets, which actions,
             how each file spells it, what the OS does with it, and any
             prefix relationships with multi-chord sequences.
  export     Render the merged deck as json, csv or md.

FLAGS
  --set <file>      A binding-set file (JSON). Repeat for each person. Bare
                    positional arguments are treated as sets too.
  --base <file>     The team standard. Its bindings win over personal sets
                    unless a personal binding is marked "override": true.
  --out <file>      Where to write. Required by merge, optional for export
                    (stdout when omitted). merge refuses to write over any
                    input file, and refuses an existing file unless --force.
  --format <fmt>    export only: json, csv or md.
  --ledger <file>   Append one JSON-lines audit record for this run.
  --suggest <n>     How many free replacement chords to propose per unresolved
                    conflict. Default 3, 0 disables.
  --strict          check only: exit non-zero on warnings as well as errors.
  --force           Allow --out to overwrite an existing (non-input) file.
  --json            Machine-readable JSON output.

CHORD NOTATION
  Ctrl+Shift+P   ctrl-shift-p   ^⇧P   Cmd+Opt+4   Super+Space   F13
  Ctrl+Alt+NumpadAdd            Ctrl+K Ctrl+S (a two-chord sequence)
  Cmd, Command, Super, Win and Windows all mean Meta. Opt and Option mean Alt.
  Canonical order is Ctrl, Alt, Shift, Meta.

EXIT CODES
  0  success, no conflicts   1  bad invocation or I/O error   2  conflicts found

EXAMPLES
  %s check --set alice.json --set bob.json --base team.json
  %s merge --set alice.json --set bob.json --base team.json --out deck.json
  %s explain "Ctrl+Shift+P" --set alice.json --set bob.json
  %s export --set alice.json --base team.json --format md --out DECK.md

Flags may appear before or after positional arguments.
`, appName, appName, appName, appName, appName, appName, appName, appName, appName, appName)
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", appName, fmt.Sprintf(format, args...))
	os.Exit(1)
}

func usageErr(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n\n", appName, fmt.Sprintf(format, args...))
	usage()
	os.Exit(1)
}

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

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)
	}
	switch args[0] {
	case "help", "-h", "--help":
		usage2stdout()
		os.Exit(0)
	}
	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usage2stdout()
			os.Exit(0)
		}
	}
	switch cmd {
	case "check":
		cmdCheck(rest)
	case "merge":
		cmdMerge(rest)
	case "explain":
		cmdExplain(rest)
	case "export":
		cmdExport(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

// usage2stdout prints the manual on stdout, for explicit help.
func usage2stdout() { usageTo(os.Stdout) }

// ---------------------------------------------------------------------------
// Flag plumbing
// ---------------------------------------------------------------------------

type stringList []string

func (s *stringList) String() string { return strings.Join(*s, ",") }

func (s *stringList) Set(v string) error {
	if strings.TrimSpace(v) == "" {
		return errors.New("empty value")
	}
	*s = append(*s, v)
	return nil
}

var valueFlags = map[string]bool{
	"set": true, "s": true,
	"base": true, "b": true,
	"out": true, "o": true,
	"ledger": true, "l": true,
	"format": true, "f": true,
	"suggest": true, "n": true,
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	return fs
}

// commonFlags is the set of flags shared by every subcommand.
type commonFlags struct {
	fs      *flag.FlagSet
	sets    stringList
	base    *string
	ledger  *string
	suggest *int
	asJSON  *bool
}

func newCommonFlags(name string) *commonFlags {
	c := &commonFlags{fs: newFlagSet(name)}
	c.fs.Var(&c.sets, "set", "binding-set file (repeatable)")
	c.fs.Var(&c.sets, "s", "shorthand for --set")
	c.base = c.fs.String("base", "", "team standard binding set")
	c.fs.StringVar(c.base, "b", "", "shorthand for --base")
	c.ledger = c.fs.String("ledger", "", "JSON-lines audit file to append to")
	c.fs.StringVar(c.ledger, "l", "", "shorthand for --ledger")
	c.suggest = c.fs.Int("suggest", 3, "free chords to propose per unresolved conflict")
	c.fs.IntVar(c.suggest, "n", 3, "shorthand for --suggest")
	c.asJSON = c.fs.Bool("json", false, "JSON output")
	return c
}

// load parses argv, folds bare positional arguments into --set, and reads the
// files. Any leftover positionals not consumed as sets are returned.
func (c *commonFlags) load(argv []string, takePositional bool) (Inputs, []string) {
	if err := c.fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	extra := c.fs.Args()
	if takePositional {
		c.sets = append(c.sets, extra...)
		extra = nil
	}
	if len(c.sets) == 0 {
		usageErr("%s needs at least one --set <file.json>", c.fs.Name())
	}
	if *c.suggest < 0 {
		usageErr("--suggest must not be negative")
	}
	in, err := loadInputs(*c.base, c.sets)
	if err != nil {
		fail("%v", err)
	}
	return in, extra
}

func encodeJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fail("%v", err)
	}
}

// ---------------------------------------------------------------------------
// Ledger
// ---------------------------------------------------------------------------

// LedgerInput records one input file and the exact bytes that were read.
type LedgerInput struct {
	Set      string `json:"set"`
	File     string `json:"file"`
	SHA256   string `json:"sha256"`
	Bindings int    `json:"bindings"`
	Base     bool   `json:"base,omitempty"`
}

// LedgerRecord is one JSON line of the audit file.
type LedgerRecord struct {
	TS       time.Time     `json:"ts"`
	Tool     string        `json:"tool"`
	Version  string        `json:"version"`
	Command  string        `json:"command"`
	Args     []string      `json:"args"`
	Base     string        `json:"base,omitempty"`
	Inputs   []LedgerInput `json:"inputs"`
	Bindings int           `json:"bindings_in"`
	Conflict int           `json:"conflicts"`
	Errors   int           `json:"conflicts_error"`
	Warnings int           `json:"conflicts_warning"`
	Deck     int           `json:"deck_bindings,omitempty"`
	Output   string        `json:"output,omitempty"`
	Status   string        `json:"status"`
}

func newLedgerRecord(cmd string, argv []string, in Inputs) LedgerRecord {
	rec := LedgerRecord{
		TS: time.Now().UTC(), Tool: appName, Version: appVersion,
		Command: cmd, Args: argv, Base: in.BaseFile, Bindings: len(in.Bindings),
		Status: "ok",
	}
	if rec.Args == nil {
		rec.Args = []string{}
	}
	for _, s := range in.Sets {
		rec.Inputs = append(rec.Inputs, LedgerInput{Set: s.Name, File: s.File, SHA256: s.SHA256, Bindings: s.Count, Base: s.IsBase})
	}
	return rec
}

func appendLedger(path string, rec LedgerRecord) {
	if path == "" {
		return
	}
	line, err := json.Marshal(rec)
	if err != nil {
		fail("cannot encode ledger record: %v", err)
	}
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			fail("cannot create %s: %v", dir, err)
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		fail("cannot open ledger %s: %v", path, err)
	}
	if _, err := f.Write(append(line, '\n')); err != nil {
		f.Close()
		fail("cannot append to ledger %s: %v", path, err)
	}
	if err := f.Close(); err != nil {
		fail("cannot close ledger %s: %v", path, err)
	}
}

// ---------------------------------------------------------------------------
// check
// ---------------------------------------------------------------------------

// cmdCheck is the command-line entry point: it prints the conflict report and
// exits 2 when there is something to fix, so a script can tell.
func cmdCheck(argv []string) {
	if runCheck(argv) {
		os.Exit(2)
	}
}

// runCheck does the work and reports whether anything needs fixing instead of
// exiting on it. The exit lives in cmdCheck so that the guided session, which
// is the double-clicked-in-Explorer path, can print exactly the same report and
// still reach its "press Enter to close" prompt — os.Exit(2) in the middle of
// that would take the console window down with it, which is the very bug the
// guided session exists to fix.
func runCheck(argv []string) bool {
	c := newCommonFlags("check")
	strict := c.fs.Bool("strict", false, "treat warnings as failures too")
	in, _ := c.load(argv, true)

	conflicts := Analyse(in, *c.suggest)
	errs, warns := countSeverities(conflicts)

	rec := newLedgerRecord("check", argv, in)
	rec.Conflict, rec.Errors, rec.Warnings = len(conflicts), errs, warns
	if errs > 0 || (*strict && warns > 0) {
		rec.Status = "conflicts"
	}
	appendLedger(*c.ledger, rec)

	if *c.asJSON {
		encodeJSON(map[string]any{
			"tool":              appName,
			"version":           appVersion,
			"generated_at":      rec.TS.Format(time.RFC3339),
			"base":              in.BaseFile,
			"sets":              in.Sets,
			"bindings":          len(in.Bindings),
			"conflicts":         conflicts,
			"conflicts_error":   errs,
			"conflicts_warning": warns,
			"strict":            *strict,
			"status":            rec.Status,
		})
	} else {
		printConflicts(os.Stdout, in, conflicts, *strict)
	}
	return rec.Status == "conflicts"
}

// ---------------------------------------------------------------------------
// merge
// ---------------------------------------------------------------------------

func cmdMerge(argv []string) {
	c := newCommonFlags("merge")
	out := c.fs.String("out", "", "file to write the merged deck to")
	c.fs.StringVar(out, "o", "", "shorthand for --out")
	force := c.fs.Bool("force", false, "allow overwriting an existing output file")
	in, _ := c.load(argv, true)

	if *out == "" {
		usageErr("merge needs --out <deck.json> - it never writes over an input set")
	}
	outAbs, err := filepath.Abs(*out)
	if err != nil {
		fail("cannot resolve --out %q: %v", *out, err)
	}
	for _, s := range in.Sets {
		abs, err := filepath.Abs(s.File)
		if err != nil {
			fail("cannot resolve %q: %v", s.File, err)
		}
		if abs == outAbs {
			fail("--out %s is an input set (%s) - merge never overwrites an input", *out, s.File)
		}
	}
	if _, err := os.Stat(outAbs); err == nil && !*force {
		fail("--out %s already exists - pass --force to replace it", *out)
	} else if err != nil && !os.IsNotExist(err) {
		fail("cannot stat --out %s: %v", *out, err)
	}

	deck := Merge(in, *c.suggest)
	conflicts := Analyse(in, 0)
	errs, warns := countSeverities(conflicts)

	rec := newLedgerRecord("merge", argv, in)
	rec.Conflict, rec.Errors, rec.Warnings = len(conflicts), errs, warns
	rec.Deck = len(deck.Bindings)
	rec.Output = *out

	doc := map[string]any{
		"tool":         appName,
		"version":      appVersion,
		"generated_at": rec.TS.Format(time.RFC3339),
		"deck":         deck,
	}
	body, err := json.MarshalIndent(doc, "", "  ")
	if err != nil {
		fail("cannot encode deck: %v", err)
	}
	if dir := filepath.Dir(outAbs); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			fail("cannot create %s: %v", dir, err)
		}
	}
	if err := os.WriteFile(outAbs, append(body, '\n'), 0o644); err != nil {
		fail("cannot write %s: %v", *out, err)
	}
	appendLedger(*c.ledger, rec)

	if *c.asJSON {
		encodeJSON(doc)
		return
	}
	printDeck(os.Stdout, deck, *out)
}

// ---------------------------------------------------------------------------
// explain
// ---------------------------------------------------------------------------

func cmdExplain(argv []string) {
	c := newCommonFlags("explain")
	in, extra := c.load(argv, false)
	if len(extra) == 0 {
		usageErr("explain needs a chord, for example: %s explain \"Ctrl+Shift+P\" --set alice.json", appName)
	}
	query := strings.Join(extra, " ")
	seq, err := ParseSequence(query)
	if err != nil {
		fail("%v", err)
	}
	deck := Merge(in, 0)
	ex := Explain(in, query, seq, &deck)

	rec := newLedgerRecord("explain", argv, in)
	rec.Deck = len(deck.Bindings)
	appendLedger(*c.ledger, rec)

	if *c.asJSON {
		encodeJSON(map[string]any{
			"tool":         appName,
			"version":      appVersion,
			"generated_at": rec.TS.Format(time.RFC3339),
			"explanation":  ex,
		})
		return
	}
	printExplanation(os.Stdout, ex)
}

// ---------------------------------------------------------------------------
// export
// ---------------------------------------------------------------------------

func cmdExport(argv []string) {
	c := newCommonFlags("export")
	format := c.fs.String("format", "", "json, csv or md")
	c.fs.StringVar(format, "f", "", "shorthand for --format")
	out := c.fs.String("out", "", "file to write to (stdout when omitted)")
	c.fs.StringVar(out, "o", "", "shorthand for --out")
	force := c.fs.Bool("force", false, "allow overwriting an existing output file")
	in, _ := c.load(argv, true)

	f := strings.ToLower(strings.TrimSpace(*format))
	if f == "" && *c.asJSON {
		f = "json"
	}
	switch f {
	case "json", "csv", "md":
	case "":
		usageErr("export needs --format json|csv|md")
	default:
		usageErr("unknown --format %q - use json, csv or md", *format)
	}

	deck := Merge(in, *c.suggest)
	rec := newLedgerRecord("export", argv, in)
	rec.Deck = len(deck.Bindings)
	rec.Output = *out

	var buf bytes.Buffer
	switch f {
	case "json":
		doc := map[string]any{
			"tool":         appName,
			"version":      appVersion,
			"generated_at": rec.TS.Format(time.RFC3339),
			"deck":         deck,
		}
		body, err := json.MarshalIndent(doc, "", "  ")
		if err != nil {
			fail("cannot encode deck: %v", err)
		}
		buf.Write(append(body, '\n'))
	case "csv":
		if err := exportCSV(&buf, deck); err != nil {
			fail("cannot write CSV: %v", err)
		}
	case "md":
		if err := exportMarkdown(&buf, deck); err != nil {
			fail("cannot write Markdown: %v", err)
		}
	}

	if *out == "" {
		os.Stdout.Write(buf.Bytes())
		appendLedger(*c.ledger, rec)
		return
	}
	outAbs, err := filepath.Abs(*out)
	if err != nil {
		fail("cannot resolve --out %q: %v", *out, err)
	}
	for _, s := range in.Sets {
		abs, err := filepath.Abs(s.File)
		if err != nil {
			fail("cannot resolve %q: %v", s.File, err)
		}
		if abs == outAbs {
			fail("--out %s is an input set (%s) - export never overwrites an input", *out, s.File)
		}
	}
	if _, err := os.Stat(outAbs); err == nil && !*force {
		fail("--out %s already exists - pass --force to replace it", *out)
	} else if err != nil && !os.IsNotExist(err) {
		fail("cannot stat --out %s: %v", *out, err)
	}
	if dir := filepath.Dir(outAbs); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			fail("cannot create %s: %v", dir, err)
		}
	}
	if err := os.WriteFile(outAbs, buf.Bytes(), 0o644); err != nil {
		fail("cannot write %s: %v", *out, err)
	}
	appendLedger(*c.ledger, rec)
	fmt.Printf("wrote %s (%s, %d bindings)\n", *out, f, len(deck.Bindings))
}
