"""Console authorization and RBAC model (#633, Phase 1).

The read-only MVP (#426–#436) ships with no authentication: protection comes
from network placement alone (#435). That is adequate while every route is a
GET, and inadequate the moment Phase 2 wires a gated write. This module is the
authorization model those writes must go through, landed *before* any of them
exists so no write can be added without an authority to check against.

Phase 1 scope is the model itself: identity resolution, the role matrix, the
privileged-action list, and a fail-closed :func:`authorize`. It deliberately
does **not** enable any write. ``webui.gated_actions`` stays globally disabled,
so an allow decision here is necessary but never sufficient.

Two invariants hold for every caller:

- **Default deny.** An unrecognised action, an unknown role, or an absent
  principal denies. There is no implicit allow branch and no "unless" clause.
- **Authorization is not execution.** :func:`authorize` returns a decision
  record. It never calls MCP, never mutates, and never consults credentials.
"""

from __future__ import annotations

import json
import os
from dataclasses import asdict, dataclass, field
from typing import Any

from task_capability_map import required_permission, required_role

# --- Roles ------------------------------------------------------------------
# Ordered least to most authority. Higher ranks inherit every lower rank's
# permitted actions; the matrix below is expressed as a minimum required rank.
VIEWER = "viewer"
OPERATOR = "operator"
CONTROLLER = "controller"
ADMIN = "admin"

ROLE_ORDER: tuple[str, ...] = (VIEWER, OPERATOR, CONTROLLER, ADMIN)
_ROLE_RANK: dict[str, int] = {role: idx for idx, role in enumerate(ROLE_ORDER)}

ROLE_DESCRIPTIONS: dict[str, str] = {
    VIEWER: "Read every console view. No write, ever, in any phase.",
    OPERATOR: "Viewer, plus author-class work: claim, comment, open a PR.",
    CONTROLLER: "Operator, plus reviewer/merger-class decisions on a PR.",
    ADMIN: "Controller, plus destructive and policy-editing actions.",
}

# --- Identity sources -------------------------------------------------------
IDENTITY_NONE = "none"
IDENTITY_LOCAL_DEV = "local_dev"
IDENTITY_ACCESS_PROXY = "access_proxy"

IDENTITY_SOURCES: dict[str, dict[str, Any]] = {
    IDENTITY_NONE: {
        "description": (
            "No authentication configured. Every request is anonymous and "
            "capped at viewer. This is the MVP default and the only mode "
            "whose safety rests entirely on network placement (#435)."
        ),
        "authenticated": False,
        "safe_for_shared_host": False,
        "phase_available": 1,
    },
    IDENTITY_LOCAL_DEV: {
        "description": (
            "Developer-supplied principal read from the environment. INSECURE: "
            "the subject and role are asserted, never verified. Loopback only."
        ),
        "authenticated": True,
        "safe_for_shared_host": False,
        "phase_available": 1,
    },
    IDENTITY_ACCESS_PROXY: {
        "description": (
            "Subject asserted by a trusted access proxy (Cloudflare Access, "
            "WARP, or an org VPN portal) via a verified request header. The "
            "proxy performs authentication; the console performs authorization."
        ),
        "authenticated": True,
        "safe_for_shared_host": True,
        "phase_available": 2,
    },
}

# Environment configuration. All are read server-side and never rendered.
AUTH_MODE_ENV = "WEBUI_AUTH_MODE"
DEV_SUBJECT_ENV = "WEBUI_DEV_SUBJECT"
DEV_ROLE_ENV = "WEBUI_DEV_ROLE"
ROLE_MAP_ENV = "WEBUI_ROLE_MAP"
REQUIRE_PROBE_AUTH_ENV = "WEBUI_REQUIRE_PROBE_AUTH"
ACCESS_SUBJECT_HEADER = "cf-access-authenticated-user-email"

# --- Action classes ---------------------------------------------------------
CLASS_READ = "read"
CLASS_WRITE = "gated_write"
CLASS_PRIVILEGED = "privileged"
CLASS_DESTRUCTIVE = "destructive"

# --- Privileged action list -------------------------------------------------
# ``task_key`` ties each console action back to ``task_capability_map``, so the
# console cannot invent an authority the MCP layer does not already define.


