"""Tests for the two-tier tracked-pubkey model + push notifications.

Covers:
- `register_primary` state machine (new / already / promoted)
- `_register_secondary_unlocked` returning whether it actually inserted
- `_is_tracked_unlocked` / `_is_primary_unlocked` discrimination
- `pubkey_stats` snapshot for untracked / secondary / primary roles
- `_last_seen_either` taking both directions into account
- `_p_tag_pubkeys_for_author` returning distinct values, excluding self
- `notify_callback` fired with the right URIs from the right call sites
- `LiveSubscriptionPool.count_for` per-pubkey counter

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 IngestDaemonSQL
from specs._helpers import new_keypair, new_pub, signed_event


# ---- fixtures ------------------------------------------------------------------


@pytest.fixture()
def daemon():
    with tempfile.TemporaryDirectory() as td:
        d = IngestDaemonSQL(os.path.join(td, "test.duckdb"))
        yield d


@pytest.fixture()
def captured_notifications(daemon):
    """Hook the daemon's notify_callback into a list. Tests assert on its
    contents (or order) without going through the async flusher."""
    sink: list[str] = []
    daemon.notify_callback = sink.append
    return sink


@pytest.fixture()
def keys():
    """Four fresh BIP-340 keypairs labelled A/B/C/D.

    Returns a namespace where ``keys.a`` / ``keys.A`` give the (prv, pub) pair
    and the bare pub hex respectively. The original tests used fixed
    ``PK_A = "a"*64`` constants; with signature verification on, every event
    must be signed by a real keypair, so we materialise four signers per test.
    """
    class _Keys:
        pass
    k = _Keys()
    for name in ("a", "b", "c", "d"):
        prv, pub = new_keypair()
        setattr(k, name, (prv, pub))
        setattr(k, name.upper(), pub)
    return k


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


# ---- register_primary state machine --------------------------------------------


def test_register_primary_new_inserts_with_null_last_scan(daemon, keys):
    assert daemon.register_primary(keys.A, 500) == "subscribed_new"
    row = daemon._conn.execute(
        "SELECT role, last_scan_at, limit_per_relay FROM tracked_pubkeys WHERE hex=?",
        [keys.A],
    ).fetchone()
    # last_scan_at NULL is the signal the auto-tracker uses to claim the row.
    assert row == ("primary", None, 500)


def test_register_primary_repeat_is_noop(daemon, keys):
    daemon.register_primary(keys.A, 500)
    # Simulate the auto-tracker claim + persist cycle so the row has a real
    # `last_scan_at`. `_persist_tracked_pubkey` uses CAS against the
    # in-progress sentinel set by `_claim_pending_secondaries`.
    daemon._claim_pending_secondaries(10)
    daemon._persist_tracked_pubkey(keys.A, ["wss://r1"], 500, role="primary")
    assert daemon.register_primary(keys.A, 999) == "already"
    row = daemon._conn.execute(
        "SELECT last_scan_at, limit_per_relay, productive_relays "
        "FROM tracked_pubkeys WHERE hex=?",
        [keys.A],
    ).fetchone()
    assert row[0] is not None
    assert row[1] == 500
    assert list(row[2]) == ["wss://r1"]


def test_register_primary_promotes_secondary_and_resets_scan(daemon, keys):
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.A)
    # Pretend the auto-tracker already ran on it as a secondary.
    daemon._claim_pending_secondaries(10)
    daemon._persist_tracked_pubkey(keys.A, ["wss://r1"], 500, role="secondary")

    assert daemon.register_primary(keys.A, 800) == "promoted"
    row = daemon._conn.execute(
        "SELECT role, last_scan_at, limit_per_relay FROM tracked_pubkeys WHERE hex=?",
        [keys.A],
    ).fetchone()
    # Promotion must reset last_scan_at so the auto-tracker rescans.
    assert row[0] == "primary"
    assert row[1] is None
    assert row[2] == 800


def test_track_run_does_not_promote_secondary(daemon, keys, monkeypatch):
    """Regression: auto-tracker must not promote a secondary to primary
    when running discovery on it. Earlier `track_pubkey.run` always
    persisted as 'primary', which exploded the frontier (every secondary
    got promoted, then expanded its own p-tags into new secondaries).

    Smoke test of the contract: `_persist_tracked_pubkey` must be called
    with role='secondary' when run() is invoked with role='secondary'.
    """
    import track_pubkey

    # Stub the discovery loop — we only care about the role passed downstream.
    monkeypatch.setattr(daemon, "conns", {})
    monkeypatch.setattr(daemon, "lock", __import__("threading").RLock())
    monkeypatch.setattr(daemon, "_reconcile_after_backfill", lambda: None)

    persist_calls: list[dict] = []
    orig_persist = daemon._persist_tracked_pubkey

    def spy(hex_key, productive, limit, role="primary"):
        persist_calls.append({"hex": hex_key, "role": role})
        return orig_persist(hex_key, productive, limit, role)

    monkeypatch.setattr(daemon, "_persist_tracked_pubkey", spy)

    # Pre-seed the secondary so live_subs.remove_pubkey + persist see a row.
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.A)

    track_pubkey.run(daemon, keys.A, limit=100, role="secondary")

    assert len(persist_calls) == 1
    assert persist_calls[0]["role"] == "secondary"
    # Verify the row in storage is still secondary.
    row = daemon._conn.execute(
        "SELECT role FROM tracked_pubkeys WHERE hex=?", [keys.A],
    ).fetchone()
    assert row[0] == "secondary"


def test_track_run_secondary_skips_p_tag_expansion(daemon, keys, monkeypatch):
    """Discovery on a secondary must NOT register the secondary's p-tags
    as further secondaries (1-hop frontier rule)."""
    import track_pubkey

    monkeypatch.setattr(daemon, "conns", {})
    monkeypatch.setattr(daemon, "lock", __import__("threading").RLock())
    monkeypatch.setattr(daemon, "_reconcile_after_backfill", lambda: None)

    # Pre-seed: keys.A is a secondary with an existing event mentioning keys.B.
    prv_a, _ = keys.a
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.A)
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B]]),
        "wss://r1",
    )
    # _ingest_event for an event whose author is secondary doesn't register
    # keys.B (only primary authors expand). Confirm baseline.
    rows = daemon._conn.execute(
        "SELECT hex FROM tracked_pubkeys ORDER BY hex"
    ).fetchall()
    assert [r[0] for r in rows] == [keys.A]

    # Now run discovery on keys.A as secondary — it must not retroactively
    # register keys.B as a secondary.
    track_pubkey.run(daemon, keys.A, limit=100, role="secondary")

    rows = daemon._conn.execute(
        "SELECT hex FROM tracked_pubkeys ORDER BY hex"
    ).fetchall()
    assert [r[0] for r in rows] == [keys.A]


def test_register_primary_rejects_bad_limit(daemon, keys):
    # The tool guards limit > 0 itself; the daemon helper just runs the SQL
    # — a 0/-1 limit_per_relay isn't fatal here, but the contract is that
    # callers normalize. We document the behavior with a regression test:
    # any non-zero int is accepted and persisted verbatim.
    assert daemon.register_primary(keys.A, 1) == "subscribed_new"
    row = daemon._conn.execute(
        "SELECT limit_per_relay FROM tracked_pubkeys WHERE hex=?", [keys.A],
    ).fetchone()
    assert row[0] == 1


# ---- _register_secondary_unlocked ----------------------------------------------


def test_register_secondary_returns_true_on_first_insert(daemon, keys):
    with daemon._sql_lock:
        assert daemon._register_secondary_unlocked(keys.A) is True


def test_register_secondary_returns_false_if_present(daemon, keys):
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.A)
        assert daemon._register_secondary_unlocked(keys.A) is False


def test_register_secondary_does_not_demote_existing_primary(daemon, keys):
    daemon.register_primary(keys.A, 500)
    with daemon._sql_lock:
        # Primary is already there — secondary insert is a no-op (returns
        # False) and role stays primary.
        assert daemon._register_secondary_unlocked(keys.A) is False
    role = daemon._conn.execute(
        "SELECT role FROM tracked_pubkeys WHERE hex=?", [keys.A],
    ).fetchone()[0]
    assert role == "primary"


# ---- _is_tracked / _is_primary -------------------------------------------------


def test_is_tracked_and_is_primary_predicates(daemon, keys):
    daemon.register_primary(keys.A, 500)
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.B)

        assert daemon._is_tracked_unlocked(keys.A) is True
        assert daemon._is_primary_unlocked(keys.A) is True

        assert daemon._is_tracked_unlocked(keys.B) is True
        assert daemon._is_primary_unlocked(keys.B) is False

        assert daemon._is_tracked_unlocked(keys.C) is False
        assert daemon._is_primary_unlocked(keys.C) is False


# ---- ingest auto-secondary registration ----------------------------------------


def test_primary_event_with_p_tags_creates_secondaries(daemon, keys):
    prv_a, _ = keys.a
    daemon.register_primary(keys.A, 500)
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B], ["p", keys.C], ["p", keys.A]]),
        "wss://r1",
    )
    rows = daemon._conn.execute(
        "SELECT hex, role FROM tracked_pubkeys ORDER BY hex"
    ).fetchall()
    # keys.A primary, keys.B and keys.C as secondary; self-tag is not duplicated.
    assert sorted(rows) == sorted([
        (keys.A, "primary"),
        (keys.B, "secondary"),
        (keys.C, "secondary"),
    ])


def test_secondary_event_with_p_tags_does_not_expand(daemon, keys):
    """Secondary's p-tags don't seed new secondaries — the 1-hop frontier."""
    prv_a, _ = keys.a
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.A)
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B], ["p", keys.C]]),
        "wss://r1",
    )
    rows = daemon._conn.execute(
        "SELECT hex, role FROM tracked_pubkeys ORDER BY hex"
    ).fetchall()
    assert rows == [(keys.A, "secondary")]


def test_untracked_event_does_not_register_anything(daemon, keys):
    """Author isn't tracked → p-tags are ignored even if they hit somebody."""
    prv_a, _ = keys.a
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B]]),
        "wss://r1",
    )
    rows = daemon._conn.execute("SELECT count(*) FROM tracked_pubkeys").fetchone()
    assert rows[0] == 0


