from pathlib import Path
import json
import socket
from contextlib import closing
from threading import Event, Thread

from sentra.discovery import (
    HostDiscoveryResult,
    LineSummaryParser,
    PassiveDiscoveryRequest,
    PassiveDiscoveryResult,
    collect_passive_observations,
)
from sentra.discovery.modules import DiscoveryModuleContext
from sentra.discovery.network import scan_network
from sentra.models import DiscoveryProviderRecord, PassiveObservation, RawEvidence, ServiceEvidence
from sentra.plugins import register_protocol_analyzer, reset_protocol_analyzers
from sentra.state import cache_path, resume_state_path


def setup_function() -> None:
    reset_protocol_analyzers()


def teardown_function() -> None:
    reset_protocol_analyzers()


def test_scan_network_detects_open_local_port(tmp_path: Path) -> None:
    ready = Event()
    stop = Event()
    server = Thread(target=_serve_once, args=(ready, stop), daemon=True)
    server.start()
    ready.wait(timeout=2)

    port = ready.port  # type: ignore[attr-defined]
    artifact = scan_network("127.0.0.1/32", ports=[port], timeout=0.5, workspace=tmp_path)

    stop.set()
    server.join(timeout=2)

    assert len(artifact.devices) == 1
    device = artifact.devices[0]
    assert device.ip_address == "127.0.0.1"
    assert [service.port for service in device.services] == [port]
    assert device.services[0].confidence > 0.0
    assert cache_path(tmp_path).exists()
    assert not resume_state_path(tmp_path).exists()


def test_scan_network_returns_no_devices_for_closed_ports(tmp_path: Path) -> None:
    artifact = scan_network("127.0.0.1/32", ports=[65500], timeout=0.05, workspace=tmp_path)
    assert artifact.devices == []
    assert artifact.cache_hits == 0


def test_scan_network_rejects_large_subnets() -> None:
    try:
        scan_network("10.0.0.0/23", ports=[80], max_hosts=8)
    except ValueError as exc:
        assert "limit is 8" in str(exc)
    else:
        raise AssertionError("Expected subnet size validation to fail.")


def test_protocol_analyzer_hooks_can_enrich_service_metadata(tmp_path: Path) -> None:
    ready = Event()
    stop = Event()
    server = Thread(target=_serve_once, args=(ready, stop), daemon=True)
    server.start()
    ready.wait(timeout=2)

    port = ready.port  # type: ignore[attr-defined]
    register_protocol_analyzer(CustomAnalyzer(port))
    artifact = scan_network("127.0.0.1/32", ports=[port], timeout=0.5, workspace=tmp_path)

    stop.set()
    server.join(timeout=2)

    service = artifact.devices[0].services[0]
    assert service.service == "custom-http"
    assert service.confidence == 0.97
    assert service.fingerprint_source == "test-analyzer"


def test_repeated_scan_reuses_cached_metadata(tmp_path: Path, monkeypatch) -> None:
    calls = {"count": 0}

    def fake_port_open(host: str, port: int, timeout: float) -> bool:
        return port == 8080

    def fake_analyzer(host: str, service):  # type: ignore[no-untyped-def]
        calls["count"] += 1
        return service.model_copy(
            update={"service": "http", "confidence": 0.91, "fingerprint_source": "cache-test"}
        )

    register_protocol_analyzer(_CallableAnalyzer("cache-test", fake_analyzer))
    monkeypatch.setattr("sentra.discovery.network._is_tcp_port_open", fake_port_open)

    first = scan_network("127.0.0.1/32", ports=[8080], workspace=tmp_path)
    second = scan_network("127.0.0.1/32", ports=[8080], workspace=tmp_path)

    assert first.cache_hits == 0
    assert second.cache_hits == 1
    assert second.devices[0].services[0].fingerprint_source == "cache-test"
    assert second.devices[0].services[0].confidence == 0.91
    assert calls["count"] == 1


