"""End-to-end integration tests for v0.17.0's admin-create user +
invite-email + claim-flow vertical (roadmap item #16, §6.1).

The release lands three halves of the same surface:

  * **Admin-create user** at `POST /api/admin/users`. The admin types
    email, first/last name, role, and an optional custom message. The
    framework provisions the invitee `users` row (granted, with the
    chosen role) and writes a `user_invite_tokens` row carrying the
    bcrypt-hashed opaque token. The "pending invite" discriminator is
    the active `user_invite_tokens` row joined on `invited_user_id`,
    not a NULL column on `users` (the existing `last_seen_at` column
    is NOT NULL). An invite email dispatches via the existing SMTP
    relay.

  * **Pending-invite admin listing** at `GET /api/admin/users/invites`.
    Lists active (not claimed, not expired) invites for the admin's
    "I sent these but they haven't been claimed yet" view.

  * **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
    they got via email; the framework verifies, marks the row claimed,
    signs them in (skipping OTC on first sign-in per the roadmap), and
    returns a `needs_passcode` hint for the frontend to route to the
    passcode-set screen.

The tests prove:

  * The happy path: admin creates → invite row + email envelope land →
    invitee claims with the token → session is established.
  * Non-admin caller is refused 403.
  * Self-invite is refused 422.
  * Duplicate email is refused 409.
  * Owner-grant by non-owner is refused 422.
  * Malformed role is refused 422 (pydantic regex).
  * Custom message over 500 chars is refused 422 (pydantic max_length).
  * Claim with valid token: signs in + marks row claimed.
  * Claim with expired token: HTTP 410.
  * Claim with already-claimed token: HTTP 410.
  * Claim with unknown token: HTTP 400.
  * The admin-create gesture writes a `permission_events` row with
    event_kind='user_invited'.
  * The user listing surfaces the `pending_invite` field for invited-
    but-not-yet-claimed users, and clears it after claim.
"""
from __future__ import annotations

import json

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_invite_envelopes(to_address: str | None = None) -> list[dict]:
    """Pull the invite-kind envelopes off the shared notifier buffer.

    Mirrors the OTC code-extraction helper in
    test_admin_users_vertical.py — invite emails land in the same
    `_SENT` buffer with `kind='invite'`.
    """
    from app import email as email_mod
    out = []
    for env in email_mod.sent_envelopes():
        if env.get("kind") != "invite":
            continue
        if to_address is not None and env["to"] != to_address:
            continue
        out.append(env)
    return out


def _extract_claim_url(envelope: dict) -> str:
    """Pull the claim URL out of the invite email body."""
    for line in envelope["body"].splitlines():
        line = line.strip()
        if line.startswith("http") and "/invites/claim" in line:
            return line
    raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")


def _extract_claim_token(envelope: dict) -> str:
    """Pull the `token` query-string param out of the claim URL."""
    from urllib.parse import urlparse, parse_qs
    url = _extract_claim_url(envelope)
    qs = parse_qs(urlparse(url).query)
    return qs["token"][0]


# ---------------------------------------------------------------------------
# Admin create + invite — happy path
# ---------------------------------------------------------------------------


