"""
Codex CLI provider implementation.

Wraps the `codex exec` command to satisfy the ProviderContext contract,
including availability checks, request validation, JSONL parsing, and
token usage normalization. Enforces read-only restrictions via native
OS-level sandboxing (--sandbox read-only flag).
"""

from __future__ import annotations

import json
import logging
import os
import subprocess
from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple

from .base import (
    ProviderCapability,
    ProviderContext,
    ProviderExecutionError,
    ProviderHooks,
    ProviderMetadata,
    ProviderRequest,
    ProviderResult,
    ProviderStatus,
    ProviderTimeoutError,
    ProviderUnavailableError,
    StreamChunk,
    TokenUsage,
)
from .detectors import detect_provider_availability
from .registry import register_provider

logger = logging.getLogger(__name__)

DEFAULT_BINARY = "codex"
DEFAULT_TIMEOUT_SECONDS = 360
AVAILABILITY_OVERRIDE_ENV = "CODEX_CLI_AVAILABLE_OVERRIDE"
CUSTOM_BINARY_ENV = "CODEX_CLI_BINARY"

# Read-only operations allowed by Codex --sandbox read-only mode
# Note: These are enforced natively by OS-level sandboxing, not by this wrapper
# macOS: Seatbelt | Linux: Landlock + seccomp | Windows: Restricted Token
SANDBOX_ALLOWED_OPERATIONS = [
    # File operations (read-only)
    "Read",
    "Grep",
    "Glob",
    "List",
    # Task delegation
    "Task",
    # Shell commands - file viewing
    "Shell(cat)",
    "Shell(head)",
    "Shell(tail)",
    "Shell(bat)",
    "Shell(less)",
    "Shell(more)",
    # Shell commands - directory listing/navigation
    "Shell(ls)",
    "Shell(tree)",
    "Shell(pwd)",
    "Shell(which)",
    "Shell(whereis)",
    # Shell commands - search/find
    "Shell(grep)",
    "Shell(rg)",
    "Shell(ag)",
    "Shell(find)",
    "Shell(fd)",
    "Shell(locate)",
    # Shell commands - git operations (read-only)
    "Shell(git log)",
    "Shell(git show)",
    "Shell(git diff)",
    "Shell(git status)",
    "Shell(git grep)",
    "Shell(git blame)",
    "Shell(git branch)",
    "Shell(git rev-parse)",
    "Shell(git describe)",
    "Shell(git ls-tree)",
    "Shell(git ls-files)",
    # Shell commands - text processing
    "Shell(wc)",
    "Shell(cut)",
    "Shell(paste)",
    "Shell(column)",
    "Shell(sort)",
    "Shell(uniq)",
    "Shell(diff)",
    # Shell commands - data formats
    "Shell(jq)",
    "Shell(yq)",
    "Shell(xmllint)",
    # Shell commands - file analysis
    "Shell(file)",
    "Shell(stat)",
    "Shell(du)",
    "Shell(df)",
    "Shell(lsof)",
    # Shell commands - checksums/hashing
    "Shell(md5sum)",
    "Shell(shasum)",
    "Shell(sha256sum)",
    "Shell(sha512sum)",
    "Shell(cksum)",
    # Shell commands - process inspection
    "Shell(ps)",
    "Shell(top)",
    "Shell(htop)",
    # Shell commands - system information
    "Shell(uname)",
    "Shell(hostname)",
    "Shell(whoami)",
    "Shell(id)",
    "Shell(date)",
    "Shell(uptime)",
]