@dataclass(frozen=True)
class ConsoleAction:
    """One console action and the authority required to invoke it."""

    action_id: str
    task_key: str
    action_class: str
    minimum_role: str
    requires_confirmation: bool
    dual_control: bool
    break_glass: bool
    phase: int
    summary: str

    @property
    def mcp_permission(self) -> str:
        return required_permission(self.task_key)

    @property
    def mcp_role(self) -> str:
        return required_role(self.task_key)

    @property
    def privileged(self) -> bool:
        return self.action_class in {CLASS_PRIVILEGED, CLASS_DESTRUCTIVE}

    def to_dict(self) -> dict[str, Any]:
        data = asdict(self)
        data["mcp_permission"] = self.mcp_permission
        data["mcp_role"] = self.mcp_role
        data["privileged"] = self.privileged
        return data


_ACTION_SPECS: tuple[ConsoleAction, ...] = (
    ConsoleAction(
        action_id="claim_issue",
        task_key="claim_issue",
        action_class=CLASS_WRITE,
        minimum_role=OPERATOR,
        requires_confirmation=True,
        dual_control=False,
        break_glass=False,
        phase=2,
        summary="Apply status:in-progress to an issue.",
    ),
    ConsoleAction(
        action_id="comment_issue",
        task_key="comment_issue",
        action_class=CLASS_WRITE,
        minimum_role=OPERATOR,
        requires_confirmation=True,
        dual_control=False,
        break_glass=False,
        phase=2,
        summary="Post an issue comment.",
    ),
    ConsoleAction(
        action_id="create_issue",
        task_key="create_issue",
        action_class=CLASS_WRITE,
        minimum_role=OPERATOR,
        requires_confirmation=True,
        dual_control=False,
        break_glass=False,
        phase=2,
        summary="Open a new tracking issue.",
    ),
    ConsoleAction(
        action_id="comment_pr",
        task_key="comment_pr",
        action_class=CLASS_WRITE,
        minimum_role=OPERATOR,
        requires_confirmation=True,
        dual_control=False,
        break_glass=False,
        phase=2,
        summary="Post a PR thread comment.",
    ),
    ConsoleAction(
        action_id="create_pr",
        task_key="create_pr",
        action_class=CLASS_WRITE,
        minimum_role=OPERATOR,
        requires_confirmation=True,
        dual_control=False,
        break_glass=False,
        phase=2,
        summary="Open a PR from a locked feature branch.",
    ),
    ConsoleAction(
        action_id="review_pr",
        task_key="review_pr",
        action_class=CLASS_PRIVILEGED,
        minimum_role=CONTROLLER,
        requires_confirmation=True,
        dual_control=False,
        break_glass=False,
        phase=3,
        summary="Submit an approve / request-changes verdict.",
    ),
    ConsoleAction(
        action_id="close_pr",
        task_key="close_pr",
        action_class=CLASS_PRIVILEGED,
        minimum_role=CONTROLLER,
        requires_confirmation=True,
        dual_control=False,
        break_glass=False,
        phase=3,
        summary="Close a pull request without merging.",
    ),
    ConsoleAction(
        action_id="merge_pr",
        task_key="merge_pr",
        action_class=CLASS_PRIVILEGED,
        minimum_role=CONTROLLER,
        requires_confirmation=True,
        dual_control=True,
        break_glass=True,
        phase=3,
        summary="Merge an approved pull request.",
    ),
    ConsoleAction(
        action_id="delete_branch",
        task_key="delete_branch",
        action_class=CLASS_DESTRUCTIVE,
        minimum_role=ADMIN,
        requires_confirmation=True,
        dual_control=True,
        break_glass=True,
        phase=3,
        summary="Remove a remote feature branch.",
    ),
)

ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}


def privileged_actions() -> tuple[ConsoleAction, ...]:
    """Actions requiring dual control, break-glass, or controller+ authority."""
    return tuple(a for a in _ACTION_SPECS if a.privileged)


def get_action(action_id: str) -> ConsoleAction | None:
    return ACTIONS.get(action_id)


# --- Principals -------------------------------------------------------------


