"""§15: the notifications fan-out and the SSE broadcaster.

This module is the single chokepoint for the producer side of §15.
Every write that names an `rfc_slug` flows through `fan_out_from_action`
(called from `bot._log`) or `fan_out_chat_message` (called from the chat
write paths in `api_branches` / `api_prs`, since chat messages don't go
through the bot wrapper). The chokepoint:

  - upserts a `watches` row for the actor per §15.6's auto-watch rule
    (a substantive gesture sets `watching`, never downgrades, and bumps
    `last_participation_at` for the 90-day decay);
  - applies the §15.1 routing rules to determine which other users
    receive a notification for the event;
  - filters out muted recipients per §15.8 (per-user mute list);
  - filters out `watches.state='muted'` per §15.6 (per-RFC mute);
  - inserts one `notifications` row per recipient with the underlying
    actor's user id per §15.9 (the bot never appears as actor);
  - dispatches the row to the §15.4 email path if the recipient's
    category toggle is on (held during quiet hours per §15.8, deferred
    to the digest otherwise);
  - publishes each new row to the per-user SSE subscriber queue so the
    inbox refresh and header-badge count back the same stream per §15.3.

If you find yourself wanting to insert a row into `notifications`
directly from an endpoint, the spec is right and you are wrong: the
chokepoint is the invariant. Read paths can query the table from
anywhere; the producer side flows through here.
"""
from __future__ import annotations

import asyncio
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Iterable

from . import db

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# §15.1 routing — action_kind → (event_kind, category, recipient_set)
# ---------------------------------------------------------------------------
#
# Three recipient sets are used by the routing rules below:
#
#   "watchers"  — every user with a watches row on this slug whose state
#                 is 'watching' (full stream) or 'following' (structural
#                 only), minus the actor and minus 'muted' rows. The
#                 structural/full split is applied per-event below.
#   "owners"    — every user named in the entry's owners_json frontmatter
#                 on cached_rfcs (resolved to user_ids via gitea_login).
#   "proposer"  — the user whose action_kind=propose_rfc opened the slug,
#                 resolved via the most recent matching audit row.
#
# The category of each event maps to one of the §15.4 buckets:
#
#   "personal-direct"   — recipient is named subject of the action.
#   "structural"        — structural beats on watched RFCs.
#   "churn"             — per-commit / per-message activity. Never emails
#                         per §15.4; aggregated by the §15.5 digest.

CATEGORY_PERSONAL = "personal-direct"
CATEGORY_STRUCTURAL = "structural"
CATEGORY_CHURN = "churn"
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"

# Action kinds whose actor's first interaction with a slug triggers
# auto-watch per §15.6. The substantive-gesture list in the spec is
# the source; we map onto our action_kind taxonomy. Reads, view advances,
# and own-action observations are intentionally excluded.
_AUTO_WATCH_ACTIONS = {
    "propose_rfc",
    "merge_proposal",
    "decline_proposal",
    "create_branch",
    "accept_change",
    "decline_change",
    "manual_flush",
    "open_branch_pr",
    "merge_branch_pr",
    "withdraw_branch_pr",
    "supersede_branch_pr",
    "open_metadata_pr",
    "open_claim_pr",
    "merge_claim_pr",
    "create_resolution_branch",
    "replay_change",
    "graduate_start",
    "graduate_complete",
    "open_review_thread",
    "resolve_thread",
    "drop_flag",
    "resolve_flag",
}


# ---------------------------------------------------------------------------
# SSE broadcaster — per-user subscriber registry
# ---------------------------------------------------------------------------


@dataclass
class _Subscriber:
    user_id: int
    queue: asyncio.Queue = field(default_factory=asyncio.Queue)


_subscribers: dict[int, list[_Subscriber]] = {}
_subscribers_lock: asyncio.Lock | None = None


def _get_lock() -> asyncio.Lock:
    global _subscribers_lock
    if _subscribers_lock is None:
        _subscribers_lock = asyncio.Lock()
    return _subscribers_lock


async def subscribe(user_id: int) -> _Subscriber:
    """Register a new SSE subscriber for a user. Each browser tab gets its
    own subscriber; the per-user fan-out pushes the same event to every
    subscriber for that user per §15 (multiple tabs see the same updates)."""
    sub = _Subscriber(user_id=user_id)
    async with _get_lock():
        _subscribers.setdefault(user_id, []).append(sub)
    return sub


async def unsubscribe(sub: _Subscriber) -> None:
    async with _get_lock():
        bucket = _subscribers.get(sub.user_id, [])
        if sub in bucket:
            bucket.remove(sub)
        if not bucket:
            _subscribers.pop(sub.user_id, None)


async def _broadcast(user_id: int, event: str, payload: Any) -> None:
    """Push an event to every subscriber for one user.

    Best-effort; if the queue is unbounded a slow consumer just sees the
    backlog when it next drains. We deliberately do not block writers on
    consumer presence — the durable inbox row is the source of truth."""
    async with _get_lock():
        subs = list(_subscribers.get(user_id, []))
    for sub in subs:
        try:
            sub.queue.put_nowait({"event": event, "payload": payload})
        except Exception:
            log.exception("notify broadcast: drop event for user %s", user_id)


# ---------------------------------------------------------------------------
# Producer-side entry points
# ---------------------------------------------------------------------------


