"""Connect-scan TCP concurrente de un host usando SOLO stdlib (socket + threads).

Funcion IMPURA: abre conexiones TCP (connect) contra una lista o rango de
puertos de un host, en paralelo con un ThreadPoolExecutor. NO requiere nmap ni
sudo: es un connect-scan simple (full three-way handshake), por lo que no es
sigiloso pero funciona desde cualquier entorno Python sin privilegios.

Complementa a `nmap_scan` cuando no se quiere/puede usar nmap o se busca un
escaneo rapido en Python puro. A diferencia de nmap_scan, NO detecta version de
servicio: solo reporta el estado del puerto (open/closed/filtered).

NO lanza excepciones: devuelve un dict con `status` "ok" o "error" y un campo
`raw` legible pensado para guardar como evidencia OSINT. Solo escanear hosts
autorizados/propios.
"""

import socket
from concurrent.futures import ThreadPoolExecutor, as_completed

# ~30 puertos TCP comunes para el preset "common".
_COMMON_PORTS = [
    21, 22, 23, 25, 53, 80, 110, 111, 135, 139, 143, 443, 445, 993, 995,
    1723, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9200, 11211, 27017,
    1433, 2049, 5060, 8000,
]


def _parse_ports(ports) -> list[int]:
    """Normaliza la especificacion de puertos a una lista ordenada de ints unicos.

    Acepta cuatro formas:
        - lista de ints: ``[22, 80, 443]``
        - string preset: ``"common"`` (~30 puertos comunes)
        - string rango: ``"1-1024"``
        - string CSV: ``"22,80,443"`` (admite tambien rangos mezclados:
          ``"22,80,8000-8010"``)

    Args:
        ports: lista de ints o string en una de las formas anteriores.

    Returns:
        Lista ordenada de ints unicos en el rango valido 1..65535.

    Raises:
        ValueError: si el formato es invalido o no quedan puertos validos.
            (Uso interno; `scan_tcp_ports` lo captura y devuelve status error.)
    """
    if isinstance(ports, (list, tuple, set)):
        out = set()
        for p in ports:
            pi = int(p)
            if 1 <= pi <= 65535:
                out.add(pi)
        if not out:
            raise ValueError("lista de puertos vacia o sin puertos validos (1..65535)")
        return sorted(out)

    if not isinstance(ports, str):
        raise ValueError(f"ports debe ser str o lista de ints, no {type(ports).__name__}")

    spec = ports.strip().lower()
    if not spec:
        raise ValueError("spec de puertos vacia")

    if spec == "common":
        return sorted(set(_COMMON_PORTS))

    out = set()
    for chunk in spec.split(","):
        chunk = chunk.strip()
        if not chunk:
            continue
        if "-" in chunk:
            lo_s, hi_s = chunk.split("-", 1)
            lo, hi = int(lo_s), int(hi_s)
            if lo > hi:
                lo, hi = hi, lo
            for pi in range(lo, hi + 1):
                if 1 <= pi <= 65535:
                    out.add(pi)
        else:
            pi = int(chunk)
            if 1 <= pi <= 65535:
                out.add(pi)
    if not out:
        raise ValueError(f"no se obtuvieron puertos validos de '{ports}'")
    return sorted(out)


