"""End-to-end integration tests for the Slice 6 vertical (§15 in full).

The fan-out chokepoint in `notify.py` is the chief structural commitment
of the slice. These tests prove:

  * §15.1 routing: every action_kind that maps to a §15 signal lands a
    `notifications` row of the right event_kind and category.
  * §15.6 auto-watch: every write that names an rfc_slug upserts a
    watches row for the actor (substantive-gesture rule).
  * §15.2 inbox listing + filter chips: unread, rfc_slug, category, and
    actor_user_id filters compose AND-wise.
  * §15.7 reconciler: advancing branch_chat_seen or pr_seen marks
    matching unread notifications read.
  * §15.8 per-user mute: the mute suppresses inbox rows from the muted
    actor; per-RFC mute suppresses every row for the slug.
  * §15.8 quiet hours: notifications still land; email is held.
  * §15.5 digest: cadence window roll-over emits a digest; a second
    run during the same window emits nothing.
  * §15.4 email-bounce webhook: sets the global opt-out and
    short-circuits future email dispatch.
  * §15.4 unsubscribe signed-URL: GET /api/email/unsubscribe?t=… flips
    one category off.
  * §15.3 SSE snapshot: opening the stream yields the current
    unread_count.
"""
from __future__ import annotations

import json as _json

import pytest

from test_propose_vertical import (  # noqa: F401
    FakeGitea,
    app_with_fake_gitea,
    provision_user_row,
    sign_in_as,
    tmp_env,
)
from test_super_draft_vertical import seed_super_draft  # noqa: F401


PITCH = "Open Human Model is a framework for representing humans."


# ---------------------------------------------------------------------------
# Fan-out: producer-side rules per §15.1
# ---------------------------------------------------------------------------


def test_propose_rfc_auto_watches_the_proposer(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        client.post("/api/rfcs/propose", json={
            "title": "Open Human Model", "slug": "ohm", "pitch": PITCH, "tags": [],
        })

        # Auto-watch lands per §15.6 substantive gesture.
        row = db.conn().execute(
            "SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?",
            (2, "ohm"),
        ).fetchone()
        assert row is not None
        assert row["state"] == "watching"
        assert row["set_by"] == "auto"


def test_proposal_merged_lands_personal_direct_for_proposer(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor", )
        provision_user_row(user_id=1, login="ben", role="owner")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
        r = client.post("/api/rfcs/propose", json={
            "title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
        })
        pr_number = r.json()["pr_number"]

        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
        r = client.post(f"/api/proposals/{pr_number}/merge")
        assert r.status_code == 200, r.text

        # Alice (proposer) gets a personal-direct proposal_merged row.
        rows = db.conn().execute(
            """
            SELECT event_kind, payload FROM notifications
             WHERE recipient_user_id = ? ORDER BY id
            """,
            (2,),
        ).fetchall()
        kinds = [r["event_kind"] for r in rows]
        assert "proposal_merged" in kinds
        # Category metadata round-trips on the payload.
        merged_row = next(r for r in rows if r["event_kind"] == "proposal_merged")
        assert _json.loads(merged_row["payload"])["category"] == "personal-direct"


def test_proposal_declined_routes_to_proposer_only(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=1, login="ben", role="owner")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/propose", json={
            "title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
        })
        pr_number = r.json()["pr_number"]
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
        r = client.post(f"/api/proposals/{pr_number}/decline", json={"comment": "Not aligned with this quarter's focus"})
        assert r.status_code == 200, r.text

        rows = db.conn().execute(
            "SELECT recipient_user_id, event_kind FROM notifications WHERE event_kind = 'proposal_declined'"
        ).fetchall()
        recipients = {r["recipient_user_id"] for r in rows}
        # Alice (id=2) receives it; Ben (the actor) does not.
        assert recipients == {2}


# ---------------------------------------------------------------------------
# Inbox surface
# ---------------------------------------------------------------------------


def test_inbox_lists_rows_with_filter_chips(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=1, login="ben", role="owner")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
        pr_number = r.json()["pr_number"]
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
        client.post(f"/api/proposals/{pr_number}/merge")

        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.get("/api/notifications")
        assert r.status_code == 200, r.text
        items = r.json()["items"]
        assert any(i["event_kind"] == "proposal_merged" for i in items)
        assert r.json()["unread_count"] >= 1

        # Filter by category isolates personal-direct.
        r = client.get("/api/notifications", params={"category": "personal-direct"})
        items = r.json()["items"]
        assert all(i["category"] == "personal-direct" for i in items)

        # Filter by rfc_slug narrows further.
        r = client.get("/api/notifications", params={"rfc_slug": "ohm"})
        items = r.json()["items"]
        assert all(i["rfc_slug"] == "ohm" for i in items)


