#!/usr/bin/env python3
"""Генерация HTML-отчёта по Ansible-фактам хостов для GitLab Pages.

Использование:
    python3 scripts/generate_report.py

Ожидает JSON-файлы в host_facts/<hostname>.json.
Результат записывается в public/ (GitLab Pages).
"""

import ipaddress
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path

import yaml

FACTS_DIR = Path("host_facts")
VMWARE_FACTS_DIR = Path("vmware_facts")
OUTPUT_DIR = Path("public")
SERVICE_MAP_FILE = Path("service_map.yml")
SERVICES_DIR = Path("services")
HISTORY_FILE = Path("connection_history.json")

# Локальные сети — внешние подключения из этих диапазонов группируются по подсети
_LOCAL_NETWORKS: list = [
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/16"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("100.64.0.0/10"),
    ipaddress.ip_network("91.218.84.0/22"),
    ipaddress.ip_network("91.236.140.0/22"),
]

BOOTSTRAP_CSS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
GENERATED_AT = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")


# ── Загрузка данных ────────────────────────────────────────────────────────────

def load_vmware_facts() -> dict:
    """Загружает VMware факты из директории vmware_facts/."""
    data: dict = {"esxi_hosts": {}, "vms": [], "datastores": []}
    if not VMWARE_FACTS_DIR.exists():
        return data

    for f in sorted(VMWARE_FACTS_DIR.glob("esxi_*.json")):
        try:
            hostname = f.stem[5:]  # убираем префикс "esxi_"
            data["esxi_hosts"][hostname] = json.loads(f.read_text(encoding="utf-8"))
        except Exception as exc:
            print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)

    vms_file = VMWARE_FACTS_DIR / "vms.json"
    if vms_file.exists():
        try:
            data["vms"] = json.loads(vms_file.read_text(encoding="utf-8"))
        except Exception as exc:
            print(f"Предупреждение: не удалось прочитать {vms_file}: {exc}", file=sys.stderr)

    ds_file = VMWARE_FACTS_DIR / "datastores.json"
    if ds_file.exists():
        try:
            data["datastores"] = json.loads(ds_file.read_text(encoding="utf-8"))
        except Exception as exc:
            print(f"Предупреждение: не удалось прочитать {ds_file}: {exc}", file=sys.stderr)

    return data


def load_facts(facts_dir: Path) -> dict:
    hosts = {}
    for f in sorted(facts_dir.glob("*.json")):
        try:
            hosts[f.stem] = json.loads(f.read_text(encoding="utf-8"))
        except Exception as exc:
            print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)
    return hosts


def load_service_map() -> list[dict]:
    """Загружает статическую карту зависимостей из service_map.yml."""
    if not SERVICE_MAP_FILE.exists():
        return []
    try:
        data = yaml.safe_load(SERVICE_MAP_FILE.read_text(encoding="utf-8")) or {}
        result = []
        for item in data.get("dependencies", []) or []:
            frm = item.get("from", {})
            to  = item.get("to", {})
            if not frm.get("host") or not to.get("host"):
                continue
            result.append({
                "from_host":    frm.get("host", ""),
                "from_service": frm.get("service", ""),
                "to_host":      to.get("host", ""),
                "to_service":   to.get("service", ""),
                "port":         item.get("port", ""),
                "protocol":     item.get("protocol", "tcp"),
                "description":  item.get("description", ""),
                "source":       "static",
            })
        return result
    except Exception as exc:
        print(f"Предупреждение: не удалось прочитать {SERVICE_MAP_FILE}: {exc}", file=sys.stderr)
        return []


def load_global_services() -> list[dict]:
    """Загружает описания глобальных сервисов из директории services/."""
    if not SERVICES_DIR.exists():
        return []
    result = []
    for f in sorted(SERVICES_DIR.glob("*.yml")) :
        try:
            data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
            if not data.get("name") or not data.get("components"):
                continue
            result.append({
                "slug":        f.stem,
                "name":        data["name"],
                "description": data.get("description", ""),
                "components":  data["components"],
                "dep_refs":    data.get("dep_refs"),
            })
        except Exception as exc:
            print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)
    for f in sorted(SERVICES_DIR.glob("*.yaml")):
        try:
            data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
            if not data.get("name") or not data.get("components"):
                continue
            result.append({
                "slug":        f.stem,
                "name":        data["name"],
                "description": data.get("description", ""),
                "components":  data["components"],
                "dep_refs":    data.get("dep_refs"),
            })
        except Exception as exc:
            print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)
    return result


# ── Вспомогательные функции ────────────────────────────────────────────────────

def fmt_bytes(b: int) -> str:
    for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"):
        if b < 1024:
            return f"{b:.1f} {unit}"
        b /= 1024
    return f"{b:.1f} ПБ"


def get_os(f: dict) -> str:
    dist = f.get("distribution", "")
    ver = f.get("distribution_version", "")
    return f"{dist} {ver}".strip() or "—"


def get_ipv4(f: dict) -> str:
    return f.get("default_ipv4", {}).get("address", "—")


def get_ram(f: dict) -> str:
    mb = f.get("memtotal_mb", 0)
    return f"{mb / 1024:.1f} ГБ" if mb >= 1024 else f"{mb} МБ"


def get_uptime(f: dict) -> str:
    secs = int(f.get("uptime_seconds", 0))
    days, secs = divmod(secs, 86400)
    hours = secs // 3600
    return f"{days}д {hours}ч"


def get_disk_root(f: dict) -> tuple:
    """Возвращает (строка_использования, процент_заполнения)."""
    for m in f.get("mounts", []):
        if m.get("mount") == "/":
            total = int(m.get("size_total", 0))
            avail = int(m.get("size_available", 0))
            if total == 0:
                return "—", 0
            pct = int((total - avail) / total * 100)
            return f"{(total - avail) // 1024**3}Г / {total // 1024**3}Г", pct
    return "—", 0


def os_badge(os_str: str) -> str:
    if "CentOS" in os_str:
        color = "danger"
    elif "Debian" in os_str:
        color = "primary"
    else:
        color = "secondary"
    return f'<span class="badge bg-{color}">{os_str}</span>'


def disk_badge(pct: int) -> str:
    color = "danger" if pct > 85 else "warning" if pct > 70 else "success"
    return f'<span class="badge bg-{color} ms-1">{pct}%</span>'


def parse_docker_containers(f: dict) -> list[dict]:
    """Извлекает данные о запущенных Docker-контейнерах из фактов хоста."""
    result = []
    for c in f.get("containers", []):
        name = c.get("Name", "").lstrip("/")
        image = c.get("Config", {}).get("Image", "—")

        # Порты: NetworkSettings.Ports → {"80/tcp": [{"HostIp": "...", "HostPort": "..."}]}
        ports_dict = c.get("NetworkSettings", {}).get("Ports", {})
        port_parts = []
        for container_port, bindings in sorted(ports_dict.items()):
            if bindings:
                for b in bindings:
                    host_ip = b.get("HostIp", "")
                    host_port = b.get("HostPort", "")
                    prefix = f"{host_ip}:" if host_ip and host_ip not in ("0.0.0.0", "::") else ""
                    port_parts.append(f"{prefix}{host_port}→{container_port}")
            else:
                port_parts.append(container_port)
        ports_str = ", ".join(port_parts) or "—"

        # Volumes: Mounts → [{"Type": "...", "Source": "...", "Destination": "..."}]
        volume_parts = []
        for m in c.get("Mounts", []):
            src = m.get("Source", "")
            dst = m.get("Destination", "")
            volume_parts.append(f"{src}:{dst}" if src else dst)
        volumes_str = ", ".join(volume_parts) or "—"

        result.append({"name": name, "image": image, "ports": ports_str, "volumes": volumes_str})
    return result


def parse_ports(raw: str) -> list[dict]:
    """Разбирает вывод ss -tlnup в список словарей."""
    ports = []
    for line in raw.splitlines():
        parts = line.split()
        # пропускаем заголовок и короткие строки
        if len(parts) < 5 or parts[0] in ("Netid", "State"):
            continue
        proto = parts[0]
        state = parts[1]
        local = parts[4]
        proc_info = " ".join(parts[6:]) if len(parts) > 6 else ""
        m = re.search(r'users:\(\("([^"]+)"', proc_info)
        process = m.group(1) if m else "—"
        m_pid = re.search(r'pid=(\d+)', proc_info)
        pid = int(m_pid.group(1)) if m_pid else None
        # разбираем адрес:порт с учётом IPv6 [::]:port
        m6 = re.match(r"\[([^\]]+)\]:(\d+|\*)", local)
        if m6:
            addr, port = m6.group(1), m6.group(2)
        elif ":" in local:
            addr, port = local.rsplit(":", 1)
        else:
            addr, port = local, "—"
        ports.append({"proto": proto, "state": state, "addr": addr, "port": port, "process": process, "pid": pid})

    return sorted(ports, key=lambda x: (x["proto"], int(x["port"]) if x["port"].isdigit() else 0))


# ── Зависимости между службами ─────────────────────────────────────────────────

def build_ip_to_host(hosts: dict) -> dict[str, str]:
    """Строит маппинг IP-адрес → имя хоста из собранных фактов."""
    mapping: dict[str, str] = {}
    for hostname, f in hosts.items():
        ip = f.get("default_ipv4", {}).get("address", "")
        if ip:
            mapping[ip] = hostname
        for iface in f.get("interfaces", []):
            iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "")
            if iface_ip and iface_ip not in mapping:
                mapping[iface_ip] = hostname
    return mapping


def _parse_addr_port(s: str) -> tuple[str, str]:
    """Разбирает строку вида addr:port или [ipv6]:port."""
    m6 = re.match(r"\[([^\]]+)\]:(\d+)$", s)
    if m6:
        return m6.group(1), m6.group(2)
    if ":" in s:
        addr, port = s.rsplit(":", 1)
        return addr, port
    return s, "—"


def parse_connections(raw: str) -> list[dict]:
    """Разбирает вывод ss -tnp state established в список соединений.

    Поддерживает оба формата iproute2:
      - со столбцом State:  ESTAB  0  0  local:port  peer:port  [Process]
      - без столбца State:  0  0  local:port  peer:port  [Process]
    """
    conns = []
    for line in raw.splitlines():
        parts = line.split()
        if len(parts) < 4:
            continue
        # Определяем смещение: первая колонка — State-слово или число (Recv-Q)
        if parts[0] in ("ESTAB", "CLOSE-WAIT", "TIME-WAIT", "SYN-SENT"):
            offset = 1   # State Recv-Q Send-Q Local Peer [Process]
        elif parts[0].isdigit():
            offset = 0   # Recv-Q Send-Q Local Peer [Process]
        else:
            continue     # заголовок или неизвестный формат
        if len(parts) < offset + 4:
            continue
        local     = parts[offset + 2]
        peer      = parts[offset + 3]
        proc_info = " ".join(parts[offset + 4:]) if len(parts) > offset + 4 else ""
        m = re.search(r'users:\(\("([^"]+)"', proc_info)
        process = m.group(1) if m else "—"
        m_pid = re.search(r'pid=(\d+)', proc_info)
        pid = int(m_pid.group(1)) if m_pid else None
        peer_addr, peer_port = _parse_addr_port(peer)
        local_addr, local_port = _parse_addr_port(local)
        conns.append({
            "local_addr": local_addr,
            "local_port": local_port,
            "peer_addr":  peer_addr,
            "peer_port":  peer_port,
            "process":    process,
            "pid":        pid,
        })
    return conns


def parse_ps_map(raw: str) -> dict:
    """Строит маппинг PID → понятное имя процесса из вывода ps -eo pid,args.

    Для Java-процессов извлекает -Dapp.name=XXX (BGBillingServer, BGInetAccess и т.д.).
    Для ActiveMQ определяет по классу запуска.
    Для остальных процессов возвращает базовое имя исполняемого файла.
    """
    result: dict[int, str] = {}
    for line in raw.splitlines():
        parts = line.split(None, 1)
        if len(parts) < 2 or not parts[0].isdigit():
            continue
        pid = int(parts[0])
        args = parts[1]
        m = re.search(r'-Dapp\.name=(\S+)', args)
        if m:
            result[pid] = m.group(1)
            continue
        if 'org.apache.activemq' in args:
            result[pid] = 'activemq'
            continue
        exe = args.split()[0]
        result[pid] = os.path.basename(exe)
    return result


