package main

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

// ---------------------------------------------------------------------------
// Parser: normalisation round-trips
// ---------------------------------------------------------------------------

func TestParseChordNormalises(t *testing.T) {
	cases := []struct {
		in   string
		want string
	}{
		{"Ctrl+Shift+P", "Ctrl+Shift+P"},
		{"ctrl-shift-p", "Ctrl+Shift+P"},
		{"CTRL+SHIFT+P", "Ctrl+Shift+P"},
		{"Shift+Ctrl+P", "Ctrl+Shift+P"}, // input order does not matter
		{"^⇧P", "Ctrl+Shift+P"},          // caret + U+21E7 shift glyph
		{"⌃⇧p", "Ctrl+Shift+P"},          // U+2303 control glyph
		{"Cmd+Opt+4", "Alt+Meta+4"},      // canonical order is Ctrl,Alt,Shift,Meta
		{"⌘⌥4", "Alt+Meta+4"},            // the same chord as glyphs
		{"Super+Space", "Meta+Space"},    //
		{"win+space", "Meta+Space"},      //
		{"Windows+Space", "Meta+Space"},  //
		{"F13", "F13"},                   // no modifiers at all
		{"f24", "F24"},                   //
		{"Ctrl+Alt+NumpadAdd", "Ctrl+Alt+NumpadAdd"},
		{"ctrl-alt-kp_add", "Ctrl+Alt+NumpadAdd"},
		{"Ctrl++", "Ctrl+Plus"},          // '+' as the key
		{"Ctrl+-", "Ctrl+Minus"},         // '-' as the key
		{"cmd--", "Meta+Minus"},          // '-' separator then '-' key
		{"Ctrl+^", "Ctrl+Caret"},         // trailing caret is a key, not a modifier
		{"Meta+.", "Meta+Period"},        //
		{"Alt+Escape", "Alt+Escape"},     //
		{"alt+esc", "Alt+Escape"},        //
		{"Control+Return", "Ctrl+Enter"}, //
		{"OPTION+pgdn", "Alt+PageDown"},  //
		{"⌘⇧4", "Shift+Meta+4"},          // pasted straight out of a mac menu
		{"  Ctrl + Shift + P  ", ""},     // spaces inside a chord are a sequence
	}
	for _, c := range cases {
		if c.want == "" {
			continue
		}
		t.Run(c.in, func(t *testing.T) {
			got, err := ParseChord(c.in)
			if err != nil {
				t.Fatalf("ParseChord(%q) failed: %v", c.in, err)
			}
			if got.String() != c.want {
				t.Fatalf("ParseChord(%q) = %q, want %q", c.in, got.String(), c.want)
			}
			// Round-trip: the canonical form must re-parse to itself.
			again, err := ParseChord(got.String())
			if err != nil {
				t.Fatalf("canonical form %q does not re-parse: %v", got.String(), err)
			}
			if again.String() != c.want {
				t.Fatalf("round-trip of %q gave %q, want %q", c.in, again.String(), c.want)
			}
		})
	}
}

func TestParseSequence(t *testing.T) {
	cases := []struct{ in, want string }{
		{"Ctrl+K Ctrl+S", "Ctrl+K Ctrl+S"},
		{"ctrl-k   ctrl-s", "Ctrl+K Ctrl+S"},
		{"Ctrl + Shift + P", "Ctrl Shift P"}, // spaces really do split chords
	}
	for _, c := range cases {
		if c.in == "Ctrl + Shift + P" {
			// "Ctrl" alone is a modifier with no key, so this must be an error,
			// not a silent three-chord sequence.
			if _, err := ParseSequence(c.in); err == nil {
				t.Fatalf("ParseSequence(%q) should fail: a chord may not be split by spaces", c.in)
			}
			continue
		}
		got, err := ParseSequence(c.in)
		if err != nil {
			t.Fatalf("ParseSequence(%q): %v", c.in, err)
		}
		if got.String() != c.want {
			t.Fatalf("ParseSequence(%q) = %q, want %q", c.in, got.String(), c.want)
		}
	}
}

// ---------------------------------------------------------------------------
// Parser: rejections must name the offending token
// ---------------------------------------------------------------------------

