package main

import (
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"math/rand"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
)

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

func mon(id string, w, h, scale int) Monitor {
	return Monitor{ID: id, Width: w, Height: h, Scale: scale,
		WorkArea: &WorkArea{X: 0, Y: 0, Width: w, Height: h}}
}

func topo(name string, ms ...Monitor) Topology {
	t := Topology{Name: name, Monitors: ms}
	if err := t.validate(); err != nil {
		panic(err)
	}
	return t
}

func pane(id string, prio, minW, minH int, idealMul int64) Pane {
	return Pane{ID: id, Label: id, Priority: prio, MinWidth: minW, MinHeight: minH,
		IdealArea: int64(minW) * int64(minH) * idealMul}
}

func scene(name string, ps ...Pane) Scene {
	s := Scene{Name: name, Panes: ps}
	if err := s.validate(); err != nil {
		panic(err)
	}
	return s
}

// placedRects returns one rectangle per placed UNIT (a tab stack contributes
// its single shared rectangle exactly once), keyed by monitor.
func placedRects(r FitResult) map[string][]rect {
	out := map[string][]rect{}
	for _, p := range r.Panes {
		if p.Rect == nil {
			continue
		}
		if p.Outcome == "stacked" && !p.StackFront {
			continue
		}
		out[p.Monitor] = append(out[p.Monitor], *p.Rect)
	}
	return out
}

// ---------------------------------------------------------------------------
// Property: placed panes never overlap and never leave the work area
// ---------------------------------------------------------------------------

func randomScene(rnd *rand.Rand, n int, allowRequired bool) Scene {
	s := Scene{Name: "random"}
	for i := 0; i < n; i++ {
		minW := 200 + rnd.Intn(700)
		minH := 150 + rnd.Intn(550)
		p := Pane{
			ID:        fmt.Sprintf("p%02d", i),
			Label:     fmt.Sprintf("pane %d", i),
			Priority:  1 + rnd.Intn(100),
			MinWidth:  minW,
			MinHeight: minH,
			IdealArea: int64(minW) * int64(minH) * int64(1+rnd.Intn(4)),
		}
		if rnd.Intn(3) == 0 {
			p.Stackable = true
			p.StackGroup = fmt.Sprintf("g%d", rnd.Intn(2))
		}
		if allowRequired && rnd.Intn(8) == 0 {
			p.Required = true
		}
		s.Panes = append(s.Panes, p)
	}
	if err := s.validate(); err != nil {
		panic(err)
	}
	return s
}

func randomTopology(rnd *rand.Rand) Topology {
	n := 1 + rnd.Intn(3)
	t := Topology{Name: "random rig"}
	scales := []int{100, 125, 150, 200}
	for i := 0; i < n; i++ {
		w := 1280 + 64*rnd.Intn(41) // 1280 .. 3840
		h := 720 + 60*rnd.Intn(25)  // 720 .. 2160
		sc := scales[rnd.Intn(len(scales))]
		m := Monitor{ID: fmt.Sprintf("m%d", i), Width: w, Height: h, Scale: sc}
		inset := 20 * rnd.Intn(4)
		m.WorkArea = &WorkArea{X: 0, Y: inset, Width: w, Height: h - inset}
		t.Monitors = append(t.Monitors, m)
	}
	if err := t.validate(); err != nil {
		panic(err)
	}
	return t
}

