// Command workspaceforge computes exact pixel rectangles for a declarative
// window layout on a described display topology, and re-fits a layout saved on
// one monitor arrangement onto a completely different one.
//
// It reads and writes only its own files. It never enumerates the displays
// actually attached to this machine and never moves a real window.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
)

const appName = "workspaceforge"

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (verbatim across the tool line)
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

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

func usage(w io.Writer) {
	fmt.Fprintf(w, `%s - window layout solver and re-fitter (Techlosoft Workspace Line)

USAGE
  %s plan   <layout.json> --topology <topology.json> [--windows <f>] [--json]
  %s check  <layout.json> [--topology <topology.json>] [--windows <f>] [--json]
  %s refit  <layout.json> --from <topology.json> --to <topology.json> [--json]
  %s render <layout.json> --topology <topology.json> --out <map.svg> [--json]
  %s topo   <topology.json> [--json]
  %s help | -h | --help

COMMANDS
  plan     Solve the layout against the topology and print the exact integer
           rectangle of every zone and every window.
  check    Validate a layout: structure, tiling, overlaps, out-of-bounds
           rectangles, minimum-size violations, unmatched windows and
           unreachable rules. With --topology it also solves the geometry.
  refit    Take a layout authored for one topology and map it onto another.
           Monitors are paired by rank (primary, then descending area, then
           position); zones are re-solved against the new work areas; monitors
           that no longer exist are folded into the destination primary. Every
           window that moves, resizes or is folded is reported.
  render   Draw the solved layout as a real, to-scale SVG diagram you can open
           in any browser.
  topo     Validate and describe a topology file: bounds, work areas, scale,
           canonical rank order and adjacency.

FLAGS
  --topology <file>  Display topology JSON (plan, check, render).
  --from <file>      Source topology the layout was authored on (refit).
  --to <file>        Destination topology to map it onto (refit).
  --windows <file>   Window list JSON, replacing the layout's own "windows".
  --out <file>       SVG file to write (render).
  --json             Machine-readable JSON output. Available on every command.

Short forms -t, -l, -w and -o are accepted for --topology, --layout, --windows
and --out. Flags may appear before or after positional arguments.

EXAMPLES
  %s plan desk.layout.json --topology desk.topology.json
  %s check desk.layout.json --topology desk.topology.json --json
  %s refit desk.layout.json --from desk.topology.json --to laptop.topology.json
  %s render desk.layout.json -t desk.topology.json --out map.svg
  %s topo laptop.topology.json

%s never asks the operating system which displays are attached and never moves
a real window. It reads the files you give it and writes the files you ask for.
`, appName, appName, appName, appName, appName, appName, appName,
		appName, appName, appName, appName, appName, appName)
}

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

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

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

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask what
		// the program needs and stay on screen. Printing usage and exiting
		// here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage(os.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "help", "-h", "--help":
		usage(os.Stdout)
		os.Exit(0)
	}
	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usage(os.Stdout)
			os.Exit(0)
		}
	}
	switch cmd {
	case "plan":
		cmdPlan(rest)
	case "check":
		cmdCheck(rest)
	case "refit":
		cmdRefit(rest)
	case "render":
		cmdRender(rest)
	case "topo":
		cmdTopo(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

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

var valueFlags = map[string]bool{
	"topology": true, "t": true,
	"layout": true, "l": true,
	"windows": true, "w": true,
	"out": true, "o": true,
	"from": true,
	"to":   true,
}

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

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

// loadLayoutAndWindows loads a layout, optionally replacing its window list.
func loadLayoutAndWindows(layoutPath, windowsPath string) *Layout {
	lay, err := LoadLayout(layoutPath)
	if err != nil {
		fail("%v", err)
	}
	if windowsPath != "" {
		wins, werr := LoadWindows(windowsPath)
		if werr != nil {
			fail("%v", werr)
		}
		lay.Windows = wins
	}
	return lay
}

func mustTopology(path string) *Topology {
	t, err := LoadTopology(path)
	if err != nil {
		fail("%v", err)
	}
	return t
}

// ---------------------------------------------------------------------------
// Table printing
// ---------------------------------------------------------------------------

type table struct {
	head  []string
	rows  [][]string
	right map[int]bool
}

func newTable(head ...string) *table {
	return &table{head: head, right: map[int]bool{}}
}

func (t *table) rightAlign(cols ...int) {
	for _, c := range cols {
		t.right[c] = true
	}
}

func (t *table) add(cells ...string) { t.rows = append(t.rows, cells) }

func (t *table) render(w io.Writer, indent string) {
	widths := make([]int, len(t.head))
	for i, h := range t.head {
		widths[i] = len(h)
	}
	for _, r := range t.rows {
		for i, c := range r {
			if i < len(widths) && len(c) > widths[i] {
				widths[i] = len(c)
			}
		}
	}
	line := func(cells []string) {
		var b strings.Builder
		b.WriteString(indent)
		for i, c := range cells {
			if i >= len(widths) {
				break
			}
			if t.right[i] {
				b.WriteString(strings.Repeat(" ", widths[i]-len(c)))
				b.WriteString(c)
			} else {
				b.WriteString(c)
				if i != len(cells)-1 {
					b.WriteString(strings.Repeat(" ", widths[i]-len(c)))
				}
			}
			if i != len(cells)-1 {
				b.WriteString("  ")
			}
		}
		fmt.Fprintln(w, strings.TrimRight(b.String(), " "))
	}
	line(t.head)
	sep := make([]string, len(t.head))
	for i := range sep {
		sep[i] = strings.Repeat("-", widths[i])
	}
	line(sep)
	for _, r := range t.rows {
		line(r)
	}
}

// ---------------------------------------------------------------------------
// plan
// ---------------------------------------------------------------------------

func cmdPlan(argv []string) {
	fs := newFlagSet("plan")
	topoPath := fs.String("topology", "", "display topology JSON")
	fs.StringVar(topoPath, "t", "", "shorthand for --topology")
	layPath := fs.String("layout", "", "layout JSON")
	fs.StringVar(layPath, "l", "", "shorthand for --layout")
	winPath := fs.String("windows", "", "window list JSON")
	fs.StringVar(winPath, "w", "", "shorthand for --windows")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *layPath == "" && fs.NArg() > 0 {
		*layPath = fs.Arg(0)
	}
	if *layPath == "" {
		usageErr("plan needs a layout file")
	}
	if *topoPath == "" && fs.NArg() > 1 {
		*topoPath = fs.Arg(1)
	}
	if *topoPath == "" {
		usageErr("plan needs --topology <topology.json>")
	}
	if fs.NArg() > 2 {
		usageErr("plan takes at most two positional arguments, got %d", fs.NArg())
	}

	topo := mustTopology(*topoPath)
	lay := loadLayoutAndWindows(*layPath, *winPath)
	res := Solve(lay, topo)
	res.Issues = append(res.Issues, VerifyTiling(res)...)
	sortIssues(res)

	if *asJSON {
		emitJSON(map[string]any{
			"command":       "plan",
			"layout_file":   lay.Source,
			"topology_file": topo.Source,
			"plan":          res,
			"ok":            len(res.Errors()) == 0,
		})
		if len(res.Errors()) > 0 {
			os.Exit(1)
		}
		return
	}

	fmt.Printf("WorkspaceForge plan\n")
	fmt.Printf("layout   : %s (%s)\n", lay.Name, lay.Source)
	fmt.Printf("topology : %s (%s)\n", topo.Name, topo.Source)
	fmt.Println()
	printMonitorTable(res)
	fmt.Println()
	printPlacementTable(res)
	fmt.Println()
	printIssues(res.Issues)
	if len(res.Errors()) > 0 {
		os.Exit(1)
	}
}

func printMonitorTable(res *SolveResult) {
	t := newTable("RANK", "MONITOR", "BOUNDS", "WORK AREA", "SCALE", "LOGICAL", "ZONES")
	t.rightAlign(0, 6)
	for _, m := range res.Monitors {
		id := m.ID
		if m.Primary {
			id += " *"
		}
		zones := fmt.Sprintf("%d", m.Zones)
		if !m.HasLayout {
			zones = "-"
		}
		logical := fmt.Sprintf("%dx%d", m.LogicalW, m.LogicalH)
		t.add(fmt.Sprintf("%d", m.Rank), id, m.Bounds.String(), m.Work.String(), m.Scale, logical, zones)
	}
	fmt.Println("MONITORS  (* = primary; rank order is primary, then descending area, then position)")
	t.render(os.Stdout, "  ")
	for _, m := range res.Monitors {
		if len(m.FoldedIn) > 0 {
			fmt.Printf("  %s absorbed the layout of: %s\n", m.ID, strings.Join(m.FoldedIn, ", "))
		}
	}
}

func printPlacementTable(res *SolveResult) {
	t := newTable("MONITOR", "ZONE", "X", "Y", "W", "H", "LOGICAL", "PATH", "WINDOWS")
	t.rightAlign(2, 3, 4, 5)
	for _, p := range res.Placements {
		wins := strings.Join(p.Apps, ", ")
		if wins == "" {
			wins = "-"
		}
		t.add(p.Monitor, p.Zone,
			fmt.Sprintf("%d", p.Frame.X), fmt.Sprintf("%d", p.Frame.Y),
			fmt.Sprintf("%d", p.Frame.W), fmt.Sprintf("%d", p.Frame.H),
			fmt.Sprintf("%dx%d", p.LogicalW, p.LogicalH),
			p.Path, wins)
	}
	fmt.Printf("ZONES  (%d; X/Y/W/H are device pixels in the virtual desktop)\n", len(res.Placements))
	if len(res.Placements) == 0 {
		fmt.Println("  (none solved)")
		return
	}
	t.render(os.Stdout, "  ")
}

func printIssues(issues []Issue) {
	var errs, warns []Issue
	for _, i := range issues {
		if i.Severity == "error" {
			errs = append(errs, i)
		} else {
			warns = append(warns, i)
		}
	}
	if len(errs) == 0 && len(warns) == 0 {
		fmt.Println("OK: no errors, no warnings.")
		return
	}
	if len(errs) > 0 {
		fmt.Printf("ERRORS (%d)\n", len(errs))
		for _, e := range errs {
			fmt.Printf("  [%s] %s\n", e.Code, e.Message)
		}
	}
	if len(warns) > 0 {
		if len(errs) > 0 {
			fmt.Println()
		}
		fmt.Printf("WARNINGS (%d)\n", len(warns))
		for _, w := range warns {
			fmt.Printf("  [%s] %s\n", w.Code, w.Message)
		}
	}
}

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

// RuleReport is the per-rule audit produced by check.
type RuleReport struct {
	Index    int    `json:"index"`
	Zone     string `json:"zone"`
	App      string `json:"app,omitempty"`
	Title    string `json:"title,omitempty"`
	Matched  int    `json:"windows_matched"`
	Reason   string `json:"reason,omitempty"`
	Reachabl bool   `json:"reachable"`
}

// cmdCheck is the command-line entry point: it prints the check report and
// exits 1 when the layout has errors, so a script can tell.
func cmdCheck(argv []string) {
	if runCheck(argv) > 0 {
		os.Exit(1)
	}
}

// runCheck does the work and reports how many errors it found instead of
// exiting on them. The exit lives in cmdCheck so that the guided session, which
// is the double-clicked-in-Explorer path, can print exactly the same report and
// still reach its "press Enter to close" prompt — os.Exit(1) in the middle of
// that would take the console window down with it, which is the very bug the
// guided session exists to fix.
func runCheck(argv []string) int {
	fs := newFlagSet("check")
	topoPath := fs.String("topology", "", "display topology JSON (optional)")
	fs.StringVar(topoPath, "t", "", "shorthand for --topology")
	layPath := fs.String("layout", "", "layout JSON")
	fs.StringVar(layPath, "l", "", "shorthand for --layout")
	winPath := fs.String("windows", "", "window list JSON")
	fs.StringVar(winPath, "w", "", "shorthand for --windows")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *layPath == "" && fs.NArg() > 0 {
		*layPath = fs.Arg(0)
	}
	if *layPath == "" {
		usageErr("check needs a layout file")
	}
	if *topoPath == "" && fs.NArg() > 1 {
		*topoPath = fs.Arg(1)
	}
	if fs.NArg() > 2 {
		usageErr("check takes at most two positional arguments, got %d", fs.NArg())
	}

	lay := loadLayoutAndWindows(*layPath, *winPath)

	var issues []Issue
	var res *SolveResult
	var topo *Topology
	if *topoPath != "" {
		topo = mustTopology(*topoPath)
		res = Solve(lay, topo)
		issues = append(issues, res.Issues...)
		issues = append(issues, VerifyTiling(res)...)
	}

	// Rule audit, which needs no topology at all.
	rules, ruleIssues := auditRules(lay)
	issues = append(issues, ruleIssues...)

	// Zones nothing lands in.
	assigned := map[string]bool{}
	for _, a := range AssignWindows(lay.Rules, lay.Windows) {
		if a.Zone != "" {
			assigned[a.Zone] = true
		}
		if a.Zone == "" && res == nil {
			issues = append(issues, Issue{
				Severity: "warning", Code: "window-unmatched",
				Message: fmt.Sprintf("window %s (app %q, title %q) matches no rule and will not be placed",
					a.Window.ID, a.Window.App, a.Window.Title),
			})
		}
	}
	for _, z := range lay.SortedZones() {
		if !assigned[z] {
			issues = append(issues, Issue{
				Severity: "warning", Code: "zone-empty-of-windows", Zone: z,
				Message: fmt.Sprintf("zone %q has no window assigned to it", z),
			})
		}
	}

	sort.SliceStable(issues, func(i, j int) bool {
		return issues[i].Severity == "error" && issues[j].Severity != "error"
	})
	nErr := 0
	for _, i := range issues {
		if i.Severity == "error" {
			nErr++
		}
	}

	if *asJSON {
		out := map[string]any{
			"command":     "check",
			"layout_file": lay.Source,
			"layout":      lay.Name,
			"zones":       lay.SortedZones(),
			"rules":       rules,
			"issues":      issues,
			"errors":      nErr,
			"warnings":    len(issues) - nErr,
			"ok":          nErr == 0,
		}
		if topo != nil {
			out["topology_file"] = topo.Source
			out["topology"] = topo.Name
			out["plan"] = res
		}
		emitJSON(out)
		return nErr
	}

	fmt.Printf("WorkspaceForge check\n")
	fmt.Printf("layout   : %s (%s)\n", lay.Name, lay.Source)
	if topo != nil {
		fmt.Printf("topology : %s (%s)\n", topo.Name, topo.Source)
	} else {
		fmt.Printf("topology : (none given - structure and rules only; pass --topology to check geometry)\n")
	}
	fmt.Printf("zones    : %d  rules: %d  windows: %d\n", len(lay.Zones()), len(lay.Rules), len(lay.Windows))
	fmt.Println()

	if len(rules) > 0 {
		t := newTable("#", "ZONE", "APP MATCH", "TITLE MATCH", "MATCHED", "STATUS")
		t.rightAlign(0, 4)
		for _, r := range rules {
			status := "reachable"
			if !r.Reachabl {
				status = r.Reason
			}
			t.add(fmt.Sprintf("%d", r.Index), r.Zone, dash(r.App), dash(r.Title),
				fmt.Sprintf("%d", r.Matched), status)
		}
		fmt.Println("RULES  (first match wins)")
		t.render(os.Stdout, "  ")
		fmt.Println()
	}

	if res != nil {
		printPlacementTable(res)
		fmt.Println()
	}
	printIssues(issues)
	return nErr
}

func dash(s string) string {
	if s == "" {
		return "-"
	}
	return s
}

// auditRules reports, for every rule, how many windows it actually caught and
// why it caught none: shadowed by an earlier rule, or matching nothing at all.
func auditRules(lay *Layout) ([]RuleReport, []Issue) {
	var issues []Issue
	zones := map[string]bool{}
	for _, z := range lay.Zones() {
		zones[z] = true
	}
	counts := make([]int, len(lay.Rules))
	for _, a := range AssignWindows(lay.Rules, lay.Windows) {
		if a.Rule > 0 {
			counts[a.Rule-1]++
		}
	}
	reports := make([]RuleReport, 0, len(lay.Rules))
	for i, r := range lay.Rules {
		rep := RuleReport{Index: i + 1, Zone: r.Zone, App: r.App, Title: r.Title,
			Matched: counts[i], Reachabl: counts[i] > 0}
		if !zones[r.Zone] {
			issues = append(issues, Issue{
				Severity: "error", Code: "rule-unknown-zone", Zone: r.Zone,
				Message: fmt.Sprintf("rule #%d targets zone %q, which the layout does not declare", i+1, r.Zone),
			})
		}
		if counts[i] == 0 {
			shadow := 0
			for _, w := range lay.Windows {
				if !r.matches(w) {
					continue
				}
				for j := 0; j < i; j++ {
					if lay.Rules[j].matches(w) {
						shadow = j + 1
						break
					}
				}
				if shadow > 0 {
					break
				}
			}
			if shadow > 0 {
				rep.Reason = fmt.Sprintf("shadowed by rule #%d", shadow)
				issues = append(issues, Issue{
					Severity: "warning", Code: "rule-shadowed", Zone: r.Zone,
					Message: fmt.Sprintf("rule #%d (zone %q) is unreachable: every window it would match is already taken by rule #%d",
						i+1, r.Zone, shadow),
				})
			} else {
				rep.Reason = "matches no window"
				issues = append(issues, Issue{
					Severity: "warning", Code: "rule-unused", Zone: r.Zone,
					Message: fmt.Sprintf("rule #%d (zone %q) matches none of the %d windows in this layout",
						i+1, r.Zone, len(lay.Windows)),
				})
			}
		}
		reports = append(reports, rep)
	}
	return reports, issues
}

// ---------------------------------------------------------------------------
// refit
// ---------------------------------------------------------------------------

func cmdRefit(argv []string) {
	fs := newFlagSet("refit")
	fromPath := fs.String("from", "", "source topology JSON")
	toPath := fs.String("to", "", "destination topology JSON")
	layPath := fs.String("layout", "", "layout JSON")
	fs.StringVar(layPath, "l", "", "shorthand for --layout")
	winPath := fs.String("windows", "", "window list JSON")
	fs.StringVar(winPath, "w", "", "shorthand for --windows")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *layPath == "" && fs.NArg() > 0 {
		*layPath = fs.Arg(0)
	}
	if *layPath == "" {
		usageErr("refit needs a layout file")
	}
	if fs.NArg() > 1 {
		usageErr("refit takes at most one positional argument, got %d", fs.NArg())
	}
	if *fromPath == "" {
		usageErr("refit needs --from <topology.json>")
	}
	if *toPath == "" {
		usageErr("refit needs --to <topology.json>")
	}

	from := mustTopology(*fromPath)
	to := mustTopology(*toPath)
	lay := loadLayoutAndWindows(*layPath, *winPath)
	r := Refit(lay, from, to)

	nErr := 0
	for _, i := range r.Issues {
		if i.Severity == "error" {
			nErr++
		}
	}

	if *asJSON {
		emitJSON(map[string]any{
			"command":   "refit",
			"layout":    lay.Source,
			"from_file": from.Source,
			"to_file":   to.Source,
			"refit":     r,
			"target":    r.Target,
			"ok":        nErr == 0,
		})
		if nErr > 0 {
			os.Exit(1)
		}
		return
	}

	fmt.Printf("WorkspaceForge re-fit\n")
	fmt.Printf("layout : %s (%s)\n", lay.Name, lay.Source)
	fmt.Printf("from   : %s (%s, %d monitors)\n", from.Name, from.Source, r.Summary.MonitorsFrom)
	fmt.Printf("to     : %s (%s, %d monitors)\n", to.Name, to.Source, r.Summary.MonitorsTo)
	fmt.Printf("fallback monitor for folds: %s\n", r.Fallback)
	fmt.Println()

	mt := newTable("SRC RANK", "SOURCE", "DST RANK", "DEST", "ACTION", "DETAIL")
	mt.rightAlign(0, 2)
	for _, m := range r.Mapping {
		mt.add(rankStr(m.SourceRank), dash(m.Source), rankStr(m.DestRank), dash(m.Dest), m.Action, m.Detail)
	}
	fmt.Println("MONITOR MAPPING")
	mt.render(os.Stdout, "  ")
	fmt.Println()

	wt := newTable("WINDOW", "APP", "ZONE", "FROM", "TO", "STATUS")
	for _, w := range r.Windows {
		wt.add(w.WindowID, w.App, dash(w.Zone),
			rectOr(w.FromRect, w.FromMonitor), rectOr(w.ToRect, w.ToMonitor), w.Status)
	}
	fmt.Printf("WINDOWS (%d)\n", len(r.Windows))
	if len(r.Windows) == 0 {
		fmt.Println("  (none)")
	} else {
		wt.render(os.Stdout, "  ")
	}
	fmt.Println()

	zt := newTable("ZONE", "FROM", "TO", "STATUS", "FOLDED")
	for _, z := range r.Zones {
		zt.add(z.Zone, rectOr(z.FromRect, z.FromMonitor), rectOr(z.ToRect, z.ToMonitor), z.Status, yesNo(z.Folded))
	}
	fmt.Printf("ZONES (%d)\n", len(r.Zones))
	zt.render(os.Stdout, "  ")
	fmt.Println()

	fmt.Printf("SUMMARY\n")
	fmt.Printf("  monitors folded away : %d\n", r.Summary.Folded)
	fmt.Printf("  monitors left empty  : %d\n", r.Summary.EmptyDest)
	fmt.Printf("  windows unchanged    : %d\n", r.Summary.Unchanged)
	fmt.Printf("  windows moved        : %d\n", r.Summary.Moved)
	fmt.Printf("  windows resized      : %d\n", r.Summary.Resized)
	fmt.Printf("  windows folded in    : %d\n", r.Summary.FoldedWins)
	fmt.Printf("  windows unplaced     : %d\n", r.Summary.Unplaced)
	fmt.Println()
	printIssues(r.Issues)
	if nErr > 0 {
		os.Exit(1)
	}
}

func rankStr(r int) string {
	if r < 0 {
		return "-"
	}
	return fmt.Sprintf("%d", r)
}

func rectOr(r *Rect, mon string) string {
	if r == nil {
		return "-"
	}
	return fmt.Sprintf("%s@%s", r.String(), mon)
}

func yesNo(b bool) string {
	if b {
		return "yes"
	}
	return "no"
}

// ---------------------------------------------------------------------------
// render
// ---------------------------------------------------------------------------

func cmdRender(argv []string) {
	fs := newFlagSet("render")
	topoPath := fs.String("topology", "", "display topology JSON")
	fs.StringVar(topoPath, "t", "", "shorthand for --topology")
	layPath := fs.String("layout", "", "layout JSON")
	fs.StringVar(layPath, "l", "", "shorthand for --layout")
	winPath := fs.String("windows", "", "window list JSON")
	fs.StringVar(winPath, "w", "", "shorthand for --windows")
	outPath := fs.String("out", "", "SVG file to write")
	fs.StringVar(outPath, "o", "", "shorthand for --out")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *layPath == "" && fs.NArg() > 0 {
		*layPath = fs.Arg(0)
	}
	if *layPath == "" {
		usageErr("render needs a layout file")
	}
	if fs.NArg() > 1 {
		usageErr("render takes at most one positional argument, got %d", fs.NArg())
	}
	if *topoPath == "" {
		usageErr("render needs --topology <topology.json>")
	}
	if *outPath == "" {
		usageErr("render needs --out <map.svg>")
	}

	topo := mustTopology(*topoPath)
	lay := loadLayoutAndWindows(*layPath, *winPath)
	res := Solve(lay, topo)
	res.Issues = append(res.Issues, VerifyTiling(res)...)
	sortIssues(res)

	title := fmt.Sprintf("%s: %s on %s", appName, lay.Name, topo.Name)
	svg := RenderSVG(res, topo, title)
	if err := os.WriteFile(*outPath, svg, 0o644); err != nil {
		fail("cannot write %s: %v", *outPath, err)
	}

	if *asJSON {
		emitJSON(map[string]any{
			"command":       "render",
			"layout_file":   lay.Source,
			"topology_file": topo.Source,
			"out":           *outPath,
			"bytes":         len(svg),
			"monitors":      len(res.Monitors),
			"zones":         len(res.Placements),
			"issues":        res.Issues,
			"ok":            len(res.Errors()) == 0,
		})
		if len(res.Errors()) > 0 {
			os.Exit(1)
		}
		return
	}

	desk := topo.DesktopBounds()
	fmt.Printf("WorkspaceForge render\n")
	fmt.Printf("layout   : %s (%s)\n", lay.Name, lay.Source)
	fmt.Printf("topology : %s (%s)\n", topo.Name, topo.Source)
	fmt.Printf("desktop  : %s\n", desk)
	fmt.Printf("wrote    : %s (%d bytes, %d monitors, %d zones)\n", *outPath, len(svg), len(res.Monitors), len(res.Placements))
	fmt.Printf("The diagram is to scale: one SVG user unit is one device pixel.\n")
	fmt.Println()
	printIssues(res.Issues)
	if len(res.Errors()) > 0 {
		os.Exit(1)
	}
}

// ---------------------------------------------------------------------------
// topo
// ---------------------------------------------------------------------------

func cmdTopo(argv []string) {
	fs := newFlagSet("topo")
	topoPath := fs.String("topology", "", "display topology JSON")
	fs.StringVar(topoPath, "t", "", "shorthand for --topology")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *topoPath == "" && fs.NArg() > 0 {
		*topoPath = fs.Arg(0)
	}
	if *topoPath == "" {
		usageErr("topo needs a topology file")
	}
	if fs.NArg() > 1 {
		usageErr("topo takes at most one positional argument, got %d", fs.NArg())
	}
	topo := mustTopology(*topoPath)
	ranked := RankedMonitors(topo)
	desk := topo.DesktopBounds()

	if *asJSON {
		emitJSON(map[string]any{
			"command":         "topo",
			"topology_file":   topo.Source,
			"topology":        topo.Name,
			"desktop_bounds":  desk,
			"primary_derived": topo.PrimaryDerived,
			"monitors":        ranked,
			"ok":              true,
		})
		return
	}

	fmt.Printf("WorkspaceForge topology\n")
	fmt.Printf("name    : %s\n", topo.Name)
	fmt.Printf("file    : %s\n", topo.Source)
	fmt.Printf("desktop : %s (%d monitors)\n", desk, len(topo.Monitors))
	if topo.PrimaryDerived {
		fmt.Printf("note    : no monitor declared itself primary; %q was chosen by rank\n", ranked[0].ID)
	}
	fmt.Println()
	t := newTable("RANK", "MONITOR", "LABEL", "BOUNDS", "INSETS t/r/b/l", "WORK AREA", "SCALE", "LOGICAL")
	t.rightAlign(0)
	for i, m := range ranked {
		id := m.ID
		if m.Primary {
			id += " *"
		}
		t.add(fmt.Sprintf("%d", i), id, dash(m.Label), m.Bounds.String(),
			fmt.Sprintf("%d/%d/%d/%d", m.Insets.Top, m.Insets.Right, m.Insets.Bottom, m.Insets.Left),
			m.Work.String(), m.ScaleString(),
			fmt.Sprintf("%dx%d", m.LogicalW(), m.LogicalH()))
	}
	t.render(os.Stdout, "  ")
	fmt.Println()
	fmt.Println("ADJACENCY (monitors sharing an edge)")
	any := false
	for i := 0; i < len(topo.Monitors); i++ {
		for j := i + 1; j < len(topo.Monitors); j++ {
			if abut(topo.Monitors[i].Bounds, topo.Monitors[j].Bounds) {
				fmt.Printf("  %s <-> %s\n", topo.Monitors[i].ID, topo.Monitors[j].ID)
				any = true
			}
		}
	}
	if !any {
		fmt.Println("  (single monitor)")
	}
	fmt.Println()
	fmt.Println("OK: topology is valid - no overlapping bounds, one connected desktop.")
}
