"""§17 endpoints for the §15 notifications surface (Slice 6).

The endpoints in this module are:

  - `GET  /api/notifications`                              — §15.2 inbox listing
  - `POST /api/notifications/<id>/read`                    — §15.2/§15.7
  - `POST /api/notifications/read`                         — §15.2 mark by filter
  - `GET  /api/notifications/stream`                       — §15.3 SSE
  - `GET  /api/watches`                                    — §15.6
  - `POST /api/rfcs/<slug>/watch`                          — §15.6 explicit set
  - `GET  /api/users/me/notification-preferences`          — §15.4 / §15.5
  - `POST /api/users/me/notification-preferences`          — set
  - `GET  /api/users/me/quiet-hours`                       — §15.8
  - `POST /api/users/me/quiet-hours`                       — set / clear
  - `POST /api/users/<id>/notification-mute`               — §15.8
  - `DELETE /api/users/<id>/notification-mute`             — §15.8
  - `GET  /api/users/me/cookie-consent`                    — §14.5
  - `PUT  /api/users/me/cookie-consent`                    — §14.5
  - `GET  /api/email/unsubscribe`                          — §15.4 one-click
  - `POST /api/webhooks/email-bounce`                      — §15.4 receiver

The `branches/<branch>/chat-seen` advance lives in `api_branches` next
to the other branch-scoped endpoints; it calls into `notify.reconcile_seen_advance`
per §15.7. The `prs/<n>/seen` endpoint in `api_prs` does the same.
"""
from __future__ import annotations

import asyncio
import json
import logging
from typing import Any

from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from itsdangerous import BadSignature
from pydantic import BaseModel, Field

from . import auth, db, email as email_mod, notify
from .config import Config

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------


class WatchBody(BaseModel):
    state: str = Field(pattern="^(watching|following|muted)$")


class PreferencesBody(BaseModel):
    email_personal_direct: bool | None = None
    email_watched_structural: bool | None = None
    email_admin_actionable: bool | None = None
    digest_cadence: str | None = Field(default=None, pattern="^(off|weekly|daily)$")


class QuietHoursBody(BaseModel):
    start: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$")
    end: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$")
    timezone: str | None = None


class MarkReadBody(BaseModel):
    ids: list[int] | None = None
    rfc_slug: str | None = None
    category: str | None = None
    actor_user_id: int | None = None


class BounceBody(BaseModel):
    email: str = Field(min_length=3, max_length=320)
    kind: str = Field(default="hard")  # 'hard' or 'complaint'
    # v0.18.0 Slice 5: when the bounce provider includes the
    # original Message-ID, the framework correlates it back to
    # the matching `outbound_emails` row and stamps
    # `status='bounced'`. Optional — providers that don't surface
    # the Message-ID still flip the global opt-out via the email
    # match, but lose the per-message attribution.
    message_id: str | None = Field(default=None, max_length=1000)


class CookieConsentBody(BaseModel):
    # `essential` is always true at the surface; we accept it for symmetry
    # but never persist a false value (the framework's strictly-necessary
    # cookies are not user-optional per SPEC §14.5).
    essential: bool = True
    analytics: bool = False
    other: bool = False


# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------