def _probe_port(ip: str, port: int, timeout_s: float) -> str:
    """Sondea un puerto TCP via connect y clasifica su estado.

    Returns:
        "open"     -> connect_ex == 0 (handshake completo).
        "closed"   -> RST / ConnectionRefused.
        "filtered" -> timeout o host inalcanzable (probable firewall).
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(timeout_s)
            rc = sock.connect_ex((ip, port))
            if rc == 0:
                return "open"
            # ECONNREFUSED (111 Linux / 10061 Win) -> puerto cerrado pero host vivo.
            if rc in (111, 10061):
                return "closed"
            return "filtered"
    except (socket.timeout, TimeoutError):
        return "filtered"
    except (ConnectionRefusedError, OSError):
        return "closed"


def scan_tcp_ports(
    host: str,
    ports="common",
    timeout_s: float = 1.0,
    workers: int = 100,
) -> dict:
    """Escanea puertos TCP de un host por connect-scan concurrente (stdlib).

    Resuelve `host` a IP, parsea la spec de puertos y lanza un connect TCP por
    cada puerto en paralelo (ThreadPoolExecutor). Clasifica cada puerto como
    open / closed / filtered y agrega los resultados.

    Args:
        host: Hostname o IP objetivo (ej. "scanme.nmap.org", "127.0.0.1"). Se
            resuelve con socket.gethostbyname; si no resuelve, status error.
        ports: Especificacion de puertos. Acepta lista de ints ([22, 80, 443]),
            string preset "common" (~30 puertos comunes, default), string rango
            "1-1024", o string CSV "22,80,443" (con rangos mezclados
            "22,80,8000-8010"). Spec invalida devuelve status error.
        timeout_s: Timeout por conexion TCP en segundos. Default 1.0. Bajo en
            redes lentas puede marcar puertos abiertos como filtered.
        workers: Numero de hilos concurrentes. Default 100. Se acota a >=1 y al
            numero de puertos a escanear. Valores muy altos pueden saturar
            descriptores de archivo o la red.

    Returns:
        Dict con status "ok" o "error". Nunca lanza.
        ok::

            {
                "status": "ok",
                "host": <host>,
                "ip": <ip resuelta>,
                "ports_scanned": <int>,
                "open": [<int>, ...],          # ordenada
                "closed_count": <int>,
                "filtered_count": <int>,
                "results": [{"port": int, "state": str}, ...],  # ordenado por puerto
                "raw": <bloque PORT/STATE legible para evidencia OSINT>,
            }

        error (host no resuelve, spec invalida)::

            {"status": "error", "error": <str>, "host": <host>}
    """
    if not host or not host.strip():
        return {"status": "error", "error": "scan_tcp_ports: host vacio", "host": host}

    host = host.strip()

    # Parsear puertos.
    try:
        port_list = _parse_ports(ports)
    except (ValueError, TypeError) as exc:
        return {
            "status": "error",
            "error": f"scan_tcp_ports: spec de puertos invalida: {exc}",
            "host": host,
        }

    # Resolver host a IP.
    try:
        ip = socket.gethostbyname(host)
    except socket.gaierror as exc:
        return {
            "status": "error",
            "error": f"scan_tcp_ports: no se pudo resolver host '{host}': {exc}",
            "host": host,
        }

    n_workers = max(1, min(int(workers), len(port_list)))

    # Sondeo concurrente.
    states: dict[int, str] = {}
    with ThreadPoolExecutor(max_workers=n_workers) as pool:
        futures = {
            pool.submit(_probe_port, ip, port, timeout_s): port
            for port in port_list
        }
        for fut in as_completed(futures):
            port = futures[fut]
            try:
                states[port] = fut.result()
            except Exception:  # noqa: BLE001 - un probe nunca debe tumbar el scan
                states[port] = "filtered"

    results = [{"port": p, "state": states[p]} for p in sorted(states)]
    open_ports = sorted(p for p, st in states.items() if st == "open")
    closed_count = sum(1 for st in states.values() if st == "closed")
    filtered_count = sum(1 for st in states.values() if st == "filtered")

    # Bloque legible para evidencia (solo open/filtered; los closed se omiten
    # para que el raw sea util sin ahogarlo en cientos de "closed").
    raw_lines = ["PORT      STATE"]
    for r in results:
        if r["state"] != "closed":
            raw_lines.append(f"{r['port']:<5}/tcp {r['state']}")
    raw = "\n".join(raw_lines)

    return {
        "status": "ok",
        "host": host,
        "ip": ip,
        "ports_scanned": len(port_list),
        "open": open_ports,
        "closed_count": closed_count,
        "filtered_count": filtered_count,
        "results": results,
        "raw": raw,
    }


if __name__ == "__main__":
    # Smoke: scan rapido contra el host oficial de pruebas de nmap (legal escanear).
    # Tolera fallo de red sin romper.
    try:
        result = scan_tcp_ports("scanme.nmap.org", ports="common", timeout_s=2.0)
        print(result["status"])
        if result["status"] == "ok":
            print(f"[ok] {result['host']} ({result['ip']}) "
                  f"escaneados={result['ports_scanned']} abiertos={result['open']}")
            print("--- raw ---")
            print(result["raw"])
        else:
            print("error:", result.get("error"))
    except Exception as exc:  # noqa: BLE001 - smoke nunca debe romper
        print("smoke fallo (tolerado):", exc)
