package qslcard

import (
	"image"
	"image/color"
	"testing"
)

// gridFrom builds a quiet() callback from a 20×32 rune mask ('.' = quiet).
func gridFrom(rows []string) func(r, c int) bool {
	return func(r, c int) bool { return rows[r][c] == '.' }
}

func maskRows(quietRect cellRect) []string {
	rows := make([]string, gridRows)
	for r := 0; r < gridRows; r++ {
		row := make([]byte, gridCols)
		for c := 0; c < gridCols; c++ {
			if r >= quietRect.r0 && r < quietRect.r1 && c >= quietRect.c0 && c < quietRect.c1 {
				row[c] = '.'
			} else {
				row[c] = '#'
			}
		}
		rows[r] = string(row)
	}
	return rows
}

func TestMaximalQuietRect(t *testing.T) {
	minCols := 18 // ceil(0.55 × 32), same as callsignZone
	minRows := 5  // ceil(0.22 × 20)
	tests := []struct {
		name  string
		quiet cellRect // the quiet region planted in the mask
		want  cellRect
		found bool
	}{
		{"whole grid quiet", cellRect{0, 0, gridRows, gridCols}, cellRect{0, 0, gridRows, gridCols}, true},
		{"top band", cellRect{0, 0, 6, gridCols}, cellRect{0, 0, 6, gridCols}, true},
		{"bottom band", cellRect{14, 4, 20, 26}, cellRect{14, 4, 20, 26}, true},
		{"too narrow", cellRect{0, 0, 20, 10}, cellRect{}, false},
		{"too short", cellRect{0, 0, 3, gridCols}, cellRect{}, false},
		{"nothing quiet", cellRect{0, 0, 0, 0}, cellRect{}, false},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, ok := maximalQuietRect(gridFrom(maskRows(tc.quiet)), minCols, minRows)
			if ok != tc.found {
				t.Fatalf("found=%v, want %v (rect %+v)", ok, tc.found, got)
			}
			if ok && got != tc.want {
				t.Fatalf("rect %+v, want %+v", got, tc.want)
			}
		})
	}
}

func TestMaximalQuietRectPrefersEdges(t *testing.T) {
	// Two equal candidates: a middle band and a top band. The top band must
	// win via the position bonus.
	rows := make([]string, gridRows)
	for r := range rows {
		switch {
		case r < 5, r >= 8 && r < 13:
			rows[r] = "................................"
		default:
			rows[r] = "################################"
		}
	}
	got, ok := maximalQuietRect(gridFrom(rows), 18, 5)
	if !ok {
		t.Fatal("no rect found")
	}
	if got.r0 != 0 {
		t.Fatalf("expected the top band to win, got %+v", got)
	}
}

// syntheticPhoto paints a flat bright sky over a noisy dark ground.
// skyShare is the fraction of the height that is sky.
func syntheticPhoto(w, h int, skyShare float64) image.Image {
	img := image.NewRGBA(image.Rect(0, 0, w, h))
	skyH := int(float64(h) * skyShare)
	for y := 0; y < h; y++ {
		for x := 0; x < w; x++ {
			if y < skyH {
				img.Set(x, y, color.RGBA{R: 170, G: 200, B: 235, A: 255}) // flat sky
			} else if (x+y)%2 == 0 { // per-pixel checkerboard = max detail
				img.Set(x, y, color.RGBA{R: 20, G: 60, B: 20, A: 255})
			} else {
				img.Set(x, y, color.RGBA{R: 120, G: 160, B: 90, A: 255})
			}
		}
	}
	return img
}

func TestAnalyzeImage(t *testing.T) {
	p := AnalyzeImage(syntheticPhoto(800, 514, 0.5), "sky.jpg")
	if p.W != 800 || p.H != 514 {
		t.Fatalf("size %dx%d, want 800x514", p.W, p.H)
	}
	// Sky cells (top rows) must read quiet and bright; ground cells busy.
	sky := p.Cells[1][16]
	ground := p.Cells[gridRows-2][16]
	if sky.Detail >= 0.15 {
		t.Fatalf("sky detail %.3f, want < 0.15", sky.Detail)
	}
	if sky.Luma <= 0.55 {
		t.Fatalf("sky luma %.3f, want > 0.55", sky.Luma)
	}
	if ground.Detail <= 0.3 {
		t.Fatalf("ground detail %.3f, want > 0.3", ground.Detail)
	}
	if p.Quietness <= 0.3 || p.Quietness >= 0.8 {
		t.Fatalf("quietness %.3f, want roughly half", p.Quietness)
	}
}

