"""Pipeline scan_port_services.

One-shot que materializa el flujo "escanear los servicios de los diferentes
puertos de un host": escanea puertos TCP y, para cada puerto ABIERTO, obtiene
(a) el servicio que se espera por convencion IANA en ese puerto y (b) el
servicio/version REAL leyendo el banner del servicio en vivo. Opcionalmente
archiva la evidencia en OSINT.

Convierte el patron de N llamadas (1 scan + 2*K por cada puerto abierto:
identify_port_service + grab_service_banner) en una sola invocacion. Compone
funciones del registry del dominio cybersecurity; no reescribe ninguna logica
de escaneo, identificacion ni persistencia.

Funciones del registry compuestas (importadas, no reimplementadas):
    scan_tcp_ports, identify_port_service, grab_service_banner,
    save_scan_to_osint
"""

from cybersecurity import (
    scan_tcp_ports,
    identify_port_service,
    grab_service_banner,
    save_scan_to_osint,
)


def _build_raw(host: str, ip: str, services: list[dict]) -> str:
    """Construye una tabla legible PORT / EXPECTED / ACTUAL / BANNER para evidencia.

    Args:
        host: host objetivo del escaneo.
        ip: IP resuelta del host.
        services: lista de dicts de servicio (ver scan_port_services).

    Returns:
        Bloque de texto multi-linea con cabecera y una fila por puerto abierto.
    """
    header = f"# scan_port_services {host} ({ip})"
    cols = f"{'PORT':<8}{'EXPECTED':<16}{'ACTUAL':<16}BANNER"
    lines = [header, "", cols]
    for s in services:
        port = str(s.get("port", ""))
        expected = str(s.get("expected_service") or "")
        actual = str(s.get("actual_service") or "")
        banner = (s.get("banner") or "").replace("\r", " ").replace("\n", " ").strip()
        if len(banner) > 80:
            banner = banner[:77] + "..."
        lines.append(f"{port:<8}{expected:<16}{actual:<16}{banner}")
    return "\n".join(lines)


