package qslcard

import (
	"fmt"
	"image"
	_ "image/jpeg" // hero/insert photo decoding
	_ "image/png"
	"math"
	"os"
	"sort"
	"strings"
)

// Analysis grid resolution (spec section 5.1): 32 columns × 20 rows.
const (
	gridCols = 32
	gridRows = 20
)

// Default card geometry: 140×90 mm at 300 dpi.
const (
	cardW     = 1654
	cardH     = 1063
	cardDPI   = 300
	cardBleed = 35
)

// detail thresholds: a cell is "quiet" (usable for text) below quietDetail;
// quietRelaxed is the fallback when a photo has no large calm zone.
const (
	quietDetail  = 0.2
	quietRelaxed = 0.3
)

// Cell is one analysis-grid cell of a photo.
type Cell struct {
	Detail float64 // mean gradient magnitude, 0–1
	Luma   float64 // mean relative luminance, 0–1
}

// PhotoAnalysis is the downscaled feature summary of one photo. All scores
// are deterministic so "Generate designs" always yields the same proposals.
type PhotoAnalysis struct {
	Path      string
	W, H      int // original pixel size
	Cells     [gridRows][gridCols]Cell
	Quietness float64 // share of cells with detail < 0.15
	AspectFit float64 // closeness to the 14:9 card ratio, 0–1
	AvgLuma   float64
	Warmth    float64 // mean (R−B), positive = warm (sunset), negative = cool
	Score     float64 // hero suitability
}

// ProfileInfo is the slice of the active profile the engine needs (actual
// values are used only for sizing — templates store {profile.*} placeholders).
type ProfileInfo struct {
	Callsign string
	Operator string
	Grid     string
	CQZone   int
	ITUZone  int
	Rig      string
	Antenna  string
}

// LayoutEngine proposes card templates from analyzed photos. The heuristic
// engine is the default; a future AI engine implements the same contract and
// falls back to the heuristic one on any error.
type LayoutEngine interface {
	Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]Template, error)
}

// AnalyzePhoto decodes and analyzes one photo file.
func AnalyzePhoto(path string) (PhotoAnalysis, error) {
	f, err := os.Open(path)
	if err != nil {
		return PhotoAnalysis{}, fmt.Errorf("open photo: %w", err)
	}
	defer f.Close()
	img, _, err := image.Decode(f)
	if err != nil {
		return PhotoAnalysis{}, fmt.Errorf("decode photo %s: %w", path, err)
	}
	return AnalyzeImage(img, path), nil
}

// AnalyzeImage computes the analysis grid of an already-decoded image.
// Exported separately so tests can feed synthetic images.
func AnalyzeImage(img image.Image, path string) PhotoAnalysis {
	b := img.Bounds()
	origW, origH := b.Dx(), b.Dy()

	// Downscale to ≤256 px on the long edge (nearest neighbor).
	scale := 1.0
	if long := max(origW, origH); long > 256 {
		scale = 256.0 / float64(long)
	}
	w := max(1, int(float64(origW)*scale))
	h := max(1, int(float64(origH)*scale))
	luma := make([]float64, w*h)
	var sumWarm float64
	for y := 0; y < h; y++ {
		sy := b.Min.Y + y*origH/h
		for x := 0; x < w; x++ {
			sx := b.Min.X + x*origW/w
			r, g, bl, _ := img.At(sx, sy).RGBA()
			rf, gf, bf := float64(r)/65535, float64(g)/65535, float64(bl)/65535
			luma[y*w+x] = 0.2126*rf + 0.7152*gf + 0.0722*bf
			sumWarm += rf - bf
		}
	}

	p := PhotoAnalysis{Path: path, W: origW, H: origH, Warmth: sumWarm / float64(w*h)}
	var lumaSum float64
	quietCells := 0
	for r := 0; r < gridRows; r++ {
		y0, y1 := r*h/gridRows, (r+1)*h/gridRows
		for c := 0; c < gridCols; c++ {
			x0, x1 := c*w/gridCols, (c+1)*w/gridCols
			var dSum, lSum float64
			n, dn := 0, 0
			for y := y0; y < y1; y++ {
				for x := x0; x < x1; x++ {
					l := luma[y*w+x]
					lSum += l
					n++
					if x+1 < w {
						dSum += math.Abs(luma[y*w+x+1] - l)
						dn++
					}
					if y+1 < h {
						dSum += math.Abs(luma[(y+1)*w+x] - l)
						dn++
					}
				}
			}
			cell := Cell{}
			if n > 0 {
				cell.Luma = lSum / float64(n)
			}
			if dn > 0 {
				// Mean neighbor difference ≈ 0.12 in textured regions; ×8
				// stretches that to ~1 so the 0–1 thresholds are meaningful.
				cell.Detail = math.Min(1, dSum/float64(dn)*8)
			}
			p.Cells[r][c] = cell
			lumaSum += cell.Luma
			if cell.Detail < 0.15 {
				quietCells++
			}
		}
	}
	p.AvgLuma = lumaSum / (gridRows * gridCols)
	p.Quietness = float64(quietCells) / (gridRows * gridCols)

	const targetAR = 14.0 / 9.0
	ar := float64(origW) / float64(origH)
	p.AspectFit = math.Min(ar, targetAR) / math.Max(ar, targetAR)
	resNorm := math.Min(1, float64(origW*origH)/(1600.0*1029.0))
	p.Score = resNorm * p.AspectFit * (0.2 + 0.8*p.Quietness)
	return p
}

