"""End-to-end integration tests for v0.9.0's admin user-management page
and new-beta-request notifications (roadmap item #7, §6.1 / §15).

The release lands two halves of the same surface:

  * **Admin notification on new beta request.** When a pending user
    submits `POST /api/auth/me/beta-request`, every owner/admin
    receives a `new_beta_request` notification (the §15 substrate
    insert lands the row; the §15.4 email path dispatches subject to
    the recipient's `email_admin_actionable` toggle).

  * **Admin user-management surface** at `/admin/users`. The
    `GET /api/admin/users` listing carries every user with their
    permission_state, profile fields, sign-up reason, and decision
    audit. The new `POST /api/admin/users/<id>/permission` endpoint
    flips the column and writes a `permission_events` row.

The tests prove:

  * The first beta-request submission fans a `new_beta_request`
    row out to every admin/owner (and not to the requester
    themselves). The row carries the captured profile in
    `payload.extras`.
  * Re-submitting the form from the same pending user doesn't
    re-fan (we only notify on the row's first complete state).
  * `GET /api/admin/users` carries the v0.9.0 columns
    (permission_state, first/last/reason, decided_by).
  * `POST /api/admin/users/<id>/permission` flips the state,
    stamps decided_by/at, and writes a `permission_events` row.
  * The endpoint refuses self-flip (422) and refuses non-admin
    callers (403).
  * The endpoint accepts only the three valid states (422 on
    anything else).
"""
from __future__ import annotations

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]:
    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


def _provision_pending_user(client, email: str) -> int:
    """Sign in a fresh OTC user (lands `pending`) and return their user_id."""
    from app import db
    _reset_outbound()
    client.post("/auth/otc/request", json={"email": email})
    code = _outbound_otc_codes(email)[-1]
    client.post("/auth/otc/verify", json={"email": email, "code": code})
    row = db.conn().execute(
        "SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
    ).fetchone()
    return row["id"]


# ---------------------------------------------------------------------------
# Admin notification on beta-request submission
# ---------------------------------------------------------------------------


def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
    """First-time submission of a beta-request fans a notification out
    to every owner and admin. The requester themselves never receives
    a row (filtered out by user_id even if they happened to be in the
    admin set, which they aren't in practice — fresh OTC users are
    `contributor`+`pending`)."""
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        # Provision two admins and one owner so the fan-out has multiple
        # targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
        # here (no OAuth round-trip in this path); we seed the role
        # directly.
        provision_user_row(user_id=10, login="ownerzero", role="owner")
        provision_user_row(user_id=11, login="admin_one", role="admin")
        provision_user_row(user_id=12, login="admin_two", role="admin")
        provision_user_row(user_id=13, login="contrib_one", role="contributor")

        # Sign in a fresh OTC user → permission_state='pending'.
        requester_id = _provision_pending_user(client, "newbie@example.com")

        # Capture-form submit.
        r = client.post(
            "/api/auth/me/beta-request",
            json={
                "first_name": "Newt",
                "last_name": "Newcomer",
                "beta_request_reason": "I want to write the Human RFC.",
            },
        )
        assert r.status_code == 200, r.text

        # Every owner + admin gets a `new_beta_request` notification.
        # The contributor (id=13) does not. The requester (whoever id
        # they got) does not.
        rows = db.conn().execute(
            """
            SELECT recipient_user_id, event_kind, actor_user_id, payload
              FROM notifications
             WHERE event_kind = 'new_beta_request'
            """
        ).fetchall()
        recipients = sorted(r["recipient_user_id"] for r in rows)
        assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
        # Actor is the requester (§15.9: never the bot).
        for r in rows:
            assert r["actor_user_id"] == requester_id
            import json as _json
            extras = _json.loads(r["payload"])
            assert extras["requester_first_name"] == "Newt"
            assert extras["requester_last_name"] == "Newcomer"
            assert extras["requester_email"] == "newbie@example.com"