# Operations blocked by Codex --sandbox read-only mode
SANDBOX_BLOCKED_OPERATIONS = [
    "Write",
    "Edit",
    "Patch",
    "Delete",
    # Web operations (data exfiltration risk)
    "WebFetch",
    # Dangerous file operations
    "Shell(rm)",
    "Shell(rmdir)",
    "Shell(dd)",
    "Shell(mkfs)",
    "Shell(fdisk)",
    "Shell(shred)",
    # File modifications
    "Shell(touch)",
    "Shell(mkdir)",
    "Shell(mv)",
    "Shell(cp)",
    "Shell(chmod)",
    "Shell(chown)",
    "Shell(chgrp)",
    "Shell(sed)",
    "Shell(awk)",
    "Shell(tee)",
    # Git write operations
    "Shell(git add)",
    "Shell(git commit)",
    "Shell(git push)",
    "Shell(git pull)",
    "Shell(git merge)",
    "Shell(git rebase)",
    "Shell(git reset)",
    "Shell(git checkout)",
    "Shell(git stash)",
    "Shell(git cherry-pick)",
    # Package installations
    "Shell(npm install)",
    "Shell(pip install)",
    "Shell(apt install)",
    "Shell(apt-get install)",
    "Shell(brew install)",
    "Shell(yum install)",
    "Shell(dnf install)",
    "Shell(cargo install)",
    # System operations
    "Shell(sudo)",
    "Shell(su)",
    "Shell(halt)",
    "Shell(reboot)",
    "Shell(shutdown)",
    "Shell(systemctl)",
    "Shell(service)",
    # Network write operations
    "Shell(curl -X POST)",
    "Shell(curl -X PUT)",
    "Shell(curl -X DELETE)",
    "Shell(wget)",
    "Shell(scp)",
    "Shell(rsync)",
]

# System prompt warning about Codex sandbox restrictions
SANDBOX_WARNING = """
IMPORTANT SECURITY NOTE: This session runs with Codex CLI's native --sandbox read-only mode:
1. Native OS-level sandboxing enforced by the operating system:
   - macOS: Seatbelt sandbox policy
   - Linux: Landlock LSM + seccomp filters
   - Windows: Restricted token + job objects
2. Only read operations are permitted - writes are blocked at the OS level
3. Shell commands are restricted to read-only operations by the sandbox
4. The sandbox is enforced by the Codex CLI itself, not just tool filtering
5. This is the most robust security model - cannot be bypassed by piped commands or escapes
6. Attempts to write files or modify system state will be blocked by the OS
"""


class RunnerProtocol(Protocol):
    """Callable signature used for executing Codex CLI commands."""

    def __call__(
        self,
        command: Sequence[str],
        *,
        timeout: Optional[int] = None,
        env: Optional[Dict[str, str]] = None,
        input_data: Optional[str] = None,
    ) -> subprocess.CompletedProcess[str]:
        raise NotImplementedError


def _default_runner(
    command: Sequence[str],
    *,
    timeout: Optional[int] = None,
    env: Optional[Dict[str, str]] = None,
    input_data: Optional[str] = None,
) -> subprocess.CompletedProcess[str]:
    """Invoke the Codex CLI via subprocess."""
    return subprocess.run(  # noqa: S603,S607 - intentional CLI invocation
        list(command),
        capture_output=True,
        text=True,
        input=input_data,
        timeout=timeout,
        env=env,
        check=False,
    )


CODEX_METADATA = ProviderMetadata(
    provider_id="codex",
    display_name="OpenAI Codex CLI",
    models=[],  # Model validation delegated to CLI
    default_model="gpt-5.2",
    capabilities={ProviderCapability.TEXT, ProviderCapability.STREAMING, ProviderCapability.FUNCTION_CALLING},
    security_flags={"writes_allowed": False, "read_only": True, "sandbox": "read-only"},
    extra={
        "cli": "codex",
        "command": "codex exec",
        "allowed_operations": SANDBOX_ALLOWED_OPERATIONS,
        "blocked_operations": SANDBOX_BLOCKED_OPERATIONS,
        "os_level_sandboxing": True,
    },
)