// ── maximal quiet rectangle ─────────────────────────────────────────────

// cellRect is a rectangle in analysis-grid cell coordinates (inclusive
// row/col start, exclusive end).
type cellRect struct{ r0, c0, r1, c1 int }

func (r cellRect) rows() int { return r.r1 - r.r0 }
func (r cellRect) cols() int { return r.c1 - r.c0 }

// maximalQuietRect finds the best axis-aligned rectangle of cells where
// quiet() is true, at least minCols × minRows, preferring the top or bottom
// third (text near the card edges keeps the photo subject visible).
// O(rows²·cols) — trivial at 20×32.
func maximalQuietRect(quiet func(r, c int) bool, minCols, minRows int) (cellRect, bool) {
	best := cellRect{}
	bestScore := 0.0
	for r0 := 0; r0 < gridRows; r0++ {
		for r1 := r0 + 1; r1 <= gridRows; r1++ {
			if r1-r0 < minRows {
				continue
			}
			run := 0
			for c := 0; c <= gridCols; c++ {
				ok := c < gridCols
				if ok {
					for r := r0; r < r1; r++ {
						if !quiet(r, c) {
							ok = false
							break
						}
					}
				}
				if ok {
					run++
					continue
				}
				if run >= minCols {
					cand := cellRect{r0: r0, c0: c - run, r1: r1, c1: c}
					score := float64(cand.rows()*cand.cols()) * positionBonus(cand)
					if score > bestScore {
						best, bestScore = cand, score
					}
				}
				run = 0
			}
		}
	}
	return best, bestScore > 0
}

// positionBonus prefers rectangles whose center sits in the top or bottom
// third of the grid.
func positionBonus(r cellRect) float64 {
	center := float64(r.r0+r.r1) / 2
	if center < gridRows/3.0 || center > gridRows*2/3.0 {
		return 1.25
	}
	return 1.0
}

// ── heuristic engine ────────────────────────────────────────────────────

// HeuristicEngine is the default, fully-offline layout engine.
type HeuristicEngine struct{}

// proposalPlan captures the decisions shared by the three proposals.
type proposalPlan struct {
	hero    PhotoAnalysis
	inserts []PhotoAnalysis
	profile ProfileInfo
	cool    bool // cool-toned photo → silver alternative
}

