"""Process Registry - Manages running and finished shell sessions.

Provides lifecycle management for spawned processes including session tracking,
output buffering, slug-based naming, and automatic cleanup of expired sessions.
"""

from __future__ import annotations

import os
import random
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum


class ProcessStatus(str, Enum):
    """Status of a managed process."""

    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    TIMED_OUT = "timed_out"
    KILLED = "killed"


DEFAULT_JOB_TTL_MS = 30 * 60 * 1000  # 30 minutes
MIN_JOB_TTL_MS = 60 * 1000  # 1 minute
MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000  # 3 hours
DEFAULT_PENDING_OUTPUT_CHARS = 30_000


def _clamp_ttl(value: int | None) -> int:
    """Clamp TTL to valid range."""
    if value is None:
        return DEFAULT_JOB_TTL_MS
    return min(max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS)


@dataclass(slots=True)
class ProcessSession:
    """A running process session."""

    id: str
    command: str
    scope_key: str = ""
    started_at: float = field(default_factory=lambda: time.time() * 1000)
    cwd: str = ""
    backgrounded: bool = False
    exited: bool = False
    exit_code: int | None = None
    exit_signal: str | None = None
    aggregated: str = ""
    tail: str = ""
    truncated: bool = False
    total_output_chars: int = 0
    max_output_chars: int = 200_000
    pending_max_output_chars: int | None = None
    pending_stdout: list[str] = field(default_factory=list)
    pending_stderr: list[str] = field(default_factory=list)
    pending_stdout_chars: int = 0
    pending_stderr_chars: int = 0
    pid: int | None = None


@dataclass(slots=True)
class FinishedSession:
    """A finished process session."""

    id: str
    command: str
    scope_key: str = ""
    started_at: float = 0.0
    ended_at: float = 0.0
    cwd: str = ""
    status: ProcessStatus = ProcessStatus.COMPLETED
    exit_code: int | None = None
    exit_signal: str | None = None
    aggregated: str = ""
    tail: str = ""
    truncated: bool = False
    total_output_chars: int = 0


# Session slug generation
SLUG_ADJECTIVES = [
    "amber", "briny", "brisk", "calm", "clear", "cool", "crisp", "dawn",
    "delta", "ember", "faint", "fast", "fresh", "gentle", "glow", "good",
    "grand", "keen", "kind", "lucky", "marine", "mellow", "mild", "neat",
    "nimble", "nova", "oceanic", "plaid", "quick", "quiet", "rapid", "salty",
    "sharp", "swift", "tender", "tidal", "tidy", "tide", "vivid", "warm",
    "wild", "young",
]

SLUG_NOUNS = [
    "atlas", "basil", "bison", "bloom", "breeze", "canyon", "cedar", "claw",
    "cloud", "comet", "coral", "cove", "crest", "daisy", "dune", "ember",
    "falcon", "fjord", "forest", "glade", "gulf", "harbor", "haven", "kelp",
    "lagoon", "meadow", "mist", "nexus", "ocean", "orbit", "otter", "pine",
    "prairie", "reef", "ridge", "river", "rook", "sable", "sage", "shell",
    "shoal", "shore", "slug", "summit", "trail", "valley", "wharf", "willow",
    "zephyr",
]