@dataclass(frozen=True)
class Principal:
    """Who is making a request, and how strongly that is known."""

    subject: str
    role: str
    identity_source: str
    authenticated: bool
    warnings: tuple[str, ...] = field(default_factory=tuple)

    @property
    def rank(self) -> int:
        return _ROLE_RANK.get(self.role, -1)

    def to_dict(self) -> dict[str, Any]:
        return {
            "subject": self.subject,
            "role": self.role,
            "identity_source": self.identity_source,
            "authenticated": self.authenticated,
            "warnings": list(self.warnings),
        }


ANONYMOUS = Principal(
    subject="anonymous",
    role=VIEWER,
    identity_source=IDENTITY_NONE,
    authenticated=False,
    warnings=("No authentication configured; capped at viewer.",),
)


def auth_mode(env: dict[str, str] | None = None) -> str:
    """Resolve the configured identity source, defaulting to ``none``."""
    source = env if env is not None else os.environ
    raw = (source.get(AUTH_MODE_ENV) or "").strip().lower().replace("-", "_")
    if raw in IDENTITY_SOURCES:
        return raw
    return IDENTITY_NONE


def _role_map(env: dict[str, str]) -> dict[str, str]:
    """Parse ``WEBUI_ROLE_MAP`` (JSON subject→role). Invalid config yields {}."""
    raw = (env.get(ROLE_MAP_ENV) or "").strip()
    if not raw:
        return {}
    try:
        parsed = json.loads(raw)
    except Exception:
        return {}
    if not isinstance(parsed, dict):
        return {}
    return {
        str(k): str(v).strip().lower()
        for k, v in parsed.items()
        if str(v).strip().lower() in _ROLE_RANK
    }


def resolve_principal(
    headers: dict[str, str] | None = None,
    env: dict[str, str] | None = None,
) -> Principal:
    """Resolve the requesting principal. Unknown or unconfigured → anonymous.

    Never raises and never trusts a client-supplied role: the role always comes
    from server-side configuration keyed by the resolved subject.
    """
    source_env = dict(env) if env is not None else dict(os.environ)
    lowered = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
    mode = auth_mode(source_env)

    if mode == IDENTITY_LOCAL_DEV:
        subject = (source_env.get(DEV_SUBJECT_ENV) or "").strip()
        if not subject:
            return ANONYMOUS
        role = (source_env.get(DEV_ROLE_ENV) or VIEWER).strip().lower()
        if role not in _ROLE_RANK:
            role = VIEWER
        return Principal(
            subject=subject,
            role=role,
            identity_source=IDENTITY_LOCAL_DEV,
            authenticated=True,
            warnings=(
                "local-dev identity is asserted, not verified; never use "
                "outside loopback.",
            ),
        )

    if mode == IDENTITY_ACCESS_PROXY:
        subject = (lowered.get(ACCESS_SUBJECT_HEADER) or "").strip()
        if not subject:
            # Proxy mode with no proxy header means the request did not
            # traverse the proxy. Fail closed rather than trust it.
            return ANONYMOUS
        role = _role_map(source_env).get(subject, VIEWER)
        return Principal(
            subject=subject,
            role=role,
            identity_source=IDENTITY_ACCESS_PROXY,
            authenticated=True,
        )

    return ANONYMOUS


def probe_auth_required(env: dict[str, str] | None = None) -> bool:
    """Whether non-public probes must be authenticated. Default False.

    #633 requires the console to *fail closed on missing auth for non-public
    health probes if configured*. The default stays off so the MVP ``/health``
    contract is unchanged; an operator opts in explicitly.
    """
    source = env if env is not None else os.environ
    return (source.get(REQUIRE_PROBE_AUTH_ENV) or "").strip().lower() in {
        "1",
        "true",
        "yes",
    }


# --- Authorization ----------------------------------------------------------

DENY_UNKNOWN_ACTION = "unknown_action"
DENY_UNAUTHENTICATED = "unauthenticated"
DENY_INSUFFICIENT_ROLE = "insufficient_role"
DENY_UNKNOWN_ROLE = "unknown_role"
DENY_PHASE_NOT_ACTIVE = "phase_not_active"
ALLOW_PREVIEW = "allowed_preview_only"

# Phase 1 is the only active console phase. Phase 2 opens gated writes and is
# gated on this model landing; nothing here enables it.
ACTIVE_PHASE = 1