def test_mark_read_per_row_and_by_filter(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=1, login="ben", role="owner")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
        client.post(f"/api/proposals/{r.json()['pr_number']}/merge")

        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        items = client.get("/api/notifications").json()["items"]
        notif_id = items[0]["id"]
        r = client.post(f"/api/notifications/{notif_id}/read")
        assert r.status_code == 200

        row = db.conn().execute("SELECT read_at FROM notifications WHERE id = ?", (notif_id,)).fetchone()
        assert row["read_at"] is not None

        # Mark-all-read by filter — should be idempotent.
        r = client.post("/api/notifications/read", json={"rfc_slug": "ohm"})
        assert r.status_code == 200


# ---------------------------------------------------------------------------
# §15.7 reconciler
# ---------------------------------------------------------------------------


def test_chat_seen_advance_marks_chat_notifications_read(app_with_fake_gitea):
    """Per §15.7: when branch_chat_seen advances, unread chat-kind
    notifications scoped to the same (slug, branch) flip to read."""
    from fastapi.testclient import TestClient
    from app import db

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=3, login="bob", role="contributor")
        seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)

        # Alice cuts the edit branch (auto-watch). Bob joins the branch
        # chat — Alice gets a chat_message_in_participated_thread row.
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
        # Alice posts the first message so she's a prior author for Bob's reply.
        view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
        thread_id = view["main_thread_id"]
        client.post(
            f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
            json={"text": "first thought"},
        )
        sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
            json={"text": "reply from bob"},
        )
        msg_id = r.json()["message_id"]

        # Alice has an unread chat_reply_to_my_message row.
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        unread = db.conn().execute(
            "SELECT id FROM notifications WHERE recipient_user_id = 2 AND read_at IS NULL"
        ).fetchall()
        assert len(unread) >= 1

        # Alice visits the branch — chat-seen advances → reconciler clears.
        r = client.post(
            f"/api/rfcs/ohm/branches/{branch}/chat-seen",
            json={"last_seen_message_id": msg_id},
        )
        assert r.status_code == 200
        assert r.json()["reconciled"] >= 1
        remaining = db.conn().execute(
            "SELECT id FROM notifications WHERE recipient_user_id = 2 AND branch_name = ? AND read_at IS NULL",
            (branch,),
        ).fetchall()
        assert remaining == []


# ---------------------------------------------------------------------------
# §15.8 mutes
# ---------------------------------------------------------------------------


def test_per_user_mute_suppresses_inbox_rows(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=3, login="bob", role="contributor")
        seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)

        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        # Alice mutes Bob.
        r = client.post("/api/users/3/notification-mute")
        assert r.status_code == 200

        # Alice cuts the branch and is a prior author.
        branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
        view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
        thread_id = view["main_thread_id"]
        client.post(
            f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
            json={"text": "alice opener"},
        )

        # Bob posts. With the mute in place Alice gets no inbox row.
        sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
        client.post(
            f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/messages",
            json={"text": "bob reply"},
        )
        rows = db.conn().execute(
            "SELECT id FROM notifications WHERE recipient_user_id = 2 AND actor_user_id = 3"
        ).fetchall()
        assert rows == []


def test_per_rfc_mute_suppresses_every_signal(app_with_fake_gitea):
    """§15.6: state='muted' on the watches row is the strongest leave-
    me-alone gesture, including for personal-direct events."""
    from fastapi.testclient import TestClient
    from app import db

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=3, login="bob", role="contributor")
        # Seed the slug so the watch endpoint can resolve it.
        seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, proposed_by="alice")

        # Alice mutes the slug. We bypass the API because the user-facing
        # surface doesn't expose 'muted' as an auto-set — it's an
        # explicit gesture. The endpoint accepts it.
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/watch", json={"state": "muted"})
        assert r.status_code == 200, r.text

        # Bob (a different user) cuts an edit branch — would normally
        # auto-watch and produce a structural beat to other watchers.
        # The mute suppresses Alice's row.
        sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
        # Add Alice as a watcher first via a chat message (auto-watch
        # already set 'muted' though); ensure no row regardless.
        client.post("/api/rfcs/ohm/start-edit-branch", json={})
        rows = db.conn().execute(
            "SELECT event_kind FROM notifications WHERE recipient_user_id = 2 AND rfc_slug = 'ohm'"
        ).fetchall()
        assert rows == []


