// Command deskscene decides what a multi-monitor screen layout has to give up
// when the scene you asked for does not fit the hardware you have, and says
// why. It is a selection and packing optimiser, not a window splitter.
package main

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

const appName = "deskscene"

// ---------------------------------------------------------------------------
// 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...)
}

// plural renders "1 monitor" / "3 monitors" without a stray s.
func plural(n int, one string) string {
	if n == 1 {
		return fmt.Sprintf("%d %s", n, one)
	}
	return fmt.Sprintf("%d %ss", n, one)
}

// perMille renders an integer per-mille value as a percentage string without
// ever using floating point.
func perMille(v int64) string {
	neg := ""
	if v < 0 {
		neg, v = "-", -v
	}
	return fmt.Sprintf("%s%d.%d%%", neg, v/10, v%10)
}

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

func usage() {
	usageTo(os.Stderr)
}

func usageTo(w *os.File) {
	fmt.Fprintf(w, `%s - degrade a multi-monitor scene onto the screens you actually have (Techlosoft Workspace Center)

USAGE
  %s fit      --scene <scene.json|preset> --displays <displays.json> [--out <file.svg>] [--json]
  %s compare  <sceneA> <sceneB> --displays <displays.json> [--json]
  %s advise   --scene <scene.json|preset> --displays <displays.json> [--json]
  %s render   --scene <scene.json|preset> --displays <displays.json> --out <file.svg> [--json]
  %s presets  [--json]
  %s help | -h | --help

COMMANDS
  fit        Decide the best realisable variant of the scene on the given
             displays: what stays full size, what shrinks, what gets stacked
             behind tabs, and what is dropped - with a reason for each.
  compare    Score two scenes on the same hardware and say which degrades less.
  advise     Report the smallest hardware change that would let the scene fit
             at full size, found by re-running the fit against candidate
             topologies rather than by guesswork.
  render     Draw the computed placement to scale as an SVG file.
  presets    List the built-in scenes. Any preset name may be used wherever a
             scene file is expected.

FLAGS
  --scene <ref>      Scene JSON file, or the name of a built-in preset.
  --displays <file>  Display topology JSON file (monitors, work areas, scales).
  --out <file.svg>   Write the placement to an SVG file (fit, render).
  --json             Machine-readable JSON output (all reporting commands).

SCENE JSON
  {"name":"...","panes":[{"id":"editor","priority":100,"minWidth":900,
   "minHeight":600,"idealArea":1260000,"required":true},
   {"id":"logs","priority":60,"minWidth":640,"minHeight":300,
    "idealArea":336000,"stackable":true,"stackGroup":"aux"}]}

DISPLAYS JSON
  {"name":"desk","monitors":[{"id":"m1","width":3840,"height":2160,
   "scale":100,"workArea":{"x":0,"y":0,"width":3840,"height":2100}}]}

EXAMPLES
  %s presets
  %s fit --scene trading-desk --displays rig.json
  %s fit --scene trading-desk --displays laptop.json --json
  %s compare trading-desk developer --displays rig.json
  %s advise --scene video-editor --displays laptop.json
  %s render --scene developer --displays rig.json --out scene.svg

Flags may appear before or after positional arguments.
`, 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.Exit(1)
}

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

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions the program needs and stay on screen. Printing usage and
		// exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch args[0] {
	case "help", "-h", "--help":
		usageTo(os.Stdout)
		os.Exit(0)
	}
	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usageTo(os.Stdout)
			os.Exit(0)
		}
	}
	switch cmd {
	case "fit":
		cmdFit(rest)
	case "compare":
		cmdCompare(rest)
	case "advise":
		cmdAdvise(rest)
	case "render":
		cmdRender(rest)
	case "presets":
		cmdPresets(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

var valueFlags = map[string]bool{
	"scene": true, "s": true,
	"displays": true, "d": true,
	"out": true, "o": true,
}

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

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

func writeSVG(path string, data []byte) {
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			fail("cannot create %s: %v", dir, err)
		}
	}
	if err := os.WriteFile(path, data, 0o644); err != nil {
		fail("cannot write %s: %v", path, err)
	}
}