func TestPlacementIsInsideWorkAreaAndNonOverlapping(t *testing.T) {
	cases := []struct {
		name  string
		seed  int64
		iters int
		panes int
	}{
		{"small scenes", 1, 120, 4},
		{"medium scenes", 2, 120, 8},
		{"large scenes", 3, 80, 13},
		{"with required panes", 4, 120, 7},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rnd := rand.New(rand.NewSource(tc.seed))
			for it := 0; it < tc.iters; it++ {
				sc := randomScene(rnd, tc.panes, strings.Contains(tc.name, "required"))
				top := randomTopology(rnd)
				res := fitScene(sc, top)
				if !res.Feasible {
					continue
				}
				bounds := map[string]logicalMon{}
				for _, m := range top.logical() {
					bounds[m.ID] = m
				}
				for monID, rs := range placedRects(res) {
					m, ok := bounds[monID]
					if !ok {
						t.Fatalf("iter %d: pane placed on unknown monitor %q", it, monID)
					}
					for _, r := range rs {
						if r.W <= 0 || r.H <= 0 {
							t.Fatalf("iter %d: degenerate rect %+v on %s", it, r, monID)
						}
						// Deliberately spelled out rather than calling r.right(),
						// r.bottom() or r.overlaps(): an assertion that shares a
						// helper with the code it is checking cancels out when that
						// helper is the thing that broke.
						if r.X < 0 || r.Y < 0 || r.X+r.W > m.W || r.Y+r.H > m.H {
							t.Fatalf("iter %d: rect %+v escapes %s work area %dx%d", it, r, monID, m.W, m.H)
						}
					}
					for i := 0; i < len(rs); i++ {
						for j := i + 1; j < len(rs); j++ {
							a, b := rs[i], rs[j]
							if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
								t.Fatalf("iter %d: rects %+v and %+v overlap on %s", it, rs[i], rs[j], monID)
							}
						}
					}
				}
				// Every placed pane must also be at or above its own minimum.
				for _, p := range res.Panes {
					if p.Rect == nil {
						continue
					}
					if p.Rect.W < p.MinWidth || p.Rect.H < p.MinHeight {
						t.Fatalf("iter %d: pane %s placed at %dx%d below its %dx%d minimum",
							it, p.ID, p.Rect.W, p.Rect.H, p.MinWidth, p.MinHeight)
					}
				}
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Priority: an equal-sized higher-priority pane is never sacrificed
// ---------------------------------------------------------------------------

func TestEqualSizedHigherPriorityIsNeverDropped(t *testing.T) {
	uniform := func(n int, prios []int) Scene {
		var ps []Pane
		for i := 0; i < n; i++ {
			ps = append(ps, pane(fmt.Sprintf("u%02d", i), prios[i], 600, 400, 2))
		}
		return scene("uniform", ps...)
	}
	cases := []struct {
		name  string
		prios []int
		top   Topology
	}{
		{"ascending priorities, tight screen", []int{10, 20, 30, 40, 50, 60}, topo("tight", mon("m", 1280, 800, 100))},
		{"descending priorities, tight screen", []int{60, 50, 40, 30, 20, 10}, topo("tight", mon("m", 1280, 800, 100))},
		{"shuffled priorities, tiny screen", []int{7, 91, 33, 12, 64, 28, 55, 3}, topo("tiny", mon("m", 1600, 900, 100))},
		{"shuffled priorities, hidpi screen", []int{5, 80, 42, 17, 99, 23}, topo("hidpi", mon("m", 3200, 1800, 200))},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			res := fitScene(uniform(len(tc.prios), tc.prios), tc.top)
			if !res.Feasible {
				t.Fatalf("uniform scene should always be feasible, got: %s", res.Infeasible)
			}
			var placed, dropped []PaneOutcome
			for _, p := range res.Panes {
				if p.Outcome == "dropped" {
					dropped = append(dropped, p)
				} else {
					placed = append(placed, p)
				}
			}
			if len(dropped) == 0 {
				t.Fatalf("nothing was dropped on %s; this case is meant to force a sacrifice", tc.top.Name)
			}
			if len(placed) == 0 {
				t.Fatalf("everything was dropped")
			}
			for _, d := range dropped {
				for _, p := range placed {
					if d.Priority > p.Priority {
						t.Fatalf("dropped %s (priority %d) while keeping equally sized %s (priority %d)",
							d.ID, d.Priority, p.ID, p.Priority)
					}
				}
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Determinism under input permutation
// ---------------------------------------------------------------------------

func fingerprint(r FitResult) string {
	type row struct {
		ID           string
		Outcome      string
		Monitor      string
		X, Y, W, H   int
		ScalePercent int
		StackedWith  string
	}
	var rows []row
	for _, p := range r.Panes {
		rw := row{ID: p.ID, Outcome: p.Outcome, Monitor: p.Monitor,
			ScalePercent: p.ScalePercent, StackedWith: strings.Join(p.StackedWith, ",")}
		if p.Rect != nil {
			rw.X, rw.Y, rw.W, rw.H = p.Rect.X, p.Rect.Y, p.Rect.W, p.Rect.H
		}
		rows = append(rows, rw)
	}
	sort.Slice(rows, func(a, b int) bool { return rows[a].ID < rows[b].ID })
	b, _ := json.Marshal(struct {
		Score int64
		Level int
		Rows  []row
	}{r.Score, r.ShrinkLevel, rows})
	return string(b)
}

func TestDeterminismUnderPermutation(t *testing.T) {
	cases := []struct {
		name string
		seed int64
		top  Topology
	}{
		{"single monitor", 11, topo("one", mon("m", 1920, 1080, 100))},
		{"dual monitor", 12, topo("two", mon("a", 2560, 1440, 100), mon("b", 1920, 1080, 100))},
		{"hidpi trio", 13, topo("three", mon("a", 3840, 2160, 200), mon("b", 1920, 1080, 100), mon("c", 2560, 1440, 125))},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rnd := rand.New(rand.NewSource(tc.seed))
			for it := 0; it < 25; it++ {
				base := randomScene(rnd, 6+rnd.Intn(5), false)
				want := fingerprint(fitScene(base, tc.top))
				for perm := 0; perm < 8; perm++ {
					shuffled := Scene{Name: base.Name, Panes: append([]Pane(nil), base.Panes...)}
					rnd.Shuffle(len(shuffled.Panes), func(i, j int) {
						shuffled.Panes[i], shuffled.Panes[j] = shuffled.Panes[j], shuffled.Panes[i]
					})
					got := fingerprint(fitScene(shuffled, tc.top))
					if got != want {
						t.Fatalf("iter %d perm %d: permuting the pane order changed the answer\nwant %s\ngot  %s",
							it, perm, want, got)
					}
				}
			}
		})
	}
}

func TestDeterminismAcrossRepeatedRuns(t *testing.T) {
	sc, err := loadPreset("trading-desk")
	if err != nil {
		t.Fatal(err)
	}
	tp := topo("rig", mon("a", 2560, 1440, 100), mon("b", 1920, 1080, 100))
	want := fingerprint(fitScene(sc, tp))
	for i := 0; i < 20; i++ {
		if got := fingerprint(fitScene(sc, tp)); got != want {
			t.Fatalf("run %d differs from run 0", i)
		}
	}
}

// ---------------------------------------------------------------------------
// Infeasible required panes
// ---------------------------------------------------------------------------

func TestInfeasibleRequiredDetection(t *testing.T) {
	req := func(p Pane) Pane { p.Required = true; return p }
	cases := []struct {
		name           string
		sc             Scene
		top            Topology
		wantFeasible   bool
		wantReasonPart string
	}{
		{
			name:           "required pane wider than any monitor",
			sc:             scene("s", req(pane("huge", 100, 3000, 400, 1)), pane("small", 10, 300, 200, 1)),
			top:            topo("t", mon("m", 1920, 1080, 100)),
			wantFeasible:   false,
			wantReasonPart: "largest monitor work area",
		},
		{
			name:           "required pane taller than any monitor",
			sc:             scene("s", req(pane("tall", 100, 400, 2000, 1))),
			top:            topo("t", mon("m", 1920, 1080, 100)),
			wantFeasible:   false,
			wantReasonPart: "largest monitor work area",
		},
		{
			name:           "hidpi scaling makes a required pane not fit",
			sc:             scene("s", req(pane("wide", 100, 1600, 800, 1))),
			top:            topo("t", mon("m", 2560, 1440, 200)), // 1280x720 logical
			wantFeasible:   false,
			wantReasonPart: "1280x720",
		},
		{
			name: "two required panes that cannot coexist",
			sc: scene("s",
				req(pane("a", 100, 1000, 900, 1)),
				req(pane("b", 99, 1000, 900, 1))),
			top:            topo("t", mon("m", 1920, 1080, 100)),
			wantFeasible:   false,
			wantReasonPart: "cannot all be placed",
		},
		{
			name: "the same panes fit once one is not required",
			sc: scene("s",
				req(pane("a", 100, 1000, 900, 1)),
				pane("b", 99, 1000, 900, 1)),
			top:          topo("t", mon("m", 1920, 1080, 100)),
			wantFeasible: true,
		},
		{
			name:         "required pane that fits exactly",
			sc:           scene("s", req(pane("exact", 100, 1920, 1080, 1))),
			top:          topo("t", mon("m", 1920, 1080, 100)),
			wantFeasible: true,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			res := fitScene(tc.sc, tc.top)
			if res.Feasible != tc.wantFeasible {
				t.Fatalf("feasible = %v, want %v (reason %q)", res.Feasible, tc.wantFeasible, res.Infeasible)
			}
			if !tc.wantFeasible {
				if res.Infeasible == "" {
					t.Fatalf("infeasible result carries no reason")
				}
				if !strings.Contains(res.Infeasible, tc.wantReasonPart) {
					t.Fatalf("reason %q does not mention %q", res.Infeasible, tc.wantReasonPart)
				}
				if res.CountDropped != len(tc.sc.Panes) {
					t.Fatalf("infeasible result reports %d dropped, want %d", res.CountDropped, len(tc.sc.Panes))
				}
			} else {
				// A feasible result must actually place every required pane.
				for _, p := range res.Panes {
					if p.Required && p.Outcome == "dropped" {
						t.Fatalf("feasible result dropped required pane %s", p.ID)
					}
				}
			}
		})
	}
}

func TestRequiredPaneIsNeverDroppedInRandomScenes(t *testing.T) {
	rnd := rand.New(rand.NewSource(99))
	for it := 0; it < 250; it++ {
		sc := randomScene(rnd, 3+rnd.Intn(8), true)
		top := randomTopology(rnd)
		res := fitScene(sc, top)
		if !res.Feasible {
			continue
		}
		for _, p := range res.Panes {
			if p.Required && p.Outcome == "dropped" {
				t.Fatalf("iter %d: feasible fit dropped required pane %s", it, p.ID)
			}
		}
	}
}

// ---------------------------------------------------------------------------
// Stacking beats dropping
// ---------------------------------------------------------------------------

func TestStackingIsPreferredToDropping(t *testing.T) {
	stackable := func(id string, prio int) Pane {
		p := pane(id, prio, 600, 400, 1)
		p.Stackable = true
		p.StackGroup = "g"
		return p
	}
	cases := []struct {
		name string
		sc   Scene
		top  Topology
	}{
		{"two into one slot", scene("s", stackable("a", 50), stackable("b", 40)),
			topo("t", mon("m", 640, 440, 100))},
		{"four into one slot", scene("s", stackable("a", 50), stackable("b", 40), stackable("c", 30), stackable("d", 20)),
			topo("t", mon("m", 640, 440, 100))},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			res := fitScene(tc.sc, tc.top)
			if !res.Feasible {
				t.Fatalf("infeasible: %s", res.Infeasible)
			}
			if res.CountDropped != 0 {
				t.Fatalf("dropped %d panes that could have been stacked instead", res.CountDropped)
			}
			if res.CountStacked != len(tc.sc.Panes) {
				t.Fatalf("stacked %d of %d panes", res.CountStacked, len(tc.sc.Panes))
			}
			fronts := 0
			for _, p := range res.Panes {
				if p.StackFront {
					fronts++
				}
			}
			if fronts != 1 {
				t.Fatalf("expected exactly one front tab, got %d", fronts)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// advise: the recommendation must genuinely work
// ---------------------------------------------------------------------------

func TestAdviseRecommendationActuallyMakesTheSceneFit(t *testing.T) {
	presets := presetNames()
	if len(presets) == 0 {
		t.Fatal("no presets embedded")
	}
	tops := []Topology{
		topo("laptop", Monitor{ID: "internal", Width: 2880, Height: 1800, Scale: 200,
			WorkArea: &WorkArea{X: 0, Y: 60, Width: 2880, Height: 1700}}),
		topo("single 1080p", mon("m", 1920, 1080, 100)),
		topo("dual 1440p", mon("a", 2560, 1440, 100), mon("b", 2560, 1440, 100)),
		topo("odd trio", mon("a", 1600, 900, 100), mon("b", 1920, 1200, 125), mon("c", 1366, 768, 100)),
	}
	for _, name := range presets {
		sc, err := loadPreset(name)
		if err != nil {
			t.Fatalf("%s: %v", name, err)
		}
		for _, tp := range tops {
			t.Run(name+"/"+tp.Name, func(t *testing.T) {
				adv := adviseScene(sc, tp)
				if adv.AlreadyFits {
					if !fitScene(sc, tp).FullFit {
						t.Fatalf("advise claims the scene already fits but the fit disagrees")
					}
					return
				}
				if adv.NoSolution {
					if adv.Recommended != nil {
						t.Fatalf("no_solution set but a recommendation was returned")
					}
					return
				}
				if adv.Recommended == nil {
					t.Fatalf("no recommendation and no no_solution flag")
				}
				// Re-run the real fit on the recommended topology.
				re := fitScene(sc, adv.Recommended.Topology)
				if !re.Feasible {
					t.Fatalf("recommended topology %q is infeasible: %s",
						adv.Recommended.Description, re.Infeasible)
				}
				if !re.FullFit {
					t.Fatalf("recommended change %q does not actually make %q fit: %d full, %d shrunk, %d stacked, %d dropped",
						adv.Recommended.Description, sc.Name,
						re.CountFull, re.CountShrunk, re.CountStacked, re.CountDropped)
				}
				for _, alt := range adv.Alternatives {
					ra := fitScene(sc, alt.Topology)
					if !ra.FullFit {
						t.Fatalf("alternative %q does not make the scene fit", alt.Description)
					}
					if alt.CostPixels < adv.Recommended.CostPixels {
						t.Fatalf("alternative %q is cheaper (%d px) than the recommendation (%d px)",
							alt.Description, alt.CostPixels, adv.Recommended.CostPixels)
					}
				}
				// Nothing cheaper in the catalogue may work: check every
				// candidate strictly cheaper than the recommendation fails.
				for _, c := range candidates(tp) {
					if c.CostPixels >= adv.Recommended.CostPixels {
						break
					}
					if fitScene(sc, c.Topology).FullFit {
						t.Fatalf("cheaper change %q (%d px) also fits but was not recommended",
							c.Description, c.CostPixels)
					}
				}
			})
		}
	}
}

func TestAdviseSaysNothingIsNeededWhenItAlreadyFits(t *testing.T) {
	sc, err := loadPreset("developer")
	if err != nil {
		t.Fatal(err)
	}
	big := topo("huge", mon("a", 3840, 2160, 100), mon("b", 3840, 2160, 100))
	adv := adviseScene(sc, big)
	if !adv.AlreadyFits {
		t.Fatalf("expected already_fits on a huge rig, got recommendation %+v", adv.Recommended)
	}
	if adv.Recommended != nil {
		t.Fatalf("already fitting scene should carry no recommendation")
	}
}

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

func TestSVGIsWellFormed(t *testing.T) {
	cases := []struct {
		name  string
		scene func() Scene
		top   Topology
	}{
		{"trading desk on a rig", func() Scene { s, _ := loadPreset("trading-desk"); return s },
			topo("rig", mon("a", 2560, 1440, 100), mon("b", 3840, 2160, 100), mon("c", 2560, 1440, 100))},
		{"trading desk on a laptop", func() Scene { s, _ := loadPreset("trading-desk"); return s },
			topo("laptop", Monitor{ID: "internal", Width: 2880, Height: 1800, Scale: 200,
				WorkArea: &WorkArea{X: 0, Y: 60, Width: 2880, Height: 1700}})},
		{"video editor, mostly dropped", func() Scene { s, _ := loadPreset("video-editor"); return s },
			topo("small", mon("m", 1600, 900, 100))},
		{"infeasible scene", func() Scene {
			p := pane("huge", 100, 4000, 3000, 1)
			p.Required = true
			return scene("impossible", p)
		}, topo("small", mon("m", 1600, 900, 100))},
		{"labels needing XML escaping", func() Scene {
			p := pane("a&b", 50, 400, 300, 1)
			p.Label = `Risk <P&L> "live" & 'fast'`
			return scene(`Scene <with> & "quotes"`, p)
		}, topo("m", mon("m", 1920, 1080, 100))},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			sc := tc.scene()
			res := fitScene(sc, tc.top)
			data := renderSVG(res, tc.top)
			if len(data) == 0 {
				t.Fatal("empty SVG")
			}
			// 1. It must parse as XML, end to end.
			dec := xml.NewDecoder(strings.NewReader(string(data)))
			depth := 0
			var root string
			var rects, texts 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
					}
					depth++
					switch e.Name.Local {
					case "rect":
						rects++
						for _, a := range e.Attr {
							if a.Name.Local == "width" || a.Name.Local == "height" {
								if strings.ContainsAny(a.Value, ".-") {
									t.Fatalf("non-integer or negative %s=%q in SVG", a.Name.Local, a.Value)
								}
							}
						}
					case "text":
						texts++
					}
				case xml.EndElement:
					depth--
					if depth < 0 {
						t.Fatal("unbalanced element nesting in SVG")
					}
				}
			}
			if depth != 0 {
				t.Fatalf("unclosed elements in SVG (depth %d)", depth)
			}
			if root != "svg" {
				t.Fatalf("root element is %q, want svg", root)
			}
			if rects == 0 || texts == 0 {
				t.Fatalf("SVG has %d rects and %d texts; expected some of each", rects, texts)
			}
			// 2. Required structural attributes.
			head := string(data[:minInt(len(data), 400)])
			for _, want := range []string{`xmlns="http://www.w3.org/2000/svg"`, "viewBox=", "width=", "height="} {
				if !strings.Contains(head, want) {
					t.Fatalf("SVG header is missing %s: %s", want, head)
				}
			}
			// 3. Every placed pane must be represented.
			for _, p := range res.Panes {
				if p.Rect == nil || (p.Outcome == "stacked" && !p.StackFront) {
					continue
				}
				if !strings.Contains(string(data), esc(p.Label)) {
					t.Fatalf("placed pane %s does not appear in the SVG", p.ID)
				}
			}
		})
	}
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

// ---------------------------------------------------------------------------
// Presets, geometry and flag plumbing
// ---------------------------------------------------------------------------

func TestPresetsAreValidAndDistinct(t *testing.T) {
	names := presetNames()
	if len(names) < 3 {
		t.Fatalf("expected at least 3 presets, got %d: %v", len(names), names)
	}
	seen := map[string]bool{}
	for _, n := range names {
		sc, err := loadPreset(n)
		if err != nil {
			t.Fatalf("preset %s: %v", n, err)
		}
		if len(sc.Panes) == 0 {
			t.Fatalf("preset %s has no panes", n)
		}
		if seen[sc.Name] {
			t.Fatalf("duplicate preset title %q", sc.Name)
		}
		seen[sc.Name] = true
		required := 0
		for _, p := range sc.Panes {
			if p.Required {
				required++
			}
			if p.IdealArea < int64(p.MinWidth)*int64(p.MinHeight) {
				t.Fatalf("preset %s pane %s: ideal area below minimum", n, p.ID)
			}
		}
		if required == 0 {
			t.Fatalf("preset %s marks nothing as required", n)
		}
	}
	if _, err := loadPreset("does-not-exist"); err == nil {
		t.Fatal("loading a missing preset should fail")
	}
}

func TestIdealDimsRespectMinimumsAndArea(t *testing.T) {
	cases := []struct {
		minW, minH int
		area       int64
	}{
		{640, 420, 540000},
		{100, 100, 10000},
		{1200, 340, 864000},
		{320, 640, 288800},
		{1, 1, 1},
		{900, 600, 100}, // ideal below minimum: must be raised
		{7, 3, 1000000},
	}
	for _, tc := range cases {
		w, h := idealDims(tc.minW, tc.minH, tc.area)
		if w < tc.minW || h < tc.minH {
			t.Fatalf("idealDims(%d,%d,%d) = %dx%d, below the minimum", tc.minW, tc.minH, tc.area, w, h)
		}
		floor := int64(tc.minW) * int64(tc.minH)
		want := tc.area
		if want < floor {
			want = floor
		}
		got := int64(w) * int64(h)
		// Integer truncation may lose at most one row and one column.
		if got > want+int64(w)+int64(h) || got < want-int64(w)-int64(h) {
			t.Fatalf("idealDims(%d,%d,%d) area %d is not close to %d", tc.minW, tc.minH, tc.area, got, want)
		}
	}
}

func TestIsqrt(t *testing.T) {
	for _, n := range []int64{0, 1, 2, 3, 4, 8, 9, 15, 16, 99, 100, 101, 1 << 20, 1<<40 - 1, 864000} {
		r := isqrt(n)
		if r < 0 {
			t.Fatalf("isqrt(%d) negative", n)
		}
		if r*r > n {
			t.Fatalf("isqrt(%d) = %d, squared overshoots", n, r)
		}
		if n >= 1 && (r+1)*(r+1) <= n {
			t.Fatalf("isqrt(%d) = %d, not maximal", n, r)
		}
	}
}

func TestReorderFlagsMovesFlagsAfterPositionals(t *testing.T) {
	cases := []struct {
		name string
		in   []string
		want []string
	}{
		{"already ordered", []string{"--displays", "d.json", "scene"}, []string{"--displays", "d.json", "scene"}},
		{"flag after positional", []string{"scene", "--displays", "d.json"}, []string{"--displays", "d.json", "scene"}},
		{"boolean flag last", []string{"a", "b", "--json"}, []string{"--json", "a", "b"}},
		{"mixed", []string{"a", "--displays", "d.json", "b", "--json"}, []string{"--displays", "d.json", "--json", "a", "b"}},
		{"shorthand value flag", []string{"a", "-d", "d.json"}, []string{"-d", "d.json", "a"}},
		{"nothing", nil, nil},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := reorderFlags(tc.in, valueFlags)
			if !reflect.DeepEqual(got, tc.want) {
				t.Fatalf("reorderFlags(%v) = %v, want %v", tc.in, got, tc.want)
			}
		})
	}
}

