"""Subprocess transport implementation using Claude Code CLI."""

from __future__ import annotations

from contextlib import suppress
import logging
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING, Any

import anyenv
import anyio
import anyio.abc
from anyio.streams.text import TextReceiveStream, TextSendStream

from clawd_code_sdk._errors import (
    CLIConnectionError,
    CLIJSONDecodeError as SDKJSONDecodeError,
    CLINotFoundError,
    ProcessError,
)
from clawd_code_sdk._internal.transport import Transport
from clawd_code_sdk._version import __version__


if TYPE_CHECKING:
    from collections.abc import AsyncIterable, AsyncIterator

    from anyio.abc import Process

    from clawd_code_sdk.models import ClaudeAgentOptions
    from clawd_code_sdk.models.messages import UserPromptMessage

logger = logging.getLogger(__name__)

_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024  # 1MB buffer limit
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"

# Platform-specific process creation flags
# On Windows, CREATE_NO_WINDOW prevents a visible console window from appearing
# when spawning the CLI subprocess, which improves UX for GUI applications
_CREATION_FLAGS = (
    subprocess.CREATE_NO_WINDOW  # type: ignore[attr-defined]
    if sys.platform == "win32" and hasattr(subprocess, "CREATE_NO_WINDOW")
    else 0
)


