"""End-to-end smoke test for the Slice 8 hardening pass.

Walks the full user lifecycle against FakeGitea: propose → owner
merges → super-draft view → start edit branch → AI proposes (seeded
directly) → accept → open body-edit PR → owner merges → graduate →
active-RFC PR open → owner merges → §12 hygiene sweep deletes the
post-merge branch. The cases are long, and they catch the integration
seams a per-slice test would miss — that's the point per the §19.1
brief.

Plus the bounce-webhook signing-seam test: when
`WEBHOOK_EMAIL_BOUNCE_SECRET` is set, an unsigned POST is refused.
"""
from __future__ import annotations

import asyncio
import json as _json
from datetime import datetime, timedelta, timezone

import pytest

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


# ---------------------------------------------------------------------------
# The lifecycle walk
# ---------------------------------------------------------------------------


def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
    """Propose → merge → super-draft view → edit branch → accept change
    → body-edit PR → merge → graduate → active-RFC PR → merge →
    §12 hygiene sweep cleans the post-merge branch.

    The §15 notification path runs through `bot._log`'s fan_out at
    every step; we assert at the end that the inbox has rows."""
    from fastapi.testclient import TestClient
    from app import cache as cache_mod, db, hygiene

    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=2, login="alice", role="contributor")

        # --- 1. Alice proposes a new RFC. ---
        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": "Open Human Model",
            "slug": "ohm",
            "pitch": "A shared definition of what we mean by *human*.",
            "tags": ["identity"],
        })
        assert r.status_code == 200, r.text
        proposal_pr = r.json()["pr_number"]

        # --- 2. Ben (owner) merges the proposal → super-draft exists. ---
        sign_in_as(client, user_id=1, gitea_login="ben",
                   display_name="Ben", role="owner", email="ben@test")
        r = client.post(f"/api/proposals/{proposal_pr}/merge")
        assert r.status_code == 200, r.text

        d = client.get("/api/rfcs/ohm").json()
        assert d["state"] == "super-draft"

        # --- 3. Ben claims ownership so he can graduate later. ---
        r = client.post("/api/rfcs/ohm/claim")
        assert r.status_code == 200, r.text
        claim_pr = r.json()["pr_number"]
        # Claim PR also auto-merges per §13.1's owner/admin path.
        r = client.post(f"/api/rfcs/ohm/prs/{claim_pr}/merge")
        assert r.status_code == 200, r.text

        # --- 4. Ben starts an edit branch on the super-draft. ---
        r = client.post("/api/rfcs/ohm/start-edit-branch", json={})
        assert r.status_code == 200, r.text
        edit_branch = r.json()["branch_name"]
        assert edit_branch.startswith("edit-ohm-")

        # --- 5. Materialize an AI-style change directly + accept. ---
        view = client.get(f"/api/rfcs/ohm/branches/{edit_branch}").json()
        thread_id = view["main_thread_id"]
        cur = db.conn().execute(
            """
            INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
                                 original, proposed, reason)
            VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tighten')
            """,
            (
                edit_branch, thread_id,
                "A shared definition of what we mean by *human*.",
                "A shared, OHM-compatible definition of what we mean by *human*.",
            ),
        )
        change_id = cur.lastrowid
        r = client.post(
            f"/api/rfcs/ohm/branches/{edit_branch}/changes/{change_id}/accept",
            json={
                "proposed": "A shared, OHM-compatible definition of what we mean by *human*.",
                "was_edited_before_accept": False,
            },
        )
        assert r.status_code == 200, r.text

        # --- 6. Open the body-edit PR + merge it. ---
        r = client.post(
            f"/api/rfcs/ohm/branches/{edit_branch}/open-pr",
            json={"title": "OHM body edit", "description": "Add OHM-compatibility clause."},
        )
        assert r.status_code == 200, r.text
        body_pr = r.json()["pr_number"]
        r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
        assert r.status_code == 200, r.text

        # --- 7. Graduate the super-draft (in-place flip, §13). ---
        r = client.post(
            "/api/rfcs/ohm/graduate?_sync=1",
            json={"rfc_id": "RFC-0001", "owners": ["ben"]},
        )
        assert r.status_code == 200, r.text
        assert r.json()["succeeded"] is True
        d = client.get("/api/rfcs/ohm").json()
        assert d["state"] == "active"
        # Meta-only topology (§1): no per-RFC repo — the active RFC lives
        # in its meta entry, `repo` stays null.
        assert d["repo"] is None

        # --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
        # v0.16.0 (item #12): ben is the RFC owner now; alice needs a
        # per-RFC contributor invitation to cut a branch. In the
        # production flow, ben would invite her via /invitations and
        # she'd accept; we shortcut to the same end-state.
        grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice",
                   display_name="Alice", role="contributor", email="alice@test")
        r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
        assert r.status_code == 200, r.text
        active_branch = r.json()["branch_name"]
        # Materialize and accept a change so the branch has commits ahead.
        view = client.get(f"/api/rfcs/ohm/branches/{active_branch}").json()
        active_thread = view["main_thread_id"]
        cur = db.conn().execute(
            """
            INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
                                 original, proposed, reason)
            VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'expand')
            """,
            (
                active_branch, active_thread,
                "OHM-compatible definition",
                "OHM-compatible, traceable definition",
            ),
        )
        change_id = cur.lastrowid
        r = client.post(
            f"/api/rfcs/ohm/branches/{active_branch}/changes/{change_id}/accept",
            json={"proposed": "OHM-compatible, traceable definition",
                  "was_edited_before_accept": False},
        )
        assert r.status_code == 200, r.text
        r = client.post(
            f"/api/rfcs/ohm/branches/{active_branch}/open-pr",
            json={"title": "Traceability clause", "description": "Add a traceability term."},
        )
        assert r.status_code == 200, r.text
        active_pr = r.json()["pr_number"]

        # --- 9. Ben merges the active-RFC PR. ---
        sign_in_as(client, user_id=1, gitea_login="ben",
                   display_name="Ben", role="owner", email="ben@test")
        r = client.post(f"/api/rfcs/ohm/prs/{active_pr}/merge")
        assert r.status_code == 200, r.text

        # --- 10. Notifications fanned out — Ben's inbox carries rows. ---
        r = client.get("/api/notifications")
        assert r.status_code == 200, r.text
        inbox = r.json()
        assert "items" in inbox
        # The merge of Alice's PR should at minimum have produced a
        # structural beat to watchers (Ben auto-watched on his earlier
        # gestures on the slug).
        kinds = {item["event_kind"] for item in inbox["items"]}
        assert kinds, f"expected non-empty inbox kinds, got: {inbox}"

        # --- 11. Backdate the merge so the §12 hygiene sweep deletes
        # the branch, then run the sweep. ---
        long_ago = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
        db.conn().execute(
            "UPDATE cached_prs SET merged_at = ? WHERE pr_number = ?",
            (long_ago, active_pr),
        )
        counters = asyncio.new_event_loop().run_until_complete(
            hygiene.run_tick(config=app.state.config, bot=app.state.bot)
        )
        assert counters["deleted_post_merge"] >= 1, counters

        # The branch is gone from FakeGitea (meta repo) + cached row flipped.
        assert active_branch not in fake.branches[("wiggleverse", "meta")]
        cached = db.conn().execute(
            "SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
            (active_branch,),
        ).fetchone()
        assert cached["state"] == "deleted"


