package main

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

// ---------------------------------------------------------------------------
// Scene model
// ---------------------------------------------------------------------------

// Pane is one window of a scene. All geometry is in LOGICAL pixels (i.e. the
// units a user actually perceives, already divided by the display scale
// factor). Nothing here is a float: the whole engine is integer arithmetic.
type Pane struct {
	ID         string `json:"id"`
	Label      string `json:"label,omitempty"`
	Priority   int    `json:"priority"`
	MinWidth   int    `json:"minWidth"`
	MinHeight  int    `json:"minHeight"`
	IdealArea  int64  `json:"idealArea"`
	Stackable  bool   `json:"stackable,omitempty"`
	StackGroup string `json:"stackGroup,omitempty"`
	Required   bool   `json:"required,omitempty"`
}

// Scene is a named set of panes.
type Scene struct {
	Name  string `json:"name"`
	Notes string `json:"notes,omitempty"`
	Panes []Pane `json:"panes"`
}

// WorkArea is the usable sub-rectangle of a monitor, in PHYSICAL device
// pixels, relative to the monitor's own top-left corner.
type WorkArea struct {
	X      int `json:"x"`
	Y      int `json:"y"`
	Width  int `json:"width"`
	Height int `json:"height"`
}

// Monitor is one physical display. Width/Height and WorkArea are physical
// device pixels; Scale is the display scale factor as an integer percentage
// (100 = 1x, 200 = 2x HiDPI). X/Y position the monitor in the virtual desktop
// and are used only for rendering; when omitted monitors are laid out
// left-to-right in the order given.
type Monitor struct {
	ID       string    `json:"id"`
	Width    int       `json:"width"`
	Height   int       `json:"height"`
	Scale    int       `json:"scale,omitempty"`
	X        *int      `json:"x,omitempty"`
	Y        *int      `json:"y,omitempty"`
	WorkArea *WorkArea `json:"workArea,omitempty"`
}

// Topology is the set of monitors DeskScene has to fit a scene onto.
type Topology struct {
	Name     string    `json:"name"`
	Monitors []Monitor `json:"monitors"`
}

// logicalMon is a monitor reduced to what the packer needs: a logical-pixel
// work area, plus the physical origin needed to draw it later.
type logicalMon struct {
	ID       string
	Index    int
	W        int // logical work-area width
	H        int // logical work-area height
	Scale    int // percent
	OriginX  int // physical x of the work-area origin in the virtual desktop
	OriginY  int // physical y of the work-area origin in the virtual desktop
	PhysW    int // physical monitor width
	PhysH    int // physical monitor height
	PhysX    int // physical monitor origin
	PhysY    int
	PhysWAW  int // physical work-area width
	PhysWAH  int // physical work-area height
	FullMon  bool
	Reported string
}

// ---------------------------------------------------------------------------
// Validation and normalisation
// ---------------------------------------------------------------------------

func (s *Scene) validate() error {
	if len(s.Panes) == 0 {
		return fmt.Errorf("scene %q has no panes", s.Name)
	}
	if strings.TrimSpace(s.Name) == "" {
		s.Name = "(unnamed scene)"
	}
	seen := map[string]bool{}
	for i := range s.Panes {
		p := &s.Panes[i]
		p.ID = strings.TrimSpace(p.ID)
		if p.ID == "" {
			return fmt.Errorf("pane #%d has no id", i+1)
		}
		if seen[p.ID] {
			return fmt.Errorf("duplicate pane id %q", p.ID)
		}
		seen[p.ID] = true
		if p.Label == "" {
			p.Label = p.ID
		}
		if p.Priority <= 0 {
			return fmt.Errorf("pane %q: priority must be a positive integer, got %d", p.ID, p.Priority)
		}
		if p.MinWidth <= 0 || p.MinHeight <= 0 {
			return fmt.Errorf("pane %q: minWidth and minHeight must both be positive, got %dx%d", p.ID, p.MinWidth, p.MinHeight)
		}
		if p.Stackable && strings.TrimSpace(p.StackGroup) == "" {
			return fmt.Errorf("pane %q: stackable panes need a stackGroup", p.ID)
		}
		if !p.Stackable {
			p.StackGroup = ""
		}
		floor := int64(p.MinWidth) * int64(p.MinHeight)
		if p.IdealArea < floor {
			// An ideal smaller than the minimum is nonsense; raise it to the
			// minimum rather than refusing the scene.
			p.IdealArea = floor
		}
	}
	return nil
}

func (t *Topology) validate() error {
	if len(t.Monitors) == 0 {
		return fmt.Errorf("display topology %q has no monitors", t.Name)
	}
	if strings.TrimSpace(t.Name) == "" {
		t.Name = "(unnamed topology)"
	}
	seen := map[string]bool{}
	for i := range t.Monitors {
		m := &t.Monitors[i]
		m.ID = strings.TrimSpace(m.ID)
		if m.ID == "" {
			m.ID = fmt.Sprintf("monitor%d", i+1)
		}
		if seen[m.ID] {
			return fmt.Errorf("duplicate monitor id %q", m.ID)
		}
		seen[m.ID] = true
		if m.Width <= 0 || m.Height <= 0 {
			return fmt.Errorf("monitor %q: width and height must both be positive, got %dx%d", m.ID, m.Width, m.Height)
		}
		if m.Scale == 0 {
			m.Scale = 100
		}
		if m.Scale < 25 || m.Scale > 400 {
			return fmt.Errorf("monitor %q: scale must be between 25 and 400 percent, got %d", m.ID, m.Scale)
		}
		if m.WorkArea == nil {
			m.WorkArea = &WorkArea{X: 0, Y: 0, Width: m.Width, Height: m.Height}
		}
		w := m.WorkArea
		if w.Width <= 0 || w.Height <= 0 {
			return fmt.Errorf("monitor %q: workArea width and height must both be positive", m.ID)
		}
		if w.X < 0 || w.Y < 0 || w.X+w.Width > m.Width || w.Y+w.Height > m.Height {
			return fmt.Errorf("monitor %q: workArea %dx%d+%d+%d does not fit inside the %dx%d panel",
				m.ID, w.Width, w.Height, w.X, w.Y, m.Width, m.Height)
		}
	}
	return nil
}

