package main

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

// ---------------------------------------------------------------------------
// The layout language
//
// A layout describes, per monitor, a tree of weighted row/column splits whose
// leaves are named zones, plus the rules that decide which window lands in
// which zone.
// ---------------------------------------------------------------------------

const (
	splitRows    = "rows"
	splitColumns = "columns"
)

// Node is one node of a split tree. It is either a split (Split set, Children
// non-empty) or a zone leaf (Zone set), never both.
type Node struct {
	Zone      string  `json:"zone,omitempty"`
	Weight    *int    `json:"weight,omitempty"`
	Split     string  `json:"split,omitempty"`
	Gap       *int    `json:"gap,omitempty"`
	Padding   int     `json:"padding,omitempty"`
	MinWidth  int     `json:"minWidth,omitempty"`
	MinHeight int     `json:"minHeight,omitempty"`
	Children  []*Node `json:"children,omitempty"`

	// foldWrap marks a split node that Refit created while folding a
	// vanished monitor in, so a second fold appends to it instead of
	// nesting wrappers. It is never read from or written to JSON.
	foldWrap bool
}

func (n *Node) isLeaf() bool { return len(n.Children) == 0 }

func (n *Node) weight() int {
	if n.Weight == nil {
		return 1
	}
	return *n.Weight
}

// clone deep-copies a subtree so re-fit can graft trees without mutating the
// layout the user wrote.
func (n *Node) clone() *Node {
	if n == nil {
		return nil
	}
	c := *n
	if n.Weight != nil {
		w := *n.Weight
		c.Weight = &w
	}
	if n.Gap != nil {
		g := *n.Gap
		c.Gap = &g
	}
	c.Children = nil
	for _, ch := range n.Children {
		c.Children = append(c.Children, ch.clone())
	}
	return &c
}

// LayoutMonitor binds a split tree to one monitor id from a topology.
type LayoutMonitor struct {
	Monitor string `json:"monitor"`
	Gap     int    `json:"gap,omitempty"`
	Padding int    `json:"padding,omitempty"`
	Root    *Node  `json:"root"`
}

// Rule assigns windows to a zone. A rule may test the app id, the window
// title, or both; when both are present both must match.
type Rule struct {
	Zone  string `json:"zone"`
	App   string `json:"app,omitempty"`
	Title string `json:"title,omitempty"`

	appM   Matcher
	titleM Matcher
	hasApp bool
	hasTtl bool
}

// Window is one window WorkspaceForge is asked to place. These come from the
// layout file (or a --windows file); WorkspaceForge does not enumerate the
// windows actually open on your machine.
type Window struct {
	ID    string `json:"id,omitempty"`
	App   string `json:"app"`
	Title string `json:"title,omitempty"`
}

// Layout is a validated layout document.
type Layout struct {
	Name     string           `json:"name"`
	Source   string           `json:"source,omitempty"`
	Topology string           `json:"topology,omitempty"` // informational: what it was authored on
	Monitors []*LayoutMonitor `json:"monitors"`
	Rules    []*Rule          `json:"rules,omitempty"`
	Windows  []Window         `json:"windows,omitempty"`

	zones []string // every zone name, in tree order
}

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

// LoadLayout reads and validates a layout file.
func LoadLayout(path string) (*Layout, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("cannot read layout %s: %w", path, err)
	}
	defer f.Close()
	dec := json.NewDecoder(f)
	dec.DisallowUnknownFields()
	var lf layoutFile
	if err := dec.Decode(&lf); err != nil {
		return nil, fmt.Errorf("layout %s is not valid JSON: %w", path, err)
	}
	l, err := buildLayout(lf)
	if err != nil {
		return nil, fmt.Errorf("layout %s: %w", path, err)
	}
	l.Source = path
	return l, nil
}

// ParseLayout validates an in-memory layout document.
func ParseLayout(data []byte) (*Layout, error) {
	dec := json.NewDecoder(strings.NewReader(string(data)))
	dec.DisallowUnknownFields()
	var lf layoutFile
	if err := dec.Decode(&lf); err != nil {
		return nil, fmt.Errorf("not valid JSON: %w", err)
	}
	return buildLayout(lf)
}

