"""Canonical action vocabulary shared by the policy and permission layers.

Every authorization decision is a comparison of a caller-supplied string
against a fixed vocabulary of action names. Two gates used to consult that
vocabulary with the raw string, so a value such as ``"execute_shell "``
(trailing space) or ``"RUN_SHELL"`` matched no entry at all: the scope gate
was skipped and the action was not classified as sensitive. This module is the
single normalization point both gates call before they look anything up, so
the very same canonical string decides the scope check, the sensitivity
classification and the policy rule match.

The vocabulary is also explicit. An action the system does not know is
unregistered and is denied by default; it becomes usable only when it is
registered here (scope-mapped, classified as sensitive, or listed as a known
safe non-mutating action). Opting in is a code change, not a runtime string,
which is what keeps an invented action name from being treated as harmless.

It also owns the format of an *already granted* authorization, because the
second gate in front of the sandbox needs the same vocabulary as the first one.
A ``cyberbrain run`` step declares its action once; the dispatcher's
authorization chain requests the human approval and then hands control to the
agent, whose own gateway re-checks that very action immediately before the
provider runs. Those two checks are one decision, not two, so the outer one
mints an ``ExecutionAuthorization`` naming the agent, the action and a digest of
the exact payload a human approved, and binds it to the single in-flight agent
call. The inner gateway re-verifies identity, action and payload digest against
that grant instead of raising a second, independent approval request: defence in
depth is preserved (every gate still runs, and a payload that changed after the
operator approved it is refused) while one human decision stays one human
decision.

The grant is deliberately not a bearer token that can be passed around. It lives
in a context variable scoped to the dispatcher's own ``await``, so it cannot be
read out of a payload, cannot be stored, cannot survive a process restart, and
is gone before the next task is dispatched. A durable task retried after a
restart therefore reruns the whole chain and asks a human again.

This module imports nothing from the rest of the security plane except
``identity`` (which has no security-plane imports of its own), so ``policy``,
``permission``, the extension layer's dispatcher and the execution plane's
gateway can all depend on it without an import cycle.
"""

from __future__ import annotations

import hashlib
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Final

from cyberbrain26.security_plane.identity import PermissionScope

__all__ = [
    "ACTION_SCOPE_REQUIREMENTS",
    "DEFAULT_EXECUTION_TIMEOUT_SECONDS",
    "KNOWN_ACTIONS",
    "KNOWN_SAFE_ACTIONS",
    "SENSITIVE_ACTIONS",
    "ExecutionAuthorization",
    "bound_execution_authorization",
    "current_execution_authorization",
    "execution_payload_digest",
    "is_registered_action",
    "normalize_action",
]

# The timeout both authorization sites assume when a payload states none. It is
# a shared constant because the digest of a payload depends on it: if the outer
# gate assumed 30 and the inner one assumed 60, every well-behaved step would
# look like a tampered one and be refused.
DEFAULT_EXECUTION_TIMEOUT_SECONDS: Final[int] = 30


def normalize_action(action: object) -> str:
    """Return the canonical form of an action name.

    Both sides of every decision — the scope lookup and the policy
    classification — must call this first, otherwise a cosmetic difference in
    the caller's string silently selects a different (weaker) authorization
    path. Surrounding whitespace and letter case are not part of an action's
    identity, so they are removed rather than trusted.

    Args:
        action: The raw action value. Anything that is not a string carries no
            action name at all and normalizes to the empty string, which is
            unregistered and therefore denied.

    Returns:
        The stripped, lower-cased action name, or ``""`` when none can be read.
    """
    if not isinstance(action, str):
        return ""
    return action.strip().lower()


def _canonical_language(language: object) -> str:
    """Return the language token the execution layer will actually act on.

    The execution gateway maps ``"py"``/``"python"`` and ``"sh"``/``"shell"`` onto
    one action each, so a digest taken from the raw payload value and a digest
    taken from the gateway's normalized value must agree; that is why the same
    strip/lower-case rule is applied on both sides. A non-string is rendered with
    ``repr`` rather than folded into the empty string, so a malformed value can
    never collide with an absent one in a digest.
    """
    if isinstance(language, str):
        return language.strip().lower()
    return repr(language)


def _digest_field(value: object) -> str:
    """Render one payload field into the material a digest is taken over."""
    return value if isinstance(value, str) else repr(value)