def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
    """Admin creates → user row + invite-token row + email envelope all
    land; the response carries the created ids and the inviter is the
    admin who issued the gesture."""
    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=100, login="adminzero", role="admin")
        sign_in_as(
            client, user_id=100, gitea_login="adminzero",
            display_name="Admin Zero", role="admin",
            email="adminzero@test",
        )
        _reset_outbound()

        r = client.post(
            "/api/admin/users",
            json={
                "email": "invitee@example.com",
                "first_name": "Inv",
                "last_name": "Tee",
                "role": "contributor",
                "custom_message": "We chatted at the conference — welcome!",
            },
        )
        assert r.status_code == 200, r.text
        body = r.json()
        assert body["ok"] is True
        assert body["email"] == "invitee@example.com"
        assert body["role"] == "contributor"
        assert body["invite_id"] > 0
        assert body["invited_user_id"] > 0

        # User row exists with the chosen role + granted. The "pending
        # invite" discriminator is the active `user_invite_tokens` row,
        # not a NULL column on `users` — see the invites.create_invite
        # docstring for the reasoning.
        row = db.conn().execute(
            "SELECT role, permission_state, first_name, last_name "
            "FROM users WHERE email = ? COLLATE NOCASE",
            ("invitee@example.com",),
        ).fetchone()
        assert row is not None
        assert row["role"] == "contributor"
        assert row["permission_state"] == "granted"
        assert row["first_name"] == "Inv"
        assert row["last_name"] == "Tee"

        # Invite-token row exists with the matching ids and the custom
        # message persisted verbatim.
        invite = db.conn().execute(
            "SELECT email, role, custom_message, created_by_admin_id, "
            "invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
            (body["invite_id"],),
        ).fetchone()
        assert invite is not None
        assert invite["email"] == "invitee@example.com"
        assert invite["role"] == "contributor"
        assert invite["custom_message"] == "We chatted at the conference — welcome!"
        assert invite["created_by_admin_id"] == 100
        assert invite["invited_user_id"] == body["invited_user_id"]
        assert invite["claimed_at"] is None

        # Email envelope landed with the invite kind and embeds the
        # custom message + claim URL. The inviter display name comes
        # off the DB row (which provision_user_row sets to
        # login.capitalize()), not the sign_in_as cookie payload.
        envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
        assert len(envelopes) == 1
        env = envelopes[0]
        assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
        assert "We chatted at the conference — welcome!" in env["body"]
        # Claim URL is well-formed.
        url = _extract_claim_url(env)
        assert "/invites/claim?token=" in url

        # `permission_events` row landed with event_kind='user_invited'.
        ev = db.conn().execute(
            "SELECT actor_user_id, subject_user_id, event_kind, details "
            "FROM permission_events WHERE event_kind = 'user_invited'"
        ).fetchall()
        assert len(ev) == 1
        assert ev[0]["actor_user_id"] == 100
        assert ev[0]["subject_user_id"] == body["invited_user_id"]
        details = json.loads(ev[0]["details"])
        assert details["email"] == "invitee@example.com"
        assert details["role"] == "contributor"


# ---------------------------------------------------------------------------
# Refusals on the admin-create endpoint
# ---------------------------------------------------------------------------


def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
    """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=110, login="contrib", role="contributor")
        sign_in_as(
            client, user_id=110, gitea_login="contrib",
            display_name="Contrib", role="contributor",
        )
        r = client.post(
            "/api/admin/users",
            json={"email": "x@y.com", "role": "contributor"},
        )
        assert r.status_code == 403, r.text

        client.cookies.clear()
        r = client.post(
            "/api/admin/users",
            json={"email": "x@y.com", "role": "contributor"},
        )
        assert r.status_code == 401


def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
    """An admin trying to invite their own email is refused 422 —
    self-invite is the wrong channel; the role-change endpoint exists
    for self-edits."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=120, login="adm", role="admin")
        # Manually set the admin's email since provision_user_row's
        # fixture uses login@test; this is what we'll try to self-invite.
        from app import db
        db.conn().execute(
            "UPDATE users SET email = ? WHERE id = ?",
            ("selfinviter@example.com", 120),
        )
        sign_in_as(
            client, user_id=120, gitea_login="adm",
            display_name="Adm", role="admin",
            email="selfinviter@example.com",
        )
        r = client.post(
            "/api/admin/users",
            json={
                "email": "selfinviter@example.com",
                "role": "contributor",
            },
        )
        assert r.status_code == 422, r.text
        assert "yourself" in r.json()["detail"].lower()


def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
    """An admin trying to invite an email that already maps to a users
    row is refused 409 — the existing role / grant gestures are the
    right surface for an existing user."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=130, login="adminD", role="admin")
        provision_user_row(user_id=131, login="existingone", role="contributor")
        sign_in_as(
            client, user_id=130, gitea_login="adminD",
            display_name="Admin D", role="admin",
        )
        # provision_user_row sets email to <login>@test, so:
        r = client.post(
            "/api/admin/users",
            json={
                "email": "existingone@test",
                "role": "contributor",
            },
        )
        assert r.status_code == 409, r.text


def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
    """An admin (not owner) trying to invite a fresh user as `owner` is
    refused 422 — §6.1's owner-zero is the only bootstrap path."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=140, login="adminNoOwner", role="admin")
        sign_in_as(
            client, user_id=140, gitea_login="adminNoOwner",
            display_name="Admin", role="admin",
        )
        r = client.post(
            "/api/admin/users",
            json={
                "email": "wouldbeowner@example.com",
                "role": "owner",
            },
        )
        assert r.status_code == 422, r.text


