package main

import (
	"bytes"
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"math/rand"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

func intp(v int) *int { return &v }

type testLayoutFile struct {
	Name     string           `json:"name"`
	Monitors []*LayoutMonitor `json:"monitors"`
	Rules    []*Rule          `json:"rules,omitempty"`
	Windows  []Window         `json:"windows,omitempty"`
}

func mustLayout(t *testing.T, f testLayoutFile) *Layout {
	t.Helper()
	data, err := json.Marshal(f)
	if err != nil {
		t.Fatalf("marshal layout: %v", err)
	}
	lay, err := ParseLayout(data)
	if err != nil {
		t.Fatalf("ParseLayout(%s): %v", data, err)
	}
	return lay
}

func mustTopo(t *testing.T, doc string) *Topology {
	t.Helper()
	topo, err := ParseTopology([]byte(doc))
	if err != nil {
		t.Fatalf("ParseTopology: %v", err)
	}
	return topo
}

func oneMonitorTopo(t *testing.T, id string, w, h int) *Topology {
	t.Helper()
	return mustTopo(t, fmt.Sprintf(
		`{"name":"t","monitors":[{"id":%q,"primary":true,"bounds":{"x":0,"y":0,"w":%d,"h":%d}}]}`, id, w, h))
}

// ---------------------------------------------------------------------------
// largest remainder
// ---------------------------------------------------------------------------

func TestLargestRemainderDistribution(t *testing.T) {
	cases := []struct {
		name    string
		total   int
		weights []int
		want    []int
	}{
		{"even split of an odd total", 100, []int{1, 1, 1}, []int{34, 33, 33}},
		{"three equal parts of 1439", 1439, []int{1, 1, 1}, []int{480, 480, 479}},
		{"golden-ish 3:2 on 2528", 2528, []int{3, 2}, []int{1517, 1011}},
		{"seven equal columns on 1920, ties broken by index", 1920, []int{1, 1, 1, 1, 1, 1, 1},
			[]int{275, 275, 274, 274, 274, 274, 274}},
		{"exact division needs no remainder", 1200, []int{1, 2, 3}, []int{200, 400, 600}},
		{"single child takes everything", 777, []int{5}, []int{777}},
		{"zero pixels", 0, []int{1, 2, 3}, []int{0, 0, 0}},
		{"total smaller than the number of parts", 2, []int{1, 1, 1}, []int{1, 1, 0}},
		{"heavy skew", 1000, []int{1, 99}, []int{10, 990}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := largestRemainder(c.total, c.weights)
			if err != nil {
				t.Fatalf("largestRemainder(%d, %v): %v", c.total, c.weights, err)
			}
			if len(got) != len(c.want) {
				t.Fatalf("got %v, want %v", got, c.want)
			}
			sum := 0
			for i := range got {
				if got[i] != c.want[i] {
					t.Errorf("part %d = %d, want %d (full result %v, want %v)", i, got[i], c.want[i], got, c.want)
				}
				sum += got[i]
			}
			if sum != c.total {
				t.Errorf("parts sum to %d, want exactly %d", sum, c.total)
			}
		})
	}
}

// TestLargestRemainderSumsExactly is the property the whole solver rests on:
// however the pixels are cut, they must add back up to the original extent and
// no part may drift more than one pixel from its exact share.
func TestLargestRemainderSumsExactly(t *testing.T) {
	rng := rand.New(rand.NewSource(20260811))
	for trial := 0; trial < 3000; trial++ {
		n := 1 + rng.Intn(9)
		weights := make([]int, n)
		var wsum int64
		for i := range weights {
			weights[i] = 1 + rng.Intn(50)
			wsum += int64(weights[i])
		}
		total := rng.Intn(8000)
		parts, err := largestRemainder(total, weights)
		if err != nil {
			t.Fatalf("trial %d: %v", trial, err)
		}
		sum := 0
		for i, p := range parts {
			sum += p
			exactNum := int64(total) * int64(weights[i])
			floor := int(exactNum / wsum)
			if p != floor && p != floor+1 {
				t.Fatalf("trial %d: part %d = %d, exact share is %d/%d so only %d or %d are allowed",
					trial, i, p, exactNum, wsum, floor, floor+1)
			}
		}
		if sum != total {
			t.Fatalf("trial %d: parts %v sum to %d, want %d (weights %v)", trial, parts, sum, total, weights)
		}
	}
}

