"""Tool policy enforcement for side_effect_policy.

Pure logic for determining read-only status and policy decisions.
No I/O imports. See docs/INTERFACES.md Section E (side_effect_policy).

Contracts:
    - is_read_only_tool: Determines if tool name matches read-only heuristic
    - check_tool_policy: Returns policy decision for tool call

>>> is_read_only_tool("read_file")
True
>>> is_read_only_tool("write_file")
False
>>> is_read_only_tool("ctl_finish")
True
"""

# @invar:allow file_size: Small module; policy logic fits in one file

from dataclasses import dataclass
from enum import Enum
from typing import Literal

from deal import ensure, post, pre

from anima.core.schemas import SideEffectPolicy, ToolPosture


class ToolClassification(str, Enum):
    """Classification of tool based on side effect analysis.

    >>> ToolClassification.CLASSIFIED_READ_ONLY.value
    'classified_read_only'
    >>> ToolClassification.CLASSIFIED_MUTATING.value
    'classified_mutating'
    >>> ToolClassification.UNCLASSIFIABLE.value
    'unclassifiable'
    """

    CLASSIFIED_READ_ONLY = "classified_read_only"
    CLASSIFIED_MUTATING = "classified_mutating"
    UNCLASSIFIABLE = "unclassifiable"


class ToolDecision(str, Enum):
    """Decision result for tool policy check.

    >>> ToolDecision.ALLOW.value
    'allow'
    >>> ToolDecision.BLOCK.value
    'block'
    >>> ToolDecision.APPROVAL.value
    'approval'
    """

    ALLOW = "allow"
    BLOCK = "block"
    APPROVAL = "approval"


@dataclass(frozen=True)
class ToolPolicyResolution:
    """Resolved tool policy decision with provenance.

    Attributes:
        decision: Effective decision to apply for this tool call.
        source: Which layer produced the decision.
        posture: Reserved metadata, currently always ``None``.

    Examples:
        >>> ToolPolicyResolution(ToolDecision.ALLOW, "side_effect_policy", None)
        ToolPolicyResolution(decision=<ToolDecision.ALLOW: 'allow'>, source='side_effect_policy', posture=None)
    """

    decision: ToolDecision
    source: Literal["side_effect_policy"]
    posture: ToolPosture | None = None


# Read-only tool name prefixes per docs/INTERFACES.md Section E
# Tools whose names start with these prefixes are considered read-only
READ_ONLY_PREFIXES = frozenset(
    ("read_", "get_", "list_", "search_", "find_", "show_", "status")
)

# CTL tools are generally permitted (lifecycle/control).
CTL_TOOL_PREFIX = "ctl_"

# Relative restrictiveness: read_only > approval_required > allow
_POLICY_RANK: dict[SideEffectPolicy, int] = {
    SideEffectPolicy.allow: 1,
    SideEffectPolicy.approval_required: 2,
    SideEffectPolicy.read_only: 3,
}


@pre(
    lambda persona_policy, job_policy: (
        (persona_policy is None or isinstance(persona_policy, SideEffectPolicy))
        and (job_policy is None or isinstance(job_policy, SideEffectPolicy))
    )
)
@post(lambda result: isinstance(result, SideEffectPolicy))
@ensure(
    lambda persona_policy, job_policy, result: (
        (persona_policy is None or _POLICY_RANK[result] >= _POLICY_RANK[persona_policy])
        and (job_policy is None or _POLICY_RANK[result] >= _POLICY_RANK[job_policy])
    )
)
def most_restrictive(
    persona_policy: SideEffectPolicy | None,
    job_policy: SideEffectPolicy | None,
) -> SideEffectPolicy:
    """Merge persona and job side-effect policies by most restrictive ordering.

    Ordering: read_only(3) > approval_required(2) > allow(1).

    Args:
        persona_policy: Policy from PersonaSpec, or None when unset.
        job_policy: Policy from JobSpec, or None when unset.

    Returns:
        The most restrictive effective policy, defaulting to allow when both unset.

    Examples:
        >>> from anima.core.schemas import SideEffectPolicy as SEP
        >>> most_restrictive(SEP.allow, SEP.read_only)
        <SideEffectPolicy.read_only: 'read_only'>
        >>> most_restrictive(None, SEP.approval_required)
        <SideEffectPolicy.approval_required: 'approval_required'>
        >>> most_restrictive(None, None)
        <SideEffectPolicy.allow: 'allow'>
        >>> most_restrictive(SEP.allow, SEP.approval_required)
        <SideEffectPolicy.approval_required: 'approval_required'>
        >>> most_restrictive(SEP.read_only, SEP.allow)
        <SideEffectPolicy.read_only: 'read_only'>
    """
    if persona_policy is None and job_policy is None:
        return SideEffectPolicy.allow
    if persona_policy is None:
        assert job_policy is not None
        return job_policy
    if job_policy is None:
        assert persona_policy is not None
        return persona_policy

    if _POLICY_RANK[persona_policy] >= _POLICY_RANK[job_policy]:
        return persona_policy
    return job_policy


