#!/usr/bin/env python3
"""Render homelab service catalog artifacts from homelab.yml."""

from __future__ import annotations

import argparse
import html
import pathlib
import re
import sys


REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
INVENTORY = REPO_ROOT / "homelab.yml"
DOC_OUTPUT = REPO_ROOT / "docs" / "service-catalog.md"
HTML_OUTPUT = REPO_ROOT / "apps" / "demos-static" / "public" / "homelab-catalog" / "index.html"


def parse_inventory(path: pathlib.Path) -> dict[str, object]:
    values: dict[str, object] = {}
    stack: list[tuple[int, str]] = []
    current_list_path: str | None = None
    pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$")

    with path.open(encoding="utf-8") as handle:
        for raw_line in handle:
            stripped = raw_line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            if stripped.startswith("- "):
                if current_list_path:
                    if not isinstance(values.get(current_list_path), list):
                        values[current_list_path] = []
                    values[current_list_path].append(stripped[2:].strip().strip("\"'"))
                continue

            match = pattern.match(raw_line.rstrip("\n"))
            if not match:
                current_list_path = None
                continue

            indent = len(match.group(1))
            key = match.group(2)
            value = (match.group(3) or "").strip()
            while stack and stack[-1][0] >= indent:
                stack.pop()
            path_key = ".".join([item[1] for item in stack] + [key])
            if value == "":
                stack.append((indent, key))
                values.setdefault(path_key, {})
                current_list_path = path_key
                continue
            if " #" in value:
                value = value.split(" #", 1)[0].strip()
            values[path_key] = value.strip("\"'")
            current_list_path = None

    return values


def value(values: dict[str, object], path: str, default: str = "") -> str:
    raw_value = values.get(path, default)
    if isinstance(raw_value, list):
        return ", ".join(str(item) for item in raw_value)
    if isinstance(raw_value, dict):
        return default
    return str(raw_value)


def host_ip(values: dict[str, object], host_name: str) -> str:
    if host_name == "kubernetes":
        return value(values, "services.traefik.load_balancer_ip")
    return value(values, f"hosts.{host_name}.lan_ip")


def host_label(values: dict[str, object], host_name: str) -> str:
    if host_name == "kubernetes":
        return "Kubernetes"
    ip = host_ip(values, host_name)
    role = value(values, f"hosts.{host_name}.role")
    if ip and role:
        return f"{host_name} ({role}, {ip})"
    if ip:
        return f"{host_name} ({ip})"
    return host_name