# ---- pubkey_stats --------------------------------------------------------------


def test_pubkey_stats_untracked(daemon, keys):
    s = daemon.pubkey_stats(keys.A)
    assert s == {"hex": keys.A, "tracked": False}


def test_pubkey_stats_secondary_minimal(daemon, keys):
    """Freshly registered secondary, no events yet — last_scan_at NULL,
    productive_relays empty, no profile, all counters zero."""
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.A)
    s = daemon.pubkey_stats(keys.A)
    assert s["tracked"] is True
    assert s["role"] == "secondary"
    assert s["last_scan_at"] is None
    assert s["limit_per_relay"] is None
    assert s["productive_relays"] == []
    assert s["profile"] is None
    assert s["events"] == {"authored": 0, "referencing": 0, "kinds_top": []}
    assert s["last_seen_per_relay"] == []
    assert s["secondaries_from_me"] == 0


def test_pubkey_stats_primary_with_events_and_profile(daemon, keys):
    prv_a, _ = keys.a
    prv_b, _ = keys.b
    daemon.register_primary(keys.A, 500)
    # Profile (kind 0) — replaceable event with NIP-01 metadata content.
    daemon._ingest_event(
        signed_event(prv_a, 0, content=json.dumps({
            "name": "alice", "display_name": "Alice", "picture": "http://x",
        }), created_at=1000),
        "wss://r1",
    )
    # keys.A authors a kind-1 mentioning B and C → spawns secondaries.
    daemon._ingest_event(
        _evt(prv_a, kind=1, ts=2000,
             tags=[["p", keys.B], ["p", keys.C]]),
        "wss://r1",
    )
    # B authors a kind-1 mentioning A — incoming for A.
    daemon._ingest_event(
        _evt(prv_b, kind=1, ts=3000, tags=[["p", keys.A]]),
        "wss://r2",
    )

    # Use the claim+persist cycle so productive_relays land on the row.
    daemon._claim_pending_secondaries(10)
    daemon._persist_tracked_pubkey(
        keys.A, ["wss://r1", "wss://r2"], 500, role="primary",
    )

    s = daemon.pubkey_stats(keys.A)
    assert s["role"] == "primary"
    assert s["productive_relays"] == ["wss://r1", "wss://r2"]
    assert s["limit_per_relay"] == 500
    assert s["profile"] == {
        "name": "alice", "display_name": "Alice",
        "about": None, "picture": "http://x", "nip05": None,
    }
    assert s["events"]["authored"] == 2  # kind 0 + kind 1
    assert s["events"]["referencing"] == 1
    # kinds_top is sorted by count DESC then arbitrary tie-break — assert by content.
    kinds = {row["kind"]: row["count"] for row in s["events"]["kinds_top"]}
    assert kinds == {0: 1, 1: 1}
    relays = {row["relay"]: row["ts"] for row in s["last_seen_per_relay"]}
    # r1 saw kind 0 (1000) + kind 1 (2000) by A → max=2000.
    # r2 saw the incoming event at 3000.
    assert relays == {"wss://r1": 2000, "wss://r2": 3000}
    assert s["secondaries_from_me"] == 2  # B and C