def _resolve_proc(process: str, pid, ps_map: dict) -> str:
    """Возвращает понятное имя процесса, используя ps_map если доступен PID."""
    if pid is not None and pid in ps_map:
        return ps_map[pid]
    return process


_CT_INCLUDE_STATES = {"ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT"}

# Слова-состояния TCP в conntrack (отличают TCP-строку от UDP-строки)
_CT_TCP_STATES = {
    "ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT", "SYN_SENT", "SYN_RECV",
    "FIN_WAIT", "LAST_ACK", "NONE",
}


def parse_conntrack(raw: str) -> list[dict]:
    """Разбирает вывод conntrack -L (TCP и UDP).

    Формат TCP: tcp  6  58  TIME_WAIT  src=A dst=B sport=P dport=Q  src=... ...
    Формат UDP: udp  17  28  src=A dst=B sport=P dport=Q  src=... ...

    Возвращает список dict с ключами:
      local_addr, local_port, peer_addr, peer_port, process, ct_state, proto.
    Поле process всегда '—'; обогащается позже через корреляцию с ports_raw.
    Для TCP принимаются только состояния из _CT_INCLUDE_STATES.
    Для UDP состояние отсутствует, принимаются все записи.
    """
    result = []
    for line in raw.splitlines():
        parts = line.split()
        if len(parts) < 4:
            continue
        proto = parts[0]
        if proto not in ("tcp", "udp"):
            continue

        if proto == "tcp":
            # parts[3] — слово состояния (ESTABLISHED, TIME_WAIT, …)
            if len(parts) < 5:
                continue
            state = parts[3]
            if state not in _CT_INCLUDE_STATES:
                continue
            field_start = 4
        else:
            # UDP: нет слова состояния; parts[3] начинается с src= или другого key=val
            state = ""
            field_start = 3

        fields: dict[str, str] = {}
        for part in parts[field_start:]:
            if "=" in part and "[" not in part:
                k, v = part.split("=", 1)
                if k not in fields:  # только первое вхождение — original direction
                    fields[k] = v

        src = fields.get("src", "—")
        dst = fields.get("dst", "—")
        sport = fields.get("sport", "—")
        dport = fields.get("dport", "—")
        if src == "—" or dst == "—":
            continue

        result.append({
            "local_addr": dst,
            "local_port": dport,
            "peer_addr":  src,
            "peer_port":  sport,
            "process":    "—",
            "ct_state":   state,
            "proto":      proto,
        })
    return result


def detect_auto_deps(hosts: dict, ip_to_host: dict) -> list[dict]:
    """Находит зависимости между хостами по активным TCP-соединениям.

    Логика:
      1. Только соединения, где peer_port является СЛУШАЮЩИМ портом на хосте-назначении.
         Это отсекает "зеркальные" строки (db-2 видит billing-main:38518 — эфемерный порт)
         и оставляет только инициирующую сторону (billing-main → db-2:3306).
      2. Сопоставляет процессы: хост A видит java→B:3306, хост B видит mysqld←A:ephemeral.
      3. Группирует по (from_host, from_service, to_host, to_service, port) — без эфемерных портов.
    """
    # Шаг 1: индекс слушающих портов и серверных соединений для каждого хоста
    host_listening:  dict[str, frozenset] = {}
    host_srv_index:  dict[str, dict]      = {}
    host_to_ips:     dict[str, set]       = {}
    host_ps_map:     dict[str, dict]      = {}

    for hostname, f in hosts.items():
        ips: set = set()
        ip = f.get("default_ipv4", {}).get("address", "")
        if ip:
            ips.add(ip)
        for iface in f.get("interfaces", []):
            iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "")
            if iface_ip:
                ips.add(iface_ip)
        host_to_ips[hostname] = ips
        host_listening[hostname] = _get_listening_ports(f)
        host_ps_map[hostname] = parse_ps_map(f.get("ps_raw", ""))

        # (local_port, peer_addr, peer_port) → (process, pid)  — для поиска серверного процесса
        index: dict = {}
        for conn in parse_connections(f.get("connections_raw", "")):
            lp = conn["local_port"]
            if lp.isdigit():
                key = (lp, conn["peer_addr"], conn["peer_port"])
                index[key] = (conn["process"], conn.get("pid"))
        host_srv_index[hostname] = index

    # Шаг 2: обходим соединения, оставляем только те, где peer_port слушает на to_host
    counts: dict = {}  # (from_host, from_proc, to_host, to_proc, port) → кол-во соед.
    for hostname, f in hosts.items():
        ps_map = host_ps_map[hostname]
        for conn in parse_connections(f.get("connections_raw", "")):
            pp = conn["peer_port"]
            lp = conn["local_port"]
            if not pp.isdigit():
                continue
            port = int(pp)
            to_host = ip_to_host.get(conn["peer_addr"])
            if not to_host or to_host == hostname:
                continue

            # Фильтр: peer_port должен быть слушающим портом на to_host.
            # Если нет — это эфемерный порт (ответный трафик), пропускаем.
            if port not in host_listening.get(to_host, frozenset()):
                continue

            client_proc = _resolve_proc(conn["process"], conn.get("pid"), ps_map)

            # Ищем серверный процесс по зеркальной записи на to_host
            server_proc = "—"
            srv_index = host_srv_index.get(to_host, {})
            to_ps_map = host_ps_map.get(to_host, {})
            for my_ip in host_to_ips.get(hostname, set()):
                candidate = srv_index.get((pp, my_ip, lp))
                if candidate:
                    srv_proc_name, srv_pid = candidate
                    server_proc = _resolve_proc(srv_proc_name, srv_pid, to_ps_map)
                    break

            key = (hostname, client_proc, to_host, server_proc, port)
            counts[key] = counts.get(key, 0) + 1

    return [
        {
            "from_host":    fh,
            "from_service": fp,
            "to_host":      th,
            "to_service":   tp,
            "port":         p,
            "count":        c,
            "source":       "auto",
        }
        for (fh, fp, th, tp, p), c in sorted(counts.items())
    ]


def _classify_external_ip(addr: str) -> tuple[str, str]:
    """Классифицирует внешний IP-адрес.

    Возвращает (тип, подсеть):
      ('local',  '10.0.0.0/8')      — адрес входит в одну из локальных сетей
      ('remote', '')                 — публичный интернет-адрес
    """
    try:
        ip = ipaddress.ip_address(addr)
    except ValueError:
        return "remote", ""
    for net in _LOCAL_NETWORKS:
        if ip in net:
            return "local", str(net)
    return "remote", ""


def _get_listening_ports(f: dict) -> frozenset:
    """Возвращает множество портов, на которых хост слушает (из ports_raw)."""
    result = set()
    for p in parse_ports(f.get("ports_raw", "")):
        if p["port"].isdigit():
            result.add(int(p["port"]))
    return frozenset(result)


def _ip_sort_key(ip: str):
    """Ключ сортировки IP-адресов (IPv4 числовой, IPv6 лексикографический)."""
    try:
        return (0, int(ipaddress.ip_address(ip)))
    except ValueError:
        return (1, ip)


def detect_external_deps(hosts: dict, ip_to_host: dict) -> list[dict]:
    """Находит входящие соединения от хостов не из проекта.

    Для каждого сервера из инвентаря смотрит его соединения, где:
      - local_port является слушающим портом этого хоста (хост — сервер)
      - peer_addr НЕ входит в ip_to_host (источник — внешний)

    Источники данных: ss (TCP ESTABLISHED) + conntrack -L (TCP и UDP, если доступен).
    Conntrack расширяет охват: TCP TIME_WAIT/CLOSE_WAIT и все UDP-соединения.
    Каждая запись соответствует одному уникальному IP-адресу источника.

    Результат:
      - ext_type='local'  → источник в локальных подсетях (_LOCAL_NETWORKS)
      - ext_type='remote' → публичный интернет
      - historical=True   → пир из connection_history.json, не активен сейчас
    """
    counts: dict = {}    # key → int
    ct_states: dict = {}  # key → лучший ct_state ('ESTABLISHED' предпочтительнее 'TIME_WAIT')

    for hostname, f in hosts.items():
        listening = _get_listening_ports(f)

        # Маппинг порт→процесс для обогащения conntrack-записей
        port_to_proc: dict = {}
        for p in parse_ports(f.get("ports_raw", "")):
            if p["process"] != "—" and p["port"].isdigit():
                port_to_proc[p["port"]] = p["process"]

        # Источник 1: ss (имена процессов есть)
        conns_ss = parse_connections(f.get("connections_raw", ""))

        # Источник 2: conntrack (обогащаем именами процессов через порты)
        conns_ct = parse_conntrack(f.get("conntrack_raw", ""))
        for c in conns_ct:
            if c["process"] == "—" and c["local_port"] in port_to_proc:
                c["process"] = port_to_proc[c["local_port"]]

        # Дедупликация: убираем из conntrack то, что ss уже показывает
        ss_seen = {(c["local_port"], c["peer_addr"], c["peer_port"]) for c in conns_ss}
        conns_ct_new = [
            c for c in conns_ct
            if (c["local_port"], c["peer_addr"], c["peer_port"]) not in ss_seen
        ]

        all_conns = conns_ss + conns_ct_new

        for conn in all_conns:
            lp = conn["local_port"]
            if not lp.isdigit():
                continue
            local_port_int = int(lp)
            # Хост является сервером только если local_port слушает
            if local_port_int not in listening:
                continue
            peer_addr = conn["peer_addr"]
            # Пропускаем известные хосты из проекта
            if peer_addr in ip_to_host:
                continue
            ext_type, subnet = _classify_external_ip(peer_addr)
            proto = conn.get("proto", "tcp")
            key = (hostname, local_port_int, conn["process"], proto, ext_type, subnet, peer_addr)
            counts[key] = counts.get(key, 0) + 1
            # Предпочитаем '' (ss/ESTABLISHED) над TIME_WAIT
            ct = conn.get("ct_state", "")
            if key not in ct_states or ct in ("", "ESTABLISHED"):
                ct_states[key] = ct

    current_keys: set = set()
    result = []
    for (th, port, proc, proto, etype, subnet, addr), c in sorted(counts.items()):
        key = (th, port, proc, proto, etype, subnet, addr)
        current_keys.add(key)
        entry: dict = {
            "to_host":    th,
            "to_port":    port,
            "to_service": proc,
            "proto":      proto,
            "ext_type":   etype,
            "subnet":     subnet,
            "ip":         addr,
            "count":      c,
        }
        ct = ct_states.get(key, "")
        if ct:
            entry["ct_state"] = ct
        result.append(entry)

    # Добавляем исторические пиры из connection_history.json
    if HISTORY_FILE.exists():
        try:
            history = json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
            for peer in history.get("peers", []):
                if peer.get("ext_type") != "local":
                    continue
                peer_proto = peer.get("proto", "tcp")
                hkey = (
                    peer["to_host"], peer["to_port"], peer.get("to_service", "—"),
                    peer_proto, "local", peer["subnet"], peer["peer_addr"],
                )
                if hkey in current_keys:
                    continue
                result.append({
                    "to_host":    peer["to_host"],
                    "to_port":    peer["to_port"],
                    "to_service": peer.get("to_service", "—"),
                    "proto":      peer_proto,
                    "ext_type":   "local",
                    "subnet":     peer["subnet"],
                    "ip":         peer["peer_addr"],
                    "count":      0,
                    "historical": True,
                    "last_seen":  peer.get("last_seen", ""),
                })
        except Exception as exc:
            print(f"Предупреждение: не удалось прочитать {HISTORY_FILE}: {exc}", file=sys.stderr)

    return result


