"""End-to-end integration tests for v0.8.0's open beta-access request
flow (§6.1 / §14.1, roadmap item #6).

The release replaces v0.3.0's `allowed_emails` allowlist as the
admission gate. Any valid email can sign in via the v0.7.0 OTC flow;
a fresh user lands in `permission_state='pending'` until an admin
grants access. The first-OTC flow captures first name, last name,
and a free-text "why I should be included in the beta" via a new
`POST /api/auth/me/beta-request` endpoint.

The tests prove:

  * A fresh OTC user lands `permission_state='pending'` with empty
    profile fields, and the verify-response carries `needs_profile=true`.
  * `POST /api/auth/me/beta-request` populates the three fields and
    leaves the row in `pending`.
  * A pending user is refused write endpoints (representative
    samples: propose RFC, post discussion thread). The refusal is
    403 (not 401 — they're authenticated, just not granted).
  * An admin-grant flow promotes pending → granted. v0.8.0 doesn't
    ship an admin UI for this (deferred to item #7 / v0.9.0), so
    the test flips the column directly via DB and asserts that
    `require_contributor` now admits the user.
  * A grandfathered user (existing row pre-migration, default
    `permission_state='granted'`) is unaffected — write endpoints
    accept them.
  * The `/auth/otc/request` endpoint accepts any email — the
    v0.7.0 allowlist gate is gone from this path. The `allowed_emails`
    table stays in the schema; the admin UI from v0.3.0 continues to
    manage it for the fast-path bypass deployments may use.
"""
from __future__ import annotations

import pytest

from test_propose_vertical import (  # noqa: F401 — fixtures land via import
    FakeGitea,
    app_with_fake_gitea,
    provision_user_row,
    sign_in_as,
    tmp_env,
)


def _reset_outbound():
    from app import email as email_mod
    email_mod.reset_sent_envelopes()


def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
    """Pluck the code line from every OTC envelope in the test buffer."""
    from app import email as email_mod
    out = []
    for env in email_mod.sent_envelopes():
        if env.get("kind") != "otc":
            continue
        if to_address is not None and env["to"] != to_address:
            continue
        for line in env["body"].splitlines():
            tok = line.strip()
            if tok.isdigit() and len(tok) == 6:
                out.append(tok)
                break
    return out


# ---------------------------------------------------------------------------
# Fresh OTC sign-in lands pending with empty fields
# ---------------------------------------------------------------------------


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

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()

        # Request + verify the OTC.
        r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
        assert r.status_code == 200, r.text
        code = _outbound_otc_codes("newcomer@example.com")[-1]

        r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
        assert r.status_code == 200, r.text
        body = r.json()
        # The verify response carries the new fields v0.8.0 added.
        assert body["needs_profile"] is True
        assert body["user"]["permission_state"] == "pending"

        # The row reflects the same: pending state, no profile yet.
        row = db.conn().execute(
            "SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
            ("newcomer@example.com",),
        ).fetchone()
        assert row is not None
        assert row["permission_state"] == "pending"
        assert row["first_name"] is None
        assert row["last_name"] is None
        assert row["beta_request_reason"] is None

        # /api/auth/me surfaces the same shape.
        me = client.get("/api/auth/me").json()
        assert me["authenticated"] is True
        assert me["user"]["permission_state"] == "pending"
        assert me["user"]["needs_profile"] is True
        assert me["user"]["first_name"] == ""
        assert me["user"]["last_name"] == ""
        assert me["user"]["beta_request_reason"] == ""


# ---------------------------------------------------------------------------
# beta-request endpoint captures the fields and leaves state pending
# ---------------------------------------------------------------------------


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

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        # Sign in the fresh user via the full OTC flow.
        client.post("/auth/otc/request", json={"email": "alice@example.com"})
        code = _outbound_otc_codes("alice@example.com")[-1]
        client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})

        # Submit the capture form.
        r = client.post(
            "/api/auth/me/beta-request",
            json={
                "first_name": "Alice",
                "last_name": "Liddell",
                "beta_request_reason": "I want to help write the RFCs.",
            },
        )
        assert r.status_code == 200, r.text

        # The row reflects the captured fields; state stays pending.
        row = db.conn().execute(
            "SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
            ("alice@example.com",),
        ).fetchone()
        assert row["permission_state"] == "pending"
        assert row["first_name"] == "Alice"
        assert row["last_name"] == "Liddell"
        assert row["beta_request_reason"] == "I want to help write the RFCs."

        # /api/auth/me now reports needs_profile=false (fields are set).
        me = client.get("/api/auth/me").json()
        assert me["user"]["permission_state"] == "pending"
        assert me["user"]["needs_profile"] is False
        assert me["user"]["first_name"] == "Alice"


