"""Tests for `IngestDaemonSQL` — the DuckDB writer.

Focus on the SQL-tricky parts: replaceable-event semantics (kind-0 /
kind-10002), COALESCE preserve on repeat deliveries, full-list rebuild for
NIP-65, and tag DELETE+INSERT idempotency.

Every ingested event is built via `signed_event` (real BIP-340 sig over
the canonical NIP-01 hash), so it passes `_verify_event` and reaches the
SQL layer under test.
"""

import json
import os
import tempfile

import pytest

from ingest_sql import COVERAGE_SINCE_MARGIN_S, IngestDaemonSQL
from specs._helpers import new_keypair, new_pub, signed_event


@pytest.fixture()
def daemon():
    """Fresh in-process IngestDaemonSQL backed by a temp DuckDB file.

    We don't start the WS thread (`daemon.start()`) — these tests poke the
    storage layer directly.
    """
    with tempfile.TemporaryDirectory() as td:
        d = IngestDaemonSQL(os.path.join(td, "test.duckdb"))
        yield d


@pytest.fixture()
def author():
    """Single shared signer used by most tests."""
    return new_keypair()


def _evt(prv, *, kind: int = 1, content: str = "hello",
         created_at: int = 1700000000, tags: list | None = None) -> dict:
    return signed_event(prv, kind, content=content,
                        created_at=created_at, tags=tags or [])


# ---- bootstrap -----------------------------------------------------------------


def test_schema_bootstrap_creates_all_tables(daemon):
    rows = daemon._conn.execute("SHOW TABLES").fetchall()
    names = sorted(r[0] for r in rows)
    # `addressables` is a view over event_tags; SHOW TABLES lists it too.
    assert names == [
        "addressables", "event_seen_on", "event_tags", "events", "fake_events",
        "live_coverage", "profiles", "pubkey_relays", "pubkeys",
        "relays", "tracked_pubkeys",
    ]


def test_schema_bootstrap_is_idempotent(daemon):
    # Running bootstrap twice must not raise (the daemon constructor already
    # ran it once; this re-applies on the same connection).
    daemon._ensure_schema()


# ---- basic event ingest --------------------------------------------------------


def test_ingest_inserts_event_pubkey_relay_and_seen_on(daemon, author):
    prv, pk = author
    evt = _evt(prv)
    daemon._ingest_event(evt, "wss://r1")

    row = daemon._conn.execute(
        "SELECT id, kind, content, pubkey FROM events"
    ).fetchall()
    assert row == [(evt["id"], 1, "hello", pk)]

    assert daemon._conn.execute(
        "SELECT hex FROM pubkeys"
    ).fetchall() == [(pk,)]
    assert daemon._conn.execute(
        "SELECT url FROM relays"
    ).fetchall() == [("wss://r1",)]
    assert daemon._conn.execute(
        "SELECT * FROM event_seen_on"
    ).fetchall() == [(evt["id"], "wss://r1")]


def test_e_tag_creates_a_stub_event(daemon, author):
    prv, _ = author
    stub_id = "c" * 64  # any 64-hex string — the stub is created verbatim
    evt = _evt(prv, tags=[["e", stub_id]])
    daemon._ingest_event(evt, "wss://r1")
    rows = daemon._conn.execute(
        "SELECT id, kind, content FROM events ORDER BY id"
    ).fetchall()
    # The stub has only id; everything else NULL — kind=NULL flags it as
    # "referenced but not fetched" per the schema convention.
    assert (evt["id"], 1, "hello") in rows
    assert (stub_id, None, None) in rows


def test_p_tag_creates_a_pubkey(daemon, author):
    prv, pk = author
    other = new_pub()
    daemon._ingest_event(_evt(prv, tags=[["p", other]]), "wss://r1")
    pks = sorted(r[0] for r in daemon._conn.execute(
        "SELECT hex FROM pubkeys"
    ).fetchall())
    assert pks == sorted([pk, other])


def test_a_tag_inserts_addressable(daemon, author):
    prv, _ = author
    daemon._ingest_event(
        _evt(prv, tags=[["a", "30023:abcd:slug"]]), "wss://r1",
    )
    rows = daemon._conn.execute(
        "SELECT value, kind, pubkey, d FROM addressables"
    ).fetchall()
    assert rows == [("30023:abcd:slug", 30023, "abcd", "slug")]


# ---- COALESCE preserve on repeat delivery -------------------------------------