// Propose builds three distinct full templates (spec section 5.3):
// best archetype + gel gold, an alternate archetype, and the best archetype
// with the alternate color treatment.
func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]Template, error) {
	if len(photos) == 0 {
		return nil, fmt.Errorf("no photos to lay out")
	}
	if profile.Callsign == "" {
		return nil, fmt.Errorf("active profile has no callsign — set one before generating designs")
	}

	sorted := make([]PhotoAnalysis, len(photos))
	copy(sorted, photos)
	sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Score > sorted[j].Score })

	plan := proposalPlan{
		hero:    sorted[0],
		profile: profile,
		cool:    sorted[0].Warmth < 0.02,
	}
	if len(sorted) > 1 {
		plan.inserts = sorted[1:min(len(sorted), 5)] // hero + up to 4 inserts
	}

	// Only side-column inserts (or none): a bottom strip collides with the QSO
	// box, which is what produced the overlapping mess.
	primary := ArchetypeSideColumn
	if len(plan.inserts) == 0 {
		primary = ArchetypeFullBleed
	}

	// Three proposals that showcase genuinely different call looks (font +
	// style + position), echoing the classic printed-QSL styles: a rounded
	// glossy gold, a distressed slab "vintage", and an angular silver. The
	// middle one is a clean full-bleed (no inserts) so the set always offers a
	// minimal option.
	p1 := buildTemplateBiased(plan, primary, "gel_gold", "Baloo 2", flipTop)
	p2 := buildTemplateBiased(plan, ArchetypeFullBleed, "gel_gold_grunge", "Alfa Slab One", flipBottom)
	p3 := buildTemplateBiased(plan, primary, "gel_silver", "Archivo Black", flipNatural)

	out := []Template{p1, p2, p3}
	for i := range out {
		out[i].Name = fmt.Sprintf("%s — %s %d", profile.Callsign, archetypeLabel(out[i]), i+1)
		if err := Validate(out[i], nil); err != nil {
			return nil, fmt.Errorf("proposal %d invalid: %w", i+1, err)
		}
	}
	return out, nil
}

func archetypeLabel(t Template) string {
	inserts := 0
	for _, e := range t.Elements {
		if e.Type == ElemInsert {
			inserts++
		}
	}
	if inserts == 0 {
		return "full bleed"
	}
	return "with inserts"
}

// pxRect is a rectangle in card pixel space.
type pxRect struct{ x, y, w, h float64 }

// Vertical bias for the callsign zone, used to spread the three proposals.
const (
	flipNatural = iota // largest quiet rect, wherever it is
	flipTop            // restrict the callsign to the top half
	flipBottom         // restrict the callsign to the bottom half
)

// charWidthFactor is a rough per-font average glyph width (in ems) for the
// heavy display faces, used to size the callsign so it fills its zone without
// overflowing. The frontend can refine with real measureText later.
func charWidthFactor(font string) float64 {
	switch font {
	case "Alfa Slab One", "Rye":
		return 0.82
	case "Baloo 2":
		return 0.72
	case "Oswald":
		return 0.55
	default: // Archivo Black, Lilita One
		return 0.72
	}
}