func buildLayout(lf layoutFile) (*Layout, error) {
	l := &Layout{
		Name:     lf.Name,
		Topology: lf.Topology,
		Monitors: lf.Monitors,
		Rules:    lf.Rules,
		Windows:  lf.Windows,
	}
	if l.Name == "" {
		l.Name = "(unnamed)"
	}
	if len(l.Monitors) == 0 {
		return nil, fmt.Errorf("no monitors declared; a layout needs at least one")
	}
	zoneOwner := map[string]string{}
	seenMon := map[string]bool{}
	for i, lm := range l.Monitors {
		if lm == nil {
			return nil, fmt.Errorf("monitor #%d is null", i+1)
		}
		id := strings.TrimSpace(lm.Monitor)
		if id == "" {
			return nil, fmt.Errorf("layout monitor #%d has no \"monitor\" id", i+1)
		}
		lm.Monitor = id
		if seenMon[id] {
			return nil, fmt.Errorf("layout declares monitor %q twice", id)
		}
		seenMon[id] = true
		if lm.Gap < 0 {
			return nil, fmt.Errorf("monitor %q: gap %d must not be negative", id, lm.Gap)
		}
		if lm.Padding < 0 {
			return nil, fmt.Errorf("monitor %q: padding %d must not be negative", id, lm.Padding)
		}
		if lm.Root == nil {
			return nil, fmt.Errorf("monitor %q has no \"root\" node", id)
		}
		if err := validateNode(lm.Root, id, "root", zoneOwner, &l.zones); err != nil {
			return nil, err
		}
	}

	for i, r := range l.Rules {
		if r == nil {
			return nil, fmt.Errorf("rule #%d is null", i+1)
		}
		if strings.TrimSpace(r.Zone) == "" {
			return nil, fmt.Errorf("rule #%d has no zone", i+1)
		}
		if r.App == "" && r.Title == "" {
			return nil, fmt.Errorf("rule #%d (zone %q) tests nothing; give it an app or a title", i+1, r.Zone)
		}
		if r.App != "" {
			m, err := parseMatcher(r.App)
			if err != nil {
				return nil, fmt.Errorf("rule #%d (zone %q): app: %w", i+1, r.Zone, err)
			}
			r.appM, r.hasApp = m, true
		}
		if r.Title != "" {
			m, err := parseMatcher(r.Title)
			if err != nil {
				return nil, fmt.Errorf("rule #%d (zone %q): title: %w", i+1, r.Zone, err)
			}
			r.titleM, r.hasTtl = m, true
		}
	}

	seenWin := map[string]bool{}
	for i := range l.Windows {
		w := &l.Windows[i]
		if strings.TrimSpace(w.App) == "" {
			return nil, fmt.Errorf("window #%d has no app id", i+1)
		}
		if w.ID == "" {
			w.ID = fmt.Sprintf("w%d", i+1)
		}
		if seenWin[w.ID] {
			return nil, fmt.Errorf("window id %q appears twice", w.ID)
		}
		seenWin[w.ID] = true
	}
	return l, nil
}

