"""
Enforces Role.action_matrix (FR-RBA-003) at the API layer. Previously this
matrix was stored and editable via Roles & Privileges but never actually
checked anywhere — any authenticated caller could do anything regardless of
their assigned role. require_permission(module, action) is a dependency
factory applied alongside get_auth_context on individual routes.
"""

import uuid

from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.security import AuthContext, get_auth_context
from app.models.identity import Role, UserRole

# The four modules a Public Portal (vendor) role can grant access to — see
# Portal Roles & Privileges (frontend/src/pages/PortalRoles.tsx). Unlike an
# internal role's action_matrix (module -> action verbs, checked by
# require_permission below), a portal role's action_matrix for these modules
# holds specific record ids it grants (module -> [record_id, ...]), mirroring
# how a Menu already points at one specific Master/Form/Page/Dashboard rather
# than a whole module. See resolve_portal_grants/require_portal_item.
# "report_templates": saved Reports (app/routers/report_templates.py).
PORTAL_ITEM_MODULES = {"masters", "forms", "pages", "dashboard", "report_templates"}

# Whole-feature grants a Portal role can hold — unlike PORTAL_ITEM_MODULES
# above, these aren't tied to one specific record, so there's nothing to
# enumerate: the module key is present with a non-empty list (by convention
# ["enabled"]) when granted, absent/empty when not: "reports" (the
# standalone Report Builder page at /portal/reports — see
# datasets.py::_require_reports_feature) and "user_management" (managing
# the portal users of one's own organization, giving only the roles listed
# under "user_management_roles" — see routers/portal_users.py). See
# PortalRoles.tsx.
PORTAL_FEATURE_MODULES = {"reports", "user_management"}


async def resolve_action_matrix(
    db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None
) -> tuple[dict[str, list[str]], bool]:
    """Returns (merged action_matrix across all of the user's roles, is_system_admin).
    A caller holding any is_system_role role (the fixed Administrator role,
    FR-RBA-005) always passes every check, so its matrix need not be merged.
    Only ever called for internal callers (require_permission short-circuits
    for vendor tokens on the modules a portal role governs instead), so no
    scope filter is needed here — a vendor user can never hold an
    internal-scope role (enforced at assignment, see routers/users.py)."""
    if user_id is None:
        return {}, False

    stmt = (
        select(Role)
        .join(UserRole, UserRole.role_id == Role.id)
        .where(UserRole.user_id == user_id, Role.tenant_id == tenant_id)
    )
    roles = list((await db.execute(stmt)).scalars().all())
    if any(r.is_system_role for r in roles):
        return {}, True

    merged: dict[str, list[str]] = {}
    for role in roles:
        for module, actions in (role.action_matrix or {}).items():
            bucket = merged.setdefault(module, [])
            for action in actions:
                if action not in bucket:
                    bucket.append(action)
    return merged, False


async def resolve_portal_grants(
    db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None
) -> dict[str, set[str]]:
    """Merged set of record ids each Portal-scope role held by this user
    grants, per module — the vendor-side counterpart to resolve_action_matrix.
    No is_system_role-style bypass exists for portal roles: every grant must
    be explicit (mirrors the "no role -> no access" posture new portal
    signups start with, see routers/auth.py::register_vendor_user)."""
    if user_id is None:
        return {}

    stmt = (
        select(Role)
        .join(UserRole, UserRole.role_id == Role.id)
        .where(UserRole.user_id == user_id, Role.tenant_id == tenant_id, Role.scope == "portal")
    )
    roles = list((await db.execute(stmt)).scalars().all())
    merged: dict[str, set[str]] = {}
    for role in roles:
        for module, ids in (role.action_matrix or {}).items():
            merged.setdefault(module, set()).update(str(i).lower() for i in ids)
    if merged.get("forms"):
        merged["forms"] |= await _embedded_form_ids(db, tenant_id, merged["forms"])
    return merged


async def _embedded_form_ids(db: AsyncSession, tenant_id: uuid.UUID, form_ids: set[str]) -> set[str]:
    """Forms embedded (an "embedded_form" field) in the published versions of
    these forms, and in those, a few levels down: granting a form grants
    what it's made of."""
    from app.models.workflow import Form

    found: set[str] = set()
    frontier = set(form_ids)
    for _ in range(3):
        ids = []
        for i in frontier:
            try:
                ids.append(uuid.UUID(i))
            except ValueError:
                continue
        if not ids:
            break
        rows = await db.execute(
            select(Form.field_definitions).where(
                Form.tenant_id == tenant_id,
                Form.form_id.in_(ids),
                Form.status == "published",
                Form.field_definitions.contains([{"type": "embedded_form"}]),
            )
        )
        children = {
            str(f.get("embedded_form_id")).lower()
            for (defs,) in rows.all()
            for f in defs or []
            if f.get("type") == "embedded_form" and f.get("embedded_form_id")
        }
        frontier = children - found - form_ids
        found |= children
    return found


def require_permission(module: str, action: str):
    """FastAPI dependency factory — Depends(require_permission("masters", "edit")).

    For a vendor (Public Portal) caller on one of PORTAL_ITEM_MODULES, this
    is a deliberate no-op: that caller's access is never governed by the
    internal module x action matrix, only by the item-level
    require_portal_item() check the route adds separately (or, for a plain
    list endpoint, by filtering its results to resolve_portal_grants()).
    Every other module is unaffected — a vendor token still fails those the
    same way it always has, since a portal role's action_matrix values are
    record ids, not action verbs, and will never match an action string."""

    async def checker(
        ctx: AuthContext = Depends(get_auth_context),
        db: AsyncSession = Depends(get_db),
    ) -> AuthContext:
        if ctx.vendor_id is not None and module in PORTAL_ITEM_MODULES:
            return ctx
        matrix, is_system_admin = await resolve_action_matrix(db, ctx.tenant_id, ctx.user_id)
        if is_system_admin:
            return ctx
        if action not in matrix.get(module, []):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Your role doesn't have '{action}' access to {module}.",
            )
        return ctx

    return checker