def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
    """A sitting owner can invite a fresh user as `owner` — the §6.1
    role-grant channel. Sanity check that the owner-grant path itself
    works, paired with the refusal above."""
    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=150, login="ownerzero", role="owner")
        sign_in_as(
            client, user_id=150, gitea_login="ownerzero",
            display_name="Owner Zero", role="owner",
        )
        r = client.post(
            "/api/admin/users",
            json={
                "email": "newowner@example.com",
                "role": "owner",
            },
        )
        assert r.status_code == 200, r.text
        row = db.conn().execute(
            "SELECT role FROM users WHERE email = ? COLLATE NOCASE",
            ("newowner@example.com",),
        ).fetchone()
        assert row["role"] == "owner"


def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
    """The pydantic regex refuses any role outside the §6.1 set."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=160, login="adminR", role="admin")
        sign_in_as(
            client, user_id=160, gitea_login="adminR",
            display_name="Admin R", role="admin",
        )
        r = client.post(
            "/api/admin/users",
            json={
                "email": "ok@example.com",
                "role": "superuser",
            },
        )
        assert r.status_code == 422


def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
    """Custom message over the 500-char ceiling is refused 422."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=170, login="adminM", role="admin")
        sign_in_as(
            client, user_id=170, gitea_login="adminM",
            display_name="Admin M", role="admin",
        )
        r = client.post(
            "/api/admin/users",
            json={
                "email": "ok@example.com",
                "role": "contributor",
                "custom_message": "x" * 501,
            },
        )
        assert r.status_code == 422


# ---------------------------------------------------------------------------
# Claim flow
# ---------------------------------------------------------------------------


def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
    """End-to-end: admin creates → invitee posts the token to
    /api/invites/claim → session lands + row marked claimed +
    last_seen_at stamps on the user row (the pending-invite
    discriminator clears)."""
    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=200, login="adminC", role="admin")
        sign_in_as(
            client, user_id=200, gitea_login="adminC",
            display_name="Admin C", role="admin",
        )
        _reset_outbound()

        r = client.post(
            "/api/admin/users",
            json={
                "email": "claimant@example.com",
                "first_name": "Clai",
                "last_name": "Mant",
                "role": "contributor",
            },
        )
        assert r.status_code == 200
        invite_id = r.json()["invite_id"]
        invited_user_id = r.json()["invited_user_id"]

        env = _outbound_invite_envelopes("claimant@example.com")[0]
        token = _extract_claim_token(env)

        # The invitee's request is anonymous (they have no session
        # yet). We clear the admin's session cookie to simulate this.
        client.cookies.clear()

        r = client.post(
            "/api/invites/claim",
            json={"token": token},
        )
        assert r.status_code == 200, r.text
        body = r.json()
        assert body["ok"] is True
        assert body["user"]["id"] == invited_user_id
        assert body["user"]["role"] == "contributor"
        assert body["user"]["permission_state"] == "granted"
        # The user has no passcode set yet → frontend should route to
        # passcode-set per the roadmap.
        assert body["needs_passcode"] is True

        # Row marked claimed; last_seen_at populated.
        invite = db.conn().execute(
            "SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
            "WHERE id = ?",
            (invite_id,),
        ).fetchone()
        assert invite["claimed_at"] is not None
        assert invite["claimed_by_user_id"] == invited_user_id

        user_row = db.conn().execute(
            "SELECT last_seen_at FROM users WHERE id = ?",
            (invited_user_id,),
        ).fetchone()
        assert user_row["last_seen_at"] is not None