def test_admin_cannot_mute_users(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=1, login="ben", role="owner")
        provision_user_row(user_id=3, login="bob", role="contributor")
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
        r = client.post("/api/users/3/notification-mute")
        assert r.status_code == 403


# ---------------------------------------------------------------------------
# §15.8 quiet hours + §15.4 email
# ---------------------------------------------------------------------------


def test_quiet_hours_holds_email_but_inbox_lands(app_with_fake_gitea, monkeypatch):
    from fastapi.testclient import TestClient
    from app import db, email as email_mod

    app, _fake = app_with_fake_gitea
    monkeypatch.setenv("APP_URL", "http://localhost:8000")
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=1, login="ben", role="owner")
        # Quiet hours covering every wall-clock moment — 00:00 → 23:59 UTC.
        db.conn().execute(
            """
            UPDATE users
               SET notification_quiet_hours_start = '00:00',
                   notification_quiet_hours_end = '23:59',
                   notification_quiet_hours_timezone = 'UTC',
                   email = 'alice@test'
             WHERE id = 2
            """
        )
        email_mod.reset_sent_envelopes()
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
        client.post(f"/api/proposals/{r.json()['pr_number']}/merge")

        # Inbox row landed.
        rows = db.conn().execute(
            "SELECT id, email_sent_at FROM notifications WHERE recipient_user_id = 2 AND event_kind = 'proposal_merged'"
        ).fetchall()
        assert len(rows) >= 1
        # Email held (email_sent_at is NULL; no envelope in the buffer).
        assert rows[0]["email_sent_at"] is None
        sent_to_alice = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
        assert sent_to_alice == []


def test_email_bounce_webhook_sets_global_opt_out(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        db.conn().execute("UPDATE users SET email = 'alice@test' WHERE id = 2")
        r = client.post("/api/webhooks/email-bounce", json={"email": "alice@test", "kind": "hard"})
        assert r.status_code == 200
        assert r.json()["matched"] is True
        row = db.conn().execute("SELECT email_opt_out_all FROM users WHERE id = 2").fetchone()
        assert row["email_opt_out_all"] == 1


def test_email_unsubscribe_signed_url_flips_category_off(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import email as email_mod, db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]
        r = client.get(f"/api/email/unsubscribe?t={token}")
        assert r.status_code == 200
        row = db.conn().execute("SELECT email_personal_direct FROM users WHERE id = 2").fetchone()
        assert row["email_personal_direct"] == 0


# ---------------------------------------------------------------------------
# §15.5 digest
# ---------------------------------------------------------------------------


def test_digest_emits_then_skips_already_included(app_with_fake_gitea, monkeypatch):
    """Two consecutive `run_tick` passes: the first emits a digest with
    the eligible rows; the second runs but emits nothing because the
    cadence window has not yet rolled over (the just-recorded
    `notification_digests` row's `period_end` is now)."""
    from fastapi.testclient import TestClient
    from app import db, digest as digest_mod, email as email_mod

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=3, login="bob", role="contributor")
        db.conn().execute(
            "UPDATE users SET email = 'alice@test', digest_cadence = 'daily' WHERE id = 2"
        )
        seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
        db.conn().execute(
            """
            INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
            VALUES (2, 'ohm', 'following', 'explicit', datetime('now', '-1 day'), datetime('now', '-1 day'))
            """
        )
        # An eligible churn row from an hour ago.
        db.conn().execute(
            """
            INSERT INTO notifications
                (recipient_user_id, event_kind, rfc_slug, actor_user_id, payload, created_at)
            VALUES (2, 'pr_commit_added', 'ohm', 3, ?, datetime('now', '-1 hour'))
            """,
            (_json.dumps({"category": "churn"}),),
        )
        # Seed a prior digest emission that's >24h ago so the daily
        # cadence has rolled over and the first tick fires.
        db.conn().execute(
            """
            INSERT INTO notification_digests (recipient_user_id, sent_at, period_start, period_end, signal_ids_included)
            VALUES (2, datetime('now', '-2 days'), datetime('now', '-3 days'), datetime('now', '-2 days'), '[]')
            """
        )
        email_mod.reset_sent_envelopes()

        # First tick: digest emitted.
        result = digest_mod.run_tick()
        assert result["digests_sent"] == 1
        envelopes = [e for e in email_mod.sent_envelopes() if "digest" in e["subject"].lower()]
        assert len(envelopes) == 1

        # Verify digest_included_at landed for the row that was in the
        # body — the audit field stays queryable per §15.5.
        included = db.conn().execute(
            "SELECT id FROM notifications WHERE recipient_user_id = 2 AND digest_included_at IS NOT NULL"
        ).fetchall()
        assert len(included) >= 1

        # Second tick fires immediately. The cadence window has not
        # rolled over (period_end on the new digest row is now), so
        # nothing is emitted.
        result2 = digest_mod.run_tick()
        assert result2["digests_sent"] == 0


