"""Error hierarchy and envelope helpers for anima schemas.

Source: vectl step `medium-fixes.split-schemas-errors` requires extracting
error definitions from `schemas.__init__` into this dedicated module while
preserving contracts and doctests.
"""

# @invar:allow file_size: Consolidated error hierarchy with convenience constructors
# @invar:allow file_size_warning: Error hierarchy intentionally centralized for schema parity

from typing import ClassVar, TypedDict

import deal


class AnimaError(Exception):
    """Base exception for all anima errors.

    All anima errors have a numeric code matching contracts/errors.yaml.
    Code must be >= 1 (0 is reserved for success/OK).

    >>> e = AnimaError(code=1, message="Invalid input")
    >>> e.code
    1
    >>> e.message
    'Invalid input'
    >>> e.details is None
    True
    >>> e = AnimaError(code=5, message="Failed", details={"key": "value"})
    >>> e.details
    {'key': 'value'}
    >>> str(e)
    '[5] Failed'
    >>> AnimaError(code=0, message="invalid")  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    """

    code: int
    message: str
    details: dict | None

    @deal.pre(
        lambda self, code, message, details=None: (
            code >= 1
            and len(message) > 0
            and (details is None or isinstance(details, dict))
        )
    )
    def __init__(self, code: int, message: str, details: dict | None = None) -> None:
        self.code = code
        self.message = message
        self.details = details
        super().__init__(f"[{code}] {message}")

    @deal.post(lambda result: len(result) > 0)
    def __str__(self) -> str:
        """Format error as [code] message."""
        return f"[{self.code}] {self.message}"


class InvalidInputError(AnimaError):
    """Malformed input or missing required field.

    Error code 1 per contracts/errors.yaml.

    >>> e = InvalidInputError("Missing field: name")
    >>> e.code
    1
    >>> e.message
    'Missing field: name'
    """

    CODE: ClassVar[int] = 1

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class NotFoundError(AnimaError):
    """Requested resource not found.

    Error code 2 per contracts/errors.yaml.

    >>> e = NotFoundError("Agent not found: ag_123")
    >>> e.code
    2
    """

    CODE: ClassVar[int] = 2

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class AnimaTimeoutError(AnimaError):
    """Operation timed out.

    Error code 3 per contracts/errors.yaml.

    >>> e = AnimaTimeoutError("Agent timed out after 30s")
    >>> e.code
    3
    """

    CODE: ClassVar[int] = 3

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class InternalError(AnimaError):
    """Unexpected internal error.

    Error code 10 per contracts/errors.yaml.

    >>> e = InternalError("Unexpected state")
    >>> e.code
    10
    """

    CODE: ClassVar[int] = 10

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class AgentNotFoundError(AnimaError):
    """Agent ID not found.

    Error code 300 per contracts/errors.yaml (anima range: 300-399).

    >>> e = AgentNotFoundError("Agent ag_123 not found")
    >>> e.code
    300
    """

    CODE: ClassVar[int] = 300

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class AgentAlreadyExistsError(AnimaError):
    """Agent with this instance_id already running.

    Error code 301 per contracts/errors.yaml.

    >>> e = AgentAlreadyExistsError("Agent already running: inst_abc")
    >>> e.code
    301
    """

    CODE: ClassVar[int] = 301

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class BudgetExhaustedError(AnimaError):
    """Token budget exceeded.

    Error code 310 per contracts/errors.yaml.

    >>> e = BudgetExhaustedError("Budget exhausted: used 10000 of 10000")
    >>> e.code
    310
    >>> str(e)
    '[310] Budget exhausted: used 10000 of 10000'
    """

    CODE: ClassVar[int] = 310

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class MaxTurnsReachedError(AnimaError):
    """Max turns safety valve triggered.

    Error code 311 per contracts/errors.yaml.

    >>> e = MaxTurnsReachedError("Max turns reached: 100")
    >>> e.code
    311
    """

    CODE: ClassVar[int] = 311

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class MaxDepthExceededError(AnimaError):
    """Sub-agent nesting depth exceeded.

    Error code 312 per contracts/errors.yaml.

    >>> e = MaxDepthExceededError("Max depth exceeded: 5 > 3")
    >>> e.code
    312
    """

    CODE: ClassVar[int] = 312

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class AgentTerminatedError(AnimaError):
    """Agent was killed or interrupted.

    Error code 313 per contracts/errors.yaml.

    >>> e = AgentTerminatedError("Agent ag_123 was killed")
    >>> e.code
    313
    """

    CODE: ClassVar[int] = 313

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class UnsupportedModelError(AnimaError):
    """Model not available via LLM gateway.

    Error code 320 per contracts/errors.yaml.

    >>> e = UnsupportedModelError("Model 'gpt-5.2' not available")
    >>> e.code
    320
    """

    CODE: ClassVar[int] = 320

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class AgentNotInteractiveError(AnimaError):
    """Agent not in interactive state.

    Error code 314 per contracts/errors.yaml.
    Agent must be in running, suspended, or awaiting_approval state
    for interactive commands like `anima talk`.

    >>> e = AgentNotInteractiveError("Agent is in failed state")
    >>> e.code
    314
    """

    CODE: ClassVar[int] = 314

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class AgentNotTerminalError(AnimaError):
    """Agent not in terminal state.

    Error code 315 per contracts/errors.yaml.
    Agent must be in completed, failed, or killed state for
    collect operation without --wait.

    >>> e = AgentNotTerminalError("Agent is in running state")
    >>> e.code
    315
    """

    CODE: ClassVar[int] = 315

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class MCPSocketNotFoundError(AnimaError):
    """Agent MCP socket not found.

    Error code 321 per contracts/errors.yaml.
    The agent socket file doesn't exist, typically meaning the agent
    is not running or has crashed.

    >>> e = MCPSocketNotFoundError("MCP socket not found: /path/to/sock")
    >>> e.code
    321
    """

    CODE: ClassVar[int] = 321

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class MCPConnectionRefusedError(AnimaError):
    """Agent MCP connection refused.

    Error code 322 per contracts/errors.yaml.
    The agent socket exists but connection was refused, typically
    meaning the agent is overloaded or in the process of terminating.

    >>> e = MCPConnectionRefusedError("Connection refused: /path/to/sock")
    >>> e.code
    322
    """

    CODE: ClassVar[int] = 322

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class MCPConnectionError(AnimaError):
    """MCP connection error.

    Error code 323 per contracts/errors.yaml.
    Generic transport-layer failure when connecting to agent MCP socket.

    >>> e = MCPConnectionError("Connection failed: timeout")
    >>> e.code
    323
    """

    CODE: ClassVar[int] = 323

    @deal.pre(
        lambda self, message, details=None: (
            len(message) > 0 and (details is None or isinstance(details, dict))
        )
    )
    @deal.post(lambda result: result is None)
    def __init__(self, message: str, details: dict | None = None) -> None:
        super().__init__(self.CODE, message, details)


