package main

import (
	"context"
	"encoding/json"
	"path/filepath"
	"testing"
	"time"

	"hamlog/internal/db"
	"hamlog/internal/qso"
	"hamlog/internal/syncfolder"
)

// syncTestApp is an App with nothing but a logbook: applySyncRecord touches the
// repository and the log file, and no more. Settings are nil, which is exactly
// the state it must survive anyway — the loop runs before the active profile is
// known.
func syncTestApp(t *testing.T) *App {
	t.Helper()
	conn, err := db.Open(filepath.Join(t.TempDir(), "log.db"))
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { conn.Close() })
	return &App{ctx: context.Background(), qso: qso.NewRepo(conn)}
}

func syncRecord(t *testing.T, op syncfolder.Op, uid string, q qso.QSO) syncfolder.Record {
	t.Helper()
	rec := syncfolder.Record{V: syncfolder.FormatVersion, Op: op, UID: uid, At: time.Now().UTC(), By: "other"}
	if op != syncfolder.OpDelete {
		b, err := json.Marshal(q)
		if err != nil {
			t.Fatalf("marshal: %v", err)
		}
		rec.Data = b
	}
	return rec
}

func countQSOs(t *testing.T, a *App) int64 {
	t.Helper()
	n, err := a.qso.Count(a.ctx)
	if err != nil {
		t.Fatalf("count: %v", err)
	}
	return n
}

// The ordinary life of a contact made on the other PC: it arrives, it is
// corrected, it is deleted. One row throughout — a sync that inserted a second
// copy on the edit would be worse than no sync at all.
func TestSyncRecordAddThenUpdateThenDelete(t *testing.T) {
	a := syncTestApp(t)
	when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
	uid := syncfolder.NewUID()

	if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, uid, qso.QSO{
		Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Ann",
	})) {
		t.Fatal("the added contact was not applied")
	}
	if n := countQSOs(t, a); n != 1 {
		t.Fatalf("log holds %d QSOs after an add, want 1", n)
	}
	// Stamped with the identity from the record — without this every later
	// change naming it would look like a contact never seen before.
	id, found, err := a.qso.IDBySyncUID(a.ctx, uid)
	if err != nil || !found {
		t.Fatalf("IDBySyncUID = (%d,%v,%v), want the new row", id, found, err)
	}

	if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
		Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Annette",
	})) {
		t.Fatal("the correction was not applied")
	}
	if n := countQSOs(t, a); n != 1 {
		t.Fatalf("log holds %d QSOs after an edit, want 1 — the edit was logged as a second contact", n)
	}
	got, err := a.qso.GetByID(a.ctx, id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.Name != "Annette" {
		t.Errorf("name = %q after the correction, want %q", got.Name, "Annette")
	}

	if !a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
		t.Fatal("the tombstone was not applied")
	}
	if n := countQSOs(t, a); n != 0 {
		t.Fatalf("log holds %d QSOs after the deletion, want 0", n)
	}
	// A tombstone that arrives twice — both peers relayed it, or the file was
	// re-read after a restore — must be quiet, not an error and not a change.
	if a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
		t.Error("a repeated tombstone reported a change; the grid would refresh for nothing, for ever")
	}
}

// The case the whole no-backfill decision rests on.
//
// Both PCs already hold the operator's 123 000 contacts — seeded from one
// database or one ADIF — and neither row carries an identity, because nothing
// has touched them since. The day the shack PC corrects a 2019 QSO it stamps an
// identity and sends an update naming it; the laptop has never seen that
// identity. Inserting would give the operator two copies of a contact they
// merely corrected, and would do it for every edit for ever.
func TestSyncRecordAdoptsTheContactAlreadyInTheLog(t *testing.T) {
	a := syncTestApp(t)
	when := time.Date(2019, 3, 2, 9, 15, 0, 0, time.UTC)

	// The copy that was already here, with no identity.
	localID, err := a.qso.Add(a.ctx, qso.QSO{
		Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Ann",
	})
	if err != nil {
		t.Fatalf("seed: %v", err)
	}

	uid := syncfolder.NewUID() // minted on the OTHER machine
	if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
		Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Annette", QTH: "Bristol",
	})) {
		t.Fatal("the correction was not applied")
	}
	if n := countQSOs(t, a); n != 1 {
		t.Fatalf("log holds %d QSOs, want 1 — the contact was duplicated instead of recognised", n)
	}
	got, err := a.qso.GetByID(a.ctx, localID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.Name != "Annette" || got.QTH != "Bristol" {
		t.Errorf("the row already here was not corrected: name=%q qth=%q", got.Name, got.QTH)
	}
	// And it now carries the identity, so the NEXT change goes straight to it
	// without needing the contact-matching fallback again.
	if id, found, _ := a.qso.IDBySyncUID(a.ctx, uid); !found || id != localID {
		t.Errorf("IDBySyncUID = (%d,%v), want the row already here (%d)", id, found, localID)
	}
}

// A different contact must NOT be adopted. The matching is deliberately narrow
// — same callsign, same minute, same band, same mode — and this pins that a
// second contact with the same station on another band stays a second contact.
func TestSyncRecordDoesNotAdoptADifferentContact(t *testing.T) {
	a := syncTestApp(t)
	when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
	if _, err := a.qso.Add(a.ctx, qso.QSO{Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "CW"}); err != nil {
		t.Fatalf("seed: %v", err)
	}
	if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{
		Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW",
	})) {
		t.Fatal("the contact was not applied")
	}
	if n := countQSOs(t, a); n != 2 {
		t.Fatalf("log holds %d QSOs, want 2 — a contact on another band was swallowed as a duplicate", n)
	}
}

// A record with no callsign is not a contact. It reaches here from a file
// truncated by a sync client mid-upload, or from a future format read
// optimistically, and inserting it would put a blank row in the log.
func TestSyncRecordIgnoresAContactWithNoCallsign(t *testing.T) {
	a := syncTestApp(t)
	if a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{Band: "20m", Mode: "CW"})) {
		t.Error("a record with no callsign was applied")
	}
	if n := countQSOs(t, a); n != 0 {
		t.Fatalf("log holds %d QSOs, want 0", n)
	}
}