func TestParseChordRejects(t *testing.T) {
	cases := []struct {
		in       string
		mustName string // substring the error is required to contain
	}{
		{"", "empty chord"},
		{"   ", "empty chord"},
		{"Ctrl+Zork+P", `"Zork"`},
		{"Ctrl+Frobnicate", `"Frobnicate"`},
		{"Ctrl", `"Ctrl"`},
		{"Shift", `"Shift"`},
		{"Ctrl+Shift", `"Shift"`},
		{"Ctrl+Ctrl+P", "repeated"},
		{"ctrl-control-p", "repeated"},
		{"Ctrl+", "separator"},
		{"Ctrl+F25", `"F25"`},
		{"Ctrl+⇧", `"⇧"`},
		{"P+Ctrl", `"P"`},
	}
	for _, c := range cases {
		t.Run(c.in, func(t *testing.T) {
			got, err := ParseChord(c.in)
			if err == nil {
				t.Fatalf("ParseChord(%q) unexpectedly succeeded as %q", c.in, got.String())
			}
			if !strings.Contains(err.Error(), c.mustName) {
				t.Fatalf("ParseChord(%q) error %q does not name the offending token (want %s)", c.in, err.Error(), c.mustName)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Alias equivalence
// ---------------------------------------------------------------------------

func TestAliasEquivalenceClasses(t *testing.T) {
	classes := [][]string{
		{"Cmd+Shift+4", "⌘⇧4", "Command+Shift+4", "Meta+Shift+4", "super-shift-4", "Win+Shift+4", "shift-cmd-4"},
		{"Ctrl+Shift+P", "ctrl-shift-p", "^⇧P", "⌃⇧P", "Control+Shift+p", "SHIFT+CTRL+P"},
		{"Cmd+Opt+4", "⌘⌥4", "Meta+Alt+4", "option-command-4"},
		{"Ctrl+Alt+NumpadAdd", "control-option-numpadplus", "Ctrl+Alt+kp_add"},
		{"Super+Space", "Win+Space", "Cmd+Space", "⌘Space", "meta-spc"},
	}
	for _, class := range classes {
		first, err := ParseChord(class[0])
		if err != nil {
			t.Fatalf("ParseChord(%q): %v", class[0], err)
		}
		for _, alt := range class[1:] {
			got, err := ParseChord(alt)
			if err != nil {
				t.Fatalf("ParseChord(%q): %v", alt, err)
			}
			if got != first {
				t.Errorf("%q normalised to %q but %q normalised to %q - they must be the same chord",
					class[0], first.String(), alt, got.String())
			}
		}
	}
}

// ---------------------------------------------------------------------------
// Reserved tables
// ---------------------------------------------------------------------------

func TestReservedTablesParse(t *testing.T) {
	if len(reservedRaw) == 0 {
		t.Fatal("the reserved tables are empty")
	}
	var win, mac int
	for _, r := range reservedRaw {
		seq, err := ParseSequence(r.Chord)
		if err != nil {
			t.Errorf("reserved entry %q does not parse: %v", r.Chord, err)
			continue
		}
		if len(seq) != 1 {
			t.Errorf("reserved entry %q is not a single chord", r.Chord)
		}
		if r.Does == "" || r.Source == "" {
			t.Errorf("reserved entry %q has no description or no source", r.Chord)
		}
		switch r.OS {
		case OSWindows:
			win++
		case OSMacOS:
			mac++
		default:
			t.Errorf("reserved entry %q has unknown os %q", r.Chord, r.OS)
		}
	}
	if win == 0 || mac == 0 {
		t.Fatalf("expected entries for both operating systems, got windows=%d macos=%d", win, mac)
	}
}

func TestReservedDetectionPerOS(t *testing.T) {
	cases := []struct {
		chord  string
		os     string
		mentks string // substring expected in the description
	}{
		{"Ctrl+Alt+Del", OSWindows, "security screen"},
		{"Win+L", OSWindows, "lock the workstation"},
		{"win-l", OSWindows, "lock the workstation"},
		{"Alt+F4", OSWindows, "close the active window"},
		{"Ctrl+Shift+Escape", OSWindows, "Task Manager"},
		{"Cmd+Space", OSMacOS, "Spotlight"},
		{"⌘Space", OSMacOS, "Spotlight"},
		{"Cmd+Tab", OSMacOS, "switch between open applications"},
		{"⌘⇧4", OSMacOS, "selected area"},
		{"Ctrl+Cmd+Q", OSMacOS, "lock the screen"},
	}
	for _, c := range cases {
		t.Run(c.chord+"/"+c.os, func(t *testing.T) {
			seq, err := ParseSequence(c.chord)
			if err != nil {
				t.Fatalf("ParseSequence(%q): %v", c.chord, err)
			}
			hits := reservedHits(seq)
			if len(hits) == 0 {
				t.Fatalf("%q (canonically %s) was not detected as reserved at all", c.chord, seq)
			}
			var found bool
			for _, h := range hits {
				if h.OS == c.os && strings.Contains(h.Does, c.mentks) {
					found = true
				}
			}
			if !found {
				t.Fatalf("%q was not reported as reserved on %s with %q; got %+v", c.chord, c.os, c.mentks, hits)
			}
		})
	}
}

func TestReservedIsPerOSNotGlobal(t *testing.T) {
	// Win+L is a Windows reservation and must NOT be attributed to macOS.
	seq, err := ParseSequence("Win+L")
	if err != nil {
		t.Fatal(err)
	}
	for _, h := range reservedHits(seq) {
		if h.OS == OSMacOS {
			t.Fatalf("Win+L must not be reported as reserved on macOS, got %+v", h)
		}
	}
	// F11 is claimed by BOTH, with different meanings.
	seq, err = ParseSequence("F11")
	if err != nil {
		t.Fatal(err)
	}
	hits := reservedHits(seq)
	oses := map[string]bool{}
	for _, h := range hits {
		oses[h.OS] = true
	}
	if !oses[OSWindows] || !oses[OSMacOS] {
		t.Fatalf("F11 should be reserved on both operating systems, got %+v", hits)
	}
	// Something entirely made up must be free.
	seq, err = ParseSequence("Ctrl+Alt+Shift+F19")
	if err != nil {
		t.Fatal(err)
	}
	if hits := reservedHits(seq); len(hits) != 0 {
		t.Fatalf("Ctrl+Alt+Shift+F19 should not be reserved, got %+v", hits)
	}
}

func TestReservedDetectedInsideASequence(t *testing.T) {
	seq, err := ParseSequence("Ctrl+K Cmd+Space")
	if err != nil {
		t.Fatal(err)
	}
	hits := reservedHits(seq)
	if len(hits) == 0 {
		t.Fatal("a reserved chord in the second position was not detected")
	}
	if hits[0].Position != 2 {
		t.Fatalf("expected the hit at position 2, got %d", hits[0].Position)
	}
}

// ---------------------------------------------------------------------------
// Test fixtures on disk
// ---------------------------------------------------------------------------

func writeSet(t *testing.T, dir, name string, bs BindingSet) string {
	t.Helper()
	p := filepath.Join(dir, name)
	body, err := json.MarshalIndent(bs, "", "  ")
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(p, body, 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

// fixture builds the three-set scenario used by the merge tests.
func fixture(t *testing.T) (base string, sets []string) {
	t.Helper()
	dir := t.TempDir()
	base = writeSet(t, dir, "team.json", BindingSet{
		Set: "team-standard",
		Bindings: []Binding{
			{Action: "command.palette", Chord: "Ctrl+Shift+P"},
			{Action: "pane.split.right", Chord: "Ctrl+Alt+D"},
			{Action: "workspace.save", Chord: "Ctrl+K Ctrl+S"},
			{Action: "terminal.toggle", Chord: "Ctrl+Backquote"},
		},
	})
	a := writeSet(t, dir, "alice.json", BindingSet{
		Set: "alice",
		Bindings: []Binding{
			{Action: "command.palette", Chord: "^⇧P"},
			{Action: "pane.split.right", Chord: "Cmd+Opt+D", Override: true},
			{Action: "workspace.save", Chord: "Ctrl+K"},
			{Action: "focus.next", Chord: "Ctrl+Alt+NumpadAdd"},
		},
	})
	b := writeSet(t, dir, "bob.json", BindingSet{
		Set: "bob",
		Bindings: []Binding{
			{Action: "build.run", Chord: "ctrl-shift-p"},
			{Action: "window.lock", Chord: "Win+L"},
			{Action: "focus.previous", Chord: "Ctrl+Alt+NumpadSubtract"},
			// Equal-priority fight with carol below: two personal sets, neither
			// marked override, both claiming Ctrl+Shift+F for a different
			// action. The tie must break on the set NAME, not on file order.
			{Action: "git.blame", Chord: "Ctrl+Shift+F"},
		},
	})
	c := writeSet(t, dir, "carol.json", BindingSet{
		Set: "carol",
		Bindings: []Binding{
			{Action: "notes.open", Chord: "Ctrl+Alt+N"},
			{Action: "deck.search", Chord: "Ctrl+Shift+F"},
		},
	})
	return base, []string{a, b, c}
}

func mustLoad(t *testing.T, base string, sets []string) Inputs {
	t.Helper()
	in, err := loadInputs(base, sets)
	if err != nil {
		t.Fatalf("loadInputs: %v", err)
	}
	return in
}

// ---------------------------------------------------------------------------
// Conflict analysis
// ---------------------------------------------------------------------------

func conflictsOfKind(cs []Conflict, kind string) []Conflict {
	var out []Conflict
	for _, c := range cs {
		if c.Kind == kind {
			out = append(out, c)
		}
	}
	return out
}

func TestAnalyseFindsEachConflictKind(t *testing.T) {
	base, sets := fixture(t)
	in := mustLoad(t, base, sets)
	cs := Analyse(in, 3)

	// Alias collision: Ctrl+Shift+P is spelled three different ways and is
	// claimed by both command.palette and build.run.
	alias := conflictsOfKind(cs, KindAlias)
	if len(alias) != 1 {
		t.Fatalf("expected exactly 1 alias-collision, got %d: %+v", len(alias), alias)
	}
	if alias[0].Chord != "Ctrl+Shift+P" {
		t.Errorf("alias collision reported on %q, want Ctrl+Shift+P", alias[0].Chord)
	}
	if alias[0].Severity != SevError {
		t.Errorf("alias collision severity = %q, want %q", alias[0].Severity, SevError)
	}
	if len(alias[0].Involved) != 3 {
		t.Errorf("expected 3 parties in the alias collision, got %d", len(alias[0].Involved))
	}

	// Prefix conflict: Ctrl+K vs Ctrl+K Ctrl+S.
	pre := conflictsOfKind(cs, KindPrefix)
	if len(pre) != 1 {
		t.Fatalf("expected exactly 1 prefix-sequence conflict, got %d: %+v", len(pre), pre)
	}
	if pre[0].Chord != "Ctrl+K" {
		t.Errorf("prefix conflict reported on %q, want Ctrl+K", pre[0].Chord)
	}

	// Divergent action: pane.split.right and workspace.save.
	div := conflictsOfKind(cs, KindDivergent)
	got := map[string]bool{}
	for _, c := range div {
		got[c.Action] = true
	}
	for _, want := range []string{"pane.split.right", "workspace.save"} {
		if !got[want] {
			t.Errorf("expected a divergent-action conflict for %q, got %v", want, got)
		}
	}

	// Reserved: Win+L on Windows, Alt+Meta+D on macOS.
	res := conflictsOfKind(cs, KindReserved)
	seen := map[string]string{}
	for _, c := range res {
		seen[c.Chord] = c.OS
	}
	if seen["Meta+L"] != OSWindows {
		t.Errorf("expected Meta+L flagged as reserved on windows, got %v", seen)
	}
	if seen["Alt+Meta+D"] != OSMacOS {
		t.Errorf("expected Alt+Meta+D flagged as reserved on macos, got %v", seen)
	}
}

func TestAnalyseIsOrderIndependent(t *testing.T) {
	base, sets := fixture(t)
	want := jsonOf(t, Analyse(mustLoad(t, base, sets), 3))
	for _, perm := range permutations(sets) {
		got := jsonOf(t, Analyse(mustLoad(t, base, perm), 3))
		if got != want {
			t.Fatalf("conflict report changed with input order %v", perm)
		}
	}
}

func TestNoConflictsOnACleanSet(t *testing.T) {
	dir := t.TempDir()
	p := writeSet(t, dir, "clean.json", BindingSet{
		Set: "clean",
		Bindings: []Binding{
			{Action: "a", Chord: "Ctrl+Alt+Shift+F19"},
			{Action: "b", Chord: "Ctrl+Alt+Shift+F20"},
		},
	})
	cs := Analyse(mustLoad(t, "", []string{p}), 3)
	if len(cs) != 0 {
		t.Fatalf("expected no conflicts, got %+v", cs)
	}
}

// ---------------------------------------------------------------------------
// Merge: determinism, precedence, override
// ---------------------------------------------------------------------------

func jsonOf(t *testing.T, v any) string {
	t.Helper()
	b, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		t.Fatal(err)
	}
	return string(b)
}

// permutations returns every ordering of s (s is small).
func permutations(s []string) [][]string {
	if len(s) <= 1 {
		return [][]string{append([]string(nil), s...)}
	}
	var out [][]string
	for i := range s {
		rest := make([]string, 0, len(s)-1)
		rest = append(rest, s[:i]...)
		rest = append(rest, s[i+1:]...)
		for _, p := range permutations(rest) {
			out = append(out, append([]string{s[i]}, p...))
		}
	}
	return out
}

func TestMergeIsOrderIndependent(t *testing.T) {
	base, sets := fixture(t)
	want := jsonOf(t, Merge(mustLoad(t, base, sets), 3))
	perms := permutations(sets)
	if len(perms) != 6 {
		t.Fatalf("expected 6 permutations of 3 sets, got %d", len(perms))
	}
	for _, perm := range perms {
		got := jsonOf(t, Merge(mustLoad(t, base, perm), 3))
		if got != want {
			t.Fatalf("merge is NOT order-independent; order %v produced a different deck:\n--- want ---\n%s\n--- got ---\n%s",
				perm, want, got)
		}
	}
}

func TestMergeIsStableAcrossRepeatedRuns(t *testing.T) {
	base, sets := fixture(t)
	in := mustLoad(t, base, sets)
	first := jsonOf(t, Merge(in, 3))
	for i := 0; i < 20; i++ {
		if got := jsonOf(t, Merge(in, 3)); got != first {
			t.Fatalf("merge run %d differed from run 0 - map iteration is leaking into the output", i)
		}
	}
}

func deckChordFor(d Deck, action string) string {
	for _, e := range d.Bindings {
		if e.Action == action {
			return e.Chord
		}
	}
	return ""
}

func deckSetFor(d Deck, action string) string {
	for _, e := range d.Bindings {
		if e.Action == action {
			return e.Set
		}
	}
	return ""
}

func TestMergeBaseWinsOverPersonal(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)

	// bob wants Ctrl+Shift+P for build.run; the team standard owns it.
	if got := deckChordFor(d, "command.palette"); got != "Ctrl+Shift+P" {
		t.Fatalf("command.palette = %q, want Ctrl+Shift+P from the base", got)
	}
	if got := deckSetFor(d, "command.palette"); got != "team-standard" {
		t.Fatalf("command.palette came from %q, want team-standard", got)
	}
	if got := deckChordFor(d, "build.run"); got != "" {
		t.Fatalf("build.run should have been dropped, but is bound to %q", got)
	}
	// The drop must be explained, naming both sides.
	var explained bool
	for _, r := range d.Resolutions {
		if r.Action == "build.run" && r.Decision == "dropped" {
			explained = true
			for _, need := range []string{"kept", "command.palette", "team-standard", "dropped", "build.run"} {
				if !strings.Contains(r.Explanation, need) {
					t.Errorf("drop explanation %q does not mention %q", r.Explanation, need)
				}
			}
		}
	}
	if !explained {
		t.Fatal("no resolution explains why build.run was dropped")
	}
}

func TestMergeOverrideBeatsBase(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)

	// alice marked pane.split.right as an override, so her chord wins over the
	// team standard's Ctrl+Alt+D.
	if got := deckChordFor(d, "pane.split.right"); got != "Alt+Meta+D" {
		t.Fatalf("pane.split.right = %q, want alice's overriding Alt+Meta+D", got)
	}
	if got := deckSetFor(d, "pane.split.right"); got != "alice" {
		t.Fatalf("pane.split.right came from %q, want alice", got)
	}
	var sawDrop bool
	for _, r := range d.Resolutions {
		if r.Decision == "dropped" && r.Chord == "Ctrl+Alt+D" {
			sawDrop = true
			if !strings.Contains(r.Explanation, "override") {
				t.Errorf("the base binding was dropped without mentioning the override: %q", r.Explanation)
			}
		}
	}
	if !sawDrop {
		t.Fatal("the base's Ctrl+Alt+D was not reported as dropped")
	}
}

func TestMergeTieBreaksOnSetNameNotFileOrder(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)
	// bob and carol both want Ctrl+Shift+F at equal precedence. "bob" sorts
	// before "carol", so bob's git.blame must win in every input order.
	if got := deckSetFor(d, "git.blame"); got != "bob" {
		t.Fatalf("git.blame came from %q, want bob (the alphabetically first set)", got)
	}
	if got := deckChordFor(d, "deck.search"); got != "" {
		t.Fatalf("carol's deck.search should have lost the tie, but is bound to %q", got)
	}
	for _, perm := range permutations(sets) {
		p := Merge(mustLoad(t, base, perm), 3)
		if deckSetFor(p, "git.blame") != "bob" {
			t.Fatalf("input order %v changed who won the Ctrl+Shift+F tie", perm)
		}
	}
}

func TestMergeWithoutOverrideKeepsBase(t *testing.T) {
	dir := t.TempDir()
	base := writeSet(t, dir, "team.json", BindingSet{
		Set:      "team-standard",
		Bindings: []Binding{{Action: "pane.split.right", Chord: "Ctrl+Alt+D"}},
	})
	// Same as the fixture, but WITHOUT "override": true.
	a := writeSet(t, dir, "alice.json", BindingSet{
		Set:      "alice",
		Bindings: []Binding{{Action: "pane.split.right", Chord: "Cmd+Opt+D"}},
	})
	d := Merge(mustLoad(t, base, []string{a}), 0)
	if got := deckChordFor(d, "pane.split.right"); got != "Ctrl+Alt+D" {
		t.Fatalf("without an override the base must win; got %q", got)
	}
}

func TestMergePrefixConflictDropsOne(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)
	if got := deckChordFor(d, "workspace.save"); got != "Ctrl+K Ctrl+S" {
		t.Fatalf("workspace.save = %q, want the base's Ctrl+K Ctrl+S", got)
	}
	// No two deck entries may be in a prefix relationship.
	for i := range d.Bindings {
		for j := range d.Bindings {
			if i == j {
				continue
			}
			a, _ := ParseSequence(d.Bindings[i].Chord)
			b, _ := ParseSequence(d.Bindings[j].Chord)
			if a.StrictPrefixOf(b) {
				t.Fatalf("the merged deck still contains a prefix conflict: %q and %q",
					d.Bindings[i].Chord, d.Bindings[j].Chord)
			}
		}
	}
}

func TestMergedDeckIsConflictFree(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)
	chords := map[string]string{}
	actions := map[string]string{}
	for _, e := range d.Bindings {
		if prev, dup := chords[e.Chord]; dup {
			t.Errorf("chord %q is in the deck twice (%s and %s)", e.Chord, prev, e.Action)
		}
		chords[e.Chord] = e.Action
		if prev, dup := actions[e.Action]; dup {
			t.Errorf("action %q is in the deck twice (%s and %s)", e.Action, prev, e.Chord)
		}
		actions[e.Action] = e.Chord
	}
	if len(d.Bindings) == 0 {
		t.Fatal("the merged deck is empty")
	}
}

func TestEveryDropIsExplained(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)
	if len(d.Resolutions) == 0 {
		t.Fatal("no resolutions were recorded")
	}
	for _, r := range d.Resolutions {
		if r.Explanation == "" {
			t.Errorf("resolution %+v has no explanation", r)
		}
		if r.Reason == "" {
			t.Errorf("resolution %+v has no machine-readable reason", r)
		}
		if r.Decision == "dropped" && !strings.Contains(r.Explanation, "because") {
			t.Errorf("drop explanation gives no reason: %q", r.Explanation)
		}
	}
}