def test_pubkey_stats_referencing_excludes_self_tags(daemon, keys):
    """An event author p-tagging themselves shouldn't bump `referencing`
    (we already count it as authored). Mirrors what a real client does
    with `p` self-mentions in long replies."""
    prv_a, _ = keys.a
    daemon.register_primary(keys.A, 500)
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.A]]),
        "wss://r1",
    )
    s = daemon.pubkey_stats(keys.A)
    assert s["events"]["authored"] == 1
    assert s["events"]["referencing"] == 0


def test_pubkey_stats_secondaries_from_me_only_counts_secondaries(daemon, keys):
    """`secondaries_from_me` is bounded to pubkeys whose current role is
    'secondary'. A primary that happens to be p-tagged by another primary
    doesn't double-count."""
    prv_a, _ = keys.a
    daemon.register_primary(keys.A, 500)
    daemon.register_primary(keys.B, 500)  # B is also primary
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B], ["p", keys.C]]),
        "wss://r1",
    )
    s = daemon.pubkey_stats(keys.A)
    # B is primary → not counted; C is secondary → counted.
    assert s["secondaries_from_me"] == 1


# ---- _last_seen_either ---------------------------------------------------------


def test_last_seen_either_takes_both_directions(daemon, keys):
    prv_a, _ = keys.a
    prv_b, _ = keys.b
    daemon._ingest_event(_evt(prv_a, ts=1000), "wss://r1")
    daemon._ingest_event(
        _evt(prv_b, ts=2000, tags=[["p", keys.A]]),
        "wss://r1",
    )
    daemon._ingest_event(
        _evt(prv_b, ts=3000, tags=[["p", keys.A]]),
        "wss://r2",
    )
    # authored only sees its own author events on the same relay.
    assert daemon._last_seen_for(keys.A, "wss://r1") == 1000
    # _last_seen_either picks max across authored OR p-tag-referenced.
    assert daemon._last_seen_either(keys.A, "wss://r1") == 2000
    assert daemon._last_seen_either(keys.A, "wss://r2") == 3000
    assert daemon._last_seen_either(keys.A, "wss://nope") is None