@dataclass(frozen=True)
class AuthorizationDecision:
    """Result of an authorization check. Never an execution grant."""

    allowed: bool
    reason_code: str
    detail: str
    action_id: str
    principal: Principal
    required_role: str | None = None
    action_class: str | None = None
    requires_confirmation: bool = False
    dual_control: bool = False
    break_glass: bool = False
    execution_enabled: bool = False

    def to_dict(self) -> dict[str, Any]:
        return {
            "allowed": self.allowed,
            "reason_code": self.reason_code,
            "detail": self.detail,
            "action_id": self.action_id,
            "principal": self.principal.to_dict(),
            "required_role": self.required_role,
            "action_class": self.action_class,
            "requires_confirmation": self.requires_confirmation,
            "dual_control": self.dual_control,
            "break_glass": self.break_glass,
            "execution_enabled": self.execution_enabled,
            "active_phase": ACTIVE_PHASE,
        }


def authorize(
    action_id: str,
    principal: Principal | None = None,
    *,
    for_execution: bool = False,
) -> AuthorizationDecision:
    """Decide whether *principal* may invoke *action_id*. Deny by default.

    ``for_execution`` distinguishes a read-only preview from a real invocation.
    Even an allowed decision reports ``execution_enabled=False`` while the
    console is in Phase 1, so no caller can read an allow as permission to
    mutate.
    """
    who = principal if principal is not None else ANONYMOUS
    action = get_action(action_id)

    if action is None:
        return AuthorizationDecision(
            allowed=False,
            reason_code=DENY_UNKNOWN_ACTION,
            detail=f"No console action registered as {action_id!r}.",
            action_id=action_id,
            principal=who,
        )

    base: dict[str, Any] = {
        "action_id": action_id,
        "principal": who,
        "required_role": action.minimum_role,
        "action_class": action.action_class,
        "requires_confirmation": action.requires_confirmation,
        "dual_control": action.dual_control,
        "break_glass": action.break_glass,
        "execution_enabled": False,
    }

    if not who.authenticated:
        return AuthorizationDecision(
            allowed=False,
            reason_code=DENY_UNAUTHENTICATED,
            detail=(
                "Write actions require an authenticated principal; this "
                "request is anonymous."
            ),
            **base,
        )

    if who.rank < 0:
        return AuthorizationDecision(
            allowed=False,
            reason_code=DENY_UNKNOWN_ROLE,
            detail=f"Role {who.role!r} is not in the console role matrix.",
            **base,
        )

    if who.rank < _ROLE_RANK[action.minimum_role]:
        return AuthorizationDecision(
            allowed=False,
            reason_code=DENY_INSUFFICIENT_ROLE,
            detail=(
                f"Action {action_id!r} requires {action.minimum_role!r}; "
                f"principal holds {who.role!r}."
            ),
            **base,
        )

    if for_execution and action.phase > ACTIVE_PHASE:
        return AuthorizationDecision(
            allowed=False,
            reason_code=DENY_PHASE_NOT_ACTIVE,
            detail=(
                f"Action {action_id!r} belongs to phase {action.phase}; the "
                f"console is in phase {ACTIVE_PHASE}. Execution is not wired."
            ),
            **base,
        )

    return AuthorizationDecision(
        allowed=True,
        reason_code=ALLOW_PREVIEW,
        detail=(
            "Principal holds the required role. Preview only — execution "
            "remains disabled until the Phase 2 action framework ships."
        ),
        **base,
    )


def rbac_matrix() -> dict[str, Any]:
    """Machine-readable RBAC matrix and privileged-action list."""
    return {
        "model_version": 1,
        "active_phase": ACTIVE_PHASE,
        "roles": [
            {
                "role": role,
                "rank": _ROLE_RANK[role],
                "description": ROLE_DESCRIPTIONS[role],
                "permitted_actions": sorted(
                    a.action_id
                    for a in _ACTION_SPECS
                    if _ROLE_RANK[role] >= _ROLE_RANK[a.minimum_role]
                ),
            }
            for role in ROLE_ORDER
        ],
        "identity_sources": IDENTITY_SOURCES,
        "actions": [a.to_dict() for a in _ACTION_SPECS],
        "privileged_actions": [a.action_id for a in privileged_actions()],
        "default_decision": "deny",
        "execution_enabled": False,
    }
