"""Spec: primary/secondary/ignored lifecycle.

## Logika systemu

``tracked_pubkeys`` ma trzy role:

- **primary** — explicit subscribe przez ``subscribe_to_pubkey``. Ich
  ``p``-tagi rozszerzają frontier (forward expansion → secondary).
- **secondary** — wykryty przez primary's ``p``-tag (forward) lub jako
  autor eventu p-tagującego primary (reverse). Pełen ingest, ALE jego
  własne ``p``-tagi NIE generują kolejnych secondaries (1-hop frontier).
- **ignored** — tombstone, no discovery, no live-sub. Auto-revive do
  secondary gdy nawiąże się relacja p-tag z którymkolwiek primary.

Reverse frontier ignoruje ``REVERSE_FRONTIER_SKIP_KINDS = {3, 10000,
30000}`` (kind 3 = follow list itp. — strukturalne, nie konwersacyjne).

``set_tracking_status`` jest JEDYNYM entry-pointem zmiany role.
``_persist_tracked_pubkey`` po discovery aktualizuje TYLKO scan-metadata.

Discovery per role:
- **primary** wysyła 3 REQ-i: ``{authors,limit}`` + ``{"#p",limit}`` +
  ``{authors, kinds:[0,3,10002]}`` (profile snapshot bez limit).
- **secondary** pomija ``{"#p"}`` — tylko ``{authors,limit}`` + profile.

Patrz ``README.md`` dla wzorców testowych.
"""

from __future__ import annotations

import pytest

from ._helpers import (
    DbHelper, RelayHelper, new_keypair, new_pub, signed_event,
)


# ============================================================================
# Creation path
# ============================================================================


def test_subscribe_to_pubkey_inserts_primary_row(core):
    """Fresh subscribe inserts the pubkey as primary with last_scan_at=NULL."""
    pub = new_pub()

    result = core.subscribe_to_pubkey(pub, limit=100)

    assert result == "Subscribed."
    rows = core.run_sql(
        "SELECT hex, role, limit_per_relay, last_scan_at FROM tracked_pubkeys"
    )
    assert rows == [{
        "hex": pub,
        "role": "primary",
        "limit_per_relay": 100,
        "last_scan_at": None,
    }]


@pytest.mark.parametrize("bad_limit", [0, -1, -100])
def test_subscribe_to_pubkey_rejects_non_positive_limit(core, bad_limit):
    """Non-positive `limit` is rejected with ValueError — no row is inserted."""
    pub = new_pub()

    with pytest.raises(ValueError, match="limit"):
        core.subscribe_to_pubkey(pub, limit=bad_limit)


