"""Centralized error handling utilities for the tu CLI.

This module provides:
- CLIError: A structured exception class for all CLI errors
- Error code constants and exit code mappings
- Human-friendly and JSON error formatting

Architecture follows oclif patterns: commands raise CLIError, global handler formats.
See: https://oclif.io/docs/error_handling/
"""

from __future__ import annotations

import json
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any

from terminaluse.core.api_error import ApiError

# =============================================================================
# Error Codes - Machine-readable identifiers for error categories
# =============================================================================


class ErrorCode:
    """Machine-readable error codes for programmatic error handling."""

    # Client errors (4xx)
    VALIDATION_ERROR = "VALIDATION_ERROR"  # 400 - Bad request, invalid params
    AUTH_REQUIRED = "AUTH_REQUIRED"  # 401 - Not authenticated
    ACCESS_DENIED = "ACCESS_DENIED"  # 403 - Permission denied
    NOT_FOUND = "NOT_FOUND"  # 404 - Resource doesn't exist
    RESOURCE_EXISTS = "RESOURCE_EXISTS"  # 409 - Conflict, already exists
    INVALID_INPUT = "INVALID_INPUT"  # 422 - Validation failed

    # Server errors (5xx)
    SERVER_ERROR = "SERVER_ERROR"  # 500+ - Server-side failure

    # CLI errors
    INTERNAL_ERROR = "INTERNAL_ERROR"  # Unexpected exception in CLI
    UNKNOWN_ERROR = "UNKNOWN_ERROR"  # Catch-all


# =============================================================================
# Exit Codes - For programmatic handling by scripts and agents
# =============================================================================


class ExitCode:
    """Exit codes for different error categories.

    Allows agents and scripts to handle errors programmatically:
        if exit_code == ExitCode.AUTH_ERROR: run_login()
        elif exit_code == ExitCode.NOT_FOUND: print("Resource not found")
    """

    SUCCESS = 0
    USER_ERROR = 1  # Bad input, validation failed, resource exists
    # Note: Exit code 2 is reserved for shell/Typer usage errors
    AUTH_ERROR = 3  # Not logged in, session expired
    NOT_FOUND = 4  # Resource doesn't exist
    PERMISSION_DENIED = 5  # Access denied
    SERVER_ERROR = 6  # 5xx errors
    INTERNAL_ERROR = 10  # Unexpected CLI exception


# Status code to exit code mapping
# Note: Exit code 2 is reserved for shell/Typer usage errors
_STATUS_TO_EXIT: dict[int, int] = {
    400: ExitCode.USER_ERROR,  # 1
    401: ExitCode.AUTH_ERROR,  # 3
    403: ExitCode.PERMISSION_DENIED,  # 5
    404: ExitCode.NOT_FOUND,  # 4
    409: ExitCode.USER_ERROR,  # 1
    422: ExitCode.USER_ERROR,  # 1
    500: ExitCode.SERVER_ERROR,  # 6
    502: ExitCode.SERVER_ERROR,  # 6
    503: ExitCode.SERVER_ERROR,  # 6
    504: ExitCode.SERVER_ERROR,  # 6
}

# Status code to error code mapping
_STATUS_TO_CODE: dict[int, str] = {
    400: ErrorCode.VALIDATION_ERROR,
    401: ErrorCode.AUTH_REQUIRED,
    403: ErrorCode.ACCESS_DENIED,
    404: ErrorCode.NOT_FOUND,
    409: ErrorCode.RESOURCE_EXISTS,
    422: ErrorCode.INVALID_INPUT,
    500: ErrorCode.SERVER_ERROR,
    502: ErrorCode.SERVER_ERROR,
    503: ErrorCode.SERVER_ERROR,
    504: ErrorCode.SERVER_ERROR,
}


# =============================================================================
# Hint Registry - Actionable suggestions for common errors
# =============================================================================