func buildTemplateBiased(plan proposalPlan, archetype, style, font string, flip int) Template {
	hero := plan.hero
	crop := cropForCard(hero)

	t := Template{
		Schema: SchemaVersion,
		Card:   Card{W: cardW, H: cardH, DPI: cardDPI, BleedPx: cardBleed},
		Hero:   Hero{Photo: hero.Path, Crop: crop},
	}

	// Place inserts first so the callsign zone can steer clear of them — the
	// call (and its trailing operator script) must never sit under an insert.
	var insertEls []Element
	var insertArea pxRect
	hasInserts := archetype != ArchetypeFullBleed && len(plan.inserts) > 0
	if hasInserts {
		insertEls, insertArea = placeInserts(plan.inserts, hero, archetype)
	}
	exclude := insertArea
	if exclude.w > 0 {
		exclude = pxRect{x: exclude.x - 24, y: exclude.y - 24, w: exclude.w + 48, h: exclude.h + 48}
	}

	zone, found := callsignZone(hero, crop, archetype, flip, exclude)
	if !found {
		// Busy photo everywhere: darken it and drop the call on a band clear
		// of the inserts.
		t.Hero.Scrim = &Scrim{Enabled: true, Color: "#0b1a2e", Opacity: 0.25}
		zone = fallbackZone(insertArea, flip)
	}

	zoneLuma := regionLuma(hero, crop, zone)
	params := adaptStyle(style, zoneLuma, hero.Warmth)

	call := plan.profile.Callsign
	cw := charWidthFactor(font)
	size := math.Min(zone.w*0.9/(cw*float64(max(len(call), 3))), zone.h*0.95)
	callW := cw * size * float64(len(call))
	callX := zone.x + (zone.w-callW)/2
	callY := zone.y + (zone.h-size)*0.3
	t.Elements = append(t.Elements, Element{
		Type: ElemCallsign, Text: "{profile.callsign}",
		Font: font, Size: math.Round(size),
		X: math.Round(clamp(callX, 40, cardW-80)), Y: math.Round(clamp(callY, 30, cardH-size-30)),
		StylePreset: style, StyleParams: params,
	})

	// Operator first name in script. Skipped when the profile's operator field
	// is just the callsign — rendering the call a second time in cursive is
	// redundant (the script slot is meant for the operator's name). Placed to
	// trail past the call's tail, or below it when an insert would clip it.
	if op := plan.profile.Operator; op != "" && !strings.EqualFold(op, call) {
		opSize := math.Round(size * 0.42)
		opX := callX + callW - size*0.12
		opY := callY + size*0.6
		if insertArea.w > 0 && opX+float64(len(op))*opSize*0.42 > insertArea.x {
			opX = callX + size*0.12 // tuck the signature under the call instead
			opY = callY + size*1.05
		}
		t.Elements = append(t.Elements, Element{
			Type: ElemOperator, Text: "{profile.operator_name}",
			Font: FontScriptDefault, Size: opSize, Rotate: -7,
			X:           math.Round(clamp(opX, 40, cardW-220)),
			Y:           math.Round(clamp(opY, 30, cardH-opSize-30)),
			StylePreset: "script_white",
			StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 2},
		})
	}

	infoY := callY + size*1.45
	if infoY > cardH-120 {
		infoY = callY - 60 // callsign near the bottom: info line goes above
	}
	t.Elements = append(t.Elements, Element{
		Type: ElemInfoLine,
		Text: "CQ Zone {profile.cq_zone} · ITU Zone {profile.itu_zone} · Loc. {profile.grid}",
		Font: FontInfoLine, Size: 29,
		X: math.Round(clamp(zone.x, 40, cardW-700)), Y: math.Round(clamp(infoY, 30, cardH-70)),
		StylePreset: "outlined_white",
		StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
	})

	// Operating conditions (rig / antenna), stacked next to the zones line.
	// Always added so the "Show on card" toggle exists, but hidden by default
	// when the profile has no rig/antenna yet (avoids an empty "Rig · Ant").
	txt, stationHidden := stationLineText(plan.profile)
	stationY := infoY + 40
	if infoY < callY { // zones line sits above the call → stack station above it
		stationY = infoY - 40
	}
	t.Elements = append(t.Elements, Element{
		Type: ElemInfoLine, Text: txt, Hidden: stationHidden,
		Font: FontInfoLine, Size: 26,
		X: math.Round(clamp(zone.x, 40, cardW-700)), Y: math.Round(clamp(stationY, 30, cardH-60)),
		StylePreset: "outlined_white",
		StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
	})

	occupied := []pxRect{zone}
	if hasInserts {
		t.Elements = append(t.Elements, insertEls...)
		occupied = append(occupied, insertArea)
	}

	box := placeQSOBox(plan.profile, zone, occupied)
	t.QSOBox = &box
	occupied = append(occupied, pxRect{x: box.X, y: box.Y, w: box.W, h: box.H})

	cx, cy := quietestCorner(hero, crop, occupied)
	t.Elements = append(t.Elements, Element{
		Type: ElemCountry, Flag: "auto", Label: "auto", Size: 30,
		X: math.Round(cx), Y: math.Round(cy),
	})
	return t
}

// stationLineText builds the rig/antenna line for the profile and whether it
// should start hidden. When the profile has neither, it returns the full
// template text with hidden=true so the editor still offers the toggle (it
// resolves cleanly once the user fills My rig / My antenna). Values fill in
// from {profile.*} at render time.
func stationLineText(p ProfileInfo) (text string, hidden bool) {
	switch {
	case p.Rig != "" && p.Antenna != "":
		return "Rig {qso.my_rig} · Ant {qso.my_antenna}", false
	case p.Rig != "":
		return "Rig {qso.my_rig}", false
	case p.Antenna != "":
		return "Ant {qso.my_antenna}", false
	default:
		return "Rig {qso.my_rig} · Ant {qso.my_antenna}", true
	}
}

// cropForCard crops the photo to the card aspect ratio, sliding the window
// toward the detail centroid so the interesting content stays visible.
func cropForCard(p PhotoAnalysis) Crop {
	targetAR := float64(cardW) / float64(cardH)
	w, h := float64(p.W), float64(p.H)
	cx, cy := detailCentroid(p)
	if w/h > targetAR { // too wide: crop horizontally around the centroid
		cw := h * targetAR
		x := clamp(cx*w-cw/2, 0, w-cw)
		return Crop{X: int(x), Y: 0, W: int(cw), H: int(h)}
	}
	ch := w / targetAR
	y := clamp(cy*h-ch/2, 0, h-ch)
	return Crop{X: 0, Y: int(y), W: int(w), H: int(ch)}
}