def detect_intra_deps(hosts: dict) -> list[dict]:
    """Находит зависимости между службами внутри одного хоста по loopback TCP-соединениям.

    Для каждого хоста ищет соединения, где peer_addr — это loopback-адрес (127.0.0.1, ::1)
    или собственный IP интерфейса, а peer_port является слушающим портом того же хоста.
    Серверный процесс определяется по зеркальной строке из того же connections_raw.
    Результат группируется по (host, client_service, server_service, port).
    """
    result = []

    for hostname, f in hosts.items():
        # Собираем все "свои" IP-адреса хоста (loopback + все интерфейсы)
        own_ips: set = {"127.0.0.1", "::1"}
        ip = f.get("default_ipv4", {}).get("address", "")
        if ip:
            own_ips.add(ip)
        for iface in f.get("interfaces", []):
            iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "")
            if iface_ip:
                own_ips.add(iface_ip)

        listening = _get_listening_ports(f)
        connections = parse_connections(f.get("connections_raw", ""))
        ps_map = parse_ps_map(f.get("ps_raw", ""))

        # Индекс серверной стороны: (local_port, peer_addr, peer_port) → (process, pid)
        # Используется для поиска серверного процесса по зеркальной строке соединения
        srv_index: dict = {}
        for conn in connections:
            lp = conn["local_port"]
            if lp.isdigit():
                key = (lp, conn["peer_addr"], conn["peer_port"])
                srv_index[key] = (conn["process"], conn.get("pid"))

        # (client_proc, server_proc, port) → количество соединений
        counts: dict = {}
        for conn in connections:
            pp = conn["peer_port"]
            lp = conn["local_port"]
            if not pp.isdigit():
                continue
            # Только соединения к собственным адресам хоста
            if conn["peer_addr"] not in own_ips:
                continue
            port = int(pp)
            # Peer_port должен быть слушающим портом — отсекаем эфемерные порты
            if port not in listening:
                continue
            client_proc = _resolve_proc(conn["process"], conn.get("pid"), ps_map)
            # Ищем серверный процесс по зеркальной записи
            srv_raw, srv_pid = srv_index.get((pp, conn["local_addr"], lp), ("—", None))
            server_proc = _resolve_proc(srv_raw, srv_pid, ps_map)
            key = (client_proc, server_proc, port)
            counts[key] = counts.get(key, 0) + 1

        for (cp, sp, port), cnt in sorted(counts.items()):
            result.append({
                "host":           hostname,
                "client_service": cp,
                "server_service": sp,
                "port":           port,
                "count":          cnt,
            })

    return result


def _node_id(hostname: str) -> str:
    """Возвращает безопасный идентификатор Mermaid-узла для имени хоста."""
    return "h_" + re.sub(r"[^a-zA-Z0-9]", "_", hostname)


def host_deps_mermaid(hostname: str, deps: list, ext_deps: list | None = None) -> str:
    """Строит Mermaid-диаграмму зависимостей для одного хоста.

    Текущий хост выделен стилем; рёбра подписаны входящим портом.
    Внешние локальные подключения — зелёный прямоугольник (subgraph).
    Внешние удалённые подключения — оранжевый прямоугольник (subgraph).
    """
    ext_deps = ext_deps or []
    if not deps and not ext_deps:
        return ""

    # рёбра: (from_node_id, to_node_id) → labels[]
    edges: dict = {}
    for d in deps:
        fid  = _node_id(d["from_host"])
        tid  = _node_id(d["to_host"])
        port = str(d.get("port", "")) if d.get("port") else ""
        proto = d.get("protocol", "tcp")
        label = f"{port}/{proto}" if port and d.get("source") == "static" else port
        key = (fid, tid, d["from_host"], d["to_host"])
        edges.setdefault(key, [])
        if label and label not in edges[key]:
            edges[key].append(label)

    all_hosts = sorted(
        {d["from_host"] for d in deps} | {d["to_host"] for d in deps} | {hostname}
    )

    lines = ["graph LR"]
    for h in all_hosts:
        nid = _node_id(h)
        if h == hostname:
            lines.append(f'  {nid}["{h}"]')
            lines.append(f'  style {nid} fill:#0d6efd,color:#fff,stroke:#0a58ca')
        else:
            lines.append(f'  {nid}["{h}"]')
    for (fid, tid, _fh, _th), labels in edges.items():
        label = ", ".join(labels)
        if label:
            lines.append(f'  {fid} -->|"{label}"| {tid}')
        else:
            lines.append(f'  {fid} --> {tid}')

    host_nid = _node_id(hostname)

    # Внешние локальные подключения — зелёный прямоугольник
    local_ext = [d for d in ext_deps if d["ext_type"] == "local"]
    if local_ext:
        subnet_ports: dict = {}
        for d in local_ext:
            subnet_ports.setdefault(d["subnet"], set()).add(str(d["to_port"]))
        lines.append('  subgraph ext_local_group ["Внешние локальные"]')
        lines.append("  style ext_local_group fill:#d4edda,stroke:#28a745,color:#155724")
        for sn in sorted(subnet_ports):
            sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
            lines.append(f'    {sn_id}["{sn}"]')
        lines.append("  end")
        for sn, ports in sorted(subnet_ports.items()):
            sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
            port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0))
            lines.append(f'  {sn_id} -->|"{port_label}"| {host_nid}')

    # Внешние удалённые подключения — оранжевый прямоугольник
    remote_ext = [d for d in ext_deps if d["ext_type"] == "remote"]
    if remote_ext:
        remote_ports: set = set()
        for d in remote_ext:
            remote_ports.add(str(d["to_port"]))
        lines.append('  subgraph ext_remote_group ["Внешние удалённые"]')
        lines.append("  style ext_remote_group fill:#fff3cd,stroke:#fd7e14,color:#856404")
        lines.append('    ext_internet["Интернет"]')
        lines.append("  end")
        port_label = ", ".join(sorted(remote_ports, key=lambda x: int(x) if x.isdigit() else 0))
        lines.append(f'  ext_internet -->|"{port_label}"| {host_nid}')

    return "\n".join(lines)


def deps_mermaid(static_deps: list, auto_deps: list, ext_deps: list | None = None) -> str:
    """Строит текст Mermaid-диаграммы зависимостей (graph LR).

    Внешние локальные подключения — зелёный subgraph по подсетям.
    Внешние удалённые подключения — оранжевый subgraph с узлом «Интернет».
    """
    ext_deps = ext_deps or []

    # рёбра: (from_node_id, to_node_id) → {fh, th, labels[]}
    edges: dict = {}

    # На диаграмме отображаем только входящий порт (порт сервера-получателя)
    for d in static_deps:
        fid, tid = _node_id(d["from_host"]), _node_id(d["to_host"])
        port, proto = d.get("port", ""), d.get("protocol", "tcp")
        label = f"{port}/{proto}" if port else "—"
        key = (fid, tid)
        edges.setdefault(key, {"fh": d["from_host"], "th": d["to_host"], "labels": []})
        if label not in edges[key]["labels"]:
            edges[key]["labels"].append(str(label))

    for d in auto_deps:
        fid, tid = _node_id(d["from_host"]), _node_id(d["to_host"])
        label = str(d["port"])
        key   = (fid, tid)
        edges.setdefault(key, {"fh": d["from_host"], "th": d["to_host"], "labels": []})
        if label not in edges[key]["labels"]:
            edges[key]["labels"].append(label)

    if not edges and not ext_deps:
        return ""

    all_hosts = sorted(
        {v["fh"] for v in edges.values()} | {v["th"] for v in edges.values()}
    )
    lines = ["graph LR"]
    for h in all_hosts:
        lines.append(f'  {_node_id(h)}["{h}"]')
    for (fid, tid), v in edges.items():
        label = ", ".join(v["labels"])
        lines.append(f'  {fid} -->|"{label}"| {tid}')

    # Внешние локальные подключения — зелёный прямоугольник
    # Группировка: subnet → {to_host → set(ports)}
    local_ext = [d for d in ext_deps if d["ext_type"] == "local"]
    if local_ext:
        subnet_host_ports: dict = {}
        for d in local_ext:
            sn = d["subnet"]
            subnet_host_ports.setdefault(sn, {})
            subnet_host_ports[sn].setdefault(d["to_host"], set()).add(str(d["to_port"]))
        lines.append('  subgraph ext_local_group ["Внешние локальные"]')
        lines.append("  style ext_local_group fill:#d4edda,stroke:#28a745,color:#155724")
        for sn in sorted(subnet_host_ports):
            sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
            lines.append(f'    {sn_id}["{sn}"]')
        lines.append("  end")
        for sn, host_ports in sorted(subnet_host_ports.items()):
            sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
            for to_host, ports in sorted(host_ports.items()):
                port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0))
                lines.append(f'  {sn_id} -->|"{port_label}"| {_node_id(to_host)}')

    # Внешние удалённые подключения — оранжевый прямоугольник
    # Группировка: to_host → set(ports)
    remote_ext = [d for d in ext_deps if d["ext_type"] == "remote"]
    if remote_ext:
        host_ports: dict = {}
        for d in remote_ext:
            host_ports.setdefault(d["to_host"], set()).add(str(d["to_port"]))
        lines.append('  subgraph ext_remote_group ["Внешние удалённые"]')
        lines.append("  style ext_remote_group fill:#fff3cd,stroke:#fd7e14,color:#856404")
        lines.append('    ext_internet["Интернет"]')
        lines.append("  end")
        for to_host, ports in sorted(host_ports.items()):
            port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0))
            lines.append(f'  ext_internet -->|"{port_label}"| {_node_id(to_host)}')

    return "\n".join(lines)


def service_mermaid(svc_def: dict, all_deps: list) -> str:
    """Строит Mermaid-диаграмму для глобального сервиса.

    Если dep_refs задан — показывает только указанные рёбра.
    Иначе — все рёбра из all_deps, где оба конца входят в components.
    """
    _ROLE_COLORS = {
        "app":        "#0d6efd",
        "db-primary": "#198754",
        "db-replica": "#d1e7dd",
    }
    _DEFAULT_COLOR = "#6c757d"

    comp_set = {(c["host"], c["service"]) for c in svc_def["components"]}

    dep_refs = svc_def.get("dep_refs")
    if dep_refs:
        ref_pairs = {
            (r["from"]["host"], r["from"]["service"], r["to"]["host"], r["to"]["service"])
            for r in dep_refs
        }
        edges = [
            d for d in all_deps
            if (d["from_host"], d["from_service"], d["to_host"], d["to_service"]) in ref_pairs
        ]
    else:
        edges = [
            d for d in all_deps
            if (d["from_host"], d["from_service"]) in comp_set
            and (d["to_host"], d["to_service"]) in comp_set
        ]

    if not edges and len(svc_def["components"]) < 2:
        return ""

    def _svc_node_id(host: str, service: str) -> str:
        return "s_" + re.sub(r"[^a-zA-Z0-9]", "_", host + "_" + service)

    # Строим карту компонентов для получения label и role
    comp_map = {(c["host"], c["service"]): c for c in svc_def["components"]}

    lines = ["graph LR"]

    # Узлы всех компонентов
    for c in svc_def["components"]:
        nid = _svc_node_id(c["host"], c["service"])
        label = c.get("label") or f"{c['host']}\\n{c['service']}"
        color = _ROLE_COLORS.get(c.get("role", ""), _DEFAULT_COLOR)
        lines.append(f'  {nid}["{label}"]')
        lines.append(f"  style {nid} fill:{color},color:#fff,stroke:#333")

    # Рёбра
    seen = set()
    for d in edges:
        fid = _svc_node_id(d["from_host"], d["from_service"])
        tid = _svc_node_id(d["to_host"], d["to_service"])
        port = str(d.get("port", ""))
        label = port if port else "—"
        key = (fid, tid, label)
        if key in seen:
            continue
        seen.add(key)
        lines.append(f'  {fid} -->|"{label}"| {tid}')

    return "\n".join(lines)


# ── Шаблон страницы ────────────────────────────────────────────────────────────

def page(title: str, body: str, depth: int = 0, extra_scripts: str = "") -> str:
    root = "../" * depth
    return f"""<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{title}</title>
  <link rel="stylesheet" href="{BOOTSTRAP_CSS}">
  <style>
    body {{ background: #f5f7fa; }}
    .navbar-brand {{ font-weight: 700; letter-spacing: .3px; }}
    code {{ font-size: .85em; }}
    .progress {{ height: 14px; }}
    th {{ white-space: nowrap; }}
  </style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark mb-4 px-3 d-flex align-items-center gap-3">
  <a class="navbar-brand" href="{root}index.html">⚙ Ansible Hosts</a>
  <a class="nav-link text-light small" href="{root}dependencies.html">Зависимости</a>
  <a class="nav-link text-light small" href="{root}services/index.html">Сервисы</a>
  <span class="ms-auto text-secondary small">Сгенерировано: {GENERATED_AT}</span>
</nav>
<div class="container-xl px-4 pb-5">
{body}
</div>
<script src="{BOOTSTRAP_JS}"></script>
{extra_scripts}
<script>
function filterTable(inputId, tbodyId) {{
  var q = document.getElementById(inputId).value.toLowerCase();
  var rows = document.getElementById(tbodyId).querySelectorAll('tr');
  rows.forEach(function(row) {{
    row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
  }});
}}
</script>
</body>
</html>"""