class SubprocessCLITransport(Transport):
    """Subprocess transport using Claude Code CLI."""

    def __init__(
        self,
        prompt: str | AsyncIterable[UserPromptMessage],
        options: ClaudeAgentOptions,
    ):
        self._prompt = prompt
        self._options = options
        self._cli_path = str(options.cli_path) if options.cli_path is not None else _find_cli()
        self._cwd = str(options.cwd) if options.cwd else None
        self._process: Process | None = None
        self._stdout_stream: TextReceiveStream | None = None
        self._stdin_stream: TextSendStream | None = None
        self._stderr_stream: TextReceiveStream | None = None
        self._stderr_task_group: anyio.abc.TaskGroup | None = None
        self._ready = False
        self._exit_error: Exception | None = None  # Track process exit errors
        self._max_buffer_size = options.max_buffer_size or _DEFAULT_MAX_BUFFER_SIZE
        self._stderr_lines: list[str] = []
        self._write_lock: anyio.Lock = anyio.Lock()

    def _build_command(self) -> list[str]:
        """Build CLI command with arguments."""
        cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]

        # system_prompt is now sent via initialize request

        match self._options.tools:
            case []:
                cmd.extend(["--tools", ""])
            case list() as tools:
                cmd.extend(["--tools", ",".join(tools)])
            case {"type": "preset"}:
                cmd.extend(["--tools", "default"])

        if self._options.allowed_tools is not None:
            cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)])

        if self._options.max_turns:
            cmd.extend(["--max-turns", str(self._options.max_turns)])

        if self._options.max_budget_usd is not None:
            cmd.extend(["--max-budget-usd", str(self._options.max_budget_usd)])

        if self._options.disallowed_tools:
            cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])

        if self._options.model:
            cmd.extend(["--model", self._options.model])

        if self._options.fallback_model:
            cmd.extend(["--fallback-model", self._options.fallback_model])

        if self._options.context_1m:
            cmd.extend(["--betas", "context-1m-2025-08-07"])

        if self._options.permission_prompt_tool_name:
            cmd.extend(["--permission-prompt-tool", self._options.permission_prompt_tool_name])

        if self._options.permission_mode:
            cmd.extend(["--permission-mode", self._options.permission_mode])

        # Session configuration
        from clawd_code_sdk.models.options import (
            ContinueLatest,
            NewSession,
            ResumeSession,
            resolve_session_config,
        )

        session = resolve_session_config(self._options.session)
        match session:
            case NewSession(session_id=sid) if sid is not None:
                cmd.extend(["--session-id", sid])
            case ResumeSession(session_id=sid, fork=fork, at_message=at_msg):
                cmd.extend(["--resume", sid])
                if fork:
                    cmd.append("--fork-session")
                if at_msg is not None:
                    cmd.extend(["--resume-session-at", at_msg])
            case ContinueLatest(fork=fork):
                cmd.append("--continue")
                if fork:
                    cmd.append("--fork-session")
        if not session.persist:
            cmd.append("--no-persist-session")

        # Handle settings and sandbox: merge sandbox into settings if both are provided
        if settings_value := self._options.build_settings_value():
            cmd.extend(["--settings", settings_value])

        if self._options.add_dirs:
            # Convert all paths to strings and add each directory
            for directory in self._options.add_dirs:
                cmd.extend(["--add-dir", str(directory)])

        match self._options.mcp_servers:
            case dict() as servers if servers:
                servers_for_cli = {
                    name: {k: v for k, v in cfg.items() if k != "instance"}
                    for name, cfg in servers.items()
                }
                dct = anyenv.dump_json({"mcpServers": servers_for_cli})
                cmd.extend(["--mcp-config", dct])
            case str() | Path() as path if str(path):
                cmd.extend(["--mcp-config", str(path)])

        cmd.append("--include-partial-messages")

        if self._options.agent:
            cmd.extend(["--agent", self._options.agent])

        if self._options.allow_dangerously_skip_permissions:
            cmd.append("--dangerously-skip-permissions")

        if self._options.debug_file:
            cmd.extend(["--debug-file", self._options.debug_file])

        if self._options.strict_mcp_config:
            cmd.append("--strict-mcp-config")

        match self._options.worktree:
            case True:
                cmd.append("--worktree")
            case str():
                cmd.extend(["--worktree", self._options.worktree])

        if self._options.chrome:
            cmd.append("--chrome")

        sources_value = ",".join(self._options.setting_sources or [])
        cmd.extend(["--setting-sources", sources_value])

        # Add plugin directories
        for plugin in self._options.plugins:
            if plugin["type"] == "local":
                cmd.extend(["--plugin-dir", plugin["path"]])
            else:
                raise ValueError(f"Unsupported plugin type: {plugin['type']}")

        # Add extra args for future CLI flags
        for flag, value in self._options.extra_args.items():
            if value is None:  # Boolean flag without value
                cmd.append(f"--{flag}")
            else:  # Flag with value
                cmd.extend([f"--{flag}", str(value)])

        # Resolve thinking config → --max-thinking-tokens
        match self._options.thinking:
            case {"type": "adaptive"}:
                cmd.extend(["--max-thinking-tokens", "32000"])
            case {"type": "enabled", "budget_tokens": budget}:
                cmd.extend(["--max-thinking-tokens", str(budget)])
            case {"type": "disabled"}:
                cmd.extend(["--max-thinking-tokens", "0"])

        if self._options.effort is not None:
            cmd.extend(["--effort", self._options.effort])

        # json_schema is now sent via initialize request

        # Always use streaming mode with stdin (matching TypeScript SDK)
        # This allows agents and other large configs to be sent via initialize request
        cmd.extend(["--input-format", "stream-json"])
        return cmd

    async def connect(self) -> None:
        """Start subprocess."""
        if self._process:
            return

        if not os.environ.get("CLAWD_CODE_SDK_SKIP_VERSION_CHECK"):
            await _check_claude_version(self._cli_path)

        cmd = self._build_command()
        # Remove CLAUDECODE from parent environment to prevent nesting detection
        # This allows SDK usage from within Claude Code (hooks, plugins, subagents)
        parent_env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}

        # Merge environment variables: system -> user -> SDK required
        process_env = {
            **parent_env,
            **self._options.env,  # User-provided env vars
            "CLAUDE_CODE_ENTRYPOINT": "sdk-py",
            "CLAWD_CODE_SDK_VERSION": __version__,
        }

        # Enable file checkpointing if requested
        if self._options.enable_file_checkpointing:
            process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true"

        if self._cwd:
            process_env["PWD"] = self._cwd

        # Always pipe stderr so we can capture it for error reporting.
        # The callback and debug mode flags control whether lines are
        # forwarded in real-time, but we always collect them.
        try:
            self._process = await anyio.open_process(
                cmd,
                cwd=self._cwd,
                env=process_env,
                user=self._options.user,
                creationflags=_CREATION_FLAGS,
            )

            if self._process.stdout:
                self._stdout_stream = TextReceiveStream(self._process.stdout)

            # Setup stderr stream if piped
            if self._process.stderr:
                self._stderr_stream = TextReceiveStream(self._process.stderr)
                # Start async task to read stderr
                self._stderr_task_group = anyio.create_task_group()
                await self._stderr_task_group.__aenter__()
                self._stderr_task_group.start_soon(self._handle_stderr)

            # Setup stdin for streaming (always used now)
            if self._process.stdin:
                self._stdin_stream = TextSendStream(self._process.stdin)

            self._ready = True

        except FileNotFoundError as e:
            # Check if the error comes from the working directory or the CLI
            if self._cwd and not Path(self._cwd).exists():
                error = CLIConnectionError(f"Working directory does not exist: {self._cwd}")
                self._exit_error = error
                raise error from e
            error = CLINotFoundError(f"Claude Code not found at: {self._cli_path}")
            self._exit_error = error
            raise error from e
        except Exception as e:
            error = CLIConnectionError(f"Failed to start Claude Code: {e}")
            self._exit_error = error
            raise error from e

    async def _handle_stderr(self) -> None:
        """Handle stderr stream - read and invoke callbacks."""
        if not self._stderr_stream:
            return

        try:
            async for line in self._stderr_stream:
                line_str = line.rstrip()
                if not line_str:
                    continue

                # Always collect stderr lines for error reporting
                self._stderr_lines.append(line_str)
                # Call the stderr callback if provided
                if self._options.stderr:
                    self._options.stderr(line_str)
        except anyio.ClosedResourceError:
            pass  # Stream closed, exit normally
        except Exception:
            pass  # Ignore other errors during stderr reading

    async def close(self) -> None:
        """Close the transport and clean up resources."""
        if not self._process:
            self._ready = False
            return

        # Close stderr task group if active
        if self._stderr_task_group:
            with suppress(Exception):
                self._stderr_task_group.cancel_scope.cancel()
                await self._stderr_task_group.__aexit__(None, None, None)
            self._stderr_task_group = None

        # Close stdin stream (acquire lock to prevent race with concurrent writes)
        async with self._write_lock:
            self._ready = False  # Set inside lock to prevent TOCTOU with write()
            if self._stdin_stream:
                with suppress(Exception):
                    await self._stdin_stream.aclose()
                self._stdin_stream = None

        if self._stderr_stream:
            with suppress(Exception):
                await self._stderr_stream.aclose()
            self._stderr_stream = None

        # Terminate and wait for process
        if self._process.returncode is None:
            with suppress(ProcessLookupError):
                self._process.terminate()
                # Wait for process to finish with timeout
                with suppress(Exception):
                    # Just try to wait, but don't block if it fails
                    await self._process.wait()

        self._process = None
        self._stdout_stream = None
        self._stdin_stream = None
        self._stderr_stream = None
        self._exit_error = None

    async def write(self, data: str) -> None:
        """Write raw data to the transport."""
        async with self._write_lock:
            # All checks inside lock to prevent TOCTOU races with close()/end_input()
            if not self._ready or not self._stdin_stream:
                raise CLIConnectionError("ProcessTransport is not ready for writing")

            if self._process and self._process.returncode is not None:
                raise CLIConnectionError(
                    f"Cannot write to terminated process (exit code: {self._process.returncode})"
                )

            if self._exit_error:
                raise CLIConnectionError(
                    f"Cannot write to process that exited with error: {self._exit_error}"
                ) from self._exit_error

            try:
                await self._stdin_stream.send(data)
            except Exception as e:
                self._ready = False
                self._exit_error = CLIConnectionError(f"Failed to write to process stdin: {e}")
                raise self._exit_error from e

    async def end_input(self) -> None:
        """End the input stream (close stdin)."""
        async with self._write_lock:
            if self._stdin_stream:
                with suppress(Exception):
                    await self._stdin_stream.aclose()
                self._stdin_stream = None

    def read_messages(self) -> AsyncIterator[dict[str, Any]]:
        """Read and parse messages from the transport."""
        return self._read_messages_impl()

    async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]:
        """Internal implementation of read_messages."""
        if not self._process or not self._stdout_stream:
            raise CLIConnectionError("Not connected")

        json_buffer = ""
        # Process stdout messages
        try:
            async for line in self._stdout_stream:
                line_str = line.strip()
                if not line_str:
                    continue

                # Accumulate partial JSON until we can parse it
                # Note: TextReceiveStream can truncate long lines, so we need to buffer
                # and speculatively parse until we get a complete JSON object
                for json_line in line_str.split("\n"):
                    json_line = json_line.strip()
                    if not json_line:
                        continue

                    # Keep accumulating partial JSON until we can parse it
                    json_buffer += json_line

                    if len(json_buffer) > self._max_buffer_size:
                        buffer_length = len(json_buffer)
                        json_buffer = ""
                        raise SDKJSONDecodeError(
                            f"JSON message exceeded maximum buffer size of {self._max_buffer_size} bytes",
                            ValueError(f"{buffer_length=} exceeds {self._max_buffer_size=}"),
                        )

                    try:
                        data = anyenv.load_json(json_buffer, return_type=dict)
                        json_buffer = ""
                        yield data
                    except anyenv.JsonLoadError:
                        # We are speculatively decoding the buffer until we get
                        # a full JSON object. If there is an actual issue, we
                        # raise an error after exceeding the configured limit.
                        continue

        except anyio.ClosedResourceError:
            pass
        except GeneratorExit:
            # Client disconnected
            pass

        # Check process completion and handle errors
        try:
            returncode = await self._process.wait()
        except Exception:
            returncode = -1

        # Wait for stderr reader to finish draining
        if self._stderr_task_group:
            with suppress(Exception):
                with anyio.move_on_after(5):
                    self._stderr_task_group.cancel_scope.cancel()
                    await self._stderr_task_group.__aexit__(None, None, None)
            self._stderr_task_group = None

        # Use exit code for error detection
        if returncode is not None and returncode != 0:
            stderr_output = "\n".join(self._stderr_lines) if self._stderr_lines else None
            msg = f"Command failed with exit code {returncode}"
            self._exit_error = ProcessError(msg, exit_code=returncode, stderr=stderr_output)
            raise self._exit_error