@pre(
    lambda name, annotations=None: (
        isinstance(name, str)
        and len(name) > 0
        and (annotations is None or isinstance(annotations, dict))
    )
)
@post(lambda result: isinstance(result, ToolClassification))
def classify_tool(
    name: str,
    annotations: dict[str, bool] | None = None,
) -> ToolClassification:
    """Classify a tool as read-only, mutating, or unclassifiable.

    Uses MCP annotations first (readOnlyHint, destructiveHint), then falls
    back to name heuristics. Tools that cannot be confidently classified
    are marked as UNCLASSIFIABLE.

    Args:
        name: Tool name to classify.
        annotations: Optional MCP tool annotations.

    Returns:
        ToolClassification indicating read-only, mutating, or unclassifiable.

    Examples:
        >>> classify_tool("read_file")
        <ToolClassification.CLASSIFIED_READ_ONLY: 'classified_read_only'>
        >>> classify_tool("write_file")
        <ToolClassification.CLASSIFIED_MUTATING: 'classified_mutating'>
        >>> classify_tool("ctl_finish")
        <ToolClassification.CLASSIFIED_READ_ONLY: 'classified_read_only'>
        >>> classify_tool("unknown_tool")
        <ToolClassification.UNCLASSIFIABLE: 'unclassifiable'>
        >>> classify_tool("unknown_tool", {"readOnlyHint": True})
        <ToolClassification.CLASSIFIED_READ_ONLY: 'classified_read_only'>
    """
    # Use annotations if available
    if annotations is not None:
        if annotations.get("readOnlyHint") is True:
            return ToolClassification.CLASSIFIED_READ_ONLY
        if annotations.get("destructiveHint") is True:
            return ToolClassification.CLASSIFIED_MUTATING

    # Fall back to name heuristic
    if is_read_only_tool(name):
        return ToolClassification.CLASSIFIED_READ_ONLY

    # If it starts with a known mutating prefix, classify as mutating
    mutating_prefixes = ("write_", "create_", "delete_", "update_", "edit_", "modify_")
    name_lower = name.lower()
    for prefix in mutating_prefixes:
        if name_lower.startswith(prefix):
            return ToolClassification.CLASSIFIED_MUTATING

    # Cannot classify - default-reject in read_only mode
    return ToolClassification.UNCLASSIFIABLE


@pre(lambda name: isinstance(name, str) and len(name) > 0)
def is_read_only_tool(name: str) -> bool:
    """Determine if a tool is read-only based on name heuristic.

    Read-only tools are those whose names start with: read, get, list,
    search, find, show, status. CTL tools are considered read-only.

    Args:
        name: Tool name to check.

    Returns:
        True if tool is read-only, False otherwise.

    Examples:
        >>> is_read_only_tool("read_file")
        True
        >>> is_read_only_tool("get_status")
        True
        >>> is_read_only_tool("list_agents")
        True
        >>> is_read_only_tool("search_index")
        True
        >>> is_read_only_tool("find_user")
        True
        >>> is_read_only_tool("show_config")
        True
        >>> is_read_only_tool("status")
        True
        >>> is_read_only_tool("ctl_finish")
        True
        >>> is_read_only_tool("write_file")
        False
        >>> is_read_only_tool("write_file")
        False
        >>> is_read_only_tool("delete_record")
        False
        >>> is_read_only_tool("create_user")
        False
        >>> is_read_only_tool("update_config")
        False
        >>> is_read_only_tool("")  # doctest: +IGNORE_EXCEPTION_DETAIL
        Traceback (most recent call last):
        ...
        PreContractError: ...
    """
    # CTL tools are considered read-only (lifecycle/control tools)
    if name.startswith(CTL_TOOL_PREFIX):
        return True

    # Check read-only prefixes
    name_lower = name.lower()
    for prefix in READ_ONLY_PREFIXES:
        if name_lower.startswith(prefix):
            return True

    # Also check for exact "status" match (no underscore required)
    if name_lower == "status":
        return True

    return False