class ProcessRegistry:
    """Registry for managing running and finished shell sessions."""

    def __init__(self) -> None:
        self._running: dict[str, ProcessSession] = {}
        self._finished: dict[str, FinishedSession] = {}
        self._lock = threading.Lock()
        self._sweeper: threading.Timer | None = None
        self._job_ttl_ms = _clamp_ttl(
            _parse_int_env("SHELL_JOB_TTL_MS")
        )

    def _is_id_taken(self, session_id: str) -> bool:
        """Check if a session ID is in use."""
        return session_id in self._running or session_id in self._finished

    def create_session_slug(self) -> str:
        """Generate a unique human-readable session slug."""
        with self._lock:
            return _create_session_slug(self._is_id_taken)

    def register_process(self, session: ProcessSession) -> None:
        """Register a new process session."""
        with self._lock:
            self._running[session.id] = session
            self._start_sweeper()

    def get_process(self, session_id: str) -> ProcessSession | None:
        """Get a running process session by ID."""
        with self._lock:
            return self._running.get(session_id)

    def get_finished(self, session_id: str) -> FinishedSession | None:
        """Get a finished session by ID."""
        with self._lock:
            return self._finished.get(session_id)

    def kill_process(self, session_id: str) -> bool:
        """Kill a running process by session ID.

        Returns:
            True if process was found and kill signal sent, False otherwise.
        """
        with self._lock:
            session = self._running.get(session_id)
            if not session or not session.pid:
                return False

        # Send SIGTERM (outside lock to avoid holding it during syscall)
        try:
            os.kill(session.pid, 15)  # SIGTERM
            return True
        except (ProcessLookupError, PermissionError):
            return False

    def list_processes(self) -> list[ProcessSession]:
        """List all backgrounded running sessions."""
        with self._lock:
            return [s for s in self._running.values() if s.backgrounded]

    def list_finished(self) -> list[FinishedSession]:
        """List all finished sessions."""
        with self._lock:
            return list(self._finished.values())

    def list_all(self) -> dict[str, list]:
        """List both running and finished sessions."""
        with self._lock:
            return {
                "running": [s for s in self._running.values() if s.backgrounded],
                "finished": list(self._finished.values()),
            }

    def delete_session(self, session_id: str) -> None:
        """Delete a session from both registries."""
        with self._lock:
            self._running.pop(session_id, None)
            self._finished.pop(session_id, None)

    def cleanup_dead(self) -> int:
        """Clean up sessions for processes that are no longer running.

        Returns:
            Number of sessions cleaned up.
        """
        cleaned = 0
        with self._lock:
            dead_ids = []
            for sid, session in self._running.items():
                if session.pid and not _is_process_alive(session.pid):
                    dead_ids.append(sid)

            for sid in dead_ids:
                session = self._running.pop(sid)
                if session.backgrounded:
                    self._finished[sid] = FinishedSession(
                        id=session.id,
                        command=session.command,
                        scope_key=session.scope_key,
                        started_at=session.started_at,
                        ended_at=time.time() * 1000,
                        cwd=session.cwd,
                        status=ProcessStatus.FAILED,
                        exit_code=session.exit_code,
                        exit_signal=session.exit_signal,
                        aggregated=session.aggregated,
                        tail=session.tail,
                        truncated=session.truncated,
                        total_output_chars=session.total_output_chars,
                    )
                cleaned += 1
        return cleaned

    def append_output(
        self, session: ProcessSession, stream: str, chunk: str
    ) -> None:
        """Append output to a session."""
        with self._lock:
            buffer = session.pending_stdout if stream == "stdout" else session.pending_stderr
            buffer_chars = (
                session.pending_stdout_chars
                if stream == "stdout"
                else session.pending_stderr_chars
            )
            pending_cap = min(
                session.pending_max_output_chars or DEFAULT_PENDING_OUTPUT_CHARS,
                session.max_output_chars,
            )

            buffer.append(chunk)
            pending_chars = buffer_chars + len(chunk)

            if pending_chars > pending_cap:
                session.truncated = True
                pending_chars = _cap_pending_buffer(buffer, pending_chars, pending_cap)

            if stream == "stdout":
                session.pending_stdout_chars = pending_chars
            else:
                session.pending_stderr_chars = pending_chars

            session.total_output_chars += len(chunk)
            new_aggregated = _trim_with_cap(
                session.aggregated + chunk, session.max_output_chars
            )
            session.truncated = session.truncated or len(new_aggregated) < len(
                session.aggregated
            ) + len(chunk)
            session.aggregated = new_aggregated
            session.tail = _tail(session.aggregated, 2000)

    def drain_session(self, session: ProcessSession) -> dict[str, str]:
        """Drain pending output from a session."""
        with self._lock:
            stdout = "".join(session.pending_stdout)
            stderr = "".join(session.pending_stderr)
            session.pending_stdout = []
            session.pending_stderr = []
            session.pending_stdout_chars = 0
            session.pending_stderr_chars = 0
            return {"stdout": stdout, "stderr": stderr}

    def mark_exited(
        self,
        session: ProcessSession,
        exit_code: int | None,
        exit_signal: str | None,
        status: ProcessStatus,
    ) -> None:
        """Mark a session as exited."""
        with self._lock:
            session.exited = True
            session.exit_code = exit_code
            session.exit_signal = exit_signal
            session.tail = _tail(session.aggregated, 2000)
            self._move_to_finished(session, status)

    def mark_backgrounded(self, session: ProcessSession) -> None:
        """Mark a session as backgrounded."""
        with self._lock:
            session.backgrounded = True

    def clear_finished(self) -> None:
        """Clear all finished sessions."""
        with self._lock:
            self._finished.clear()

    def set_job_ttl_ms(self, value: int | None) -> None:
        """Set the TTL for finished jobs."""
        if value is None:
            return
        self._job_ttl_ms = _clamp_ttl(value)
        self._stop_sweeper()
        self._start_sweeper()

    def reset_for_tests(self) -> None:
        """Reset all state (for testing only)."""
        with self._lock:
            self._running.clear()
            self._finished.clear()
            self._stop_sweeper()

    def _move_to_finished(
        self, session: ProcessSession, status: ProcessStatus
    ) -> None:
        """Move session from running to finished."""
        self._running.pop(session.id, None)
        if not session.backgrounded:
            return
        self._finished[session.id] = FinishedSession(
            id=session.id,
            command=session.command,
            scope_key=session.scope_key,
            started_at=session.started_at,
            ended_at=time.time() * 1000,
            cwd=session.cwd,
            status=status,
            exit_code=session.exit_code,
            exit_signal=session.exit_signal,
            aggregated=session.aggregated,
            tail=session.tail,
            truncated=session.truncated,
            total_output_chars=session.total_output_chars,
        )

    def _prune_finished(self) -> None:
        """Prune expired finished sessions."""
        cutoff = time.time() * 1000 - self._job_ttl_ms
        with self._lock:
            expired = [
                sid
                for sid, session in self._finished.items()
                if session.ended_at < cutoff
            ]
            for sid in expired:
                del self._finished[sid]

    def _start_sweeper(self) -> None:
        """Start the background sweeper timer."""
        if self._sweeper is not None:
            return
        interval = max(30.0, self._job_ttl_ms / 6000.0)

        def sweep() -> None:
            self._prune_finished()
            self._sweeper = None
            self._start_sweeper()

        self._sweeper = threading.Timer(interval, sweep)
        self._sweeper.daemon = True
        self._sweeper.start()

    def _stop_sweeper(self) -> None:
        """Stop the background sweeper timer."""
        if self._sweeper is not None:
            self._sweeper.cancel()
            self._sweeper = None