# ── Docker (индекс + страница) ────────────────────────────────────────────────

def docker_summary_section(hosts: dict) -> str:
    """Рендерит краткую сводку Docker-контейнеров на главной странице."""
    all_containers = []
    hosts_with_containers = 0
    for hostname, f in hosts.items():
        containers = parse_docker_containers(f)
        if containers:
            hosts_with_containers += 1
            all_containers.extend(containers)

    if not all_containers:
        return ""

    return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2">
  <h4 class="mb-0">Docker-контейнеры</h4>
  <span class="badge bg-secondary">{len(all_containers)}</span>
  <span class="badge bg-info text-dark">{hosts_with_containers} хостов</span>
  <a href="containers.html" class="btn btn-sm btn-outline-secondary ms-2">Подробнее →</a>
</div>"""


def docker_page(hosts: dict) -> str:
    """Генерирует HTML-страницу со всеми Docker-контейнерами."""
    rows = []
    for hostname, f in hosts.items():
        for c in parse_docker_containers(f):
            rows.append(
                f"<tr>"
                f"<td><code>{c['name']}</code></td>"
                f"<td><a href='hosts/{hostname}.html' class='fw-semibold text-decoration-none'>{hostname}</a></td>"
                f"<td><small class='text-muted'>{c['image']}</small></td>"
                f"<td><small class='text-muted'>{c['ports']}</small></td>"
                f"<td><small class='text-muted'>{c['volumes']}</small></td>"
                f"</tr>"
            )

    containers_section = ""
    if rows:
        containers_section = f"""
<h5 class="mt-4 mb-2">Контейнеры <span class="badge bg-secondary">{len(rows)}</span></h5>
<div class="mb-2">
  <input type="text" id="dockerFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по хосту, контейнеру, образу…" oninput="filterTable('dockerFilter','dockerBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr>
        <th>Имя контейнера</th>
        <th>Хост</th>
        <th>Образ</th>
        <th>Порты</th>
        <th>Volumes</th>
      </tr>
    </thead>
    <tbody id="dockerBody">{''.join(rows)}</tbody>
  </table>
</div>"""

    body = f"""
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
    <li class="breadcrumb-item active">Docker-контейнеры</li>
  </ol>
</nav>
<h4 class="mb-1">Docker-контейнеры</h4>
{containers_section}"""

    return page("Docker-контейнеры", body)


# ── Зависимости между службами (страница) ─────────────────────────────────────

def deps_index_section(static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list) -> str:
    """Рендерит краткую сводку зависимостей на главной странице."""
    total = len(static_deps) + len(auto_deps)
    if total == 0 and not ext_deps and not intra_deps:
        return ""
    ext_local  = sum(1 for d in ext_deps if d["ext_type"] == "local")
    ext_remote = sum(1 for d in ext_deps if d["ext_type"] == "remote")
    return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2 flex-wrap">
  <h4 class="mb-0">Зависимости между службами</h4>
  <span class="badge bg-secondary">{total}</span>
  <span class="badge bg-primary">{len(static_deps)} статических</span>
  <span class="badge bg-info text-dark">{len(auto_deps)} авто</span>
  <span class="badge bg-success">{len(intra_deps)} внутрихостовых</span>
  <span class="badge bg-warning text-dark">{ext_local} внешних локальных</span>
  <span class="badge bg-danger">{ext_remote} внешних удалённых</span>
  <a href="dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Зависимости →</a>
  <a href="external_connections.html" class="btn btn-sm btn-outline-secondary ms-1">Внешние подключения →</a>
</div>"""


def global_services_index_section(global_services: list) -> str:
    """Рендерит краткую сводку глобальных сервисов на главной странице."""
    if not global_services:
        return ""
    links = "".join(
        f'<a href="services/{s["slug"]}.html" class="btn btn-sm btn-outline-primary ms-1">{s["name"]}</a>'
        for s in global_services
    )
    return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2 flex-wrap">
  <h4 class="mb-0">Глобальные сервисы</h4>
  <span class="badge bg-secondary">{len(global_services)}</span>
  <a href="services/index.html" class="btn btn-sm btn-outline-secondary ms-2">Все сервисы →</a>
  {links}
</div>"""


def external_page(ext_deps: list) -> str:
    """Генерирует HTML-страницу external_connections.html.

    Структура: хост → служба → IP-адреса источников без группировки по подсетям.
    Локальные подключения — зелёная таблица, удалённые — оранжевая.
    """
    if not ext_deps:
        body = """
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
    <li class="breadcrumb-item"><a href="dependencies.html">Зависимости</a></li>
    <li class="breadcrumb-item active">Внешние подключения</li>
  </ol>
</nav>
<h4 class="mb-3">Внешние подключения</h4>
<p class="text-muted">Данные об внешних подключениях отсутствуют.</p>"""
        return page("Внешние подключения", body)

    # Структура: {to_host: {to_service: {"local": [(port, proto, ip, count, historical, last_seen, ct_state)], "remote": [(port, ip, count)]}}}
    structure: dict = {}
    for d in ext_deps:
        host = d["to_host"]
        svc  = d["to_service"]
        structure.setdefault(host, {})
        structure[host].setdefault(svc, {"local": [], "remote": []})
        if d["ext_type"] == "local":
            entry = (
                d["to_port"], d.get("proto", "tcp"), d.get("ip", ""), d["count"],
                d.get("historical", False), d.get("last_seen", ""), d.get("ct_state", ""),
            )
            structure[host][svc]["local"].append(entry)
        else:
            structure[host][svc]["remote"].append((d["to_port"], d.get("ip", ""), d["count"]))

    host_cards = []
    for host in sorted(structure):
        svc_blocks = []
        for idx, svc in enumerate(sorted(structure[host])):
            data = structure[host][svc]

            # Таблица локальных подключений — индивидуальные IP, сортировка по порту → IP
            local_block = ""
            if data["local"]:
                rows = []
                for port, proto, ip, count, historical, last_seen, ct_state in sorted(
                    data["local"],
                    key=lambda x: (x[0], x[1], _ip_sort_key(x[2]))
                ):
                    port_badge = f"<span class='badge bg-primary'>{port}/{proto}</span>"
                    if historical:
                        ls_short = (last_seen or "")[:10]
                        rows.append(
                            f"<tr class='text-muted'>"
                            f"<td>{port_badge}</td>"
                            f"<td><code class='text-muted'>{ip}</code></td>"
                            f"<td class='text-end'><small class='fst-italic'>last seen: {ls_short}</small></td>"
                            f"</tr>"
                        )
                    elif ct_state == "TIME_WAIT":
                        rows.append(
                            f"<tr>"
                            f"<td>{port_badge}</td>"
                            f"<td><code>{ip}</code></td>"
                            f"<td class='text-end text-muted'><small>(TIME_WAIT)</small></td>"
                            f"</tr>"
                        )
                    else:
                        rows.append(
                            f"<tr>"
                            f"<td>{port_badge}</td>"
                            f"<td><code>{ip}</code></td>"
                            f"<td class='text-end text-muted'><small>{count}</small></td>"
                            f"</tr>"
                        )
                active_local = sum(1 for _, _, _, _, h, _, _ in data["local"] if not h)
                hist_local   = sum(1 for _, _, _, _, h, _, _ in data["local"] if h)
                hist_badge_l = f' <span class="badge bg-secondary ms-1">{hist_local} истор.</span>' if hist_local else ""
                local_block = f"""
<div class="mt-2 mb-1">
  <span class="fw-semibold small" style="color:#155724">&#9632; Локальные сети
    <span class="badge bg-success ms-1">{active_local}</span>{hist_badge_l}
  </span>
</div>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white mb-2" style="border-color:#28a745;max-width:480px">
    <thead style="background-color:#d4edda">
      <tr><th>Порт</th><th>IP-адрес источника</th><th class="text-end">Соед.</th></tr>
    </thead>
    <tbody>{''.join(rows)}</tbody>
  </table>
</div>"""

            # Таблица удалённых подключений — индивидуальные IP, сортировка по порту → IP
            remote_block = ""
            if data["remote"]:
                rows = []
                for port, ip, count in sorted(
                    data["remote"],
                    key=lambda x: (x[0], _ip_sort_key(x[1]))
                ):
                    rows.append(
                        f"<tr>"
                        f"<td><span class='badge bg-primary'>{port}/tcp</span></td>"
                        f"<td><code>{ip}</code></td>"
                        f"<td class='text-end text-muted'><small>{count}</small></td>"
                        f"</tr>"
                    )
                remote_block = f"""
<div class="mt-2 mb-1">
  <span class="fw-semibold small" style="color:#856404">&#9632; Публичный интернет
    <span class="badge bg-warning text-dark ms-1">{len(rows)}</span>
  </span>
</div>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white mb-2" style="border-color:#fd7e14;max-width:480px">
    <thead style="background-color:#fff3cd">
      <tr><th>Порт</th><th>IP-адрес источника</th><th class="text-end">Соед.</th></tr>
    </thead>
    <tbody>{''.join(rows)}</tbody>
  </table>
</div>"""

            border_top = ' style="border-top:1px solid #dee2e6"' if idx > 0 else ""
            svc_blocks.append(f"""
<div class="px-3 py-2"{border_top}>
  <div class="fw-semibold mb-1"><code>{svc}</code></div>
  {local_block}
  {remote_block}
</div>""")

        svc_count  = len(structure[host])
        local_ips  = len({(svc, e[2]) for svc, d in structure[host].items() for e in d["local"] if not e[4]})
        remote_ips = len({(svc, e[1]) for svc, d in structure[host].items() for e in d["remote"]})
        badges = ""
        if local_ips:
            badges += f' <span class="badge bg-success">{local_ips} лок. IP</span>'
        if remote_ips:
            badges += f' <span class="badge bg-warning text-dark">{remote_ips} удал. IP</span>'

        host_cards.append(f"""
<div class="card shadow-sm mb-4 ext-host-card">
  <div class="card-header bg-dark text-white d-flex align-items-center gap-2 flex-wrap">
    <a href="hosts/{host}.html" class="text-white fw-semibold text-decoration-none">{host}</a>
    <span class="badge bg-secondary">{svc_count} служб</span>{badges}
  </div>
  <div class="card-body p-0">
    {''.join(svc_blocks)}
  </div>
</div>""")

    total_hosts  = len(structure)
    total_svcs   = sum(len(v) for v in structure.values())
    local_ips    = len({d.get("ip", "") for d in ext_deps if d["ext_type"] == "local" and not d.get("historical")})
    remote_ips   = len({d.get("ip", "") for d in ext_deps if d["ext_type"] == "remote"})

    body = f"""
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
    <li class="breadcrumb-item"><a href="dependencies.html">Зависимости</a></li>
    <li class="breadcrumb-item active">Внешние подключения</li>
  </ol>
</nav>
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
  <h4 class="mb-0">Внешние подключения</h4>
  <span class="badge bg-secondary">{total_hosts} хостов</span>
  <span class="badge bg-secondary">{total_svcs} служб</span>
  <span class="badge bg-success">{local_ips} локальных IP</span>
  <span class="badge bg-warning text-dark">{remote_ips} удалённых IP</span>
</div>
<div class="mb-3">
  <input type="text" id="extPageFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по хосту, службе или IP…"
         oninput="(function(q){{document.querySelectorAll('.ext-host-card').forEach(function(c){{c.style.display=c.textContent.toLowerCase().includes(q)?'':'none'}})}})( this.value.toLowerCase())">