func TestLargestRemainderRejectsBadInput(t *testing.T) {
	cases := []struct {
		name    string
		total   int
		weights []int
	}{
		{"no weights", 100, nil},
		{"zero weight", 100, []int{1, 0}},
		{"negative weight", 100, []int{1, -3}},
		{"negative total", -1, []int{1}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if _, err := largestRemainder(c.total, c.weights); err == nil {
				t.Fatalf("largestRemainder(%d, %v) accepted bad input", c.total, c.weights)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// exact tiling, as a property over many random weight vectors
// ---------------------------------------------------------------------------

// randomTree builds a gapless, paddingless split tree with random weights.
func randomTree(rng *rand.Rand, depth int, zone *int) *Node {
	if depth == 0 || rng.Intn(4) == 0 {
		*zone++
		return &Node{Zone: fmt.Sprintf("z%d", *zone), Weight: intp(1 + rng.Intn(4))}
	}
	split := splitColumns
	if rng.Intn(2) == 0 {
		split = splitRows
	}
	n := &Node{Split: split, Gap: intp(0), Weight: intp(1 + rng.Intn(4))}
	for i, k := 0, 2+rng.Intn(2); i < k; i++ {
		n.Children = append(n.Children, randomTree(rng, depth-1, zone))
	}
	return n
}

// TestExactTilingProperty asserts, over many random weight vectors and tree
// shapes, that the computed zone rectangles EXACTLY cover the work area: every
// rectangle is inside it, no two overlap, and their areas sum to its area.
// Those three facts together mean the tiling is exact - not one pixel is lost
// to rounding and not one pixel is claimed twice.
func TestExactTilingProperty(t *testing.T) {
	sizes := []struct{ w, h int }{
		{7680, 4320}, {2560, 1440}, {1919, 1079}, {3841, 2161}, {1366, 768},
	}
	rng := rand.New(rand.NewSource(7))
	for trial := 0; trial < 400; trial++ {
		sz := sizes[trial%len(sizes)]
		topo := oneMonitorTopo(t, "m", sz.w, sz.h)
		zone := 0
		root := randomTree(rng, 3, &zone)
		root.Weight = nil
		lay := mustLayout(t, testLayoutFile{
			Name:     "prop",
			Monitors: []*LayoutMonitor{{Monitor: "m", Gap: 0, Padding: 0, Root: root}},
		})
		res := Solve(lay, topo)
		if errs := res.Errors(); len(errs) > 0 {
			t.Fatalf("trial %d (%dx%d, %d zones): solver reported %v", trial, sz.w, sz.h, zone, errs[0])
		}
		if got := len(res.Placements); got != zone {
			t.Fatalf("trial %d: %d placements for %d zones", trial, got, zone)
		}
		work := topo.Monitors[0].Work

		var area int64
		for _, p := range res.Placements {
			if p.Frame != p.Cell {
				t.Fatalf("trial %d: zone %s frame %s != cell %s with zero padding",
					trial, p.Zone, p.Frame, p.Cell)
			}
			if p.Frame.W < 1 || p.Frame.H < 1 {
				t.Fatalf("trial %d: zone %s degenerated to %s", trial, p.Zone, p.Frame)
			}
			if !containsRect(work, p.Frame) {
				t.Fatalf("trial %d: zone %s frame %s escapes the work area %s",
					trial, p.Zone, p.Frame, work)
			}
			area += p.Frame.Area()
		}
		for i := 0; i < len(res.Placements); i++ {
			for j := i + 1; j < len(res.Placements); j++ {
				if ov := intersect(res.Placements[i].Frame, res.Placements[j].Frame); !ov.Empty() {
					t.Fatalf("trial %d: zones %s %s and %s %s overlap over %s",
						trial, res.Placements[i].Zone, res.Placements[i].Frame,
						res.Placements[j].Zone, res.Placements[j].Frame, ov)
				}
			}
		}
		if area != work.Area() {
			t.Fatalf("trial %d: zones cover %d px^2 of a %d px^2 work area (short by %d)",
				trial, area, work.Area(), work.Area()-area)
		}
		if issues := VerifyTiling(res); len(issues) != 0 {
			t.Fatalf("trial %d: VerifyTiling disagreed: %v", trial, issues[0])
		}
	}
}

// TestGapAccountingIsExact checks that with gaps the cells still account for
// every pixel: cell extents plus the gap strips equal the parent extent.
func TestGapAccountingIsExact(t *testing.T) {
	cases := []struct {
		name  string
		gap   int
		count int
		work  int
	}{
		{"no gap", 0, 4, 1440},
		{"8px gaps, 3 zones", 8, 3, 2560},
		{"1px gaps, 9 zones", 1, 9, 1001},
		{"37px gaps, 2 zones", 37, 2, 999},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			topo := oneMonitorTopo(t, "m", c.work, 1000)
			root := &Node{Split: splitColumns, Gap: intp(c.gap)}
			for i := 0; i < c.count; i++ {
				root.Children = append(root.Children, &Node{
					Zone: fmt.Sprintf("z%d", i), Weight: intp(i + 1),
				})
			}
			lay := mustLayout(t, testLayoutFile{
				Name: "gaps", Monitors: []*LayoutMonitor{{Monitor: "m", Root: root}},
			})
			res := Solve(lay, topo)
			if errs := res.Errors(); len(errs) > 0 {
				t.Fatalf("unexpected error: %v", errs[0])
			}
			sum := 0
			for i, p := range res.Placements {
				sum += p.Cell.W
				if i > 0 {
					prev := res.Placements[i-1].Cell
					if got := p.Cell.X - prev.Right(); got != c.gap {
						t.Errorf("gap between %s and %s is %d px, want %d", prev, p.Cell, got, c.gap)
					}
				}
			}
			want := c.work - c.gap*(c.count-1)
			if sum != want {
				t.Errorf("cell widths sum to %d, want %d (work %d minus %d gaps of %d)",
					sum, want, c.work, c.count-1, c.gap)
			}
			first := res.Placements[0].Cell
			last := res.Placements[len(res.Placements)-1].Cell
			if first.X != 0 {
				t.Errorf("first cell starts at %d, want 0", first.X)
			}
			if last.Right() != c.work {
				t.Errorf("last cell ends at %d, want %d", last.Right(), c.work)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// minimum sizes
// ---------------------------------------------------------------------------

func TestMinSizeViolationDetection(t *testing.T) {
	cases := []struct {
		name     string
		scale    float64
		monW     int
		monH     int
		minW     int
		minH     int
		weights  [2]int
		wantCode string
		wantSubs []string
	}{
		{
			name: "width satisfied", scale: 1, monW: 2000, monH: 1000,
			minW: 400, weights: [2]int{1, 1}, wantCode: "",
		},
		{
			name: "width short by one pixel", scale: 1, monW: 799, monH: 1000,
			minW: 401, weights: [2]int{1, 1}, wantCode: "min-width",
			wantSubs: []string{"400 px wide", "short by 1 px"},
		},
		{
			name: "scale doubles the requirement", scale: 2, monW: 1600, monH: 1000,
			minW: 500, weights: [2]int{1, 1}, wantCode: "min-width",
			wantSubs: []string{"= 1000 device px at 2x", "800 px wide"},
		},
		{
			name: "fractional scale rounds the requirement up", scale: 1.5, monW: 600, monH: 1000,
			minW: 201, weights: [2]int{1, 1}, wantCode: "min-width",
			wantSubs: []string{"= 302 device px at 1.5x"},
		},
		{
			name: "height violation is reported separately", scale: 1, monW: 2000, monH: 100,
			minH: 300, weights: [2]int{1, 1}, wantCode: "min-height",
			wantSubs: []string{"100 px tall", "short by 200 px"},
		},
		{
			name: "a starved weight is what fails", scale: 1, monW: 1000, monH: 1000,
			minW: 400, weights: [2]int{1, 9}, wantCode: "min-width",
			wantSubs: []string{`zone "a"`, "100 px wide"},
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			topo := mustTopo(t, fmt.Sprintf(
				`{"name":"t","monitors":[{"id":"m","primary":true,"scale":%g,"bounds":{"x":0,"y":0,"w":%d,"h":%d}}]}`,
				c.scale, c.monW, c.monH))
			lay := mustLayout(t, testLayoutFile{
				Name: "min",
				Monitors: []*LayoutMonitor{{Monitor: "m", Root: &Node{
					Split: splitColumns,
					Children: []*Node{
						{Zone: "a", Weight: intp(c.weights[0]), MinWidth: c.minW, MinHeight: c.minH},
						{Zone: "b", Weight: intp(c.weights[1])},
					},
				}}},
			})
			res := Solve(lay, topo)
			var found *Issue
			for i := range res.Issues {
				if res.Issues[i].Code == "min-width" || res.Issues[i].Code == "min-height" {
					found = &res.Issues[i]
					break
				}
			}
			if c.wantCode == "" {
				if found != nil {
					t.Fatalf("unexpected violation: %s", found.Message)
				}
				return
			}
			if found == nil {
				t.Fatalf("expected a %s violation, got issues %v", c.wantCode, res.Issues)
			}
			if found.Code != c.wantCode {
				t.Fatalf("got code %q, want %q (%s)", found.Code, c.wantCode, found.Message)
			}
			if found.Severity != "error" {
				t.Errorf("min-size violations must be errors, got %q", found.Severity)
			}
			for _, sub := range c.wantSubs {
				if !strings.Contains(found.Message, sub) {
					t.Errorf("message %q does not mention %q", found.Message, sub)
				}
			}
		})
	}
}

func TestSplitTooSmallIsReportedNotSolved(t *testing.T) {
	topo := oneMonitorTopo(t, "m", 20, 500)
	root := &Node{Split: splitColumns, Gap: intp(10)}
	for i := 0; i < 5; i++ {
		root.Children = append(root.Children, &Node{Zone: fmt.Sprintf("z%d", i)})
	}
	lay := mustLayout(t, testLayoutFile{Name: "tiny",
		Monitors: []*LayoutMonitor{{Monitor: "m", Root: root}}})
	res := Solve(lay, topo)
	errs := res.Errors()
	if len(errs) == 0 {
		t.Fatalf("expected an error, got placements %v", res.Placements)
	}
	if errs[0].Code != "split-too-small" {
		t.Fatalf("got code %q, want split-too-small: %s", errs[0].Code, errs[0].Message)
	}
	if len(res.Placements) != 0 {
		t.Fatalf("solver produced %d rectangles for an impossible split; it must produce none", len(res.Placements))
	}
	if !strings.Contains(errs[0].Message, "cannot fit 5 children") {
		t.Errorf("message should say what could not fit: %s", errs[0].Message)
	}
}

// ---------------------------------------------------------------------------
// matching
// ---------------------------------------------------------------------------

func TestMatcherParsingAndMatching(t *testing.T) {
	cases := []struct {
		spec     string
		wantKind string
		yes      []string
		no       []string
	}{
		{"exact:code", "exact", []string{"code"}, []string{"Code", "code2", "vscode", ""}},
		{"code", "exact", []string{"code"}, []string{"codex", "cod"}},
		{"prefix:org.mozilla.", "prefix",
			[]string{"org.mozilla.firefox", "org.mozilla."},
			[]string{"org.mozilla", "com.org.mozilla.firefox", ""}},
		{"glob:*.terminal", "glob",
			[]string{"com.apple.terminal", "x.terminal", ".terminal"},
			[]string{"terminal", "com.apple.terminal2"}},
		{"glob:com.*.slack*", "glob",
			[]string{"com.tinyspeck.slackmacgap", "com.a.slack"},
			[]string{"com.slack", "org.tinyspeck.slackmacgap"}},
		{"glob:*", "glob", []string{"", "anything at all"}, nil},
		{"glob:a?c", "glob", []string{"abc", "a c"}, []string{"ac", "abbc"}},
		{"glob:**a**", "glob", []string{"a", "xxaxx"}, []string{"", "b"}},
		{"glob:?*", "glob", []string{"a", "ab"}, []string{""}},
	}
	for _, c := range cases {
		t.Run(c.spec, func(t *testing.T) {
			m, err := parseMatcher(c.spec)
			if err != nil {
				t.Fatalf("parseMatcher(%q): %v", c.spec, err)
			}
			if m.Kind != c.wantKind {
				t.Fatalf("kind %q, want %q", m.Kind, c.wantKind)
			}
			for _, s := range c.yes {
				if !m.Match(s) {
					t.Errorf("%s should match %q", m, s)
				}
			}
			for _, s := range c.no {
				if m.Match(s) {
					t.Errorf("%s should NOT match %q", m, s)
				}
			}
		})
	}
}

func TestMatcherRejectsEmptySpecs(t *testing.T) {
	for _, spec := range []string{"", "exact:", "prefix:", "glob:"} {
		if _, err := parseMatcher(spec); err == nil {
			t.Errorf("parseMatcher(%q) should have failed", spec)
		}
	}
}

func TestRuleAssignmentFirstMatchWins(t *testing.T) {
	lay := mustLayout(t, testLayoutFile{
		Name: "rules",
		Monitors: []*LayoutMonitor{{Monitor: "m", Root: &Node{
			Split:    splitRows,
			Children: []*Node{{Zone: "top"}, {Zone: "bottom"}},
		}}},
		Rules: []*Rule{
			{Zone: "top", App: "glob:com.*.slack*"},
			{Zone: "bottom", App: "exact:com.tinyspeck.slackmacgap"},
			{Zone: "bottom", App: "prefix:org.", Title: "glob:*log*"},
		},
		Windows: []Window{
			{ID: "a", App: "com.tinyspeck.slackmacgap", Title: "#general"},
			{ID: "b", App: "org.gnome.terminal", Title: "build log"},
			{ID: "c", App: "org.gnome.terminal", Title: "zsh"},
		},
	})
	got := AssignWindows(lay.Rules, lay.Windows)
	want := []struct {
		zone string
		rule int
	}{{"top", 1}, {"bottom", 3}, {"", 0}}
	for i, w := range want {
		if got[i].Zone != w.zone || got[i].Rule != w.rule {
			t.Errorf("window %s -> zone %q by rule %d, want zone %q by rule %d",
				got[i].Window.ID, got[i].Zone, got[i].Rule, w.zone, w.rule)
		}
	}
	reports, _ := auditRules(lay)
	if reports[1].Reachabl {
		t.Errorf("rule #2 is shadowed by rule #1 and should be reported unreachable")
	}
	if reports[1].Reason != "shadowed by rule #1" {
		t.Errorf("reason %q, want %q", reports[1].Reason, "shadowed by rule #1")
	}
}

// ---------------------------------------------------------------------------
// topology validation
// ---------------------------------------------------------------------------

func TestTopologyValidation(t *testing.T) {
	cases := []struct {
		name    string
		doc     string
		wantErr string
	}{
		{"valid pair", `{"name":"ok","monitors":[
			{"id":"a","primary":true,"bounds":{"x":0,"y":0,"w":1920,"h":1080}},
			{"id":"b","bounds":{"x":1920,"y":0,"w":1920,"h":1080}}]}`, ""},
		{"overlapping bounds", `{"name":"bad","monitors":[
			{"id":"a","bounds":{"x":0,"y":0,"w":1920,"h":1080}},
			{"id":"b","bounds":{"x":1900,"y":0,"w":1920,"h":1080}}]}`, "overlap"},
		{"one pixel gap", `{"name":"bad","monitors":[
			{"id":"a","bounds":{"x":0,"y":0,"w":1920,"h":1080}},
			{"id":"b","bounds":{"x":1921,"y":0,"w":1920,"h":1080}}]}`, "disjoint groups"},
		{"corner touch only", `{"name":"bad","monitors":[
			{"id":"a","bounds":{"x":0,"y":0,"w":1920,"h":1080}},
			{"id":"b","bounds":{"x":1920,"y":1080,"w":1920,"h":1080}}]}`, "disjoint groups"},
		{"duplicate id", `{"name":"bad","monitors":[
			{"id":"a","bounds":{"x":0,"y":0,"w":100,"h":100}},
			{"id":"a","bounds":{"x":100,"y":0,"w":100,"h":100}}]}`, "appears twice"},
		{"two primaries", `{"name":"bad","monitors":[
			{"id":"a","primary":true,"bounds":{"x":0,"y":0,"w":100,"h":100}},
			{"id":"b","primary":true,"bounds":{"x":100,"y":0,"w":100,"h":100}}]}`, "marked primary"},
		{"insets eat the screen", `{"name":"bad","monitors":[
			{"id":"a","bounds":{"x":0,"y":0,"w":100,"h":100},"insets":{"top":60,"bottom":60}}]}`,
			"leave a 100x-20 work area"},
		{"zero size", `{"name":"bad","monitors":[{"id":"a","bounds":{"x":0,"y":0,"w":0,"h":100}}]}`,
			"must be positive"},
		{"no monitors", `{"name":"bad","monitors":[]}`, "at least one"},
		{"absurd scale", `{"name":"bad","monitors":[
			{"id":"a","scale":99,"bounds":{"x":0,"y":0,"w":100,"h":100}}]}`, "expected between"},
		{"unknown field", `{"name":"bad","screens":[]}`, "unknown field"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, err := ParseTopology([]byte(c.doc))
			if c.wantErr == "" {
				if err != nil {
					t.Fatalf("expected success, got %v", err)
				}
				return
			}
			if err == nil {
				t.Fatalf("expected an error mentioning %q, got none", c.wantErr)
			}
			if !strings.Contains(err.Error(), c.wantErr) {
				t.Fatalf("error %q does not mention %q", err, c.wantErr)
			}
		})
	}
}

func TestPrimaryIsDerivedByRankWhenUndeclared(t *testing.T) {
	topo := mustTopo(t, `{"name":"t","monitors":[
		{"id":"small","bounds":{"x":0,"y":0,"w":1280,"h":1024}},
		{"id":"big","bounds":{"x":1280,"y":0,"w":2560,"h":1440}}]}`)
	if !topo.PrimaryDerived {
		t.Fatalf("expected the primary to be derived")
	}
	ranked := RankedMonitors(topo)
	if ranked[0].ID != "big" {
		t.Fatalf("rank 0 is %q, want the largest monitor %q", ranked[0].ID, "big")
	}
}

func TestRankOrderPutsPrimaryFirstThenArea(t *testing.T) {
	topo := mustTopo(t, `{"name":"t","monitors":[
		{"id":"huge","bounds":{"x":2000,"y":0,"w":3840,"h":2160}},
		{"id":"main","primary":true,"bounds":{"x":0,"y":0,"w":2000,"h":1200}},
		{"id":"side","bounds":{"x":0,"y":1200,"w":2000,"h":960}}]}`)
	var got []string
	for _, m := range RankedMonitors(topo) {
		got = append(got, m.ID)
	}
	want := []string{"main", "huge", "side"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("rank order %v, want %v", got, want)
	}
}

// ---------------------------------------------------------------------------
// re-fit
// ---------------------------------------------------------------------------

func threeMonitorSetup(t *testing.T) (*Layout, *Topology) {
	t.Helper()
	topo := mustTopo(t, `{"name":"desk","monitors":[
		{"id":"left","bounds":{"x":-2560,"y":0,"w":2560,"h":1440}},
		{"id":"center","primary":true,"bounds":{"x":0,"y":0,"w":2560,"h":1440}},
		{"id":"right","bounds":{"x":2560,"y":0,"w":2560,"h":1440}}]}`)
	lay := mustLayout(t, testLayoutFile{
		Name: "desk",
		Monitors: []*LayoutMonitor{
			{Monitor: "center", Root: &Node{Split: splitColumns, Children: []*Node{
				{Zone: "editor", Weight: intp(3)}, {Zone: "term", Weight: intp(1)}}}},
			{Monitor: "left", Root: &Node{Zone: "docs"}},
			{Monitor: "right", Root: &Node{Zone: "logs"}},
		},
		Rules: []*Rule{
			{Zone: "editor", App: "code"},
			{Zone: "term", App: "glob:*terminal*"},
			{Zone: "docs", App: "prefix:md."},
			{Zone: "logs", App: "lnav"},
		},
		Windows: []Window{
			{ID: "w1", App: "code"}, {ID: "w2", App: "gnome.terminal"},
			{ID: "w3", App: "md.obsidian"}, {ID: "w4", App: "lnav"},
		},
	})
	return lay, topo
}

func TestRefitOntoFewerMonitors(t *testing.T) {
	lay, from := threeMonitorSetup(t)
	to := mustTopo(t, `{"name":"laptop","monitors":[
		{"id":"builtin","primary":true,"bounds":{"x":0,"y":0,"w":1600,"h":1000}}]}`)

	r := Refit(lay, from, to)

	if r.Fallback != "builtin" {
		t.Fatalf("fallback monitor is %q, want the destination primary %q", r.Fallback, "builtin")
	}
	if r.Summary.Folded != 2 {
		t.Fatalf("folded %d monitors, want 2", r.Summary.Folded)
	}
	if len(r.Target.Errors()) > 0 {
		t.Fatalf("re-fit produced errors: %v", r.Target.Errors())
	}
	// Every zone survives, all on the one remaining monitor.
	for _, z := range []string{"editor", "term", "docs", "logs"} {
		p, ok := r.Target.Zone(z)
		if !ok {
			t.Fatalf("zone %q disappeared in the re-fit", z)
		}
		if p.Monitor != "builtin" {
			t.Errorf("zone %q landed on %q, want builtin", z, p.Monitor)
		}
	}
	// The fold is three equal columns: the centre tree, then left, then right.
	// 1600 px / 3 by largest remainder = 534, 533, 533.
	ed, _ := r.Target.Zone("editor")
	tm, _ := r.Target.Zone("term")
	docs, _ := r.Target.Zone("docs")
	logs, _ := r.Target.Zone("logs")
	if got := ed.Frame.W + tm.Frame.W; got != 534 {
		t.Errorf("the original centre column is %d px wide, want 534", got)
	}
	if docs.Frame.W != 533 || logs.Frame.W != 533 {
		t.Errorf("folded columns are %d and %d px wide, want 533 and 533", docs.Frame.W, logs.Frame.W)
	}
	if docs.Frame.X != 534 || logs.Frame.X != 1067 {
		t.Errorf("folded columns start at x=%d and x=%d, want 534 and 1067", docs.Frame.X, logs.Frame.X)
	}
	// The three columns must still exactly tile the 1600x1000 screen.
	var area int64
	for _, p := range r.Target.Placements {
		area += p.Frame.Area()
	}
	if area != 1600*1000 {
		t.Errorf("re-fitted zones cover %d px^2, want %d", area, 1600*1000)
	}
	if issues := VerifyTiling(r.Target); len(issues) != 0 {
		t.Fatalf("re-fitted layout does not tile: %v", issues[0])
	}

	// Every window that moved is reported, with both rectangles.
	if len(r.Windows) != 4 {
		t.Fatalf("reported %d windows, want 4", len(r.Windows))
	}
	foldedSeen := 0
	for _, w := range r.Windows {
		if w.FromRect == nil || w.ToRect == nil {
			t.Errorf("window %s: missing from/to rectangle", w.WindowID)
			continue
		}
		if w.Status != "moved+resized" && w.Status != "moved" && w.Status != "resized" {
			t.Errorf("window %s: status %q, want a change to be reported", w.WindowID, w.Status)
		}
		if w.Folded {
			foldedSeen++
			if !strings.Contains(w.Detail, "folded into builtin") {
				t.Errorf("window %s: detail %q does not explain the fold", w.WindowID, w.Detail)
			}
		}
	}
	if foldedSeen != 2 {
		t.Errorf("%d windows reported as folded, want 2 (docs and logs)", foldedSeen)
	}
}

func TestRefitOntoLargerMonitor(t *testing.T) {
	lay := mustLayout(t, testLayoutFile{
		Name: "one",
		Monitors: []*LayoutMonitor{{Monitor: "small", Root: &Node{
			Split: splitColumns, Children: []*Node{
				{Zone: "a", Weight: intp(2)}, {Zone: "b", Weight: intp(1)}}}}},
		Rules:   []*Rule{{Zone: "a", App: "code"}, {Zone: "b", App: "term"}},
		Windows: []Window{{ID: "w1", App: "code"}, {ID: "w2", App: "term"}},
	})
	from := oneMonitorTopo(t, "small", 1200, 800)
	to := mustTopo(t, `{"name":"big","monitors":[
		{"id":"wall","primary":true,"bounds":{"x":0,"y":0,"w":3840,"h":2160}}]}`)

	r := Refit(lay, from, to)
	if r.Summary.Folded != 0 || r.Summary.EmptyDest != 0 {
		t.Fatalf("nothing should fold or be left empty: %+v", r.Summary)
	}
	a, _ := r.Target.Zone("a")
	b, _ := r.Target.Zone("b")
	// Proportions are preserved exactly: 2:1 of 3840 is 2560 and 1280.
	if a.Frame.W != 2560 || b.Frame.W != 1280 {
		t.Errorf("widths %d and %d, want 2560 and 1280", a.Frame.W, b.Frame.W)
	}
	if a.Frame.H != 2160 || b.Frame.H != 2160 {
		t.Errorf("heights %d and %d, want 2160 each", a.Frame.H, b.Frame.H)
	}
	if a.Frame.X != 0 || b.Frame.X != 2560 {
		t.Errorf("origins %d and %d, want 0 and 2560", a.Frame.X, b.Frame.X)
	}
	for _, w := range r.Windows {
		if w.Status != "resized" && w.Status != "moved+resized" {
			t.Errorf("window %s on a bigger screen should be resized, got %q", w.WindowID, w.Status)
		}
		if w.ToMonitor != "wall" {
			t.Errorf("window %s landed on %q, want wall", w.WindowID, w.ToMonitor)
		}
	}
	if len(r.Target.Errors()) > 0 {
		t.Fatalf("unexpected errors: %v", r.Target.Errors())
	}
}

func TestRefitIsDeterministic(t *testing.T) {
	lay, from := threeMonitorSetup(t)
	to := mustTopo(t, `{"name":"two","monitors":[
		{"id":"p","primary":true,"bounds":{"x":0,"y":0,"w":1920,"h":1080}},
		{"id":"q","bounds":{"x":1920,"y":0,"w":1920,"h":1080}}]}`)
	first := Refit(lay, from, to)
	for i := 0; i < 5; i++ {
		lay2, from2 := threeMonitorSetup(t)
		again := Refit(lay2, from2, to)
		a, _ := json.Marshal(first.Zones)
		b, _ := json.Marshal(again.Zones)
		if !bytes.Equal(a, b) {
			t.Fatalf("run %d differs:\n%s\n%s", i, a, b)
		}
	}
	if first.Summary.Folded != 1 {
		t.Fatalf("folded %d, want 1 (three source monitors onto two)", first.Summary.Folded)
	}
	// The lowest-ranked source monitor is the one that folds, and it folds into
	// the destination primary.
	last := first.Mapping[len(first.Mapping)-1]
	if last.Action != "folded" || last.Dest != "p" {
		t.Fatalf("last mapping row is %+v, want a fold into p", last)
	}
}

func TestRefitOntoMoreMonitorsLeavesTheExtraEmpty(t *testing.T) {
	lay := mustLayout(t, testLayoutFile{
		Name:     "one",
		Monitors: []*LayoutMonitor{{Monitor: "only", Root: &Node{Zone: "full"}}},
	})
	from := oneMonitorTopo(t, "only", 1920, 1080)
	to := mustTopo(t, `{"name":"two","monitors":[
		{"id":"p","primary":true,"bounds":{"x":0,"y":0,"w":1920,"h":1080}},
		{"id":"q","bounds":{"x":1920,"y":0,"w":1920,"h":1080}}]}`)
	r := Refit(lay, from, to)
	if r.Summary.EmptyDest != 1 {
		t.Fatalf("EmptyDest = %d, want 1", r.Summary.EmptyDest)
	}
	var sawEmpty bool
	for _, m := range r.Mapping {
		if m.Dest == "q" && m.Action == "empty" {
			sawEmpty = true
		}
	}
	if !sawEmpty {
		t.Fatalf("monitor q should be reported as left empty: %+v", r.Mapping)
	}
}

// ---------------------------------------------------------------------------
// SVG
// ---------------------------------------------------------------------------

func TestSVGOutputIsWellFormed(t *testing.T) {
	lay, topo := threeMonitorSetup(t)
	res := Solve(lay, topo)
	svg := RenderSVG(res, topo, `layout "desk" & <friends>`)

	if !bytes.HasPrefix(svg, []byte(`<?xml version="1.0" encoding="UTF-8"?>`)) {
		t.Fatalf("missing XML declaration: %.60s", svg)
	}

	dec := xml.NewDecoder(bytes.NewReader(svg))
	depth := 0
	var root string
	elements := map[string]int{}
	for {
		tok, err := dec.Token()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatalf("SVG is not well-formed XML: %v", err)
		}
		switch e := tok.(type) {
		case xml.StartElement:
			if depth == 0 {
				root = e.Name.Local
			}
			elements[e.Name.Local]++
			depth++
		case xml.EndElement:
			depth--
		}
	}
	if depth != 0 {
		t.Fatalf("unbalanced elements, depth ended at %d", depth)
	}
	if root != "svg" {
		t.Fatalf("root element is %q, want svg", root)
	}
	// One rect per monitor plus one per zone, plus the background.
	wantRects := 1 + len(res.Monitors) + len(res.Placements)
	if elements["rect"] < wantRects {
		t.Errorf("%d <rect> elements, want at least %d", elements["rect"], wantRects)
	}
	if elements["text"] == 0 {
		t.Errorf("no <text> elements: the diagram has no labels")
	}
	// The title must have been escaped, not injected raw.
	if bytes.Contains(svg, []byte(`& <friends>`)) {
		t.Errorf("title was not XML-escaped")
	}
	if !bytes.Contains(svg, []byte(`&amp; &lt;friends&gt;`)) {
		t.Errorf("expected the escaped title in the output")
	}
	// The diagram is drawn in device pixels: the solved rectangles appear verbatim.
	ed, _ := res.Zone("editor")
	want := fmt.Sprintf(`x="%d" y="%d" width="%d" height="%d"`, ed.Frame.X, ed.Frame.Y, ed.Frame.W, ed.Frame.H)
	if !bytes.Contains(svg, []byte(want)) {
		t.Errorf("expected the editor rectangle %s verbatim in the SVG", want)
	}
	// viewBox must cover the whole virtual desktop, negative origin included.
	if !bytes.Contains(svg, []byte(`viewBox="-2752 -192 8064 2016"`)) {
		t.Errorf("viewBox does not frame the desktop: %s", firstLine(svg, "viewBox"))
	}
}

func firstLine(b []byte, needle string) string {
	for _, line := range strings.Split(string(b), "\n") {
		if strings.Contains(line, needle) {
			return line
		}
	}
	return "(not found)"
}

func TestEscapeXML(t *testing.T) {
	cases := []struct{ in, want string }{
		{"plain", "plain"},
		{`a & b`, "a &amp; b"},
		{`<script>`, "&lt;script&gt;"},
		{`say "hi"`, "say &quot;hi&quot;"},
		{"it's", "it&apos;s"},
		{"bell\x07here", "bellhere"},
	}
	for _, c := range cases {
		if got := escapeXML(c.in); got != c.want {
			t.Errorf("escapeXML(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// ---------------------------------------------------------------------------
// layout validation and CLI plumbing
// ---------------------------------------------------------------------------

func TestLayoutValidation(t *testing.T) {
	cases := []struct {
		name    string
		doc     string
		wantErr string
	}{
		{"leaf with children", `{"name":"x","monitors":[{"monitor":"m","root":
			{"zone":"a","split":"rows","children":[{"zone":"b"}]}}]}`, "both a zone"},
		{"unknown split", `{"name":"x","monitors":[{"monitor":"m","root":
			{"split":"diagonal","children":[{"zone":"a"},{"zone":"b"}]}}]}`, `expected "rows"`},
		{"nameless leaf", `{"name":"x","monitors":[{"monitor":"m","root":{}}]}`, "no zone name"},
		{"duplicate zone", `{"name":"x","monitors":[{"monitor":"m","root":
			{"split":"rows","children":[{"zone":"a"},{"zone":"a"}]}}]}`, "declared twice"},
		{"zero weight", `{"name":"x","monitors":[{"monitor":"m","root":
			{"split":"rows","children":[{"zone":"a","weight":0},{"zone":"b"}]}}]}`, "weight 0 must be >= 1"},
		{"negative gap", `{"name":"x","monitors":[{"monitor":"m","root":
			{"split":"rows","gap":-4,"children":[{"zone":"a"},{"zone":"b"}]}}]}`, "must not be negative"},
		{"rule with no test", `{"name":"x","monitors":[{"monitor":"m","root":{"zone":"a"}}],
			"rules":[{"zone":"a"}]}`, "tests nothing"},
		{"no monitors", `{"name":"x","monitors":[]}`, "at least one"},
		{"unknown field", `{"name":"x","screens":[]}`, "unknown field"},
		{"valid", `{"name":"x","monitors":[{"monitor":"m","root":
			{"split":"rows","children":[{"zone":"a"},{"zone":"b"}]}}]}`, ""},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, err := ParseLayout([]byte(c.doc))
			if c.wantErr == "" {
				if err != nil {
					t.Fatalf("expected success, got %v", err)
				}
				return
			}
			if err == nil || !strings.Contains(err.Error(), c.wantErr) {
				t.Fatalf("error %v does not mention %q", err, c.wantErr)
			}
		})
	}
}

func TestLayoutTargetingAMissingMonitorIsAnError(t *testing.T) {
	lay := mustLayout(t, testLayoutFile{
		Name:     "x",
		Monitors: []*LayoutMonitor{{Monitor: "ghost", Root: &Node{Zone: "a"}}},
	})
	res := Solve(lay, oneMonitorTopo(t, "real", 800, 600))
	errs := res.Errors()
	if len(errs) == 0 || errs[0].Code != "monitor-missing" {
		t.Fatalf("expected monitor-missing, got %v", res.Issues)
	}
	if !strings.Contains(errs[0].Message, "refit") {
		t.Errorf("the error should point at refit: %s", errs[0].Message)
	}
}

func TestReorderFlags(t *testing.T) {
	vf := map[string]bool{"topology": true, "t": true, "out": true}
	cases := []struct {
		name string
		in   []string
		want []string
	}{
		{"flags already first", []string{"--topology", "t.json", "l.json"},
			[]string{"--topology", "t.json", "l.json"}},
		{"flags after positionals", []string{"l.json", "--topology", "t.json"},
			[]string{"--topology", "t.json", "l.json"}},
		{"boolean flag last", []string{"l.json", "--json"}, []string{"--json", "l.json"}},
		{"mixed", []string{"l.json", "-t", "t.json", "--json", "extra"},
			[]string{"-t", "t.json", "--json", "l.json", "extra"}},
		{"equals form survives", []string{"l.json", "--topology=t.json"},
			[]string{"--topology=t.json", "l.json"}},
		{"nothing", nil, nil},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := reorderFlags(c.in, vf)
			if strings.Join(got, "|") != strings.Join(c.want, "|") {
				t.Fatalf("reorderFlags(%v) = %v, want %v", c.in, got, c.want)
			}
		})
	}
}

func TestUsageGoesWhereItIsAsked(t *testing.T) {
	var buf bytes.Buffer
	usage(&buf)
	for _, want := range []string{"USAGE", "plan", "check", "refit", "render", "topo",
		"never asks the operating system"} {
		if !strings.Contains(buf.String(), want) {
			t.Errorf("usage output does not mention %q", want)
		}
	}
}

func TestScaleStringAndLogicalConversion(t *testing.T) {
	cases := []struct {
		permille int
		device   int
		want     int
		str      string
	}{
		{1000, 1920, 1920, "1x"},
		{2000, 2880, 1440, "2x"},
		{1500, 3840, 2560, "1.5x"},
		{1250, 1000, 800, "1.25x"},
		{1500, 1001, 667, "1.5x"}, // 667.33 rounds to 667
	}
	for _, c := range cases {
		m := Monitor{ScalePermille: c.permille}
		if got := m.ScaleString(); got != c.str {
			t.Errorf("ScaleString(%d) = %q, want %q", c.permille, got, c.str)
		}
		if got := toLogical(c.device, c.permille); got != c.want {
			t.Errorf("toLogical(%d, %d) = %d, want %d", c.device, c.permille, got, c.want)
		}
	}
	// Minimum sizes round UP so a stated minimum is never quietly shaved.
	if got := toDevice(201, 1500); got != 302 {
		t.Errorf("toDevice(201, 1500) = %d, want 302", got)
	}
	if got := toDevice(100, 1000); got != 100 {
		t.Errorf("toDevice(100, 1000) = %d, want 100", got)
	}
}