def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
    """A token whose `expires_at` has passed surfaces as HTTP 410."""
    from fastapi.testclient import TestClient
    from app import db, invites

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=210, login="adminE", role="admin")
        sign_in_as(
            client, user_id=210, gitea_login="adminE",
            display_name="Admin E", role="admin",
        )
        _reset_outbound()

        # Create the invite, then back-date the expires_at to the past.
        r = client.post(
            "/api/admin/users",
            json={
                "email": "expired@example.com",
                "role": "contributor",
            },
        )
        assert r.status_code == 200
        invite_id = r.json()["invite_id"]
        db.conn().execute(
            "UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
            "WHERE id = ?",
            (invite_id,),
        )
        env = _outbound_invite_envelopes("expired@example.com")[0]
        token = _extract_claim_token(env)

        client.cookies.clear()
        r = client.post("/api/invites/claim", json={"token": token})
        assert r.status_code == 410, r.text
        assert "expired" in r.json()["detail"].lower()


def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
    """Re-claiming an already-consumed token surfaces as HTTP 410."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=220, login="adminA", role="admin")
        sign_in_as(
            client, user_id=220, gitea_login="adminA",
            display_name="Admin A", role="admin",
        )
        _reset_outbound()

        r = client.post(
            "/api/admin/users",
            json={
                "email": "twice@example.com",
                "role": "contributor",
            },
        )
        assert r.status_code == 200
        env = _outbound_invite_envelopes("twice@example.com")[0]
        token = _extract_claim_token(env)

        client.cookies.clear()
        # First claim succeeds.
        r = client.post("/api/invites/claim", json={"token": token})
        assert r.status_code == 200
        # Second claim, with the same token, refuses with 410.
        client.cookies.clear()
        r = client.post("/api/invites/claim", json={"token": token})
        assert r.status_code == 410, r.text
        assert "already" in r.json()["detail"].lower()


def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
    """A token that doesn't match any active invite is HTTP 400."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        # No invite ever created; the token is whatever the attacker
        # types in. The endpoint should refuse without disclosing
        # whether the token "looked" right.
        r = client.post(
            "/api/invites/claim",
            json={"token": "totally-made-up-token-string-that-is-not-real"},
        )
        assert r.status_code == 400, r.text


# ---------------------------------------------------------------------------
# Pending-invite admin listing
# ---------------------------------------------------------------------------


def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
    """The `GET /api/admin/users/invites` listing filters to active
    invites — claimed and expired rows do not surface here (the admin
    user-listing carries the per-row pending-invite badge for the
    living rows; once claimed, the badge clears)."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=300, login="adminL", role="admin")
        sign_in_as(
            client, user_id=300, gitea_login="adminL",
            display_name="Admin L", role="admin",
        )
        _reset_outbound()

        # Create three invites: one stays pending, one we'll claim, one
        # we'll back-date to expired.
        for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
            r = client.post(
                "/api/admin/users",
                json={"email": email, "role": "contributor"},
            )
            assert r.status_code == 200

        # Claim the middle one.
        env = _outbound_invite_envelopes("claimed@ex.co")[0]
        token_claim = _extract_claim_token(env)

        # Expire the third one.
        from app import db
        db.conn().execute(
            "UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
            "WHERE email = 'expired@ex.co'"
        )

        # The admin's session is still on the cookie. Claim works
        # anonymously; we clear and restore.
        admin_cookie = client.cookies.get("rfc_session")
        client.cookies.clear()
        r = client.post("/api/invites/claim", json={"token": token_claim})
        assert r.status_code == 200
        client.cookies.set("rfc_session", admin_cookie)

        r = client.get("/api/admin/users/invites")
        assert r.status_code == 200, r.text
        items = r.json()["items"]
        emails = sorted(i["email"] for i in items)
        assert emails == ["alive@ex.co"]


def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
    """The `/api/admin/users` listing surfaces `pending_invite` while
    the invite is unclaimed; after the invitee claims, the row's
    pending_invite is null."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=310, login="adminB", role="admin")
        sign_in_as(
            client, user_id=310, gitea_login="adminB",
            display_name="Admin B", role="admin",
        )
        _reset_outbound()

        r = client.post(
            "/api/admin/users",
            json={"email": "badgey@ex.co", "role": "contributor"},
        )
        assert r.status_code == 200
        invited_id = r.json()["invited_user_id"]

        # Before claim — pending_invite is populated.
        r = client.get("/api/admin/users")
        assert r.status_code == 200
        row = next(u for u in r.json()["items"] if u["id"] == invited_id)
        assert row["pending_invite"] is not None
        assert row["pending_invite"]["invite_id"] > 0

        # Claim.
        env = _outbound_invite_envelopes("badgey@ex.co")[0]
        token = _extract_claim_token(env)
        admin_cookie = client.cookies.get("rfc_session")
        client.cookies.clear()
        r = client.post("/api/invites/claim", json={"token": token})
        assert r.status_code == 200
        client.cookies.set("rfc_session", admin_cookie)

        # After claim — pending_invite is null.
        r = client.get("/api/admin/users")
        assert r.status_code == 200
        row = next(u for u in r.json()["items"] if u["id"] == invited_id)
        assert row["pending_invite"] is None


