"""Core logic for the ``mld dev`` command.

Starts plugin backend + optional frontend dev servers with hot reload.
With ``--platform``, also starts the platform and configures dev proxy.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - Python < 3.11 fallback
    import tomli as tomllib  # type: ignore[no-redef]

from mld_sdk._discover import discover_plugin
from mld_sdk.cli import _detect_package_manager


def _build_backend_cmd(factory_target: str, host: str, port: int) -> list[str]:
    """Build the uvicorn command for running a plugin backend."""
    return [
        "uv", "run", "uvicorn", factory_target,
        "--factory", "--reload", "--host", host, "--port", str(port),
    ]


def _build_frontend_cmd(frontend_dir: Path) -> list[str] | None:
    """Build the dev server command for a frontend directory.

    Returns None if no package.json exists.
    """
    if not (frontend_dir / "package.json").exists():
        return None
    _name, cmd = _detect_package_manager(frontend_dir)
    return [cmd, "run", "dev"]


def _find_platform_dir(start: Path) -> Path | None:
    """Locate the platform directory as a sibling of *start*.

    Walks the parent directory of *start* looking for a directory that
    contains ``api/main.py`` (the platform entrypoint).
    """
    workspace = start.parent
    # Check the conventional name first
    candidate = workspace / "mld"
    if (candidate / "api" / "main.py").exists():
        return candidate

    # Fallback: scan siblings
    try:
        for child in workspace.iterdir():
            if child == start or not child.is_dir():
                continue
            if (child / "api" / "main.py").exists():
                return child
    except OSError:
        pass
    return None


def _load_toml(path: Path) -> dict[str, Any] | None:
    """Load TOML config into a dict, returning None if parsing fails."""
    if not path.exists():
        return {}
    try:
        return tomllib.loads(path.read_text())
    except Exception as exc:
        print(
            f"Warning: could not parse {path}: {exc}",
            file=sys.stderr,
        )
        return None


def _toml_key(key: str) -> str:
    """Render a TOML key, quoting non-bare keys like '/drp'."""
    if re.match(r"^[A-Za-z0-9_-]+$", key):
        return key
    return json.dumps(key)


def _toml_scalar(value: Any) -> str:
    """Serialize a scalar TOML value."""
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return str(value)
    if isinstance(value, str):
        return json.dumps(value)
    if isinstance(value, list) and all(not isinstance(item, (dict, list)) for item in value):
        return "[" + ", ".join(_toml_scalar(item) for item in value) + "]"
    raise TypeError(f"Unsupported TOML scalar value: {value!r}")


def _is_array_of_tables(value: Any) -> bool:
    return isinstance(value, list) and all(isinstance(item, dict) for item in value)


def _dump_toml_table(
    lines: list[str],
    table_data: dict[str, Any],
    path: tuple[str, ...],
) -> None:
    """Serialize a TOML table body recursively."""
    # Scalars first
    for key, value in table_data.items():
        if isinstance(value, dict) or _is_array_of_tables(value):
            continue
        lines.append(f"{_toml_key(key)} = {_toml_scalar(value)}")

    # Child tables
    for key, value in table_data.items():
        if not isinstance(value, dict):
            continue
        if lines and lines[-1] != "":
            lines.append("")
        table_path = path + (key,)
        lines.append(f"[{'.'.join(_toml_key(part) for part in table_path)}]")
        _dump_toml_table(lines, value, table_path)

    # Arrays of tables
    for key, value in table_data.items():
        if not _is_array_of_tables(value):
            continue
        table_path = path + (key,)
        for item in value:
            if lines and lines[-1] != "":
                lines.append("")
            lines.append(f"[[{'.'.join(_toml_key(part) for part in table_path)}]]")
            _dump_toml_table(lines, item, table_path)


def _dump_toml(data: dict[str, Any]) -> str:
    """Serialize config dict to TOML while preserving structured data."""
    lines: list[str] = []
    _dump_toml_table(lines, data, ())
    return ("\n".join(lines).rstrip() + "\n") if lines else ""


def _write_dev_proxy_config(platform_dir: Path, prefix: str, port: int) -> None:
    """Write or update ``config.dev.toml`` with a proxy entry for *prefix*."""
    config_path = platform_dir / "config.dev.toml"
    config = _load_toml(config_path)
    if config is None:
        return

    proxy = config.get("proxy")
    if not isinstance(proxy, dict):
        proxy = {}

    target = f"http://localhost:{port}"
    existing_entry = proxy.get(prefix)
    if isinstance(existing_entry, dict):
        # Preserve metadata (name/version/nav_items/...) while updating target.
        updated_entry = dict(existing_entry)
        updated_entry["target"] = target
        proxy[prefix] = updated_entry
    else:
        proxy[prefix] = target

    config["proxy"] = proxy
    config_path.write_text(_dump_toml(config))


def _cleanup_dev_proxy_config(platform_dir: Path, prefix: str) -> None:
    """Remove *prefix* from ``config.dev.toml``, deleting the file if empty."""
    config_path = platform_dir / "config.dev.toml"
    if not config_path.exists():
        return

    config = _load_toml(config_path)
    if config is None:
        return

    proxy = config.get("proxy")
    if isinstance(proxy, dict):
        proxy.pop(prefix, None)
        if proxy:
            config["proxy"] = proxy
        else:
            config.pop("proxy", None)

    if not config:
        config_path.unlink(missing_ok=True)
        return

    config_path.write_text(_dump_toml(config))


def _resolve_platform_dir(project_dir: Path, platform_dir_arg: str | None) -> Path | None:
    """Resolve the platform directory from CLI arg or sibling auto-discovery."""
    if platform_dir_arg:
        candidate = Path(platform_dir_arg).expanduser().resolve()
        return candidate if (candidate / "api" / "main.py").exists() else None
    return _find_platform_dir(project_dir)


def _run_processes(commands: list[tuple[str, list[str], Path]]) -> None:
    """Run multiple processes in parallel, blocking until interrupted.

    Each entry in *commands* is ``(label, argv, cwd)``.
    Sends SIGTERM on shutdown; escalates to SIGKILL after 5 seconds.
    """
    procs: dict[str, subprocess.Popen[bytes]] = {}
    shutdown = False

    def _on_signal(signum: int, _frame: object) -> None:
        nonlocal shutdown
        shutdown = True

    prev_sigint = signal.signal(signal.SIGINT, _on_signal)
    prev_sigterm = signal.signal(signal.SIGTERM, _on_signal)

    try:
        for label, cmd, cwd in commands:
            proc = subprocess.Popen(cmd, cwd=cwd, start_new_session=True)
            procs[label] = proc

        # Poll until shutdown or critical process exits
        while not shutdown:
            for label, proc in list(procs.items()):
                ret = proc.poll()
                if ret is not None:
                    if "backend" in label.lower():
                        print(
                            f"\n{label} exited with code {ret}. Shutting down.",
                            file=sys.stderr,
                        )
                        shutdown = True
                        break
                    else:
                        print(
                            f"\nWarning: {label} exited with code {ret}.",
                            file=sys.stderr,
                        )
                        del procs[label]
                        break
            if not procs:
                break
            time.sleep(0.5)
    finally:
        signal.signal(signal.SIGINT, prev_sigint)
        signal.signal(signal.SIGTERM, prev_sigterm)

        # Graceful shutdown: SIGTERM all process groups
        for label, proc in procs.items():
            if proc.poll() is None:
                try:
                    os.killpg(proc.pid, signal.SIGTERM)
                except OSError:
                    pass

        # Wait up to 5 seconds, then SIGKILL survivors
        deadline = time.monotonic() + 5
        for label, proc in procs.items():
            remaining = max(0, deadline - time.monotonic())
            try:
                proc.wait(timeout=remaining)
            except subprocess.TimeoutExpired:
                try:
                    os.killpg(proc.pid, signal.SIGKILL)
                except OSError:
                    pass


def cmd_dev(args: argparse.Namespace) -> None:
    """Execute the ``mld dev`` command."""
    project_dir = Path.cwd()
    plugin = discover_plugin(project_dir)

    print(f"Discovered plugin: {plugin.class_name} ({plugin.module_path})")

    prefix = getattr(args, "prefix", None) or plugin.routes_prefix
    host = getattr(args, "host", "127.0.0.1")
    port = int(getattr(args, "port", 8003))
    no_frontend = getattr(args, "no_frontend", False)
    platform_mode = getattr(args, "platform", False)
    platform_dir_arg = getattr(args, "platform_dir", None)

    commands: list[tuple[str, list[str], Path]] = []

    # Plugin backend (always)
    backend_cmd = _build_backend_cmd(plugin.factory_target, host, port)
    commands.append(("Plugin backend", backend_cmd, project_dir))

    # Plugin frontend (if present and not skipped)
    frontend_port = 5175
    if not no_frontend and plugin.has_frontend:
        frontend_dir = project_dir / "frontend"
        fe_cmd = _build_frontend_cmd(frontend_dir)
        if fe_cmd is not None:
            commands.append(("Plugin frontend", fe_cmd, frontend_dir))

    platform_dir: Path | None = None
    if platform_mode:
        platform_dir = _resolve_platform_dir(project_dir, platform_dir_arg)
        if platform_dir is None:
            print(
                "Error: Could not find platform directory. "
                "Provide --platform-dir or use a sibling directory containing api/main.py.",
                file=sys.stderr,
            )
            sys.exit(1)

        _write_dev_proxy_config(platform_dir, prefix, port)

        platform_backend = [
            "uv", "run", "uvicorn", "api.main:app",
            "--reload", "--port", "8001",
        ]
        commands.append(("Platform backend", platform_backend, platform_dir))

        platform_fe_dir = platform_dir / "frontend"
        platform_fe_cmd = _build_frontend_cmd(platform_fe_dir)
        if platform_fe_cmd is not None:
            commands.append(("Platform frontend", platform_fe_cmd, platform_fe_dir))

    # Print startup summary
    print()
    if platform_mode:
        print(f"  Plugin backend   http://{host}:{port}/api{prefix}")
        if not no_frontend and plugin.has_frontend:
            print(f"  Plugin frontend  http://localhost:{frontend_port}")
        print(f"  Platform         http://localhost:8001")
        print(f"  Proxy            {prefix} -> http://localhost:{port}")
    else:
        print(f"  Backend   http://{host}:{port}/api{prefix}")
        if not no_frontend and plugin.has_frontend:
            print(f"  Frontend  http://localhost:{frontend_port}")
    print()
    print("Press Ctrl+C to stop.")
    print()

    try:
        _run_processes(commands)
    finally:
        if platform_dir is not None:
            _cleanup_dev_proxy_config(platform_dir, prefix)