def test_scan_applies_fingerprint_overrides_and_offline_vulns(tmp_path: Path, monkeypatch) -> None:
    override_path = tmp_path / ".sentra" / "fingerprint_overrides.json"
    override_path.parent.mkdir(parents=True, exist_ok=True)
    override_path.write_text(
        json.dumps(
            [
                {
                    "name": "Custom MQTT Hub",
                    "vendor": "Lab Vendor",
                    "model": "Hub 1",
                    "services": ["mqtt"],
                    "products": ["mosquitto"],
                    "protocol_hints": ["mqtt"],
                    "confidence": 0.95,
                }
            ]
        ),
        encoding="utf-8",
    )
    feed_path = tmp_path / ".sentra" / "cve_feed.json"
    feed_path.write_text(
        json.dumps(
            [
                {
                    "cve_id": "CVE-2026-0001",
                    "product": "mosquitto",
                    "aliases": ["mqtt broker"],
                    "severity": "high",
                    "summary": "Test vulnerability feed entry.",
                    "fixed_in": "2.1.0",
                    "confidence": 0.88,
                }
            ]
        ),
        encoding="utf-8",
    )

    monkeypatch.setattr("sentra.discovery.network._is_tcp_port_open", lambda host, port, timeout: port == 1883)
    artifact = scan_network("127.0.0.1/32", ports=[1883], workspace=tmp_path)

    device = artifact.devices[0]
    assert device.vendor == "Lab Vendor"
    assert device.model == "Hub 1"
    assert device.fingerprint_matches[0] == "Custom MQTT Hub"
    assert device.vulnerability_matches[0].cve_id == "CVE-2026-0001"
    assert device.fingerprint_confidence >= 0.9
    assert device.fingerprint_evidence
    assert any(item.kind == "fingerprint-service-match" for item in device.fingerprint_evidence)
    assert "version<fixed_in:2.1.0" in device.vulnerability_matches[0].matched_on


def test_scan_network_honors_host_budget(monkeypatch) -> None:
    scanned_hosts: list[str] = []

    def fake_scan_host(host: str, ports: list[int], timeout: float, cache, cache_lock=None, workspace=None, discovery_modules=None):  # type: ignore[no-untyped-def]
        scanned_hosts.append(host)
        return None, 0, [], []

    monkeypatch.setattr("sentra.discovery.network._scan_host", fake_scan_host)
    scan_network("127.0.0.0/29", ports=[80], host_budget=2)

    assert len(scanned_hosts) == 2


def test_scan_can_resume_from_saved_state(tmp_path: Path, monkeypatch) -> None:
    scanned_hosts: list[str] = []

    def fake_scan_host(host: str, ports: list[int], timeout: float, cache, cache_lock=None, workspace=None, discovery_modules=None):  # type: ignore[no-untyped-def]
        scanned_hosts.append(host)
        return None, 0, [], []

    monkeypatch.setattr("sentra.discovery.network._scan_host", fake_scan_host)

    try:
        scan_network(
            "127.0.0.0/30",
            ports=[80],
            workspace=tmp_path,
            on_host_scanned=lambda host: (_ for _ in ()).throw(RuntimeError("boom")) if host == "127.0.0.1" else None,
        )
    except RuntimeError:
        pass

    assert resume_state_path(tmp_path).exists()

    scanned_hosts.clear()
    artifact = scan_network("127.0.0.0/30", ports=[80], workspace=tmp_path)

    assert artifact.resumed is True
    assert scanned_hosts == ["127.0.0.2"]
    assert not resume_state_path(tmp_path).exists()


def test_scan_ignores_corrupt_cache_file(tmp_path: Path, monkeypatch) -> None:
    cache_path(tmp_path).parent.mkdir(parents=True, exist_ok=True)
    cache_path(tmp_path).write_text("{not-json", encoding="utf-8")

    monkeypatch.setattr("sentra.discovery.network._is_tcp_port_open", lambda host, port, timeout: port == 8080)
    artifact = scan_network("127.0.0.1/32", ports=[8080], workspace=tmp_path)

    assert len(artifact.devices) == 1
    payload = json.loads(cache_path(tmp_path).read_text(encoding="utf-8"))
    assert "entries" in payload


def test_scan_deletes_stale_resume_state_for_different_target(tmp_path: Path, monkeypatch) -> None:
    resume_state_path(tmp_path).parent.mkdir(parents=True, exist_ok=True)
    resume_state_path(tmp_path).write_text(
        json.dumps(
            {
                "target": "127.0.0.1/32",
                "hosts_total": 1,
                "completed_hosts": ["127.0.0.1"],
                "devices": [],
            }
        ),
        encoding="utf-8",
    )

    scanned_hosts: list[str] = []

    def fake_scan_host(host: str, ports: list[int], timeout: float, cache, cache_lock=None, workspace=None, discovery_modules=None):  # type: ignore[no-untyped-def]
        scanned_hosts.append(host)
        return None, 0, [], []

    monkeypatch.setattr("sentra.discovery.network._scan_host", fake_scan_host)
    scan_network("127.0.0.0/30", ports=[80], workspace=tmp_path)

    assert scanned_hosts == ["127.0.0.1", "127.0.0.2"]
    assert not resume_state_path(tmp_path).exists()