def fan_out_from_action(
    *,
    actor_user_id: int | None,
    action_kind: str,
    rfc_slug: str | None,
    branch_name: str | None,
    pr_number: int | None,
    details: dict | None,
) -> None:
    """Called from `bot._log` after every successful Git write.

    Synchronous on purpose — runs inside the same request as the action,
    so a failure here surfaces with the action rather than as a silent
    drop. The SSE push is asyncio-scheduled if a loop is running; if
    no loop is available (test harness, sync test client) the dispatch
    falls back to writing the row and relying on the next subscriber
    poll to surface it.
    """
    if rfc_slug is None or action_kind not in _AUTO_WATCH_ACTIONS:
        # Actions without an rfc_slug (e.g. permission events) or that
        # aren't a §15.6 substantive gesture don't drive the chokepoint.
        return

    if actor_user_id is not None:
        _bump_auto_watch(actor_user_id, rfc_slug)

    rules = _ROUTING.get(action_kind)
    if rules is None:
        return

    for rule in rules:
        recipients = _resolve_recipients(
            rule=rule,
            actor_user_id=actor_user_id,
            rfc_slug=rfc_slug,
            details=details,
        )
        if not recipients:
            continue
        for recipient_id in recipients:
            _emit_one(
                recipient_user_id=recipient_id,
                event_kind=rule["event_kind"],
                category=rule["category"],
                actor_user_id=actor_user_id,
                rfc_slug=rfc_slug,
                branch_name=branch_name,
                pr_number=pr_number,
                details=details or {},
            )


def fan_out_new_beta_request(
    *,
    requester_user_id: int,
) -> None:
    """v0.9.0 (roadmap item #7): announce a fresh beta-access request to
    every admin/owner.

    Called from `POST /api/auth/me/beta-request` after the row's
    first/last/why fields are populated. Fan-out shape mirrors the §15
    chokepoint contract: one row per recipient, written via `_emit_one`
    so the SSE broadcast + email dispatch run through the same surface
    every other notification uses. The event has no rfc_slug (it is
    framework-scoped, not RFC-scoped); the deep-link payload points
    `/admin/users` instead of `/rfc/<slug>`.

    Actor is the requester per §15.9 (the underlying user, never the
    bot). Category is `admin-actionable` so the §15.4 email gate
    consults `email_admin_actionable` (owners/admins-only by
    construction) and the digest exclusion rules treat it identically
    to other admin-actionable signals (graduation_ready et al).

    Recipients are owners + admins minus the requester themselves
    (a self-promotion shouldn't reach the requester's own inbox). The
    requester is never in the role set in practice — the endpoint
    refuses 'granted'/'revoked' callers and a fresh OTC user lands
    `contributor`+`pending` — but we filter regardless so the call
    is robust to future changes in the auth gate.
    """
    requester = db.conn().execute(
        "SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
        (requester_user_id,),
    ).fetchone()
    if requester is None:
        return
    first = (requester["first_name"] or "").strip()
    last = (requester["last_name"] or "").strip()
    email = requester["email"] or ""
    display = requester["display_name"] or email or "a new user"
    full_name = (f"{first} {last}").strip() or display
    details = {
        "requester_user_id": requester_user_id,
        "requester_first_name": first,
        "requester_last_name": last,
        "requester_email": email,
        "requester_display": full_name,
    }
    for recipient_id in _admin_user_ids():
        if recipient_id == requester_user_id:
            continue
        _emit_one(
            recipient_user_id=recipient_id,
            event_kind="new_beta_request",
            category=CATEGORY_ADMIN_ACTIONABLE,
            actor_user_id=requester_user_id,
            rfc_slug=None,
            branch_name=None,
            pr_number=None,
            details=details,
        )


def fan_out_contribution_request(
    *,
    rfc_slug: str,
    requester_user_id: int,
    request_id: int,
    matched_term: str,
    who_i_am: str,
    why: str,
    use_case: str | None,
) -> list[int]:
    """Roadmap #28 Part 3: a reader asked to contribute to a pending
    (super-draft) RFC. Land one actionable notification per owner and
    return their ids (the caller stamps the first onto the request row as
    the inbox-action handle).

    Personal-direct: the owner is the named subject of the request, so the
    §15.4 email gate consults `email_personal_direct` exactly as for the
    other owner-facing personal events — no new preference column is
    needed. The request's three free-text fields ride along in the payload
    so the inbox row can show the full ask inline without a second fetch.
    Actor is the requester per §15.9.
    """
    requester = db.conn().execute(
        "SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
    ).fetchone()
    display = (requester["display_name"] if requester else None) or "Someone"
    details = {
        "request_id": request_id,
        "matched_term": matched_term,
        "requester_user_id": requester_user_id,
        "requester_display": display,
        "who_i_am": who_i_am,
        "why": why,
        "use_case": use_case or "",
    }
    notif_ids: list[int] = []
    for recipient_id in _entry_owner_user_ids(rfc_slug):
        if recipient_id == requester_user_id:
            continue
        notif_ids.append(
            _emit_one(
                recipient_user_id=recipient_id,
                event_kind="contribution_request_on_pending_rfc",
                category=CATEGORY_PERSONAL,
                actor_user_id=requester_user_id,
                rfc_slug=rfc_slug,
                branch_name=None,
                pr_number=None,
                details=details,
            )
        )
    return notif_ids