# ---- _p_tag_pubkeys_for_author -------------------------------------------------


def test_p_tag_pubkeys_for_author_distinct_excludes_self(daemon, keys):
    prv_a, _ = keys.a
    daemon._ingest_event(
        _evt(prv_a, ts=1000,
             tags=[["p", keys.B], ["p", keys.C], ["p", keys.A]]),
        "wss://r1",
    )
    daemon._ingest_event(
        _evt(prv_a, ts=2000, tags=[["p", keys.B]]),  # duplicate B
        "wss://r1",
    )
    out = sorted(daemon._p_tag_pubkeys_for_author(keys.A))
    assert out == sorted([keys.B, keys.C])


def test_p_tag_pubkeys_for_author_empty_for_unknown(daemon, keys):
    assert daemon._p_tag_pubkeys_for_author(keys.A) == []


# ---- notify_callback wiring ----------------------------------------------------


def test_notify_register_primary_emits_list_and_pubkey(daemon, keys, captured_notifications):
    daemon.register_primary(keys.A, 500)
    assert "tracked://list" in captured_notifications
    assert f"tracked://pubkey/{keys.A}" in captured_notifications


def test_notify_register_primary_already_is_silent(daemon, keys, captured_notifications):
    daemon.register_primary(keys.A, 500)
    captured_notifications.clear()
    assert daemon.register_primary(keys.A, 500) == "already"
    # No state change → no fan-out.
    assert captured_notifications == []