class ToolBlockedError(AnimaError):
    """Tool call blocked by sandbox policy.

    Error code 330 per contracts/errors.yaml.
    Agent attempted to call a non-read-only tool in read_only mode.

    >>> e = ToolBlockedError("write_file", "read_only")
    >>> e.code
    330
    >>> e.message
    'Tool write_file blocked by read_only policy'
    >>> e.details
    {'tool_name': 'write_file', 'policy': 'read_only'}
    """

    CODE: ClassVar[int] = 330

    @deal.pre(lambda self, tool_name, policy: len(tool_name) > 0 and len(policy) > 0)
    @deal.post(lambda result: result is None)
    def __init__(self, tool_name: str, policy: str) -> None:
        """Create blocked tool error.

        Args:
            tool_name: Name of the blocked tool.
            policy: Policy that blocked the tool (e.g., "read_only").
        """
        super().__init__(
            self.CODE,
            f"Tool {tool_name} blocked by {policy} policy",
            details={"tool_name": tool_name, "policy": policy},
        )


class ApprovalRequiredError(AnimaError):
    """Tool call requires approval but approval not yet requested.

    Error code 331 per contracts/errors.yaml.
    Agent must call ctl_request_approval before executing this tool.

    >>> e = ApprovalRequiredError("delete_record")
    >>> e.code
    331
    >>> e.message
    'Tool delete_record requires approval'
    >>> e.details
    {'tool_name': 'delete_record'}
    """

    CODE: ClassVar[int] = 331

    @deal.pre(lambda self, tool_name: len(tool_name) > 0)
    @deal.post(lambda result: result is None)
    def __init__(self, tool_name: str) -> None:
        """Create approval required error.

        Args:
            tool_name: Name of the tool requiring approval.
        """
        super().__init__(
            self.CODE,
            f"Tool {tool_name} requires approval",
            details={"tool_name": tool_name},
        )


