package main

import (
	"fmt"
	"path/filepath"
	"sync"
	"testing"

	"hamlog/internal/gridcache"
)

// The grid cache used to drop EVERYTHING at its ceiling. That was survivable
// while the only feed was this station's own decodes, which never reached it.
// Fed by anything larger it is a cliff: every locator in the cluster list
// disappears at once, periodically, and the operator sees the column empty
// itself for no reason.
//
// Rotation keeps the previous generation, so a full cache costs the older half
// and nothing more.
func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) {
	a := &App{}

	// Fill exactly one generation.
	for i := 0; i < decodeGridsCap; i++ {
		a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
	}
	if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
		t.Fatalf("first entry lost before any rotation: %q", got)
	}
	if a.decodeGridsOld != nil {
		t.Fatal("rotated early — the cap is the ceiling of ONE generation")
	}

	// One more entry rotates.
	a.rememberDecodeGrid("NEWCALL", "IO91", gridcache.SourceDecode)
	if a.decodeGridsOld == nil {
		t.Fatal("did not rotate at the cap")
	}
	if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
		t.Errorf("the entry that caused the rotation was lost: %q", got)
	}
	// The whole previous generation is still readable — this is the point.
	if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
		t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got)
	}
	if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" {
		t.Errorf("previous generation incomplete: %q", got)
	}

	// A second rotation is what finally retires the oldest half.
	for i := 0; i < decodeGridsCap; i++ {
		a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20", gridcache.SourceDecode)
	}
	if got := a.lookupDecodeGrid("CALL000000"); got != "" {
		t.Errorf("the cache is unbounded: %q survived two rotations", got)
	}
	if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
		t.Errorf("an entry one generation old was retired too early: %q", got)
	}
}

// Callsigns are normalised on the way in AND on the way out, or a spot for
// "f4bpo" would miss a grid learnt as "F4BPO".
func TestDecodeGridCaseAndBlanks(t *testing.T) {
	a := &App{}
	a.rememberDecodeGrid(" f4bpo ", "JN36", gridcache.SourceDecode)
	if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
		t.Errorf("lookup of the upper-case form failed: %q", got)
	}
	a.rememberDecodeGrid("", "JN36", gridcache.SourceDecode)
	a.rememberDecodeGrid("K1ABC", "", gridcache.SourceDecode)
	if got := a.lookupDecodeGrid("K1ABC"); got != "" {
		t.Errorf("stored an empty grid: %q", got)
	}
}

// The write path runs on the decode goroutine while the cluster status builder
// reads. Rotation swaps the map headers, so an unguarded read is a data race
// rather than a stale value.
//
// This build is CGO-free, so -race is not available here; the test exercises the
// interleaving and would fault on a concurrent map access even without it.
func TestDecodeGridConcurrentAccess(t *testing.T) {
	a := &App{}
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		for i := 0; i < 20000; i++ {
			a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31", gridcache.SourceDecode)
		}
	}()
	go func() {
		defer wg.Done()
		for i := 0; i < 20000; i++ {
			_ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i))
		}
	}()
	wg.Wait()
}

// Only a CHANGE may reach the write batch.
//
// The feeds repeat themselves — the same station is reported by dozens of
// receivers a minute — so queueing every report would put the whole stream in
// the batch instead of the news in it, and turn a cache into a write amplifier.
func TestOnlyChangesAreQueued(t *testing.T) {
	st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
	if err != nil {
		t.Fatal(err)
	}
	defer st.Close()
	a := &App{gridStore: st}

	a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
	if n := st.Pending(); n != 1 {
		t.Fatalf("a new locator queued %d writes, want 1", n)
	}
	if err := st.Flush(); err != nil {
		t.Fatal(err)
	}

	// The same report, a hundred times over, is not news.
	for i := 0; i < 100; i++ {
		a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
	}
	if n := st.Pending(); n != 0 {
		t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n)
	}

	// A station that moved is.
	a.rememberDecodeGrid("F4BPO", "KP30", gridcache.SourceDecode)
	if n := st.Pending(); n != 1 {
		t.Errorf("a changed locator queued %d writes, want 1", n)
	}
	if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" {
		t.Errorf("the map kept the old locator: %q", got)
	}
}

// Rotation must be OFF while a store is attached: it would discard callsigns the
// database still holds, and the lookup would then miss something we know.
func TestNoRotationWhilePersisting(t *testing.T) {
	st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
	if err != nil {
		t.Fatal(err)
	}
	defer st.Close()
	a := &App{gridStore: st}

	for i := 0; i < decodeGridsCap+10; i++ {
		a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
	}
	if a.decodeGridsOld != nil {
		t.Error("rotated while a store was attached — locators the database holds would go missing")
	}
	if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
		t.Errorf("the first entry was dropped: %q", got)
	}
}