</div>
{''.join(host_cards)}"""

    return page("Внешние подключения", body)


def deps_page(static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list) -> str:
    """Генерирует HTML-страницу dependencies.html."""

    # ── Mermaid-диаграмма ─────────────────────────────────────────────────────
    mermaid_text = deps_mermaid(static_deps, auto_deps, ext_deps)
    diagram_section = ""
    if mermaid_text:
        escaped = mermaid_text.replace("`", "\\`")
        diagram_section = f"""
<h5 class="mt-4 mb-2">Граф зависимостей</h5>
<div class="bg-white border rounded p-3 shadow-sm mb-4 text-center">
  <div class="mermaid">
{mermaid_text}
  </div>
</div>"""

    # ── Статические зависимости ───────────────────────────────────────────────
    static_rows = []
    for d in static_deps:
        from_cell = (
            f"<a href='hosts/{d['from_host']}.html' class='fw-semibold text-decoration-none'>{d['from_host']}</a>"
            + (f"<br><small class='text-muted'>{d['from_service']}</small>" if d["from_service"] else "")
        )
        to_cell = (
            f"<a href='hosts/{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
            + (f"<br><small class='text-muted'>{d['to_service']}</small>" if d["to_service"] else "")
        )
        port_cell = (
            f"<span class='badge bg-primary'>{d['port']}/{d['protocol']}</span>"
            if d["port"] else "—"
        )
        static_rows.append(
            f"<tr>"
            f"<td>{from_cell}</td>"
            f"<td>{to_cell}</td>"
            f"<td>{port_cell}</td>"
            f"<td><small class='text-muted'>{d['description'] or '—'}</small></td>"
            f"</tr>"
        )

    static_section = ""
    if static_rows:
        static_section = f"""
<h5 class="mt-4 mb-2">
  Статические зависимости
  <span class="badge bg-secondary">{len(static_rows)}</span>
  <small class="text-muted fw-normal ms-2" style="font-size:.8em">из service_map.yml</small>
</h5>
<div class="mb-2">
  <input type="text" id="staticFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по хосту, службе, порту…" oninput="filterTable('staticFilter','staticBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>От (хост / служба)</th><th>До (хост / служба)</th><th>Порт</th><th>Описание</th></tr>
    </thead>
    <tbody id="staticBody">{''.join(static_rows)}</tbody>
  </table>
</div>"""

    # ── Автоопределённые зависимости ─────────────────────────────────────────
    auto_rows = []
    for d in sorted(auto_deps, key=lambda x: (x["port"], x["to_host"], x["from_host"])):
        from_link   = f"<a href='hosts/{d['from_host']}.html' class='fw-semibold text-decoration-none'>{d['from_host']}</a>"
        to_link     = f"<a href='hosts/{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
        from_svc    = d.get("from_service") or "—"
        to_svc      = d.get("to_service") or "—"
        port_badge  = f"<span class='badge bg-primary'>{d['port']}/tcp</span>"
        svc_matched = from_svc != "—" and to_svc != "—"
        match_badge = (
            "<span class='badge bg-success ms-1' title='Службы сопоставлены'>✓</span>"
            if svc_matched else ""
        )
        auto_rows.append(
            f"<tr>"
            f"<td>{from_link}<br><small class='text-muted'>{from_svc}</small></td>"
            f"<td>{to_link}<br><small class='text-muted'>{to_svc}</small></td>"
            f"<td>{port_badge}{match_badge}</td>"
            f"<td class='text-end text-muted'><small>{d['count']}</small></td>"
            f"</tr>"
        )

    auto_section = ""
    if auto_rows:
        matched = sum(1 for d in auto_deps if d.get("from_service", "—") != "—" and d.get("to_service", "—") != "—")
        auto_section = f"""
<h5 class="mt-4 mb-2">
  Автоопределённые зависимости
  <span class="badge bg-secondary">{len(auto_rows)}</span>
  <span class="badge bg-success" title="Службы сопоставлены на обоих концах">{matched} сопоставлено</span>
  <small class="text-muted fw-normal ms-2" style="font-size:.8em">по активным TCP-соединениям из ss</small>
</h5>
<div class="mb-2">
  <input type="text" id="autoFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по хосту, службе, порту…" oninput="filterTable('autoFilter','autoBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Источник (хост / служба)</th><th>Назначение (хост / служба)</th><th>Порт</th><th class="text-end">Соед.</th></tr>
    </thead>
    <tbody id="autoBody">{''.join(auto_rows)}</tbody>
  </table>
</div>"""

    if not static_section and not auto_section:
        auto_section = '<p class="text-muted mt-4">Зависимости не обнаружены. Соберите факты хостов и/или заполните service_map.yml.</p>'

    # ── Внутрихостовые зависимости ────────────────────────────────────────────
    intra_rows = []
    for d in sorted(intra_deps, key=lambda x: (x["host"], x["port"], x["client_service"])):
        host_link    = f"<a href='hosts/{d['host']}.html' class='fw-semibold text-decoration-none'>{d['host']}</a>"
        client_svc   = d.get("client_service") or "—"
        server_svc   = d.get("server_service") or "—"
        port_badge   = f"<span class='badge bg-primary'>{d['port']}/tcp</span>"
        svc_matched  = client_svc != "—" and server_svc != "—"
        match_badge  = (
            "<span class='badge bg-success ms-1' title='Службы сопоставлены'>✓</span>"
            if svc_matched else ""
        )
        intra_rows.append(
            f"<tr>"
            f"<td>{host_link}</td>"
            f"<td><small class='text-muted'>{client_svc}</small></td>"
            f"<td><small class='text-muted'>{server_svc}</small></td>"
            f"<td>{port_badge}{match_badge}</td>"
            f"<td class='text-end text-muted'><small>{d['count']}</small></td>"
            f"</tr>"
        )

    intra_section = ""
    if intra_rows:
        matched_intra = sum(
            1 for d in intra_deps
            if d.get("client_service", "—") != "—" and d.get("server_service", "—") != "—"
        )
        intra_section = f"""
<h5 class="mt-4 mb-2">
  Внутрихостовые зависимости
  <span class="badge bg-secondary">{len(intra_rows)}</span>
  <span class="badge bg-success" title="Службы сопоставлены на обоих концах">{matched_intra} сопоставлено</span>
  <small class="text-muted fw-normal ms-2" style="font-size:.8em">loopback TCP-соединения внутри хоста</small>
</h5>
<div class="mb-2">
  <input type="text" id="intraFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по хосту, службе, порту…" oninput="filterTable('intraFilter','intraBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Хост</th><th>Клиент (служба)</th><th>Сервер (служба)</th><th>Порт</th><th class="text-end">Соед.</th></tr>
    </thead>
    <tbody id="intraBody">{''.join(intra_rows)}</tbody>
  </table>
</div>"""

    # ── Внешние локальные подключения ─────────────────────────────────────────
    # Показываем индивидуальные IP: текущие (ss/conntrack) и исторические
    ext_local_entries = [d for d in ext_deps if d["ext_type"] == "local"]

    ext_local_rows = []
    for d in sorted(
        ext_local_entries,
        key=lambda x: (x["to_host"], x["to_port"], x["to_service"], _ip_sort_key(x["ip"])),
    ):
        historical = d.get("historical", False)
        ct_state   = d.get("ct_state", "")
        proto      = d.get("proto", "tcp")
        to_link    = f"<a href='hosts/{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
        svc_cell   = f"<small class='text-muted'>{d['to_service']}</small>" if d["to_service"] != "—" else "—"
        port_badge = f"<span class='badge bg-primary'>{d['to_port']}/{proto}</span>"
        subnet_badge = f"<span class='badge bg-warning text-dark'>{d['subnet']}</span>"

        if historical:
            last_seen = (d.get("last_seen") or "")[:10]
            ip_cell   = f"<code class='text-muted'>{d['ip']}</code>"
            status    = f"<small class='text-muted fst-italic'>last seen: {last_seen}</small>"
            row_class = " class='text-muted'"
        elif ct_state == "TIME_WAIT":
            ip_cell   = f"<code>{d['ip']}</code>"
            status    = f"<small class='text-muted'>(TIME_WAIT)</small>"
            row_class = ""
        else:
            ip_cell   = f"<code>{d['ip']}</code>"
            status    = f"<small class='text-muted'>×{d['count']}</small>" if d["count"] > 1 else ""
            row_class = ""

        ext_local_rows.append(
            f"<tr{row_class}>"
            f"<td>{to_link}</td>"
            f"<td>{svc_cell}</td>"
            f"<td>{port_badge}</td>"
            f"<td>{ip_cell} {subnet_badge}</td>"
            f"<td>{status}</td>"
            f"</tr>"
        )

    ext_local_section = ""
    if ext_local_rows:
        active_count = sum(1 for d in ext_local_entries if not d.get("historical"))
        hist_count   = sum(1 for d in ext_local_entries if d.get("historical"))
        hist_badge   = f' <span class="badge bg-secondary">{hist_count} истор.</span>' if hist_count else ""
        ext_local_section = f"""
<h5 class="mt-4 mb-2">
  Внешние локальные подключения
  <span class="badge bg-success">{active_count} активных</span>{hist_badge}
  <small class="text-muted fw-normal ms-2" style="font-size:.8em">источник — локальные сети, индивидуальные IP из ss/conntrack и истории</small>
</h5>
<div class="mb-2">
  <input type="text" id="extLocalFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по хосту, службе, IP…" oninput="filterTable('extLocalFilter','extLocalBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Сервер</th><th>Служба</th><th>Порт</th><th>IP / Подсеть</th><th>Статус / Соед.</th></tr>
    </thead>
    <tbody id="extLocalBody">{''.join(ext_local_rows)}</tbody>
  </table>
</div>"""

    # ── Внешние удалённые подключения ─────────────────────────────────────────
    # Группировка: (to_host, to_port, proto, to_service) → total count
    remote_groups: dict = {}
    for d in ext_deps:
        if d["ext_type"] != "remote":
            continue
        key = (d["to_host"], d["to_port"], d.get("proto", "tcp"), d["to_service"])
        remote_groups[key] = remote_groups.get(key, 0) + d["count"]

    ext_remote_rows = []
    for (to_host, to_port, proto, to_svc), total in sorted(remote_groups.items()):
        to_link    = f"<a href='hosts/{to_host}.html' class='fw-semibold text-decoration-none'>{to_host}</a>"
        svc_cell   = f"<small class='text-muted'>{to_svc}</small>" if to_svc != "—" else "—"
        port_badge = f"<span class='badge bg-primary'>{to_port}/{proto}</span>"
        ext_remote_rows.append(
            f"<tr>"
            f"<td>{to_link}</td>"
            f"<td>{svc_cell}</td>"
            f"<td>{port_badge}</td>"
            f"<td class='text-end text-muted'><small>{total}</small></td>"
            f"</tr>"
        )

    ext_remote_section = ""
    if ext_remote_rows:
        ext_remote_section = f"""
<h5 class="mt-4 mb-2">
  Внешние удалённые подключения
  <span class="badge bg-secondary">{len(ext_remote_rows)}</span>
  <small class="text-muted fw-normal ms-2" style="font-size:.8em">источник — публичный интернет, суммарное количество</small>
</h5>
<div class="mb-2">
  <input type="text" id="extRemoteFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по хосту или службе…" oninput="filterTable('extRemoteFilter','extRemoteBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Сервер</th><th>Служба</th><th>Порт</th><th class="text-end">Всего соединений</th></tr>
    </thead>
    <tbody id="extRemoteBody">{''.join(ext_remote_rows)}</tbody>
  </table>
</div>"""

    body = f"""
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
    <li class="breadcrumb-item active">Зависимости</li>
  </ol>
</nav>
<div class="d-flex align-items-center mb-1 gap-2 flex-wrap">
  <h4 class="mb-0">Зависимости между службами</h4>
  <a href="external_connections.html" class="btn btn-sm btn-outline-secondary ms-2">Внешние подключения по службам →</a>
</div>
<p class="text-muted mb-0"><small>
  Статические — из <code>service_map.yml</code>.
  Авто — из активных TCP-соединений (<code>ss -tnp state established</code>), собранных при последнем запуске gather_facts.