def scan_port_services(
    host: str,
    ports: "str | list[int]" = "common",
    timeout_s: float = 1.0,
    workers: int = 100,
    grab_banners: bool = True,
    banner_timeout_s: float = 3.0,
    save: bool = True,
) -> dict:
    """Escanea puertos de un host e identifica el servicio (esperado y real) de cada uno.

    Compone, en un solo paso:
      1. ``scan_tcp_ports(host, ports, ...)`` para hallar los puertos abiertos.
      2. Por cada puerto abierto, ``identify_port_service(port)`` (servicio que
         la convencion IANA espera ahi) y, si ``grab_banners`` es True,
         ``grab_service_banner(host, port, ...)`` (servicio/version REAL leido
         del banner en vivo).
      3. Si ``save`` es True, archiva una tabla de evidencia en OSINT via
         ``save_scan_to_osint`` con ``scan_type="port_services"``.

    Nunca lanza excepciones: cualquier fallo se refleja en la clave ``status``
    del dict devuelto.

    Args:
        host: hostname o IP objetivo (ej. "127.0.0.1", "scanme.nmap.org").
        ports: especificacion de puertos, se pasa tal cual a scan_tcp_ports.
            Acepta lista de ints ([22, 80, 443]), string preset "common"
            (~30 puertos comunes), string rango "1-1024" o string CSV
            "22,80,443" (con rangos mezclados).
        timeout_s: timeout por conexion TCP del escaneo de puertos, en segundos.
            Default 1.0.
        workers: numero de hilos concurrentes del escaneo. Default 100.
        grab_banners: si True (default) llama grab_service_banner por cada
            puerto abierto para identificar el servicio/version real; si False
            solo usa identify_port_service (sin tocar el servicio en vivo -> mas
            rapido y mas sigiloso, sin segunda ronda de conexiones).
        banner_timeout_s: timeout del grab de banner por puerto, en segundos.
            Default 3.0. Solo aplica si grab_banners=True.
        save: si True (default) archiva la evidencia en OSINT via
            save_scan_to_osint (scan_type="port_services"); si False solo
            ejecuta el escaneo y no toca el vault ni el service. Si el sink
            falla, el resultado degrada sin romper (osint.status="error").

    Returns:
        dict de estado. Nunca lanza.
        ok::

            {
                "status": "ok",
                "host": <host>,
                "ip": <ip resuelta>,
                "open_ports": [22, 5432, 6379],
                "services": [
                    {
                        "port": 22,
                        "expected_service": "ssh",
                        "expected_desc": "Secure Shell",
                        "actual_service": "ssh",   # None si grab_banners=False
                        "product": "OpenSSH",      # "" si no se grabo banner
                        "version": "9.6p1",        # "" si no se grabo banner
                        "banner": "SSH-2.0-OpenSSH_9.6p1 ...",  # "" si no se grabo
                        "match": True,             # expected==actual; None si no se grabo
                    },
                    ...
                ],
                "saved": <dict de save_scan_to_osint> | None,
                "raw": "# scan_port_services ...\nPORT  EXPECTED ...",
            }

        error (el escaneo de puertos fallo: host no resuelve, spec invalida)::

            {"status": "error", "stage": "scan", "scan": <dict del scan>}
    """
    # 1. Escaneo de puertos. Si falla, propagamos sin continuar.
    scan = scan_tcp_ports(host, ports=ports, timeout_s=timeout_s, workers=workers)
    if scan.get("status") != "ok":
        return {"status": "error", "stage": "scan", "scan": scan}

    ip = scan.get("ip", "")
    open_ports = list(scan.get("open") or [])

    # 2. Por cada puerto abierto: servicio esperado (puro) + servicio real (banner).
    #    Secuencial a proposito (KISS): los puertos abiertos suelen ser pocos y
    #    cada grab ya tiene su propio timeout acotado.
    services: list[dict] = []
    for port in open_ports:
        expected = identify_port_service(port, proto="tcp")
        entry = {
            "port": port,
            "expected_service": expected.get("service"),
            "expected_desc": expected.get("description"),
            "actual_service": None,
            "product": "",
            "version": "",
            "banner": "",
            "match": None,
        }

        if grab_banners:
            banner_res = grab_service_banner(
                host, port, timeout_s=banner_timeout_s, send_probe=True
            )
            if banner_res.get("status") == "ok":
                actual = banner_res.get("service")
                entry["actual_service"] = actual
                entry["product"] = banner_res.get("product", "")
                entry["version"] = banner_res.get("version", "")
                entry["banner"] = banner_res.get("banner", "")
                # match solo es significativo si ambos servicios son concretos.
                exp = expected.get("service")
                entry["match"] = (
                    exp == actual
                    if actual and actual != "unknown" and exp and exp != "unknown"
                    else False
                )
            else:
                # El grab fallo (timeout/refused): dejamos actual como "unknown"
                # para distinguirlo de "no se intento" (None con grab_banners=False).
                entry["actual_service"] = "unknown"
                entry["match"] = False

        services.append(entry)

    raw = _build_raw(host, ip, services)

    # 3. Archiva la evidencia en OSINT si procede (degrada sin romper).
    saved = None
    if save:
        summary = {
            "open_ports": open_ports,
            "services_detected": [
                f"{s['port']}:{s.get('actual_service') or s.get('expected_service')}"
                for s in services
            ],
        }
        saved = save_scan_to_osint(
            host,
            "port_services",
            raw,
            summary=summary,
            tool="scan_port_services",
        )

    return {
        "status": "ok",
        "host": host,
        "ip": ip,
        "open_ports": open_ports,
        "services": services,
        "saved": saved,
        "raw": raw,
    }


def _parse_cli(argv: list[str]) -> dict:
    """Parsea los args de CLI: <host> [ports] [--no-banners] [--no-save].

    Devuelve un dict de kwargs para scan_port_services.
    """
    positional: list[str] = []
    grab_banners = True
    save = True

    for arg in argv:
        if arg == "--no-banners":
            grab_banners = False
        elif arg == "--no-save":
            save = False
        else:
            positional.append(arg)

    return {"positional": positional, "grab_banners": grab_banners, "save": save}


if __name__ == "__main__":
    import sys

    parsed = _parse_cli(sys.argv[1:])
    positional = parsed["positional"]
    host = positional[0] if len(positional) >= 1 else "127.0.0.1"
    ports = positional[1] if len(positional) >= 2 else "common"

    try:
        result = scan_port_services(
            host,
            ports=ports,
            grab_banners=parsed["grab_banners"],
            save=parsed["save"],
        )
        print("status:", result.get("status"))
        if result.get("status") == "ok":
            print(f"host: {result['host']} ({result['ip']})")
            print("open_ports:", result["open_ports"])
            print("--- services ---")
            print(result["raw"])
            saved = result.get("saved") or {}
            if saved:
                print("note_path:", saved.get("note_path"))
                print("registered:", saved.get("registered"))
        else:
            print("error:", result.get("scan", {}).get("error"))
    except Exception as exc:  # noqa: BLE001 - smoke nunca debe romper
        print("smoke exception (tolerada):", repr(exc))