// ---------------------------------------------------------------------------
// Suggestions
// ---------------------------------------------------------------------------

func TestSuggestionsAreActuallyFree(t *testing.T) {
	base, sets := fixture(t)
	in := mustLoad(t, base, sets)
	d := Merge(in, 5)
	if len(d.Suggestions) == 0 {
		t.Fatal("the fixture drops bindings but produced no suggestions")
	}

	used := map[string]bool{}
	for _, b := range in.Bindings {
		for _, c := range b.Seq {
			used[c.String()] = true
		}
	}
	for _, e := range d.Bindings {
		seq, err := ParseSequence(e.Chord)
		if err != nil {
			t.Fatal(err)
		}
		for _, c := range seq {
			used[c.String()] = true
		}
	}

	total := 0
	for _, s := range d.Suggestions {
		if len(s.Free) == 0 {
			t.Errorf("no free chord was proposed for %s (%s)", s.Action, s.Set)
		}
		for _, cand := range s.Free {
			total++
			c, err := ParseChord(cand)
			if err != nil {
				t.Errorf("suggested chord %q does not parse: %v", cand, err)
				continue
			}
			if c.String() != cand {
				t.Errorf("suggested chord %q is not in canonical form (%q)", cand, c.String())
			}
			if used[cand] {
				t.Errorf("suggested chord %q is already used by a set or by the deck", cand)
			}
			if isReservedAnywhere(c) {
				t.Errorf("suggested chord %q is reserved by an operating system", cand)
			}
			if c.Mods == 0 {
				t.Errorf("suggested chord %q has no modifier", cand)
			}
		}
		// Suggestions must not repeat themselves.
		seen := map[string]bool{}
		for _, cand := range s.Free {
			if seen[cand] {
				t.Errorf("suggestion list for %s repeats %q", s.Action, cand)
			}
			seen[cand] = true
		}
	}
	if total == 0 {
		t.Fatal("no suggestions were checked")
	}
}