class CodexProvider(ProviderContext):
    """ProviderContext implementation backed by the Codex CLI with OS-level read-only sandboxing."""

    # Environment variables that must be unset for Codex CLI to work properly
    # These interfere with Codex's own API configuration
    _UNSET_ENV_VARS = ("OPENAI_API_KEY", "OPENAI_BASE_URL")

    def __init__(
        self,
        metadata: ProviderMetadata,
        hooks: ProviderHooks,
        *,
        model: Optional[str] = None,
        binary: Optional[str] = None,
        runner: Optional[RunnerProtocol] = None,
        env: Optional[Dict[str, str]] = None,
        timeout: Optional[int] = None,
    ):
        super().__init__(metadata, hooks)
        self._runner = runner or _default_runner
        self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
        self._env = self._prepare_subprocess_env(env)
        self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
        self._model = model or metadata.default_model or "gpt-5.2"

    def _prepare_subprocess_env(self, custom_env: Optional[Dict[str, str]]) -> Dict[str, str]:
        """
        Prepare environment variables for subprocess execution.

        Codex CLI uses its own authentication and must not have OPENAI_API_KEY
        or OPENAI_BASE_URL set, as these interfere with its internal API routing.
        """
        # Start with current environment
        subprocess_env = os.environ.copy()

        # Remove variables that interfere with Codex CLI
        for var in self._UNSET_ENV_VARS:
            subprocess_env.pop(var, None)

        # Merge custom environment if provided
        if custom_env:
            subprocess_env.update(custom_env)

        return subprocess_env

    def _validate_request(self, request: ProviderRequest) -> None:
        """Validate and normalize request, ignoring unsupported parameters."""
        unsupported: List[str] = []
        if request.temperature is not None:
            unsupported.append("temperature")
        if request.max_tokens is not None:
            unsupported.append("max_tokens")
        if unsupported:
            # Log warning but continue - ignore unsupported parameters
            logger.warning(
                f"Codex CLI ignoring unsupported parameters: {', '.join(unsupported)}"
            )

    def _build_prompt(self, request: ProviderRequest) -> str:
        """
        Build prompt with sandbox security warning injected.

        Combines user system prompt + SANDBOX_WARNING + user prompt to ensure
        the AI is aware of the read-only sandbox restrictions.
        """
        parts = []

        # Add user system prompt if provided
        if request.system_prompt:
            parts.append(request.system_prompt.strip())

        # Add sandbox warning (always)
        parts.append(SANDBOX_WARNING.strip())

        # Add user prompt
        parts.append(request.prompt)

        return "\n\n".join(parts)

    def _normalize_attachment_paths(self, request: ProviderRequest) -> List[str]:
        attachments = []
        for entry in request.attachments:
            if isinstance(entry, str) and entry.strip():
                attachments.append(entry.strip())
        return attachments

    def _build_command(self, model: str, attachments: List[str]) -> List[str]:
        # Note: codex CLI requires --json flag for JSONL output (non-interactive mode)
        # --skip-git-repo-check allows running outside trusted git directories
        # Using "-" to read prompt from stdin (avoids CLI arg length limits for long prompts)
        command = [self._binary, "exec", "--sandbox", "read-only", "--skip-git-repo-check", "--json"]
        if model:
            command.extend(["-m", model])
        for path in attachments:
            command.extend(["--image", path])
        command.append("-")  # Read prompt from stdin
        return command

    def _run(
        self, command: Sequence[str], timeout: Optional[float], input_data: Optional[str] = None
    ) -> subprocess.CompletedProcess[str]:
        try:
            return self._runner(
                command, timeout=int(timeout) if timeout else None, env=self._env, input_data=input_data
            )
        except FileNotFoundError as exc:
            raise ProviderUnavailableError(
                f"Codex CLI '{self._binary}' is not available on PATH.",
                provider=self.metadata.provider_id,
            ) from exc
        except subprocess.TimeoutExpired as exc:
            raise ProviderTimeoutError(
                f"Command timed out after {exc.timeout} seconds",
                provider=self.metadata.provider_id,
                elapsed=float(exc.timeout) if exc.timeout else None,
                timeout=float(exc.timeout) if exc.timeout else None,
            ) from exc

    def _flatten_text(self, payload: Any) -> str:
        if isinstance(payload, str):
            return payload
        if isinstance(payload, dict):
            pieces: List[str] = []
            for key in ("text", "content", "value"):
                value = payload.get(key)
                if value:
                    pieces.append(self._flatten_text(value))
            if "parts" in payload and isinstance(payload["parts"], list):
                pieces.extend(self._flatten_text(part) for part in payload["parts"])
            if "messages" in payload and isinstance(payload["messages"], list):
                pieces.extend(self._flatten_text(message) for message in payload["messages"])
            return "".join(pieces)
        if isinstance(payload, list):
            return "".join(self._flatten_text(item) for item in payload)
        return ""

    def _extract_agent_text(self, payload: Dict[str, Any]) -> str:
        # Check if this is an item with type="agent_message" or type="reasoning"
        item_type = payload.get("type")
        if item_type in ("agent_message", "reasoning"):
            text = self._flatten_text(payload)
            if text:
                return text

        # Check for specific message keys
        for key in ("agent_message", "message", "delta", "content"):
            if key in payload:
                text = self._flatten_text(payload[key])
                if text:
                    return text

        # Recurse into nested item
        item = payload.get("item")
        if isinstance(item, dict):
            return self._extract_agent_text(item)
        return ""

    def _token_usage_from_payload(self, payload: Dict[str, Any]) -> TokenUsage:
        usage = payload.get("usage") or payload.get("token_usage") or {}
        cached = usage.get("cached_input_tokens") or usage.get("cached_tokens") or 0
        return TokenUsage(
            input_tokens=int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0),
            output_tokens=int(usage.get("output_tokens") or usage.get("completion_tokens") or 0),
            cached_input_tokens=int(cached),
            total_tokens=int(usage.get("total_tokens") or 0),
        )

    def _process_events(
        self,
        stdout: str,
        *,
        stream: bool,
    ) -> Tuple[str, TokenUsage, Dict[str, Any], Optional[str]]:
        lines = [line.strip() for line in stdout.splitlines() if line.strip()]
        if not lines:
            raise ProviderExecutionError(
                "Codex CLI returned empty output.",
                provider=self.metadata.provider_id,
            )

        events: List[Dict[str, Any]] = []
        final_content = ""
        usage = TokenUsage()
        thread_id: Optional[str] = None
        reported_model: Optional[str] = None
        stream_index = 0
        streamed_chunks: List[str] = []

        for line in lines:
            try:
                event = json.loads(line)
            except json.JSONDecodeError as exc:
                raise ProviderExecutionError(
                    f"Codex CLI emitted invalid JSON: {exc}",
                    provider=self.metadata.provider_id,
                ) from exc

            events.append(event)
            event_type = str(event.get("type") or event.get("event") or "").lower()

            if event_type == "thread.started":
                thread_id = (
                    event.get("thread_id")
                    or (event.get("thread") or {}).get("id")
                    or event.get("id")
                )
            elif event_type in {"item.delta", "response.delta"}:
                delta_text = self._extract_agent_text(event)
                if delta_text:
                    streamed_chunks.append(delta_text)
                    if stream:
                        self._emit_stream_chunk(StreamChunk(content=delta_text, index=stream_index))
                        stream_index += 1
            elif event_type in {"item.completed", "response.completed"}:
                completed_text = self._extract_agent_text(event)
                if completed_text:
                    final_content = completed_text
            elif event_type in {"turn.completed", "usage"}:
                usage = self._token_usage_from_payload(event)
            if reported_model is None:
                reported_model = (
                    event.get("model")
                    or (event.get("item") or {}).get("model")
                    or (event.get("agent_message") or {}).get("model")
                )

        if not final_content:
            if streamed_chunks:
                final_content = "".join(streamed_chunks)
            else:
                raise ProviderExecutionError(
                    "Codex CLI did not emit a completion event.",
                    provider=self.metadata.provider_id,
                )

        metadata: Dict[str, Any] = {}
        if thread_id:
            metadata["thread_id"] = thread_id
        metadata["events"] = events

        return final_content, usage, metadata, reported_model

    def _extract_error_from_jsonl(self, stdout: str) -> Optional[str]:
        """
        Extract error message from Codex JSONL output.

        Codex CLI outputs errors as JSONL events to stdout, not stderr.
        Look for {"type":"error"} or {"type":"turn.failed"} events.
        """
        if not stdout:
            return None

        errors: List[str] = []
        for line in stdout.strip().splitlines():
            if not line.strip():
                continue
            try:
                event = json.loads(line)
            except json.JSONDecodeError:
                continue

            event_type = str(event.get("type", "")).lower()

            # Extract from {"type":"error","message":"..."}
            if event_type == "error":
                msg = event.get("message", "")
                # Skip reconnection messages, get the final error
                if msg and not msg.startswith("Reconnecting"):
                    errors.append(msg)

            # Extract from {"type":"turn.failed","error":{"message":"..."}}
            elif event_type == "turn.failed":
                error_obj = event.get("error", {})
                if isinstance(error_obj, dict):
                    msg = error_obj.get("message", "")
                    if msg:
                        errors.append(msg)

        # Return the last (most specific) error, or join if multiple
        if errors:
            # Deduplicate while preserving order
            seen = set()
            unique_errors = []
            for e in errors:
                if e not in seen:
                    seen.add(e)
                    unique_errors.append(e)
            return "; ".join(unique_errors)
        return None

    def _execute(self, request: ProviderRequest) -> ProviderResult:
        self._validate_request(request)
        # Resolve model: request.model takes precedence, then metadata, then instance default
        model = (
            request.model
            or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
            or self._model
        )
        prompt = self._build_prompt(request)
        attachments = self._normalize_attachment_paths(request)
        command = self._build_command(model, attachments)
        timeout = request.timeout or self._timeout
        completed = self._run(command, timeout=timeout, input_data=prompt)

        if completed.returncode != 0:
            stderr = (completed.stderr or "").strip()
            logger.debug(f"Codex CLI stderr: {stderr or 'no stderr'}")

            # Extract error message from JSONL stdout (Codex outputs errors there, not stderr)
            jsonl_error = self._extract_error_from_jsonl(completed.stdout)

            error_msg = f"Codex CLI exited with code {completed.returncode}"
            if jsonl_error:
                error_msg += f": {jsonl_error[:500]}"
            elif stderr:
                error_msg += f": {stderr[:500]}"
            raise ProviderExecutionError(
                error_msg,
                provider=self.metadata.provider_id,
            )

        content, usage, metadata, reported_model = self._process_events(
            completed.stdout,
            stream=request.stream,
        )

        return ProviderResult(
            content=content,
            provider_id=self.metadata.provider_id,
            model_used=f"{self.metadata.provider_id}:{reported_model or model}",
            status=ProviderStatus.SUCCESS,
            tokens=usage,
            stderr=(completed.stderr or "").strip() or None,
            raw_payload=metadata,
        )


