"""
Network-exposure scoring from Wazuh syscollector ports.

Tester request: a host running an exposed remote-control or legacy-
cleartext listener (VNC, RDP, Telnet, SMB, …) is network-vulnerabler
regardless of its CVE count. This service walks the open LISTENING
sockets per asset, classifies the risky ones, and computes a 0-100
exposure score stored on the asset as an extra risk dimension.

The score is informational — it does NOT auto-change asset
criticality (operator-owned). It's surfaced as a badge + a list of
the risky services so the operator can decide.
"""
from __future__ import annotations

import json
import logging
from datetime import datetime
from typing import Dict, List, Optional

from sqlalchemy.orm import Session

from app.models.asset import Asset

logger = logging.getLogger(__name__)


# port → (service label, base risk weight 0-40). Higher = worse to expose.
# Remote-control + cleartext-admin protocols rank highest.
#
# Weights rebalanced (tester: almost every Windows host hit 100 because
# baseline Windows services — SMB/MSRPC/NetBIOS/WinRM — were weighted like
# real exposures). Baseline Windows services are now LOW; genuine remote-
# control / cleartext-admin exposures stay HIGH. Crown-jewel ROLES (DC,
# ADCS, SQL, Exchange, …) are scored separately by risk_dimensions_service.
_RISKY_PORTS: Dict[int, tuple] = {
    23:   ("Telnet (cleartext)", 40),
    3389: ("RDP", 30),
    5900: ("VNC", 35), 5901: ("VNC", 35), 5902: ("VNC", 30),
    5903: ("VNC", 30), 5904: ("VNC", 30), 5905: ("VNC", 30),
    21:   ("FTP (cleartext)", 25),
    512:  ("rexec", 30), 513: ("rlogin", 30), 514: ("rsh", 30),
    27017: ("MongoDB", 24), 6379: ("Redis", 26), 9200: ("Elasticsearch", 20),
    11211: ("Memcached", 24),
    1433: ("MSSQL", 18), 3306: ("MySQL", 18), 5432: ("PostgreSQL", 18),
    161:  ("SNMP", 14),
    2049: ("NFS", 16),
    5985: ("WinRM-HTTP", 12), 5986: ("WinRM-HTTPS", 8),
    445:  ("SMB", 10),
    389:  ("LDAP (cleartext)", 8),
    135:  ("MSRPC", 6),
    139:  ("NetBIOS", 6),
    22:   ("SSH", 6),
    8834: ("Nessus", 8),
}

# Listeners bound to these local IPs are NOT network-exposed.
_LOOPBACK_PREFIXES = ("127.", "::1", "0.0.0.0")  # 0.0.0.0 = all-iface = exposed, handled below


def _is_externally_bound(local_ip: str) -> bool:
    """True if the listener is reachable from the network (not loopback)."""
    ip = (local_ip or "").strip()
    if not ip:
        return True  # unknown bind → assume exposed (conservative)
    if ip.startswith("127.") or ip == "::1":
        return False
    return True  # 0.0.0.0 / :: / real IP = exposed


def analyze_ports(ports: List[dict]) -> tuple:
    """Classify listening sockets → (exposure_score, services list).

    services: [{port, proto, service, risk, local_ip}]
    score: 0-100 — highest single risk + diminishing add-on for the rest.
    """
    risky: List[dict] = []
    seen: set = set()
    for p in ports:
        if not isinstance(p, dict):
            continue
        # Only listening sockets count as exposure.
        state = str(p.get("state") or "").lower()
        proto = str(p.get("protocol") or p.get("proto") or "").lower()
        if proto == "tcp" and state and state != "listening":
            continue
        try:
            port = int(p.get("local_port") or p.get("local", {}).get("port") or 0)
        except (ValueError, TypeError):
            continue
        if port <= 0 or port not in _RISKY_PORTS:
            continue
        local_ip = p.get("local_ip") or (p.get("local") or {}).get("ip") or ""
        if not _is_externally_bound(local_ip):
            continue
        label, weight = _RISKY_PORTS[port]
        key = (port, proto)
        if key in seen:
            continue
        seen.add(key)
        risky.append({
            "port": port,
            "proto": proto or "tcp",
            "service": label,
            "risk": weight,
            "local_ip": local_ip or "0.0.0.0",
            "process": p.get("process") or "",
        })

    if not risky:
        return 0.0, []

    # Score: strongest listener at full weight, each additional one adds
    # 40% of its weight, capped 100. So one VNC = 35; VNC+RDP+Telnet
    # stacks toward 100 (clearly very exposed).
    risky.sort(key=lambda r: r["risk"], reverse=True)
    score = float(risky[0]["risk"])
    for r in risky[1:]:
        score += r["risk"] * 0.4
    score = round(min(score, 100.0), 1)
    return score, risky


def refresh_asset_exposure(db: Session, wazuh, asset: Asset) -> Optional[dict]:
    """Pull ports for one asset, compute + persist exposure. Returns
    {score, count} or None when no agent / fetch failed."""
    if not asset.wazuh_agent_id:
        return None
    try:
        ports = wazuh.get_ports(asset.wazuh_agent_id) or []
    except Exception as e:
        logger.warning("exposure: ports fetch failed for %s: %s", asset.hostname, e)
        return None
    score, services = analyze_ports(ports)
    asset.network_exposure_score = score
    asset.exposed_services = json.dumps(services) if services else None
    asset.exposure_updated_at = datetime.now()

    # Risk Dimensions (crown-jewel roles) — reuse the ports already fetched
    # plus the package list; no extra Wazuh round-trip beyond get_packages.
    hv_score = 0.0
    try:
        from app.services.risk_dimensions_service import detect_risk_dimensions
        try:
            packages = wazuh.get_packages(asset.wazuh_agent_id) or []
        except Exception:
            packages = []
        rd = detect_risk_dimensions(ports, packages)
        hv_score = rd["score"]
        asset.high_value_score = rd["score"]
        asset.risk_dimensions = json.dumps(rd["dimensions"]) if rd["dimensions"] else None
        asset.risk_dimensions_updated_at = datetime.now()
    except Exception as e:
        logger.warning("risk-dimensions failed for %s: %s", asset.hostname, e)

    return {"score": score, "count": len(services), "high_value_score": hv_score}


def refresh_all_exposure(db: Session, wazuh) -> dict:
    """Walk every Wazuh-linked asset, refresh exposure. Returns stats."""
    assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
    stats = {"assets": 0, "exposed": 0, "errors": 0}
    for asset in assets:
        res = refresh_asset_exposure(db, wazuh, asset)
        if res is None:
            stats["errors"] += 1
            continue
        stats["assets"] += 1
        if res["score"] > 0:
            stats["exposed"] += 1
    db.commit()
    logger.info("exposure refresh: %s", stats)
    return stats