func TestSuggestSkipsAReservedNeighbour(t *testing.T) {
	// Cmd+Shift+4 is reserved on macOS, so a request near "4" must not offer it.
	cs := &chordSpace{taken: map[string]bool{}}
	got := cs.suggest(Chord{Mods: ModMeta | ModShift, Key: "4"}, 12)
	if len(got) == 0 {
		t.Fatal("no suggestions produced")
	}
	for _, g := range got {
		if g == "Shift+Meta+4" {
			t.Fatalf("suggested the reserved chord %q", g)
		}
	}
}

func TestSuggestRespectsCount(t *testing.T) {
	cs := &chordSpace{taken: map[string]bool{}}
	for _, n := range []int{0, 1, 3, 7} {
		got := cs.suggest(Chord{Mods: ModCtrl | ModShift, Key: "P"}, n)
		if len(got) != n {
			t.Errorf("suggest(n=%d) returned %d chords: %v", n, len(got), got)
		}
	}
}

// ---------------------------------------------------------------------------
// Loading and validation
// ---------------------------------------------------------------------------

func TestLoadRejectsBadInput(t *testing.T) {
	dir := t.TempDir()
	write := func(name, body string) string {
		p := filepath.Join(dir, name)
		if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
			t.Fatal(err)
		}
		return p
	}
	cases := []struct{ name, body, want string }{
		{"badchord.json", `{"set":"x","bindings":[{"action":"a","chord":"Ctrl+Wat"}]}`, `"Wat"`},
		{"noaction.json", `{"set":"x","bindings":[{"action":"","chord":"Ctrl+P"}]}`, "empty action"},
		{"empty.json", `{"set":"x","bindings":[]}`, "no bindings"},
		{"notjson.json", `nope`, "not a valid binding set"},
		{"unknown.json", `{"set":"x","bindingz":[]}`, "not a valid binding set"},
		{"dupaction.json", `{"set":"x","bindings":[{"action":"a","chord":"Ctrl+P"},{"action":"a","chord":"Ctrl+Q"}]}`, "twice"},
		{"badplat.json", `{"set":"x","platform":"beos","bindings":[{"action":"a","chord":"Ctrl+P"}]}`, "platform"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			p := write(c.name, c.body)
			_, _, err := loadSetFile(p, false)
			if err == nil {
				t.Fatalf("%s loaded without error", c.name)
			}
			if !strings.Contains(err.Error(), c.want) {
				t.Fatalf("%s error %q does not contain %q", c.name, err, c.want)
			}
		})
	}
	if _, _, err := loadSetFile(filepath.Join(dir, "nope.json"), false); err == nil {
		t.Fatal("a missing file loaded without error")
	}
}