def _parse_int_env(key: str) -> int | None:
    """Parse an integer from environment variable."""
    val = os.environ.get(key)
    if val is None:
        return None
    try:
        return int(val)
    except ValueError:
        return None


def _is_process_alive(pid: int) -> bool:
    """Check if a process is alive."""
    try:
        os.kill(pid, 0)
        return True
    except (ProcessLookupError, PermissionError):
        return False


def _tail(text: str, max_chars: int = 2000) -> str:
    """Get the tail of a string."""
    if len(text) <= max_chars:
        return text
    return text[-max_chars:]


def _trim_with_cap(text: str, max_chars: int) -> str:
    """Trim string to max length, keeping the end."""
    if len(text) <= max_chars:
        return text
    return text[-max_chars:]


def _cap_pending_buffer(
    buffer: list[str], pending_chars: int, cap: int
) -> int:
    """Cap pending output buffer size."""
    if pending_chars <= cap:
        return pending_chars

    last = buffer[-1] if buffer else None
    if last and len(last) >= cap:
        buffer.clear()
        buffer.append(last[-cap:])
        return cap

    while buffer and pending_chars - len(buffer[0]) >= cap:
        pending_chars -= len(buffer[0])
        buffer.pop(0)

    if buffer and pending_chars > cap:
        overflow = pending_chars - cap
        buffer[0] = buffer[0][overflow:]
        pending_chars = cap

    return pending_chars


def _create_slug_base(words: int = 2) -> str:
    """Create a slug base from random words."""
    parts = [
        random.choice(SLUG_ADJECTIVES),
        random.choice(SLUG_NOUNS),
    ]
    if words > 2:
        parts.append(random.choice(SLUG_NOUNS))
    return "-".join(parts)


def _create_session_slug(
    is_taken: Callable[[str], bool] | None = None,
) -> str:
    """Generate a unique session slug."""
    check = is_taken or (lambda _: False)

    for _ in range(12):
        base = _create_slug_base(2)
        if not check(base):
            return base
        for i in range(2, 13):
            candidate = f"{base}-{i}"
            if not check(candidate):
                return candidate

    for _ in range(12):
        base = _create_slug_base(3)
        if not check(base):
            return base
        for i in range(2, 13):
            candidate = f"{base}-{i}"
            if not check(candidate):
                return candidate

    fallback = f"{_create_slug_base(3)}-{random.randint(100, 999)}"
    if check(fallback):
        fallback = f"{fallback}-{int(time.time())}"
    return fallback