// logical projects a validated topology into the packer's coordinate system.
// Physical work-area pixels are converted to logical pixels by dividing by the
// scale percentage; the division truncates, which deliberately under-reports
// rather than over-reports the room available.
func (t *Topology) logical() []logicalMon {
	out := make([]logicalMon, 0, len(t.Monitors))
	cursor := 0
	for i, m := range t.Monitors {
		px, py := cursor, 0
		if m.X != nil {
			px = *m.X
		}
		if m.Y != nil {
			py = *m.Y
		}
		cursor = px + m.Width
		wa := m.WorkArea
		lm := logicalMon{
			ID:      m.ID,
			Index:   i,
			W:       wa.Width * 100 / m.Scale,
			H:       wa.Height * 100 / m.Scale,
			Scale:   m.Scale,
			OriginX: px + wa.X,
			OriginY: py + wa.Y,
			PhysW:   m.Width,
			PhysH:   m.Height,
			PhysX:   px,
			PhysY:   py,
			PhysWAW: wa.Width,
			PhysWAH: wa.Height,
			FullMon: wa.X == 0 && wa.Y == 0 && wa.Width == m.Width && wa.Height == m.Height,
		}
		lm.Reported = fmt.Sprintf("%dx%d physical @ %d%% = %dx%d logical", wa.Width, wa.Height, m.Scale, lm.W, lm.H)
		out = append(out, lm)
	}
	return out
}

// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------

//go:embed presets/*.json
var presetFS embed.FS

type presetInfo struct {
	Name  string `json:"name"`
	File  string `json:"file"`
	Notes string `json:"notes"`
	Panes int    `json:"panes"`
}

func presetNames() []string {
	ents, err := presetFS.ReadDir("presets")
	if err != nil {
		return nil
	}
	var out []string
	for _, e := range ents {
		out = append(out, strings.TrimSuffix(e.Name(), ".json"))
	}
	sort.Strings(out)
	return out
}

func loadPreset(name string) (Scene, error) {
	var s Scene
	b, err := presetFS.ReadFile("presets/" + name + ".json")
	if err != nil {
		return s, fmt.Errorf("no built-in preset named %q (try: %s)", name, strings.Join(presetNames(), ", "))
	}
	if err := json.Unmarshal(b, &s); err != nil {
		return s, fmt.Errorf("built-in preset %q is corrupt: %w", name, err)
	}
	if err := s.validate(); err != nil {
		return s, fmt.Errorf("built-in preset %q is invalid: %w", name, err)
	}
	return s, nil
}

func listPresets() []presetInfo {
	var out []presetInfo
	for _, n := range presetNames() {
		s, err := loadPreset(n)
		if err != nil {
			continue
		}
		out = append(out, presetInfo{Name: n, File: "presets/" + n + ".json", Notes: s.Notes, Panes: len(s.Panes)})
	}
	return out
}

// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------

// loadScene accepts either a path to a JSON file or the name of a built-in
// preset. A path is anything that exists on disk, or that looks like a path
// (contains a separator or ends in .json).
func loadScene(ref string) (Scene, string, error) {
	var s Scene
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return s, "", fmt.Errorf("empty scene reference")
	}
	looksLikePath := strings.ContainsRune(ref, filepath.Separator) ||
		strings.HasSuffix(strings.ToLower(ref), ".json") ||
		strings.ContainsRune(ref, '/')
	if !looksLikePath {
		if _, err := os.Stat(ref); err != nil {
			p, err := loadPreset(ref)
			return p, "preset:" + ref, err
		}
	}
	b, err := os.ReadFile(ref)
	if err != nil {
		if os.IsNotExist(err) && !looksLikePath {
			p, perr := loadPreset(ref)
			return p, "preset:" + ref, perr
		}
		return s, ref, fmt.Errorf("cannot read scene %s: %w", ref, err)
	}
	if err := json.Unmarshal(b, &s); err != nil {
		return s, ref, fmt.Errorf("scene %s is not valid JSON: %w", ref, err)
	}
	if err := s.validate(); err != nil {
		return s, ref, fmt.Errorf("scene %s: %w", ref, err)
	}
	return s, ref, nil
}

func loadTopology(path string) (Topology, error) {
	var t Topology
	b, err := os.ReadFile(path)
	if err != nil {
		return t, fmt.Errorf("cannot read displays %s: %w", path, err)
	}
	if err := json.Unmarshal(b, &t); err != nil {
		return t, fmt.Errorf("displays %s is not valid JSON: %w", path, err)
	}
	if err := t.validate(); err != nil {
		return t, fmt.Errorf("displays %s: %w", path, err)
	}
	return t, nil
}

// clone makes an independent copy so advise can mutate candidate topologies.
func (t Topology) clone() Topology {
	out := Topology{Name: t.Name, Monitors: make([]Monitor, len(t.Monitors))}
	for i, m := range t.Monitors {
		c := m
		if m.WorkArea != nil {
			w := *m.WorkArea
			c.WorkArea = &w
		}
		if m.X != nil {
			v := *m.X
			c.X = &v
		}
		if m.Y != nil {
			v := *m.Y
			c.Y = &v
		}
		out.Monitors[i] = c
	}
	return out
}