def test_notify_promotion_emits(daemon, keys, captured_notifications):
    with daemon._sql_lock:
        daemon._register_secondary_unlocked(keys.A)
    captured_notifications.clear()
    daemon.register_primary(keys.A, 500)
    assert "tracked://list" in captured_notifications
    assert f"tracked://pubkey/{keys.A}" in captured_notifications


def test_notify_persist_tracked_pubkey_emits(daemon, keys, captured_notifications):
    daemon._persist_tracked_pubkey(keys.A, ["wss://r1"], 500, role="primary")
    assert "tracked://list" in captured_notifications
    assert f"tracked://pubkey/{keys.A}" in captured_notifications


def test_notify_ingest_pings_only_tracked_targets(daemon, keys, captured_notifications):
    prv_a, _ = keys.a
    daemon.register_primary(keys.A, 500)
    captured_notifications.clear()

    # Author A is tracked → fires for A. p-tag on B (untracked at start of
    # ingest) creates a NEW secondary B → fires for B too, plus list ping.
    # keys.C is untracked AND just becomes secondary in the same call → fires
    # for C and list. keys.D is not p-tagged → never appears.
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B], ["p", keys.C]]),
        "wss://r1",
    )
    assert f"tracked://pubkey/{keys.A}" in captured_notifications
    assert f"tracked://pubkey/{keys.B}" in captured_notifications
    assert f"tracked://pubkey/{keys.C}" in captured_notifications
    assert f"tracked://pubkey/{keys.D}" not in captured_notifications
    assert "tracked://list" in captured_notifications


def test_notify_ingest_silent_when_no_tracked_involved(daemon, keys, captured_notifications):
    """An event whose author and every p-tag target are untracked must
    not fire any notifications — there's nobody to tell."""
    prv_a, _ = keys.a
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B]]),
        "wss://r1",
    )
    assert captured_notifications == []


def test_notify_ingest_for_p_tag_target_already_tracked(daemon, keys, captured_notifications):
    """Author untracked, p-tag target already tracked as primary → ping the
    target so its stats refresh AND register the author as a secondary
    (reverse-frontier rule: anyone p-tagging a primary is interesting)."""
    prv_a, _ = keys.a
    daemon.register_primary(keys.B, 500)
    captured_notifications.clear()
    daemon._ingest_event(
        _evt(prv_a, tags=[["p", keys.B]]),
        "wss://r1",
    )
    assert f"tracked://pubkey/{keys.B}" in captured_notifications
    # Author A is reverse-frontier registered as secondary; roster ping fires.
    assert f"tracked://pubkey/{keys.A}" in captured_notifications
    assert "tracked://list" in captured_notifications
    rows = daemon._conn.execute(
        "SELECT hex, role FROM tracked_pubkeys ORDER BY hex"
    ).fetchall()
    assert sorted(rows) == sorted([(keys.A, "secondary"), (keys.B, "primary")])


# ---- LiveSubscriptionPool.count_for --------------------------------------------


def test_live_subs_count_for_starts_at_zero(daemon, keys):
    assert daemon.live_subs.count_for(keys.A) == 0


def test_live_subs_count_for_does_not_start_threads_in_test(daemon, keys):
    """`count_for` is a pure registry lookup — calling it on an empty pool
    must not require live workers. (Sanity guard so future refactors don't
    accidentally make it spin up sockets.)"""
    # Nothing added — count is 0 for arbitrary pubkeys.
    for pk in (keys.A, keys.B, keys.C, "0" * 64):
        assert daemon.live_subs.count_for(pk) == 0