def test_repeat_delivery_within_cache_window_short_circuits(daemon, author):
    """Cache hit: the second delivery of the same event id (any relay) is
    skipped wholesale — no DB touch, no second `event_seen_on` row. Trade-off
    accepted by design: 70+ reader threads burst-deliver the same event from
    different relays, and rebuilding tags + walking through INSERTs for every
    duplicate dominated CPU. We lose accuracy of `last_seen_per_relay` for
    very-recently-seen events, gain throughput.
    """
    prv, _ = author
    evt = _evt(prv, content="first")
    daemon._ingest_event(evt, "wss://r1")
    daemon._ingest_event(evt, "wss://r2")  # same event id → cache hit

    content = daemon._conn.execute(
        "SELECT content FROM events"
    ).fetchone()[0]
    assert content == "first"

    relays = sorted(r[0] for r in daemon._conn.execute(
        "SELECT relay_url FROM event_seen_on"
    ).fetchall())
    assert relays == ["wss://r1"]


def test_repeat_delivery_after_cache_eviction_registers_seen_on(daemon, author):
    """Cache miss + DB hit (post-eviction or post-restart): second-layer
    defense kicks in inside `_upsert_event_sql`. Tags are NOT rebuilt
    (content-addressed by id, would be a no-op churn) but the new relay
    DOES land in `event_seen_on` — preserves long-term accuracy of
    `last_seen_per_relay` for events outside the hot cache window.
    """
    prv, _ = author
    evt = _evt(prv, content="first")
    daemon._ingest_event(evt, "wss://r1")
    # Simulate cache eviction without waiting for 10K events to flush it.
    daemon._recent_ids.clear()
    daemon._recent_ids_set.clear()
    daemon._ingest_event(evt, "wss://r2")

    content = daemon._conn.execute(
        "SELECT content FROM events"
    ).fetchone()[0]
    assert content == "first"

    relays = sorted(r[0] for r in daemon._conn.execute(
        "SELECT relay_url FROM event_seen_on"
    ).fetchall())
    assert relays == ["wss://r1", "wss://r2"]


def test_real_event_fills_in_a_pre_existing_stub(daemon, author):
    """An e-tag creates a stub for some other event's id; when that real
    event later arrives, its actual fields should replace the NULLs."""
    prv_a, _ = author
    prv_b, pk_b = new_keypair()
    # Build the real event first so we know its id.
    real_evt = signed_event(prv_b, 7, content="like", created_at=1700000050)

    # First a kind-1 with an e-tag referencing the future real event.
    daemon._ingest_event(_evt(prv_a, tags=[["e", real_evt["id"]]]), "wss://r1")
    # Then the real event itself — fills in the stub.
    daemon._ingest_event(real_evt, "wss://r2")

    row = daemon._conn.execute(
        "SELECT kind, content, pubkey FROM events WHERE id = ?",
        [real_evt["id"]],
    ).fetchone()
    assert row == (7, "like", pk_b)


# ---- tag rebuild idempotency ---------------------------------------------------


def test_reingest_rebuilds_tags_to_same_count(daemon, author):
    prv, _ = author
    other = new_pub()
    evt = _evt(prv, tags=[["p", other], ["t", "foo"]])
    # Same payload twice — the second pass hits the DB second-layer dedup
    # (cache cleared first to bypass the short-circuit and exercise the
    # SELECT-existing branch in `_upsert_event_sql`).
    daemon._ingest_event(evt, "wss://r1")
    daemon._recent_ids.clear()
    daemon._recent_ids_set.clear()
    daemon._ingest_event(evt, "wss://r1")

    n_tags = daemon._conn.execute(
        "SELECT count(DISTINCT tag_idx) FROM event_tags WHERE event_id = ?",
        [evt["id"]],
    ).fetchone()[0]
    n_elems = daemon._conn.execute(
        "SELECT count(*) FROM event_tags WHERE event_id = ?", [evt["id"]],
    ).fetchone()[0]
    assert n_tags == 2
    assert n_elems == 4  # 2 elements per tag (name at elem_idx=0 + value at 1)


# ---- profile (kind-0) replaceable-event ---------------------------------------


def _kind0(prv, ts: int, meta: dict) -> dict:
    return signed_event(prv, 0, content=json.dumps(meta), created_at=ts)


def test_profile_first_ingest_writes_fields(daemon, author):
    prv, _ = author
    daemon._ingest_event(
        _kind0(prv, 1700000100,
               {"name": "alice", "displayName": "Alice", "nip05": "a@b.com"}),
        "wss://r1",
    )
    row = daemon._conn.execute(
        "SELECT name, display_name, nip05, created_at FROM profiles"
    ).fetchone()
    # `displayName` camelCase is the NIP-01 alias for display_name.
    assert row == ("alice", "Alice", "a@b.com", 1700000100)


def test_profile_older_kind0_does_not_overwrite(daemon, author):
    prv, _ = author
    daemon._ingest_event(_kind0(prv, 1700000100, {"name": "current"}), "r1")
    daemon._ingest_event(_kind0(prv, 1600000000, {"name": "OLD"}), "r1")

    name = daemon._conn.execute("SELECT name FROM profiles").fetchone()[0]
    assert name == "current"


