#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

REPO_ROOT = Path("{{REPO_ROOT}}")
RUNTIME_ROOT = Path("{{RUNTIME_ROOT}}")
HEALTHCHECK_PATH = Path("/usr/local/sbin/metin-login-healthcheck")
INCIDENT_COLLECTOR_PATH = Path("/usr/local/sbin/metin-collect-incident")
INCIDENT_ROOT = Path("/var/lib/metin/incidents")

sys.path.insert(0, str(REPO_ROOT))

import channel_inventory


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Operational CLI for the Debian Metin runtime")
    subparsers = parser.add_subparsers(dest="command", required=True)

    inventory_parser = subparsers.add_parser("inventory", help="Show declared channel inventory")
    inventory_parser.add_argument("--json", action="store_true", help="Print raw JSON")

    subparsers.add_parser("units", help="List managed systemd units")

    status_parser = subparsers.add_parser("status", help="Show current unit state")
    status_parser.add_argument("target", nargs="?", default="all", help="stack, db, auth, game, channel:<id>, instance:<name>")

    ports_parser = subparsers.add_parser("ports", help="Show declared listener ports")
    ports_parser.add_argument("--live", action="store_true", help="Also show whether the port is currently listening")

    for action in ("start", "stop", "restart"):
        action_parser = subparsers.add_parser(action, help=f"{action.title()} a managed target")
        action_parser.add_argument("target", help="stack, db, auth, game, channel:<id>, instance:<name>")

    logs_parser = subparsers.add_parser("logs", help="Show journalctl logs for a managed target")
    logs_parser.add_argument("target", help="stack, db, auth, game, channel:<id>, instance:<name>")
    logs_parser.add_argument("-n", "--lines", type=int, default=100, help="Number of journal lines")
    logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow the journal")

    cores_parser = subparsers.add_parser("cores", help="List core files under the runtime tree")
    cores_parser.add_argument("--json", action="store_true", help="Print raw JSON")

    incidents_parser = subparsers.add_parser("incidents", help="List collected incident bundles")
    incidents_parser.add_argument("--limit", type=int, default=10, help="Maximum number of bundles to show")

    incident_collect = subparsers.add_parser("incident-collect", help="Collect an incident bundle")
    incident_collect.add_argument("--tag", default="manual", help="Short incident tag")
    incident_collect.add_argument("--since", default="-30 minutes", help="journalctl --since value")
    incident_collect.add_argument("--include-cores", action="store_true", help="Copy matching core files into the bundle")

    subparsers.add_parser("healthcheck", help="Run the root-only headless healthcheck")
    return parser.parse_args()


def build_command(command: list[str], require_root: bool = False) -> list[str]:
    if require_root and os.geteuid() != 0:
        return ["sudo", *command]
    return command


def run(command: list[str], require_root: bool = False, capture_output: bool = False, check: bool = True) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        build_command(command, require_root=require_root),
        check=check,
        capture_output=capture_output,
        text=True,
    )


def get_unit_state(unit: str) -> tuple[str, str, str]:
    active = run(["systemctl", "is-active", unit], capture_output=True, check=False).stdout.strip() or "unknown"
    enabled = run(["systemctl", "is-enabled", unit], capture_output=True, check=False).stdout.strip() or "unknown"
    sub_state = run(["systemctl", "show", unit, "--property=SubState", "--value"], capture_output=True, check=False).stdout.strip() or "-"
    return active, sub_state, enabled


def print_table(headers: list[str], rows: list[list[str]]) -> None:
    widths = [len(header) for header in headers]
    for row in rows:
        for index, value in enumerate(row):
            widths[index] = max(widths[index], len(value))

    header_line = "  ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
    print(header_line)
    print("  ".join("-" * widths[index] for index in range(len(headers))))
    for row in rows:
        print("  ".join(value.ljust(widths[index]) for index, value in enumerate(row)))