def service_rows(values: dict[str, object]) -> list[dict[str, str]]:
    domain = value(values, "domain.base")
    public_url = value(values, "domain.public_url")
    debian_ip = value(values, "hosts.debian.lan_ip")
    rpi_ip = value(values, "hosts.rpi4.lan_ip")
    traefik_ip = value(values, "network.metallb.traefik_ip")
    registry = value(values, "services.local_registry.endpoint")

    rows = [
        {
            "name": "Website",
            "host": "kubernetes",
            "managed_by": "Argo CD / apps/website",
            "local": f"http://{traefik_ip}/",
            "public": public_url,
            "docs": "apps/website/README.md",
        },
        {
            "name": "Demo Apps",
            "host": "kubernetes",
            "managed_by": "Argo CD / apps/demos-static",
            "local": f"http://{traefik_ip}/demo-apps/",
            "public": f"https://demos.{domain}/",
            "docs": "apps/demos-static/",
        },
        {
            "name": "Service Catalog",
            "host": "kubernetes",
            "managed_by": "Generated from homelab.yml",
            "local": f"http://{traefik_ip}/demo-apps/homelab-catalog/",
            "public": f"https://demos.{domain}/homelab-catalog/",
            "docs": "docs/service-catalog.md",
        },
        {
            "name": "Gitea",
            "host": value(values, "services.gitea.host", "debian"),
            "managed_by": "Docker Compose / jeannie deploy-gitea",
            "local": f"http://{debian_ip}:{value(values, 'services.gitea.http_port', '3000')}/",
            "public": value(values, "services.gitea.root_url"),
            "docs": "infra/gitea/README.md",
        },
        {
            "name": "GitOps mirror",
            "host": value(values, "services.gitops_mirror.host", "debian"),
            "managed_by": "Bare Git repository",
            "local": value(values, "services.gitops_mirror.path"),
            "public": value(values, "services.gitops_mirror.ssh_url"),
            "docs": "README.md#gitops-and-gitea-actions",
        },
        {
            "name": "Container Registry",
            "host": value(values, "services.local_registry.host", "debian"),
            "managed_by": "Argo CD / apps/container-registry",
            "local": f"http://{registry}/v2/_catalog",
            "public": "",
            "docs": "apps/container-registry/",
        },
        {
            "name": "Traefik",
            "host": "kubernetes",
            "managed_by": "OpenTofu / bootstrap/platform",
            "local": f"http://{traefik_ip}/",
            "public": public_url,
            "docs": "README.md#loadbalancer-services",
        },
        {
            "name": "Pi-hole",
            "host": "rpi4",
            "managed_by": "Docker Compose / jeannie rpi-services",
            "local": f"http://{rpi_ip}:{value(values, 'services.rpi_dns.pihole_web_port', '8081')}/admin/",
            "public": "",
            "docs": "infra/rpi-services/README.md",
        },
        {
            "name": "Unbound",
            "host": "rpi4",
            "managed_by": "Docker Compose / jeannie rpi-services",
            "local": f"{rpi_ip}:53",
            "public": "",
            "docs": "infra/rpi-services/README.md",
        },
        {
            "name": "Uptime Kuma",
            "host": "rpi4",
            "managed_by": "Docker Compose / jeannie rpi-services",
            "local": f"http://{rpi_ip}:{value(values, 'services.rpi_dns.uptime_kuma_port', '3001')}/",
            "public": "",
            "docs": "infra/rpi-services/README.md",
        },
        {
            "name": "Provisioning PXE/HTTP",
            "host": "debian",
            "managed_by": "OpenTofu / bootstrap/provisioning",
            "local": f"http://{debian_ip}:{value(values, 'services.provisioning.http_port', '8088')}/",
            "public": "",
            "docs": "bootstrap/provisioning/README.md",
        },
        {
            "name": "PXE Rescue Toolkit",
            "host": "debian",
            "managed_by": "OpenTofu / bootstrap/provisioning",
            "local": value(values, "services.provisioning.rescue_url"),
            "public": "",
            "docs": "bootstrap/provisioning/README.md",
        },
        {
            "name": "Ollama",
            "host": "debian",
            "managed_by": "Local Debian service",
            "local": value(values, "services.ollama.url"),
            "public": "",
            "docs": "apps/website/ollama/README.md",
        },
        {
            "name": "OCI Edge",
            "host": "oci_edge",
            "managed_by": "OpenTofu / bootstrap/edge",
            "local": value(values, "hosts.oci_edge.install_dir"),
            "public": public_url,
            "docs": "docs/runbooks/edge-failures.md",
        },
        {
            "name": "Heimdall",
            "host": "kubernetes",
            "managed_by": "Argo CD / apps/heimdall",
            "local": f"http://{traefik_ip}/",
            "public": f"https://heimdall.{domain}/",
            "docs": "apps/heimdall/",
        },
        {
            "name": "n8n",
            "host": "kubernetes",
            "managed_by": "Argo CD / apps/n8n",
            "local": f"http://{traefik_ip}/",
            "public": f"https://n8n.{domain}/",
            "docs": "apps/n8n/",
        },
        {
            "name": "Argo CD",
            "host": "kubernetes",
            "managed_by": "OpenTofu / bootstrap/platform",
            "local": f"http://{traefik_ip}/",
            "public": f"https://argocd.{domain}/",
            "docs": "bootstrap/platform/",
        },
        {
            "name": "Grafana",
            "host": "kubernetes",
            "managed_by": "OpenTofu / bootstrap/platform",
            "local": f"http://{traefik_ip}/",
            "public": f"https://grafana.{domain}/",
            "docs": "bootstrap/platform/",
        },
        {
            "name": "Prometheus",
            "host": "kubernetes",
            "managed_by": "OpenTofu / bootstrap/platform",
            "local": f"http://{traefik_ip}/",
            "public": f"https://prometheus.{domain}/",
            "docs": "bootstrap/platform/",
        },
        {
            "name": "Alertmanager",
            "host": "kubernetes",
            "managed_by": "OpenTofu / bootstrap/platform",
            "local": f"http://{traefik_ip}/",
            "public": f"https://alertmanager.{domain}/",
            "docs": "bootstrap/platform/",
        },
    ]

    for service_name in [
        "prowlarr",
        "sonarr",
        "radarr",
        "qbittorrent",
        "kapowarr",
        "suwayomi",
        "maintainerr",
        "flaresolverr",
    ]:
        display_name = {
            "qbittorrent": "qBittorrent",
            "suwayomi": "Suwayomi",
            "maintainerr": "Maintainerr",
            "flaresolverr": "FlareSolverr",
        }.get(service_name, service_name.capitalize())
        port = value(values, f"services.arr.{service_name}_port")
        rows.append(
            {
                "name": display_name,
                "host": value(values, "services.arr.host", "debian"),
                "managed_by": "Docker Compose / infra/arr-stack",
                "local": f"http://{debian_ip}:{port}/" if port else "",
                "public": f"https://{service_name}.{domain}/",
                "docs": "infra/arr-stack/README.md",
            }
        )

    return rows