def make_router(config: Config) -> APIRouter:
    router = APIRouter()

    # ----- Inbox listing + mark-read -----

    @router.get("/api/notifications")
    async def list_notifications(
        request: Request,
        unread: bool = False,
        rfc_slug: str | None = None,
        category: str | None = None,
        actor_user_id: int | None = None,
        bundled: bool = False,
    ) -> dict[str, Any]:
        viewer = auth.require_user(request)
        return notify.list_inbox(
            user_id=viewer.user_id,
            unread=unread,
            rfc_slug=rfc_slug,
            category=category,
            actor_user_id=actor_user_id,
            bundled=bundled,
        )

    @router.post("/api/notifications/{notif_id}/read")
    async def mark_one_read(notif_id: int, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        row = db.conn().execute(
            "SELECT id FROM notifications WHERE id = ? AND recipient_user_id = ?",
            (notif_id, viewer.user_id),
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Notification not found")
        db.conn().execute(
            "UPDATE notifications SET read_at = datetime('now') WHERE id = ? AND read_at IS NULL",
            (notif_id,),
        )
        # Push the read event so other tabs update their badge counts.
        asyncio.create_task(notify._broadcast(viewer.user_id, "read", {"id": notif_id}))
        return {"ok": True}

    @router.post("/api/notifications/read")
    async def mark_filtered_read(body: MarkReadBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        marked = notify.mark_read_by_filter(
            user_id=viewer.user_id,
            rfc_slug=body.rfc_slug,
            category=body.category,
            actor_user_id=body.actor_user_id,
            ids=body.ids,
        )
        return {"marked": marked}

    @router.get("/api/notifications/stream")
    async def stream_notifications(request: Request):
        viewer = auth.require_user(request)
        sub = await notify.subscribe(viewer.user_id)

        async def event_stream():
            # On open, send the current unread count as a snapshot so
            # the badge initializes correctly without a second request.
            count = db.conn().execute(
                "SELECT COUNT(*) AS c FROM notifications WHERE recipient_user_id = ? AND read_at IS NULL",
                (viewer.user_id,),
            ).fetchone()["c"]
            yield _sse("snapshot", {"unread_count": count})
            try:
                while True:
                    if await request.is_disconnected():
                        break
                    try:
                        evt = await asyncio.wait_for(sub.queue.get(), timeout=15.0)
                    except asyncio.TimeoutError:
                        # Keep-alive comment line; clients ignore comments.
                        yield ": keep-alive\n\n"
                        continue
                    yield _sse(evt.get("event", "update"), evt.get("payload"))
            finally:
                await notify.unsubscribe(sub)

        headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
        return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)

    # ----- Watches -----

    @router.get("/api/watches")
    async def list_watches(request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        rows = db.conn().execute(
            """
            SELECT w.id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at,
                   r.title AS rfc_title
              FROM watches w
         LEFT JOIN cached_rfcs r ON r.slug = w.rfc_slug
             WHERE w.user_id = ?
             ORDER BY w.set_at DESC
            """,
            (viewer.user_id,),
        ).fetchall()
        return {
            "items": [
                {
                    "id": r["id"],
                    "rfc_slug": r["rfc_slug"],
                    "rfc_title": r["rfc_title"],
                    "state": r["state"],
                    "set_by": r["set_by"],
                    "set_at": r["set_at"],
                    "last_participation_at": r["last_participation_at"],
                }
                for r in rows
            ]
        }

    @router.post("/api/rfcs/{slug}/watch")
    async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        rfc = db.conn().execute("SELECT (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
        if rfc is None:
            raise HTTPException(404, "RFC not found")
        # §22.5 visibility gate (subtractive): gated → 404 to non-members.
        auth.require_project_readable(viewer, rfc["project_id"])
        db.conn().execute(
            """
            INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
            VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
            ON CONFLICT(collection_id, user_id, rfc_slug) DO UPDATE SET
                state = excluded.state,
                set_by = 'explicit',
                set_at = excluded.set_at
            """,
            (viewer.user_id, slug, body.state),
        )
        return {"ok": True, "state": body.state, "set_by": "explicit"}

    # ----- Per-user notification preferences (§15.4 / §15.5) -----

    @router.get("/api/users/me/notification-preferences")
    async def get_prefs(request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        row = db.conn().execute(
            """
            SELECT email_personal_direct, email_watched_structural,
                   email_admin_actionable, digest_cadence, email_opt_out_all
              FROM users WHERE id = ?
            """,
            (viewer.user_id,),
        ).fetchone()
        return {
            "email_personal_direct": bool(row["email_personal_direct"]),
            "email_watched_structural": bool(row["email_watched_structural"]),
            "email_admin_actionable": bool(row["email_admin_actionable"]),
            "email_watched_churn": False,  # §15.4 permanently off
            "email_opt_out_all": bool(row["email_opt_out_all"]),
            "digest_cadence": row["digest_cadence"],
        }

    @router.post("/api/users/me/notification-preferences")
    async def set_prefs(body: PreferencesBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        sets = []
        args: list[Any] = []
        if body.email_personal_direct is not None:
            sets.append("email_personal_direct = ?")
            args.append(1 if body.email_personal_direct else 0)
        if body.email_watched_structural is not None:
            sets.append("email_watched_structural = ?")
            args.append(1 if body.email_watched_structural else 0)
        if body.email_admin_actionable is not None:
            sets.append("email_admin_actionable = ?")
            args.append(1 if body.email_admin_actionable else 0)
        if body.digest_cadence is not None:
            sets.append("digest_cadence = ?")
            args.append(body.digest_cadence)
        if not sets:
            return {"ok": True}
        args.append(viewer.user_id)
        db.conn().execute(f"UPDATE users SET {', '.join(sets)} WHERE id = ?", args)
        return {"ok": True}

    # ----- Quiet hours (§15.8) -----

    @router.get("/api/users/me/quiet-hours")
    async def get_quiet_hours(request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        row = db.conn().execute(
            """
            SELECT notification_quiet_hours_start AS start,
                   notification_quiet_hours_end AS end_,
                   notification_quiet_hours_timezone AS tz
              FROM users WHERE id = ?
            """,
            (viewer.user_id,),
        ).fetchone()
        return {"start": row["start"], "end": row["end_"], "timezone": row["tz"]}

    @router.post("/api/users/me/quiet-hours")
    async def set_quiet_hours(body: QuietHoursBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        # Per §15.8: all three to set, all null to clear. Reject partials.
        filled = [body.start, body.end, body.timezone]
        if any(filled) and not all(filled):
            raise HTTPException(422, "Set start, end, and timezone together, or clear all three")
        db.conn().execute(
            """
            UPDATE users
               SET notification_quiet_hours_start = ?,
                   notification_quiet_hours_end = ?,
                   notification_quiet_hours_timezone = ?
             WHERE id = ?
            """,
            (body.start, body.end, body.timezone, viewer.user_id),
        )
        return {"ok": True}

    # ----- Per-user notification mute (§15.8) -----

    @router.get("/api/users/me/notification-mutes")
    async def list_user_mutes(request: Request) -> dict[str, Any]:
        """Slice 7: the §15.8 mute list the settings surface renders.
        Joined against users so the settings UI can show @gitea_login and
        display_name without a second round-trip.
        """
        viewer = auth.require_user(request)
        rows = db.conn().execute(
            """
            SELECT m.muted_user_id, m.muted_at,
                   u.gitea_login, u.display_name
              FROM notification_user_mutes m
              JOIN users u ON u.id = m.muted_user_id
             WHERE m.muter_user_id = ?
             ORDER BY m.muted_at DESC
            """,
            (viewer.user_id,),
        ).fetchall()
        return {
            "items": [
                {
                    "muted_user_id": r["muted_user_id"],
                    "gitea_login": r["gitea_login"],
                    "display_name": r["display_name"],
                    "muted_at": r["muted_at"],
                }
                for r in rows
            ]
        }

    @router.post("/api/users/{user_id}/notification-mute")
    async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        if user_id == viewer.user_id:
            raise HTTPException(422, "You cannot mute yourself")
        # Refusal per §15.8: admins/owners cannot mute notifications
        # from anyone (they are exercising app-wide authority); arbiters
        # cannot mute participants on RFCs where they hold authority.
        if viewer.role in ("owner", "admin"):
            raise HTTPException(
                403,
                "Admins and owners cannot mute notifications — the role requires receiving signals from everyone",
            )
        if _is_arbiter_with_overlap(viewer.user_id, user_id):
            raise HTTPException(
                403,
                "You hold arbiter authority on an RFC where this user is active — muting is refused per §15.8",
            )
        target = db.conn().execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
        if target is None:
            raise HTTPException(404, "User not found")
        db.conn().execute(
            """
            INSERT OR IGNORE INTO notification_user_mutes (muter_user_id, muted_user_id)
            VALUES (?, ?)
            """,
            (viewer.user_id, user_id),
        )
        return {"ok": True}

    @router.delete("/api/users/{user_id}/notification-mute")
    async def remove_user_mute(user_id: int, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        db.conn().execute(
            "DELETE FROM notification_user_mutes WHERE muter_user_id = ? AND muted_user_id = ?",
            (viewer.user_id, user_id),
        )
        return {"ok": True}

    # ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
    #
    # The shape is intentionally small: three flags + a recorded-at stamp.
    # The banner's local-vs-server precedence rule lives in the frontend
    # (`consent.js`): on sign-in, the server row (if any) overrides local;
    # otherwise local is uploaded.

    @router.get("/api/users/me/cookie-consent")
    async def get_cookie_consent(request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        row = db.conn().execute(
            """
            SELECT essential, analytics, other_cookies, recorded_at
              FROM cookie_consent WHERE user_id = ?
            """,
            (viewer.user_id,),
        ).fetchone()
        if row is None:
            return {
                "essential": True,
                "analytics": False,
                "other": False,
                "recorded_at": None,
            }
        return {
            "essential": bool(row["essential"]),
            "analytics": bool(row["analytics"]),
            "other": bool(row["other_cookies"]),
            "recorded_at": row["recorded_at"],
        }

    @router.put("/api/users/me/cookie-consent")
    async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        # `essential` is the framework's strictly-necessary set; the
        # surface accepts the flag for symmetry but never persists a
        # false value. SPEC §14.5: a deployment that wants to make
        # session-cookie storage optional must change the framework
        # contract, not flip a flag here.
        db.conn().execute(
            """
            INSERT INTO cookie_consent
                   (user_id, essential, analytics, other_cookies, recorded_at)
            VALUES (?, 1, ?, ?, datetime('now'))
            ON CONFLICT(user_id) DO UPDATE SET
                essential     = 1,
                analytics     = excluded.analytics,
                other_cookies = excluded.other_cookies,
                recorded_at   = excluded.recorded_at
            """,
            (
                viewer.user_id,
                1 if body.analytics else 0,
                1 if body.other else 0,
            ),
        )
        row = db.conn().execute(
            "SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
            (viewer.user_id,),
        ).fetchone()
        return {
            "ok": True,
            "essential": True,
            "analytics": bool(body.analytics),
            "other": bool(body.other),
            "recorded_at": row["recorded_at"] if row else None,
        }

    # ----- Email: one-click unsubscribe + bounce webhook -----

    # v0.18.0: the category → column map. The `all` synthetic
    # category lands the bundle's one-click on the global opt-out
    # flag (per `email._send_bundle` in v0.18.0 Slice 2 — a bundle
    # spans multiple categories, so a per-category flip wouldn't
    # honor the user's intent).
    _CATEGORY_COLUMN: dict[str, str] = {
        "personal-direct": "email_personal_direct",
        "structural": "email_watched_structural",
        "admin-actionable": "email_admin_actionable",
        "all": "email_opt_out_all",
    }

    def _apply_unsubscribe(user_id: int, category: str) -> bool:
        """Flip the matching column. Returns True on success, False
        if the category is unknown. Idempotent — running twice on
        the same (user, category) is harmless (it sets the column
        to its current value)."""
        column = _CATEGORY_COLUMN.get(category)
        if column is None:
            return False
        # `all` sets the flag to 1 (opt out); per-category sets to 0
        # (turn that category off). The column semantic is "1 means
        # don't send"; the per-category booleans are "1 means do
        # send". Different polarities, hence the case split.
        if category == "all":
            db.conn().execute(
                f"UPDATE users SET {column} = 1 WHERE id = ?", (user_id,)
            )
        else:
            db.conn().execute(
                f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,)
            )
        return True

    @router.get("/api/email/unsubscribe")
    async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse:
        try:
            user_id, category = email_mod.verify_unsubscribe_token(t)
        except BadSignature:
            return HTMLResponse(
                "<h1>Link expired or invalid</h1>"
                "<p>Open the app to manage your notification preferences directly.</p>",
                status_code=400,
            )
        if not _apply_unsubscribe(user_id, category):
            return HTMLResponse(
                f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
            )
        if category == "all":
            body = (
                "<h1>Unsubscribed</h1><p>You will no longer receive any email "
                "from this app. You can re-enable individual categories from "
                "your notification preferences after signing in.</p>"
            )
        else:
            body = (
                f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
                f"You can re-enable them in your notification preferences.</p>"
            )
        return HTMLResponse(body)

    @router.post("/api/email/unsubscribe")
    async def email_unsubscribe_post(
        request: Request,
        t: str = Query(..., description="Signed token from the List-Unsubscribe header"),
    ) -> dict[str, Any]:
        """v0.18.0: RFC 8058 one-click endpoint.

        Gmail and Yahoo POST `List-Unsubscribe=One-Click` (as a
        form-encoded body) to the URL in the `List-Unsubscribe`
        header when the user clicks their MUA's "Unsubscribe"
        button. The endpoint MUST accept POST (per the
        `List-Unsubscribe-Post` header we advertise) and MUST be
        idempotent.

        The body content is checked loosely — RFC 8058 says it
        SHOULD be exactly `List-Unsubscribe=One-Click`, but some
        intermediaries strip / re-encode the body, so the
        framework accepts any POST to the URL once the token
        verifies. The bar is that the token signature carries the
        authority; the body is hint-only.
        """
        try:
            user_id, category = email_mod.verify_unsubscribe_token(t)
        except BadSignature:
            raise HTTPException(400, "Invalid or expired token")
        if not _apply_unsubscribe(user_id, category):
            raise HTTPException(400, f"Unknown category: {category}")
        return {"ok": True, "category": category}

    @router.post("/api/webhooks/email-bounce")
    async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
        # §15.4: hard bounces and complaints flip the global opt-out.
        # Per the §19.2 "email bounce webhook authentication" candidate
        # Slice 8 settles: when `WEBHOOK_EMAIL_BOUNCE_SECRET` is set in
        # env, the webhook requires the same value in the
        # `X-Webhook-Secret` header. The shared-secret shape is the
        # narrowest seam that covers the major providers — Sendgrid's
        # `X-Twilio-Email-Event-Webhook-Signature`, SES via SNS topic
        # signatures, Postmark's HTTP basic auth — without forcing a
        # per-provider verifier today. When the operator wires a real
        # SMTP provider they pick the equivalent shared-secret or
        # rotate the value behind whichever signature scheme the
        # provider supports. When the env var is unset the webhook
        # stays unauthenticated for dev (the v1 contract).
        import os as _os
        expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
        # v0.25.0 (audit 0026 M5): fail closed. An unset secret used to
        # leave this endpoint fully unauthenticated — anyone could suppress
        # any user's mail by POSTing their address (email_opt_out_all flip
        # below). Now an unset secret DISABLES the endpoint (503) instead
        # of opening it. A dev that genuinely wants it open opts in
        # explicitly with RFC_APP_INSECURE_BOUNCE_WEBHOOK=1, mirroring the
        # RFC_APP_INSECURE_WEBHOOKS dev-bypass on the Gitea hook.
        if not expected:
            if _os.environ.get("RFC_APP_INSECURE_BOUNCE_WEBHOOK", "").strip() == "1":
                log.warning(
                    "email-bounce webhook running UNAUTHENTICATED "
                    "(RFC_APP_INSECURE_BOUNCE_WEBHOOK=1) — never set this in production"
                )
            else:
                log.error(
                    "email-bounce webhook refused: WEBHOOK_EMAIL_BOUNCE_SECRET is unset "
                    "(set the secret to enable, or RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 for dev)"
                )
                raise HTTPException(503, "Bounce webhook not configured")
        else:
            received = request.headers.get("X-Webhook-Secret", "")
            import hmac as _hmac
            if not received or not _hmac.compare_digest(expected, received):
                raise HTTPException(401, "Invalid webhook signature")
        # v0.18.0 Slice 5: correlate the bounce back to the
        # matching outbound_emails row if the provider supplied
        # the Message-ID. The hard-bounce -> global-opt-out
        # logic below still fires regardless; this is an
        # additional audit signal.
        correlated_row_id: int | None = None
        if body.message_id:
            correlated = db.conn().execute(
                "SELECT id FROM outbound_emails WHERE message_id = ?",
                (body.message_id,),
            ).fetchone()
            if correlated is not None:
                correlated_row_id = correlated["id"]
                db.conn().execute(
                    "UPDATE outbound_emails SET status = 'bounced', "
                    "error = COALESCE(error, '') || ? WHERE id = ?",
                    (f"bounce ({body.kind})", correlated_row_id),
                )
                log.info(
                    "email-bounce: correlated message_id=%s -> outbound_emails.id=%s",
                    body.message_id, correlated_row_id,
                )
            else:
                log.info(
                    "email-bounce: message_id=%s did not match any "
                    "outbound_emails row (provider may be replaying an old bounce, "
                    "or the row was pruned)",
                    body.message_id,
                )

        row = db.conn().execute(
            "SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
        ).fetchone()
        if row is None:
            return {"ok": True, "matched": False, "correlated_id": correlated_row_id}
        db.conn().execute(
            "UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
        )
        log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
        return {"ok": True, "matched": True, "correlated_id": correlated_row_id}

    return router


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _sse(event: str, payload: Any) -> str:
    return f"event: {event}\ndata: {json.dumps(payload)}\n\n"


def _is_arbiter_with_overlap(muter_user_id: int, muted_user_id: int) -> bool:
    """§15.8: an arbiter cannot mute notifications from a user who is
    active on an RFC the arbiter has authority on. Active is defined
    here as "has a watches row" — a low bar, but it's the cheapest
    proxy for participation and the spec intends a generous refusal.
    """
    muter_login_row = db.conn().execute(
        "SELECT gitea_login FROM users WHERE id = ?", (muter_user_id,)
    ).fetchone()
    muted_login_row = db.conn().execute(
        "SELECT gitea_login FROM users WHERE id = ?", (muted_user_id,)
    ).fetchone()
    if not muter_login_row or not muted_login_row:
        return False
    muter_login = muter_login_row["gitea_login"]
    rfcs = db.conn().execute(
        "SELECT slug, arbiters_json FROM cached_rfcs WHERE state = 'active'"
    ).fetchall()
    for r in rfcs:
        try:
            arbiters = json.loads(r["arbiters_json"] or "[]")
        except json.JSONDecodeError:
            continue
        if muter_login in arbiters:
            other_active = db.conn().execute(
                "SELECT 1 FROM watches WHERE user_id = ? AND rfc_slug = ?",
                (muted_user_id, r["slug"]),
            ).fetchone()
            if other_active:
                return True
    return False