def execution_payload_digest(
    *,
    code: object,
    language: object,
    workspace: object,
    timeout_seconds: object = DEFAULT_EXECUTION_TIMEOUT_SECONDS,
) -> str:
    """Return a stable fingerprint of everything that decides what will run.

    The digest covers the four inputs the execution gateway hands to the
    provider - the code, the normalized language, the workspace it runs in and
    the timeout - and nothing else. It is the security-critical half of the
    single-approval flow: the outer gate approves this fingerprint, so the inner
    gate may only skip asking again while the payload in front of it still
    hashes to it. Any difference in any of the four fields (including whitespace
    in the workspace path) produces a different digest, which the inner gate
    refuses instead of executing.

    Fields are joined with a NUL separator, which cannot occur in the payload, so
    no two different field tuples can produce the same material.
    """
    material = "\x00".join(
        (
            _digest_field(code),
            _canonical_language(language),
            _digest_field(workspace),
            _digest_field(timeout_seconds),
        )
    )
    return hashlib.sha256(material.encode("utf-8")).hexdigest()


@dataclass(frozen=True)
class ExecutionAuthorization:
    """One already-granted authorization for exactly one execution payload.

    Minted by the dispatcher's authorization chain *after* a human approved the
    declared action, and consumed by the execution gateway for the very payload
    the digest was taken over. Immutable and unforgeable from outside: the only
    producer is the dispatcher, it never travels in a payload or a database, and
    its lifetime is one in-flight agent call (see
    :func:`bound_execution_authorization`).

    Attributes:
        agent_id: Identity the approval was granted to. A grant is never usable
            by another identity, so one agent's approval cannot be spent by a
            second one.
        task_id: Dispatch task the approval belongs to, recorded for the audit
            trail. The binding that actually scopes the grant to one task is the
            context-variable lifetime, not this field.
        action: Canonical action a human approved. The gateway ignores the grant
            entirely when the action it is about to perform differs, which is
            what keeps an approval for one action from authorizing another (a
            step declaring the non-mutating label ``"code"``, for instance, still
            raises its own approval for ``execute_python``).
        resource: The declared resource of the dispatch, carried for the audit
            trail only. It is deliberately *not* compared at consumption time:
            the dispatcher's declared resource and the gateway's execution
            workspace are different attributes of a step, and the workspace is
            already covered by the payload digest.
        payload_digest: Fingerprint from :func:`execution_payload_digest`.
        approval_request_id: The approval request a human decided, or ``None``
            when the outer chain allowed the action outright. A grant without a
            request id carries no human decision, so the gateway never treats it
            as a substitute for one.
    """

    agent_id: str
    task_id: str
    action: str
    payload_digest: str
    approval_request_id: str | None = None
    resource: str | None = None

    def covers(self, *, agent_id: str, action: str, payload_digest: str) -> bool:
        """Report whether this grant authorizes the execution being attempted.

        Callers must have already decided that the grant's action is the action
        being performed; a differing action is not answered here, because the
        right response to "approved something else" is to run the full
        authorization chain for the real action, not to fail a match.
        """
        return (
            self.agent_id == agent_id
            and self.action == action
            and self.payload_digest == payload_digest
        )


_CURRENT_EXECUTION_AUTHORIZATION: ContextVar[ExecutionAuthorization | None] = ContextVar(
    "cyberbrain26_execution_authorization", default=None
)


def current_execution_authorization() -> ExecutionAuthorization | None:
    """Return the grant bound to the dispatch currently in flight, if any."""
    return _CURRENT_EXECUTION_AUTHORIZATION.get()


@contextmanager
def bound_execution_authorization(
    authorization: ExecutionAuthorization | None,
) -> Iterator[None]:
    """Bind ``authorization`` for the duration of one agent call.

    Security rationale: the binding is both the propagation mechanism and the
    security boundary. Because the context variable is set and reset around the
    dispatcher's own ``await agent.execute(...)``, a grant is readable by the
    gates of exactly that one call and by nothing else - it cannot be read from
    the payload, forged by a caller, smuggled in through the event bus, reused
    by a later step or persisted for a later process. Binding ``None`` clears
    any grant inherited from an enclosing scope, so a nested dispatch cannot
    spend an outer task's approval.
    """
    token = _CURRENT_EXECUTION_AUTHORIZATION.set(authorization)
    try:
        yield
    finally:
        _CURRENT_EXECUTION_AUTHORIZATION.reset(token)


