from dataclasses import dataclass
import json
from pathlib import Path

from sqlalchemy import select

from l4d2web.db import session_scope
from l4d2web.models import (
    Blueprint,
    BlueprintOverlay,
    Overlay,
    OverlayWorkshopItem,
    Server,
    User,
    WorkshopItem,
)
from l4d2web.services import host_commands
from l4d2web.services.server_identity import server_unit_name
from l4d2web.services.spec_yaml import write_temp_spec
from l4d2web.services.workshop_paths import cache_path


@dataclass(slots=True)
class ServerStatus:
    state: str
    raw_active_state: str
    raw_sub_state: str


def build_server_spec_payload(
    server: Server,
    blueprint: Blueprint,
    overlay_rows: list[tuple[int, str, bool]],
    *,
    resolved_hostname: str = "",
) -> dict:
    overlays: list[dict] = []
    for overlay_id, path, expose in overlay_rows:
        if expose:
            overlays.append({"path": path, "alias": f"overlay_{overlay_id}"})
        else:
            overlays.append({"path": path})
    # Source `exec` is last-wins. First list entry = topmost overlay = highest
    # precedence, so its exec runs LAST. Emit in reverse position order.
    exec_lines = [
        f"exec server_overlay_{overlay_id}"
        for overlay_id, _, expose in reversed(overlay_rows)
        if expose
    ]
    config_lines: list[str] = exec_lines + json.loads(blueprint.config)
    if resolved_hostname:
        config_lines.append(f'hostname "{resolved_hostname}"')
    # rcon_password is appended LAST so neither overlays, blueprint config,
    # nor hostname can override it (Source's cvar semantics are last-wins).
    if server.rcon_password:
        config_lines.append(f'rcon_password "{server.rcon_password}"')
    return {
        "port": server.port,
        "overlays": overlays,
        "arguments": json.loads(blueprint.arguments),
        "config": config_lines,
    }


def load_server_blueprint_bundle(
    server_id: int,
) -> tuple[Server, Blueprint, list[tuple[int, str, bool]]]:
    with session_scope() as db:
        server = db.scalar(select(Server).where(Server.id == server_id))
        if server is None:
            raise ValueError("server not found")

        blueprint = db.scalar(select(Blueprint).where(Blueprint.id == server.blueprint_id))
        if blueprint is None:
            raise ValueError("blueprint not found")

        rows = db.execute(
            select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg)
            .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
            .where(BlueprintOverlay.blueprint_id == blueprint.id)
            .order_by(BlueprintOverlay.position)
        ).all()
        overlay_rows = [(int(i), str(p), bool(e)) for i, p, e in rows]
        return server, blueprint, overlay_rows


def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None:
    host_commands.run_command(
        ["l4d2ctl", "install"],
        on_stdout=on_stdout,
        on_stderr=on_stderr,
        should_cancel=should_cancel,
    )