// ---------------------------------------------------------------------------
// fit
// ---------------------------------------------------------------------------

func cmdFit(argv []string) {
	fs := newFlagSet("fit")
	sceneRef := fs.String("scene", "", "scene JSON file or preset name")
	fs.StringVar(sceneRef, "s", "", "shorthand for --scene")
	dispPath := fs.String("displays", "", "display topology JSON file")
	fs.StringVar(dispPath, "d", "", "shorthand for --displays")
	outPath := fs.String("out", "", "write an SVG of the placement here")
	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 *sceneRef == "" && fs.NArg() > 0 {
		*sceneRef = fs.Arg(0)
	}
	if *sceneRef == "" {
		usageErr("fit needs --scene <scene.json|preset>")
	}
	if *dispPath == "" {
		usageErr("fit needs --displays <displays.json>")
	}
	sc, src, err := loadScene(*sceneRef)
	if err != nil {
		fail("%v", err)
	}
	top, err := loadTopology(*dispPath)
	if err != nil {
		fail("%v", err)
	}
	res := fitScene(sc, top)
	res.SceneSource = src
	svgPath := ""
	if *outPath != "" {
		writeSVG(*outPath, renderSVG(res, top))
		svgPath = *outPath
	}
	if *asJSON {
		out := struct {
			FitResult
			SVG string `json:"svg,omitempty"`
		}{res, svgPath}
		encodeJSON(out)
		return
	}
	printFit(res, top)
	if svgPath != "" {
		fmt.Printf("\nSVG written to %s\n", svgPath)
	}
}

func printFit(r FitResult, top Topology) {
	fmt.Printf("DeskScene fit\n")
	fmt.Printf("scene    : %s (%s)", r.Scene, plural(r.PanesTotal, "pane"))
	if r.SceneSource != "" {
		fmt.Printf("  [%s]", r.SceneSource)
	}
	fmt.Println()
	fmt.Printf("displays : %s (%s)\n", r.Topology, plural(len(r.Monitors), "monitor"))
	for _, m := range top.logical() {
		fmt.Printf("             %-10s %s\n", m.ID, m.Reported)
	}
	fmt.Printf("algorithm: %s\n", r.Algorithm)
	fmt.Printf("search   : %s, %s\n", plural(r.ConfigsSearched, "stack configuration"), r.SearchMode)
	fmt.Println()

	if !r.Feasible {
		fmt.Printf("RESULT   : INFEASIBLE\n")
		fmt.Printf("reason   : %s\n", r.Infeasible)
		fmt.Println()
		fmt.Printf("A scene that cannot hold a required pane is reported infeasible rather\n")
		fmt.Printf("than quietly degraded. Relax the pane's minimum, drop its required flag,\n")
		fmt.Printf("or give it more screen (try: %s advise).\n", appName)
		return
	}

	fmt.Printf("fit score: %d / %d  (%s of the priority-weighted ideal)\n", r.Score, r.MaxScore, perMille(r.FitPerMille))
	fmt.Printf("outcome  : %d full, %d shrunk, %d stacked, %d dropped\n", r.CountFull, r.CountShrunk, r.CountStacked, r.CountDropped)
	if r.ShrinkLevel == 100 {
		fmt.Printf("shrink   : none - every placed pane is at its ideal linear size\n")
	} else if r.ShrinkLevel == 0 {
		fmt.Printf("shrink   : maximum - placed panes are at their stated minimums\n")
	} else {
		fmt.Printf("shrink   : uniform schedule level %d%% of ideal linear size\n", r.ShrinkLevel)
	}
	fmt.Println()

	panes := append([]PaneOutcome(nil), r.Panes...)
	sort.SliceStable(panes, func(a, b int) bool {
		if panes[a].Priority != panes[b].Priority {
			return panes[a].Priority > panes[b].Priority
		}
		return panes[a].ID < panes[b].ID
	})
	fmt.Printf("PANES (highest priority first)\n")
	for _, p := range panes {
		req := " "
		if p.Required {
			req = "*"
		}
		where := "-"
		if p.Rect != nil {
			where = fmt.Sprintf("%s %dx%d at (%d,%d)", p.Monitor, p.Rect.W, p.Rect.H, p.Rect.X, p.Rect.Y)
		}
		fmt.Printf("  %s %-18s p%-4d %-8s %s\n", req, p.ID, p.Priority, p.Outcome, where)
		fmt.Printf("      %s\n", p.Reason)
	}
	fmt.Println()
	fmt.Printf("MONITORS\n")
	for _, m := range r.Monitors {
		fmt.Printf("  %-10s %dx%d logical  used %d%%  free %d px^2  (%d rectangles)\n",
			m.ID, m.LogicalWidth, m.LogicalHeight, m.UsedPercent, m.FreeArea, len(m.Panes))
	}
	fmt.Println()
	fmt.Printf("* = required pane\n")
}