def test_subscribe_to_pubkey_on_existing_primary_is_noop(core):
    """Subscribing an already-primary pubkey returns the no-op response and
    leaves `limit_per_relay` untouched (second call's limit is ignored)."""
    pub = new_pub()
    core.subscribe_to_pubkey(pub, limit=50)

    result = core.subscribe_to_pubkey(pub, limit=200)

    assert result == "It is already subscribed."
    row = DbHelper.expect_row(
        core,
        "SELECT role, limit_per_relay FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row == {"role": "primary", "limit_per_relay": 50}


def test_subscribe_to_pubkey_promotes_existing_secondary_to_primary(core):
    """Subscribing an existing secondary promotes it to primary, resets
    last_scan_at to NULL, and replaces limit_per_relay with the new value."""
    pub = new_pub()
    core.set_tracking_status(pub, "secondary", limit=50)

    result = core.subscribe_to_pubkey(pub, limit=200)

    assert result == "Subscribed."
    row = DbHelper.expect_row(
        core,
        "SELECT role, limit_per_relay, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row == {"role": "primary", "limit_per_relay": 200, "last_scan_at": None}


# ============================================================================
# Forward frontier (primary's events → new secondaries)
# ============================================================================


def test_primary_event_with_p_tag_registers_target_as_secondary(
    running_core, mock_relay,
):
    """A kind-1 event from a primary with a p-tag registers the target as a
    new secondary in tracked_pubkeys."""
    primary_prv, primary_pub = new_keypair()
    target_pub = new_pub()

    evt = signed_event(
        primary_prv, kind=1,
        content="hello @target",
        tags=[["p", target_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_pub, limit=10)

    row = DbHelper.expect_row(
        running_core,
        "SELECT hex, role FROM tracked_pubkeys WHERE hex = ?",
        [target_pub],
    )
    assert row == {"hex": target_pub, "role": "secondary"}


def test_primary_p_tagging_another_primary_does_not_demote_them(
    running_core, mock_relay,
):
    """A primary's p-tag pointing at an already-primary pubkey is a no-op —
    the target stays primary; forward expansion never demotes."""
    primary_a_prv, primary_a_pub = new_keypair()
    primary_b_pub = new_pub()
    running_core.subscribe_to_pubkey(primary_b_pub, limit=10)  # bootstrap as primary

    evt = signed_event(
        primary_a_prv, kind=1,
        content="hi @b",
        tags=[["p", primary_b_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_a_pub, limit=10)

    # B must still be primary after A's event ingests
    DbHelper.expect_row(
        running_core,
        "SELECT 1 FROM events WHERE id = ?", [evt["id"]],
    )
    row = DbHelper.expect_row(
        running_core,
        "SELECT role FROM tracked_pubkeys WHERE hex = ?",
        [primary_b_pub],
    )
    assert row == {"role": "primary"}


def test_secondary_event_with_p_tag_does_not_register_target(
    running_core, mock_relay,
):
    """A secondary's p-tag must NOT register the target — forward expansion
    is primary-only (1-hop frontier rule)."""
    primary_prv, primary_pub = new_keypair()
    secondary_prv, secondary_pub = new_keypair()
    target_pub = new_pub()

    # Bootstrap: subscribe primary so a relay is probed, plus mark secondary
    # explicitly so its event is verified-and-stored under that role.
    running_core.set_tracking_status(secondary_pub, "secondary", limit=10)

    evt = signed_event(
        secondary_prv, kind=1,
        content="from secondary @target",
        tags=[["p", target_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_pub, limit=10)

    # Wait until the secondary's event is in the DB, then assert the target
    # was NOT registered (forward expansion is gated on primary author).
    DbHelper.expect_row(
        running_core,
        "SELECT 1 FROM events WHERE id = ?", [evt["id"]],
    )
    rows = running_core.run_sql(
        "SELECT 1 FROM tracked_pubkeys WHERE hex = ?", [target_pub],
    )
    assert rows == []


def test_primary_kind3_follow_list_registers_all_followed_as_secondary(
    running_core, mock_relay,
):
    """A primary's kind-3 follow list registers every p-tag target as a
    secondary — forward expansion is unrestricted on kind (kind 3 is a
    deliberate 'who I follow' signal from the user)."""
    primary_prv, primary_pub = new_keypair()
    followed = [new_pub() for _ in range(3)]

    evt = signed_event(
        primary_prv, kind=3,
        tags=[["p", f] for f in followed],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_pub, limit=10)

    DbHelper.expect_rows(
        running_core,
        "SELECT hex FROM tracked_pubkeys WHERE role = 'secondary' "
        "  AND hex IN (?, ?, ?)",
        followed,
        count=3,
    )


# ============================================================================
# Reverse frontier (events mentioning a primary → author becomes secondary)
# ============================================================================


def test_non_primary_kind1_p_tagging_primary_registers_author_as_secondary(
    running_core, mock_relay,
):
    """A kind-1 event from a non-primary author that p-tags a primary makes
    the AUTHOR a secondary — reverse frontier expansion."""
    primary_prv, primary_pub = new_keypair()
    other_prv, other_pub = new_keypair()

    evt = signed_event(
        other_prv, kind=1,
        content="hi @primary",
        tags=[["p", primary_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_pub, limit=10)

    row = DbHelper.expect_row(
        running_core,
        "SELECT hex, role FROM tracked_pubkeys WHERE hex = ?",
        [other_pub],
    )
    assert row == {"hex": other_pub, "role": "secondary"}


def test_kind3_p_tagging_primary_does_not_register_author_reverse(
    running_core, mock_relay,
):
    """A kind-3 (follow list) event mentioning a primary must NOT register
    the author — reverse frontier skips kinds in REVERSE_FRONTIER_SKIP_KINDS
    so popular primaries don't get 10k follower-list authors as secondaries."""
    primary_prv, primary_pub = new_keypair()
    other_prv, other_pub = new_keypair()

    evt = signed_event(
        other_prv, kind=3,
        tags=[["p", primary_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_pub, limit=10)

    # Event must be ingested (proves the pipeline ran), then assert that the
    # reverse-frontier branch correctly skipped the author.
    DbHelper.expect_row(
        running_core,
        "SELECT 1 FROM events WHERE id = ?", [evt["id"]],
    )
    rows = running_core.run_sql(
        "SELECT 1 FROM tracked_pubkeys WHERE hex = ?", [other_pub],
    )
    assert rows == []


# ============================================================================
# Role transitions via set_tracking_status
# ============================================================================


def test_set_tracking_status_demotes_primary_to_secondary(core):
    """primary → secondary: role flips, last_scan_at resets to NULL, new limit
    replaces the old one."""
    pub = new_pub()
    core.subscribe_to_pubkey(pub, limit=100)

    result = core.set_tracking_status(pub, "secondary", limit=50)

    assert result == "primary_to_secondary"
    row = DbHelper.expect_row(
        core,
        "SELECT role, limit_per_relay, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row == {"role": "secondary", "limit_per_relay": 50, "last_scan_at": None}


def test_set_tracking_status_demotes_primary_to_ignored(core):
    """primary → ignored: role flips, last_scan_at is STAMPED (not NULL) so
    the auto-tracker stays away from this tombstone row."""
    pub = new_pub()
    core.subscribe_to_pubkey(pub, limit=100)

    result = core.set_tracking_status(pub, "ignored")

    assert result == "primary_to_ignored"
    row = DbHelper.expect_row(
        core,
        "SELECT role, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row["role"] == "ignored"
    assert row["last_scan_at"] is not None


def test_set_tracking_status_promotes_secondary_to_primary(core):
    """secondary → primary: role flips, last_scan_at resets to NULL so
    rediscovery runs under the new (primary) filter set."""
    pub = new_pub()
    core.set_tracking_status(pub, "secondary", limit=50)

    result = core.set_tracking_status(pub, "primary", limit=200)

    assert result == "secondary_to_primary"
    row = DbHelper.expect_row(
        core,
        "SELECT role, limit_per_relay, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row == {"role": "primary", "limit_per_relay": 200, "last_scan_at": None}


def test_set_tracking_status_demotes_secondary_to_ignored(core):
    """secondary → ignored: role flips, last_scan_at is stamped (auto-tracker skips)."""
    pub = new_pub()
    core.set_tracking_status(pub, "secondary", limit=50)

    result = core.set_tracking_status(pub, "ignored")

    assert result == "secondary_to_ignored"
    row = DbHelper.expect_row(
        core,
        "SELECT role, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row["role"] == "ignored"
    assert row["last_scan_at"] is not None


def test_set_tracking_status_revives_ignored_to_secondary(core):
    """ignored → secondary: role flips, last_scan_at resets to NULL so
    discovery runs at the next auto-tracker tick."""
    pub = new_pub()
    core.set_tracking_status(pub, "ignored")

    result = core.set_tracking_status(pub, "secondary", limit=50)

    assert result == "ignored_to_secondary"
    row = DbHelper.expect_row(
        core,
        "SELECT role, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row == {"role": "secondary", "last_scan_at": None}


def test_set_tracking_status_promotes_ignored_to_primary(core):
    """ignored → primary: role flips straight to primary, last_scan_at=NULL."""
    pub = new_pub()
    core.set_tracking_status(pub, "ignored")

    result = core.set_tracking_status(pub, "primary", limit=100)

    assert result == "ignored_to_primary"
    row = DbHelper.expect_row(
        core,
        "SELECT role, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row == {"role": "primary", "last_scan_at": None}


def test_demoting_one_primary_leaves_shared_secondary_intact(
    running_core, mock_relay,
):
    """Dwóch primary (A, B) p-taguje wspólnego secondary X. Demotion A do
    secondary NIE usuwa X — registracja secondary jest ``ON CONFLICT DO
    NOTHING``, jednorazowa, bez mechanizmu cleanup. B nadal trzyma
    relację, więc X pozostaje secondary."""
    a_prv, a_pub = new_keypair()
    b_prv, b_pub = new_keypair()
    shared_pub = new_pub()

    evt_a = signed_event(
        a_prv, kind=1, content="from a",
        tags=[["p", shared_pub]], created_at=1,
    )
    evt_b = signed_event(
        b_prv, kind=1, content="from b",
        tags=[["p", shared_pub]], created_at=2,
    )
    mock_relay.stage_event(evt_a)
    mock_relay.stage_event(evt_b)

    running_core.subscribe_to_pubkey(a_pub, limit=10)
    running_core.subscribe_to_pubkey(b_pub, limit=10)

    # Wait until shared_pub has been registered as secondary (either A's
    # or B's forward expansion will create it; both are primary so order
    # doesn't matter).
    DbHelper.expect_row(
        running_core,
        "SELECT hex, role FROM tracked_pubkeys "
        "WHERE hex = ? AND role = 'secondary'",
        [shared_pub],
    )

    running_core.set_tracking_status(a_pub, "secondary", limit=10)

    row = DbHelper.expect_row(
        running_core,
        "SELECT role FROM tracked_pubkeys WHERE hex = ?",
        [shared_pub],
    )
    assert row == {"role": "secondary"}


def test_secondary_promotion_retroactively_registers_existing_p_tags(
    running_core, mock_relay,
):
    """Promocja secondary → primary uruchamia retro-skan ``p``-tagów z
    eventów już zaingestowanych: pubkeye p-tagowane przez promowanego
    (gdy był jeszcze secondary, więc forward frontier ich pominął)
    rejestrowane są jako secondary po promocji. Bez tego retro-skanu
    follow-graph promowanego usera startowałby od zera. Patrz prompt.txt
    §subscribe_to_pubkey / „retro-skan _p_tag_pubkeys_for_author"."""
    prv, candidate_pub = new_keypair()
    target_pub = new_pub()

    # Event published while candidate is (about to be) secondary —
    # candidate's `p`-tag of target must NOT be expanded yet.
    evt = signed_event(
        prv, kind=1, content="hi target",
        tags=[["p", target_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.set_tracking_status(candidate_pub, "secondary", limit=10)

    DbHelper.expect_row(
        running_core, "SELECT 1 FROM events WHERE id = ?", [evt["id"]],
    )
    pre_promotion = running_core.run_sql(
        "SELECT 1 FROM tracked_pubkeys WHERE hex = ?", [target_pub],
    )
    assert pre_promotion == []

    # Promote: subscribe_to_pubkey on existing secondary → primary.
    # track_pubkey.run for primary triggers `_p_tag_pubkeys_for_author`
    # over already-ingested events.
    result = running_core.subscribe_to_pubkey(candidate_pub, limit=10)
    assert result == "Subscribed."

    row = DbHelper.expect_row(
        running_core,
        "SELECT role FROM tracked_pubkeys WHERE hex = ? AND role = 'secondary'",
        [target_pub],
    )
    assert row == {"role": "secondary"}


def test_set_tracking_status_on_current_role_is_already_noop(core):
    """Setting a row to its current role is a no-op — returns 'already' and
    does NOT silently overwrite limit_per_relay."""
    pub = new_pub()
    core.subscribe_to_pubkey(pub, limit=100)

    result = core.set_tracking_status(pub, "primary", limit=200)

    assert result == "already"
    row = DbHelper.expect_row(
        core,
        "SELECT role, limit_per_relay FROM tracked_pubkeys WHERE hex = ?",
        [pub],
    )
    assert row == {"role": "primary", "limit_per_relay": 100}


# ============================================================================
# Ignored revive — automatic promotion when a primary relation appears
# ============================================================================


def test_primary_p_tagging_ignored_revives_them_as_secondary(
    running_core, mock_relay,
):
    """Forward revive: an existing 'ignored' pubkey p-tagged by a primary's
    event is auto-promoted to secondary (the tombstone precondition broke)."""
    primary_prv, primary_pub = new_keypair()
    ignored_pub = new_pub()
    running_core.set_tracking_status(ignored_pub, "ignored")

    evt = signed_event(
        primary_prv, kind=1,
        content="welcome back",
        tags=[["p", ignored_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_pub, limit=10)

    # SQL filters on the post-revive role so `expect_row` waits for the
    # transition; otherwise it would see the still-ignored row immediately.
    row = DbHelper.expect_row(
        running_core,
        "SELECT hex, role FROM tracked_pubkeys "
        "WHERE hex = ? AND role = 'secondary'",
        [ignored_pub],
    )
    assert row == {"hex": ignored_pub, "role": "secondary"}


def test_ignored_p_tagging_primary_revives_them_as_secondary_reverse(
    running_core, mock_relay,
):
    """Reverse revive: an existing 'ignored' pubkey whose event p-tags a
    primary is auto-promoted to secondary at ingest time."""
    primary_prv, primary_pub = new_keypair()
    revive_prv, revive_pub = new_keypair()
    running_core.set_tracking_status(revive_pub, "ignored")

    evt = signed_event(
        revive_prv, kind=1,
        content="reaching out @primary",
        tags=[["p", primary_pub]],
    )
    mock_relay.stage_event(evt)

    running_core.subscribe_to_pubkey(primary_pub, limit=10)

    row = DbHelper.expect_row(
        running_core,
        "SELECT hex, role FROM tracked_pubkeys "
        "WHERE hex = ? AND role = 'secondary'",
        [revive_pub],
    )
    assert row == {"hex": revive_pub, "role": "secondary"}


# ============================================================================
# Discovery shape per role
# ============================================================================


def test_subscribe_to_primary_triggers_relay_query_for_user_events(
    running_core, mock_relay,
):
    """Primary discovery sends 3 REQs per relay: authors+limit, #p+limit, and
    a no-limit profile snapshot for kinds 0/3/10002."""
    pub = new_pub()

    running_core.subscribe_to_pubkey(pub, limit=10)

    RelayHelper.expect_reqs(mock_relay, [
        {"authors": [pub], "limit": 10},
        {"#p": [pub], "limit": 10},
        {"authors": [pub], "kinds": [0, 3, 10002]},
    ])


def test_secondary_discovery_does_not_send_p_tag_filter(
    running_core, mock_relay,
):
    """Secondary discovery skips the #p filter — only `authors`-based REQs go
    out. The 1-hop frontier rule means we don't care about who p-tags a
    secondary; that traffic is the dominant volume and the lowest value."""
    pub = new_pub()
    running_core.set_tracking_status(pub, "secondary", limit=10)

    # Wait until the profile-snapshot REQ has fired — that's the last REQ a
    # secondary discovery sends, so after this point any #p REQ would already
    # have arrived.
    RelayHelper.expect_reqs(mock_relay, [
        {"authors": [pub], "kinds": [0, 3, 10002]},
    ])
    p_reqs = [
        frame[2] for frame in mock_relay.received
        if frame[0] == "REQ" and "#p" in frame[2]
    ]
    assert p_reqs == [], f"secondary discovery must not send #p REQ; got: {p_reqs}"