def iter_port_rows() -> list[dict[str, str]]:
    rows = [
        {
            "scope": "db",
            "name": "db",
            "port": str(channel_inventory.get_db()["port"]),
            "p2p_port": "-",
            "unit": channel_inventory.DB_UNIT,
            "visibility": "internal",
        },
        {
            "scope": "auth",
            "name": "auth",
            "port": str(channel_inventory.get_auth()["port"]),
            "p2p_port": str(channel_inventory.get_auth()["p2p_port"]),
            "unit": channel_inventory.AUTH_UNIT,
            "visibility": "public",
        },
    ]

    for channel in channel_inventory.iter_channels():
        channel_id = int(channel["id"])
        visibility = "public" if channel.get("public") else "internal"
        for core in sorted(channel["cores"], key=lambda item: int(item["id"])):
            core_id = int(core["id"])
            instance = channel_inventory.instance_name(channel_id, core_id)
            rows.append(
                {
                    "scope": f"channel:{channel_id}",
                    "name": instance,
                    "port": str(core["port"]),
                    "p2p_port": str(core["p2p_port"]),
                    "unit": channel_inventory.game_unit(instance),
                    "visibility": visibility,
                }
            )

    return rows


def iter_core_files() -> list[Path]:
    return [path for path in sorted(RUNTIME_ROOT.glob("channels/**/core*")) if path.is_file()]


def live_ports() -> set[int]:
    if shutil.which("ss") is None:
        return set()

    completed = run(["ss", "-ltnH"], capture_output=True, check=True)
    ports: set[int] = set()
    for line in completed.stdout.splitlines():
        fields = line.split()
        if len(fields) < 4:
            continue
        local = fields[3]
        if ":" not in local:
            continue
        try:
            ports.add(int(local.rsplit(":", 1)[1]))
        except ValueError:
            continue
    return ports


def print_inventory(as_json: bool) -> int:
    if as_json:
        print(json.dumps(channel_inventory.load_inventory(), indent=2))
        return 0

    rows: list[list[str]] = []
    for channel in channel_inventory.iter_channels():
        channel_id = int(channel["id"])
        ports = ",".join(str(core["port"]) for core in sorted(channel["cores"], key=lambda item: int(item["id"])))
        rows.append(
            [
                str(channel_id),
                channel["name"],
                "yes" if channel.get("public") else "no",
                "yes" if channel.get("client_visible") else "no",
                str(len(channel["cores"])),
                ports,
            ]
        )

    print_table(["channel", "name", "public", "visible", "cores", "ports"], rows)
    return 0


def print_units() -> int:
    rows = [
        ["stack", channel_inventory.STACK_UNIT],
        ["db", channel_inventory.DB_UNIT],
        ["db-ready", channel_inventory.DB_READY_UNIT],
        ["auth", channel_inventory.AUTH_UNIT],
    ]
    for unit in channel_inventory.get_game_units():
        rows.append(["game", unit])
    print_table(["kind", "unit"], rows)
    return 0


def resolve_target_units(target: str) -> list[str]:
    normalized = target.strip().lower()

    if normalized in {"all", "stack", "server"}:
        return [channel_inventory.STACK_UNIT]
    if normalized == "db":
        return [channel_inventory.DB_UNIT]
    if normalized in {"db-ready", "db_ready"}:
        return [channel_inventory.DB_READY_UNIT]
    if normalized == "auth":
        return [channel_inventory.AUTH_UNIT]
    if normalized in {"game", "games"}:
        return channel_inventory.get_game_units()
    if normalized.startswith("channel:"):
        channel_id = int(normalized.split(":", 1)[1])
        return channel_inventory.get_game_units([channel_id])
    if normalized.startswith("instance:"):
        return [channel_inventory.game_unit(target.split(":", 1)[1])]
    if normalized.startswith("channel") and "_core" in normalized:
        return [channel_inventory.game_unit(target)]
    raise SystemExit(f"Unknown target: {target}")


def print_status(target: str) -> int:
    if target == "all":
        units = [
            channel_inventory.STACK_UNIT,
            channel_inventory.DB_UNIT,
            channel_inventory.DB_READY_UNIT,
            channel_inventory.AUTH_UNIT,
            *channel_inventory.get_game_units(),
        ]
    else:
        units = resolve_target_units(target)

    rows: list[list[str]] = []
    for unit in units:
        active, sub_state, enabled = get_unit_state(unit)
        rows.append([unit, active, sub_state, enabled])
    print_table(["unit", "active", "sub", "enabled"], rows)
    return 0