// ---------------------------------------------------------------------------
// compare
// ---------------------------------------------------------------------------

func cmdCompare(argv []string) {
	fs := newFlagSet("compare")
	dispPath := fs.String("displays", "", "display topology JSON file")
	fs.StringVar(dispPath, "d", "", "shorthand for --displays")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() != 2 {
		usageErr("compare needs exactly two scenes, got %d", fs.NArg())
	}
	if *dispPath == "" {
		usageErr("compare needs --displays <displays.json>")
	}
	top, err := loadTopology(*dispPath)
	if err != nil {
		fail("%v", err)
	}
	scA, srcA, err := loadScene(fs.Arg(0))
	if err != nil {
		fail("%v", err)
	}
	scB, srcB, err := loadScene(fs.Arg(1))
	if err != nil {
		fail("%v", err)
	}
	a := fitScene(scA, top)
	a.SceneSource = srcA
	b := fitScene(scB, top)
	b.SceneSource = srcB

	winner, verdict := compareVerdict(a, b)

	if *asJSON {
		encodeJSON(map[string]any{
			"topology": top.Name,
			"a":        a,
			"b":        b,
			"winner":   winner,
			"verdict":  verdict,
		})
		return
	}
	fmt.Printf("DeskScene compare\n")
	fmt.Printf("displays : %s (%s)\n", top.Name, plural(len(top.Monitors), "monitor"))
	for _, m := range top.logical() {
		fmt.Printf("             %-10s %s\n", m.ID, m.Reported)
	}
	fmt.Println()
	printCompareRow(a)
	printCompareRow(b)
	fmt.Printf("verdict  : %s\n", verdict)
}

func compareVerdict(a, b FitResult) (string, string) {
	switch {
	case a.Feasible && !b.Feasible:
		return a.Scene, fmt.Sprintf("%q wins: %q is infeasible here (%s)", a.Scene, b.Scene, b.Infeasible)
	case !a.Feasible && b.Feasible:
		return b.Scene, fmt.Sprintf("%q wins: %q is infeasible here (%s)", b.Scene, a.Scene, a.Infeasible)
	case !a.Feasible && !b.Feasible:
		return "", "neither scene is feasible on this hardware"
	}
	switch {
	case a.FitPerMille > b.FitPerMille:
		return a.Scene, fmt.Sprintf("%q degrades less: %s vs %s of its own ideal, and drops %s vs %s",
			a.Scene, perMille(a.FitPerMille), perMille(b.FitPerMille), plural(a.CountDropped, "pane"), plural(b.CountDropped, "pane"))
	case b.FitPerMille > a.FitPerMille:
		return b.Scene, fmt.Sprintf("%q degrades less: %s vs %s of its own ideal, and drops %s vs %s",
			b.Scene, perMille(b.FitPerMille), perMille(a.FitPerMille), plural(b.CountDropped, "pane"), plural(a.CountDropped, "pane"))
	case a.CountDropped != b.CountDropped:
		if a.CountDropped < b.CountDropped {
			return a.Scene, fmt.Sprintf("tied on fit (%s each); %q keeps more panes", perMille(a.FitPerMille), a.Scene)
		}
		return b.Scene, fmt.Sprintf("tied on fit (%s each); %q keeps more panes", perMille(b.FitPerMille), b.Scene)
	}
	return "", fmt.Sprintf("dead heat: both realise %s of their own ideal on this hardware", perMille(a.FitPerMille))
}

