package web

// Dungeon map rendering.
//
// gogobee sends the fog-of-war cut of an adventurer's zone graph (whoMap):
// every room they have visited with its true kind, plus the one-hop frontier of
// doors leading out of visited rooms, with the rooms behind them withheld as
// kind "unknown". Pete lays that graph out and draws it as an inline SVG on the
// who page. No node labels or contents ever cross the wire — the map knows a
// room is a trap, never what the trap is.
//
// Layout is a left-to-right layering: column = BFS depth from the entry along
// the edges we were given, row = order the node first appears in the payload.
// It is computed server-side and fully deterministic, so the same run draws the
// same map every render.

// Layout geometry. The node/ring radii live in who.html and the map CSS; only
// the spacing the layout math needs is here.
const (
	mapColGap = 84 // horizontal spacing between depth columns
	mapRowGap = 60 // vertical spacing between nodes sharing a column
	mapMargin = 26 // padding around the node field
)

// mapView is the laid-out map, ready for the template's SVG.
type mapView struct {
	Nodes []mapNode
	Edges []mapEdge
	W     int // svg viewBox width
	H     int // svg viewBox height
}

type mapNode struct {
	X, Y    int
	Kind    string // ZoneNodeKind, or "unknown" for an unreached frontier room
	Glyph   string // the single character drawn inside the node
	Class   string // CSS class picking the node's colour family
	Label   string // accessible title, e.g. "Trap" or "Unexplored"
	Current bool   // the room the adventurer is standing in
}

type mapEdge struct {
	X1, Y1, X2, Y2 int
	Locked         bool
	Lock           string // the gate's kind, for the door's title text
}

// nodeStyle maps a room kind to how it draws. Kept small and total: an unknown
// kind (a room type Pete hasn't been taught yet) falls through to a neutral dot
// rather than vanishing.
type nodeStyle struct {
	glyph string
	class string
	label string
}

var mapNodeStyles = map[string]nodeStyle{
	"entry":       {"⌂", "map-node-entry", "Entrance"},
	"exploration": {"·", "map-node-plain", "Room"},
	"trap":        {"!", "map-node-trap", "Trap"},
	"elite":       {"◆", "map-node-elite", "Elite"},
	"boss":        {"♛", "map-node-boss", "Boss"}, // text dingbats only — U+26CF/U+2620 fall back to emoji or tofu
	"harvest":     {"❖", "map-node-harvest", "Harvest"},
	"rest_camp":   {"✚", "map-node-rest", "Rest camp"},
	"secret":      {"✦", "map-node-secret", "Secret"},
	"fork":        {"⋔", "map-node-plain", "Fork"},
	"merge":       {"⋏", "map-node-plain", "Merge"},
	"unknown":     {"?", "map-node-unknown", "Unexplored"},
}

func styleFor(kind string) nodeStyle {
	if s, ok := mapNodeStyles[kind]; ok {
		return s
	}
	return nodeStyle{"·", "map-node-plain", "Room"}
}