def test_scan_network_supports_udp_discovery(tmp_path: Path, monkeypatch) -> None:
    monkeypatch.setattr("sentra.discovery.network._is_tcp_port_open", lambda host, port, timeout: False)

    def fake_udp_probe(host: str, port: int, timeout: float) -> ServiceEvidence | None:
        if port != 5353:
            return None
        return ServiceEvidence(
            port=5353,
            protocol="udp",
            service="mdns",
            banner="_http._tcp.local",
            confidence=0.82,
            fingerprint_source="mdns-probe",
            metadata={"mdns_probe": {"labels": ["_http._tcp.local"], "payload_preview": "_http._tcp.local"}},
            evidence=[RawEvidence(source="mdns-probe", kind="raw-udp-response", value="_http._tcp.local", confidence=0.82)],
        )

    monkeypatch.setattr("sentra.discovery.network._probe_udp_discovery", fake_udp_probe)
    artifact = scan_network("127.0.0.1/32", ports=[5353], workspace=tmp_path)

    assert len(artifact.devices) == 1
    service = artifact.devices[0].services[0]
    assert service.protocol == "udp"
    assert service.service == "mdns"
    assert service.metadata["mdns_probe"]["labels"] == ["_http._tcp.local"]
    assert service.evidence[0].kind == "raw-udp-response"
    assert artifact.discovery_providers
    assert artifact.passive_observations[0].protocol == "mdns"


def test_scan_network_captures_http_mqtt_tls_evidence(tmp_path: Path, monkeypatch) -> None:
    monkeypatch.setattr("sentra.discovery.network._is_tcp_port_open", lambda host, port, timeout: port in {80, 1883, 443})
    monkeypatch.setattr(
        "sentra.discovery.network._probe_http",
        lambda host, port, timeout: {
            "status_line": "HTTP/1.1 200 OK",
            "status_code": 200,
            "server_header": "GoAhead/5.2",
            "headers": {"server": "GoAhead/5.2"},
        },
    )
    monkeypatch.setattr(
        "sentra.discovery.network._probe_mqtt",
        lambda host, port, timeout: {"connack_rc": 0, "session_present": True},
    )
    monkeypatch.setattr(
        "sentra.discovery.network._probe_tls",
        lambda host, port, timeout: {"subject": "CN=device.local", "issuer": "CN=Sentra Lab", "san": ["device.local"]},
    )

    artifact = scan_network("127.0.0.1/32", ports=[80, 1883, 443], workspace=tmp_path)

    services = {service.port: service for service in artifact.devices[0].services}
    assert services[80].product == "GoAhead"
    assert services[80].version == "5.2"
    assert any(item.kind == "http-banner" for item in services[80].evidence)
    assert services[1883].service == "mqtt"
    assert any(item.kind == "mqtt-connack" for item in services[1883].evidence)
    assert services[443].metadata["certificate"]["issuer"] == "CN=Sentra Lab"
    assert any(item.kind == "tls-certificate" for item in services[443].evidence)


def test_scan_network_captures_snmp_and_modbus_metadata(tmp_path: Path, monkeypatch) -> None:
    monkeypatch.setattr("sentra.discovery.network._is_tcp_port_open", lambda host, port, timeout: port == 502)
    monkeypatch.setattr(
        "sentra.discovery.network._probe_modbus",
        lambda host, port, timeout: {"device_id": "Atlas Gateway 2.1", "unit_id": 1, "function_code": 17},
    )
    monkeypatch.setattr(
        "sentra.discovery.network._send_udp_probe",
        lambda host, port, timeout: b"sysDescr=Northwind PLC 4.0; sysName=plc-01" if port == 161 else None,
    )
    artifact = scan_network("127.0.0.1/32", ports=[161, 502], workspace=tmp_path)

    services = {service.port: service for service in artifact.devices[0].services}
    assert services[161].service == "snmp"
    assert services[161].metadata["snmp_probe"]["sysname"] == "plc-01"
    assert any(item.kind == "snmp-sysdescr" for item in services[161].evidence)
    assert services[502].service == "modbus"
    assert services[502].metadata["modbus_probe"]["unit_id"] == 1
    assert any(item.kind == "modbus-identity" for item in services[502].evidence)