// detailCentroid returns the detail-weighted centroid in normalized [0,1]
// photo coordinates.
func detailCentroid(p PhotoAnalysis) (float64, float64) {
	var sx, sy, sw float64
	for r := 0; r < gridRows; r++ {
		for c := 0; c < gridCols; c++ {
			d := p.Cells[r][c].Detail
			sx += d * (float64(c) + 0.5) / gridCols
			sy += d * (float64(r) + 0.5) / gridRows
			sw += d
		}
	}
	if sw == 0 {
		return 0.5, 0.5
	}
	return sx / sw, sy / sw
}

// cellInsideCrop reports whether grid cell (r,c) of the photo lies fully
// inside the crop window.
func cellInsideCrop(p PhotoAnalysis, crop Crop, r, c int) bool {
	x0, x1 := c*p.W/gridCols, (c+1)*p.W/gridCols
	y0, y1 := r*p.H/gridRows, (r+1)*p.H/gridRows
	return x0 >= crop.X && x1 <= crop.X+crop.W && y0 >= crop.Y && y1 <= crop.Y+crop.H
}

// cellToCard converts a cell rectangle (photo grid space) to card pixels.
func cellToCard(p PhotoAnalysis, crop Crop, r cellRect) pxRect {
	x0 := float64(r.c0*p.W/gridCols-crop.X) * cardW / float64(crop.W)
	x1 := float64(r.c1*p.W/gridCols-crop.X) * cardW / float64(crop.W)
	y0 := float64(r.r0*p.H/gridRows-crop.Y) * cardH / float64(crop.H)
	y1 := float64(r.r1*p.H/gridRows-crop.Y) * cardH / float64(crop.H)
	return pxRect{
		x: clamp(x0, 0, cardW), y: clamp(y0, 0, cardH),
		w: clamp(x1, 0, cardW) - clamp(x0, 0, cardW),
		h: clamp(y1, 0, cardH) - clamp(y0, 0, cardH),
	}
}

// callsignZone finds the calm zone for the callsign: ≥55% card width,
// ≥22% card height, preferring the top/bottom third. flip restricts the
// search to a vertical half (so the three proposals land in distinct
// positions), and exclude (card-space, w>0 to apply) keeps the zone off the
// insert column.
func callsignZone(p PhotoAnalysis, crop Crop, archetype string, flip int, exclude pxRect) (pxRect, bool) {
	maxRow := gridRows
	if archetype == ArchetypeBottomStrip {
		maxRow = gridRows * 2 / 3 // the strip owns the bottom band
	}
	// Minimum size in cells, derived from the crop→card scale.
	minCols := int(math.Ceil(0.55 * gridCols))
	minRows := int(math.Ceil(0.22 * gridRows))
	// flip is a preference, not a mandate: try the requested half first, then
	// fall back to the natural best zone. Position variety must never push the
	// callsign onto a busy region just to differ from another proposal.
	tries := []int{flip}
	if flip != flipNatural {
		tries = append(tries, flipNatural)
	}
	for _, fl := range tries {
		for _, threshold := range []float64{quietDetail, quietRelaxed} {
			quiet := func(r, c int) bool {
				if r >= maxRow || !cellInsideCrop(p, crop, r, c) {
					return false
				}
				if fl == flipBottom && r < gridRows/2 {
					return false
				}
				if fl == flipTop && r >= gridRows/2 {
					return false
				}
				if exclude.w > 0 && cellCenterIn(p, crop, r, c, exclude) {
					return false
				}
				return p.Cells[r][c].Detail < threshold
			}
			if rect, ok := maximalQuietRect(quiet, minCols, minRows); ok {
				return cellToCard(p, crop, rect), true
			}
		}
	}
	return pxRect{}, false
}

// fallbackZone picks a callsign band when the photo has no quiet zone, kept
// clear of the inserts: opposite the strip for a wide bottom/top strip, beside
// a side column, honouring the flip preference for full-bleed cards.
func fallbackZone(insertArea pxRect, flip int) pxRect {
	z := pxRect{x: cardW * 0.05, y: cardH * 0.06, w: cardW * 0.9, h: cardH * 0.26}
	switch {
	case insertArea.w == 0: // no inserts
		if flip == flipBottom {
			z.y = cardH * 0.62
		}
	case insertArea.w > cardW*0.6: // wide strip → band on the opposite side
		if insertArea.y < cardH/2 {
			z.y = cardH * 0.62
		}
	case insertArea.x > cardW/2: // right column
		z.w = insertArea.x - 40 - z.x
		if flip == flipBottom {
			z.y = cardH * 0.62
		}
	default: // left column
		z.x = insertArea.x + insertArea.w + 40
		z.w = cardW - 40 - z.x
		if flip == flipBottom {
			z.y = cardH * 0.62
		}
	}
	if z.w < cardW*0.35 { // guard against a degenerate sliver
		z.x, z.w = cardW*0.05, cardW*0.9
	}
	return z
}

