"""
Agent Spawner — Launch and manage local LLM agents as background processes.

Runtime-side port of ``src/brynq/spawner.py``.  Uses ``RuntimeConfig.data_dir``
(``~/.brynq/``) for all persistence instead of the legacy ``state/broker/``
paths, and drops the ``brynq.server`` import dependency.

Agent state files live at ``~/.brynq/agents/{name}.json``.
Agent log files live at ``~/.brynq/logs/{name}.log``.

All stdlib -- no external dependencies beyond RuntimeConfig.

Functions:
    cleanup_dead_agents -- Remove tracked entries for exited processes.
    spawn_agent   -- Launch a single agent process.
    spawn_swarm   -- Launch a coordinated group of agents on a shared channel.
    list_agents   -- Enumerate agents and their liveness.
    kill_agent    -- Terminate one agent by name.
    kill_swarm    -- Terminate all agents belonging to a swarm.
    restart_agent -- Kill + re-spawn an agent with its saved config.
"""

import json
import logging
import os
import signal
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from .config import RuntimeConfig, load_config

# -- Logging ----------------------------------------------------------------

log = logging.getLogger("brynq.runtime.spawner")

# -- Config-driven paths ---------------------------------------------------

_cfg: Optional[RuntimeConfig] = None


def _get_config() -> RuntimeConfig:
    """Return the cached RuntimeConfig, loading it once on first access."""
    global _cfg
    if _cfg is None:
        _cfg = load_config()
    return _cfg


def _agents_dir() -> Path:
    """``~/.brynq/agents/``"""
    return _get_config().data_dir / "agents"


def _logs_dir() -> Path:
    """``~/.brynq/logs/``"""
    return _get_config().data_dir / "logs"


def _ensure_dirs() -> None:
    """Create the agents state and logs directories if they do not exist."""
    _agents_dir().mkdir(parents=True, exist_ok=True)
    _logs_dir().mkdir(parents=True, exist_ok=True)


# -- Internal helpers -------------------------------------------------------


def _agent_file(name: str) -> Path:
    """Return the state file path for a named agent."""
    return _agents_dir() / f"{name}.json"


def _agent_log_file(name: str) -> Path:
    """Return the log file path for a named agent."""
    return _logs_dir() / f"{name}.log"


def _read_agent(name: str) -> Optional[dict]:
    """Read an agent's persisted state, or None if missing / corrupt."""
    path = _agent_file(name)
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return None


def _write_agent(data: dict) -> None:
    """Persist an agent's state to disk."""
    _ensure_dirs()
    path = _agent_file(data["name"])
    path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")


def _remove_agent_file(name: str) -> None:
    """Delete an agent's state file if it exists."""
    path = _agent_file(name)
    try:
        path.unlink(missing_ok=True)
    except OSError:
        pass


def _is_pid_alive(pid: int) -> bool:
    """Check whether a process with the given PID is still running."""
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError, PermissionError):
        return False


def _kill_pid(pid: int) -> bool:
    """Attempt to terminate a process by PID.  Returns True if the signal
    was delivered (or the process was already dead)."""
    if pid <= 0:
        return False
    try:
        if sys.platform == "win32":
            subprocess.run(
                ["taskkill", "/F", "/T", "/PID", str(pid)],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                check=False,
            )
        else:
            os.kill(pid, signal.SIGTERM)
        return True
    except (OSError, ProcessLookupError, PermissionError):
        return False


def _uptime_seconds(started_iso: str) -> float:
    """Return seconds elapsed since an ISO-8601 timestamp."""
    try:
        started = datetime.fromisoformat(started_iso)
        now = datetime.now(timezone.utc)
        if started.tzinfo is None:
            started = started.replace(tzinfo=timezone.utc)
        return max(0.0, (now - started).total_seconds())
    except (ValueError, TypeError):
        return 0.0


# -- Public API -------------------------------------------------------------