func TestProposeCallsignInFlatSky(t *testing.T) {
	hero := AnalyzeImage(syntheticPhoto(1600, 1029, 0.45), "hero.jpg")
	out, err := HeuristicEngine{}.Propose([]PhotoAnalysis{hero},
		ProfileInfo{Callsign: "XV9Q", Operator: "Greg", Grid: "OK30"})
	if err != nil {
		t.Fatalf("propose: %v", err)
	}
	if len(out) != 3 {
		t.Fatalf("got %d proposals, want 3", len(out))
	}
	for i, tmpl := range out {
		call := findElement(t, tmpl, ElemCallsign)
		// The flat sky is the top 45%: the callsign must land there.
		if call.Y+call.Size > float64(tmpl.Card.H)*0.5 {
			t.Errorf("proposal %d: callsign at y=%g size=%g not in the sky band", i, call.Y, call.Size)
		}
		// Bright placement zone → dark outline variant (gel presets only; the
		// classic-white-outline preset has no halo and keeps its white edge).
		if call.StyleParams != nil && call.StyleParams.Halo != nil &&
			call.StyleParams.OutlineColor != "#2a3f5c" {
			t.Errorf("proposal %d: outline %q, want dark #2a3f5c on bright zone", i, call.StyleParams.OutlineColor)
		}
	}
}

func TestProposeThreeDistinctWithInserts(t *testing.T) {
	hero := AnalyzeImage(syntheticPhoto(1600, 1029, 0.5), "hero.jpg")
	in1 := AnalyzeImage(syntheticPhoto(400, 300, 0.2), "in1.jpg")
	in2 := AnalyzeImage(syntheticPhoto(400, 300, 0.1), "in2.jpg")
	out, err := HeuristicEngine{}.Propose([]PhotoAnalysis{hero, in1, in2},
		ProfileInfo{Callsign: "F4XYZ", Operator: "Greg", Grid: "IN88", CQZone: 14, ITUZone: 27})
	if err != nil {
		t.Fatalf("propose: %v", err)
	}
	if len(out) != 3 {
		t.Fatalf("got %d proposals, want 3", len(out))
	}
	// Hero must be the big photo, inserts the small ones.
	for i, tmpl := range out {
		if tmpl.Hero.Photo != "hero.jpg" {
			t.Errorf("proposal %d: hero is %q", i, tmpl.Hero.Photo)
		}
		if err := Validate(tmpl, nil); err != nil {
			t.Errorf("proposal %d invalid: %v", i, err)
		}
	}
	// The three proposals must showcase distinct call looks: each style is a
	// known preset, and no two proposals share the same font+style pairing.
	seenStyle := map[string]bool{}
	seenFont := map[string]bool{}
	for i, tmpl := range out {
		call := findElement(t, tmpl, ElemCallsign)
		if _, ok := Presets[call.StylePreset]; !ok {
			t.Errorf("proposal %d: unknown call style %q", i, call.StylePreset)
		}
		if seenStyle[call.StylePreset] {
			t.Errorf("proposal %d reuses call style %q", i, call.StylePreset)
		}
		if seenFont[call.Font] {
			t.Errorf("proposal %d reuses call font %q", i, call.Font)
		}
		seenStyle[call.StylePreset] = true
		seenFont[call.Font] = true
	}
	// At least one proposal uses the insert photos.
	if countInserts(out[0]) == 0 && countInserts(out[1]) == 0 && countInserts(out[2]) == 0 {
		t.Error("no proposal uses the insert photos")
	}
}

func findElement(t *testing.T, tmpl Template, typ string) Element {
	t.Helper()
	for _, e := range tmpl.Elements {
		if e.Type == typ {
			return e
		}
	}
	t.Fatalf("no %s element in template %q", typ, tmpl.Name)
	return Element{}
}

func countInserts(tmpl Template) int {
	n := 0
	for _, e := range tmpl.Elements {
		if e.Type == ElemInsert {
			n++
		}
	}
	return n
}