func TestLoadRejectsDuplicateSetNames(t *testing.T) {
	dir := t.TempDir()
	a := writeSet(t, dir, "a.json", BindingSet{Set: "same", Bindings: []Binding{{Action: "x", Chord: "Ctrl+P"}}})
	b := writeSet(t, dir, "b.json", BindingSet{Set: "same", Bindings: []Binding{{Action: "y", Chord: "Ctrl+Q"}}})
	if _, err := loadInputs("", []string{a, b}); err == nil {
		t.Fatal("two sets sharing a name loaded without error")
	}
}

func TestSetNameDefaultsToFileName(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "dave.json")
	if err := os.WriteFile(p, []byte(`{"bindings":[{"action":"a","chord":"Ctrl+P"}]}`), 0o644); err != nil {
		t.Fatal(err)
	}
	info, _, err := loadSetFile(p, false)
	if err != nil {
		t.Fatal(err)
	}
	if info.Name != "dave" {
		t.Fatalf("set name = %q, want dave", info.Name)
	}
	if info.SHA256 == "" || len(info.SHA256) != 64 {
		t.Fatalf("sha256 = %q, want 64 hex characters", info.SHA256)
	}
}

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

func TestExplainFindsEveryBinder(t *testing.T) {
	base, sets := fixture(t)
	in := mustLoad(t, base, sets)
	seq, err := ParseSequence("ctrl-shift-p")
	if err != nil {
		t.Fatal(err)
	}
	d := Merge(in, 0)
	ex := Explain(in, "ctrl-shift-p", seq, &d)
	if ex.Chord != "Ctrl+Shift+P" {
		t.Fatalf("explain canonicalised to %q", ex.Chord)
	}
	got := map[string]string{}
	for _, p := range ex.BoundBy {
		got[p.Set] = p.Action
	}
	want := map[string]string{"team-standard": "command.palette", "alice": "command.palette", "bob": "build.run"}
	if len(got) != len(want) {
		t.Fatalf("explain found %v, want %v", got, want)
	}
	for k, v := range want {
		if got[k] != v {
			t.Errorf("explain: set %q bound to %q, want %q", k, got[k], v)
		}
	}
	if ex.InDeck == nil || ex.InDeck.Action != "command.palette" {
		t.Errorf("explain did not report the merged-deck owner: %+v", ex.InDeck)
	}
}