func TestPerMilleFormatting(t *testing.T) {
	cases := []struct {
		in   int64
		want string
	}{
		{0, "0.0%"}, {1, "0.1%"}, {468, "46.8%"}, {998, "99.8%"}, {1000, "100.0%"}, {-25, "-2.5%"},
	}
	for _, tc := range cases {
		if got := perMille(tc.in); got != tc.want {
			t.Fatalf("perMille(%d) = %q, want %q", tc.in, got, tc.want)
		}
	}
}

func TestFitResultRoundTripsThroughJSON(t *testing.T) {
	sc, err := loadPreset("trading-desk")
	if err != nil {
		t.Fatal(err)
	}
	tp := topo("laptop", Monitor{ID: "internal", Width: 2880, Height: 1800, Scale: 200,
		WorkArea: &WorkArea{X: 0, Y: 60, Width: 2880, Height: 1700}})
	res := fitScene(sc, tp)
	b, err := json.Marshal(res)
	if err != nil {
		t.Fatal(err)
	}
	var back FitResult
	if err := json.Unmarshal(b, &back); err != nil {
		t.Fatal(err)
	}
	if fingerprint(back) != fingerprint(res) {
		t.Fatal("FitResult does not survive a JSON round trip")
	}
	// Geometry must serialise as integers only.
	if strings.Contains(string(b), `.0`) || strings.Contains(string(b), `e+`) {
		t.Fatalf("JSON contains a floating point number: %s", string(b))
	}
}