def test_beta_request_refuses_anonymous(app_with_fake_gitea):
    """The endpoint requires authentication — an anonymous caller can't
    file a request without first signing in via OTC."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        client.cookies.clear()
        r = client.post(
            "/api/auth/me/beta-request",
            json={"first_name": "A", "last_name": "B", "beta_request_reason": "Hi"},
        )
        assert r.status_code == 401


def test_beta_request_refuses_granted_user(app_with_fake_gitea):
    """A grandfathered (already granted) user has no business filing a
    beta request. The endpoint refuses with 409 so the client can
    distinguish the failure from "we don't know you" (401)."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=1, login="grandfathered", role="contributor")
        sign_in_as(
            client,
            user_id=1,
            gitea_login="grandfathered",
            display_name="Grandfathered",
            role="contributor",
        )
        r = client.post(
            "/api/auth/me/beta-request",
            json={"first_name": "G", "last_name": "F", "beta_request_reason": "x"},
        )
        assert r.status_code == 409


# ---------------------------------------------------------------------------
# Pending user is refused write endpoints; admin grant promotes them
# ---------------------------------------------------------------------------


def test_pending_user_is_refused_write_endpoints(app_with_fake_gitea):
    """A pending user can read everything anonymous can read, but every
    write-shaped endpoint refuses with 403. The refusal shape mirrors
    the v0.6.0 / item #4 audit's anon-401 — both are "no contributor
    capability"; pending is the authenticated-but-ungranted variant.

    Representative samples: propose RFC, post discussion thread.
    """
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        # Sign in via fresh OTC — lands pending.
        client.post("/auth/otc/request", json={"email": "pending@example.com"})
        code = _outbound_otc_codes("pending@example.com")[-1]
        client.post("/auth/otc/verify", json={"email": "pending@example.com", "code": code})

        # Reads work — every anonymous surface stays reachable.
        assert client.get("/api/health").status_code == 200
        assert client.get("/api/rfcs").status_code == 200
        assert client.get("/api/philosophy").status_code == 200

        # Propose — write-shaped, refused with 403.
        r = client.post(
            "/api/rfcs/propose",
            json={"title": "T", "slug": "t", "pitch": "p", "tags": []},
        )
        assert r.status_code == 403
        # The error body mentions the review state so a UI surface can
        # render the right message — but the test asserts only on the
        # status code (the body shape is the FastAPI default detail).


def test_admin_grant_promotes_pending_to_granted(app_with_fake_gitea):
    """v0.8.0 doesn't ship an admin UI for this — it's deferred to
    item #7 / v0.9.0. For this release, an admin gesture is an
    `UPDATE users SET permission_state='granted' WHERE email=?`. The
    test flips the column directly via DB and asserts the
    `require_contributor` gate now admits the user.
    """
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        # Sign in a fresh OTC user — lands pending.
        client.post("/auth/otc/request", json={"email": "promoted@example.com"})
        code = _outbound_otc_codes("promoted@example.com")[-1]
        client.post("/auth/otc/verify", json={"email": "promoted@example.com", "code": code})

        # Before the grant: propose refused with 403.
        r = client.post(
            "/api/rfcs/propose",
            json={"title": "T", "slug": "t-pre", "pitch": "p", "tags": []},
        )
        assert r.status_code == 403

        # The admin gesture (v0.8.0 shape — direct UPDATE; v0.9.0 will
        # ship a UI). The test stamps `permission_decided_by` and
        # `permission_decided_at` as the v0.9.0 admin UI will, so the
        # column population exercises the schema slot. user_id=99 is
        # a placeholder admin row — provision it so the FK resolves.
        provision_user_row(user_id=99, login="adminuser", role="admin")
        db.conn().execute(
            """
            UPDATE users
               SET permission_state = 'granted',
                   permission_decided_by = 99,
                   permission_decided_at = datetime('now')
             WHERE email = ?
            """,
            ("promoted@example.com",),
        )

        # The next request reads the fresh column from the DB. The
        # propose endpoint reaches the route body now (it then refuses
        # for a different reason — the slug 't-prop' will fail
        # the slug-format check or hit a mock-gitea path — but the
        # status code is _not_ 403/401, which is the v0.8.0 assertion).
        r = client.post(
            "/api/rfcs/propose",
            json={"title": "Title", "slug": "tprop", "pitch": "Pitch text.", "tags": []},
        )
        assert r.status_code != 403, r.text
        assert r.status_code != 401, r.text