def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
    """Once a user has completed the capture form, re-submitting it
    (the endpoint is idempotent for pending users) must not re-fan a
    fresh notification to every admin — that would carpet-bomb the
    inbox on every typo correction."""
    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=20, login="adminzero", role="admin")
        _provision_pending_user(client, "carpet@example.com")

        body = {
            "first_name": "Carpet",
            "last_name": "Bomb",
            "beta_request_reason": "first draft",
        }
        r1 = client.post("/api/auth/me/beta-request", json=body)
        assert r1.status_code == 200

        # Re-submit with edited reason — endpoint accepts (idempotent
        # update), but the admin inbox stays at one row.
        body2 = dict(body, beta_request_reason="cleaner final draft")
        r2 = client.post("/api/auth/me/beta-request", json=body2)
        assert r2.status_code == 200

        rows = db.conn().execute(
            "SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
        ).fetchone()
        assert rows["n"] == 1


def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
    """The §15.4 category mapping must route `new_beta_request` to the
    admin-actionable bucket so the email gate consults
    `email_admin_actionable` (and skips for non-admin recipients).
    """
    from app import email as email_mod

    assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"


# ---------------------------------------------------------------------------
# /api/admin/users — listing carries the v0.9.0 columns
# ---------------------------------------------------------------------------


def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
    """The Users tab consumes this shape — confirm every required
    column is on the response."""
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        # Seed an admin and a pending user with all the v0.8.0 columns
        # populated. Direct-DB insert avoids the OTC dance (which would
        # overwrite the cookie); the test above proves the capture
        # pathway end-to-end and this one just exercises the listing
        # surface's shape.
        provision_user_row(user_id=30, login="ben", role="owner")
        db.conn().execute(
            """
            INSERT INTO users (id, gitea_id, gitea_login, email,
                               display_name, avatar_url, role,
                               permission_state, first_name, last_name,
                               beta_request_reason)
            VALUES (31, NULL, NULL, 'pendinguser@example.com',
                    'pendinguser', '', 'contributor',
                    'pending', 'Penn', 'Ding', 'I want in.')
            """
        )

        sign_in_as(
            client, user_id=30, gitea_login="ben",
            display_name="Ben", role="owner",
        )

        r = client.get("/api/admin/users")
        assert r.status_code == 200
        items = r.json()["items"]
        assert isinstance(items, list)
        pending = next(
            (i for i in items if i["email"] == "pendinguser@example.com"), None,
        )
        assert pending is not None
        assert pending["permission_state"] == "pending"
        assert pending["first_name"] == "Penn"
        assert pending["last_name"] == "Ding"
        assert pending["beta_request_reason"] == "I want in."
        assert pending["permission_decided_at"] is None
        assert pending["permission_decided_by_login"] is None
        # Pending bucket is listed first (sort order).
        assert items[0]["permission_state"] == "pending"


# ---------------------------------------------------------------------------
# /api/admin/users/<id>/permission — the flip endpoint
# ---------------------------------------------------------------------------


def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
    """The end-to-end gesture: a fresh OTC user lands pending, an admin
    flips them to granted via the endpoint, the row reflects the new
    state + decided_by/at, and a `permission_events` audit row lands."""
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        # Pending user.
        pending_id = _provision_pending_user(client, "flip@example.com")

        # Admin acting on them.
        provision_user_row(user_id=40, login="adminflipper", role="admin")
        sign_in_as(
            client, user_id=40, gitea_login="adminflipper",
            display_name="Admin Flipper", role="admin",
        )

        r = client.post(
            f"/api/admin/users/{pending_id}/permission",
            json={"state": "granted"},
        )
        assert r.status_code == 200, r.text
        body = r.json()
        assert body["permission_state"] == "granted"
        assert body["changed"] is True

        # Row reflects the new state + decision stamp.
        row = db.conn().execute(
            "SELECT permission_state, permission_decided_by, permission_decided_at "
            "FROM users WHERE id = ?",
            (pending_id,),
        ).fetchone()
        assert row["permission_state"] == "granted"
        assert row["permission_decided_by"] == 40
        assert row["permission_decided_at"] is not None

        # Audit row landed in permission_events.
        events = db.conn().execute(
            """
            SELECT actor_user_id, subject_user_id, event_kind
              FROM permission_events
             WHERE event_kind = 'permission_granted'
            """
        ).fetchall()
        assert len(events) == 1
        assert events[0]["actor_user_id"] == 40
        assert events[0]["subject_user_id"] == pending_id