</small></p>
{diagram_section}
{static_section}
{auto_section}
{intra_section}
{ext_local_section}
{ext_remote_section}"""

    mermaid_script = """<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({{startOnLoad: true, theme: 'default'}});</script>"""

    return page("Зависимости между службами", body, extra_scripts=mermaid_script)


# ── VMware vSphere ─────────────────────────────────────────────────────────────

def vmware_index_section(vmw: dict) -> str:
    """Рендерит краткую сводку VMware vSphere на главной странице."""
    esxi_count = len(vmw["esxi_hosts"])
    vm_count = len(vmw["vms"])
    powered_on = sum(1 for v in vmw["vms"] if v.get("power_state") == "poweredOn")
    ds_count = len(vmw["datastores"])

    if esxi_count == 0 and vm_count == 0:
        return ""

    return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2">
  <h4 class="mb-0">VMware vSphere</h4>
  <span class="badge bg-secondary">{esxi_count} ESXi</span>
  <span class="badge bg-secondary">{vm_count} VM</span>
  <span class="badge bg-success">{powered_on} вкл.</span>
  <span class="badge bg-secondary">{ds_count} датасторов</span>
  <a href="vmware.html" class="btn btn-sm btn-outline-secondary ms-2">Подробнее →</a>
</div>"""


def vmware_page(vmw: dict) -> str:
    """Генерирует HTML-страницу с обзором VMware vSphere инфраструктуры."""

    # ── ESXi гипервизоры ──────────────────────────────────────────────────────
    esxi_rows = []
    for hostname, facts in vmw["esxi_hosts"].items():
        vcpus = facts.get("ansible_processor_vcpus", facts.get("ansible_processor_count", "—"))
        ram_mb = facts.get("ansible_memtotal_mb", 0)
        ram_str = f"{ram_mb / 1024:.1f} ГБ" if ram_mb >= 1024 else f"{ram_mb} МБ"
        product = facts.get("ansible_product_name", "—")
        kernel = facts.get("ansible_kernel", "—")
        esxi_rows.append(
            f"<tr>"
            f"<td><code>{hostname}</code></td>"
            f"<td class='text-end'>{vcpus}</td>"
            f"<td class='text-end'>{ram_str}</td>"
            f"<td><small>{product}</small></td>"
            f"<td><small class='text-muted'>{kernel}</small></td>"
            f"</tr>"
        )

    esxi_section = ""
    if esxi_rows:
        esxi_section = f"""
<h5 class="mt-4 mb-2">ESXi-гипервизоры <span class="badge bg-secondary">{len(esxi_rows)}</span></h5>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Хост</th><th class="text-end">CPU</th><th class="text-end">RAM</th><th>Модель</th><th>Версия ESXi</th></tr>
    </thead>
    <tbody>{''.join(esxi_rows)}</tbody>
  </table>
</div>"""

    # ── Виртуальные машины ────────────────────────────────────────────────────
    vm_rows = []
    for vm in vmw["vms"]:
        name = vm.get("guest_name", "—")
        power = vm.get("power_state", "—")
        power_badge = (
            '<span class="badge bg-success">poweredOn</span>' if power == "poweredOn"
            else '<span class="badge bg-secondary">poweredOff</span>' if power == "poweredOff"
            else f'<span class="badge bg-warning text-dark">{power}</span>'
        )
        esxi_host = vm.get("esxi_hostname", "—")
        ip = vm.get("ip_address") or "—"
        cpu = vm.get("num_cpu", "—")
        mem_mb = vm.get("memory_mb", 0)
        mem_str = f"{mem_mb / 1024:.1f} ГБ" if mem_mb >= 1024 else f"{mem_mb} МБ"
        os_guest = vm.get("guest_fullname") or "—"
        vm_rows.append(
            f"<tr>"
            f"<td><span class='fw-semibold'>{name}</span></td>"
            f"<td>{power_badge}</td>"
            f"<td><small class='text-muted'>{esxi_host}</small></td>"
            f"<td><code>{ip}</code></td>"
            f"<td class='text-end'>{cpu}</td>"
            f"<td class='text-end'>{mem_str}</td>"
            f"<td><small>{os_guest}</small></td>"
            f"</tr>"
        )

    vms_section = ""
    if vm_rows:
        powered_on = sum(1 for v in vmw["vms"] if v.get("power_state") == "poweredOn")
        vms_section = f"""
<h5 class="mt-4 mb-2">
  Виртуальные машины
  <span class="badge bg-secondary">{len(vm_rows)}</span>
  <span class="badge bg-success">{powered_on} вкл.</span>
</h5>
<div class="mb-2">
  <input type="text" id="vmFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по имени, хосту, ОС…" oninput="filterTable('vmFilter','vmBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Имя VM</th><th>Состояние</th><th>Гипервизор</th><th>IP</th>
          <th class="text-end">CPU</th><th class="text-end">RAM</th><th>ОС</th></tr>
    </thead>
    <tbody id="vmBody">{''.join(vm_rows)}</tbody>
  </table>
</div>"""

    # ── Датасторы ─────────────────────────────────────────────────────────────
    ds_rows = []
    for ds in vmw["datastores"]:
        ds_name = ds.get("name", "—")
        ds_type = ds.get("type", "—")
        capacity = int(ds.get("capacity", 0))
        free = int(ds.get("freeSpace", 0))
        accessible = ds.get("accessible", False)
        if capacity > 0:
            pct = int((capacity - free) / capacity * 100)
            color = "danger" if pct > 85 else "warning" if pct > 70 else "success"
            bar = (
                f'<div class="progress">'
                f'<div class="progress-bar bg-{color}" style="width:{pct}%">{pct}%</div>'
                f'</div>'
            )
            usage_str = f"{fmt_bytes(capacity - free)} / {fmt_bytes(capacity)}"
        else:
            bar = "—"
            usage_str = "—"
        access_badge = (
            '<span class="badge bg-success">ОК</span>' if accessible
            else '<span class="badge bg-danger">Недоступен</span>'
        )
        ds_rows.append(
            f"<tr>"
            f"<td><span class='fw-semibold'>{ds_name}</span></td>"
            f"<td><span class='badge bg-info text-dark'>{ds_type}</span></td>"
            f"<td>{access_badge}</td>"
            f"<td><small>{usage_str}</small></td>"
            f'<td style="min-width:110px">{bar}</td>'
            f"</tr>"
        )

    ds_section = ""
    if ds_rows:
        ds_section = f"""
<h5 class="mt-4 mb-2">Датасторы <span class="badge bg-secondary">{len(ds_rows)}</span></h5>
<div class="mb-2">
  <input type="text" id="dsFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по имени или типу…" oninput="filterTable('dsFilter','dsBody')">
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Датастор</th><th>Тип</th><th>Доступность</th><th>Использование</th>
          <th style="min-width:110px">Заполнение</th></tr>
    </thead>
    <tbody id="dsBody">{''.join(ds_rows)}</tbody>
  </table>
</div>"""

    body = f"""
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
    <li class="breadcrumb-item active">VMware vSphere</li>
  </ol>
</nav>
<h4 class="mb-1">VMware vSphere</h4>
<p class="text-muted mb-4"><small>Датацентр: <strong>dc1</strong> / Кластер: <strong>cl1</strong></small></p>
{esxi_section}
{vms_section}
{ds_section}"""

    return page("VMware vSphere", body)


# ── Глобальные сервисы ─────────────────────────────────────────────────────────

def services_index_page(global_services: list, hosts: dict, all_deps: list) -> str:
    """Генерирует public/services/index.html — каталог всех глобальных сервисов."""
    if not global_services:
        body = """
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
    <li class="breadcrumb-item active">Сервисы</li>
  </ol>
</nav>
<h4 class="mb-1">Глобальные сервисы</h4>
<p class="text-muted">Файлы сервисов не найдены. Создайте <code>services/*.yml</code> в корне репозитория.</p>"""
        return page("Глобальные сервисы", body, depth=1)

    cards = []
    for svc in global_services:
        comp_rows = []
        for c in svc["components"]:
            hostname = c["host"]
            service  = c["service"]
            role     = c.get("role", "—")
            label    = c.get("label", "")
            state = hosts.get(hostname, {}).get("services", {}).get(service, {}).get("state", "")
            if state == "running":
                state_badge = '<span class="badge bg-success">running</span>'
            elif state:
                state_badge = f'<span class="badge bg-secondary">{state}</span>'
            else:
                state_badge = '<span class="text-muted">—</span>'
            comp_rows.append(
                f"<tr>"
                f"<td><a href='../hosts/{hostname}.html'>{hostname}</a></td>"
                f"<td><code>{service}</code></td>"
                f"<td><small class='text-muted'>{role}</small></td>"
                f"<td>{state_badge}</td>"
                f"</tr>"
            )
        comp_table = f"""
<table class="table table-sm table-bordered mb-0">
  <thead class="table-dark"><tr><th>Хост</th><th>Служба</th><th>Роль</th><th>Состояние</th></tr></thead>
  <tbody>{''.join(comp_rows)}</tbody>
</table>"""
        desc_html = f'<p class="card-text text-muted small mb-2">{svc["description"]}</p>' if svc["description"] else ""
        cards.append(f"""
<div class="col">
  <div class="card h-100 shadow-sm">
    <div class="card-header bg-dark text-white fw-semibold">{svc['name']}</div>
    <div class="card-body pb-2">
      {desc_html}
      {comp_table}
    </div>
    <div class="card-footer bg-transparent">
      <a href="./{svc['slug']}.html" class="btn btn-sm btn-outline-primary">Подробнее →</a>
    </div>
  </div>
</div>""")

    body = f"""
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
    <li class="breadcrumb-item active">Сервисы</li>
  </ol>
</nav>
<div class="d-flex align-items-center mb-3 gap-2">
  <h4 class="mb-0">Глобальные сервисы</h4>
  <span class="badge bg-secondary">{len(global_services)}</span>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-4">
  {''.join(cards)}
</div>"""
    return page("Глобальные сервисы", body, depth=1)


def service_detail_page(svc_def: dict, hosts: dict, all_deps: list) -> str:
    """Генерирует public/services/{slug}.html — страница конкретного сервиса."""
    name = svc_def["name"]
    slug = svc_def["slug"]

    mermaid_text = service_mermaid(svc_def, all_deps)
    if mermaid_text:
        graph_html = f"""
<div class="pre-mermaid mb-4 bg-white p-3 rounded shadow-sm">
  <pre class="mermaid">{mermaid_text}</pre>
</div>"""
        mermaid_script = """<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({{startOnLoad: true, theme: 'default'}});</script>"""
    else:
        graph_html = '<p class="text-muted">Зависимости между компонентами не обнаружены.</p>'
        mermaid_script = ""

    comp_rows = []
    for c in svc_def["components"]:
        hostname = c["host"]
        service  = c["service"]
        role     = c.get("role", "—")
        label    = c.get("label", "—")
        svc_data = hosts.get(hostname, {}).get("services", {}).get(service, {})
        state    = svc_data.get("state", "")
        enabled  = svc_data.get("status", "")
        if state == "running":
            state_badge = '<span class="badge bg-success">running</span>'
        elif state:
            state_badge = f'<span class="badge bg-secondary">{state}</span>'
        else:
            state_badge = '<span class="text-muted">—</span>'
        if enabled == "enabled":
            enabled_badge = '<span class="badge bg-success">enabled</span>'
        elif enabled:
            enabled_badge = f'<span class="badge bg-secondary">{enabled}</span>'
        else:
            enabled_badge = '<span class="text-muted">—</span>'
        comp_rows.append(
            f"<tr>"
            f"<td><a href='../hosts/{hostname}.html'>{hostname}</a></td>"
            f"<td><code>{service}</code></td>"
            f"<td>{label}</td>"
            f"<td><small class='text-muted'>{role}</small></td>"
            f"<td>{state_badge}</td>"
            f"<td>{enabled_badge}</td>"
            f"</tr>"
        )

    desc_html = f'<p class="text-muted mb-4">{svc_def["description"]}</p>' if svc_def["description"] else ""

    body = f"""
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
    <li class="breadcrumb-item"><a href="./index.html">Сервисы</a></li>
    <li class="breadcrumb-item active">{name}</li>
  </ol>
</nav>
<h4 class="mb-1">{name}</h4>
{desc_html}
<h5 class="mt-4 mb-3">Граф зависимостей</h5>
{graph_html}
<h5 class="mt-4 mb-2">Компоненты</h5>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr><th>Хост</th><th>Служба</th><th>Метка</th><th>Роль</th><th>Состояние</th><th>Автозапуск</th></tr>
    </thead>
    <tbody>{''.join(comp_rows)}</tbody>
  </table>
</div>"""
    return page(f"{name} — сервис", body, depth=1, extra_scripts=mermaid_script)