def test_pending_invites_listing_admin_only(app_with_fake_gitea):
    """The listing requires admin/owner; contributor gets 403."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=320, login="contribL", role="contributor")
        sign_in_as(
            client, user_id=320, gitea_login="contribL",
            display_name="Contrib L", role="contributor",
        )
        r = client.get("/api/admin/users/invites")
        assert r.status_code == 403


# ---------------------------------------------------------------------------
# v0.18.0: invite-envelope header shape — Slice 2
#
# Invite mail goes through `build_envelope` and MUST land Date,
# Message-ID, Auto-Submitted, AND a `List-Unsubscribe: <mailto:…>`
# (no URL — the invitee isn't a user yet, so no per-user opt-out
# row exists). The mailto: target is the operator's `EMAIL_FROM`
# by default; the operator can override via `EMAIL_UNSUBSCRIBE_MAILTO`.
# ---------------------------------------------------------------------------


def _provision_admin_and_send_invite(client, app_with_fake_gitea_fixture, *, to: str = "headers@ex.co"):
    provision_user_row(user_id=400, login="adminH", role="admin")
    sign_in_as(
        client, user_id=400, gitea_login="adminH",
        display_name="Admin H", role="admin",
        email="adminh@test",
    )
    _reset_outbound()
    r = client.post(
        "/api/admin/users",
        json={
            "email": to,
            "first_name": "Header",
            "last_name": "Test",
            "role": "contributor",
            "custom_message": "",
        },
    )
    assert r.status_code == 200, r.text
    return _outbound_invite_envelopes(to)[-1]


def test_invite_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from email.utils import parsedate_to_datetime

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        env = _provision_admin_and_send_invite(client, (app, _fake))
        msg = env["message"]
        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"


def test_invite_envelope_has_mailto_list_unsubscribe_only(app_with_fake_gitea):
    """The invitee isn't a user yet — no per-user opt-out URL is
    available. The `List-Unsubscribe` MUST be a mailto: form, and
    the `List-Unsubscribe-Post` header MUST be absent (the
    one-click semantic requires a URL the MUA can POST to)."""
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        env = _provision_admin_and_send_invite(client, (app, _fake))
        msg = env["message"]
        lu = msg["List-Unsubscribe"]
        assert lu is not None and lu.startswith("<mailto:")
        # No URL part — invite is mailto-only.
        assert "https://" not in lu and "http://" not in lu
        assert msg["List-Unsubscribe-Post"] is None


def test_invite_envelope_respects_email_unsubscribe_mailto_override(app_with_fake_gitea, monkeypatch):
    """When `EMAIL_UNSUBSCRIBE_MAILTO` is set, the mailto: target on
    `List-Unsubscribe` honors it (lets a deployment route opt-outs
    to a humans-monitored mailbox distinct from the no-reply
    sender)."""
    from fastapi.testclient import TestClient

    monkeypatch.setenv("EMAIL_UNSUBSCRIBE_MAILTO", "ohm@wiggleverse.org?subject=remove")
    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        env = _provision_admin_and_send_invite(client, (app, _fake))
        msg = env["message"]
        assert "ohm@wiggleverse.org?subject=remove" in msg["List-Unsubscribe"]
