"""Two-tier (primary / secondary / ignored) tracking model for
``IngestDaemonSQL``.

Lifecycle methods live here so ``ingest_sql.py`` can stay focused on the
NIP-01 → SQL mapping. The model owns:

* ``set_tracking_status`` — single entry point for every role transition,
  including side effects (live-sub teardown, ``last_scan_at`` reset, push
  notifications).
* Atomic CAS sentinel for the auto-tracker: ``_claim_pending_secondaries``
  flips rows from ``NULL`` to ``_SCAN_IN_PROGRESS_SENTINEL`` and
  ``_persist_tracked_pubkey`` matches that sentinel so a concurrent
  rescan/subscribe issued mid-discovery wins over stale persist.
* ``_register_secondary_unlocked`` (frontier hot path) and
  ``revive_related_ignored`` (startup sweep).
* ``_record_discovery_outcome`` — bumps relay picks/rejections and flips
  ``active=false`` on persistently-rejected relays.

Lock convention (inherited): every method that takes ``self._sql_lock``
is non-reentrant. Helpers with the ``_unlocked`` suffix REQUIRE the
caller to hold the lock already; do NOT call locked methods from inside
a ``with self._sql_lock:`` block — that deadlocks the writer.
"""

from __future__ import annotations

import logging
import time

log = logging.getLogger("ingest_sql")


# Per-relay REQ limit when the auto-tracker first picks up a freshly inserted
# secondary (no `limit_per_relay` on the row yet). Kept here next to
# ``set_tracking_status`` / ``_claim_pending_secondaries`` because those
# are the only callers.
DEFAULT_AUTO_TRACK_LIMIT = 100

# Auto-disable threshold for relays that consistently lose the PHASE 2
# greedy max-cover. Each ``track_pubkey.run`` bumps ``discovery_picks`` for
# winners and ``discovery_rejections`` for relays that participated in
# PHASE 1 but were not selected. After this many rejections WITH ZERO picks
# we flip ``active=false``, permanently removing the relay from future
# PHASE 1 probes (one pick ever protects from auto-disable).
DISCOVERY_REJECT_DISABLE_THRESHOLD = 100


