"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.

A gated project or collection is invisible to non-members (§22.5), so joining is
by invite (an Owner grants directly — `api_memberships.py`) *or* by request: a
user who knows a scope exists asks to join it, naming a desired role. This module
is the request side:

  * ``GET  /api/scopes/{scope_type}/{scope_id}/join-target`` — what the join
    form needs (the scope's name, the viewer's eligibility + whether they already
    have a pending ask + their current role).
  * ``POST /api/scopes/{scope_type}/{scope_id}/join-requests`` — submit the ask
    (desired role + optional message); lands a row + one §15 notification per
    Owner across the scope's subtree (the cross-collection inbox, §22.11).
  * ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/accept`` —
    Owner: accept, which writes the `memberships` row via ``memberships.grant``
    (the §22.8 "accepting writes the membership row"), then notifies the requester.
  * ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/decline`` —
    Owner: decline; the request closes and the requester is notified.

Mirrors ``api_contributions.py`` (the per-RFC contribute-request flow) but at the
scope grain: the target is a ``(scope_type, scope_id)`` pair drawn from the
``memberships`` scope vocabulary (minus ``global`` — a deployment isn't a thing
one discovers and joins), and accept grants a scope role rather than minting an
RFC invitation.

The request POST deliberately does **not** require the scope be *readable*: the
whole point of request-to-join is to ask into a *gated* scope you were told about
but cannot see (§22.8). It is gated only on "you're signed in, granted, and not
already a member". Accept/decline are gated on Owner reach over the scope
(``auth.can_invite_at_project`` / ``auth.can_invite_at_collection``).
"""
from __future__ import annotations

import sqlite3
from typing import Any

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

from . import (
    auth,
    collections as collections_mod,
    db,
    memberships as memberships_mod,
    notify,
)

_MESSAGE_MAX = 4000


class JoinRequestBody(BaseModel):
    role: str
    message: str | None = Field(default=None, max_length=_MESSAGE_MAX)


class DecideBody(BaseModel):
    # On accept, the Owner may grant a role narrower than the one requested; a
    # missing value grants exactly the requested role.
    role: str | None = None


def _project_name(project_id: str) -> str | None:
    row = db.conn().execute(
        "SELECT name FROM projects WHERE id = ?", (project_id,)
    ).fetchone()
    return row["name"] if row and row["name"] else None


def _resolve_scope(scope_type: str, scope_id: str) -> dict[str, Any]:
    """Resolve a `(scope_type, scope_id)` target to its display facts, or 404 if
    it doesn't exist. Returns `{project_id, scope_name, project_name}`. The
    `scope_type` itself must be one of the join-able scopes."""
    if scope_type == "project":
        row = db.conn().execute(
            "SELECT id, name FROM projects WHERE id = ?", (scope_id,)
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Not found")
        name = row["name"] or scope_id
        return {"project_id": scope_id, "scope_name": name, "project_name": name}
    if scope_type == "collection":
        col = collections_mod.get_collection(scope_id)
        if col is None:
            raise HTTPException(404, "Not found")
        pid = col["project_id"]
        return {
            "project_id": pid,
            "scope_name": col.get("name") or scope_id,
            "project_name": _project_name(pid),
        }
    raise HTTPException(404, "Not found")


def _require_join_owner(viewer, scope_type: str, scope_id: str) -> None:
    """The accept/decline gate: an Owner whose reach covers the scope (§22.8 'the
    scope's Owners across the subtree'). Reuses the S4 invite gates."""
    ok = (
        auth.can_invite_at_collection(viewer, scope_id)
        if scope_type == "collection"
        else auth.can_invite_at_project(viewer, scope_id)
    )
    if not ok:
        raise HTTPException(403, "Only an Owner of this scope can act on join requests")


def _require_request(scope_type: str, scope_id: str, request_id: int):
    row = db.conn().execute(
        """
        SELECT id, scope_type, scope_id, requester_user_id, requested_role,
               message, status
          FROM join_requests
         WHERE id = ? AND scope_type = ? AND scope_id = ?
        """,
        (request_id, scope_type, scope_id),
    ).fetchone()
    if row is None:
        raise HTTPException(404, "Join request not found")
    return row


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

    # ---------------------------------------------------------------
    # GET — what the join form needs to render + gate itself.
    # ---------------------------------------------------------------

    @router.get("/api/scopes/{scope_type}/{scope_id}/join-target")
    async def join_target(scope_type: str, scope_id: str, request: Request) -> dict[str, Any]:
        facts = _resolve_scope(scope_type, scope_id)
        viewer = auth.current_user(request)

        eligible = True
        reason: str | None = None
        already_requested = False
        current_role = auth.effective_role_at_scope(viewer, scope_type, scope_id)

        if viewer is None:
            eligible, reason = False, "Sign in to request to join."
        elif viewer.permission_state != "granted":
            eligible, reason = False, "Your beta access request is in review."
        elif current_role is not None:
            eligible, reason = False, f"You already hold {('Owner' if current_role == 'owner' else 'RFC Contributor')} here."
        else:
            already_requested = bool(
                db.conn().execute(
                    """
                    SELECT 1 FROM join_requests
                     WHERE scope_type = ? AND scope_id = ? AND requester_user_id = ?
                       AND status = 'pending' LIMIT 1
                    """,
                    (scope_type, scope_id, viewer.user_id),
                ).fetchone()
            )

        return {
            "scope_type": scope_type,
            "scope_id": scope_id,
            "name": facts["scope_name"],
            "project_id": facts["project_id"],
            "eligible": eligible and not already_requested,
            "reason": reason,
            "already_requested": already_requested,
            "current_role": current_role,
        }

    # ---------------------------------------------------------------
    # POST — submit a request to join.
    # ---------------------------------------------------------------

    @router.post("/api/scopes/{scope_type}/{scope_id}/join-requests")
    async def create_join_request(
        scope_type: str, scope_id: str, body: JoinRequestBody, request: Request
    ) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        facts = _resolve_scope(scope_type, scope_id)

        role = (body.role or "").strip().lower()
        if role not in memberships_mod.VALID_ROLES:
            raise HTTPException(422, f"invalid role {body.role!r}")

        # Already a member of the scope (at this or a broader grain)? Then there
        # is nothing to request — a clear 409 rather than a useless self-request.
        if auth.effective_role_at_scope(viewer, scope_type, scope_id) is not None:
            raise HTTPException(409, "You already hold a role in this scope.")

        message = (body.message or "").strip() or None

        try:
            cur = db.conn().execute(
                """
                INSERT INTO join_requests
                    (scope_type, scope_id, requester_user_id, requested_role, message)
                VALUES (?, ?, ?, ?, ?)
                """,
                (scope_type, scope_id, viewer.user_id, role, message),
            )
        except sqlite3.IntegrityError:
            # The partial unique index — one open request per (scope, user).
            raise HTTPException(409, "You already have a pending request to join this scope.")
        request_id = cur.lastrowid

        # One actionable notification per Owner across the subtree; stamp the
        # first onto the row as the inbox-action handle (any Owner may act).
        notif_ids = notify.fan_out_join_request(
            scope_type=scope_type,
            scope_id=scope_id,
            scope_name=facts["scope_name"],
            project_id=facts["project_id"],
            project_name=facts["project_name"],
            requester_user_id=viewer.user_id,
            request_id=request_id,
            requested_role=role,
            message=message,
        )
        if notif_ids:
            db.conn().execute(
                "UPDATE join_requests SET notification_id = ? WHERE id = ?",
                (notif_ids[0], request_id),
            )

        return {"id": request_id, "scope_type": scope_type, "scope_id": scope_id, "status": "pending"}

    # ---------------------------------------------------------------
    # POST — Owner accepts → write the membership row.
    # ---------------------------------------------------------------

    @router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/accept")
    async def accept_join_request(
        scope_type: str, scope_id: str, request_id: int, body: DecideBody, request: Request
    ) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        facts = _resolve_scope(scope_type, scope_id)
        _require_join_owner(viewer, scope_type, scope_id)

        req = _require_request(scope_type, scope_id, request_id)
        if req["status"] != "pending":
            raise HTTPException(409, f"This request was already {req['status']}.")

        # The Owner may narrow the requested role on accept; default to what was
        # asked for. (Both are within the Owner's grant reach at this scope.)
        granted_role = (body.role or req["requested_role"] or "").strip().lower()
        if granted_role not in memberships_mod.VALID_ROLES:
            raise HTTPException(422, f"invalid role {body.role!r}")

        memberships_mod.grant(
            scope_type=scope_type,
            scope_id=scope_id,
            user_id=req["requester_user_id"],
            role=granted_role,
            granted_by=viewer.user_id,
        )
        db.conn().execute(
            """
            UPDATE join_requests
               SET status = 'accepted', decided_at = datetime('now'),
                   decided_by_user_id = ?, granted_role = ?
             WHERE id = ?
            """,
            (viewer.user_id, granted_role, request_id),
        )
        notify.notify_join_decided(
            requester_user_id=req["requester_user_id"],
            decider_user_id=viewer.user_id,
            request_id=request_id,
            scope_type=scope_type,
            scope_id=scope_id,
            scope_name=facts["scope_name"],
            granted_role=granted_role,
            accepted=True,
        )
        return {"ok": True, "status": "accepted", "granted_role": granted_role}

    # ---------------------------------------------------------------
    # POST — Owner declines.
    # ---------------------------------------------------------------

    @router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/decline")
    async def decline_join_request(
        scope_type: str, scope_id: str, request_id: int, request: Request
    ) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        facts = _resolve_scope(scope_type, scope_id)
        _require_join_owner(viewer, scope_type, scope_id)

        req = _require_request(scope_type, scope_id, request_id)
        if req["status"] != "pending":
            raise HTTPException(409, f"This request was already {req['status']}.")

        db.conn().execute(
            """
            UPDATE join_requests
               SET status = 'declined', decided_at = datetime('now'),
                   decided_by_user_id = ?
             WHERE id = ?
            """,
            (viewer.user_id, request_id),
        )
        notify.notify_join_decided(
            requester_user_id=req["requester_user_id"],
            decider_user_id=viewer.user_id,
            request_id=request_id,
            scope_type=scope_type,
            scope_id=scope_id,
            scope_name=facts["scope_name"],
            granted_role=None,
            accepted=False,
        )
        return {"ok": True, "status": "declined"}

    return router
