package qso

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

	"hamlog/internal/db"
)

// openRepo gives a migrated, empty logbook on disk.
func openRepo(t *testing.T) *Repo {
	t.Helper()
	conn, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { conn.Close() })
	return NewRepo(conn)
}

func addQSO(t *testing.T, r *Repo, call string, when time.Time) int64 {
	t.Helper()
	id, err := r.Add(context.Background(), QSO{
		Callsign: call, QSODate: when, Band: "20m", Mode: "CW",
	})
	if err != nil {
		t.Fatalf("insert %s: %v", call, err)
	}
	return id
}

// A QSO written and read back must come out whole. selectCols and scanQSO are
// two hand-maintained lists that must line up column for column, and adding
// sync_uid touched both — a drift there fails every read at runtime, which no
// compiler catches.
func TestSyncUIDRoundTrips(t *testing.T) {
	r := openRepo(t)
	ctx := context.Background()
	id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))

	got, err := r.GetByID(ctx, id)
	if err != nil {
		t.Fatalf("read back: %v", err)
	}
	if got.Callsign != "M0ABC" {
		t.Fatalf("read back %+v", got)
	}
	if got.SyncUID != "" {
		t.Errorf("a fresh QSO has identity %q — it should have none until sync is switched on", got.SyncUID)
	}

	if err := r.SetSyncUID(ctx, id, "abc123"); err != nil {
		t.Fatalf("SetSyncUID: %v", err)
	}
	got, _ = r.GetByID(ctx, id)
	if got.SyncUID != "abc123" {
		t.Errorf("SyncUID = %q after stamping", got.SyncUID)
	}
}

// An ordinary edit must NEVER clobber the identity. sync_uid is deliberately
// outside columnList for this reason: another machine that has already seen the
// contact addresses it by that id, and losing it makes the same QSO arrive
// again as a new one.
func TestAnEditDoesNotClobberTheIdentity(t *testing.T) {
	r := openRepo(t)
	ctx := context.Background()
	id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
	if err := r.SetSyncUID(ctx, id, "keepme"); err != nil {
		t.Fatal(err)
	}

	q, _ := r.GetByID(ctx, id)
	q.Name = "Edited"
	q.SyncUID = "" // exactly what a caller that knows nothing about sync sends
	if err := r.Update(ctx, q); err != nil {
		t.Fatalf("update: %v", err)
	}

	got, _ := r.GetByID(ctx, id)
	if got.Name != "Edited" {
		t.Errorf("the edit did not take: %+v", got)
	}
	if got.SyncUID != "keepme" {
		t.Errorf("SyncUID = %q — an edit wiped the sync identity", got.SyncUID)
	}
}

// The lookup an incoming change performs, once per record.
func TestIDBySyncUID(t *testing.T) {
	r := openRepo(t)
	ctx := context.Background()
	id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
	_ = r.SetSyncUID(ctx, id, "u-1")

	got, ok, err := r.IDBySyncUID(ctx, "u-1")
	if err != nil || !ok || got != id {
		t.Fatalf("IDBySyncUID = (%d,%v,%v), want (%d,true,nil)", got, ok, err, id)
	}
	if _, ok, _ := r.IDBySyncUID(ctx, "nope"); ok {
		t.Error("an unknown identity was resolved")
	}
	// An empty id must never match the rows that have none.
	if _, ok, _ := r.IDBySyncUID(ctx, ""); ok {
		t.Error("the empty identity matched a row — every un-stamped QSO would be that row")
	}
}

// Two machines can already hold the SAME old contact — the second was seeded by
// copying the database or importing an ADIF — with different row ids and no
// identity on either. The day one of them edits it, it stamps an identity and
// sends a change naming it; the other has never seen that identity and would
// insert a duplicate. Matching on the contact itself recognises it instead.
func TestIDByDedupeKeyRecognisesTheSameContact(t *testing.T) {
	r := openRepo(t)
	ctx := context.Background()
	when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
	id := addQSO(t, r, "M0ABC", when)

	minute := when.UTC().Format("2006-01-02T15:04")
	gotID, uid, found, err := r.IDByDedupeKey(ctx, "M0ABC", minute, "20m", "CW")
	if err != nil || !found {
		t.Fatalf("IDByDedupeKey = (%d,%q,%v,%v), want it found", gotID, uid, found, err)
	}
	if gotID != id {
		t.Errorf("resolved to id %d, want %d", gotID, id)
	}
	if uid != "" {
		t.Errorf("uid = %q, want empty until something stamps it", uid)
	}

	// A contact this machine does not have must NOT match something else.
	if _, _, found, _ := r.IDByDedupeKey(ctx, "M0ABC", minute, "40m", "CW"); found {
		t.Error("a different band matched — the key must be all four parts")
	}
	if _, _, found, _ := r.IDByDedupeKey(ctx, "M0XYZ", minute, "20m", "CW"); found {
		t.Error("a different callsign matched")
	}
	// An empty key must never match anything.
	if _, _, found, _ := r.IDByDedupeKey(ctx, "", "", "", ""); found {
		t.Error("an empty key matched a row")
	}
}

// A service with no status column of its own — HAMLOG.online, whose field the
// ADIF standard does not define — finds its backlog by the ABSENCE of an extras
// key. The LIKE has to match the key, and only the key: a QSO whose comment
// happens to contain the same text must not be mistaken for one already sent.
func TestListMissingExtraMatchesTheKeyNotItsText(t *testing.T) {
	r := openRepo(t)
	ctx := context.Background()

	sent := addQSO(t, r, "F4BPO", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
	notSent, err := r.Add(ctx, QSO{
		Callsign: "F1TRF", QSODate: time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC),
		Band: "40m", Mode: "CW",
		Comment: "mentions APP_OPSLOG_HAMLOG_SENT in the text",
	})
	if err != nil {
		t.Fatal(err)
	}
	if err := r.SetExtra(ctx, sent, "APP_OPSLOG_HAMLOG_SENT", "20260823"); err != nil {
		t.Fatal(err)
	}

	got, err := r.ListMissingExtra(ctx, "APP_OPSLOG_HAMLOG_SENT")
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 || got[0].ID != notSent {
		ids := make([]int64, len(got))
		for i, q := range got {
			ids[i] = q.ID
		}
		t.Fatalf("backlog = %v, want just %d (the one never sent)", ids, notSent)
	}
}