func TestExplainReportsPrefixRelations(t *testing.T) {
	base, sets := fixture(t)
	in := mustLoad(t, base, sets)
	seq, err := ParseSequence("Ctrl+K")
	if err != nil {
		t.Fatal(err)
	}
	ex := Explain(in, "Ctrl+K", seq, nil)
	if len(ex.PrefixOf) == 0 {
		t.Fatal("Ctrl+K was not reported as a prefix of Ctrl+K Ctrl+S")
	}
	if ex.PrefixOf[0].Chord != "Ctrl+K Ctrl+S" {
		t.Fatalf("prefix target = %q", ex.PrefixOf[0].Chord)
	}
}

func TestExplainOnAFreeChord(t *testing.T) {
	base, sets := fixture(t)
	in := mustLoad(t, base, sets)
	seq, err := ParseSequence("Ctrl+Alt+Shift+F22")
	if err != nil {
		t.Fatal(err)
	}
	ex := Explain(in, "Ctrl+Alt+Shift+F22", seq, nil)
	if len(ex.BoundBy) != 0 || len(ex.Reserved) != 0 {
		t.Fatalf("expected a completely free chord, got %+v", ex)
	}
}

// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------

func TestExportFormats(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)

	var csv strings.Builder
	if err := exportCSV(&csv, d); err != nil {
		t.Fatal(err)
	}
	lines := strings.Split(strings.TrimRight(csv.String(), "\n"), "\n")
	if len(lines) != len(d.Bindings)+1 {
		t.Fatalf("CSV has %d lines, want %d (header + bindings)", len(lines), len(d.Bindings)+1)
	}
	if !strings.HasPrefix(lines[0], "action,chord,set") {
		t.Fatalf("CSV header = %q", lines[0])
	}

	var md strings.Builder
	if err := exportMarkdown(&md, d); err != nil {
		t.Fatal(err)
	}
	for _, need := range []string{"# WindowDeck merged deck", "## Bindings", "## Resolutions", "| Action | Chord |"} {
		if !strings.Contains(md.String(), need) {
			t.Errorf("markdown export is missing %q", need)
		}
	}
}

func TestDeckOutputIsSorted(t *testing.T) {
	base, sets := fixture(t)
	d := Merge(mustLoad(t, base, sets), 3)
	actions := make([]string, len(d.Bindings))
	for i, e := range d.Bindings {
		actions[i] = e.Action
	}
	if !sort.StringsAreSorted(actions) {
		t.Fatalf("deck bindings are not sorted by action: %v", actions)
	}
	if !sort.StringsAreSorted(d.Warnings) {
		t.Fatalf("warnings are not sorted: %v", d.Warnings)
	}
}

// ---------------------------------------------------------------------------
// Flag reordering
// ---------------------------------------------------------------------------

func TestReorderFlags(t *testing.T) {
	in := []string{"alice.json", "--base", "team.json", "bob.json", "--json"}
	got := reorderFlags(in, valueFlags)
	want := []string{"--base", "team.json", "--json", "alice.json", "bob.json"}
	if fmt.Sprint(got) != fmt.Sprint(want) {
		t.Fatalf("reorderFlags = %v, want %v", got, want)
	}
}