# Registry of all error codes to their exception classes
ERROR_CODES: dict[int, type[AnimaError]] = {
    InvalidInputError.CODE: InvalidInputError,
    NotFoundError.CODE: NotFoundError,
    AnimaTimeoutError.CODE: AnimaTimeoutError,
    InternalError.CODE: InternalError,
    AgentNotFoundError.CODE: AgentNotFoundError,
    AgentAlreadyExistsError.CODE: AgentAlreadyExistsError,
    BudgetExhaustedError.CODE: BudgetExhaustedError,
    MaxTurnsReachedError.CODE: MaxTurnsReachedError,
    MaxDepthExceededError.CODE: MaxDepthExceededError,
    AgentTerminatedError.CODE: AgentTerminatedError,
    UnsupportedModelError.CODE: UnsupportedModelError,
    AgentNotInteractiveError.CODE: AgentNotInteractiveError,
    AgentNotTerminalError.CODE: AgentNotTerminalError,
    MCPSocketNotFoundError.CODE: MCPSocketNotFoundError,
    MCPConnectionRefusedError.CODE: MCPConnectionRefusedError,
    MCPConnectionError.CODE: MCPConnectionError,
    ToolBlockedError.CODE: ToolBlockedError,
    ApprovalRequiredError.CODE: ApprovalRequiredError,
}


# Reverse mapping: numeric code to string code (for CLI JSON output)
_ERROR_CODE_NAMES: dict[int, str] = {
    1: "INVALID_INPUT",
    2: "NOT_FOUND",
    3: "TIMEOUT",
    10: "INTERNAL",
    300: "AGENT_NOT_FOUND",
    301: "AGENT_ALREADY_EXISTS",
    310: "BUDGET_EXHAUSTED",
    311: "MAX_TURNS_REACHED",
    312: "MAX_DEPTH_EXCEEDED",
    313: "AGENT_TERMINATED",
    314: "AGENT_NOT_INTERACTIVE",
    315: "AGENT_NOT_TERMINAL",
    320: "UNSUPPORTED_MODEL",
    321: "MCP_SOCKET_NOT_FOUND",
    322: "MCP_CONNECTION_REFUSED",
    323: "MCP_CONNECTION_ERROR",
    330: "TOOL_BLOCKED",
    331: "APPROVAL_REQUIRED",
}


class ErrorPayload(TypedDict):
    """JSON error payload shape per docs/INTERFACES.md Section G.

    This typed shape prevents ambiguous error field typing at call sites
    that need to serialize `AnimaError` into transport envelopes.
    """

    code: str
    numeric_code: int
    message: str
    details: dict | None


class ErrorEnvelope(TypedDict):
    """Top-level error envelope containing a structured error payload."""

    error: ErrorPayload


@deal.pre(lambda code: code >= 1)
@deal.post(lambda result: len(result) > 0)
def numeric_code_to_string(code: int) -> str:
    """Convert numeric error code to string code.

    Maps numeric codes from contracts/errors.yaml to their string names.
    Returns ERROR_{code} for unknown codes.

    >>> numeric_code_to_string(300)
    'AGENT_NOT_FOUND'
    >>> numeric_code_to_string(1)
    'INVALID_INPUT'
    >>> numeric_code_to_string(10)
    'INTERNAL'
    >>> numeric_code_to_string(999)
    'ERROR_999'
    """
    return _ERROR_CODE_NAMES.get(code, f"ERROR_{code}")


@deal.pre(lambda error: error.code >= 1)
@deal.post(lambda result: result["error"]["numeric_code"] >= 1)
def build_error_envelope(error: AnimaError) -> ErrorEnvelope:
    """Convert `AnimaError` to unambiguous JSON envelope.

    Source: coverage-tech-debt.fix-anima-error-class-shadow requires
    transport errors to be unambiguous in mcp_serve/schemas.

    >>> envelope = build_error_envelope(AgentNotFoundError("Agent missing"))
    >>> envelope["error"]["code"]
    'AGENT_NOT_FOUND'
    >>> envelope["error"]["numeric_code"]
    300
    >>> envelope["error"]["message"]
    'Agent missing'
    """
    return {
        "error": {
            "code": numeric_code_to_string(error.code),
            "numeric_code": error.code,
            "message": error.message,
            "details": error.details,
        }
    }
