// Package gridcache is the long-term callsign→grid store behind grid chasing.
//
// Its own SQLite file, not a table in the settings database: that one sits
// wherever the operator put it, often a synchronised folder, and this rewrites
// itself every minute. Deleting the file costs a few days of listening.
//
// A callsign has one grid and the newest report wins — a stale locator reads as
// a square already worked, which is worse than none.
package gridcache

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"sync"
	"time"

	_ "modernc.org/sqlite"
)

// Retention bounds a store that would otherwise only ever grow. Two years is
// chosen to be far longer than any propagation interest and short enough that a
// reassigned callsign eventually stops carrying its previous holder's square —
// the one way this cache can be actively wrong rather than merely empty.
const Retention = 2 * 365 * 24 * time.Hour

// FlushEvery is the batch interval. Long enough that a burst of reports costs
// one transaction, short enough that a crash loses a minute of learning.
const FlushEvery = 60 * time.Second

// Sources a locator can come from.
const (
	SourceDecode = "decode" // a CQ this station's own receiver decoded
	SourceMQTT   = "mqtt"   // a PSK Reporter report
)

// Entry is one locator and where it came from.
type Entry struct {
	Grid   string
	Source string
}

type Store struct {
	db *sql.DB

	mu    sync.Mutex
	dirty map[string]Entry // call → what to write

	stop     chan struct{}
	stopOnce sync.Once
	wg       sync.WaitGroup

	logf func(string, ...any)
}

// Open creates or opens the store at path and prunes what has aged out.
func Open(path string, logf func(string, ...any)) (*Store, error) {
	if logf == nil {
		logf = func(string, ...any) {}
	}
	// WAL so a flush never blocks a read, and a busy timeout because the flush
	// goroutine and the startup load can overlap on a slow disk.
	db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
	if err != nil {
		return nil, fmt.Errorf("gridcache: open %s: %w", path, err)
	}
	if _, err := db.Exec("CREATE TABLE IF NOT EXISTS grids (" +
		"call TEXT PRIMARY KEY, grid TEXT NOT NULL, updated_at INTEGER NOT NULL, " +
		"source TEXT NOT NULL DEFAULT '')"); err != nil {
		db.Close()
		return nil, fmt.Errorf("gridcache: schema: %w", err)
	}
	// A file written before the column existed keeps its rows; this fails
	// harmlessly when the column is already there.
	db.Exec("ALTER TABLE grids ADD COLUMN source TEXT NOT NULL DEFAULT ''")
	s := &Store{db: db, dirty: map[string]Entry{}, stop: make(chan struct{}), logf: logf}
	if n, err := s.prune(); err != nil {
		s.logf("gridcache: prune failed: %v", err)
	} else if n > 0 {
		s.logf("gridcache: pruned %d locators not heard in %d days", n, int(Retention.Hours()/24))
	}
	return s, nil
}

func (s *Store) prune() (int64, error) {
	cut := time.Now().Add(-Retention).Unix()
	res, err := s.db.Exec(`DELETE FROM grids WHERE updated_at < ?`, cut)
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

// LoadAll returns every stored locator, for seeding the in-memory map at
// startup. One query and one map build — the point of the whole package is that
// nothing afterwards has to ask the database anything.
func (s *Store) LoadAll() (map[string]string, error) {
	rows, err := s.db.Query(`SELECT call, grid FROM grids`)
	if err != nil {
		return nil, fmt.Errorf("gridcache: load: %w", err)
	}
	defer rows.Close()
	out := make(map[string]string, 4096)
	for rows.Next() {
		var call, grid string
		if err := rows.Scan(&call, &grid); err != nil {
			return nil, err
		}
		out[call] = grid
	}
	return out, rows.Err()
}

// Put queues a locator for writing. Callers pass only what CHANGED — an
// unchanged report is the common case by a wide margin and must not reach here,
// or the batch would carry the whole feed instead of the news in it.
func (s *Store) Put(call, grid, source string) {
	call = strings.ToUpper(strings.TrimSpace(call))
	grid = strings.TrimSpace(grid)
	if call == "" || grid == "" {
		return
	}
	s.mu.Lock()
	s.dirty[call] = Entry{Grid: grid, Source: source}
	s.mu.Unlock()
}

// Start runs the flush loop until Close.
func (s *Store) Start(ctx context.Context) {
	s.wg.Add(1)
	go func() {
		defer s.wg.Done()
		t := time.NewTicker(FlushEvery)
		defer t.Stop()
		for {
			select {
			case <-s.stop:
				return
			case <-ctx.Done():
				return
			case <-t.C:
				if err := s.Flush(); err != nil {
					s.logf("gridcache: flush failed: %v", err)
				}
			}
		}
	}()
}

// Flush writes the pending batch in one transaction. Safe to call with nothing
// pending, which is most of the time on a quiet band.
func (s *Store) Flush() error {
	s.mu.Lock()
	if len(s.dirty) == 0 {
		s.mu.Unlock()
		return nil
	}
	batch := s.dirty
	s.dirty = map[string]Entry{}
	s.mu.Unlock()

	tx, err := s.db.Begin()
	if err != nil {
		s.requeue(batch)
		return err
	}
	st, err := tx.Prepare(`INSERT INTO grids (call, grid, updated_at, source) VALUES (?, ?, ?, ?)
		ON CONFLICT(call) DO UPDATE SET grid = excluded.grid, updated_at = excluded.updated_at,
		source = excluded.source`)
	if err != nil {
		tx.Rollback()
		s.requeue(batch)
		return err
	}
	defer st.Close()
	now := time.Now().Unix()
	for call, e := range batch {
		if _, err := st.Exec(call, e.Grid, now, e.Source); err != nil {
			tx.Rollback()
			s.requeue(batch)
			return err
		}
	}
	if err := tx.Commit(); err != nil {
		s.requeue(batch)
		return err
	}
	// Logged because a batch that never reaches the disk looks exactly like one
	// that does: the locators are in memory either way until the next restart,
	// which is the one moment the difference shows.
	s.logf("gridcache: wrote %d locators", len(batch))
	return nil
}

// requeue puts a failed batch back, without overwriting anything learnt while it
// was in flight — the newer value is the right one.
func (s *Store) requeue(batch map[string]Entry) {
	s.mu.Lock()
	defer s.mu.Unlock()
	for call, e := range batch {
		if _, newer := s.dirty[call]; !newer {
			s.dirty[call] = e
		}
	}
}

// Pending reports how many locators are waiting to be written.
func (s *Store) Pending() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.dirty)
}

// Close stops the loop and writes whatever is pending. A restart is the moment
// the cache is most valuable, so losing the last minute of learning to a clean
// shutdown would be a poor trade.
func (s *Store) Close() error {
	s.stopOnce.Do(func() { close(s.stop) })
	s.wg.Wait()
	err := s.Flush()
	if cerr := s.db.Close(); err == nil {
		err = cerr
	}
	return err
}