def test_profile_newer_kind0_overwrites(daemon, author):
    prv, _ = author
    daemon._ingest_event(_kind0(prv, 1700000100, {"name": "old"}), "r1")
    daemon._ingest_event(_kind0(prv, 1800000000, {"name": "new"}), "r1")

    name = daemon._conn.execute("SELECT name FROM profiles").fetchone()[0]
    assert name == "new"


# ---- NIP-65 (kind-10002) -------------------------------------------------------


def _kind10002(prv, ts: int, tags: list) -> dict:
    return signed_event(prv, 10002, content="", created_at=ts, tags=tags)


def test_nip65_writes_publishes_and_reads(daemon, author):
    prv, pk = author
    daemon._ingest_event(
        _kind10002(prv, 1700000200, [
            ["r", "wss://both.example"],                  # no marker = both
            ["r", "wss://write.example", "write"],
            ["r", "wss://read.example", "read"],
        ]),
        "wss://r1",
    )
    pubs = sorted(r[0] for r in daemon._conn.execute(
        "SELECT relay_url FROM pubkey_relays WHERE pubkey = ? AND write", [pk],
    ).fetchall())
    reads = sorted(r[0] for r in daemon._conn.execute(
        "SELECT relay_url FROM pubkey_relays WHERE pubkey = ? AND read", [pk],
    ).fetchall())
    assert pubs == ["wss://both.example", "wss://write.example"]
    assert reads == ["wss://both.example", "wss://read.example"]


def test_nip65_old_event_is_skipped(daemon, author):
    prv, pk = author
    daemon._ingest_event(
        _kind10002(prv, 1700000200, [["r", "wss://current.example"]]),
        "wss://r1",
    )
    daemon._ingest_event(
        _kind10002(prv, 1500000000, [["r", "wss://stale.example"]]),
        "wss://r1",
    )
    pubs = sorted(r[0] for r in daemon._conn.execute(
        "SELECT relay_url FROM pubkey_relays WHERE pubkey = ? AND write", [pk],
    ).fetchall())
    # Stale event must not have replaced the list.
    assert pubs == ["wss://current.example"]


def test_nip65_newer_event_replaces_full_list(daemon, author):
    # NIP-65 is replaceable in full — a newer event blows away the old list.
    prv, pk = author
    daemon._ingest_event(
        _kind10002(prv, 1700000200, [
            ["r", "wss://old1.example"], ["r", "wss://old2.example"],
        ]),
        "wss://r1",
    )
    daemon._ingest_event(
        _kind10002(prv, 1800000000, [["r", "wss://only.example"]]),
        "wss://r1",
    )
    pubs = sorted(r[0] for r in daemon._conn.execute(
        "SELECT relay_url FROM pubkey_relays WHERE pubkey = ? AND write", [pk],
    ).fetchall())
    assert pubs == ["wss://only.example"]


def test_nip65_updates_pubkey_relay_list_ts(daemon, author):
    prv, pk = author
    daemon._ingest_event(
        _kind10002(prv, 1700000200, [["r", "wss://x.example"]]),
        "wss://r1",
    )
    ts = daemon._conn.execute(
        "SELECT relay_list_ts FROM pubkeys WHERE hex = ?", [pk],
    ).fetchone()[0]
    assert ts == 1700000200


# ---- helpers used by orchestrators --------------------------------------------


def test_follows_of_returns_p_tags_of_latest_kind3(daemon):
    prv_user, user = new_keypair()
    b, c, d = new_pub(), new_pub(), new_pub()
    # Older kind-3: 1 follow.
    daemon._ingest_event(
        signed_event(prv_user, 3, content="", created_at=1700000100,
                     tags=[["p", b]]),
        "wss://r1",
    )
    # Newer kind-3: 2 follows. Latest wins, both should appear.
    daemon._ingest_event(
        signed_event(prv_user, 3, content="", created_at=1700000200,
                     tags=[["p", c], ["p", d]]),
        "wss://r1",
    )
    follows = sorted(daemon.follows_of(user))
    assert follows == sorted([c, d])


def test_follows_of_returns_empty_when_no_kind3(daemon):
    assert daemon.follows_of("a" * 64) == []


def test_events_count_per_pubkey_returns_zero_for_missing(daemon, author):
    prv, pk = author
    other = new_pub()
    daemon._ingest_event(_evt(prv), "wss://r1")
    counts = daemon.events_count_per_pubkey([pk, other])
    assert counts == {pk: 1, other: 0}


def test_events_count_per_pubkey_empty_input(daemon):
    assert daemon.events_count_per_pubkey([]) == {}