def notify_scope_role_granted(
    *,
    recipient_user_id: int,
    granter_user_id: int | None,
    scope_type: str,
    scope_id: str,
    role: str,
    project_id: str | None,
    project_name: str | None,
    collection_name: str | None,
) -> int | None:
    """§22 S4 (C.2): a scope Owner granted `recipient` a role at a scope.
    Personal-direct — the recipient is the named subject — so it rides the
    `email_personal_direct` gate like the other owner-facing personal events.
    The scope facts ride in the payload so the inbox row (and email body) names
    the project and role without a second fetch. Actor is the granter (§15.9);
    a system/administrative grant with no granter renders as "the app".

    Returns the notification id, or None when the grantee would be notifying
    themselves (a self-grant — no notification)."""
    if granter_user_id is not None and recipient_user_id == granter_user_id:
        return None
    details = {
        "scope_type": scope_type,
        "scope_id": scope_id,
        "role": role,
        "project_id": project_id or "",
        "project_name": project_name or "",
        "collection_name": collection_name or "",
    }
    return _emit_one(
        recipient_user_id=recipient_user_id,
        event_kind="scope_role_granted",
        category=CATEGORY_PERSONAL,
        actor_user_id=granter_user_id,
        rfc_slug=None,
        branch_name=None,
        pr_number=None,
        details=details,
    )


def notify_contribution_decided(
    *,
    rfc_slug: str,
    requester_user_id: int,
    decider_user_id: int,
    request_id: int,
    accepted: bool,
) -> None:
    """Roadmap #28 Part 3: tell the requester an owner accepted or declined
    their contribute request. On accept the requester also receives the
    #12 invitation email out-of-band; this inbox row is the in-app echo
    that points them at it."""
    _emit_one(
        recipient_user_id=requester_user_id,
        event_kind=(
            "contribution_request_accepted" if accepted else "contribution_request_declined"
        ),
        category=CATEGORY_PERSONAL,
        actor_user_id=decider_user_id,
        rfc_slug=rfc_slug,
        branch_name=None,
        pr_number=None,
        details={"request_id": request_id},
    )


def fan_out_join_request(
    *,
    scope_type: str,
    scope_id: str,
    scope_name: str | None,
    project_id: str | None,
    project_name: str | None,
    requester_user_id: int,
    request_id: int,
    requested_role: str,
    message: str | None,
) -> list[int]:
    """§22.8: a user asked to join a scope. Land one actionable notification per
    Owner across the scope's subtree (the cross-collection inbox, §22.11) and
    return their ids (the caller stamps the first onto the request row as the
    inbox-action handle — any of them can act on it).

    Personal-direct: each Owner is a named subject able to act, so it rides the
    `email_personal_direct` gate like the other owner-facing personal events. The
    requested role + message ride in the payload so the inbox row shows the full
    ask inline. Actor is the requester per §15.9.
    """
    requester = db.conn().execute(
        "SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
    ).fetchone()
    display = (requester["display_name"] if requester else None) or "Someone"
    details = {
        "request_id": request_id,
        "scope_type": scope_type,
        "scope_id": scope_id,
        "scope_name": scope_name or scope_id,
        "project_id": project_id or "",
        "project_name": project_name or "",
        "requested_role": requested_role,
        "requester_user_id": requester_user_id,
        "requester_display": display,
        "message": message or "",
    }
    notif_ids: list[int] = []
    for recipient_id in _scope_owner_user_ids(scope_type, scope_id):
        if recipient_id == requester_user_id:
            continue
        notif_ids.append(
            _emit_one(
                recipient_user_id=recipient_id,
                event_kind="join_request_on_scope",
                category=CATEGORY_PERSONAL,
                actor_user_id=requester_user_id,
                rfc_slug=None,
                branch_name=None,
                pr_number=None,
                details=details,
            )
        )
    return notif_ids


def notify_join_decided(
    *,
    requester_user_id: int,
    decider_user_id: int,
    request_id: int,
    scope_type: str,
    scope_id: str,
    scope_name: str | None,
    granted_role: str | None,
    accepted: bool,
) -> None:
    """§22.8: tell the requester an Owner accepted (writing their `memberships`
    row) or declined their request to join. The scope + granted role ride in the
    payload so the inbox row names where they were let in without a second fetch."""
    _emit_one(
        recipient_user_id=requester_user_id,
        event_kind=("join_request_accepted" if accepted else "join_request_declined"),
        category=CATEGORY_PERSONAL,
        actor_user_id=decider_user_id,
        rfc_slug=None,
        branch_name=None,
        pr_number=None,
        details={
            "request_id": request_id,
            "scope_type": scope_type,
            "scope_id": scope_id,
            "scope_name": scope_name or scope_id,
            "granted_role": granted_role or "",
        },
    )