// cellCenterIn reports whether the card-space center of grid cell (r,c) falls
// inside rect.
func cellCenterIn(p PhotoAnalysis, crop Crop, r, c int, rect pxRect) bool {
	cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
	cx, cy := cell.x+cell.w/2, cell.y+cell.h/2
	return cx >= rect.x && cx <= rect.x+rect.w && cy >= rect.y && cy <= rect.y+rect.h
}

// regionLuma averages the luma of the photo cells under a card-space rect.
func regionLuma(p PhotoAnalysis, crop Crop, zone pxRect) float64 {
	var sum float64
	n := 0
	for r := 0; r < gridRows; r++ {
		for c := 0; c < gridCols; c++ {
			cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
			if cell.w <= 0 || cell.h <= 0 {
				continue
			}
			if cell.x+cell.w/2 >= zone.x && cell.x+cell.w/2 <= zone.x+zone.w &&
				cell.y+cell.h/2 >= zone.y && cell.y+cell.h/2 <= zone.y+zone.h {
				sum += p.Cells[r][c].Luma
				n++
			}
		}
	}
	if n == 0 {
		return p.AvgLuma
	}
	return sum / float64(n)
}

// adaptStyle tunes the preset defaults to the photo: bright placement zones
// get the dark outline + subdued halo, dark zones a stronger light halo and
// lighter outline; warm photos deepen the gold gradient.
func adaptStyle(preset string, zoneLuma, warmth float64) *StyleParams {
	def := Presets[preset].Defaults
	p := def // copy
	if preset == "gel_gold" && warmth > 0.08 {
		p.Gradient = []string{"#FFD83A", "#FFB000", "#E07E00"} // sunset ambers
	}
	if def.Halo != nil {
		halo := *def.Halo
		if zoneLuma > 0.55 {
			p.OutlineColor = "#2a3f5c"
			halo.Opacity = 0.25
		} else {
			p.OutlineColor = "#1d2e44"
			halo.Opacity = 0.6
			halo.Blur = 8
		}
		p.Halo = &halo
	}
	return &p
}

// placeInserts lays the remaining photos out as inserts and returns them
// plus the card area they occupy.
func placeInserts(inserts []PhotoAnalysis, hero PhotoAnalysis, archetype string) ([]Element, pxRect) {
	const border = 14
	if archetype == ArchetypeBottomStrip {
		n := min(len(inserts), 4)
		gap, margin := 24.0, 64.0
		w := math.Min(420, (cardW-2*margin-float64(n-1)*gap)/float64(n))
		var els []Element
		x := margin
		for i := 0; i < n; i++ {
			ph := inserts[i]
			h := w * float64(ph.H) / float64(ph.W)
			rot := 1.5
			if i%2 == 0 {
				rot = -1.5
			}
			els = append(els, Element{
				Type: ElemInsert, Photo: ph.Path,
				X: math.Round(x), Y: math.Round(cardH - h - margin),
				W: math.Round(w), BorderPx: border, Rotate: rot, Shadow: true,
			})
			x += w + gap
		}
		return els, pxRect{x: margin, y: cardH - 380, w: cardW - 2*margin, h: 380 - margin}
	}

	// Side column: pick the calmer half of the hero photo. The insert width
	// shrinks as needed so up to 4 framed photos stack down the side.
	n := min(len(inserts), 4)
	gap, margin := 20.0, 70.0
	availH := float64(cardH) - 2*margin - float64(n-1)*gap
	var arSum float64
	for i := 0; i < n; i++ {
		arSum += float64(inserts[i].H) / float64(inserts[i].W)
	}
	w := 400.0
	if arSum > 0 {
		w = clamp(availH/arSum, 240, 400) // fit all n vertically, within sane bounds
	}
	left := halfDetail(hero, true) < halfDetail(hero, false)
	x := cardW - w - margin
	if left {
		x = margin
	}
	var els []Element
	y := margin
	for i := 0; i < n; i++ {
		ph := inserts[i]
		h := w * float64(ph.H) / float64(ph.W)
		if y+h > cardH-margin {
			break
		}
		rot := 1.2
		if i%2 == 0 {
			rot = -1.5
		}
		els = append(els, Element{
			Type: ElemInsert, Photo: ph.Path,
			X: math.Round(x), Y: math.Round(y),
			W: w, BorderPx: border, Rotate: rot, Shadow: true,
		})
		y += h + gap
	}
	return els, pxRect{x: x - 10, y: 0, w: w + 20, h: float64(cardH)}
}