# Format: (status_code, message_pattern, resource_type) -> hint
# None means "match any"
_HINT_REGISTRY: list[tuple[int | None, str | None, str | None, str]] = [
    # Auth errors
    (401, None, None, "Run `tu login` to authenticate."),
    (403, None, None, "You don't have permission for this resource. Check with your team admin."),
    # Not found errors - resource-specific hints
    (404, None, "agent", "Use `tu agents ls` to see available agents."),
    (404, None, "filesystem", "Use `tu fs ls` to see available filesystems."),
    (404, None, "project", "Use `tu projects ls` to see available projects."),
    (404, None, "task", "Use `tu tasks ls` to see available tasks."),
    (404, None, None, "The requested resource was not found."),
    # Conflict errors
    (409, "already exists", None, "Use a different name, or delete the existing resource first."),
    # Validation errors
    (422, None, None, "Check your input format and try again."),
    # Server errors
    (500, None, None, "This is a server error. Please try again in a few moments."),
    (502, None, None, "Service temporarily unavailable. Please try again in a few moments."),
    (503, None, None, "Service temporarily unavailable. Please try again in a few moments."),
    (504, None, None, "Request timed out. Please try again."),
]


def _get_hint(status: int | None, message: str, resource_type: str | None = None) -> str | None:
    """Get an actionable hint for an error.

    Args:
        status: HTTP status code (or None for non-API errors)
        message: Error message (used for pattern matching)
        resource_type: Type of resource involved (agent, filesystem, etc.)

    Returns:
        Hint string or None if no hint applies
    """
    message_lower = message.lower()

    for hint_status, hint_pattern, hint_resource, hint_text in _HINT_REGISTRY:
        # Check status match
        if hint_status is not None and hint_status != status:
            continue

        # Check message pattern match
        if hint_pattern is not None and hint_pattern.lower() not in message_lower:
            continue

        # Check resource type match
        if hint_resource is not None and hint_resource != resource_type:
            continue

        return hint_text

    return None


# =============================================================================
# CLIError - The main error class
# =============================================================================