def print_ports(show_live: bool) -> int:
    listening = live_ports() if show_live else set()
    headers = ["scope", "name", "port", "p2p", "visibility", "unit"]
    if show_live:
        headers.append("live")
    rows: list[list[str]] = []
    for row in iter_port_rows():
        values = [row["scope"], row["name"], row["port"], row["p2p_port"], row["visibility"], row["unit"]]
        if show_live:
            values.append("yes" if int(row["port"]) in listening else "no")
        rows.append(values)
    print_table(headers, rows)
    return 0


def print_cores(as_json: bool) -> int:
    entries = []
    for path in iter_core_files():
        stat = path.stat()
        entries.append(
            {
                "path": str(path),
                "relative_path": str(path.relative_to(RUNTIME_ROOT)),
                "size_bytes": stat.st_size,
                "mtime_epoch": int(stat.st_mtime),
            }
        )

    if as_json:
        print(json.dumps(entries, indent=2))
        return 0

    if not entries:
        print("No core files found under the runtime tree.")
        return 0

    rows = [[entry["relative_path"], str(entry["size_bytes"]), str(entry["mtime_epoch"])] for entry in entries]
    print_table(["path", "size_bytes", "mtime_epoch"], rows)
    return 0


def print_incidents(limit: int) -> int:
    if not INCIDENT_ROOT.exists():
        print(f"No incident directory: {INCIDENT_ROOT}")
        return 0

    bundles = sorted((path for path in INCIDENT_ROOT.iterdir() if path.is_dir()), reverse=True)[:limit]
    if not bundles:
        print(f"No incident bundles in {INCIDENT_ROOT}")
        return 0

    rows = [[bundle.name, str(bundle)] for bundle in bundles]
    print_table(["bundle", "path"], rows)
    return 0


def run_unit_action(action: str, target: str) -> int:
    units = resolve_target_units(target)
    run(["systemctl", action, *units], require_root=True)
    return 0


def run_logs(target: str, lines: int, follow: bool) -> int:
    units = resolve_target_units(target)
    command = ["journalctl", "--no-pager", f"-n{lines}"]
    for unit in units:
        command.extend(["-u", unit])
    if follow:
        command = ["journalctl", f"-n{lines}", "-f", *sum((["-u", unit] for unit in units), [])]
    run(command, require_root=True)
    return 0


def run_healthcheck() -> int:
    if not HEALTHCHECK_PATH.exists():
        raise SystemExit(f"Missing healthcheck wrapper: {HEALTHCHECK_PATH}")
    run([str(HEALTHCHECK_PATH)], require_root=True)
    return 0


def run_incident_collect(tag: str, since: str, include_cores: bool) -> int:
    if not INCIDENT_COLLECTOR_PATH.exists():
        raise SystemExit(f"Missing incident collector: {INCIDENT_COLLECTOR_PATH}")

    command = [str(INCIDENT_COLLECTOR_PATH), "--tag", tag, "--since", since]
    if include_cores:
        command.append("--include-cores")
    run(command, require_root=True)
    return 0


def main() -> int:
    args = parse_args()

    if args.command == "inventory":
        return print_inventory(args.json)
    if args.command == "units":
        return print_units()
    if args.command == "status":
        return print_status(args.target)
    if args.command == "ports":
        return print_ports(args.live)
    if args.command == "cores":
        return print_cores(args.json)
    if args.command == "incidents":
        return print_incidents(args.limit)
    if args.command in {"start", "stop", "restart"}:
        return run_unit_action(args.command, args.target)
    if args.command == "logs":
        return run_logs(args.target, args.lines, args.follow)
    if args.command == "incident-collect":
        return run_incident_collect(args.tag, args.since, args.include_cores)
    if args.command == "healthcheck":
        return run_healthcheck()
    raise SystemExit(f"Unsupported command: {args.command}")


if __name__ == "__main__":
    raise SystemExit(main())