// buildMapView lays out a fog-of-war graph. Returns nil when there is nothing
// worth drawing (no nodes) so the template can skip the panel entirely.
func buildMapView(m *whoMap) *mapView {
	if m == nil || len(m.Nodes) == 0 {
		return nil
	}

	// Adjacency for the depth walk, and appearance order for stable rows.
	order := make(map[string]int, len(m.Nodes))
	for i, n := range m.Nodes {
		if _, dup := order[n.ID]; !dup {
			order[n.ID] = i
		}
	}
	adj := make(map[string][]string, len(m.Nodes))
	for _, e := range m.Edges {
		// Only lay out edges whose endpoints are both nodes we were given; a
		// half-edge would point at nothing.
		if _, ok := order[e.From]; !ok {
			continue
		}
		if _, ok := order[e.To]; !ok {
			continue
		}
		adj[e.From] = append(adj[e.From], e.To)
	}

	// Depth = shortest hop count from the entry. The first visited node is the
	// entry (gogobee sends Visited in path order); fall back to appearance
	// order 0 if Visited is somehow empty.
	start := ""
	if len(m.Visited) > 0 {
		start = m.Visited[0]
	} else {
		start = m.Nodes[0].ID
	}
	depth := bfsDepth(start, adj, order)

	// Group nodes by depth column, each column ordered by first appearance.
	maxDepth := 0
	byCol := map[int][]string{}
	for _, n := range m.Nodes {
		d := depth[n.ID]
		byCol[d] = append(byCol[d], n.ID)
		if d > maxDepth {
			maxDepth = d
		}
	}
	maxRows := 0
	for d := 0; d <= maxDepth; d++ {
		sortByOrder(byCol[d], order)
		if len(byCol[d]) > maxRows {
			maxRows = len(byCol[d])
		}
	}

	kindByID := make(map[string]string, len(m.Nodes))
	for _, n := range m.Nodes {
		kindByID[n.ID] = n.Kind
	}

	// Place nodes. Each column is vertically centred against the tallest column
	// so the map reads as balanced rather than top-aligned.
	pos := make(map[string][2]int, len(m.Nodes))
	view := &mapView{
		W: mapMargin*2 + maxDepth*mapColGap,
		H: mapMargin*2 + max(0, maxRows-1)*mapRowGap,
	}
	if view.W < mapMargin*2 {
		view.W = mapMargin * 2
	}
	for d := 0; d <= maxDepth; d++ {
		col := byCol[d]
		x := mapMargin + d*mapColGap
		offset := (maxRows - len(col)) * mapRowGap / 2
		for i, id := range col {
			y := mapMargin + offset + i*mapRowGap
			pos[id] = [2]int{x, y}
		}
	}

	for d := 0; d <= maxDepth; d++ {
		for _, id := range byCol[d] {
			p := pos[id]
			st := styleFor(kindByID[id])
			view.Nodes = append(view.Nodes, mapNode{
				X: p[0], Y: p[1],
				Kind:    kindByID[id],
				Glyph:   st.glyph,
				Class:   st.class,
				Label:   st.label,
				Current: id == m.CurrentNode,
			})
		}
	}
	for _, e := range m.Edges {
		a, okA := pos[e.From]
		b, okB := pos[e.To]
		if !okA || !okB {
			continue
		}
		view.Edges = append(view.Edges, mapEdge{
			X1: a[0], Y1: a[1], X2: b[0], Y2: b[1],
			Locked: e.Lock != "" && e.Lock != "none",
			Lock:   e.Lock,
		})
	}
	return view
}

// bfsDepth returns the hop distance from start to every node reachable along
// adj. Nodes never reached (there should be none in a well-formed cut) default
// to 0, so a stray node still lands in the first column rather than off-canvas.
func bfsDepth(start string, adj map[string][]string, order map[string]int) map[string]int {
	depth := map[string]int{start: 0}
	queue := []string{start}
	for len(queue) > 0 {
		u := queue[0]
		queue = queue[1:]
		for _, v := range adj[u] {
			if _, seen := depth[v]; !seen {
				depth[v] = depth[u] + 1
				queue = append(queue, v)
			}
		}
	}
	// Any node with an appearance order but no depth (unreachable via the cut)
	// gets 0 so it is still placed.
	for id := range order {
		if _, ok := depth[id]; !ok {
			depth[id] = 0
		}
	}
	return depth
}

// sortByOrder sorts ids in place by their first-appearance index. Insertion
// sort — columns hold a handful of nodes, and it keeps the ordering stable
// without pulling in a comparator closure.
func sortByOrder(ids []string, order map[string]int) {
	for i := 1; i < len(ids); i++ {
		for j := i; j > 0 && order[ids[j]] < order[ids[j-1]]; j-- {
			ids[j], ids[j-1] = ids[j-1], ids[j]
		}
	}
}
