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

Called by the dashboard's "Launch Agent" and "Launch Swarm" buttons when
launching LOCAL LLM agents (Ollama, LM Studio, etc. — not Claude/Gemini
terminals which open in new console windows).

Each agent runs ``brynq.agent_loop`` as a headless subprocess.  PID and
config are persisted to ``state/broker/agents/{name}.json`` so they survive
dashboard restarts and can be listed / killed / restarted later.

All stdlib — no external dependencies.

Functions:
    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
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from .server import BROKER_DIR, CHANNELS_DIR

# ── Logging ───────────────────────────────────────────────────────────────

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

# ── Paths ─────────────────────────────────────────────────────────────────

AGENTS_DIR = BROKER_DIR / "agents"


def _ensure_agents_dir() -> None:
    """Create the agents state directory if it does not exist."""
    AGENTS_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 _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_agents_dir()
    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 with signal 0 does not actually send a signal — it just
        # checks whether the process exists and we have permission to signal it.
        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":
            # On Windows, SIGTERM is not reliable for subprocesses.
            # Use taskkill /F /T to kill the process tree.
            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 _post_to_channel(channel: str, message: str, msg_type: str = "status") -> None:
    """Append a message to a broker channel's JSONL file."""
    CHANNELS_DIR.mkdir(parents=True, exist_ok=True)
    entry = {
        "id": uuid.uuid4().hex[:12],
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "session_id": "spawner",
        "session_name": "brynq-spawner",
        "platform": "spawner",
        "channel": channel,
        "msg_type": msg_type,
        "message": message,
    }
    ch_path = CHANNELS_DIR / f"{channel}.jsonl"
    try:
        with ch_path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry, default=str) + "\n")
    except OSError as exc:
        log.warning("Failed to post to #%s: %s", channel, exc)


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)
        # Handle naive timestamps by assuming 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 spawn_agent(
    name: str,
    backend: str = "ollama",
    model: str = "llama3",
    channel: str = "general",
    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 with
    no console window.

    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.
        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_agents_dir()

    # 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."
        )

    # Resolve the working directory.
    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,
    ]

    # Prepare environment — inherit current env and set PYTHONPATH so
    # brynq is importable when the subprocess starts.
    env = os.environ.copy()
    src_dir = str(Path(__file__).resolve().parents[1])
    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

    # Launch the process in the background.
    creation_flags = 0
    kwargs: dict[str, Any] = {}
    if sys.platform == "win32":
        # CREATE_NO_WINDOW prevents a console flash on Windows.
        CREATE_NO_WINDOW = 0x08000000
        creation_flags = CREATE_NO_WINDOW
        kwargs["creationflags"] = creation_flags

    proc = subprocess.Popen(
        cmd,
        cwd=resolved_cwd,
        env=env,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        stdin=subprocess.DEVNULL,
        **kwargs,
    )

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

    # Persist agent metadata.
    agent_data = {
        "name": name,
        "pid": proc.pid,
        "backend": backend,
        "model": model,
        "channel": channel,
        "cwd": resolved_cwd,
        "started": started,
    }
    _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 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
    and the task description is posted there for them to pick up.

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

    Returns:
        Dict with ``swarm``, ``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")
        cwd = agent_cfg.get("cwd", ".")

        try:
            result = spawn_agent(
                name=agent_name,
                backend=backend,
                model=model,
                channel=swarm_channel,
                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)})

    # Post the task to the swarm channel so all agents can read it.
    agent_names = [a["name"] for a in spawned if a.get("status") == "running"]
    _post_to_channel(
        swarm_channel,
        (
            f"SWARM TASK for '{swarm_name}' ({len(agent_names)} agents):\n\n"
            f"{task}\n\n"
            f"Agents: {', '.join(agent_names)}\n"
            f"Coordinate here."
        ),
        msg_type="request",
    )

    # Also announce on the main channel.
    _post_to_channel(
        "dragon-synapse",
        (
            f"Swarm '{swarm_name}' launched with {len(agent_names)} agents: "
            f"{', '.join(agent_names)}. Task: {task[:200]}"
        ),
    )

    log.info("Swarm '%s' launched — %d agents on #%s", swarm_name, len(agent_names), swarm_channel)

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


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

    Reads every ``state/broker/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_agents_dir()
    results: list[dict] = []

    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:
            # Clean up dead agent files automatically.
            _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 kill_agent(name: str) -> bool:
    """Kill a running agent by name.

    Terminates the process, removes the state file, and posts a
    notification to the broker.

    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)

    channel = data.get("channel", "dragon-synapse")
    _post_to_channel(channel, f"{name} terminated", msg_type="status")
    _post_to_channel("dragon-synapse", f"Agent '{name}' terminated (was PID {pid})")

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


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_agents_dir()
    swarm_channel = f"swarm-{swarm_name}"
    killed_count = 0

    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:
        _post_to_channel(
            "dragon-synapse",
            f"Swarm '{swarm_name}' terminated — {killed_count} agent(s) killed",
        )
        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.")

    # Capture config before killing (kill_agent removes the file).
    backend = data.get("backend", "ollama")
    model = data.get("model", "llama3")
    channel = data.get("channel", "general")
    cwd = data.get("cwd", ".")

    # Kill the old process (ignore failure — it may already be dead).
    kill_agent(name)

    # Spawn a fresh instance.
    result = spawn_agent(
        name=name,
        backend=backend,
        model=model,
        channel=channel,
        cwd=cwd,
    )

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