func printCompareRow(r FitResult) {
	fmt.Printf("%s\n", r.Scene)
	if r.SceneSource != "" {
		fmt.Printf("  source     : %s\n", r.SceneSource)
	}
	if !r.Feasible {
		fmt.Printf("  result     : INFEASIBLE - %s\n\n", r.Infeasible)
		return
	}
	fmt.Printf("  panes      : %d\n", r.PanesTotal)
	fmt.Printf("  fit score  : %d / %d  (%s)\n", r.Score, r.MaxScore, perMille(r.FitPerMille))
	fmt.Printf("  outcome    : %d full, %d shrunk, %d stacked, %d dropped\n", r.CountFull, r.CountShrunk, r.CountStacked, r.CountDropped)
	var lost []string
	for _, p := range r.Panes {
		if p.Outcome == "dropped" {
			lost = append(lost, fmt.Sprintf("%s(p%d)", p.ID, p.Priority))
		}
	}
	if len(lost) > 0 {
		fmt.Printf("  dropped    : %s\n", strings.Join(lost, ", "))
	}
	fmt.Println()
}

// ---------------------------------------------------------------------------
// advise
// ---------------------------------------------------------------------------

func cmdAdvise(argv []string) {
	fs := newFlagSet("advise")
	sceneRef := fs.String("scene", "", "scene JSON file or preset name")
	fs.StringVar(sceneRef, "s", "", "shorthand for --scene")
	dispPath := fs.String("displays", "", "display topology JSON file")
	fs.StringVar(dispPath, "d", "", "shorthand for --displays")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *sceneRef == "" && fs.NArg() > 0 {
		*sceneRef = fs.Arg(0)
	}
	if *sceneRef == "" {
		usageErr("advise needs --scene <scene.json|preset>")
	}
	if *dispPath == "" {
		usageErr("advise needs --displays <displays.json>")
	}
	sc, src, err := loadScene(*sceneRef)
	if err != nil {
		fail("%v", err)
	}
	top, err := loadTopology(*dispPath)
	if err != nil {
		fail("%v", err)
	}
	res := adviseScene(sc, top)
	res.SceneSource = src
	if *asJSON {
		encodeJSON(res)
		return
	}
	fmt.Printf("DeskScene advise\n")
	fmt.Printf("scene    : %s  [%s]\n", res.Scene, res.SceneSource)
	fmt.Printf("displays : %s\n", res.Topology)
	fmt.Println()
	if !res.Current.Feasible {
		fmt.Printf("current  : INFEASIBLE - %s\n", res.Current.Infeasible)
	} else {
		fmt.Printf("current  : %s of ideal; %d full, %d shrunk, %d stacked, %d dropped\n",
			perMille(res.Current.FitPerMille), res.Current.CountFull, res.Current.CountShrunk,
			res.Current.CountStacked, res.Current.CountDropped)
	}
	fmt.Println()
	if res.AlreadyFits {
		fmt.Printf("verdict  : %s\n", res.Note)
		return
	}
	fmt.Printf("searched : %d candidate topologies\n", res.CandidatesTested)
	fmt.Printf("           %s\n", res.SearchSpace)
	fmt.Println()
	if res.NoSolution {
		fmt.Printf("verdict  : no single change is enough.\n")
		fmt.Printf("           %s\n", res.Note)
		return
	}
	fmt.Printf("SMALLEST CHANGE THAT MAKES THE WHOLE SCENE FIT\n")
	fmt.Printf("  %s\n", res.Recommended.Description)
	fmt.Printf("  cost: %d additional physical pixels; verified by re-running the fit (%s, full fit)\n",
		res.Recommended.CostPixels, perMille(res.Recommended.FitPerMille))
	if len(res.Alternatives) > 0 {
		fmt.Println()
		fmt.Printf("OTHER CHANGES THAT ALSO WORK\n")
		for _, a := range res.Alternatives {
			fmt.Printf("  %s  (+%d px)\n", a.Description, a.CostPixels)
		}
	}
}

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