def is_codex_available() -> bool:
    """Codex CLI availability check."""
    return detect_provider_availability("codex")


def create_provider(
    *,
    hooks: ProviderHooks,
    model: Optional[str] = None,
    dependencies: Optional[Dict[str, object]] = None,
    overrides: Optional[Dict[str, object]] = None,
) -> CodexProvider:
    """
    Factory used by the provider registry.

    dependencies/overrides allow callers (or tests) to inject runner/env/binary.
    """
    dependencies = dependencies or {}
    overrides = overrides or {}
    runner = dependencies.get("runner")
    env = dependencies.get("env")
    binary = overrides.get("binary") or dependencies.get("binary")
    timeout = overrides.get("timeout")
    selected_model = overrides.get("model") if overrides.get("model") else model

    return CodexProvider(
        metadata=CODEX_METADATA,
        hooks=hooks,
        model=selected_model,  # type: ignore[arg-type]
        binary=binary,  # type: ignore[arg-type]
        runner=runner if runner is not None else None,  # type: ignore[arg-type]
        env=env if env is not None else None,  # type: ignore[arg-type]
        timeout=timeout if timeout is not None else None,  # type: ignore[arg-type]
    )


register_provider(
    "codex",
    factory=create_provider,
    metadata=CODEX_METADATA,
    availability_check=is_codex_available,
    description="OpenAI Codex CLI adapter with native OS-level read-only sandboxing",
    tags=("cli", "text", "function_calling", "read-only", "sandboxed"),
    replace=True,
)


__all__ = [
    "CodexProvider",
    "create_provider",
    "is_codex_available",
    "CODEX_METADATA",
]