@pre(
    lambda name, annotations=None: (
        isinstance(name, str)
        and len(name) > 0
        and (annotations is None or isinstance(annotations, dict))
    )
)
def is_read_only_tool_with_annotations(
    name: str,
    annotations: dict[str, bool] | None = None,
) -> bool:
    """Determine read-only status using MCP annotations, then name heuristic.

    Precedence order:
    1. ``readOnlyHint=True`` => read-only.
    2. ``destructiveHint=True`` => not read-only.
    3. Fall back to ``is_read_only_tool`` name heuristic.

    Args:
        name: Tool name to check.
        annotations: Optional MCP tool annotations.

    Returns:
        True if tool should be treated as read-only, else False.

    Examples:
        >>> is_read_only_tool_with_annotations("write_file", {"readOnlyHint": True})
        True
        >>> is_read_only_tool_with_annotations("read_file", {"destructiveHint": True})
        False
        >>> is_read_only_tool_with_annotations("write_file", None)
        False
        >>> is_read_only_tool_with_annotations(
        ...     "write_file", {"readOnlyHint": True, "destructiveHint": True}
        ... )
        True
    """
    if annotations is not None and annotations.get("readOnlyHint") is True:
        return True

    if annotations is not None and annotations.get("destructiveHint") is True:
        return False

    return is_read_only_tool(name)


@pre(
    lambda tool_name, annotations=None: (
        isinstance(tool_name, str)
        and len(tool_name) > 0
        and (annotations is None or isinstance(annotations, dict))
    )
)
@post(lambda result: isinstance(result, ToolPosture))
def required_tool_posture(
    tool_name: str,
    annotations: dict[str, bool] | None = None,
) -> ToolPosture:
    """Return minimum posture required to run a specific tool.

    Mapping is conservative for unknown tools: unclassifiable tools require
    ``destructive`` posture.

    Examples:
        >>> required_tool_posture("read_file")
        <ToolPosture.read_only: 'read_only'>
        >>> required_tool_posture("write_file")
        <ToolPosture.read_write: 'read_write'>
        >>> required_tool_posture("write_file", {"destructiveHint": True})
        <ToolPosture.destructive: 'destructive'>
        >>> required_tool_posture("unknown_tool")
        <ToolPosture.destructive: 'destructive'>
    """
    classification = classify_tool(tool_name, annotations)
    if classification == ToolClassification.CLASSIFIED_READ_ONLY:
        return ToolPosture.read_only

    if annotations is not None and annotations.get("destructiveHint") is True:
        return ToolPosture.destructive

    if classification == ToolClassification.CLASSIFIED_MUTATING:
        return ToolPosture.read_write

    return ToolPosture.destructive


@pre(
    lambda merged_policy, tool_name, annotations=None: (
        isinstance(merged_policy, SideEffectPolicy)
        and isinstance(tool_name, str)
        and len(tool_name) > 0
        and (annotations is None or isinstance(annotations, dict))
    )
)
@post(lambda result: isinstance(result, ToolPolicyResolution))
def resolve_tool_policy(
    merged_policy: SideEffectPolicy,
    tool_name: str,
    annotations: dict[str, bool] | None = None,
) -> ToolPolicyResolution:
    """Resolve effective tool decision from merged side_effect_policy.

    Resolution source is always ``side_effect_policy`` in the core policy layer.

    Args:
        merged_policy: Effective merged side_effect_policy.
        tool_name: Tool name being evaluated.
        annotations: Optional MCP tool annotations used during classification.

    Returns:
        ToolPolicyResolution containing decision, source, and posture metadata.

    Examples:
        >>> from anima.core.schemas import SideEffectPolicy as SEP
        >>> resolve_tool_policy(SEP.read_only, "write_file")
        ToolPolicyResolution(decision=<ToolDecision.BLOCK: 'block'>, source='side_effect_policy', posture=None)
        >>> resolve_tool_policy(
        ...     SEP.read_only,
        ...     "write_file",
        ...     {"readOnlyHint": True},
        ... )
        ToolPolicyResolution(decision=<ToolDecision.ALLOW: 'allow'>, source='side_effect_policy', posture=None)
    """
    return ToolPolicyResolution(
        decision=check_tool_policy(merged_policy, tool_name, annotations),
        source="side_effect_policy",
        posture=None,
    )