def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
    server, blueprint, overlay_rows = load_server_blueprint_bundle(server_id)

    # Builders are NOT run here. Overlays rebuild from their own save/build
    # flows; doing it on every Start is expensive and redundant.

    # Workshop overlays may have items not yet downloaded. Fail fast rather
    # than mount a partial overlay (would silently leave maps missing in-game).
    _check_workshop_overlay_caches(blueprint_id=blueprint.id)

    # Resolve hostname — explicit override or ephemeral fallback
    if server.hostname:
        resolved_hostname = server.hostname
    else:
        with session_scope() as db:
            user = db.get(User, server.user_id)
            resolved_hostname = f"{user.username} {server.name}"

    spec_path = write_temp_spec(build_server_spec_payload(
        server, blueprint, overlay_rows, resolved_hostname=resolved_hostname,
    ))
    try:
        host_commands.run_command(
            ["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)],
            on_stdout=on_stdout,
            on_stderr=on_stderr,
            should_cancel=should_cancel,
        )
    finally:
        spec_path.unlink(missing_ok=True)


def _run_blueprint_builders(
    *,
    blueprint_id: int,
    on_stdout=None,
    on_stderr=None,
    should_cancel=None,
) -> None:
    """Synchronously invoke each overlay's builder for the given blueprint."""
    from l4d2web.services.overlay_builders import BUILDERS

    with session_scope() as db:
        overlays = db.scalars(
            select(Overlay)
            .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
            .where(BlueprintOverlay.blueprint_id == blueprint_id)
            .order_by(BlueprintOverlay.position)
        ).all()
        for overlay in overlays:
            db.expunge(overlay)

    log_stdout = on_stdout if on_stdout is not None else (lambda _line: None)
    log_stderr = on_stderr if on_stderr is not None else (lambda _line: None)
    cancel = should_cancel if should_cancel is not None else (lambda: False)

    for overlay in overlays:
        builder = BUILDERS.get(overlay.type)
        if builder is None:
            raise ValueError(f"no builder registered for overlay type {overlay.type!r}")
        builder.build(
            overlay,
            on_stdout=log_stdout,
            on_stderr=log_stderr,
            should_cancel=cancel,
        )


def _check_workshop_overlay_caches(*, blueprint_id: int) -> None:
    """Raise if any workshop overlay attached to this blueprint has items
    that aren't yet in the workshop_cache. Mounting a partial overlay would
    leave maps mysteriously missing in-game; surface the issue here instead.
    """
    with session_scope() as db:
        rows = db.execute(
            select(Overlay.id, Overlay.name, WorkshopItem.steam_id)
            .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
            .join(
                OverlayWorkshopItem,
                OverlayWorkshopItem.overlay_id == Overlay.id,
            )
            .join(
                WorkshopItem,
                WorkshopItem.id == OverlayWorkshopItem.workshop_item_id,
            )
            .where(
                BlueprintOverlay.blueprint_id == blueprint_id,
                Overlay.type == "workshop",
            )
        ).all()

    missing: dict[tuple[int, str], list[str]] = {}
    for overlay_id, overlay_name, steam_id in rows:
        if not cache_path(steam_id).exists():
            missing.setdefault((overlay_id, overlay_name), []).append(steam_id)

    if not missing:
        return

    parts = []
    for (overlay_id, overlay_name), steam_ids in missing.items():
        ids = ", ".join(steam_ids)
        parts.append(
            f"overlay {overlay_name!r} (id={overlay_id}): items {ids} not yet downloaded"
        )
    detail = "; ".join(parts)
    raise RuntimeError(
        f"workshop content missing — {detail}. "
        f"Open the overlay page and click Build (or wait for the auto-rebuild job), "
        f"then retry."
    )


def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
    # Always initialize before starting so blueprint edits and overlay rebuilds
    # take effect on the next start without a manual two-step.
    initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel)
    server, _, _ = load_server_blueprint_bundle(server_id)
    host_commands.run_command(
        ["l4d2ctl", "start", server_unit_name(server.id)],
        on_stdout=on_stdout,
        on_stderr=on_stderr,
        should_cancel=should_cancel,
    )


def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
    server, _, _ = load_server_blueprint_bundle(server_id)
    host_commands.run_command(
        ["l4d2ctl", "stop", server_unit_name(server.id)],
        on_stdout=on_stdout,
        on_stderr=on_stderr,
        should_cancel=should_cancel,
    )


def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
    server, _, _ = load_server_blueprint_bundle(server_id)
    host_commands.run_command(
        ["l4d2ctl", "delete", server_unit_name(server.id)],
        on_stdout=on_stdout,
        on_stderr=on_stderr,
        should_cancel=should_cancel,
    )


def reset_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
    server, _, _ = load_server_blueprint_bundle(server_id)
    host_commands.run_command(
        ["l4d2ctl", "reset", server_unit_name(server.id)],
        on_stdout=on_stdout,
        on_stderr=on_stderr,
        should_cancel=should_cancel,
    )


def server_status(unit_name: str) -> ServerStatus:
    result = host_commands.run_command(["l4d2ctl", "status", unit_name, "--json"])
    payload = json.loads(result.stdout or "{}")
    return ServerStatus(
        state=str(payload.get("state", "unknown")),
        raw_active_state=str(payload.get("raw_active_state", "unknown")),
        raw_sub_state=str(payload.get("raw_sub_state", "unknown")),
    )


def stream_server_logs(unit_name: str, *, lines: int = 200, follow: bool = True):
    command = ["l4d2ctl", "logs", unit_name, "--lines", str(lines)]
    command.append("--follow" if follow else "--no-follow")
    return host_commands.stream_command(command)