# ---------------------------------------------------------------------------
# §15.3 SSE snapshot
# ---------------------------------------------------------------------------


def test_notify_subscriber_receives_broadcast(app_with_fake_gitea):
    """The per-user SSE subscriber registry in `notify.subscribe` is
    the substrate behind `/api/notifications/stream`. Driving it
    directly verifies that an inbox-row insert pushes onto every
    subscriber's queue, which is what backs the live badge counter
    and the toast surface per §15.3.

    The HTTP-level stream test is intentionally out-of-scope here:
    TestClient buffers chunked responses and so cannot observe an
    SSE handler that yields once and then waits — the production
    path uses a real ASGI server with chunk flushing.
    """
    import asyncio as _asyncio
    from app import db, notify

    app, _fake = app_with_fake_gitea
    from fastapi.testclient import TestClient
    with TestClient(app):
        provision_user_row(user_id=2, login="alice", role="contributor")

        async def _drive():
            sub = await notify.subscribe(2)
            # Insert a notification row via the chokepoint. The push
            # is scheduled on the running loop.
            db.conn().execute(
                """
                INSERT INTO notifications (recipient_user_id, event_kind, rfc_slug, payload)
                VALUES (2, 'proposal_merged', 'ohm', '{"category":"personal-direct"}')
                """
            )
            nid = db.conn().execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
            await notify._broadcast(2, "notification", notify._row_payload(nid))
            evt = await _asyncio.wait_for(sub.queue.get(), timeout=2.0)
            await notify.unsubscribe(sub)
            return evt

        evt = _asyncio.new_event_loop().run_until_complete(_drive())
        assert evt["event"] == "notification"
        assert evt["payload"]["event_kind"] == "proposal_merged"


# ---------------------------------------------------------------------------
# Preferences
# ---------------------------------------------------------------------------