// halfDetail sums cell detail over the left or right half of the photo.
func halfDetail(p PhotoAnalysis, left bool) float64 {
	var sum float64
	c0, c1 := gridCols/2, gridCols
	if left {
		c0, c1 = 0, gridCols/2
	}
	for r := 0; r < gridRows; r++ {
		for c := c0; c < c1; c++ {
			sum += p.Cells[r][c].Detail
		}
	}
	return sum
}

// placeQSOBox puts the confirmation box in the vertical half opposite the
// callsign, on the side not occupied by inserts.
func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
	box := QSOBox{
		Enabled: true, W: 760, H: 220,
		BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
		Title:  "Confirming QSO with {qso.callsign}",
		// The frequency belongs on a card: "20m" says which band the QSL is for,
		// the megahertz say where the contact actually was — and a card is often
		// the only record the OTHER station keeps. Removable in the designer for
		// anyone who wants the band alone.
		Fields: []string{"qso_date", "time_on", "band", "freq", "mode", "rst_sent"},
		// PSE/TNX stamp (auto: TNX if a QSL was received, else PSE QSL) leads the
		// message. {qso.pse_tnx} is a normal token — it can be moved to its own
		// element anywhere on the card in the designer.
		Footer: "{qso.pse_tnx}   {qso.qsl_msg}",
	}
	box.Y = cardH - box.H - 110
	if zone.y+zone.h/2 > cardH/2 {
		box.Y = 90
	}
	box.X = 64
	if overlapsAny(pxRect{x: box.X, y: box.Y, w: box.W, h: box.H}, occupied) {
		box.X = cardW - box.W - 64
	}
	return box
}

// quietestCorner returns the top-left position for the country block in the
// calmest unoccupied corner.
func quietestCorner(p PhotoAnalysis, crop Crop, occupied []pxRect) (float64, float64) {
	const margin, blockW, blockH = 64.0, 320.0, 60.0
	corners := []pxRect{
		{x: margin, y: cardH - margin - blockH, w: blockW, h: blockH}, // bottom-left
		{x: cardW - margin - blockW, y: cardH - margin - blockH, w: blockW, h: blockH},
		{x: margin, y: margin, w: blockW, h: blockH},
		{x: cardW - margin - blockW, y: margin, w: blockW, h: blockH},
	}
	bestX, bestY := corners[0].x, corners[0].y
	bestDetail := math.Inf(1)
	for _, corner := range corners {
		if overlapsAny(corner, occupied) {
			continue
		}
		d := regionDetail(p, crop, corner)
		if d < bestDetail {
			bestDetail = d
			bestX, bestY = corner.x, corner.y
		}
	}
	return bestX, bestY
}

// regionDetail averages cell detail under a card-space rect.
func regionDetail(p PhotoAnalysis, crop Crop, zone pxRect) float64 {
	var sum float64
	n := 0
	for r := 0; r < gridRows; r++ {
		for c := 0; c < gridCols; c++ {
			cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
			if cell.w <= 0 || cell.h <= 0 {
				continue
			}
			if cell.x+cell.w/2 >= zone.x && cell.x+cell.w/2 <= zone.x+zone.w &&
				cell.y+cell.h/2 >= zone.y && cell.y+cell.h/2 <= zone.y+zone.h {
				sum += p.Cells[r][c].Detail
				n++
			}
		}
	}
	if n == 0 {
		return 1
	}
	return sum / float64(n)
}

func overlapsAny(a pxRect, rects []pxRect) bool {
	for _, b := range rects {
		if a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y {
			return true
		}
	}
	return false
}

func clamp(v, lo, hi float64) float64 {
	if hi < lo {
		return lo
	}
	return math.Max(lo, math.Min(hi, v))
}