// ---------------------------------------------------------------------------
// A generous rig must fit the presets at full size
// ---------------------------------------------------------------------------

func TestGenerousRigFitsPresetsFully(t *testing.T) {
	rig := topo("generous", mon("a", 3840, 2160, 100), mon("b", 3840, 2160, 100), mon("c", 2560, 1440, 100))
	for _, name := range presetNames() {
		t.Run(name, func(t *testing.T) {
			sc, err := loadPreset(name)
			if err != nil {
				t.Fatal(err)
			}
			res := fitScene(sc, rig)
			if !res.Feasible {
				t.Fatalf("infeasible on a generous rig: %s", res.Infeasible)
			}
			if !res.FullFit {
				t.Fatalf("%s did not fully fit a generous rig: %d full, %d shrunk, %d stacked, %d dropped",
					name, res.CountFull, res.CountShrunk, res.CountStacked, res.CountDropped)
			}
			if res.Score != res.MaxScore && res.FitPerMille < 990 {
				t.Fatalf("%s scored only %d/%d on a generous rig", name, res.Score, res.MaxScore)
			}
			if res.ShrinkLevel != 100 {
				t.Fatalf("%s needed shrink level %d on a generous rig", name, res.ShrinkLevel)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Degradation is monotone in the direction you expect
// ---------------------------------------------------------------------------

func TestSmallerScreenNeverScoresHigher(t *testing.T) {
	sc, err := loadPreset("trading-desk")
	if err != nil {
		t.Fatal(err)
	}
	sizes := []int{1280, 1600, 1920, 2560, 3200, 3840}
	prev := int64(-1)
	for _, w := range sizes {
		h := w * 9 / 16
		res := fitScene(sc, topo(fmt.Sprintf("%dx%d", w, h), mon("m", w, h, 100)))
		if !res.Feasible {
			continue
		}
		if res.FitPerMille < prev {
			t.Fatalf("a %dx%d screen scored %d, lower than the smaller screen before it (%d)", w, h, res.FitPerMille, prev)
		}
		prev = res.FitPerMille
	}
}

// ---------------------------------------------------------------------------
// Every degradation carries a reason
// ---------------------------------------------------------------------------

func TestEveryOutcomeCarriesAReason(t *testing.T) {
	rnd := rand.New(rand.NewSource(7))
	for it := 0; it < 150; it++ {
		sc := randomScene(rnd, 4+rnd.Intn(8), false)
		top := randomTopology(rnd)
		res := fitScene(sc, top)
		for _, p := range res.Panes {
			if strings.TrimSpace(p.Reason) == "" {
				t.Fatalf("iter %d: pane %s (%s) has no reason", it, p.ID, p.Outcome)
			}
			switch p.Outcome {
			case "full", "shrunk", "stacked", "dropped":
			default:
				t.Fatalf("iter %d: pane %s has unknown outcome %q", it, p.ID, p.Outcome)
			}
			if p.Outcome == "dropped" && p.Rect != nil {
				t.Fatalf("iter %d: dropped pane %s still carries a rectangle", it, p.ID)
			}
			if p.Outcome != "dropped" && p.Rect == nil && res.Feasible {
				t.Fatalf("iter %d: placed pane %s has no rectangle", it, p.ID)
			}
		}
	}
}

// ---------------------------------------------------------------------------
// CLI surface: exit codes, streams, flag ordering, --json validity
// ---------------------------------------------------------------------------

var binPath string

func TestMain(m *testing.M) {
	dir, err := os.MkdirTemp("", "deskscene-test")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	binPath = filepath.Join(dir, "deskscene")
	cmd := exec.Command("go", "build", "-o", binPath, ".")
	if out, err := cmd.CombinedOutput(); err != nil {
		fmt.Fprintf(os.Stderr, "cannot build test binary: %v\n%s\n", err, out)
		binPath = ""
	}
	code := m.Run()
	os.RemoveAll(dir)
	os.Exit(code)
}

func run(t *testing.T, args ...string) (string, string, int) {
	t.Helper()
	if binPath == "" {
		t.Skip("test binary was not built")
	}
	cmd := exec.Command(binPath, args...)
	var stdout, stderr strings.Builder
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	err := cmd.Run()
	code := 0
	if ee, ok := err.(*exec.ExitError); ok {
		code = ee.ExitCode()
	} else if err != nil {
		t.Fatalf("running %v: %v", args, err)
	}
	return stdout.String(), stderr.String(), code
}

func TestCLIHelpAndErrors(t *testing.T) {
	dir := t.TempDir()
	disp := filepath.Join(dir, "d.json")
	if err := os.WriteFile(disp, []byte(`{"name":"t","monitors":[{"id":"m","width":1920,"height":1080}]}`), 0o644); err != nil {
		t.Fatal(err)
	}
	cases := []struct {
		name        string
		args        []string
		wantCode    int
		wantStdout  string
		wantStderr  string
		emptyStderr bool
		emptyStdout bool
	}{
		{name: "help subcommand", args: []string{"help"}, wantCode: 0, wantStdout: "USAGE", emptyStderr: true},
		{name: "-h", args: []string{"-h"}, wantCode: 0, wantStdout: "USAGE", emptyStderr: true},
		{name: "--help", args: []string{"--help"}, wantCode: 0, wantStdout: "USAGE", emptyStderr: true},
		{name: "help after a subcommand", args: []string{"fit", "--help"}, wantCode: 0, wantStdout: "USAGE", emptyStderr: true},
		{name: "no arguments", args: nil, wantCode: 1, wantStderr: "USAGE", emptyStdout: true},
		{name: "unknown command", args: []string{"nope"}, wantCode: 1, wantStderr: "unknown command", emptyStdout: true},
		{name: "fit without displays", args: []string{"fit", "--scene", "developer"}, wantCode: 1, wantStderr: "needs --displays", emptyStdout: true},
		{name: "fit without scene", args: []string{"fit", "--displays", disp}, wantCode: 1, wantStderr: "needs --scene", emptyStdout: true},
		{name: "compare with one scene", args: []string{"compare", "developer", "--displays", disp}, wantCode: 1, wantStderr: "exactly two scenes", emptyStdout: true},
		{name: "render without out", args: []string{"render", "--scene", "developer", "--displays", disp}, wantCode: 1, wantStderr: "needs --out", emptyStdout: true},
		{name: "missing scene file", args: []string{"fit", "--scene", "./missing.json", "--displays", disp}, wantCode: 1, wantStderr: "cannot read scene", emptyStdout: true},
		{name: "unknown preset", args: []string{"fit", "--scene", "banana", "--displays", disp}, wantCode: 1, wantStderr: "no built-in preset", emptyStdout: true},
		{name: "presets with a stray argument", args: []string{"presets", "junk"}, wantCode: 1, wantStderr: "no positional arguments", emptyStdout: true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			so, se, code := run(t, tc.args...)
			if code != tc.wantCode {
				t.Fatalf("exit %d, want %d (stdout %q stderr %q)", code, tc.wantCode, so, se)
			}
			if tc.wantStdout != "" && !strings.Contains(so, tc.wantStdout) {
				t.Fatalf("stdout %q does not contain %q", so, tc.wantStdout)
			}
			if tc.wantStderr != "" && !strings.Contains(se, tc.wantStderr) {
				t.Fatalf("stderr %q does not contain %q", se, tc.wantStderr)
			}
			if tc.emptyStderr && se != "" {
				t.Fatalf("expected empty stderr, got %q", se)
			}
			if tc.emptyStdout && so != "" {
				t.Fatalf("expected empty stdout, got %q", so)
			}
		})
	}
}

func TestCLIJSONOutputIsValid(t *testing.T) {
	dir := t.TempDir()
	disp := filepath.Join(dir, "d.json")
	if err := os.WriteFile(disp, []byte(
		`{"name":"rig","monitors":[{"id":"a","width":2560,"height":1440},{"id":"b","width":1920,"height":1080}]}`), 0o644); err != nil {
		t.Fatal(err)
	}
	svg := filepath.Join(dir, "out.svg")
	cases := [][]string{
		{"presets", "--json"},
		{"fit", "--scene", "trading-desk", "--displays", disp, "--json"},
		{"compare", "trading-desk", "developer", "--displays", disp, "--json"},
		{"advise", "--scene", "video-editor", "--displays", disp, "--json"},
		{"render", "--scene", "developer", "--displays", disp, "--out", svg, "--json"},
		// flags placed AFTER positional arguments must still work
		{"compare", "trading-desk", "--json", "developer", "--displays", disp},
		{"fit", "trading-desk", "--displays", disp, "--json"},
	}
	for _, args := range cases {
		t.Run(strings.Join(args, " "), func(t *testing.T) {
			so, se, code := run(t, args...)
			if code != 0 {
				t.Fatalf("exit %d: %s", code, se)
			}
			var v any
			if err := json.Unmarshal([]byte(so), &v); err != nil {
				t.Fatalf("output is not valid JSON: %v\n%s", err, so)
			}
			if se != "" {
				t.Fatalf("unexpected stderr: %q", se)
			}
		})
	}
	if _, err := os.Stat(svg); err != nil {
		t.Fatalf("render --out did not create the SVG: %v", err)
	}
}

func TestCLIFitEndToEnd(t *testing.T) {
	dir := t.TempDir()
	disp := filepath.Join(dir, "laptop.json")
	if err := os.WriteFile(disp, []byte(
		`{"name":"laptop","monitors":[{"id":"internal","width":2880,"height":1800,"scale":200,`+
			`"workArea":{"x":0,"y":60,"width":2880,"height":1700}}]}`), 0o644); err != nil {
		t.Fatal(err)
	}
	so, se, code := run(t, "fit", "--scene", "trading-desk", "--displays", disp)
	if code != 0 {
		t.Fatalf("exit %d: %s", code, se)
	}
	for _, want := range []string{"DeskScene fit", "fit score", "PANES", "MONITORS", "stacked", "dropped"} {
		if !strings.Contains(so, want) {
			t.Fatalf("fit output is missing %q:\n%s", want, so)
		}
	}
}