# Canonical action -> identity scope the action requires. This mapping is the
# scope gate: an action that is not listed here has no scope requirement, so it
# must also be on KNOWN_SAFE_ACTIONS to be usable at all.
ACTION_SCOPE_REQUIREMENTS: Final[dict[str, str]] = {
    "read_file": PermissionScope.READ_FILE.value,
    "write_file": PermissionScope.WRITE_FILE.value,
    "delete_file": PermissionScope.DELETE_FILE.value,
    "execute_shell": PermissionScope.SHELL.value,
    "execute_python": PermissionScope.PYTHON.value,
    "access_network": PermissionScope.NETWORK.value,
    "access_browser": PermissionScope.BROWSER.value,
    "execute_git": PermissionScope.GIT.value,
    "execute_docker": PermissionScope.DOCKER.value,
    "access_secrets": PermissionScope.SECRETS.value,
    "access_mcp": PermissionScope.MCP.value,
    "audit_log": PermissionScope.AUDIT.value,
    # Egress actions: sending data out of the sandbox is a network effect, so
    # they require the same scope that authorises reaching the network at all.
    # Leaving them unmapped would make them *unreachable* rather than merely
    # gated: the permission gateway denies any action that is neither
    # scope-mapped nor on KNOWN_SAFE_ACTIONS, which swallowed these four
    # sensitive actions before policy could route them to the approval gate
    # SENSITIVE_ACTIONS explicitly prepares for them. Scoping them keeps both
    # gates meaningful — identity still says *who* may egress, policy/approval
    # still says *whether this particular egress happens*.
    "publish_content": PermissionScope.NETWORK.value,
    "send_external_message": PermissionScope.NETWORK.value,
    "data_upload": PermissionScope.NETWORK.value,
    # Pentest actions. Each one is a network effect against a target a human
    # authorised, so all three require the scope that authorises reaching the
    # network at all - the pentest catalog is the thing that decides *which*
    # target and *which* arguments, this mapping is the thing that decides
    # whether this identity may perform that class of action at all. They are
    # three names rather than one because policy, the approval record and the
    # audit trail all have to be able to tell "listed a host" from "probed it
    # for vulnerabilities" from "used an exploit against it"; collapsing them
    # into a single name would erase exactly the distinction a reviewer needs.
    "scan_network": PermissionScope.NETWORK.value,
    "vuln_scan": PermissionScope.NETWORK.value,
    "exploit_target": PermissionScope.NETWORK.value,
    # Reading or handing over a credential is access to secrets, exactly what
    # "access_secrets" already scopes; a distinct scope for the act of
    # *obtaining* one would not restrict anything, so the existing member is
    # reused rather than a new enum member being invented.
    "credential_access": PermissionScope.SECRETS.value,
}

# Actions classified as sensitive: policy treats them as risky and SAFE mode
# demands approval for them. Every entry here is also scope-mapped in
# ACTION_SCOPE_REQUIREMENTS — a sensitive action that were left unmapped would
# be denied by the permission gateway as unregistered and could therefore never
# reach this classification, so the two sets must stay in lock-step. The
# invariant is asserted by the test suite (no action may be both unmapped and
# allowed).
SENSITIVE_ACTIONS: Final[frozenset[str]] = frozenset(
    {
        "delete_file",
        "write_file",
        "execute_shell",
        "execute_python",
        "access_network",
        "access_secrets",
        "execute_docker",
        "publish_content",
        "send_external_message",
        "credential_access",
        "data_upload",
        # Pentest actions: every one of them reaches a host on the network, so
        # all three are sensitive and all three are scope-mapped above. Being
        # sensitive is what routes them to the human-approval gate in SAFE mode
        # instead of letting a policy "allow" wave them through.
        "scan_network",
        "vuln_scan",
        "exploit_target",
    }
)

# The complete list of actions that need no identity scope *and* are known not
# to mutate anything: the prototype agents behind them only build an in-memory
# result. It is deliberately tiny — an entry here is a standing claim that the
# action cannot cause an effect, so anything that touches the filesystem, the
# network, a process or a secret must never be added to it.
#
# Caveat kept honest: an entry here authorises the *label*, not the payload. A
# payload that carries executable content behind such a label is still stopped
# by AuthorizedExecutionService, which re-authorizes the real action
# ("execute_python" / "execute_shell") with its own scope and approval gate
# before a provider is reached.
#
# "code" is registered because the prototype CoderAgent also records a code step
# that touches nothing. The moment its payload carries ``code``, the caveat above
# applies: the run is re-authorized as "execute_python" and runs nothing without
# that scope and approval.
#
# That re-authorization is not a second opinion on the same decision, so it does
# not become a second human decision. When a step declares the real action
# ("execute_python") the dispatcher's grant is valid for it and the gateway
# re-verifies identity, action and payload digest instead of asking again; when a
# step declares a label such as "code", the actions differ, the grant does not
# apply, and the gateway asks for the real action on its own. Either way a label
# on its own can never authorize executable content.
KNOWN_SAFE_ACTIONS: Final[frozenset[str]] = frozenset({"plan", "evaluate", "code"})

# Every action the system recognises. An action outside this set is
# unregistered: neither scope-mapped, nor classified, nor explicitly safe, so
# the system has no statement to make about it and denies it.
KNOWN_ACTIONS: Final[frozenset[str]] = (
    frozenset(ACTION_SCOPE_REQUIREMENTS) | SENSITIVE_ACTIONS | KNOWN_SAFE_ACTIONS
)


def is_registered_action(action: object) -> bool:
    """Check whether an action name is part of the canonical vocabulary.

    The name is normalized first, so a whitespace or case variant of a known
    action is recognised rather than treated as a different, unknown action.
    """
    return normalize_action(action) in KNOWN_ACTIONS