async def _check_claude_version(cli_path: str) -> None:
    """Check Claude Code version and warn if below minimum."""
    proc = None
    try:
        with anyio.fail_after(2):  # 2 second timeout
            proc = await anyio.open_process([cli_path, "-v"], creationflags=_CREATION_FLAGS)
            if proc.stdout:
                stdout_bytes = await proc.stdout.receive()
                version_output = stdout_bytes.decode().strip()
                match = re.match(r"([0-9]+\.[0-9]+\.[0-9]+)", version_output)
                if match:
                    version = match.group(1)
                    version_parts = [int(x) for x in version.split(".")]
                    min_parts = [int(x) for x in MINIMUM_CLAUDE_CODE_VERSION.split(".")]
                    if version_parts < min_parts:
                        warning = (
                            f"Warning: Claude Code version {version} is unsupported in the Agent SDK. "
                            f"Minimum required version is {MINIMUM_CLAUDE_CODE_VERSION}. "
                            "Some features may not work correctly."
                        )
                        logger.warning(warning)
                        print(warning, file=sys.stderr)
    except Exception:
        pass
    finally:
        if proc:
            with suppress(Exception):
                proc.terminate()
            with suppress(Exception):
                await proc.wait()


def _find_bundled_cli() -> str | None:
    """Find bundled CLI binary if it exists."""
    # Determine the CLI binary name based on platform
    cli_name = "claude.exe" if platform.system() == "Windows" else "claude"
    # Get the path to the bundled CLI
    # The _bundled directory is in the same package as this module
    bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name
    if bundled_path.exists() and bundled_path.is_file():
        logger.info(f"Using bundled Claude Code CLI: {bundled_path}")
        return str(bundled_path)
    return None


def _find_cli() -> str:
    """Find Claude Code CLI binary."""
    # First, check for bundled CLI
    if bundled_cli := _find_bundled_cli():
        return bundled_cli

    # Fall back to system-wide search
    if cli := shutil.which("claude"):
        return cli

    locations = [
        Path.home() / ".npm-global/bin/claude",
        Path("/usr/local/bin/claude"),
        Path.home() / ".local/bin/claude",
        Path.home() / "node_modules/.bin/claude",
        Path.home() / ".yarn/bin/claude",
        Path.home() / ".claude/local/claude",
    ]

    for path in locations:
        if path.exists() and path.is_file():
            return str(path)

    raise CLINotFoundError(
        "Claude Code not found. Install with:\n"
        "  npm install -g @anthropic-ai/claude-code\n"
        "\nIf already installed locally, try:\n"
        '  export PATH="$HOME/node_modules/.bin:$PATH"\n'
        "\nOr provide the path via ClaudeAgentOptions:\n"
        "  ClaudeAgentOptions(cli_path='/path/to/claude')"
    )