func cmdRender(argv []string) {
	fs := newFlagSet("render")
	sceneRef := fs.String("scene", "", "scene JSON file or preset name")
	fs.StringVar(sceneRef, "s", "", "shorthand for --scene")
	dispPath := fs.String("displays", "", "display topology JSON file")
	fs.StringVar(dispPath, "d", "", "shorthand for --displays")
	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 *sceneRef == "" && fs.NArg() > 0 {
		*sceneRef = fs.Arg(0)
	}
	if *sceneRef == "" {
		usageErr("render needs --scene <scene.json|preset>")
	}
	if *dispPath == "" {
		usageErr("render needs --displays <displays.json>")
	}
	if *outPath == "" {
		usageErr("render needs --out <file.svg>")
	}
	sc, src, err := loadScene(*sceneRef)
	if err != nil {
		fail("%v", err)
	}
	top, err := loadTopology(*dispPath)
	if err != nil {
		fail("%v", err)
	}
	res := fitScene(sc, top)
	res.SceneSource = src
	data := renderSVG(res, top)
	writeSVG(*outPath, data)
	if *asJSON {
		encodeJSON(map[string]any{
			"scene":         res.Scene,
			"scene_source":  src,
			"topology":      res.Topology,
			"svg":           *outPath,
			"bytes":         len(data),
			"feasible":      res.Feasible,
			"full_fit":      res.FullFit,
			"fit_per_mille": res.FitPerMille,
			"panes_drawn":   res.CountFull + res.CountShrunk + res.CountStacked,
			"panes_dropped": res.CountDropped,
		})
		return
	}
	fmt.Printf("wrote %s (%d bytes)\n", *outPath, len(data))
	fmt.Printf("scene    : %s [%s]\n", res.Scene, src)
	fmt.Printf("displays : %s\n", res.Topology)
	if res.Feasible {
		fmt.Printf("fit      : %s of ideal; %d full, %d shrunk, %d stacked, %d dropped\n",
			perMille(res.FitPerMille), res.CountFull, res.CountShrunk, res.CountStacked, res.CountDropped)
	} else {
		fmt.Printf("fit      : INFEASIBLE - %s\n", res.Infeasible)
	}
}

// ---------------------------------------------------------------------------
// presets
// ---------------------------------------------------------------------------

func cmdPresets(argv []string) {
	fs := newFlagSet("presets")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		usageErr("presets takes no positional arguments, got %q", fs.Arg(0))
	}
	list := listPresets()
	if *asJSON {
		out := make([]map[string]any, 0, len(list))
		for _, p := range list {
			sc, err := loadPreset(p.Name)
			if err != nil {
				continue
			}
			out = append(out, map[string]any{
				"name":  p.Name,
				"title": sc.Name,
				"notes": sc.Notes,
				"panes": sc.Panes,
			})
		}
		encodeJSON(map[string]any{"presets": out, "count": len(out)})
		return
	}
	fmt.Printf("Built-in scenes (use the name wherever a scene file is expected)\n\n")
	for _, p := range list {
		sc, err := loadPreset(p.Name)
		if err != nil {
			continue
		}
		fmt.Printf("  %-14s %s - %d panes\n", p.Name, sc.Name, len(sc.Panes))
		fmt.Printf("                 %s\n", sc.Notes)
		var req []string
		for _, pane := range sc.Panes {
			if pane.Required {
				req = append(req, pane.ID)
			}
		}
		if len(req) > 0 {
			fmt.Printf("                 required: %s\n", strings.Join(req, ", "))
		}
		fmt.Println()
	}
	fmt.Printf("Example:  %s fit --scene %s --displays displays.json\n", appName, list[0].Name)
}
