"""Process Queue - Command queuing with lane-based serialization.

Provides cross-platform process execution utilities with:
- Lane-based command queuing for serialization
- Timeout support
- Concurrent execution control per lane
"""

from __future__ import annotations

import asyncio
import logging
import os
import subprocess
import sys
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import Enum
from typing import TypeVar

logger = logging.getLogger(__name__)

T = TypeVar("T")


class CommandLane(str, Enum):
    """Command execution lanes for organizing concurrent command execution."""

    MAIN = "main"
    CRON = "cron"
    SUBAGENT = "subagent"
    NESTED = "nested"


@dataclass(slots=True)
class SpawnResult:
    """Result from a spawned command execution."""

    stdout: str
    stderr: str
    code: int | None
    signal: str | None
    killed: bool


@dataclass(slots=True)
class CommandOptions:
    """Options for running a command with timeout."""

    timeout_ms: int = 10_000
    cwd: str | None = None
    input_data: str | None = None
    env: dict[str, str] | None = None


@dataclass(slots=True)
class _QueueEntry:
    """Internal queue entry."""

    task: Callable[[], Awaitable[object]]
    future: asyncio.Future[object]
    enqueued_at: float = field(default_factory=time.time)
    warn_after_ms: int = 2_000
    on_wait: Callable[[float, int], None] | None = None


@dataclass(slots=True)
class _LaneState:
    """Internal lane state."""

    lane: str
    queue: list[_QueueEntry] = field(default_factory=list)
    active: int = 0
    max_concurrent: int = 1
    draining: bool = False


class ProcessQueue:
    """Manages command queuing with lane-based serialization."""

    def __init__(self) -> None:
        self._lanes: dict[str, _LaneState] = {}
        self._lock = asyncio.Lock()

    def _get_lane_state(self, lane: str) -> _LaneState:
        """Get or create lane state."""
        cleaned = lane.strip() or CommandLane.MAIN.value
        if cleaned not in self._lanes:
            self._lanes[cleaned] = _LaneState(lane=cleaned)
        return self._lanes[cleaned]

    async def enqueue(
        self,
        task: Callable[[], Awaitable[T]],
        lane: str = CommandLane.MAIN.value,
        warn_after_ms: int = 2_000,
        on_wait: Callable[[float, int], None] | None = None,
    ) -> T:
        """Enqueue a task to execute in a specific lane.

        Args:
            task: Async function to execute.
            lane: Lane name for queue assignment.
            warn_after_ms: Warn if task waits longer than this.
            on_wait: Callback for long waits.

        Returns:
            The result of the task.
        """
        loop = asyncio.get_running_loop()
        future: asyncio.Future[T] = loop.create_future()

        entry = _QueueEntry(
            task=task,
            future=future,
            enqueued_at=time.time(),
            warn_after_ms=warn_after_ms,
            on_wait=on_wait,
        )

        async with self._lock:
            state = self._get_lane_state(lane)
            state.queue.append(entry)

        await self._drain_lane(lane)
        return await future

    async def dequeue(self, lane: str = CommandLane.MAIN.value) -> _QueueEntry | None:
        """Peek and remove the next entry from the queue without executing.

        Returns:
            The next queue entry, or None if empty.
        """
        async with self._lock:
            state = self._get_lane_state(lane)
            if state.queue:
                return state.queue.pop(0)
            return None

    async def peek(self, lane: str = CommandLane.MAIN.value) -> _QueueEntry | None:
        """Peek at the next entry without removing it.

        Returns:
            The next queue entry, or None if empty.
        """
        async with self._lock:
            state = self._get_lane_state(lane)
            return state.queue[0] if state.queue else None

    def size(self, lane: str = CommandLane.MAIN.value) -> int:
        """Get the current queue size for a lane (queued + active)."""
        cleaned = lane.strip() or CommandLane.MAIN.value
        state = self._lanes.get(cleaned)
        if not state:
            return 0
        return len(state.queue) + state.active

    def total_size(self) -> int:
        """Get total queue size across all lanes."""
        return sum(len(s.queue) + s.active for s in self._lanes.values())

    async def clear(self, lane: str = CommandLane.MAIN.value) -> int:
        """Clear all pending tasks in a lane.

        Returns:
            Number of tasks cleared.
        """
        async with self._lock:
            state = self._lanes.get(lane.strip() or CommandLane.MAIN.value)
            if not state:
                return 0
            removed = len(state.queue)
            # Cancel futures for removed entries
            for entry in state.queue:
                if not entry.future.done():
                    entry.future.cancel()
            state.queue.clear()
            return removed

    def set_lane_concurrency(self, lane: str, max_concurrent: int) -> None:
        """Set the maximum concurrency for a command lane."""
        cleaned = lane.strip() or CommandLane.MAIN.value
        state = self._get_lane_state(cleaned)
        state.max_concurrent = max(1, int(max_concurrent))

    async def _drain_lane(self, lane: str) -> None:
        """Drain ready tasks from a lane."""
        cleaned = lane.strip() or CommandLane.MAIN.value

        async with self._lock:
            state = self._get_lane_state(cleaned)
            if state.draining:
                return
            state.draining = True

        try:
            while True:
                async with self._lock:
                    state = self._get_lane_state(cleaned)
                    if state.active >= state.max_concurrent or not state.queue:
                        state.draining = False
                        break

                    entry = state.queue.pop(0)
                    waited_ms = (time.time() - entry.enqueued_at) * 1000
                    if waited_ms >= entry.warn_after_ms and entry.on_wait:
                        entry.on_wait(waited_ms, len(state.queue))
                    state.active += 1

                # Execute outside lock
                try:
                    result = await entry.task()
                    if not entry.future.done():
                        entry.future.set_result(result)
                except Exception as exc:
                    if not entry.future.done():
                        entry.future.set_exception(exc)
                finally:
                    async with self._lock:
                        state = self._get_lane_state(cleaned)
                        state.active -= 1
        except Exception:
            async with self._lock:
                state = self._get_lane_state(cleaned)
                state.draining = False