# ---- relay status persistence -------------------------------------------------


def test_persist_status_updates_only_existing_rows(daemon):
    # Pre-register a relay (the daemon's reconcile only sees registered ones).
    daemon._conn.execute(
        "INSERT INTO relays (url, active) VALUES (?, true)", ["wss://r1"],
    )
    daemon._persist_status([
        {"url": "wss://r1", "status": "ok", "last_error": None},
        # Unknown URL — must NOT be inserted (status only updates rows that
        # already exist in `relays`; the registry is managed externally).
        {"url": "wss://unknown", "status": "error", "last_error": "boom"},
    ])
    rows = daemon._conn.execute(
        "SELECT url, status, last_error FROM relays ORDER BY url"
    ).fetchall()
    assert len(rows) == 1
    assert rows[0][0] == "wss://r1"
    assert rows[0][1] == "ok"
    assert rows[0][2] is None


def test_read_active_relay_urls_skips_inactive(daemon):
    daemon._conn.execute(
        "INSERT INTO relays (url, active) VALUES (?, true)", ["wss://on"],
    )
    daemon._conn.execute(
        "INSERT INTO relays (url, active) VALUES (?, false)", ["wss://off"],
    )
    # NULL active should default to true (COALESCE in the query).
    daemon._conn.execute(
        "INSERT INTO relays (url) VALUES (?)", ["wss://null"],
    )
    urls = daemon._read_active_relay_urls()
    assert urls == {"wss://on", "wss://null"}


# ---- last_seen_for ------------------------------------------------------------


def test_last_seen_for_returns_max_created_at(daemon):
    prv, pk = new_keypair()
    for ts in (1000, 2000, 1500):
        daemon._ingest_event(
            signed_event(prv, 1, content="", created_at=ts), "wss://r1",
        )
    # Same pubkey, different relay — must NOT count toward _last_seen_for(r1).
    daemon._ingest_event(
        signed_event(prv, 1, content="", created_at=9999), "wss://r2",
    )
    other = new_pub()
    assert daemon._last_seen_for(pk, "wss://r1") == 2000
    assert daemon._last_seen_for(pk, "wss://r2") == 9999
    assert daemon._last_seen_for(pk, "wss://nope") is None
    assert daemon._last_seen_for(other, "wss://r1") is None


# ---- live-coverage watermark --------------------------------------------------


def test_coverage_since_falls_back_to_last_seen_before_first_coverage(daemon):
    # No coverage row yet → `since` is the last event seen + 1 (old behavior),
    # so the very first subscribe doesn't re-walk all of history.
    prv, pk = new_keypair()
    daemon._ingest_event(
        signed_event(prv, 1, content="", created_at=5000), "wss://r1",
    )
    assert daemon._coverage_since(pk, "wss://r1", "out", "secondary") == 5001
    # Nothing seen at all → None (full backfill).
    assert daemon._coverage_since(new_pub(), "wss://r1", "out", "secondary") is None


def test_coverage_since_prefers_watermark_minus_margin(daemon):
    # Once a coverage watermark exists it wins over the last event, and the
    # safety margin is subtracted so a restart re-pulls a small overlap.
    prv, pk = new_keypair()
    daemon._ingest_event(
        signed_event(prv, 1, content="", created_at=5000), "wss://r1",
    )
    daemon._conn.execute(
        "INSERT INTO live_coverage (pubkey, relay_url, direction, covered_through) "
        "VALUES (?, ?, ?, ?)",
        [pk, "wss://r1", "out", 9_000],
    )
    assert daemon._coverage_since(pk, "wss://r1", "out", "secondary") == (
        9_000 - COVERAGE_SINCE_MARGIN_S
    )
    # A different direction has no row → still falls back to last event.
    assert daemon._coverage_since(pk, "wss://r1", "in", "primary") == 5001


def test_coverage_flush_persists_only_caught_up_subs(daemon):
    # The flush stamps covered_through=now for whatever the pool reports as
    # caught up — one batched write — and skips when nothing is caught up.
    class _StubPool:
        def __init__(self, triples):
            self._triples = triples

        def caught_up_triples(self):
            return self._triples

    pk_a, pk_b = new_pub(), new_pub()
    daemon.live_subs = _StubPool([
        (pk_a, "wss://r1", "out"),
        (pk_a, "wss://r1", "in"),
        (pk_b, "wss://r2", "out"),
    ])
    stamped = daemon._coverage_flush(reason="test")
    assert stamped == 3
    cov = daemon._coverage_read(pk_a, "wss://r1", "out")
    assert cov is not None and cov > 1_700_000_000  # plausible unix-seconds now

    # Nothing caught up → no write, returns 0.
    daemon.live_subs = _StubPool([])
    assert daemon._coverage_flush(reason="test") == 0