def host_rows(values: dict[str, object]) -> list[dict[str, str]]:
    rows = []
    for host in ["debian", "rpi4", "opi5_pimox", "oci_edge"]:
        rows.append(
            {
                "name": host,
                "role": value(values, f"hosts.{host}.role"),
                "lan": value(values, f"hosts.{host}.lan_ip") or value(values, f"hosts.{host}.public_ip"),
                "tailscale": value(values, f"hosts.{host}.tailscale_ip"),
                "storage": value(values, f"hosts.{host}.docker_root")
                or value(values, f"hosts.{host}.docker_nvme_root")
                or value(values, f"hosts.{host}.worker_storage"),
            }
        )
    return rows


def markdown_table(headers: list[str], rows: list[list[str]]) -> str:
    lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
    for row in rows:
        lines.append("| " + " | ".join(cell.replace("\n", " ") for cell in row) + " |")
    return "\n".join(lines)


def md_link(label: str, target: str) -> str:
    if not target:
        return ""
    if target.startswith(("http://", "https://", "ssh://")):
        return f"[{label}]({target})"
    return f"`{target}`"


def render_markdown(values: dict[str, object]) -> str:
    hosts = host_rows(values)
    services = service_rows(values)
    host_table = markdown_table(
        ["Host", "Role", "Address", "Tailscale", "Storage"],
        [[row["name"], row["role"], row["lan"], row["tailscale"], row["storage"]] for row in hosts],
    )
    service_table = markdown_table(
        ["Service", "Host", "Managed By", "Local", "Public", "Docs"],
        [
            [
                row["name"],
                host_label(values, row["host"]),
                row["managed_by"],
                md_link("local", row["local"]),
                md_link("public", row["public"]),
                md_link("docs", row["docs"]),
            ]
            for row in services
        ],
    )
    return f"""# Homelab Service Catalog

<!-- Generated by scripts/render-service-catalog from homelab.yml. Do not edit by hand. -->

This catalog is the human-readable view of the non-secret homelab inventory.
Update `homelab.yml`, then run `scripts/render-service-catalog`.

## Hosts

{host_table}

## Services

{service_table}

## Focused Checks

- `./jeannie status`
- `./jeannie preflight`
- `./jeannie doctor-edge`
- `./jeannie doctor-gitea`
- `./jeannie doctor-rpi`
- `./jeannie doctor-cluster`
"""