def _resolve_command(command: str) -> str:
    """Resolve a command for cross-platform compatibility."""
    if sys.platform != "win32":
        return command
    basename = os.path.basename(command).lower()
    ext = os.path.splitext(basename)[1]
    if ext:
        return command
    cmd_commands = {"npm", "pnpm", "yarn", "npx"}
    if basename in cmd_commands:
        return f"{command}.cmd"
    return command


async def run_exec(
    command: str,
    args: list[str],
    timeout_ms: int = 10_000,
    max_buffer: int | None = None,
) -> dict[str, str]:
    """Simple async process execution with timeout.

    Args:
        command: The command to execute.
        args: Command arguments.
        timeout_ms: Timeout in milliseconds.
        max_buffer: Maximum output buffer size.

    Returns:
        Dict with 'stdout' and 'stderr' keys.

    Raises:
        asyncio.TimeoutError: If command exceeds timeout.
        OSError: If command cannot be executed.
    """
    resolved = _resolve_command(command)
    timeout_sec = timeout_ms / 1000.0

    proc = await asyncio.create_subprocess_exec(
        resolved,
        *args,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )

    stdout_bytes, stderr_bytes = await asyncio.wait_for(
        proc.communicate(), timeout=timeout_sec
    )

    stdout = stdout_bytes.decode("utf-8", errors="replace")
    stderr = stderr_bytes.decode("utf-8", errors="replace")

    if max_buffer is not None:
        stdout = stdout[:max_buffer]
        stderr = stderr[:max_buffer]

    return {"stdout": stdout, "stderr": stderr}


async def run_command_with_timeout(
    argv: list[str],
    options: CommandOptions | None = None,
) -> SpawnResult:
    """Run a command with timeout, input, and environment control.

    Args:
        argv: Command and arguments as list.
        options: Execution options.

    Returns:
        SpawnResult with stdout, stderr, exit code, etc.
    """
    opts = options or CommandOptions()
    timeout_sec = opts.timeout_ms / 1000.0

    resolved_command = _resolve_command(argv[0])
    resolved_env = dict(os.environ)
    if opts.env:
        resolved_env.update(opts.env)

    # Suppress npm fund messages
    base_cmd = os.path.basename(resolved_command).lower()
    if base_cmd in ("npm", "npm.cmd", "npm.exe"):
        resolved_env.setdefault("NPM_CONFIG_FUND", "false")
        resolved_env.setdefault("npm_config_fund", "false")

    try:
        proc = await asyncio.create_subprocess_exec(
            resolved_command,
            *argv[1:],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            stdin=subprocess.PIPE if opts.input_data is not None else subprocess.DEVNULL,
            cwd=opts.cwd,
            env=resolved_env,
        )

        input_bytes = opts.input_data.encode("utf-8") if opts.input_data else None

        try:
            stdout_bytes, stderr_bytes = await asyncio.wait_for(
                proc.communicate(input=input_bytes), timeout=timeout_sec
            )
        except asyncio.TimeoutError:
            proc.kill()
            await proc.wait()
            return SpawnResult(
                stdout="",
                stderr="",
                code=None,
                signal="SIGKILL",
                killed=True,
            )

        return SpawnResult(
            stdout=stdout_bytes.decode("utf-8", errors="replace"),
            stderr=stderr_bytes.decode("utf-8", errors="replace"),
            code=proc.returncode,
            signal=None,
            killed=False,
        )
    except FileNotFoundError:
        return SpawnResult(
            stdout="",
            stderr=f"Command not found: {resolved_command}",
            code=127,
            signal=None,
            killed=False,
        )
    except OSError as e:
        return SpawnResult(
            stdout="",
            stderr=str(e),
            code=1,
            signal=None,
            killed=False,
        )