# ── Страница-индекс ────────────────────────────────────────────────────────────

def index_page(hosts: dict, static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list, global_services: list | None = None) -> str:
    rows = []
    for hostname, f in hosts.items():
        disk_str, pct = get_disk_root(f)
        disk_cell = f"{disk_str}{disk_badge(pct)}" if pct > 0 else disk_str
        vcpus = f.get("processor_vcpus", f.get("processor_count", "—"))
        collected = f.get("date_time", {}).get("iso8601", "—")

        rows.append(f"""
      <tr>
        <td><a href="hosts/{hostname}.html" class="fw-semibold text-decoration-none">{hostname}</a></td>
        <td><code>{get_ipv4(f)}</code></td>
        <td>{os_badge(get_os(f))}</td>
        <td><small class="text-muted">{f.get("kernel", "—")}</small></td>
        <td class="text-end">{vcpus}</td>
        <td class="text-end">{get_ram(f)}</td>
        <td><small>{disk_cell}</small></td>
        <td class="text-end">{get_uptime(f)}</td>
        <td><small class="text-muted">{collected}</small></td>
      </tr>""")

    vmw = load_vmware_facts()
    body = f"""
<div class="d-flex align-items-center mb-3 gap-2">
  <h4 class="mb-0">Управляемые хосты</h4>
  <span class="badge bg-secondary">{len(hosts)}</span>
</div>
<div class="table-responsive">
  <table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
    <thead class="table-dark">
      <tr>
        <th>Хост</th>
        <th>IP</th>
        <th>ОС</th>
        <th>Ядро</th>
        <th class="text-end">CPU</th>
        <th class="text-end">RAM</th>
        <th>Диск /</th>
        <th class="text-end">Аптайм</th>
        <th>Факты собраны</th>
      </tr>
    </thead>
    <tbody>{''.join(rows)}</tbody>
  </table>
</div>
{vmware_index_section(vmw)}
{docker_summary_section(hosts)}
{global_services_index_section(global_services or [])}
{deps_index_section(static_deps, auto_deps, ext_deps, intra_deps)}"""
    return page("Ansible Hosts — Обзор", body)


# ── Страница хоста ─────────────────────────────────────────────────────────────

def host_page(hostname: str, f: dict, deps: list | None = None, intra_deps: list | None = None, ext_deps: list | None = None) -> str:
    # Сетевые интерфейсы
    iface_rows = []
    for iface in sorted(f.get("interfaces", [])):
        data = f.get(iface, {})
        ipv4 = data.get("ipv4", {}).get("address", "—")
        mask = data.get("ipv4", {}).get("netmask", "")
        mac = data.get("macaddress", "—")
        mtu = data.get("mtu", "—")
        addr_cell = f"{ipv4} / {mask}" if mask else ipv4
        iface_rows.append(
            f"<tr><td>{iface}</td><td><code>{addr_cell}</code></td>"
            f"<td><code>{mac}</code></td><td>{mtu}</td></tr>"
        )

    # Файловые системы
    mount_rows = []
    for m in sorted(f.get("mounts", []), key=lambda x: x.get("mount", "")):
        total = int(m.get("size_total", 0))
        avail = int(m.get("size_available", 0))
        if total > 0:
            pct = int((total - avail) / total * 100)
            color = "danger" if pct > 85 else "warning" if pct > 70 else "success"
            bar = (
                f'<div class="progress">'
                f'<div class="progress-bar bg-{color}" style="width:{pct}%">{pct}%</div>'
                f'</div>'
            )
            used_str = f"{fmt_bytes(total - avail)} / {fmt_bytes(total)}"
        else:
            bar = "—"
            used_str = "—"
        mount_rows.append(
            f"<tr><td><code>{m.get('mount', '?')}</code></td>"
            f"<td><small>{m.get('device', '—')}</small></td>"
            f"<td>{m.get('fstype', '—')}</td>"
            f"<td><small>{used_str}</small></td>"
            f'<td style="min-width:110px">{bar}</td></tr>'
        )

    # CPU-модель (берём первую непустую нецифровую строку из списка процессоров)
    procs = f.get("processor", [])
    cpu_model = next((p for p in procs if p and not p.strip().isdigit()), "—")

    vcpus = f.get("processor_vcpus", f.get("processor_count", "—"))
    pkg_count = len(f.get("packages", {}))
    dns = ", ".join(f.get("dns_nameservers", [])) or "—"
    gateway = f.get("default_ipv4", {}).get("gateway", "—")
    iface_name = f.get("default_ipv4", {}).get("interface", "—")
    collected = f.get("date_time", {}).get("iso8601", "—")

    cards = f"""
<div class="row g-3 mb-4">
  <div class="col-lg-4">
    <div class="card h-100 shadow-sm">
      <div class="card-header bg-dark text-white fw-semibold">Система</div>
      <div class="card-body p-0">
        <table class="table table-sm table-borderless mb-0">
          <tr><th class="ps-3 w-35">ОС</th><td>{os_badge(get_os(f))}</td></tr>
          <tr><th class="ps-3">Ядро</th><td><code>{f.get("kernel", "—")}</code></td></tr>
          <tr><th class="ps-3">Архитектура</th><td>{f.get("architecture", "—")}</td></tr>
          <tr><th class="ps-3">FQDN</th><td><small>{f.get("fqdn", "—")}</small></td></tr>
          <tr><th class="ps-3">Аптайм</th><td>{get_uptime(f)}</td></tr>
          <tr><th class="ps-3">Время</th><td><small>{collected}</small></td></tr>
        </table>
      </div>
    </div>
  </div>
  <div class="col-lg-4">
    <div class="card h-100 shadow-sm">
      <div class="card-header bg-dark text-white fw-semibold">Железо</div>
      <div class="card-body p-0">
        <table class="table table-sm table-borderless mb-0">
          <tr><th class="ps-3 w-35">CPU (vCPU)</th><td>{vcpus}</td></tr>
          <tr><th class="ps-3">Модель CPU</th><td><small>{cpu_model}</small></td></tr>
          <tr><th class="ps-3">RAM</th><td>{get_ram(f)}</td></tr>
          <tr><th class="ps-3">RAM своб.</th><td>{f.get("memfree_mb", "—")} МБ</td></tr>
          <tr><th class="ps-3">SWAP</th><td>{f.get("swaptotal_mb", 0)} МБ</td></tr>
          <tr><th class="ps-3">Пакеты</th><td>{"%d уст." % pkg_count if pkg_count else "—"}</td></tr>
        </table>
      </div>
    </div>
  </div>
  <div class="col-lg-4">
    <div class="card h-100 shadow-sm">
      <div class="card-header bg-dark text-white fw-semibold">Сеть</div>
      <div class="card-body p-0">
        <table class="table table-sm table-borderless mb-0">
          <tr><th class="ps-3 w-35">IPv4</th><td><code>{get_ipv4(f)}</code></td></tr>
          <tr><th class="ps-3">Шлюз</th><td><code>{gateway}</code></td></tr>
          <tr><th class="ps-3">Интерфейс</th><td>{iface_name}</td></tr>
          <tr><th class="ps-3">DNS</th><td><small>{dns}</small></td></tr>
        </table>
      </div>
    </div>
  </div>
</div>"""

    ifaces_section = ""
    if iface_rows:
        ifaces_section = f"""
<h5 class="mt-4 mb-2">Сетевые интерфейсы</h5>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white shadow-sm">
    <thead class="table-secondary">
      <tr><th>Интерфейс</th><th>IPv4 / маска</th><th>MAC</th><th>MTU</th></tr>
    </thead>
    <tbody>{''.join(iface_rows)}</tbody>
  </table>
</div>"""

    mounts_section = ""
    if mount_rows:
        mounts_section = f"""
<h5 class="mt-4 mb-2">Файловые системы</h5>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white shadow-sm">
    <thead class="table-secondary">
      <tr>
        <th>Точка монтирования</th>
        <th>Устройство</th>
        <th>Тип ФС</th>
        <th>Использование</th>
        <th style="min-width:110px">Заполнение</th>
      </tr>
    </thead>
    <tbody>{''.join(mount_rows)}</tbody>
  </table>
</div>"""

    # Открытые порты
    ports_section = ""
    ports = parse_ports(f.get("ports_raw", ""))
    if ports:
        proto_badge = {
            "tcp": '<span class="badge bg-primary">TCP</span>',
            "tcp6": '<span class="badge bg-primary">TCP6</span>',
            "udp": '<span class="badge bg-warning text-dark">UDP</span>',
            "udp6": '<span class="badge bg-warning text-dark">UDP6</span>',
        }
        port_rows = "".join(
            f"<tr>"
            f"<td>{proto_badge.get(p['proto'], p['proto'])}</td>"
            f"<td class='text-end'><strong>{p['port']}</strong></td>"
            f"<td><code>{p['addr']}</code></td>"
            f"<td><small class='text-muted'>{p['state']}</small></td>"
            f"<td><small>{p['process']}</small></td>"
            f"</tr>"
            for p in ports
        )
        ports_section = f"""
<h5 class="mt-4 mb-2">Открытые порты</h5>
<div class="mb-2">
  <input type="text" id="portFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по порту или процессу…" oninput="filterTable('portFilter','portsBody')">
</div>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white shadow-sm">
    <thead class="table-secondary">
      <tr><th>Протокол</th><th class="text-end">Порт</th><th>Адрес</th><th>Состояние</th><th>Процесс</th></tr>
    </thead>
    <tbody id="portsBody">{port_rows}</tbody>
  </table>
</div>"""

    # Службы
    services_section = ""
    services = f.get("services", {})
    if services:
        state_order = {"running": 0, "stopped": 1, "failed": 2}
        sorted_svcs = sorted(
            services.values(),
            key=lambda s: (state_order.get(s.get("state", ""), 3), s.get("name", "")),
        )
        state_badge = {
            "running": '<span class="badge bg-success">running</span>',
            "stopped": '<span class="badge bg-secondary">stopped</span>',
            "failed":  '<span class="badge bg-danger">failed</span>',
        }
        status_badge = {
            "enabled":  '<span class="badge bg-success bg-opacity-75">enabled</span>',
            "disabled": '<span class="badge bg-secondary">disabled</span>',
            "static":   '<span class="badge bg-info text-dark">static</span>',
            "masked":   '<span class="badge bg-dark">masked</span>',
        }
        svc_rows = "".join(
            f"<tr>"
            f"<td><small>{s.get('name', '—')}</small></td>"
            f"<td>{state_badge.get(s.get('state', ''), s.get('state', '—'))}</td>"
            f"<td>{status_badge.get(s.get('status', ''), s.get('status', '—'))}</td>"
            f"<td><small class='text-muted'>{s.get('source', '—')}</small></td>"
            f"</tr>"
            for s in sorted_svcs
        )
        running_count = sum(1 for s in services.values() if s.get("state") == "running")
        services_section = f"""
<h5 class="mt-4 mb-2">Службы <span class="badge bg-secondary">{len(services)}</span> <span class="badge bg-success">{running_count} running</span></h5>
<div class="mb-2">
  <input type="text" id="svcFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по названию службы…" oninput="filterTable('svcFilter','svcsBody')">
</div>
<div class="table-responsive" style="max-height:400px;overflow-y:auto">
  <table class="table table-sm table-bordered bg-white shadow-sm">
    <thead class="table-secondary sticky-top">
      <tr><th>Служба</th><th>Состояние</th><th>Автозапуск</th><th>Источник</th></tr>
    </thead>
    <tbody id="svcsBody">{svc_rows}</tbody>
  </table>
</div>"""

    # Фаервол
    firewall_section = ""
    firewall_raw = f.get("firewall_raw", "").strip()
    if firewall_raw:
        firewall_section = f"""
<h5 class="mt-4 mb-2">Фаервол</h5>
<pre class="bg-white border rounded p-3 small" style="max-height:400px;overflow-y:auto">{firewall_raw}</pre>"""

    # Зависимости хоста
    host_deps_section = ""
    if deps:
        dep_rows = []
        for d in deps:
            from_link   = f"<a href='{d['from_host']}.html' class='fw-semibold text-decoration-none'>{d['from_host']}</a>"
            to_link     = f"<a href='{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
            from_svc    = d.get("from_service") or "—"
            proto       = d.get("protocol", "tcp")
            dst_port    = f"<span class='badge bg-primary'>{d['port']}/{proto}</span>" if d.get("port") else "—"
            src_badge   = "<span class='badge bg-primary'>статич.</span>" if d["source"] == "static" else "<span class='badge bg-info text-dark'>авто</span>"
            desc        = d.get("description", "")
            dep_rows.append(
                f"<tr>"
                f"<td>{from_link}</td>"
                f"<td><small class='text-muted'>{from_svc}</small></td>"
                f"<td>{to_link}</td>"
                f"<td>{dst_port}</td>"
                f"<td>{src_badge}</td>"
                f"<td><small class='text-muted'>{desc}</small></td>"
                f"</tr>"
            )

        col_heads = "<tr><th>Источник</th><th>Служба источника</th><th>Назначение</th><th>Порт назначения</th><th>Тип</th><th>Описание</th></tr>"
        dep_table = ""
        if dep_rows:
            dep_table = f"""
<div class="table-responsive mb-3">
  <table class="table table-sm table-bordered bg-white shadow-sm">
    <thead class="table-secondary">{col_heads}</thead>
    <tbody>{''.join(dep_rows)}</tbody>
  </table>
</div>"""

        host_mermaid = host_deps_mermaid(hostname, deps, ext_deps)
        diagram = ""
        if host_mermaid:
            diagram = f"""
<div class="bg-white border rounded p-3 shadow-sm mb-3 text-center">
  <div class="mermaid">
{host_mermaid}
  </div>
</div>"""

        if diagram or dep_table:
            host_deps_section = f"""
<h5 class="mt-4 mb-2">Зависимости
  <a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
{diagram}
{dep_table}"""
    elif ext_deps:
        host_mermaid = host_deps_mermaid(hostname, [], ext_deps)
        if host_mermaid:
            host_deps_section = f"""
<h5 class="mt-4 mb-2">Зависимости
  <a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="bg-white border rounded p-3 shadow-sm mb-3 text-center">
  <div class="mermaid">
{host_mermaid}
  </div>
</div>"""

    # Внутрихостовые зависимости
    host_intra_deps_section = ""
    if intra_deps:
        intra_rows = []
        for d in sorted(intra_deps, key=lambda x: (x["port"], x["client_service"])):
            client_svc  = d.get("client_service") or "—"
            server_svc  = d.get("server_service") or "—"
            port_badge  = f"<span class='badge bg-primary'>{d['port']}/tcp</span>"
            intra_rows.append(
                f"<tr>"
                f"<td><small class='text-muted'>{client_svc}</small></td>"
                f"<td><small class='text-muted'>{server_svc}</small></td>"
                f"<td>{port_badge}</td>"
                f"<td class='text-end text-muted'><small>{d['count']}</small></td>"
                f"</tr>"
            )
        if intra_rows:
            host_intra_deps_section = f"""
<h5 class="mt-4 mb-2">Внутрихостовые зависимости
  <span class="badge bg-secondary">{len(intra_rows)}</span>
  <a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white shadow-sm">
    <thead class="table-secondary">
      <tr><th>Клиент (служба)</th><th>Сервер (служба)</th><th>Порт</th><th class="text-end">Соед.</th></tr>
    </thead>
    <tbody>{''.join(intra_rows)}</tbody>
  </table>
</div>"""

    # Внешние локальные подключения на странице хоста
    host_ext_local_section = ""
    host_ext_remote_section = ""
    if ext_deps:
        local_host_deps = [d for d in ext_deps if d["ext_type"] == "local"]
        if local_host_deps:
            local_groups: dict = {}
            for d in local_host_deps:
                key = (d["to_port"], d.get("proto", "tcp"), d["to_service"])
                local_groups.setdefault(key, {})
                local_groups[key][d["subnet"]] = local_groups[key].get(d["subnet"], 0) + d["count"]
            local_rows = []
            for (to_port, proto, to_svc), subnets in sorted(local_groups.items()):
                svc_cell   = f"<small class='text-muted'>{to_svc}</small>" if to_svc != "—" else "—"
                port_badge = f"<span class='badge bg-primary'>{to_port}/{proto}</span>"
                total      = sum(subnets.values())
                subnet_badges = " ".join(
                    f"<span class='badge bg-warning text-dark me-1'>{sn}</span>"
                    f"<small class='text-muted me-2'>×{cnt}</small>"
                    for sn, cnt in sorted(subnets.items())
                )
                local_rows.append(
                    f"<tr>"
                    f"<td>{svc_cell}</td>"
                    f"<td>{port_badge}</td>"
                    f"<td>{subnet_badges}</td>"
                    f"<td class='text-end text-muted'><small>{total}</small></td>"
                    f"</tr>"
                )
            if local_rows:
                host_ext_local_section = f"""
<h5 class="mt-4 mb-2" style="color:#155724">Внешние локальные подключения
  <span class="badge bg-success">{len(local_rows)}</span>
  <a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="mb-2">
  <input type="text" id="hostExtLocalFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по службе или подсети…" oninput="filterTable('hostExtLocalFilter','hostExtLocalBody')">
</div>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white shadow-sm" style="border-color:#28a745">
    <thead style="background-color:#d4edda">
      <tr><th>Служба</th><th>Порт</th><th>Источники (подсеть × кол-во)</th><th class="text-end">Всего</th></tr>
    </thead>
    <tbody id="hostExtLocalBody">{''.join(local_rows)}</tbody>
  </table>
</div>"""

        remote_host_deps = [d for d in ext_deps if d["ext_type"] == "remote"]
        if remote_host_deps:
            remote_groups: dict = {}
            for d in remote_host_deps:
                key = (d["to_port"], d.get("proto", "tcp"), d["to_service"])
                remote_groups[key] = remote_groups.get(key, 0) + d["count"]
            remote_rows = []
            for (to_port, proto, to_svc), total in sorted(remote_groups.items()):
                svc_cell   = f"<small class='text-muted'>{to_svc}</small>" if to_svc != "—" else "—"
                port_badge = f"<span class='badge bg-primary'>{to_port}/{proto}</span>"
                remote_rows.append(
                    f"<tr>"
                    f"<td>{svc_cell}</td>"
                    f"<td>{port_badge}</td>"
                    f"<td class='text-end text-muted'><small>{total}</small></td>"
                    f"</tr>"
                )
            if remote_rows:
                host_ext_remote_section = f"""
<h5 class="mt-4 mb-2" style="color:#856404">Внешние удалённые подключения
  <span class="badge bg-warning text-dark">{len(remote_rows)}</span>
  <a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="mb-2">
  <input type="text" id="hostExtRemoteFilter" class="form-control form-control-sm w-auto d-inline-block"
         placeholder="Фильтр по службе или порту…" oninput="filterTable('hostExtRemoteFilter','hostExtRemoteBody')">
</div>
<div class="table-responsive">
  <table class="table table-sm table-bordered bg-white shadow-sm" style="border-color:#fd7e14">
    <thead style="background-color:#fff3cd">
      <tr><th>Служба</th><th>Порт</th><th class="text-end">Всего соединений</th></tr>
    </thead>
    <tbody id="hostExtRemoteBody">{''.join(remote_rows)}</tbody>
  </table>
</div>"""

    mermaid_script = ""
    if host_deps_section:
        mermaid_script = """<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({startOnLoad: true, theme: 'default'});</script>"""

    body = f"""
<nav aria-label="breadcrumb" class="mb-3">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
    <li class="breadcrumb-item active">{hostname}</li>
  </ol>
</nav>
<h4 class="mb-3">{hostname}</h4>
{cards}
{ifaces_section}
{mounts_section}
{ports_section}
{services_section}
{firewall_section}
{host_deps_section}
{host_intra_deps_section}
{host_ext_local_section}
{host_ext_remote_section}"""

    return page(f"{hostname} — факты", body, depth=1, extra_scripts=mermaid_script)