def test_offline_vuln_alias_matching_applies_inference(tmp_path: Path, monkeypatch) -> None:
    feed_path = tmp_path / ".sentra" / "cve_feed.json"
    feed_path.parent.mkdir(parents=True, exist_ok=True)
    feed_path.write_text(
        json.dumps(
            [
                {
                    "cve_id": "CVE-2026-1111",
                    "product": "goahead",
                    "aliases": ["web admin"],
                    "severity": "medium",
                    "summary": "Alias-driven match.",
                    "affected_version": "5.2",
                    "confidence": 0.76,
                }
            ]
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("sentra.discovery.network._is_tcp_port_open", lambda host, port, timeout: port == 80)
    monkeypatch.setattr(
        "sentra.discovery.network._probe_http",
        lambda host, port, timeout: {
            "status_line": "HTTP/1.1 200 OK",
            "status_code": 200,
            "server_header": "web-admin/5.2",
            "headers": {"server": "web-admin/5.2"},
        },
    )

    artifact = scan_network("127.0.0.1/32", ports=[80], workspace=tmp_path)

    match = artifact.devices[0].vulnerability_matches[0]
    assert match.cve_id == "CVE-2026-1111"
    assert match.inference is True
    assert "version:5.2" in match.matched_on


def test_scan_network_accepts_custom_discovery_modules(tmp_path: Path) -> None:
    artifact = scan_network(
        "127.0.0.1/32",
        ports=[9999],
        workspace=tmp_path,
        discovery_modules=[_StaticDiscoveryModule()],
    )

    assert len(artifact.devices) == 1
    service = artifact.devices[0].services[0]
    assert service.service == "custom-passive"
    assert artifact.discovery_providers[0].name == "static-module"
    assert service.observed_facts
    assert service.inference_hints


def test_passive_discovery_provider_abstraction_collects_observations() -> None:
    result = collect_passive_observations([_StaticPassiveProvider()], PassiveDiscoveryRequest(duration_seconds=1.0))

    assert result.providers[0].name == "test-passive"
    assert result.observations[0].protocol == "mdns"
    assert result.observations[0].summary == "_http._tcp.local"


def test_line_summary_parser_detects_passive_protocol_hints() -> None:
    parser = LineSummaryParser()

    observations = parser.parse_summary(
        "\n".join(
            [
                "mDNS announce from 192.168.1.5 _http._tcp.local",
                "SSDP notify from 192.168.1.6 upnp rootdesc.xml",
                "DHCP vendor class from 192.168.1.7 option 55 printer",
            ]
        )
    )

    assert [item.protocol for item in observations] == ["dhcp", "mdns", "ssdp"]


class CustomAnalyzer:
    name = "test-analyzer"

    def __init__(self, port: int) -> None:
        self.port = port

    def analyze(self, host: str, service):  # type: ignore[no-untyped-def]
        if service.port != self.port:
            return None
        return service.model_copy(
            update={
                "service": "custom-http",
                "banner": f"{host}:{self.port}",
                "confidence": 0.97,
                "fingerprint_source": self.name,
            }
        )


class _CallableAnalyzer:
    def __init__(self, name: str, func) -> None:  # type: ignore[no-untyped-def]
        self.name = name
        self._func = func

    def analyze(self, host: str, service):  # type: ignore[no-untyped-def]
        return self._func(host, service)


class _StaticDiscoveryModule:
    name = "static-module"

    def scan_host(self, context: DiscoveryModuleContext) -> HostDiscoveryResult:
        return HostDiscoveryResult(
            services=[
                ServiceEvidence(
                    port=9999,
                    protocol="tcp",
                    service="custom-passive",
                    banner="sentra-static",
                    confidence=0.88,
                    fingerprint_source=self.name,
                )
            ],
            providers=[
                DiscoveryProviderRecord(
                    name=self.name,
                    kind="active",
                    protocol="tcp",
                    confidence=0.88,
                )
            ],
            passive_observations=[
                PassiveObservation(
                    provider=self.name,
                    protocol="mdns",
                    summary="_services._dns-sd._udp.local",
                    source_address=context.host,
                    confidence=0.71,
                )
            ],
        )


class _StaticPassiveProvider:
    name = "test-passive"

    def listen(self, request: PassiveDiscoveryRequest):  # type: ignore[no-untyped-def]
        return PassiveDiscoveryResult(
            observations=[
                PassiveObservation(
                    provider=self.name,
                    protocol="mdns",
                    summary="_http._tcp.local",
                    source_address="224.0.0.251",
                    confidence=0.66,
                )
            ],
            providers=[
                DiscoveryProviderRecord(
                    name=self.name,
                    kind="passive",
                    protocol="mdns",
                    confidence=0.66,
                )
            ],
        )


def _serve_once(ready: Event, stop: Event) -> None:
    with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as server:
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind(("127.0.0.1", 0))
        server.listen(1)
        server.settimeout(0.2)
        ready.port = server.getsockname()[1]  # type: ignore[attr-defined]
        ready.set()
        while not stop.is_set():
            try:
                client, _ = server.accept()
            except socket.timeout:
                continue
            with closing(client):
                client.sendall(b"sentra-test")
                return