def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
    """Revoke is the symmetric gesture. Used when an account earned a
    grant then later lost it (§6.1 / `revoked` state)."""
    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=50, login="goner", role="contributor")
        # Default permission_state is 'granted' via the column default.
        provision_user_row(user_id=51, login="adminrevoker", role="admin")
        sign_in_as(
            client, user_id=51, gitea_login="adminrevoker",
            display_name="Admin Revoker", role="admin",
        )

        r = client.post(
            "/api/admin/users/50/permission",
            json={"state": "revoked"},
        )
        assert r.status_code == 200, r.text

        row = db.conn().execute(
            "SELECT permission_state FROM users WHERE id = 50"
        ).fetchone()
        assert row["permission_state"] == "revoked"

        events = db.conn().execute(
            "SELECT event_kind FROM permission_events "
            "WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
        ).fetchall()
        assert len(events) == 1


def test_permission_flip_refuses_self(app_with_fake_gitea):
    """Symmetric to set_mute / set_role: an admin can't self-flip.
    The state-change channel for one's own grant is somebody else's
    hand."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=60, login="selfflipper", role="admin")
        sign_in_as(
            client, user_id=60, gitea_login="selfflipper",
            display_name="Self Flipper", role="admin",
        )
        r = client.post(
            "/api/admin/users/60/permission",
            json={"state": "revoked"},
        )
        assert r.status_code == 422


def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
    """The endpoint is admin-only (§17 admin/* requires require_admin).
    A contributor caller is refused 403; an anonymous caller 401."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=70, login="target", role="contributor")
        provision_user_row(user_id=71, login="contrib", role="contributor")
        sign_in_as(
            client, user_id=71, gitea_login="contrib",
            display_name="Contrib", role="contributor",
        )
        r = client.post(
            "/api/admin/users/70/permission",
            json={"state": "granted"},
        )
        assert r.status_code == 403

        client.cookies.clear()
        r = client.post(
            "/api/admin/users/70/permission",
            json={"state": "granted"},
        )
        assert r.status_code == 401


def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
    """Pydantic regex pattern refuses anything outside the three
    canonical states with 422."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=80, login="targetx", role="contributor")
        provision_user_row(user_id=81, login="adminx", role="admin")
        sign_in_as(
            client, user_id=81, gitea_login="adminx",
            display_name="Admin X", role="admin",
        )
        r = client.post(
            "/api/admin/users/80/permission",
            json={"state": "banished"},
        )
        assert r.status_code == 422


def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
    """An admin flipping a granted user to granted gets 200 with
    `changed: false` — no audit row, no decided_at update."""
    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=90, login="alreadygranted", role="contributor")
        provision_user_row(user_id=91, login="adminN", role="admin")
        sign_in_as(
            client, user_id=91, gitea_login="adminN",
            display_name="Admin N", role="admin",
        )

        before_events = db.conn().execute(
            "SELECT COUNT(*) AS n FROM permission_events"
        ).fetchone()["n"]

        r = client.post(
            "/api/admin/users/90/permission",
            json={"state": "granted"},
        )
        assert r.status_code == 200
        body = r.json()
        assert body["changed"] is False

        after_events = db.conn().execute(
            "SELECT COUNT(*) AS n FROM permission_events"
        ).fetchone()["n"]
        assert after_events == before_events