@pre(
    lambda policy, tool_name, annotations=None: (
        isinstance(policy, SideEffectPolicy)
        and isinstance(tool_name, str)
        and len(tool_name) > 0
        and (annotations is None or isinstance(annotations, dict))
    )
)
def check_tool_policy(
    policy: SideEffectPolicy,
    tool_name: str,
    annotations: dict[str, bool] | None = None,
) -> ToolDecision:
    """Check if a tool call is permitted under the given policy.

    Uses explicit tool classification to determine policy decisions.
    UNCLASSIFIABLE tools are denied by default in read_only mode.

    Args:
        policy: The side effect policy to enforce.
        tool_name: Name of the tool being called.
        annotations: Optional MCP tool annotations.

    Returns:
        ALLOW: Tool can proceed.
        BLOCK: Tool should be rejected with error message.
        APPROVAL: Tool requires approval via ctl_request_approval.

    Examples:
        >>> from anima.core.schemas import SideEffectPolicy as SEP
        >>> check_tool_policy(SEP.allow, "write_file")
        <ToolDecision.ALLOW: 'allow'>
        >>> check_tool_policy(SEP.allow, "read_file")
        <ToolDecision.ALLOW: 'allow'>
        >>> check_tool_policy(SEP.read_only, "read_file")
        <ToolDecision.ALLOW: 'allow'>
        >>> check_tool_policy(SEP.read_only, "write_file")
        <ToolDecision.BLOCK: 'block'>
        >>> check_tool_policy(SEP.read_only, "write_file")
        <ToolDecision.BLOCK: 'block'>
        >>> check_tool_policy(SEP.read_only, "ctl_finish")
        <ToolDecision.ALLOW: 'allow'>
        >>> check_tool_policy(SEP.read_only, "unknown_tool")
        <ToolDecision.BLOCK: 'block'>
        >>> check_tool_policy(SEP.approval_required, "read_file")
        <ToolDecision.ALLOW: 'allow'>
        >>> check_tool_policy(SEP.approval_required, "write_file")
        <ToolDecision.APPROVAL: 'approval'>
        >>> check_tool_policy(SEP.approval_required, "delete_record")
        <ToolDecision.APPROVAL: 'approval'>
        >>> check_tool_policy(SEP.approval_required, "ctl_finish")
        <ToolDecision.ALLOW: 'allow'>
    """
    # Allow policy: all tools permitted
    if policy == SideEffectPolicy.allow:
        return ToolDecision.ALLOW

    # Classify the tool explicitly
    classification = classify_tool(tool_name, annotations)

    # Read-only policy: block mutating and unclassifiable tools
    if policy == SideEffectPolicy.read_only:
        if classification == ToolClassification.CLASSIFIED_READ_ONLY:
            return ToolDecision.ALLOW
        # Default-reject: block both mutating and unclassifiable
        return ToolDecision.BLOCK

    # Approval_required policy: require approval for mutating and unclassifiable
    if policy == SideEffectPolicy.approval_required:
        if classification == ToolClassification.CLASSIFIED_READ_ONLY:
            return ToolDecision.ALLOW
        # Default-reject: require approval for both mutating and unclassifiable
        return ToolDecision.APPROVAL

    # This should never be reached (exhaustive switch)
    raise ValueError(f"Unknown policy: {policy}")


__all__ = [
    "ToolClassification",
    "ToolDecision",
    "ToolPolicyResolution",
    "READ_ONLY_PREFIXES",
    "CTL_TOOL_PREFIX",
    "most_restrictive",
    "classify_tool",
    "is_read_only_tool",
    "is_read_only_tool_with_annotations",
    "required_tool_posture",
    "resolve_tool_policy",
    "check_tool_policy",
]