def cleanup_dead_agents() -> int:
    """Remove tracked agent entries whose processes have exited.

    Iterates all agent state files in ``~/.brynq/agents/``, checks each
    PID for liveness, and removes the state file for any agent whose
    process is no longer running.

    Returns:
        The number of dead agent entries cleaned up.
    """
    _ensure_dirs()
    agents_dir = _agents_dir()
    cleaned = 0

    for path in sorted(agents_dir.glob("*.json")):
        try:
            data = json.loads(path.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        pid = data.get("pid", 0)
        name = data.get("name", path.stem)

        if not _is_pid_alive(pid):
            _remove_agent_file(name)
            cleaned += 1
            log.info("cleanup_dead_agents: removed dead agent '%s' (PID %d)", name, pid)

    if cleaned:
        log.info("cleanup_dead_agents: cleaned up %d dead agent(s)", cleaned)

    return cleaned


def spawn_agent(
    name: str,
    backend: str = "ollama",
    model: str = "llama3",
    channel: str = "general",
    system_prompt: Optional[str] = None,
    cwd: str = ".",
) -> dict:
    """Launch a local LLM agent as a background subprocess.

    The agent runs ``python -m brynq.agent_loop --name {name} --backend
    {backend} --model {model} --channel {channel}`` in the background.
    Stdout and stderr are redirected to ``~/.brynq/logs/{name}.log``.

    Args:
        name:          Unique agent name (used as filename and session name).
        backend:       LLM backend -- ``"ollama"`` or ``"lmstudio"``.
        model:         Model identifier (e.g. ``"llama3"``, ``"mistral"``).
        channel:       Broker channel the agent should monitor.
        system_prompt: Optional system prompt passed to the agent loop.
        cwd:           Working directory for the subprocess.

    Returns:
        Dict with ``name``, ``pid``, and ``status`` keys.

    Raises:
        RuntimeError: If an agent with the same name is already running.
    """
    _ensure_dirs()

    # Clean up any dead agent entries before proceeding.
    cleanup_dead_agents()

    # Enforce maximum concurrent agent limit.
    running = sum(1 for a in list_agents() if a["status"] == "running")
    if running >= 20:
        raise RuntimeError("Maximum concurrent agent limit reached (20)")

    # Refuse to double-spawn.
    existing = _read_agent(name)
    if existing and _is_pid_alive(existing.get("pid", 0)):
        raise RuntimeError(
            f"Agent '{name}' is already running (PID {existing['pid']}). "
            "Kill it first or choose a different name."
        )

    resolved_cwd = str(Path(cwd).resolve())

    # Build the subprocess command.
    cmd = [
        sys.executable, "-m", "brynq.agent_loop",
        "--name", name,
        "--backend", backend,
        "--model", model,
        "--channel", channel,
    ]
    if system_prompt:
        cmd.extend(["--system-prompt", system_prompt])

    # Inherit env and ensure brynq is importable.
    env = os.environ.copy()
    src_dir = str(Path(__file__).resolve().parents[1] / "src")
    existing_pp = env.get("PYTHONPATH", "")
    if src_dir not in existing_pp:
        env["PYTHONPATH"] = f"{src_dir}{os.pathsep}{existing_pp}" if existing_pp else src_dir

    # Platform-specific subprocess flags — detach so agents survive terminal close.
    kwargs: dict[str, Any] = {}
    if sys.platform == "win32":
        CREATE_NO_WINDOW = 0x08000000
        CREATE_NEW_PROCESS_GROUP = 0x00000200
        DETACHED_PROCESS = 0x00000008
        kwargs["creationflags"] = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
    else:
        kwargs["start_new_session"] = True  # detach from terminal on Unix

    # Redirect stdout/stderr to a per-agent log file.
    log_file = _agent_log_file(name)
    try:
        log_fh = open(log_file, "a", encoding="utf-8")  # noqa: SIM115
    except OSError as exc:
        log.error(
            "spawn_agent '%s': cannot open log file '%s': %s",
            name, log_file, exc,
        )
        raise RuntimeError(
            f"Cannot spawn agent '{name}': failed to open log file "
            f"'{log_file}': {exc}"
        ) from exc

    try:
        proc = subprocess.Popen(
            cmd,
            cwd=resolved_cwd,
            env=env,
            stdout=log_fh,
            stderr=log_fh,
            stdin=subprocess.DEVNULL,
            **kwargs,
        )
    except FileNotFoundError:
        log_fh.close()
        log.error(
            "spawn_agent '%s': Python executable not found: %s",
            name, sys.executable,
        )
        raise RuntimeError(
            f"Cannot spawn agent '{name}': Python executable not found "
            f"at '{sys.executable}'. Check your Python installation."
        )
    except PermissionError as exc:
        log_fh.close()
        log.error(
            "spawn_agent '%s': permission denied launching subprocess: %s",
            name, exc,
        )
        raise RuntimeError(
            f"Cannot spawn agent '{name}': permission denied. "
            f"Check execute permissions for '{sys.executable}' and "
            f"working directory '{resolved_cwd}'."
        ) from exc
    except OSError as exc:
        log_fh.close()
        log.error(
            "spawn_agent '%s': OS error launching subprocess "
            "(cmd=%s, cwd=%s): %s",
            name, cmd, resolved_cwd, exc,
        )
        raise RuntimeError(
            f"Cannot spawn agent '{name}': {exc}. "
            f"Verify working directory '{resolved_cwd}' exists and is accessible."
        ) from exc
    except subprocess.SubprocessError as exc:
        log_fh.close()
        log.error(
            "spawn_agent '%s': subprocess error (cmd=%s): %s",
            name, cmd, exc,
        )
        raise RuntimeError(
            f"Cannot spawn agent '{name}': subprocess failed — {exc}"
        ) from exc
    except ValueError as exc:
        log_fh.close()
        log.error(
            "spawn_agent '%s': invalid arguments (cmd=%s, cwd=%s): %s",
            name, cmd, resolved_cwd, exc,
        )
        raise RuntimeError(
            f"Cannot spawn agent '{name}': invalid subprocess arguments — {exc}"
        ) from exc
    finally:
        # Parent doesn't need the FD — child inherited it.
        # close() is safe to call multiple times on the same file handle.
        log_fh.close()

    # Check for immediate process death (bad module, missing deps, etc.).
    exit_code = proc.poll()
    if exit_code is not None:
        log.error(
            "spawn_agent '%s': process exited immediately with code %d "
            "(check log at '%s')",
            name, exit_code, log_file,
        )
        raise RuntimeError(
            f"Agent '{name}' exited immediately with code {exit_code}. "
            f"Check log file: {log_file}"
        )

    started = datetime.now(timezone.utc).isoformat()

    # Persist agent metadata (including restart config).
    agent_data = {
        "name": name,
        "pid": proc.pid,
        "backend": backend,
        "model": model,
        "channel": channel,
        "system_prompt": system_prompt,
        "cwd": resolved_cwd,
        "log_file": str(log_file),
        "started": started,
        "auto_restart": True,
        "max_restarts": 3,
        "restart_count": 0,
    }
    _write_agent(agent_data)

    log.info(
        "Spawned agent '%s' (PID %d) -- %s/%s on #%s",
        name, proc.pid, backend, model, channel,
    )

    return {"name": name, "pid": proc.pid, "status": "running"}


def kill_agent(name: str) -> bool:
    """Kill a running agent by name.

    Terminates the process and removes the state file.

    Args:
        name: The agent name to kill.

    Returns:
        True if the agent was found and the kill signal was sent.
        False if no agent with that name was tracked.
    """
    data = _read_agent(name)
    if data is None:
        log.warning("kill_agent: no agent named '%s' found", name)
        return False

    pid = data.get("pid", 0)
    killed = _kill_pid(pid)

    _remove_agent_file(name)

    log.info("Killed agent '%s' (PID %d) -- signal sent: %s", name, pid, killed)
    return True


def list_agents() -> list[dict]:
    """List all tracked agents and their current liveness status.

    Reads every ``~/.brynq/agents/*.json`` file, probes the PID to
    determine whether the process is still alive, and automatically
    cleans up state files for dead agents.

    Returns:
        List of dicts with ``name``, ``pid``, ``status`` (``"running"`` or
        ``"dead"``), ``model``, ``backend``, ``channel``, and ``uptime``
        (seconds) keys.
    """
    _ensure_dirs()
    results: list[dict] = []
    agents_dir = _agents_dir()

    for path in sorted(agents_dir.glob("*.json")):
        try:
            data = json.loads(path.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        pid = data.get("pid", 0)
        alive = _is_pid_alive(pid)

        if not alive:
            _remove_agent_file(data.get("name", path.stem))

        results.append({
            "name": data.get("name", path.stem),
            "pid": pid,
            "status": "running" if alive else "dead",
            "backend": data.get("backend", "unknown"),
            "model": data.get("model", "unknown"),
            "channel": data.get("channel", "unknown"),
            "uptime": _uptime_seconds(data.get("started", "")) if alive else 0.0,
        })

    return results


def spawn_swarm(
    swarm_name: str,
    task: str,
    agents: list[dict],
) -> dict:
    """Launch a coordinated swarm of local LLM agents.

    All agents are placed on a dedicated ``#swarm-{swarm_name}`` channel.
    The task is recorded in the swarm metadata returned to the caller.

    Args:
        swarm_name: A short identifier for this swarm.
        task:       The task description for the swarm.
        agents:     List of agent configs.  Each dict should contain at
                    minimum ``"name"``; optional keys are ``"backend"``
                    (default ``"ollama"``), ``"model"`` (default
                    ``"llama3"``), ``"system_prompt"``, and ``"cwd"``
                    (default ``"."``).

    Returns:
        Dict with ``swarm``, ``task``, ``agents`` (list of spawn results),
        and ``channel`` keys.
    """
    swarm_channel = f"swarm-{swarm_name}"
    spawned: list[dict] = []

    for agent_cfg in agents:
        agent_name = agent_cfg.get("name", f"{swarm_name}-{len(spawned) + 1}")
        backend = agent_cfg.get("backend", "ollama")
        model = agent_cfg.get("model", "llama3")
        system_prompt = agent_cfg.get("system_prompt")
        cwd = agent_cfg.get("cwd", ".")

        try:
            result = spawn_agent(
                name=agent_name,
                backend=backend,
                model=model,
                channel=swarm_channel,
                system_prompt=system_prompt,
                cwd=cwd,
            )
            spawned.append(result)
        except RuntimeError as exc:
            log.warning("Swarm '%s': failed to spawn '%s': %s", swarm_name, agent_name, exc)
            spawned.append({"name": agent_name, "pid": 0, "status": "failed", "error": str(exc)})

    agent_names = [a["name"] for a in spawned if a.get("status") == "running"]
    log.info("Swarm '%s' launched -- %d agents on #%s", swarm_name, len(agent_names), swarm_channel)

    return {
        "swarm": swarm_name,
        "task": task,
        "agents": spawned,
        "channel": swarm_channel,
    }


def kill_swarm(swarm_name: str) -> int:
    """Kill all agents belonging to a swarm.

    An agent belongs to a swarm if its name starts with ``swarm_name``
    or if it is on the ``#swarm-{swarm_name}`` channel.

    Args:
        swarm_name: The swarm identifier used when calling ``spawn_swarm``.

    Returns:
        The number of agents killed.
    """
    _ensure_dirs()
    swarm_channel = f"swarm-{swarm_name}"
    killed_count = 0
    agents_dir = _agents_dir()

    for path in sorted(agents_dir.glob("*.json")):
        try:
            data = json.loads(path.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        agent_name = data.get("name", path.stem)
        agent_channel = data.get("channel", "")

        if agent_name.startswith(swarm_name) or agent_channel == swarm_channel:
            if kill_agent(agent_name):
                killed_count += 1

    if killed_count > 0:
        log.info("Swarm '%s' killed -- %d agents", swarm_name, killed_count)

    return killed_count


def restart_agent(name: str) -> dict:
    """Restart an agent by killing the old process and spawning a new one.

    Reads the agent's saved configuration, kills the existing process,
    and launches a fresh subprocess with the same parameters.

    Args:
        name: The agent name to restart.

    Returns:
        The new agent info dict (same format as ``spawn_agent``).

    Raises:
        RuntimeError: If no saved config exists for the agent.
    """
    data = _read_agent(name)
    if data is None:
        raise RuntimeError(f"No saved config for agent '{name}' -- cannot restart.")

    backend = data.get("backend", "ollama")
    model = data.get("model", "llama3")
    channel = data.get("channel", "general")
    system_prompt = data.get("system_prompt")
    cwd = data.get("cwd", ".")

    kill_agent(name)

    result = spawn_agent(
        name=name,
        backend=backend,
        model=model,
        channel=channel,
        system_prompt=system_prompt,
        cwd=cwd,
    )

    log.info("Restarted agent '%s' -- new PID %d", name, result["pid"])
    return result


def check_health() -> list[dict]:
    """Check all agents and auto-restart crashed ones.

    Reads agent metadata, checks if PIDs are alive, and restarts dead
    agents that have auto_restart=True and restart_count < max_restarts.

    Returns:
        List of status dicts, one per agent.
    """
    results = []
    for agent in list_agents():
        name = agent["name"]
        pid = agent.get("pid", 0)
        alive = _is_pid_alive(pid) if pid else False

        if alive:
            results.append({"name": name, "status": "running", "pid": pid})
            continue

        # Agent is dead — check restart policy
        data = _read_agent(name)
        if data is None:
            results.append({"name": name, "status": "dead", "pid": pid})
            continue

        auto_restart = data.get("auto_restart", False)
        restart_count = data.get("restart_count", 0)
        max_restarts = data.get("max_restarts", 3)

        if auto_restart and restart_count < max_restarts:
            try:
                # Update restart count before restarting
                data["restart_count"] = restart_count + 1
                _write_agent(data)

                result = restart_agent(name)
                log.info(
                    "Auto-restarted agent '%s' (restart %d/%d)",
                    name, restart_count + 1, max_restarts,
                )
                results.append({
                    "name": name, "status": "restarted",
                    "pid": result["pid"],
                    "restart_count": restart_count + 1,
                })
            except Exception as e:
                log.error("Failed to auto-restart '%s': %s", name, e)
                results.append({"name": name, "status": "restart_failed", "error": str(e)})
        elif auto_restart and restart_count >= max_restarts:
            log.warning("Agent '%s' exceeded max restarts (%d)", name, max_restarts)
            results.append({
                "name": name, "status": "max_restarts_exceeded",
                "restart_count": restart_count,
            })
        else:
            results.append({"name": name, "status": "dead", "pid": pid})

    return results