# ---------------------------------------------------------------------------
# Bounce-webhook signing seam (§19.2 → settled)
# ---------------------------------------------------------------------------


def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gitea, monkeypatch):
    """When `WEBHOOK_EMAIL_BOUNCE_SECRET` is set, the webhook requires
    the same value in the `X-Webhook-Secret` header. An unsigned POST
    returns 401."""
    from fastapi.testclient import TestClient

    monkeypatch.setenv("WEBHOOK_EMAIL_BOUNCE_SECRET", "shhh")
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        r = client.post(
            "/api/webhooks/email-bounce",
            json={"email": "stranger@example.com", "kind": "hard"},
        )
        assert r.status_code == 401, r.text

        # With the right header, the call passes the guard. (No matching
        # user exists, so we get {matched: False} — that's the v1 contract.)
        # v0.18.0 Slice 5: the response now includes `correlated_id`
        # (the outbound_emails row id that matched the bounce's
        # `message_id`, if one was supplied). The body didn't pass a
        # message_id, so correlated_id is None.
        r = client.post(
            "/api/webhooks/email-bounce",
            json={"email": "stranger@example.com", "kind": "hard"},
            headers={"X-Webhook-Secret": "shhh"},
        )
        assert r.status_code == 200, r.text
        assert r.json() == {"ok": True, "matched": False, "correlated_id": None}


def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
    """The v1 contract: when no `WEBHOOK_EMAIL_BOUNCE_SECRET` is set,
    the webhook stays unauthenticated for dev. The SMTP provider's
    callback URL is the only contract."""
    from fastapi.testclient import TestClient

    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        r = client.post(
            "/api/webhooks/email-bounce",
            json={"email": "nobody@example.com", "kind": "complaint"},
        )
        assert r.status_code == 200, r.text