# ── Точка входа ────────────────────────────────────────────────────────────────

def main() -> None:
    if not FACTS_DIR.exists():
        sys.exit(f"Ошибка: директория '{FACTS_DIR}' не найдена")

    hosts = load_facts(FACTS_DIR)
    if not hosts:
        sys.exit(f"Ошибка: в '{FACTS_DIR}' нет JSON-файлов")

    OUTPUT_DIR.mkdir(exist_ok=True)
    (OUTPUT_DIR / "hosts").mkdir(exist_ok=True)

    # Зависимости: статические + автоопределённые + внутрихостовые + внешние
    static_deps     = load_service_map()
    ip_to_host      = build_ip_to_host(hosts)
    auto_deps       = detect_auto_deps(hosts, ip_to_host)
    intra_deps      = detect_intra_deps(hosts)
    ext_deps        = detect_external_deps(hosts, ip_to_host)
    all_deps        = static_deps + auto_deps
    global_services = load_global_services()

    (OUTPUT_DIR / "index.html").write_text(index_page(hosts, static_deps, auto_deps, ext_deps, intra_deps, global_services), encoding="utf-8")
    print(f"index.html — {len(hosts)} хостов")

    for hostname, facts in hosts.items():
        host_deps = [d for d in all_deps if d["from_host"] == hostname or d["to_host"] == hostname]
        host_intra = [d for d in intra_deps if d["host"] == hostname]
        host_ext = [d for d in ext_deps if d["to_host"] == hostname]
        out = OUTPUT_DIR / "hosts" / f"{hostname}.html"
        out.write_text(host_page(hostname, facts, host_deps, host_intra, host_ext), encoding="utf-8")
        print(f"  hosts/{hostname}.html")

    vmw = load_vmware_facts()
    if vmw["esxi_hosts"] or vmw["vms"] or vmw["datastores"]:
        (OUTPUT_DIR / "vmware.html").write_text(vmware_page(vmw), encoding="utf-8")
        print(f"vmware.html — {len(vmw['esxi_hosts'])} ESXi, {len(vmw['vms'])} VM, {len(vmw['datastores'])} датасторов")
    else:
        print("vmware.html — пропущено (нет данных в vmware_facts/)")

    containers_exist = any(parse_docker_containers(f) for f in hosts.values())
    if containers_exist:
        (OUTPUT_DIR / "containers.html").write_text(docker_page(hosts), encoding="utf-8")
        total = sum(len(parse_docker_containers(f)) for f in hosts.values())
        print(f"containers.html — {total} контейнеров")
    else:
        print("containers.html — пропущено (нет данных о контейнерах)")

    ext_local  = sum(1 for d in ext_deps if d["ext_type"] == "local")
    ext_remote = sum(1 for d in ext_deps if d["ext_type"] == "remote")
    (OUTPUT_DIR / "dependencies.html").write_text(deps_page(static_deps, auto_deps, ext_deps, intra_deps), encoding="utf-8")
    print(f"dependencies.html — {len(static_deps)} статических, {len(auto_deps)} авто, {len(intra_deps)} внутрихостовых, {ext_local} внешних локальных, {ext_remote} внешних удалённых")

    (OUTPUT_DIR / "external_connections.html").write_text(external_page(ext_deps), encoding="utf-8")
    ext_hosts = len({d["to_host"] for d in ext_deps})
    ext_svcs  = len({(d["to_host"], d["to_service"]) for d in ext_deps})
    print(f"external_connections.html — {ext_hosts} хостов, {ext_svcs} служб, {ext_local} локальных, {ext_remote} удалённых")

    (OUTPUT_DIR / "services").mkdir(exist_ok=True)
    (OUTPUT_DIR / "services" / "index.html").write_text(
        services_index_page(global_services, hosts, all_deps), encoding="utf-8"
    )
    for svc in global_services:
        out = OUTPUT_DIR / "services" / f"{svc['slug']}.html"
        out.write_text(service_detail_page(svc, hosts, all_deps), encoding="utf-8")
        print(f"  services/{svc['slug']}.html")
    print(f"services/index.html — {len(global_services)} сервисов")

    print(f"\nГотово → {OUTPUT_DIR}/")


if __name__ == "__main__":
    main()