def test_grandfathered_user_is_unaffected_by_migration(app_with_fake_gitea):
    """An existing `users` row at migration time has
    `permission_state='granted'` via the column default. The
    grandfathered user passes write endpoints without filing a
    beta request and without the admin UI. v0.6.0 (anon-write
    audit) is the v0.6.0 contract; v0.8.0 widens the gate but
    does not break this case.
    """
    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=5, login="oldhand", role="contributor")
        # provision_user_row uses INSERT OR REPLACE INTO users with
        # the column list it knows; permission_state is not in that
        # list, so it picks up the column default ('granted') on
        # insert. Confirm directly.
        row = db.conn().execute(
            "SELECT permission_state FROM users WHERE id = 5"
        ).fetchone()
        assert row["permission_state"] == "granted"

        sign_in_as(
            client,
            user_id=5,
            gitea_login="oldhand",
            display_name="Old Hand",
            role="contributor",
        )

        # Propose is write-shaped; the call should not refuse on
        # the permission_state gate. (Subsequent failure modes —
        # e.g. mock-gitea wiring — are not the v0.8.0 concern; this
        # test asserts on the gate, not the propose body's success.)
        r = client.post(
            "/api/rfcs/propose",
            json={"title": "Title", "slug": "gf-slug", "pitch": "Pitch.", "tags": []},
        )
        assert r.status_code != 403, r.text
        assert r.status_code != 401, r.text


# ---------------------------------------------------------------------------
# /auth/otc/request accepts any email — the v0.7.0 allowlist gate is gone
# ---------------------------------------------------------------------------


def test_otc_request_accepts_any_email_regardless_of_allowlist(app_with_fake_gitea):
    """v0.7.0 silently dropped OTC requests for emails not on the
    `allowed_emails` table. v0.8.0 reverses this: the request
    endpoint sends a code to any valid email; admission gates at
    `permission_state` post-verify instead. The `allowed_emails`
    table stays in the schema as a fast-path bypass for
    deployments that want to pre-mark known-good emails (the v0.9.0
    admin user-management page will collapse the two surfaces).
    """
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        # Populate the allowlist with one specific email so the v0.7.0
        # gate would have engaged. v0.8.0 ignores it for the request
        # path.
        db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("known@example.com",))

        # An email NOT on the allowlist still gets a code under v0.8.0.
        r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
        assert r.status_code == 200
        codes = _outbound_otc_codes("stranger@example.com")
        assert len(codes) == 1, "OTC code must be sent regardless of allowlist state"

        # The row is there and the user can complete sign-in (and will
        # land in 'pending' per the other tests).
        row = db.conn().execute(
            "SELECT 1 FROM otc_codes WHERE email = ?",
            ("stranger@example.com",),
        ).fetchone()
        assert row is not None


def test_allowlist_table_still_present_in_schema(app_with_fake_gitea):
    """The schema migration leaves the `allowed_emails` table in
    place — the admin UI from v0.3.0 still manages it for the
    fast-path bypass deployments may use. This is a regression net
    for "did the v0.8.0 cleanup accidentally drop the table"."""
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app):
        # The table accepts inserts (i.e. it exists) — no schema check
        # gymnastics needed.
        db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("kept@example.com",))
        row = db.conn().execute(
            "SELECT email FROM allowed_emails WHERE email = ?",
            ("kept@example.com",),
        ).fetchone()
        assert row is not None