def test_notification_preferences_round_trip(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        r = client.get("/api/users/me/notification-preferences")
        assert r.status_code == 200
        assert r.json()["email_personal_direct"] is True
        assert r.json()["email_watched_churn"] is False

        r = client.post(
            "/api/users/me/notification-preferences",
            json={"email_watched_structural": True, "digest_cadence": "weekly"},
        )
        assert r.status_code == 200

        r = client.get("/api/users/me/notification-preferences")
        assert r.json()["email_watched_structural"] is True
        assert r.json()["digest_cadence"] == "weekly"


def test_quiet_hours_endpoint_round_trip(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        # Setting a partial trio is rejected per §15.8.
        r = client.post("/api/users/me/quiet-hours", json={"start": "22:00"})
        assert r.status_code == 422

        # Full trio sets cleanly.
        r = client.post(
            "/api/users/me/quiet-hours",
            json={"start": "22:00", "end": "07:00", "timezone": "America/Los_Angeles"},
        )
        assert r.status_code == 200, r.text

        r = client.get("/api/users/me/quiet-hours")
        assert r.json()["start"] == "22:00"
        assert r.json()["timezone"] == "America/Los_Angeles"

        # All-null clears.
        r = client.post(
            "/api/users/me/quiet-hours",
            json={"start": None, "end": None, "timezone": None},
        )
        assert r.status_code == 200
        assert client.get("/api/users/me/quiet-hours").json()["start"] is None


# ---------------------------------------------------------------------------
# Watches surface
# ---------------------------------------------------------------------------


def test_explicit_watch_set_overrides_auto(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/ohm/watch", json={"state": "following"})
        assert r.status_code == 200

        row = db.conn().execute(
            "SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'"
        ).fetchone()
        assert row["state"] == "following"
        assert row["set_by"] == "explicit"

        # The auto-watch upsert from a subsequent gesture must not
        # downgrade the explicit setting. Trigger a substantive
        # gesture (cut an edit branch).
        client.post("/api/rfcs/ohm/start-edit-branch", json={})
        row = db.conn().execute(
            "SELECT state, set_by FROM watches WHERE user_id = 2 AND rfc_slug = 'ohm'"
        ).fetchone()
        # Following → watching is the *one* auto-upgrade in §15.6, but
        # only for set_by='auto' rows; explicit rows must stay where
        # the user put them.
        assert row["set_by"] == "explicit"
        assert row["state"] == "following"


# ---------------------------------------------------------------------------
# v0.18.0 — envelope headers + RFC 8058 one-click POST endpoint
#
# Watcher notifications are bulk-adjacent (a busy RFC can produce
# dozens of structural events); per the proposal, they MUST carry
# `Date`, `Message-ID`, `Auto-Submitted`, full `List-Unsubscribe`
# (mailto + signed URL), AND `List-Unsubscribe-Post:
# List-Unsubscribe=One-Click` per RFC 8058. Gmail and Yahoo
# enforce this for senders at OHM's volume tier.
# ---------------------------------------------------------------------------


def test_notification_envelope_carries_full_one_click_headers(app_with_fake_gitea):
    """A `proposal_merged` event lands a watcher notification email
    with the full one-click unsubscribe shape."""
    from fastapi.testclient import TestClient
    from email.utils import parsedate_to_datetime
    from app import db, email as email_mod

    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=1, login="ben", role="owner")

        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
        r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
        assert r.status_code == 200
        email_mod.reset_sent_envelopes()
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
        merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
        assert merge_r.status_code == 200, merge_r.text

        envelopes = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
        assert envelopes, "watcher notification did not fire"
        msg = envelopes[-1]["message"]
        # Always-present headers from the helper.
        assert parsedate_to_datetime(msg["Date"]) is not None
        assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
        assert msg["Auto-Submitted"] == "auto-generated"
        # Full one-click unsubscribe.
        lu = msg["List-Unsubscribe"]
        assert lu is not None
        assert "<mailto:" in lu
        # URL part carries the signed token per make_unsubscribe_url.
        assert "/api/email/unsubscribe?t=" in lu
        assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"


def test_email_unsubscribe_post_one_click_flips_category_off(app_with_fake_gitea):
    """RFC 8058: Gmail/Yahoo POST `List-Unsubscribe=One-Click` to the
    URL in the List-Unsubscribe header. The endpoint MUST accept POST
    + the same token shape as the GET handler + return 200 + flip the
    flag."""
    from fastapi.testclient import TestClient
    from app import db, email as email_mod

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]

        r = client.post(
            f"/api/email/unsubscribe?t={token}",
            data={"List-Unsubscribe": "One-Click"},
        )
        assert r.status_code == 200, r.text
        body = r.json()
        assert body["ok"] is True
        assert body["category"] == "personal-direct"

        row = db.conn().execute(
            "SELECT email_personal_direct FROM users WHERE id = 2"
        ).fetchone()
        assert row["email_personal_direct"] == 0


def test_email_unsubscribe_post_all_sets_global_opt_out(app_with_fake_gitea):
    """The v0.18.0 `all` synthetic category (used by the bundle +
    digest paths) MUST set `email_opt_out_all = 1`."""
    from fastapi.testclient import TestClient
    from app import db, email as email_mod

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
        r = client.post(f"/api/email/unsubscribe?t={token}")
        assert r.status_code == 200
        assert r.json() == {"ok": True, "category": "all"}
        row = db.conn().execute(
            "SELECT email_opt_out_all FROM users WHERE id = 2"
        ).fetchone()
        assert row["email_opt_out_all"] == 1


def test_email_unsubscribe_get_all_sets_global_opt_out(app_with_fake_gitea):
    """GET handler also accepts the `all` category and lands the
    global opt-out (so an MUA that doesn't honor RFC 8058 POST and
    just opens the URL in a browser still works)."""
    from fastapi.testclient import TestClient
    from app import db, email as email_mod

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
        r = client.get(f"/api/email/unsubscribe?t={token}")
        assert r.status_code == 200
        assert "Unsubscribed" in r.text
        row = db.conn().execute(
            "SELECT email_opt_out_all FROM users WHERE id = 2"
        ).fetchone()
        assert row["email_opt_out_all"] == 1


def test_email_unsubscribe_post_refuses_invalid_token(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        r = client.post("/api/email/unsubscribe?t=not-a-valid-token")
        assert r.status_code == 400