class TrackedPubkeysMixin:
    """Mixin for ``IngestDaemonSQL`` — owns ``tracked_pubkeys`` lifecycle.

    Expects the host class to provide: ``self._conn`` (DuckDB connection),
    ``self._sql_lock`` (non-reentrant), ``self.live_subs`` (LiveSubscriptionPool
    with ``remove_pubkey``), ``self._notify(uri)`` (best-effort push hook).
    """

    # Valid `tracked_pubkeys.role` values. 'primary' / 'secondary' get
    # discovery + live subs; 'ignored' is a tombstone marker that keeps the
    # row out of every active path (auto-tracker, mention-sweep, replay,
    # frontier-expansion via the existence check in `_register_secondary_unlocked`).
    # Append-only schema — adding 'ignored' to existing rows does not need a
    # DDL change because `tracked_pubkeys.role` has no CHECK constraint.
    VALID_TRACKING_STATUSES = ("primary", "secondary", "ignored")

    # Sentinel stamped on ``last_scan_at`` by the atomic claim — marks the row
    # as "discovery in flight". Persist uses it for a CAS-style update so a
    # rescan request that arrives while discovery is running can reset the
    # row to ``NULL`` and survive the stale persist.
    _SCAN_IN_PROGRESS_SENTINEL = -1

    # ---- predicates ---------------------------------------------------------

    def _is_primary_unlocked(self, hex_key: str) -> bool:
        """Caller must hold ``self._sql_lock``."""
        row = self._conn.execute(
            "SELECT role FROM tracked_pubkeys WHERE hex = ?", [hex_key]
        ).fetchone()
        return bool(row and row[0] == "primary")

    def _is_tracked_unlocked(self, hex_key: str) -> bool:
        """True iff ``hex_key`` has a row in ``tracked_pubkeys`` (any role).
        Caller must hold ``self._sql_lock``."""
        row = self._conn.execute(
            "SELECT 1 FROM tracked_pubkeys WHERE hex = ?", [hex_key]
        ).fetchone()
        return row is not None

    # ---- role transitions ---------------------------------------------------

    def set_tracking_status(
        self,
        hex_key: str,
        status: str,
        limit: int | None = None,
    ) -> str:
        """Unified entry point for ``tracked_pubkeys.role`` transitions.

        ``status`` ∈ {'primary','secondary','ignored'}. Detects the current
        role and applies the right side effects:

          - Transitions INTO 'primary'/'secondary' set ``last_scan_at = NULL``
            so the auto-tracker re-runs discovery under the new role at the
            next tick (in 'full-scan' mode).
          - Transitions AWAY from 'primary' (→ 'secondary' or → 'ignored')
            tear down all live subs for this pubkey immediately: primary's
            ``{"#p"}`` filter must drop, and 'ignored' must not be live-subbed
            at all.
          - Transitions to 'ignored' from any role also drop live subs and
            stamp ``last_scan_at = now`` (sentinel != NULL) so the row is
            invisible to the auto-tracker even if a filter ever regresses.
          - secondary→primary leaves existing subs alone; ``track_pubkey.run``
            will tear them down and reinstall with primary filters when the
            auto-tracker picks the row up.

        Returns a short transition tag (e.g. ``'new_primary'``, ``'already'``,
        ``'primary_to_secondary'``, ``'secondary_to_ignored'``). Raises
        ``ValueError`` on an unknown status — bad input must not be silently
        normalized.

        ``limit`` only applies to transitions into 'primary'/'secondary': it
        sets ``limit_per_relay`` for the row (used by discovery + live REQs).
        Ignored when transitioning to 'ignored'. Passing ``None`` keeps the
        existing value on update, or falls back to ``DEFAULT_AUTO_TRACK_LIMIT``
        on insert.
        """
        if status not in self.VALID_TRACKING_STATUSES:
            raise ValueError(
                f"status must be one of {self.VALID_TRACKING_STATUSES}, "
                f"got {status!r}"
            )
        now_s = int(time.time())
        cascaded: list[str] = []
        with self._sql_lock:
            row = self._conn.execute(
                "SELECT role, limit_per_relay FROM tracked_pubkeys WHERE hex = ?",
                [hex_key],
            ).fetchone()
            current = row[0] if row else None
            current_limit = row[1] if row else None

            if current == status:
                transition = "already"
            elif current is None:
                effective_limit = (
                    int(limit) if limit is not None
                    else int(DEFAULT_AUTO_TRACK_LIMIT)
                )
                new_scan = now_s if status == "ignored" else None
                self._conn.execute(
                    "INSERT INTO tracked_pubkeys "
                    "(hex, added_at, last_scan_at, limit_per_relay, role) "
                    "VALUES (?, ?, ?, ?, ?)",
                    [hex_key, now_s, new_scan, effective_limit, status],
                )
                transition = f"new_{status}"
            else:
                if status == "ignored":
                    new_scan = now_s
                    effective_limit = current_limit
                else:
                    new_scan = None  # trigger rediscovery
                    effective_limit = (
                        int(limit) if limit is not None else current_limit
                    )
                self._conn.execute(
                    "UPDATE tracked_pubkeys "
                    "SET role = ?, last_scan_at = ?, limit_per_relay = ? "
                    "WHERE hex = ?",
                    [status, new_scan, effective_limit, hex_key],
                )
                transition = f"{current}_to_{status}"

            # Cascade: gdy primary znika z aktywnych ról, secondaries trzymane
            # WYŁĄCZNIE przez tę relację muszą trafić do 'ignored'. Każdy
            # secondary z choć jedną relacją p-tag (forward LUB reverse) do
            # innego aktualnego primary zostaje nietknięty. Po promocji z
            # powrotem do primary retro-skan ``_p_tag_pubkeys_for_author`` →
            # ``_register_secondary_unlocked`` przywróci ich do secondary.
            if current == "primary" and status in ("secondary", "ignored"):
                cascaded = self._cascade_demote_dependents(hex_key, now_s)

        # Side effects on the live-sub pool. Drop subs whenever the active
        # filter shape changes outward (away from 'primary', or to 'ignored').
        # secondary→primary keeps existing {authors} subs running until
        # `track_pubkey.run` reinstalls under the primary filter set.
        if transition != "already":
            drop_subs = (
                status == "ignored"
                or (current == "primary" and status == "secondary")
            )
            if drop_subs:
                self.live_subs.remove_pubkey(hex_key)
        for dep in cascaded:
            self.live_subs.remove_pubkey(dep)

        if transition != "already":
            self._notify("tracked://list")
            self._notify(f"tracked://pubkey/{hex_key}")
        if cascaded:
            log.info(
                "cascade: %s demoted → ignored: %s",
                hex_key[:8], ", ".join(d[:8] for d in cascaded),
            )
            self._notify("tracked://list")
            for dep in cascaded:
                self._notify(f"tracked://pubkey/{dep}")
        log.info(
            "set_tracking_status %s: %s (limit=%s)",
            hex_key[:8], transition,
            "n/a" if status == "ignored" else str(limit),
        )
        return transition

    def register_primary(self, hex_key: str, limit: int) -> str:
        """Back-compat shim — promote/insert a pubkey as primary.

        Returns one of (mapped from ``set_tracking_status`` transitions for
        backwards compatibility with callers that branch on these strings):
          - "subscribed_new" — first time we see this pubkey
          - "promoted"       — was a secondary (or 'ignored'), now primary
          - "already"        — already a primary; nothing changes
        """
        transition = self.set_tracking_status(hex_key, "primary", limit=limit)
        if transition == "already":
            return "already"
        if transition == "new_primary":
            return "subscribed_new"
        # secondary_to_primary or ignored_to_primary
        return "promoted"

    def mark_for_rescan(self, hex_key: str) -> str:
        """Set ``last_scan_at = NULL`` so the auto-tracker re-runs discovery
        on this pubkey at the next tick. Role is preserved (primary stays
        primary, secondary stays secondary). Productive_relays kept as-is —
        live subs continue running on stale relays until ``track_pubkey.run``
        tears them down at the start of the rescan.

        Returns:
          - 'queued'    — row marked
          - 'untracked' — pubkey not in tracked_pubkeys (caller should use
                          ``register_primary`` first)
        """
        with self._sql_lock:
            row = self._conn.execute(
                "SELECT 1 FROM tracked_pubkeys WHERE hex = ?", [hex_key]
            ).fetchone()
            if row is None:
                return "untracked"
            self._conn.execute(
                "UPDATE tracked_pubkeys SET last_scan_at = NULL WHERE hex = ?",
                [hex_key],
            )
        log.info("mark_for_rescan %s: queued", hex_key[:8])
        self._notify("tracked://list")
        self._notify(f"tracked://pubkey/{hex_key}")
        return "queued"

    def _register_secondary_unlocked(self, hex_key: str) -> bool:
        """Insert or revive a secondary row.
          - no row → INSERT role='secondary'
          - row with role ∈ {'primary','secondary'} → no-op (return False)
          - row with role='ignored' → UPDATE role='secondary',
            last_scan_at=NULL so the auto-tracker re-runs discovery. 'Ignored'
            is only valid for pubkeys with no relation to any primary; the
            very fact this helper was called means a primary just created a
            relation, so the precondition fails and the row must come back.
        Returns True iff the row was inserted OR revived (callers fan out
        ``tracked://list`` on True).
        Caller must hold ``self._sql_lock``.
        """
        row = self._conn.execute(
            "SELECT role FROM tracked_pubkeys WHERE hex = ?", [hex_key]
        ).fetchone()
        if row is None:
            self._conn.execute(
                "INSERT INTO tracked_pubkeys (hex, added_at, role) "
                "VALUES (?, ?, 'secondary')",
                [hex_key, int(time.time())],
            )
            return True
        if row[0] == "ignored":
            self._conn.execute(
                "UPDATE tracked_pubkeys "
                "SET role = 'secondary', last_scan_at = NULL "
                "WHERE hex = ?",
                [hex_key],
            )
            log.info("frontier: revived ignored %s → secondary", hex_key[:8])
            return True
        return False

    def _cascade_demote_dependents(
        self, demoted_hex: str, now_s: int,
    ) -> list[str]:
        """Find secondaries whose only primary p-tag relation was with
        ``demoted_hex`` and flip them to ``ignored``. Returns the list of
        cascaded hexes so the caller can fan out notifications and tear
        down live subs.

        A secondary stays put iff it has any p-tag relation (forward OR
        reverse) with at least one OTHER currently-primary pubkey. The
        caller has already executed the role flip for ``demoted_hex``, so
        ``WHERE role='primary'`` naturally excludes it.

        Caller must hold ``self._sql_lock``.
        """
        rows = self._conn.execute(
            "WITH other_primaries AS ("
            "  SELECT hex FROM tracked_pubkeys WHERE role = 'primary'"
            ") "
            "SELECT DISTINCT s.hex "
            "FROM tracked_pubkeys s "
            "WHERE s.role = 'secondary' "
            "  AND ("
            "    EXISTS ("
            "      SELECT 1 FROM events e JOIN event_tags t ON t.event_id = e.id "
            "      WHERE e.pubkey = ? AND t.name = 'p' AND t.elem_idx = 1 AND t.value = s.hex"
            "    ) OR EXISTS ("
            "      SELECT 1 FROM events e JOIN event_tags t ON t.event_id = e.id "
            "      WHERE e.pubkey = s.hex AND t.name = 'p' AND t.elem_idx = 1 AND t.value = ?"
            "    )"
            "  ) "
            "  AND NOT EXISTS ("
            "    SELECT 1 FROM events e JOIN event_tags t ON t.event_id = e.id "
            "    WHERE e.pubkey IN (SELECT hex FROM other_primaries) "
            "      AND t.name = 'p' AND t.elem_idx = 1 AND t.value = s.hex"
            "  ) "
            "  AND NOT EXISTS ("
            "    SELECT 1 FROM events e JOIN event_tags t ON t.event_id = e.id "
            "    WHERE e.pubkey = s.hex AND t.name = 'p' AND t.elem_idx = 1 "
            "      AND t.value IN (SELECT hex FROM other_primaries)"
            "  )",
            [demoted_hex, demoted_hex],
        ).fetchall()
        dependents = [r[0] for r in rows]
        if not dependents:
            return []
        placeholders = ",".join(["?"] * len(dependents))
        self._conn.execute(
            f"UPDATE tracked_pubkeys "
            f"SET role = 'ignored', last_scan_at = ? "
            f"WHERE hex IN ({placeholders})",
            [now_s, *dependents],
        )
        return dependents

    def revive_related_ignored(self) -> int:
        """Startup-time scan: any row with role='ignored' that ALREADY has
        a p-tag relation to a primary in ``events``/``tags`` (either
        direction) gets promoted to 'secondary' with ``last_scan_at=NULL``
        so the auto-tracker picks it up. 'Ignored' only applies to pubkeys
        with no relation to any primary — this catches relations that
        existed before the live frontier-revival was wired up, or that
        were ingested while the pubkey was already ignored. Returns count
        of revived rows.
        """
        with self._sql_lock:
            rows = self._conn.execute(
                "WITH primaries AS ("
                "  SELECT hex FROM tracked_pubkeys WHERE role = 'primary'"
                ") "
                "SELECT DISTINCT i.hex "
                "FROM tracked_pubkeys i "
                "WHERE i.role = 'ignored' AND ("
                "  EXISTS ("
                "    SELECT 1 FROM events e JOIN event_tags t ON t.event_id = e.id "
                "    WHERE e.pubkey = i.hex AND t.name = 'p' AND t.elem_idx = 1 "
                "      AND t.value IN (SELECT hex FROM primaries)"
                "  ) OR EXISTS ("
                "    SELECT 1 FROM events e JOIN event_tags t ON t.event_id = e.id "
                "    WHERE e.pubkey IN (SELECT hex FROM primaries) "
                "      AND t.name = 'p' AND t.elem_idx = 1 AND t.value = i.hex"
                "  )"
                ")"
            ).fetchall()
            hexes = [r[0] for r in rows]
            if not hexes:
                return 0
            placeholders = ",".join(["?"] * len(hexes))
            self._conn.execute(
                f"UPDATE tracked_pubkeys "
                f"SET role = 'secondary', last_scan_at = NULL "
                f"WHERE hex IN ({placeholders})",
                hexes,
            )
        for h in hexes:
            log.info(
                "startup-revive: ignored %s → secondary (related to primary)",
                h[:8],
            )
            self._notify(f"tracked://pubkey/{h}")
        self._notify("tracked://list")
        return len(hexes)

    # ---- auto-tracker claim / persist CAS -----------------------------------

    def _claim_pending_secondaries(self, max_n: int) -> list[dict]:
        """Atomically claim rows the auto-tracker hasn't run discovery on
        yet (``last_scan_at IS NULL``): one UPDATE flips them to the
        in-progress sentinel and returns the affected rows.

        Atomicity matters: if discovery is in flight when somebody
        ``rescan``/``subscribe``-s the same pubkey (resetting
        ``last_scan_at`` to NULL), the stale persist must NOT win. The
        sentinel lets ``_persist_tracked_pubkey`` distinguish *its* row
        version from a newer rescan — see its CAS-style WHERE clause.

        Primary rows go first regardless of ``added_at``. A fresh
        ``subscribe_to_pubkey`` (or a promotion of a long-known
        secondary) is an explicit user signal — making it wait behind a
        backlog of bulk-harvested secondaries would defeat the point.
        Within each role bucket FIFO by ``added_at`` keeps ordering
        deterministic.
        """
        with self._sql_lock:
            rows = self._conn.execute(
                "SELECT hex, role, COALESCE(limit_per_relay, ?) "
                "FROM tracked_pubkeys "
                "WHERE last_scan_at IS NULL "
                "  AND role IN ('primary', 'secondary') "
                "ORDER BY (role = 'primary') DESC, added_at "
                "LIMIT ?",
                [DEFAULT_AUTO_TRACK_LIMIT, int(max_n)],
            ).fetchall()
            if rows:
                # Two statements under one lock = atomic relative to any
                # other writer (they all take `_sql_lock` too).
                placeholders = ",".join(["?"] * len(rows))
                self._conn.execute(
                    f"UPDATE tracked_pubkeys SET last_scan_at = ? "
                    f"WHERE hex IN ({placeholders})",
                    [self._SCAN_IN_PROGRESS_SENTINEL, *(r[0] for r in rows)],
                )
        return [{"hex": r[0], "role": r[1], "limit": int(r[2])} for r in rows]

    def _persist_tracked_pubkey(
        self,
        pubkey_hex: str,
        productive_relays: list[str],
        limit: int,
        role: str = "primary",
    ) -> None:
        """Persist the scan-result for a pubkey: ``last_scan_at``,
        ``productive_relays`` and ``limit_per_relay``. Role is NOT touched
        on an existing row — ``set_tracking_status`` owns role transitions
        and a concurrent demote/ignore/revive issued while discovery was
        in flight must win over the stale role this discovery ran under.

        Uses CAS on ``last_scan_at = _SCAN_IN_PROGRESS_SENTINEL`` (set by
        ``_claim_pending_secondaries``): if anyone reset the row back to
        ``last_scan_at IS NULL`` while discovery was running (e.g. user
        called ``rescan_pubkey`` or promoted via ``subscribe_to_pubkey``),
        the UPDATE matches 0 rows and the rescan request survives. The
        auto-tracker picks the row up at its next tick.

        If the row was deleted between the auto-tracker claim and this
        call, re-insert it under the role discovery ran with so nothing
        is lost.
        """
        if role not in ("primary", "secondary"):
            raise ValueError(f"role must be 'primary' or 'secondary', got {role!r}")
        now_s = int(time.time())
        with self._sql_lock:
            existing = self._conn.execute(
                "SELECT role, last_scan_at FROM tracked_pubkeys WHERE hex = ?",
                [pubkey_hex],
            ).fetchone()
            if existing is None:
                self._conn.execute(
                    "INSERT INTO tracked_pubkeys "
                    "(hex, added_at, last_scan_at, limit_per_relay, productive_relays, role) "
                    "VALUES (?, ?, ?, ?, ?, ?)",
                    [pubkey_hex, now_s, now_s, int(limit),
                     list(productive_relays), role],
                )
                final_role = role
                final_scan = now_s
            else:
                self._conn.execute(
                    "UPDATE tracked_pubkeys "
                    "SET last_scan_at = ?, "
                    "    limit_per_relay = ?, "
                    "    productive_relays = ? "
                    "WHERE hex = ? AND last_scan_at = ?",
                    [now_s, int(limit), list(productive_relays),
                     pubkey_hex, self._SCAN_IN_PROGRESS_SENTINEL],
                )
                final_role = existing[0]
                # `last_scan_at` may already have been reset by a concurrent
                # rescan request — re-read for accurate logging.
                final_scan = self._conn.execute(
                    "SELECT last_scan_at FROM tracked_pubkeys WHERE hex = ?",
                    [pubkey_hex],
                ).fetchone()[0]
        rescan_intercepted = (existing is not None and final_scan != now_s)
        log.info(
            "persist %s: requested=%s final=%s relays=%d limit=%d%s",
            pubkey_hex[:8], role, final_role, len(productive_relays), int(limit),
            " (rescan intercepted)" if rescan_intercepted else "",
        )
        self._notify("tracked://list")
        self._notify(f"tracked://pubkey/{pubkey_hex}")

    def _record_discovery_outcome(
        self, winners: set[str], losers: set[str],
    ) -> None:
        """Bump per-relay discovery counters after a ``track_pubkey.run``.

        ``winners`` — URLs picked by the PHASE 2 greedy selection.
        ``losers``  — URLs that participated in PHASE 1 (returned an id-set)
                      but were not picked. Skipped/errored relays don't
                      count as losers — they never got a fair shot.

        After bumping, any relay with ``discovery_picks=0`` and
        ``discovery_rejections >= DISCOVERY_REJECT_DISABLE_THRESHOLD`` is
        flipped to ``active=false`` so the next reconcile drops its WS and
        future PHASE 1 probes skip it. One pick ever immunizes a relay.
        """
        with self._sql_lock:
            if winners:
                self._conn.execute(
                    "UPDATE relays SET discovery_picks = discovery_picks + 1 "
                    f"WHERE url IN ({','.join(['?'] * len(winners))})",
                    list(winners),
                )
            if losers:
                self._conn.execute(
                    "UPDATE relays SET discovery_rejections = "
                    "discovery_rejections + 1 "
                    f"WHERE url IN ({','.join(['?'] * len(losers))})",
                    list(losers),
                )
                disabled = self._conn.execute(
                    "UPDATE relays SET active = false "
                    "WHERE discovery_picks = 0 "
                    "  AND discovery_rejections >= ? "
                    "  AND COALESCE(active, true) "
                    "RETURNING url",
                    [DISCOVERY_REJECT_DISABLE_THRESHOLD],
                ).fetchall()
                if disabled:
                    log.info(
                        "auto-disabled %d relay(s) (>=%d discovery "
                        "rejections, 0 picks): %s",
                        len(disabled),
                        DISCOVERY_REJECT_DISABLE_THRESHOLD,
                        ", ".join(r[0] for r in disabled),
                    )

    def _read_tracked_pubkeys(self) -> list[dict]:
        with self._sql_lock:
            rows = self._conn.execute(
                "SELECT hex, added_at, last_scan_at, limit_per_relay, productive_relays, role "
                "FROM tracked_pubkeys"
            ).fetchall()
        return [
            {
                "hex": r[0],
                "added_at": r[1],
                "last_scan_at": r[2],
                "limit_per_relay": r[3],
                "productive_relays": list(r[4]) if r[4] is not None else [],
                "role": r[5],
            }
            for r in rows
        ]
