"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
discussion (roadmap item #12).

The RFC's owner can invite a specific email to one of two per-RFC roles:

  * `contributor` — may open PRs against this RFC AND post in its
    discussion (PR-permission strictly includes discussion-permission).
  * `discussant` — may post in this RFC's PR-less discussion only.

Non-invited users keep the v0.6.0 anonymous-read contract: they can
read but cannot write/discuss the RFC. Reads are not narrowed by
this item.

Endpoints:

  * `POST   /api/rfcs/{slug}/invitations`              — owner: create + email
  * `GET    /api/rfcs/{slug}/invitations`              — owner: list pending/accepted
  * `POST   /api/rfcs/{slug}/invitations/{id}/revoke`  — owner: revoke
  * `GET    /api/invitations/accept`                   — token lookup (signed-in user)
  * `POST   /api/invitations/accept`                   — token redeem (signed-in user)

The accept endpoints are deliberately platform-scoped (not nested under
the RFC slug) because the user clicking the email link only has the
token and may not even know the slug yet. The GET shape lets the
frontend show a confirmation page ("RFC <X> invited you to be a
<role> — accept?") before the POST commits the membership.

Permission gates (composed with `require_contributor`):

  * Issue / list / revoke: `auth.can_invite_to_rfc` — RFC owner or
    platform admin/owner.
  * Accept: any platform-granted signed-in user; the gate is the
    token, not the role. The token also constrains which email the
    accept lands under — the accepting user's email must match the
    invitation's invitee_email (case-insensitive). This prevents an
    invited-but-not-the-account-holder situation from minting a
    collaborator row under the wrong identity.

Email shape: a single plain-text body sent via the existing SMTP path
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
unsubscribe footer — the email is transactional and per-invite, not a
recurring notification. No tracking pixel.

Admin-page hook: when an accept lands and the user's
`permission_state` is still `pending`, that signals to the admin's
`/admin/users` queue that the user is here because they accepted a
per-RFC invitation — informing (not deciding) the admin's
platform-grant call. v0.16.0 surfaces this via additive columns on
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
diff in the same release) — no new endpoint, no restructure.
"""
from __future__ import annotations

import logging
import secrets
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from typing import Any

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field

from . import auth, db
from .email import EmailConfig, _SENT

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------


class CreateInvitationBody(BaseModel):
    """The owner picks an email and a role-in-RFC. No custom-message
    field — that belongs to item #16's platform-level invite surface,
    not here.

    We validate the email with a deliberately narrow pattern rather
    than `pydantic.EmailStr` to avoid pulling in `email-validator` as
    a dependency (and v0.7.0's OTC body does the same — see
    `OTCRequestBody`'s shape). The validation here is intentionally
    permissive: a local-part, an `@`, and a domain part with no
    whitespace. Operator-side typo catching is the job of the email
    transport; the framework only guards against obviously malformed
    input."""
    invitee_email: str = Field(min_length=3, max_length=320,
                               pattern=r"^[^\s@]+@[^\s@]+$")
    role_in_rfc: str = Field(pattern="^(contributor|discussant)$")


class AcceptInvitationBody(BaseModel):
    token: str = Field(min_length=1, max_length=200)


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------


# 30-day TTL matches the device-trust window the framework already
# ships (v0.11.0). A pending invitation past this is rejected at the
# accept endpoint regardless of the row's `status` column.
INVITATION_TTL_DAYS = 30


# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------


def make_router() -> APIRouter:
    router = APIRouter()

    # ---------------------------------------------------------------
    # POST /api/rfcs/<slug>/invitations
    # The owner creates an invitation. The endpoint mints the token,
    # writes the row, and dispatches the email synchronously. A failure
    # to send the email does NOT roll back the row — the owner can
    # share the link directly out-of-band if SMTP is briefly down (the
    # `GET /api/rfcs/<slug>/invitations` response carries the token
    # for that fallback).
    # ---------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/invitations")
    async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        rfc = _require_rfc(slug, viewer)
        if not auth.can_invite_to_rfc(viewer, slug):
            raise HTTPException(
                403,
                "Only the RFC's owner can invite collaborators",
            )

        return issue_invitation(
            slug=slug,
            inviter_user_id=viewer.user_id,
            inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
            invitee_email=body.invitee_email,
            role_in_rfc=body.role_in_rfc,
            rfc_title=rfc["title"],
        )

    # ---------------------------------------------------------------
    # GET /api/rfcs/<slug>/invitations
    # The owner's listing of every invitation on the RFC, regardless
    # of status. Carries the token (for the resend / re-share path).
    # ---------------------------------------------------------------

    @router.get("/api/rfcs/{slug}/invitations")
    async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        _require_rfc(slug, viewer)
        if not auth.can_invite_to_rfc(viewer, slug):
            raise HTTPException(
                403,
                "Only the RFC's owner can view invitations",
            )

        rows = db.conn().execute(
            """
            SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
                   i.expires_at, i.created_at, i.accepted_at,
                   i.inviter_user_id, i.accepted_by_user_id,
                   u_inviter.display_name AS inviter_display,
                   u_inviter.gitea_login AS inviter_login,
                   u_accept.display_name AS accepted_by_display,
                   u_accept.gitea_login AS accepted_by_login
              FROM rfc_invitations i
         LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
         LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
             WHERE i.rfc_slug = ?
             ORDER BY i.id DESC
            """,
            (slug,),
        ).fetchall()

        return {
            "items": [
                {
                    "id": r["id"],
                    "invitee_email": r["invitee_email"],
                    "role_in_rfc": r["role_in_rfc"],
                    "status": _effective_status(r),
                    "token": r["token"],
                    "expires_at": r["expires_at"],
                    "created_at": r["created_at"],
                    "accepted_at": r["accepted_at"],
                    "inviter_display": r["inviter_display"],
                    "inviter_login": r["inviter_login"],
                    "accepted_by_display": r["accepted_by_display"],
                    "accepted_by_login": r["accepted_by_login"],
                }
                for r in rows
            ],
        }

    # ---------------------------------------------------------------
    # POST /api/rfcs/<slug>/invitations/<id>/revoke
    # Revokes a pending invitation. Already-accepted invitations
    # cannot be "revoked" from this surface — the corresponding
    # collaborator-removal surface is a §19.2 candidate; v0.16.0
    # only lifts the *pending* link.
    # ---------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
    async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        _require_rfc(slug, viewer)
        if not auth.can_invite_to_rfc(viewer, slug):
            raise HTTPException(
                403,
                "Only the RFC's owner can revoke invitations",
            )

        row = db.conn().execute(
            "SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
            (invitation_id, slug),
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Invitation not found")
        if row["status"] != "pending":
            raise HTTPException(
                409,
                f"Invitation is {row['status']}; only pending invitations can be revoked",
            )

        db.conn().execute(
            "UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
            (invitation_id,),
        )
        return {"ok": True, "id": invitation_id, "status": "revoked"}

    # ---------------------------------------------------------------
    # GET /api/invitations/accept?token=...
    # Lookup-only — returns what the invitation grants so the
    # frontend can render a confirmation page before the POST. The
    # token is required; no token, no peek.
    # ---------------------------------------------------------------

    @router.get("/api/invitations/accept")
    async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        row = _lookup_invitation_by_token(token)
        if row is None:
            raise HTTPException(404, "Invitation not found")
        effective = _effective_status(row)
        rfc = db.conn().execute(
            "SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
        ).fetchone()
        return {
            "rfc_slug": row["rfc_slug"],
            "rfc_title": rfc["title"] if rfc else row["rfc_slug"],
            "role_in_rfc": row["role_in_rfc"],
            "status": effective,
            "invitee_email": row["invitee_email"],
            "email_matches_you": (viewer.email or "").strip().lower()
                                == row["invitee_email"].strip().lower(),
            "expires_at": row["expires_at"],
        }

    # ---------------------------------------------------------------
    # POST /api/invitations/accept
    # The accept gesture: token → collaborator row.
    #
    # Requires:
    #   * an authenticated user (no token-only acceptance — we want
    #     the per-user audit trail),
    #   * a valid (pending, non-expired, non-revoked) invitation,
    #   * the accepting user's email matches invitee_email
    #     (case-insensitive).
    #
    # On success the row's status flips to 'accepted' and a
    # rfc_collaborators row is inserted (or upgraded if the user
    # already had a lower role). Idempotent: re-accepting the same
    # already-accepted invitation reads as a 200 no-op with
    # `changed=false`.
    # ---------------------------------------------------------------

    @router.post("/api/invitations/accept")
    async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_user(request)
        row = _lookup_invitation_by_token(body.token)
        if row is None:
            raise HTTPException(404, "Invitation not found")

        effective = _effective_status(row)
        if effective == "revoked":
            raise HTTPException(409, "Invitation was revoked")
        if effective == "expired":
            raise HTTPException(409, "Invitation has expired")

        # Email match — case-insensitive. Empty viewer email cannot
        # accept (an OAuth-only user with no captured email shape).
        viewer_email = (viewer.email or "").strip().lower()
        invitee_email = row["invitee_email"].strip().lower()
        if not viewer_email or viewer_email != invitee_email:
            raise HTTPException(
                403,
                "This invitation was sent to a different email; sign in with that address",
            )

        if effective == "accepted":
            # Idempotent re-accept — surface the existing collaborator
            # row without writing anything new.
            collab = db.conn().execute(
                "SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
                (row["rfc_slug"], viewer.user_id),
            ).fetchone()
            return {
                "ok": True,
                "changed": False,
                "rfc_slug": row["rfc_slug"],
                "role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
            }

        # First-time accept. Flip the invitation; upsert the
        # collaborator. We do the upsert with ON CONFLICT so a
        # user who already held a lower role gets upgraded, never
        # downgraded (the MAX-style precedence is contributor >
        # discussant; lower roles never overwrite higher).
        with db.tx() as c:
            c.execute(
                """
                UPDATE rfc_invitations
                   SET status = 'accepted',
                       accepted_at = datetime('now'),
                       accepted_by_user_id = ?
                 WHERE id = ?
                """,
                (viewer.user_id, row["id"]),
            )
            existing = c.execute(
                "SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
                (row["rfc_slug"], viewer.user_id),
            ).fetchone()
            target_role = _max_role(
                existing["role_in_rfc"] if existing else None,
                row["role_in_rfc"],
            )
            if existing is None:
                c.execute(
                    """
                    INSERT INTO rfc_collaborators
                        (rfc_slug, user_id, role_in_rfc, invitation_id)
                    VALUES (?, ?, ?, ?)
                    """,
                    (row["rfc_slug"], viewer.user_id, target_role, row["id"]),
                )
            elif existing["role_in_rfc"] != target_role:
                c.execute(
                    """
                    UPDATE rfc_collaborators
                       SET role_in_rfc = ?, invitation_id = ?
                     WHERE rfc_slug = ? AND user_id = ?
                    """,
                    (target_role, row["id"], row["rfc_slug"], viewer.user_id),
                )

        return {
            "ok": True,
            "changed": True,
            "rfc_slug": row["rfc_slug"],
            "role_in_rfc": target_role,
        }

    return router


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _require_rfc(slug: str, viewer):
    """The invitation surface only operates on a known, non-withdrawn
    RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the
    discussion endpoints' `_require_rfc_readable` shape. The §22.5
    visibility gate is subtractive: a gated project's entries 404 to
    non-members (§22.7)."""
    row = db.conn().execute(
        "SELECT slug, title, state, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,),
    ).fetchone()
    if row is None:
        raise HTTPException(404, "RFC not found")
    auth.require_project_readable(viewer, row["project_id"])
    if row["state"] == "withdrawn":
        raise HTTPException(409, "RFC is withdrawn")
    return row


def _lookup_invitation_by_token(token: str):
    return db.conn().execute(
        """
        SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
               status, token, expires_at, created_at, accepted_at,
               accepted_by_user_id
          FROM rfc_invitations
         WHERE token = ?
        """,
        (token,),
    ).fetchone()


def _effective_status(row) -> str:
    """The row's column status is the authoritative truth except for
    `expired` — that is derived from `expires_at` at read time so an
    unattended cron isn't required to flip rows. A revoked-then-
    expired row reads as `revoked` (the explicit gesture wins)."""
    column_status = row["status"]
    if column_status != "pending":
        return column_status
    # Compare via SQL so the comparison is in sqlite-time, matching the
    # `datetime('now')` insert. A simpler same-process comparison would
    # work too, but routing through the DB keeps the timezone handling
    # consistent with the inserts.
    is_past = db.conn().execute(
        "SELECT datetime(?) <= datetime('now') AS past",
        (row["expires_at"],),
    ).fetchone()["past"]
    return "expired" if is_past else "pending"


def issue_invitation(
    *,
    slug: str,
    inviter_user_id: int,
    inviter_display: str,
    invitee_email: str,
    role_in_rfc: str,
    rfc_title: str,
) -> dict:
    """Mint + persist + email one ``rfc_invitations`` row.

    The single chokepoint for issuing an invitation: the owner's manual
    `POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
    accept path both route through here, so the dup-guard, token mint,
    insert, and transactional email stay identical.

    Refuses (409) re-inviting an email that already has a pending
    invitation on this RFC at the same role. A different-role re-invite is
    allowed (the discussant → contributor upgrade) — the new row
    supersedes the old in the listing's natural ordering, and acceptance
    of either picks up the corresponding role.

    Returns the new row's dict (including the raw token, for the owner's
    out-of-band share / the caller's record-keeping). A send failure logs
    and returns; the row stays so the owner can recover via the listing.
    """
    invitee_email = invitee_email.strip()
    existing = db.conn().execute(
        """
        SELECT id FROM rfc_invitations
         WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
           AND role_in_rfc = ? AND status = 'pending'
         LIMIT 1
        """,
        (slug, invitee_email, role_in_rfc),
    ).fetchone()
    if existing:
        raise HTTPException(
            409,
            f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
        )

    token = _mint_token()
    cur = db.conn().execute(
        """
        INSERT INTO rfc_invitations
            (rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
             token, expires_at)
        VALUES (?, ?, ?, ?, ?, datetime('now', ?))
        """,
        (slug, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
    )
    invitation_id = cur.lastrowid

    _send_invitation_email(
        to_address=invitee_email,
        inviter_display=inviter_display,
        rfc_title=rfc_title,
        role_in_rfc=role_in_rfc,
        token=token,
    )

    return {
        "id": invitation_id,
        "rfc_slug": slug,
        "invitee_email": invitee_email,
        "role_in_rfc": role_in_rfc,
        "status": "pending",
        "token": token,
    }


def _mint_token() -> str:
    """A 256-bit URL-safe token. The token shape is opaque to the
    consumer; the email link encodes it as a query param."""
    return secrets.token_urlsafe(32)


def _max_role(existing: str | None, new: str) -> str:
    """contributor strictly dominates discussant. A re-accept that
    would lower the role is a no-op (the existing role survives)."""
    precedence = {"discussant": 0, "contributor": 1}
    if existing is None:
        return new
    if precedence.get(new, 0) > precedence.get(existing, 0):
        return new
    return existing


# ---------------------------------------------------------------------------
# Email dispatch — transactional, no preferences honored
# ---------------------------------------------------------------------------


def _send_invitation_email(
    *,
    to_address: str,
    inviter_display: str,
    rfc_title: str,
    role_in_rfc: str,
    token: str,
) -> bool:
    """Compose and send the invitation email.

    Like `email_otc.send_otc_email`, this writes its own envelope and
    reuses `EmailConfig.from_env()` for the SMTP plumbing. The
    `_SENT` buffer is appended either way so integration tests can
    assert on the outbound shape without a real SMTP server.

    Returns True on the happy path / dev fallback; False on SMTP
    failure. The caller does not roll back the invitation row on
    failure — the owner has the token in the create response and on
    the listing surface for an out-of-band share.
    """
    cfg = EmailConfig.from_env()
    subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}"
    role_label = (
        "open PRs against the RFC and join its discussion"
        if role_in_rfc == "contributor"
        else "join the RFC's discussion"
    )
    link = f"{cfg.app_url}/invitations/accept?token={token}"
    body = (
        f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n"
        f"This invitation lets you {role_label}.\n\n"
        f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n"
        f"    {link}\n\n"
        f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n"
        f"this, you can safely ignore the email.\n\n"
        f"---\n"
        f"{cfg.from_name} · {cfg.app_url}\n"
    )
    envelope = {
        "to": to_address,
        "from": formataddr((cfg.from_name, cfg.from_address)),
        "subject": subject,
        "body": body,
        "kind": "rfc_invitation",
    }
    _SENT.append(envelope)

    if not cfg.enabled:
        log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address)
        return True
    if not cfg.smtp_host:
        # Dev fallback — surface the link at INFO so the operator can
        # complete an accept flow without an SMTP relay.
        log.info(
            "invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s",
            to_address, rfc_title, role_in_rfc, link,
        )
        return True

    try:
        msg = EmailMessage()
        msg["From"] = envelope["from"]
        msg["To"] = to_address
        msg["Subject"] = subject
        msg.set_content(body)
        smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
        try:
            if cfg.smtp_starttls:
                smtp.starttls()
            if cfg.smtp_user:
                smtp.login(cfg.smtp_user, cfg.smtp_password)
            smtp.send_message(msg)
        finally:
            smtp.quit()
        return True
    except Exception:
        log.exception("invitation email send failed: to=%s", to_address)
        return False