@dataclass
class CLIError(Exception):
    """Structured exception for CLI errors.

    All CLI errors should be raised as CLIError instances. The global error
    handler in main.py will format them appropriately for human or JSON output.

    Attributes:
        message: Human-readable error message
        code: Machine-readable error code (e.g., "NOT_FOUND", "AUTH_REQUIRED")
        exit_code: Process exit code for scripts/agents
        hint: Optional actionable suggestion
        status: HTTP status code (if from API error)
        request_id: Request ID for support (from API error headers)
        debug_info: Extra context shown only in debug mode
    """

    message: str
    code: str = ErrorCode.UNKNOWN_ERROR
    exit_code: int = ExitCode.USER_ERROR
    hint: str | None = None
    status: int | None = None
    request_id: str | None = None
    trace_id: str | None = None
    debug_info: dict[str, Any] = field(default_factory=dict)

    def __str__(self) -> str:
        return self.message

    @classmethod
    def from_api_error(
        cls,
        error: ApiError,
        resource_type: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> CLIError:
        """Create a CLIError from an API error.

        Extracts the human-readable message from the API response body,
        maps status codes to error codes and exit codes, and generates
        appropriate hints.

        Args:
            error: The ApiError from the SDK
            resource_type: Type of resource (agent, filesystem, etc.) for hints
            context: Additional context to include in debug_info

        Returns:
            CLIError with properly extracted and formatted information
        """
        # Extract message from API response
        message = _extract_error_message(error)

        # Map status to codes
        status = error.status_code or 500

        # Handle 5xx server errors with range check (not all 5xx codes are in the mapping)
        if status >= 500:
            code = ErrorCode.SERVER_ERROR
            exit_code = ExitCode.SERVER_ERROR
        else:
            code = _STATUS_TO_CODE.get(status, ErrorCode.UNKNOWN_ERROR)
            exit_code = _STATUS_TO_EXIT.get(status, ExitCode.USER_ERROR)

        # Get hint
        hint = _get_hint(status, message, resource_type)

        # Extract request ID and trace ID from headers for support reference
        request_id = None
        trace_id = None
        if error.headers:
            request_id = error.headers.get("x-request-id")
            trace_id = error.headers.get("x-trace-id")

        # Build debug info
        debug_info: dict[str, Any] = {
            "status": status,
        }
        if request_id:
            debug_info["request_id"] = request_id
        if context:
            debug_info.update(context)

        return cls(
            message=message,
            code=code,
            exit_code=exit_code,
            hint=hint,
            status=status,
            request_id=request_id,
            trace_id=trace_id,
            debug_info=debug_info,
        )

    @classmethod
    def from_unexpected(cls, error: Exception) -> CLIError:
        """Create a CLIError from an unexpected exception.

        Used for catching unhandled exceptions and presenting them cleanly.
        Uses Python's built-in traceback formatting for the message.

        Args:
            error: Any exception that wasn't expected

        Returns:
            CLIError with exception details and full traceback in debug_info
        """
        # Use Python's built-in formatting: "ExceptionType: message"
        formatted = traceback.format_exception_only(type(error), error)
        message = formatted[-1].strip() if formatted else f"{type(error).__name__}: {error}"

        return cls(
            message=message,
            code=ErrorCode.INTERNAL_ERROR,
            exit_code=ExitCode.INTERNAL_ERROR,
            hint="If this persists, please email founders@terminaluse.com",
            debug_info={
                "exception_type": type(error).__name__,
                "exception_message": str(error).strip() or "(no message)",
                "traceback": traceback.format_exc(),
            },
        )

    @classmethod
    def auth_expired(cls) -> CLIError:
        """Create a CLIError for expired session."""
        return cls(
            message="Session expired.",
            code=ErrorCode.AUTH_REQUIRED,
            exit_code=ExitCode.AUTH_ERROR,
            hint="Run `tu login` to re-authenticate.",
        )

    @classmethod
    def not_authenticated(cls) -> CLIError:
        """Create a CLIError for missing authentication."""
        return cls(
            message="Not authenticated.",
            code=ErrorCode.AUTH_REQUIRED,
            exit_code=ExitCode.AUTH_ERROR,
            hint="Run `tu login` first.",
        )

    @classmethod
    def with_partial_success(
        cls,
        error: ApiError,
        resource_type: str,
        resource_id: str,
        retry_command: str,
        phase: str,
    ) -> CLIError:
        """Create a CLIError for partial success scenarios.

        Use when a resource was created successfully but a subsequent operation
        failed. The error will include a hint with the created resource ID and
        a command to retry the failed operation.

        Args:
            error: The ApiError from the failed operation
            resource_type: Type of resource that was created (task, filesystem, etc.)
            resource_id: ID of the successfully created resource
            retry_command: Full command to retry the failed operation
            phase: Operation phase that failed (for debug info)

        Returns:
            CLIError with hint about the created resource and retry command
        """
        cli_error = cls.from_api_error(error, resource_type=resource_type)
        cli_error.hint = f"{resource_type.capitalize()} was created (ID: {resource_id}). Retry: {retry_command}"
        cli_error.debug_info["resource_id"] = resource_id
        cli_error.debug_info["phase"] = phase
        return cli_error


# =============================================================================
# Error Message Extraction
# =============================================================================


def _extract_error_message(error: ApiError) -> str:
    """Extract human-readable error message from API error response.

    Handles various API response formats:
    - {"message": "..."} - Standard format
    - {"detail": "..."} - FastAPI format
    - {"detail": [...]} - Pydantic validation errors

    Args:
        error: The ApiError from the SDK

    Returns:
        Human-readable error message
    """
    try:
        if hasattr(error, "body") and error.body:
            body = error.body

            if isinstance(body, dict):
                # Standard message format
                if "message" in body:
                    return str(body["message"])

                # FastAPI/Pydantic format
                if "detail" in body:
                    detail = body["detail"]

                    # Pydantic validation errors (list of error dicts)
                    if isinstance(detail, list):
                        messages = []
                        for err in detail:
                            if isinstance(err, dict):
                                loc = ".".join(str(x) for x in err.get("loc", []))
                                msg = err.get("msg", "")
                                messages.append(f"{loc}: {msg}" if loc else msg)
                            else:
                                messages.append(str(err))
                        return "; ".join(messages) if messages else "Validation failed"

                    # Simple detail string
                    return str(detail)

            # Body is not a dict - return as string
            if isinstance(body, str):
                return body

            return str(body)

    except Exception:
        pass

    # Fallback based on status code
    status = error.status_code
    if status == 401:
        return "Authentication required"
    elif status == 403:
        return "Access denied"
    elif status == 404:
        return "Resource not found"
    elif status == 409:
        return "Resource conflict"
    elif status == 422:
        return "Validation error"
    elif status and status >= 500:
        return f"Server error (HTTP {status})"

    # Last resort: include status code if available for debugging
    if status:
        return f"Request failed (HTTP {status})"
    return "Request failed"


# =============================================================================
# Error Formatting
# =============================================================================


def format_error_human(
    error: CLIError,
    version: str,
    debug: bool = False,
) -> str:
    """Format error for human-readable terminal output.

    Output format:
        tu v0.1.0

        Error: <message>

        [Debug info (if debug=True)]

        Hint: <hint>

    Args:
        error: The CLIError to format
        version: CLI version string
        debug: Whether to show debug information

    Returns:
        Formatted error string (without Rich markup - caller handles styling)
    """
    lines = []

    # Version first (will be dimmed by caller)
    lines.append(f"tu v{version}")
    lines.append("")

    # Error message
    lines.append(f"Error: {error.message}")

    # Debug info
    if debug and error.debug_info:
        lines.append("")
        lines.append("Debug info:")
        for key, value in error.debug_info.items():
            if key == "traceback":
                lines.append(f"  {key}:")
                # Indent traceback
                for tb_line in str(value).strip().split("\n"):
                    lines.append(f"    {tb_line}")
            else:
                lines.append(f"  {key}: {value}")

    # Hint
    if error.hint:
        lines.append("")
        lines.append(f"Hint: {error.hint}")

    return "\n".join(lines)


def _is_retryable(error: CLIError) -> bool:
    """Determine if an error is retryable (transient)."""
    # Keep in sync with http_client._should_retry()
    retryable_status_codes = {408, 429, 500, 502, 503, 504}
    if error.status is not None and error.status in retryable_status_codes:
        return True
    if error.code == ErrorCode.SERVER_ERROR:
        return True
    return False


def format_error_json(error: CLIError, version: str) -> str:
    """Format error as JSON for machine parsing.

    Output format:
        {
            "success": false,
            "error": {
                "code": "NOT_FOUND",
                "message": "Agent not found",
                "exit_code": 4,
                "status_code": 404,
                "hint": "Use `tu agents ls` to see available agents.",
                "request_id": null,
                "retryable": false,
                "timestamp": "2025-01-29T14:00:00Z"
            },
            "version": "0.1.0"
        }

    All fields are always present (nullable fields use null) for predictable parsing.

    Args:
        error: The CLIError to format
        version: CLI version string

    Returns:
        JSON string
    """
    # Always include all fields with explicit nulls for predictable schema
    error_dict: dict[str, Any] = {
        "code": error.code,
        "message": error.message,
        "exit_code": error.exit_code,
        "status_code": error.status,  # None if not from API
        "hint": error.hint,  # None if no hint
        "request_id": error.request_id,  # None if not from API
        "trace_id": error.trace_id,  # None if not from API
        "retryable": _is_retryable(error),
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }

    output: dict[str, Any] = {
        "success": False,
        "error": error_dict,
        "version": version,
    }

    return json.dumps(output, indent=2)


def print_error(
    error: CLIError,
    version: str,
    debug: bool = False,
    json_mode: bool = False,
) -> None:
    """Print error to appropriate stream with formatting.

    Human errors go to stderr (so piping works).
    JSON errors go to stdout (standard for machine-readable output).

    Note: Callers should check exit code to distinguish success from error
    when parsing JSON output, as both success and error JSON go to stdout.

    Args:
        error: The CLIError to print
        version: CLI version string
        debug: Whether to show debug information
        json_mode: Whether to output JSON format
    """
    if json_mode:
        # JSON goes to stdout for machine parsing
        print(format_error_json(error, version))
    else:
        # Human output goes to stderr with Rich formatting
        from rich.console import Console

        console = Console(stderr=True)

        # Version (dimmed)
        console.print(f"[dim]tu v{version}[/dim]\n")

        # Error message
        console.print(f"[red]Error:[/red] {error.message}")

        # Debug info
        if debug and error.debug_info:
            console.print("\n[dim]Debug info:[/dim]")
            for key, value in error.debug_info.items():
                if key == "traceback":
                    console.print(f"[dim]  {key}:[/dim]")
                    for tb_line in str(value).strip().split("\n"):
                        console.print(f"[dim]    {tb_line}[/dim]")
                else:
                    console.print(f"[dim]  {key}: {value}[/dim]")

        # Hint
        if error.hint:
            console.print(f"\n[dim]Hint:[/dim] {error.hint}")

        # Show trace ID for all API errors (enables support lookup in Grafana)
        if error.trace_id:
            console.print(f"\n[dim]Trace ID:[/dim] {error.trace_id}")

        # Suggest --debug if there's more info available
        if not debug and error.debug_info:
            console.print("\n[dim]Run with[/dim] tu --debug [dim]for more details[/dim]")

        # For server errors, show contact info
        if error.exit_code == ExitCode.SERVER_ERROR:
            console.print()
            console.print("[dim]If this persists, email founders@terminaluse.com[/dim]")