def fan_out_chat_message(
    *,
    actor_user_id: int,
    rfc_slug: str,
    branch_name: str | None,
    thread_id: int,
    message_id: int,
    is_review_thread: bool = False,
    pr_number: int | None = None,
) -> None:
    """Called from chat write paths after a user message is persisted.

    Chat messages don't flow through bot.py (no Git write); the chokepoint
    here is the equivalent for §15's chat-message events. The routing
    rule is: prior authors in the thread (besides the message author) get
    a personal-direct `chat_reply_to_my_message`; users watching the RFC
    (state='watching', i.e. full stream) get a churn-class
    `chat_message_in_participated_thread`. The two are union'd so a user
    who is both gets only the personal-direct row.

    v0.5.0: `branch_name` may be None — that is the PR-less per-RFC
    discussion shape (`threads.branch_name IS NULL`, §5). The
    notifications row carries the null through; the inbox prose renders
    identically whether the chat lives on a branch or on the RFC's
    discussion surface, and the §15.7 reconciler keys on
    (rfc_slug, branch_name) so a null branch correctly matches the
    PR-less discussion's eventual chat-seen-equivalent advance.
    """
    _bump_auto_watch(actor_user_id, rfc_slug)

    prior_authors = {
        row["author_user_id"]
        for row in db.conn().execute(
            """
            SELECT DISTINCT author_user_id
              FROM thread_messages
             WHERE thread_id = ?
               AND author_user_id IS NOT NULL
               AND author_user_id != ?
               AND id < ?
            """,
            (thread_id, actor_user_id, message_id),
        )
    }

    # §15.6 / §15.8 filters apply here too — a muted recipient never sees
    # a row regardless of which producer path generated it.
    muted_rfc = _muted_user_ids_for_rfc(rfc_slug)
    muters = _muters_of(actor_user_id)
    prior_authors = (prior_authors - muted_rfc) - muters

    # Personal-direct: prior thread authors.
    for recipient_id in prior_authors:
        event_kind = (
            "pr_review_thread_reply" if is_review_thread else "chat_reply_to_my_message"
        )
        _emit_one(
            recipient_user_id=recipient_id,
            event_kind=event_kind,
            category=CATEGORY_PERSONAL,
            actor_user_id=actor_user_id,
            rfc_slug=rfc_slug,
            branch_name=branch_name,
            pr_number=pr_number,
            thread_id=thread_id,
            details={"message_id": message_id},
        )

    # Churn: watchers of the RFC who weren't already covered.
    watcher_ids = _watcher_user_ids(rfc_slug, scope="watching") - prior_authors
    watcher_ids.discard(actor_user_id)
    watcher_ids = (watcher_ids - muted_rfc) - muters
    for recipient_id in watcher_ids:
        event_kind = (
            "pr_review_thread_new" if is_review_thread
            else "chat_message_in_participated_thread"
        )
        _emit_one(
            recipient_user_id=recipient_id,
            event_kind=event_kind,
            category=CATEGORY_CHURN,
            actor_user_id=actor_user_id,
            rfc_slug=rfc_slug,
            branch_name=branch_name,
            pr_number=pr_number,
            thread_id=thread_id,
            details={"message_id": message_id},
        )


# ---------------------------------------------------------------------------
# Routing rules — per action_kind, a list of recipient×event×category
# ---------------------------------------------------------------------------