func validateNode(n *Node, monitor, path string, zoneOwner map[string]string, zones *[]string) error {
	where := fmt.Sprintf("monitor %q node %s", monitor, path)
	if n.Padding < 0 {
		return fmt.Errorf("%s: padding %d must not be negative", where, n.Padding)
	}
	if n.Gap != nil && *n.Gap < 0 {
		return fmt.Errorf("%s: gap %d must not be negative", where, *n.Gap)
	}
	if n.Weight != nil && *n.Weight < 1 {
		return fmt.Errorf("%s: weight %d must be >= 1", where, *n.Weight)
	}
	if n.MinWidth < 0 || n.MinHeight < 0 {
		return fmt.Errorf("%s: minWidth/minHeight must not be negative", where)
	}
	if n.isLeaf() {
		if n.Split != "" {
			return fmt.Errorf("%s: has split %q but no children", where, n.Split)
		}
		z := strings.TrimSpace(n.Zone)
		if z == "" {
			return fmt.Errorf("%s: leaf node has no zone name", where)
		}
		n.Zone = z
		if owner, dup := zoneOwner[z]; dup {
			return fmt.Errorf("zone %q is declared twice (already on monitor %q, again at %s); zone names must be unique across the whole layout",
				z, owner, where)
		}
		zoneOwner[z] = monitor
		*zones = append(*zones, z)
		return nil
	}
	if n.Zone != "" {
		return fmt.Errorf("%s: node has both a zone (%q) and children; a zone must be a leaf", where, n.Zone)
	}
	if n.Split != splitRows && n.Split != splitColumns {
		return fmt.Errorf("%s: split is %q; expected %q or %q", where, n.Split, splitRows, splitColumns)
	}
	for i, c := range n.Children {
		if c == nil {
			return fmt.Errorf("%s: child #%d is null", where, i+1)
		}
		if err := validateNode(c, monitor, fmt.Sprintf("%s/%s[%d]", path, n.Split, i), zoneOwner, zones); err != nil {
			return err
		}
	}
	return nil
}

// Zones lists every zone name declared in the layout, in tree order.
func (l *Layout) Zones() []string { return l.zones }

// SortedZones lists zone names alphabetically.
func (l *Layout) SortedZones() []string {
	out := append([]string(nil), l.zones...)
	sort.Strings(out)
	return out
}

// MonitorLayout returns the layout block for a monitor id.
func (l *Layout) MonitorLayout(id string) *LayoutMonitor {
	for _, lm := range l.Monitors {
		if lm.Monitor == id {
			return lm
		}
	}
	return nil
}

// LoadWindows reads a standalone window list. The file may be a bare JSON
// array of window objects, or an object with a "windows" key.
func LoadWindows(path string) ([]Window, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("cannot read window list %s: %w", path, err)
	}
	trimmed := strings.TrimSpace(string(data))
	var out []Window
	if strings.HasPrefix(trimmed, "[") {
		if err := json.Unmarshal(data, &out); err != nil {
			return nil, fmt.Errorf("window list %s is not valid JSON: %w", path, err)
		}
	} else {
		var obj struct {
			Windows []Window `json:"windows"`
		}
		if err := json.Unmarshal(data, &obj); err != nil {
			return nil, fmt.Errorf("window list %s is not valid JSON: %w", path, err)
		}
		out = obj.Windows
	}
	seen := map[string]bool{}
	for i := range out {
		if strings.TrimSpace(out[i].App) == "" {
			return nil, fmt.Errorf("window list %s: window #%d has no app id", path, i+1)
		}
		if out[i].ID == "" {
			out[i].ID = fmt.Sprintf("w%d", i+1)
		}
		if seen[out[i].ID] {
			return nil, fmt.Errorf("window list %s: window id %q appears twice", path, out[i].ID)
		}
		seen[out[i].ID] = true
	}
	return out, nil
}

// ---------------------------------------------------------------------------
// Rule evaluation
// ---------------------------------------------------------------------------

func (r *Rule) matches(w Window) bool {
	if r.hasApp && !r.appM.Match(w.App) {
		return false
	}
	if r.hasTtl && !r.titleM.Match(w.Title) {
		return false
	}
	return true
}

// Assignment is the result of running the rule list over one window.
type Assignment struct {
	Window Window `json:"window"`
	Zone   string `json:"zone,omitempty"`
	Rule   int    `json:"rule,omitempty"` // 1-based rule index, 0 when unmatched
}

// AssignWindows applies the rules in order; the FIRST rule that matches a
// window wins. Windows matched by no rule are returned with an empty zone.
func AssignWindows(rules []*Rule, windows []Window) []Assignment {
	out := make([]Assignment, 0, len(windows))
	for _, w := range windows {
		a := Assignment{Window: w}
		for i, r := range rules {
			if r.matches(w) {
				a.Zone = r.Zone
				a.Rule = i + 1
				break
			}
		}
		out = append(out, a)
	}
	return out
}