def require_run_access(module: str, action: str):
    """Like require_permission(module, action), except a vendor (Public
    Portal) caller is always let through instead of failing on the internal
    action_matrix (which a portal role never populates for a non-
    PORTAL_ITEM_MODULES module like "tasks"/"workflows" in the first place —
    see require_permission's own doc). Used only for routes whose OWN body
    already enforces the real vendor-safe boundary — POST /tasks/{id}/run
    (published-only unless allow_draft, which the route forces false for a
    vendor caller regardless of what the request body asked for) and POST
    /workflow-instances (starts only a workflow's current PUBLISHED
    version, unconditionally). Both exist specifically to be triggered as
    the side effect of a Form/Page action (on_submit/on_load/a button) a
    vendor already has legitimate access to — not a Studio-only action the
    "edit"/"create" verb was written to gate. An internal caller is
    unaffected, still going through the normal require_permission check."""

    async def checker(
        ctx: AuthContext = Depends(get_auth_context),
        db: AsyncSession = Depends(get_db),
    ) -> AuthContext:
        if ctx.vendor_id is not None:
            return ctx
        return await require_permission(module, action)(ctx=ctx, db=db)

    return checker


def require_portal_item(module: str, id_param: str):
    """Item-level Public Portal gate for one of PORTAL_ITEM_MODULES —
    Depends(require_portal_item("pages", "page_id")) on a route whose path
    has a {page_id} segment. A no-op for internal callers (their access is
    already governed by require_permission on the same route)."""

    async def checker(
        request: Request,
        ctx: AuthContext = Depends(get_auth_context),
        db: AsyncSession = Depends(get_db),
    ) -> AuthContext:
        if ctx.vendor_id is None:
            return ctx
        record_id = request.path_params.get(id_param)
        grants = await resolve_portal_grants(db, ctx.tenant_id, ctx.user_id)
        if record_id is None or str(record_id).lower() not in grants.get(module, set()):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Your portal role doesn't grant access to this item.",
            )
        return ctx

    return checker


async def portal_item_actions(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None, module: str, item_id: uuid.UUID | str) -> set[str]:
    """What a portal user may do with one granted record (PORTAL_ITEM_ACTIONS):
    across their portal roles that grant it, the actions each allows — for
    a role with no detailed setting for it, default_portal_actions (what
    roles could do before these settings). Empty = not granted at all. A form only
    reachable as one embedded in granted forms (_embedded_form_ids) gets
    what those forms allow."""
    from app.schemas.identity import default_portal_actions

    if user_id is None:
        return set()
    key = str(item_id).lower()
    roles = (
        await db.execute(
            select(Role)
            .join(UserRole, UserRole.role_id == Role.id)
            .where(UserRole.user_id == user_id, Role.tenant_id == tenant_id, Role.scope == "portal")
        )
    ).scalars().all()

    def allowed(role: Role, record: str) -> set[str]:
        if record not in {str(i).lower() for i in (role.action_matrix or {}).get(module, [])}:
            return set()
        detailed = ((role.portal_permissions or {}).get(module) or {}).get(record)
        return default_portal_actions(module, role.action_matrix) if detailed is None else set(detailed) | {"view"}

    out: set[str] = set()
    for role in roles:
        out |= allowed(role, key)
    if out or module != "forms":
        return out
    for role in roles:
        for parent in {str(i).lower() for i in (role.action_matrix or {}).get("forms", [])}:
            if key in await _embedded_form_ids(db, tenant_id, {parent}):
                out |= allowed(role, parent)
    return out


async def check_portal_action(db: AsyncSession, ctx: AuthContext, module: str, item_id: uuid.UUID | str, action: str) -> None:
    """403 unless a portal caller's roles allow `action` on this record —
    see portal_item_actions. A no-op for internal callers."""
    if ctx.vendor_id is None:
        return
    actions = await portal_item_actions(db, ctx.tenant_id, ctx.user_id, module, item_id)
    if action not in actions:
        noun = {"masters": "datasource", "forms": "form", "report_templates": "report"}.get(module, "item")
        verb = {
            "view": "open", "create": "add records to", "submit": "submit", "edit": "edit records of", "delete": "delete records of",
            "import": "import records into", "export": "export",
        }.get(action, action)
        detail = f"Your portal role doesn't let you {verb} this {noun}." if actions else "Your portal role doesn't grant access to this item."
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)


def require_portal_action(module: str, id_param: str, action: str):
    """require_portal_item, for one action (PORTAL_ITEM_ACTIONS) on the
    record named by a path param — e.g. Depends(require_portal_action(
    "masters", "master_id", "delete"))."""

    async def checker(
        request: Request,
        ctx: AuthContext = Depends(get_auth_context),
        db: AsyncSession = Depends(get_db),
    ) -> AuthContext:
        await check_portal_action(db, ctx, module, request.path_params.get(id_param) or "", action)
        return ctx

    return checker


def deny_vendor(detail: str = "Not available to Public Portal accounts."):
    """Defense-in-depth for studio design endpoints (Pillar 7) — a vendor
    user's token should never grant access here regardless of any role
    permission it happens to carry."""

    async def checker(ctx: AuthContext = Depends(get_auth_context)) -> AuthContext:
        if ctx.vendor_id is not None:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
        return ctx

    return checker