_ROUTING: dict[str, list[dict]] = {
    # §9.1: propose surfaces the new slug. The proposer is the actor;
    # the personal-direct beats fire on merge/decline, not on propose
    # itself. Admins and owners get a structural ping so the
    # pending-ideas queue isn't invisible.
    "propose_rfc": [
        {"event_kind": "proposal_opened_on_watched_topic",
         "category": CATEGORY_STRUCTURAL, "recipients": "admins"},
    ],
    # §9.3: merge fires personal-direct to the proposer per §15.4's
    # named-subject rule. Watchers also see the structural beat.
    "merge_proposal": [
        {"event_kind": "proposal_merged",
         "category": CATEGORY_PERSONAL, "recipients": "proposer"},
        {"event_kind": "proposal_merged",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "decline_proposal": [
        {"event_kind": "proposal_declined",
         "category": CATEGORY_PERSONAL, "recipients": "proposer"},
    ],
    # §10.1: PR opened. Watchers see it; the opener auto-watches but
    # doesn't get an inbox row for their own gesture.
    "open_branch_pr": [
        {"event_kind": "pr_opened",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "open_metadata_pr": [
        {"event_kind": "pr_opened",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "open_claim_pr": [
        {"event_kind": "claim_opened",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    # §10.5: merge fires personal-direct to the PR opener if someone
    # else merged; watchers see the structural beat regardless.
    "merge_branch_pr": [
        {"event_kind": "pr_merged",
         "category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
        {"event_kind": "pr_merged",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "withdraw_branch_pr": [
        {"event_kind": "pr_withdrawn",
         "category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
        {"event_kind": "pr_withdrawn",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "supersede_branch_pr": [
        {"event_kind": "pr_withdrawn",
         "category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
    ],
    # §8.6: accept_change is per-commit churn. Watchers (full stream)
    # get the row; following-only watchers don't.
    "accept_change": [
        {"event_kind": "pr_commit_added",
         "category": CATEGORY_CHURN, "recipients": "watchers_full"},
    ],
    "manual_flush": [
        {"event_kind": "pr_commit_added",
         "category": CATEGORY_CHURN, "recipients": "watchers_full"},
    ],
    "replay_change": [
        {"event_kind": "pr_commit_added",
         "category": CATEGORY_CHURN, "recipients": "watchers_full"},
    ],
    # §13.3: graduation_complete is personal-direct to owners (the
    # super-draft's owners list) and structural to watchers.
    "graduate_complete": [
        {"event_kind": "graduation_complete",
         "category": CATEGORY_PERSONAL, "recipients": "owners"},
        {"event_kind": "graduation_complete",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "graduate_start": [
        {"event_kind": "super_draft_graduation_ready",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "create_resolution_branch": [
        {"event_kind": "pr_conflict_with_main",
         "category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
    ],
    "create_branch": [
        # No notification — branch creation is a private gesture until
        # work lands on it. Auto-watch is the visible effect.
    ],
}


# ---------------------------------------------------------------------------
# Recipient resolvers
# ---------------------------------------------------------------------------


def _resolve_recipients(
    *,
    rule: dict,
    actor_user_id: int | None,
    rfc_slug: str,
    details: dict | None,
) -> set[int]:
    kind = rule["recipients"]
    if kind == "watchers_structural":
        # following (structural only) + watching (full stream) — both
        # see structural events.
        ids = _watcher_user_ids(rfc_slug, scope="any")
    elif kind == "watchers_full":
        ids = _watcher_user_ids(rfc_slug, scope="watching")
    elif kind == "owners":
        ids = _entry_owner_user_ids(rfc_slug)
    elif kind == "admins":
        ids = _admin_user_ids()
    elif kind == "proposer":
        ids = _proposer_user_id(rfc_slug)
    elif kind == "pr_opener_if_other":
        opener = _pr_opener_user_id(rfc_slug, details)
        ids = {opener} if opener is not None and opener != actor_user_id else set()
    else:
        log.warning("notify: unknown recipient kind %s", kind)
        return set()

    if actor_user_id is not None:
        ids.discard(actor_user_id)

    # §15.6: per-RFC mute suppresses every signal for the (user, slug).
    muted = _muted_user_ids_for_rfc(rfc_slug)
    ids -= muted

    # §15.8: per-user mute suppresses notifications produced by the actor
    # for each muter. (Inbox-volume only; content visibility unchanged.)
    if actor_user_id is not None:
        muters = _muters_of(actor_user_id)
        ids -= muters

    return ids


def _watcher_user_ids(rfc_slug: str, *, scope: str) -> set[int]:
    """scope: 'watching' = full stream only; 'any' = watching+following."""
    if scope == "watching":
        rows = db.conn().execute(
            "SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'watching'",
            (rfc_slug,),
        )
    else:
        rows = db.conn().execute(
            "SELECT user_id FROM watches WHERE rfc_slug = ? AND state IN ('watching', 'following')",
            (rfc_slug,),
        )
    return {r["user_id"] for r in rows}


def _entry_owner_user_ids(rfc_slug: str) -> set[int]:
    row = db.conn().execute(
        "SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
    ).fetchone()
    if row is None:
        return set()
    try:
        owners = json.loads(row["owners_json"] or "[]")
    except json.JSONDecodeError:
        return set()
    if not owners:
        return set()
    placeholders = ",".join("?" * len(owners))
    user_rows = db.conn().execute(
        f"SELECT id FROM users WHERE gitea_login IN ({placeholders})",
        tuple(owners),
    )
    return {r["id"] for r in user_rows}


def _admin_user_ids() -> set[int]:
    return {
        r["id"]
        for r in db.conn().execute("SELECT id FROM users WHERE role IN ('owner', 'admin')")
    }


def _scope_owner_user_ids(scope_type: str, scope_id: str) -> set[int]:
    """The Owners who administer a scope *across the subtree* (§22.8 / §22.11) —
    the recipients of a request-to-join, aggregated upward so the request reaches
    everyone who could grant it. For a `collection`: its collection-scope Owners,
    its project's Owners, the global Owners, and deployment owners/admins. For a
    `project`: its project-scope Owners plus global Owners and deployment
    owners/admins. (Mirrors the upward fold in `auth.can_invite_at_*`.)"""
    # Deployment owners/admins are global Owners by §B.1; explicit
    # scope_type='global' Owner grants join them.
    ids: set[int] = set(_admin_user_ids())
    for r in db.conn().execute(
        "SELECT user_id AS id FROM memberships WHERE scope_type = 'global' AND role = 'owner'"
    ):
        ids.add(r["id"])

    def _owners_at(stype: str, sid: str) -> None:
        for r in db.conn().execute(
            "SELECT user_id AS id FROM memberships "
            "WHERE scope_type = ? AND scope_id = ? AND role = 'owner'",
            (stype, sid),
        ):
            ids.add(r["id"])

    if scope_type == "collection":
        _owners_at("collection", scope_id)
        prow = db.conn().execute(
            "SELECT project_id FROM collections WHERE id = ?", (scope_id,)
        ).fetchone()
        if prow and prow["project_id"]:
            _owners_at("project", prow["project_id"])
    elif scope_type == "project":
        _owners_at("project", scope_id)
    return ids


def _proposer_user_id(rfc_slug: str) -> set[int]:
    row = db.conn().execute(
        """
        SELECT actor_user_id FROM actions
         WHERE action_kind = 'propose_rfc' AND rfc_slug = ?
         ORDER BY id LIMIT 1
        """,
        (rfc_slug,),
    ).fetchone()
    if row is None or row["actor_user_id"] is None:
        return set()
    return {row["actor_user_id"]}


def _pr_opener_user_id(rfc_slug: str, details: dict | None) -> int | None:
    """For pr_opener_if_other recipients, find the user who opened the PR.

    Uses cached_prs.opened_by (gitea_login) → users.id, falling back to
    the audit log if the cache row isn't there yet.
    """
    pr_number = (details or {}).get("pr_number")
    # When called from merge/withdraw routing, details is the original
    # action's details — pr_number is on the audit row, not here. Pull
    # it from the most-recent relevant action.
    row = db.conn().execute(
        """
        SELECT actor_user_id FROM actions
         WHERE action_kind = 'open_branch_pr' AND rfc_slug = ?
         ORDER BY id DESC LIMIT 1
        """,
        (rfc_slug,),
    ).fetchone()
    if row and row["actor_user_id"] is not None:
        return row["actor_user_id"]
    return pr_number  # last-ditch: keep typing consistent (rarely matters)


def _muted_user_ids_for_rfc(rfc_slug: str) -> set[int]:
    return {
        r["user_id"]
        for r in db.conn().execute(
            "SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'muted'",
            (rfc_slug,),
        )
    }


def _muters_of(actor_user_id: int) -> set[int]:
    return {
        r["muter_user_id"]
        for r in db.conn().execute(
            "SELECT muter_user_id FROM notification_user_mutes WHERE muted_user_id = ?",
            (actor_user_id,),
        )
    }


# ---------------------------------------------------------------------------
# Auto-watch upsert
# ---------------------------------------------------------------------------


def _bump_auto_watch(user_id: int, rfc_slug: str) -> None:
    """Per §15.6 substantive-gesture rule: upsert a `watching` row for
    the actor, never downgrade. Bumps `last_participation_at` for the
    90-day decay timer regardless of current state.

    The role-implicit watching for owners/arbiters is computed at fan-out
    time (recipient resolution), not stored on the row, so this upsert
    doesn't need to special-case them.
    """
    existing = db.conn().execute(
        "SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?",
        (user_id, rfc_slug),
    ).fetchone()
    now = "datetime('now')"
    if existing is None:
        db.conn().execute(
            f"""
            INSERT INTO watches
                (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
            VALUES (?, ?, 'watching', 'auto', {now}, {now})
            """,
            (user_id, rfc_slug),
        )
        return
    # Don't downgrade: a 'muted' explicit setting stays muted; a 'watching'
    # row stays watching. The only auto-upgrade is following → watching.
    if existing["state"] == "following" and existing["set_by"] != "explicit":
        db.conn().execute(
            f"UPDATE watches SET state = 'watching', last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?",
            (user_id, rfc_slug),
        )
    else:
        db.conn().execute(
            f"UPDATE watches SET last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?",
            (user_id, rfc_slug),
        )


# ---------------------------------------------------------------------------
# Inserting a notification row + dispatch
# ---------------------------------------------------------------------------


def _emit_one(
    *,
    recipient_user_id: int,
    event_kind: str,
    category: str,
    actor_user_id: int | None,
    rfc_slug: str | None,
    branch_name: str | None,
    pr_number: int | None,
    details: dict,
    thread_id: int | None = None,
) -> int:
    payload = {"category": category, **details}
    cur = db.conn().execute(
        """
        INSERT INTO notifications
            (recipient_user_id, event_kind, rfc_slug, branch_name, pr_number,
             thread_id, actor_user_id, payload)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            recipient_user_id,
            event_kind,
            rfc_slug,
            branch_name,
            pr_number,
            thread_id,
            actor_user_id,
            json.dumps(payload),
        ),
    )
    notif_id = cur.lastrowid

    row_payload = _row_payload(notif_id)
    _schedule_broadcast(recipient_user_id, "notification", row_payload)

    # Email dispatch: §15.4. Held during quiet hours per §15.8; held
    # during a global opt-out (bounce) per §15.4. Defers to the digest
    # otherwise per §15.5's exclusion rules.
    _schedule_email(notif_id, recipient_user_id, event_kind, category, row_payload)

    return notif_id


def _row_payload(notif_id: int) -> dict:
    row = db.conn().execute(
        """
        SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
               n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at,
               u.display_name AS actor_display, u.gitea_login AS actor_login,
               r.title AS rfc_title
          FROM notifications n
     LEFT JOIN users u ON u.id = n.actor_user_id
     LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
         WHERE n.id = ?
        """,
        (notif_id,),
    ).fetchone()
    if row is None:
        return {}
    try:
        extras = json.loads(row["payload"] or "{}")
    except json.JSONDecodeError:
        extras = {}
    return {
        "id": row["id"],
        "event_kind": row["event_kind"],
        "rfc_slug": row["rfc_slug"],
        "rfc_title": row["rfc_title"],
        "branch_name": row["branch_name"],
        "pr_number": row["pr_number"],
        "thread_id": row["thread_id"],
        "actor_user_id": row["actor_user_id"],
        "actor_login": row["actor_login"],
        "actor_display": row["actor_display"],
        "created_at": row["created_at"],
        "read_at": row["read_at"],
        "category": extras.get("category"),
        "summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
        "extras": extras,
    }


def render_summary(event_kind: str, actor_display: str | None, rfc_title: str | None, extras: dict) -> str:
    """One short sentence shared by the inbox row and the email body
    per §15.4. Actor is the underlying user per §15.9; null actor renders
    as "the app" so a system-generated event still reads as a sentence."""
    actor = actor_display or "the app"
    title = rfc_title or extras.get("slug") or "this RFC"
    if event_kind == "proposal_merged":
        return f"{actor} merged your proposal — {title} is now a super-draft."
    if event_kind == "proposal_declined":
        return f"{actor} declined your proposal for {title}."
    if event_kind == "proposal_opened_on_watched_topic":
        return f"{actor} proposed a new RFC: {title}."
    if event_kind == "pr_opened":
        return f"{actor} opened a PR on {title}."
    if event_kind == "pr_merged":
        return f"{actor} merged a PR on {title}."
    if event_kind == "pr_withdrawn":
        return f"{actor} withdrew a PR on {title}."
    if event_kind == "pr_commit_added":
        return f"{actor} added a commit on {title}."
    if event_kind == "pr_review_thread_new":
        return f"{actor} opened a review thread on {title}."
    if event_kind == "pr_review_thread_reply":
        return f"{actor} replied to your review thread on {title}."
    if event_kind == "chat_message_in_participated_thread":
        return f"{actor} posted a chat message on {title}."
    if event_kind == "chat_reply_to_my_message":
        return f"{actor} replied to your message on {title}."
    if event_kind == "claim_opened":
        return f"{actor} opened a claim PR on {title}."
    if event_kind == "graduation_complete":
        return f"{title} graduated to an active RFC."
    if event_kind == "super_draft_graduation_ready":
        return f"{actor} began graduating {title}."
    if event_kind == "pr_conflict_with_main":
        return f"{actor} started a resolution branch on {title}."
    if event_kind == "contribution_request_on_pending_rfc":
        # Roadmap #28 Part 3: owner-facing, actionable. The term is the
        # super-draft reference that surfaced the offer; the inbox row
        # renders Accept/Decline beneath this line.
        term = extras.get("matched_term") or title
        return f"{actor} wants to contribute to your pending RFC for '{term}'."
    if event_kind == "contribution_request_accepted":
        return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
    if event_kind == "contribution_request_declined":
        return f"{actor} declined your request to contribute to {title}."
    if event_kind == "scope_role_granted":
        # §22 S4 (C.2): names the role and the scope (the project, and the
        # collection when collection-scoped) per "a §15 notification naming the
        # project and role".
        role_label = "Owner" if extras.get("role") == "owner" else "RFC Contributor"
        project_label = extras.get("project_name") or extras.get("project_id") or "a project"
        scope_type = extras.get("scope_type")
        if scope_type == "collection":
            col_label = extras.get("collection_name") or extras.get("scope_id") or "a collection"
            return f"{actor} granted you {role_label} on collection {project_label}/{col_label}."
        if scope_type == "global":
            return f"{actor} granted you {role_label} across the whole deployment."
        return f"{actor} granted you {role_label} on project {project_label}."
    if event_kind == "join_request_on_scope":
        # §22.8: owner-facing, actionable. Names who wants in, where, and as
        # what; the inbox row renders Accept/Decline beneath this line.
        role_label = "Owner" if extras.get("requested_role") == "owner" else "RFC Contributor"
        scope_type = extras.get("scope_type")
        scope_label = extras.get("scope_name") or extras.get("scope_id") or "a scope"
        where = (
            f"collection {scope_label}" if scope_type == "collection" else f"project {scope_label}"
        )
        return f"{actor} asked to join {where} as {role_label}."
    if event_kind == "join_request_accepted":
        role_label = "Owner" if extras.get("granted_role") == "owner" else "RFC Contributor"
        scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
        return f"{actor} accepted your request to join {scope_label} — you're in as {role_label}."
    if event_kind == "join_request_declined":
        scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
        return f"{actor} declined your request to join {scope_label}."
    if event_kind == "new_beta_request":
        # v0.9.0: framework-scoped, not RFC-scoped. The actor (the
        # requester) and the captured full name + email read as
        # one self-contained sentence; the inbox row and the email
        # body share this text per §15.4.
        full_name = extras.get("requester_display") or actor
        email_addr = extras.get("requester_email") or ""
        if email_addr:
            return f"New beta-access request from {full_name} ({email_addr})."
        return f"New beta-access request from {full_name}."
    return f"{event_kind} on {title}"


def _schedule_broadcast(user_id: int, event: str, payload: Any) -> None:
    """Best-effort SSE push; survives the absence of a running loop so
    sync test clients still write the row."""
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        return
    loop.create_task(_broadcast(user_id, event, payload))


def _schedule_email(
    notif_id: int,
    recipient_user_id: int,
    event_kind: str,
    category: str,
    payload: dict,
) -> None:
    """§15.4 dispatch — synchronous so the email_sent_at timestamp lands
    in the same tick as the notification row, which is what the §15.5
    digest's exclusion rule 1 keys on. The actual SMTP send happens in
    `email.py`; this just consults the recipient's preferences and quiet
    hours, then calls into the adapter."""
    from . import email as email_mod  # avoid import cycle

    email_mod.maybe_send(
        notif_id=notif_id,
        recipient_user_id=recipient_user_id,
        event_kind=event_kind,
        category=category,
        payload=payload,
    )


# ---------------------------------------------------------------------------
# Per-user mute and watch helpers used by api_notifications
# ---------------------------------------------------------------------------


def list_inbox(
    *,
    user_id: int,
    unread: bool | None = None,
    rfc_slug: str | None = None,
    category: str | None = None,
    actor_user_id: int | None = None,
    bundled: bool = False,
    limit: int = 200,
) -> dict:
    """§15.2: inbox query. Filter chips are AND-combined.

    When `bundled=True` is set, rows are grouped server-side by
    (rfc_slug, event_kind) per the §15.2 bundle toggle, with the most
    recent timestamp on each bundle.
    """
    where = ["recipient_user_id = ?"]
    args: list[Any] = [user_id]
    if unread:
        where.append("read_at IS NULL")
    if rfc_slug:
        where.append("rfc_slug = ?")
        args.append(rfc_slug)
    if actor_user_id is not None:
        where.append("actor_user_id = ?")
        args.append(actor_user_id)
    rows = db.conn().execute(
        f"""
        SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
               n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at,
               u.display_name AS actor_display, u.gitea_login AS actor_login,
               r.title AS rfc_title
          FROM notifications n
     LEFT JOIN users u ON u.id = n.actor_user_id
     LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
         WHERE {' AND '.join(where)}
         ORDER BY n.id DESC
         LIMIT ?
        """,
        (*args, limit),
    ).fetchall()
    items = []
    for row in rows:
        try:
            extras = json.loads(row["payload"] or "{}")
        except json.JSONDecodeError:
            extras = {}
        if category and extras.get("category") != category:
            continue
        items.append({
            "id": row["id"],
            "event_kind": row["event_kind"],
            "rfc_slug": row["rfc_slug"],
            "rfc_title": row["rfc_title"],
            "branch_name": row["branch_name"],
            "pr_number": row["pr_number"],
            "thread_id": row["thread_id"],
            "actor_user_id": row["actor_user_id"],
            "actor_login": row["actor_login"],
            "actor_display": row["actor_display"],
            "created_at": row["created_at"],
            "read_at": row["read_at"],
            "category": extras.get("category"),
            "summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
            # The row's payload, surfaced for kinds that render inline
            # detail (e.g. #28 Part 3's contribute-request who/why/use-case
            # + Accept/Decline). Safe to expose: a recipient only ever sees
            # their own notifications.
            "extras": extras,
        })

    if bundled:
        items = _bundle(items)

    unread_count = db.conn().execute(
        "SELECT COUNT(*) AS c FROM notifications WHERE recipient_user_id = ? AND read_at IS NULL",
        (user_id,),
    ).fetchone()["c"]

    return {"items": items, "unread_count": unread_count}


def _bundle(items: list[dict]) -> list[dict]:
    """§15.2: collapse per-row notifications into per-(RFC, event_kind)
    bundles; the bundle's representative row is the most-recent
    constituent, with a `bundled_ids` array carrying the rest."""
    buckets: dict[tuple, list[dict]] = {}
    for it in items:
        key = (it["rfc_slug"], it["event_kind"])
        buckets.setdefault(key, []).append(it)
    bundled = []
    for key, rows in buckets.items():
        rep = dict(rows[0])  # most recent (sorted DESC above)
        rep["bundled_ids"] = [r["id"] for r in rows]
        rep["bundled_count"] = len(rows)
        bundled.append(rep)
    bundled.sort(key=lambda r: r["created_at"], reverse=True)
    return bundled


def mark_read_by_filter(
    *,
    user_id: int,
    unread: bool | None = None,
    rfc_slug: str | None = None,
    category: str | None = None,
    actor_user_id: int | None = None,
    ids: Iterable[int] | None = None,
) -> int:
    """`Mark all read` per §15.2 — respects the active filter so the
    user can mark all churn read without touching personal-direct."""
    where = ["recipient_user_id = ?", "read_at IS NULL"]
    args: list[Any] = [user_id]
    if rfc_slug:
        where.append("rfc_slug = ?")
        args.append(rfc_slug)
    if actor_user_id is not None:
        where.append("actor_user_id = ?")
        args.append(actor_user_id)
    if ids:
        ids = list(ids)
        if not ids:
            return 0
        placeholders = ",".join("?" * len(ids))
        where.append(f"id IN ({placeholders})")
        args.extend(ids)
    # Category lives inside payload JSON; we filter post-fetch to keep
    # the SQL portable.
    rows = db.conn().execute(
        f"SELECT id, payload FROM notifications WHERE {' AND '.join(where)}",
        args,
    ).fetchall()
    selected_ids: list[int] = []
    for r in rows:
        if category:
            try:
                extras = json.loads(r["payload"] or "{}")
            except json.JSONDecodeError:
                extras = {}
            if extras.get("category") != category:
                continue
        selected_ids.append(r["id"])
    if not selected_ids:
        return 0
    placeholders = ",".join("?" * len(selected_ids))
    db.conn().execute(
        f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})",
        selected_ids,
    )
    for nid in selected_ids:
        _schedule_broadcast(user_id, "read", {"id": nid})
    return len(selected_ids)


def reconcile_seen_advance(
    *,
    user_id: int,
    rfc_slug: str,
    branch_name: str | None = None,
    pr_number: int | None = None,
) -> int:
    """§15.7 reconciler — when a scope cursor advances on visit, mark
    matching unread notifications read. Called from the chat-seen and
    pr-seen advance endpoints.
    """
    where = ["recipient_user_id = ?", "read_at IS NULL", "rfc_slug = ?"]
    args: list[Any] = [user_id, rfc_slug]
    if pr_number is not None:
        where.append("pr_number = ?")
        args.append(pr_number)
    elif branch_name is not None:
        where.append("branch_name = ?")
        args.append(branch_name)
    rows = db.conn().execute(
        f"SELECT id FROM notifications WHERE {' AND '.join(where)}",
        args,
    ).fetchall()
    ids = [r["id"] for r in rows]
    if not ids:
        return 0
    placeholders = ",".join("?" * len(ids))
    db.conn().execute(
        f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})",
        ids,
    )
    for nid in ids:
        _schedule_broadcast(user_id, "read", {"id": nid})
    return len(ids)


# ---------------------------------------------------------------------------
# 90-day decay sweep (§15.6) — called by the digest job's nightly loop
# ---------------------------------------------------------------------------


def decay_watches() -> int:
    """Downgrade `watching` rows whose last_participation_at is older
    than 90 days to `following`. Explicit and role-implicit rows are
    exempt; role-implicit isn't stored on the row, so it's a no-op for
    those. Returns the number of rows downgraded."""
    cur = db.conn().execute(
        """
        UPDATE watches
           SET state = 'following', last_participation_at = datetime('now')
         WHERE state = 'watching'
           AND set_by = 'auto'
           AND (last_participation_at IS NULL
                OR datetime(last_participation_at, '+90 days') < datetime('now'))
        """
    )
    return cur.rowcount or 0