def html_link(target: str) -> str:
    if not target:
        return '<span class="muted">not exposed</span>'
    escaped = html.escape(target)
    if target.startswith(("http://", "https://", "ssh://")):
        return f'<a href="{escaped}">{escaped}</a>'
    return f"<code>{escaped}</code>"


def render_html(values: dict[str, object]) -> str:
    hosts = host_rows(values)
    services = service_rows(values)
    host_cards = "\n".join(
        f"""        <article class="catalog-card service-host">
          <span>{html.escape(row["role"])}</span>
          <h2>{html.escape(row["name"])}</h2>
          <p><strong>Address:</strong> {html.escape(row["lan"] or "n/a")}</p>
          <p><strong>Tailscale:</strong> {html.escape(row["tailscale"] or "n/a")}</p>
          <p><strong>Storage:</strong> {html.escape(row["storage"] or "n/a")}</p>
        </article>"""
        for row in hosts
    )
    service_rows_html = "\n".join(
        f"""          <tr>
            <td>{html.escape(row["name"])}</td>
            <td>{html.escape(host_label(values, row["host"]))}</td>
            <td>{html.escape(row["managed_by"])}</td>
            <td>{html_link(row["local"])}</td>
            <td>{html_link(row["public"])}</td>
            <td>{html_link(row["docs"])}</td>
          </tr>"""
        for row in services
    )
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Homelab Service Catalog</title>
  <link rel="stylesheet" href="../shared.css">
</head>
<body class="theme-dark">
  <main class="shell catalog-shell">
    <nav class="topline">
      <a href="../">All demos</a>
      <a href="/demos.php">Website catalog</a>
    </nav>

    <section class="hero catalog-hero">
      <p class="kicker">Inventory view</p>
      <h1>Homelab Service Catalog</h1>
      <p>Generated from <code>homelab.yml</code>. Use this as the operational map for hosts, ports, public routes, local URLs, ownership, and runbooks.</p>
    </section>

    <section class="grid host-grid" aria-label="Homelab hosts">
{host_cards}
    </section>

    <section class="panel service-table-panel" aria-label="Homelab services">
      <div class="table-scroll">
        <table class="service-table">
          <thead>
            <tr>
              <th>Service</th>
              <th>Host</th>
              <th>Managed by</th>
              <th>Local</th>
              <th>Public</th>
              <th>Docs</th>
            </tr>
          </thead>
          <tbody>
{service_rows_html}
          </tbody>
        </table>
      </div>
    </section>

    <section class="panel command-panel">
      <h2>Focused Checks</h2>
      <p><code>./jeannie status</code> <code>./jeannie preflight</code> <code>./jeannie doctor-edge</code> <code>./jeannie doctor-gitea</code> <code>./jeannie doctor-rpi</code> <code>./jeannie doctor-cluster</code></p>
    </section>
  </main>
  <script src="../theme.js"></script>
</body>
</html>
"""


def write_if_changed(path: pathlib.Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


def check_file(path: pathlib.Path, content: str) -> bool:
    if not path.exists():
        return False
    return path.read_text(encoding="utf-8") == content


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--check", action="store_true", help="fail if generated catalog artifacts are stale")
    args = parser.parse_args()

    values = parse_inventory(INVENTORY)
    markdown = render_markdown(values)
    catalog_html = render_html(values)

    if args.check:
        stale = []
        if not check_file(DOC_OUTPUT, markdown):
            stale.append(str(DOC_OUTPUT.relative_to(REPO_ROOT)))
        if not check_file(HTML_OUTPUT, catalog_html):
            stale.append(str(HTML_OUTPUT.relative_to(REPO_ROOT)))
        if stale:
            print("Generated service catalog artifacts are stale:", file=sys.stderr)
            for path in stale:
                print(f"  {path}", file=sys.stderr)
            return 1
        print("service catalog is current")
        return 0

    write_if_changed(DOC_OUTPUT, markdown)
    write_if_changed(HTML_OUTPUT, catalog_html)
    print(f"Rendered {DOC_OUTPUT.relative_to(REPO_ROOT)}")
    print(f"Rendered {HTML_OUTPUT.relative_to(REPO_ROOT)}")
    return 0


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