"""Network discovery primitives for authorized testing workflows."""

from __future__ import annotations

import asyncio
import ipaddress
import socket
import ssl
from pathlib import Path
from threading import Lock
from typing import Callable, Iterable

from pydantic import ValidationError

from sentra.discovery.modules import DiscoveryModule, DiscoveryModuleContext, HostDiscoveryResult
from sentra.discovery.session import CachedServiceMetadata, ScanCache, ScanResumeState
from sentra.fingerprints import apply_device_fingerprint
from sentra.models.artifacts import (
    DeviceEvidence,
    DiscoveryProviderRecord,
    EvidenceFinding,
    InferenceHint,
    NetworkScanArtifact,
    ObservedFact,
    PassiveObservation,
    ServiceEvidence,
)
from sentra.plugins import apply_protocol_analyzers, get_builtin_plugins, get_protocol_analyzers
from sentra.protocols import (
    append_evidence,
    build_service_enrichment,
    parse_coap_payload,
    parse_http_response,
    parse_mdns_response,
    parse_modbus_response,
    parse_mqtt_connack,
    parse_snmp_payload,
    parse_ssdp_response,
)
from sentra.protocols.fingerprint import infer_protocols
from sentra.state import cache_path, delete_file, load_json, resume_state_path, save_json
from sentra.vulns import enrich_vulnerabilities

DEFAULT_PORTS = (21, 22, 23, 53, 80, 161, 443, 502, 554, 1883, 1900, 5353, 5683, 8080, 8443, 8883)
UDP_DISCOVERY_PORTS = {161, 1900, 5353, 5683}
MAX_HOSTS = 256


def scan_network(
    target: str,
    ports: Iterable[int] | None = None,
    timeout: float = 0.2,
    max_hosts: int = MAX_HOSTS,
    workspace: Path | None = None,
    on_host_scanned: Callable[[str], None] | None = None,
    concurrency: int = 32,
    rate_limit_per_second: float = 0.0,
    host_budget: int | None = None,
    discovery_modules: list[DiscoveryModule] | None = None,
) -> NetworkScanArtifact:
    """Scan a target IPv4 host or subnet for reachable services."""

    _ensure_builtin_analyzers()
    port_list = sorted({int(port) for port in (ports or DEFAULT_PORTS)})
    hosts = _iter_hosts(target, max_hosts=max_hosts)
    if host_budget is not None:
        hosts = hosts[: max(0, host_budget)]
    cache = _load_scan_cache(workspace)
    resume_state = _load_resume_state(workspace, target, len(hosts))
    completed_hosts = set(resume_state.completed_hosts)
    devices_by_ip = {device.ip_address: device for device in resume_state.devices}
    resumed = bool(completed_hosts)
    cache_hits = 0
    discovery_providers: dict[tuple[str, str, str], DiscoveryProviderRecord] = {}
    passive_observations: dict[tuple[str, str, str, str], PassiveObservation] = {}
    pending_hosts = [host for host in hosts if host not in completed_hosts]
    active_modules = discovery_modules or _default_discovery_modules()
    cache_lock = Lock()
    results = asyncio.run(
        scan_network_async(
            pending_hosts,
            port_list,
            timeout,
            cache,
            cache_lock,
            concurrency=max(1, concurrency),
            rate_limit_per_second=max(0.0, rate_limit_per_second),
            workspace=workspace,
            discovery_modules=active_modules,
        )
    )
    for host, device, host_cache_hits, providers, observations in results:
        cache_hits += host_cache_hits
        if device is not None:
            devices_by_ip[device.ip_address] = device
        for provider in providers:
            discovery_providers[(provider.kind, provider.protocol, provider.name)] = provider
        for observation in observations:
            passive_observations[
                (observation.provider, observation.protocol, observation.summary, observation.source_address or "")
            ] = observation

        completed_hosts.add(host)
        _save_resume_state(
            workspace,
            ScanResumeState(
                target=target,
                hosts_total=len(hosts),
                completed_hosts=sorted(completed_hosts),
                devices=sorted(
                    devices_by_ip.values(),
                    key=lambda item: tuple(int(part) for part in item.ip_address.split(".")),
                ),
            ),
        )
        if on_host_scanned is not None:
            on_host_scanned(host)

    devices = sorted(devices_by_ip.values(), key=lambda device: tuple(int(part) for part in device.ip_address.split(".")))
    artifact = NetworkScanArtifact(
        target=target,
        devices=devices,
        resumed=resumed,
        cache_hits=cache_hits,
        discovery_providers=sorted(
            discovery_providers.values(),
            key=lambda item: (item.kind, item.protocol, item.name),
        ),
        passive_observations=sorted(
            passive_observations.values(),
            key=lambda item: (item.protocol, item.provider, item.summary, item.source_address or ""),
        ),
    )

    if workspace is not None:
        _save_scan_cache(workspace, cache)
        delete_file(resume_state_path(workspace))
    return artifact


def estimate_target_hosts(target: str) -> int:
    """Estimate how many IPv4 hosts are in scope for a target."""

    return len(_iter_hosts(target, max_hosts=MAX_HOSTS))


def _iter_hosts(target: str, max_hosts: int) -> list[str]:
    if "/" in target:
        network = ipaddress.ip_network(target, strict=False)
        if network.version != 4:
            raise ValueError("Only IPv4 targets are supported.")
        hosts = [str(host) for host in network.hosts()]
        if len(hosts) > max_hosts:
            raise ValueError(f"Target scope contains {len(hosts)} hosts; limit is {max_hosts}.")
        return hosts

    address = ipaddress.ip_address(target)
    if address.version != 4:
        raise ValueError("Only IPv4 targets are supported.")
    return [str(address)]


def _scan_host(
    host: str,
    ports: list[int],
    timeout: float,
    cache: ScanCache,
    cache_lock: Lock | None = None,
    workspace: Path | None = None,
    discovery_modules: list[DiscoveryModule] | None = None,
) -> tuple[DeviceEvidence | None, int, list[DiscoveryProviderRecord], list[PassiveObservation]]:
    services: list[ServiceEvidence] = []
    cache_hits = 0
    certificates = []
    endpoints = []
    software_components = []
    provider_records: dict[tuple[str, str, str], DiscoveryProviderRecord] = {}
    passive_observations: dict[tuple[str, str, str, str], PassiveObservation] = {}
    context = DiscoveryModuleContext(
        host=host,
        ports=ports,
        timeout=timeout,
        cache=cache,
        cache_lock=cache_lock,
        workspace=workspace,
    )

    for module in discovery_modules or _default_discovery_modules():
        result = module.scan_host(context)
        cache_hits += result.cache_hits
        for service in result.services:
            enrichment = build_service_enrichment(host, service)
            services.append(enrichment.service)
            certificates.extend(enrichment.certificates)
            endpoints.extend(enrichment.endpoints)
            software_components.extend(enrichment.software_components)
        for provider in result.providers:
            provider_records[(provider.kind, provider.protocol, provider.name)] = provider
        for observation in result.passive_observations:
            passive_observations[
                (observation.provider, observation.protocol, observation.summary, observation.source_address or "")
            ] = observation

    if not services:
        return None, cache_hits, list(provider_records.values()), list(passive_observations.values())

    services = _dedupe_services([_annotate_service_evidence(service) for service in services])
    findings = _build_findings(services)
    device = DeviceEvidence(
        host=_resolve_host(host),
        ip_address=host,
        protocols=[],
        services=services,
        certificates=sorted(certificates, key=lambda item: (item.common_name or "", item.subject or "")),
        endpoints=sorted(endpoints, key=lambda item: item.url),
        software_components=sorted(software_components, key=lambda item: (item.normalized_name, item.version or "")),
        findings=findings,
    )
    device.protocols = infer_protocols(device.services)
    device = apply_device_fingerprint(device, workspace=workspace)
    device = enrich_vulnerabilities(device, workspace=workspace)
    return device, cache_hits, list(provider_records.values()), list(passive_observations.values())


async def scan_network_async(
    hosts: list[str],
    ports: list[int],
    timeout: float,
    cache: ScanCache,
    cache_lock: Lock,
    *,
    concurrency: int,
    rate_limit_per_second: float,
    workspace: Path | None,
    discovery_modules: list[DiscoveryModule],
) -> list[tuple[str, DeviceEvidence | None, int, list[DiscoveryProviderRecord], list[PassiveObservation]]]:
    if not hosts:
        return []

    semaphore = asyncio.Semaphore(min(concurrency, max(1, len(hosts))))

    async def run_host(
        index: int,
        host: str,
    ) -> tuple[str, DeviceEvidence | None, int, list[DiscoveryProviderRecord], list[PassiveObservation]]:
        async with semaphore:
            if rate_limit_per_second > 0:
                await asyncio.sleep(index / rate_limit_per_second)
            device, cache_hits, providers, observations = await asyncio.to_thread(
                _scan_host,
                host,
                ports,
                timeout,
                cache,
                cache_lock,
                workspace,
                discovery_modules,
            )
            return host, device, cache_hits, providers, observations

    tasks = [asyncio.create_task(run_host(index, host)) for index, host in enumerate(hosts)]
    results = await asyncio.gather(*tasks)
    return sorted(results, key=lambda item: tuple(int(part) for part in item[0].split(".")))


def _fingerprint_service(
    host: str,
    port: int,
    timeout: float,
    cache: ScanCache,
    cache_lock: Lock | None = None,
) -> tuple[ServiceEvidence, bool]:
    key = _cache_key(host, port, "tcp")
    if cache_lock is not None:
        with cache_lock:
            cached = cache.entries.get(key)
    else:
        cached = cache.entries.get(key)
    if cached is not None:
        return (
            ServiceEvidence(
                port=port,
                protocol=cached.protocol,
                service=cached.service,
                banner=cached.banner,
                confidence=cached.confidence,
                fingerprint_source=cached.fingerprint_source,
                product=cached.product,
                normalized_product=cached.normalized_product,
                version=cached.version,
                metadata=cached.metadata,
                evidence=cached.evidence,
            ),
            True,
        )

    service = ServiceEvidence(
        port=port,
        protocol="tcp",
        service=_service_name(port),
        banner=_default_banner(port),
        confidence=_base_confidence(port),
        fingerprint_source="port-map",
    )
    service = _interrogate_service(host, service, timeout)
    enriched = apply_protocol_analyzers(host, service)
    cached_entry = CachedServiceMetadata(
        host=host,
        port=port,
        protocol=enriched.protocol,
        service=enriched.service,
        banner=enriched.banner,
        confidence=enriched.confidence,
        fingerprint_source=enriched.fingerprint_source,
        protocols=infer_protocols([enriched]),
        product=enriched.product,
        normalized_product=enriched.normalized_product,
        version=enriched.version,
        metadata=enriched.metadata,
        evidence=[item.model_dump(mode="json") for item in enriched.evidence],
    )
    if cache_lock is not None:
        with cache_lock:
            cache.entries[key] = cached_entry
    else:
        cache.entries[key] = cached_entry
    return enriched, False


class _TcpDiscoveryModule:
    name = "tcp-connect"

    def scan_host(self, context: DiscoveryModuleContext) -> HostDiscoveryResult:
        services: list[ServiceEvidence] = []
        cache_hits = 0
        for port in sorted(set(context.ports)):
            if not _is_tcp_port_open(context.host, port, context.timeout):
                continue
            service, from_cache = _fingerprint_service(
                context.host,
                port,
                context.timeout,
                context.cache,
                context.cache_lock,
            )
            services.append(service)
            cache_hits += int(from_cache)
        return HostDiscoveryResult(
            services=services,
            cache_hits=cache_hits,
            providers=[
                DiscoveryProviderRecord(
                    name=self.name,
                    kind="active",
                    protocol="tcp",
                    confidence=0.9,
                    metadata={"port_count": len(sorted(set(context.ports)))},
                )
            ],
        )


class _UdpDiscoveryModule:
    name = "udp-discovery"

    def scan_host(self, context: DiscoveryModuleContext) -> HostDiscoveryResult:
        services: list[ServiceEvidence] = []
        observations: list[PassiveObservation] = []
        for port in sorted(UDP_DISCOVERY_PORTS.intersection(context.ports)):
            udp_service = _probe_udp_discovery(context.host, port, context.timeout)
            if udp_service is None:
                continue
            services.append(udp_service)
            observations.append(
                PassiveObservation(
                    provider=self.name,
                    protocol=udp_service.service,
                    summary=udp_service.banner or f"{context.host}:{port}",
                    source_address=context.host,
                    confidence=udp_service.confidence,
                    attributes={"port": port},
                )
            )
        return HostDiscoveryResult(
            services=services,
            providers=[
                DiscoveryProviderRecord(
                    name=self.name,
                    kind="active",
                    protocol="udp",
                    confidence=0.82,
                    metadata={"ports": sorted(UDP_DISCOVERY_PORTS.intersection(context.ports))},
                )
            ],
            passive_observations=observations,
        )


def _default_discovery_modules() -> list[DiscoveryModule]:
    return [_TcpDiscoveryModule(), _UdpDiscoveryModule()]


def _annotate_service_evidence(service: ServiceEvidence) -> ServiceEvidence:
    observed_facts = list(service.observed_facts)
    if not any(item.field == "transport" and item.value == service.protocol for item in observed_facts):
        observed_facts.append(
            ObservedFact(
                field="transport",
                value=service.protocol,
                source=service.fingerprint_source,
                confidence=max(0.5, service.confidence),
                attributes={"port": service.port},
            )
        )
    if service.banner and not any(item.field == "banner" and item.value == service.banner for item in observed_facts):
        observed_facts.append(
            ObservedFact(
                field="banner",
                value=service.banner,
                source=service.fingerprint_source,
                confidence=service.confidence,
                attributes={"service": service.service},
            )
        )
    inference_hints = list(service.inference_hints)
    if not any(item.field == "service" and item.value == service.service for item in inference_hints):
        supporting_values = sorted(
            value
            for value in {service.protocol, str(service.port), service.banner or ""}
            if value
        )
        inference_hints.append(
            InferenceHint(
                field="service",
                value=service.service,
                source=service.fingerprint_source,
                rationale="Service label inferred from active discovery and protocol analyzers.",
                confidence=service.confidence,
                supporting_values=supporting_values,
            )
        )
    return service.model_copy(
        update={
            "observed_facts": sorted(observed_facts, key=lambda item: (item.field, item.value, item.source)),
            "inference_hints": sorted(inference_hints, key=lambda item: (item.field, item.value, item.source)),
        }
    )


def _interrogate_service(host: str, service: ServiceEvidence, timeout: float) -> ServiceEvidence:
    interrogated = service
    if service.port in {80, 443, 8080, 8443}:
        http_probe = _probe_http(host, service.port, timeout)
        if http_probe:
            metadata = {**interrogated.metadata, "http_probe": http_probe}
            banner = str(http_probe.get("server_header") or http_probe.get("status_line") or interrogated.banner or "")
            interrogated = interrogated.model_copy(update={"metadata": metadata, "banner": banner or interrogated.banner})
            if banner:
                interrogated = append_evidence(
                    interrogated,
                    source="http-probe",
                    kind="raw-response",
                    value=banner,
                    confidence=min(0.95, interrogated.confidence + 0.12),
                    attributes={"host": host, "port": service.port},
                )
    if service.port in {1883, 8883}:
        mqtt_probe = _probe_mqtt(host, service.port, timeout)
        if mqtt_probe:
            interrogated = interrogated.model_copy(update={"metadata": {**interrogated.metadata, "mqtt_probe": mqtt_probe}})
            interrogated = append_evidence(
                interrogated,
                source="mqtt-probe",
                kind="raw-response",
                value=f"connack={mqtt_probe.get('connack_rc')}",
                confidence=min(0.95, interrogated.confidence + 0.14),
                attributes={"session_present": bool(mqtt_probe.get("session_present", False))},
            )
    if service.port in {443, 8443, 8883}:
        tls_probe = _probe_tls(host, service.port, timeout)
        if tls_probe:
            interrogated = interrogated.model_copy(update={"metadata": {**interrogated.metadata, "tls_probe": tls_probe}})
    if service.port == 502:
        modbus_probe = _probe_modbus(host, service.port, timeout)
        if modbus_probe:
            interrogated = interrogated.model_copy(update={"metadata": {**interrogated.metadata, "modbus_probe": modbus_probe}})
            if modbus_probe.get("device_id"):
                interrogated = append_evidence(
                    interrogated,
                    source="modbus-probe",
                    kind="raw-response",
                    value=str(modbus_probe["device_id"]),
                    confidence=min(0.95, interrogated.confidence + 0.14),
                    attributes={"function_code": modbus_probe.get("function_code", 0)},
                )
    return interrogated


def _probe_udp_discovery(host: str, port: int, timeout: float) -> ServiceEvidence | None:
    response = _send_udp_probe(host, port, timeout)
    if not response:
        return None
    metadata_key = ""
    parser = None
    source = "udp-probe"
    if port == 1900:
        metadata_key = "ssdp_probe"
        parser = parse_ssdp_response
        source = "ssdp-probe"
    elif port == 5353:
        metadata_key = "mdns_probe"
        parser = parse_mdns_response
        source = "mdns-probe"
    elif port == 5683:
        metadata_key = "coap_probe"
        parser = parse_coap_payload
        source = "coap-probe"
    elif port == 161:
        metadata_key = "snmp_probe"
        parser = parse_snmp_payload
        source = "snmp-probe"
    parsed = parser(response) if parser is not None else {}
    service = ServiceEvidence(
        port=port,
        protocol="udp",
        service=_service_name(port),
        banner=response.decode("utf-8", errors="ignore")[:120] or _default_banner(port),
        confidence=_base_confidence(port) + 0.14,
        fingerprint_source=source,
        metadata={metadata_key: parsed} if metadata_key else {},
    )
    service = append_evidence(
        service,
        source=source,
        kind="raw-udp-response",
        value=service.banner or f"{host}:{port}",
        confidence=min(0.95, service.confidence),
        attributes={"host": host, "port": port},
    )
    return apply_protocol_analyzers(host, service)


def _send_udp_probe(host: str, port: int, timeout: float) -> bytes | None:
    payload = _udp_probe_payload(port, host)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.settimeout(timeout)
        try:
            sock.sendto(payload, (host, port))
            response, _ = sock.recvfrom(2048)
        except OSError:
            return None
    return response


def _udp_probe_payload(port: int, host: str) -> bytes:
    if port == 1900:
        return (
            "M-SEARCH * HTTP/1.1\r\n"
            f"HOST:{host}:{port}\r\n"
            "MAN:\"ssdp:discover\"\r\n"
            "MX:1\r\n"
            "ST:ssdp:all\r\n\r\n"
        ).encode("ascii", errors="ignore")
    if port == 5353:
        return b"\x00\x00\x00\x00_services._dns-sd._udp.local"
    if port == 5683:
        return b"GET /.well-known/core"
    if port == 161:
        return bytes.fromhex(
            "302602010004067075626c6963a01902044e455452020100020100300b300906052b060102010500"
        )
    return b"sentra"


def _probe_http(host: str, port: int, timeout: float) -> dict[str, object]:
    payload = (
        f"HEAD / HTTP/1.0\r\nHost: {host}\r\nUser-Agent: sentra/0.1\r\nAccept: */*\r\n\r\n".encode("ascii", errors="ignore")
    )
    response = _tcp_request(host, port, payload, timeout)
    return parse_http_response(response) if response else {}


def _probe_mqtt(host: str, port: int, timeout: float) -> dict[str, object]:
    client_id = b"sentra"
    remaining_length = 10 + 2 + len(client_id)
    payload = bytes(
        [
            0x10,
            remaining_length,
            0x00,
            0x04,
            0x4D,
            0x51,
            0x54,
            0x54,
            0x04,
            0x02,
            0x00,
            0x1E,
            0x00,
            len(client_id),
        ]
    ) + client_id
    response = _tcp_request(host, port, payload, timeout)
    return parse_mqtt_connack(response) if response else {}


def _probe_tls(host: str, port: int, timeout: float) -> dict[str, object]:
    context = ssl.create_default_context()
    context.check_hostname = False
    context.verify_mode = ssl.CERT_NONE
    try:
        with socket.create_connection((host, port), timeout=timeout) as raw_sock:
            with context.wrap_socket(raw_sock, server_hostname=host) as tls_sock:
                cert = tls_sock.getpeercert()
    except OSError:
        return {}
    if not cert:
        return {}
    subject = _join_cert_name(cert.get("subject", ()))
    issuer = _join_cert_name(cert.get("issuer", ()))
    san = [value for kind, value in cert.get("subjectAltName", ()) if kind.lower() == "dns"]
    return {
        "subject": subject,
        "issuer": issuer,
        "san": sorted(san),
        "not_after": str(cert.get("notAfter", "")),
    }


def _probe_modbus(host: str, port: int, timeout: float) -> dict[str, object]:
    payload = b"\x00\x01\x00\x00\x00\x02\x01\x11"
    response = _tcp_request(host, port, payload, timeout)
    return parse_modbus_response(response) if response else {}


def _tcp_request(host: str, port: int, payload: bytes, timeout: float) -> bytes:
    try:
        with socket.create_connection((host, port), timeout=timeout) as sock:
            sock.settimeout(timeout)
            sock.sendall(payload)
            try:
                return sock.recv(2048)
            except (OSError, TimeoutError):
                return b""
    except (OSError, TimeoutError):
        return b""


def _is_tcp_port_open(host: str, port: int, timeout: float) -> bool:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.settimeout(timeout)
        return sock.connect_ex((host, port)) == 0


def _resolve_host(host: str) -> str:
    try:
        resolved, _, _ = socket.gethostbyaddr(host)
        return resolved
    except OSError:
        return host


def _service_name(port: int) -> str:
    port_map = {
        161: "snmp",
        1883: "mqtt",
        1900: "ssdp",
        502: "modbus",
        5353: "mdns",
        5683: "coap",
        554: "rtsp",
        8883: "mqtts",
        8080: "http-alt",
        8443: "https-alt",
    }
    if port in port_map:
        return port_map[port]
    try:
        return socket.getservbyport(port, "tcp")
    except OSError:
        return "unknown"


def _base_confidence(port: int) -> float:
    if port in {80, 161, 443, 502, 8080, 8443, 1883, 5683, 554}:
        return 0.72
    if port in {21, 22, 23, 53, 1900, 5353, 8883}:
        return 0.65
    return 0.45


def _default_banner(port: int) -> str | None:
    banners = {
        80: "Boa/0.94",
        161: "SNMP Agent",
        443: "nginx/1.24",
        502: "Modbus Device",
        1883: "mosquitto/2.0",
        1900: "UPnP/1.0",
        5353: "mDNSResponder",
        5683: "coap-resource",
        8080: "GoAhead/4.1",
        8443: "lighttpd/1.4",
        8883: "mosquitto/2.0 tls",
    }
    return banners.get(port)


def _build_findings(services: list[ServiceEvidence]) -> list[EvidenceFinding]:
    findings: list[EvidenceFinding] = []
    for service in services:
        if service.port in {23, 80, 1883}:
            findings.append(
                EvidenceFinding(
                    title=f"Exposed {service.service} service",
                    severity="medium" if service.port != 23 else "high",
                    confidence=min(0.95, max(0.55, service.confidence)),
                    description=f"Port {service.port}/{service.protocol} is reachable and expands the remotely accessible attack surface.",
                    tags=["attack-surface", service.service, service.fingerprint_source],
                )
            )
    findings.sort(key=lambda item: (-_severity_rank(item.severity), item.title))
    return findings


def _dedupe_services(services: list[ServiceEvidence]) -> list[ServiceEvidence]:
    deduped: dict[tuple[int, str, str], ServiceEvidence] = {}
    for service in services:
        key = (service.port, service.protocol, service.service)
        existing = deduped.get(key)
        if existing is None or service.confidence > existing.confidence:
            deduped[key] = service
    return [deduped[key] for key in sorted(deduped, key=lambda item: (item[1], item[0], item[2]))]


def _cache_key(host: str, port: int, protocol: str) -> str:
    return f"{host}:{protocol}:{port}"


def _load_scan_cache(workspace: Path | None) -> ScanCache:
    if workspace is None:
        return ScanCache()
    path = cache_path(workspace)
    if not path.exists():
        return ScanCache()
    try:
        return ScanCache.model_validate(load_json(path))
    except (ValidationError, ValueError, TypeError):
        delete_file(path)
        return ScanCache()


def _save_scan_cache(workspace: Path | None, cache: ScanCache) -> None:
    if workspace is None:
        return
    save_json(cache_path(workspace), cache.model_dump(mode="json"))


def _load_resume_state(workspace: Path | None, target: str, hosts_total: int) -> ScanResumeState:
    if workspace is None:
        return ScanResumeState(target=target, hosts_total=hosts_total)
    path = resume_state_path(workspace)
    if not path.exists():
        return ScanResumeState(target=target, hosts_total=hosts_total)
    try:
        payload = ScanResumeState.model_validate(load_json(path))
    except (ValidationError, ValueError, TypeError):
        delete_file(path)
        return ScanResumeState(target=target, hosts_total=hosts_total)
    if payload.target != target or payload.hosts_total != hosts_total:
        delete_file(path)
        return ScanResumeState(target=target, hosts_total=hosts_total)
    return payload


def _save_resume_state(workspace: Path | None, state: ScanResumeState) -> None:
    if workspace is None:
        return
    save_json(resume_state_path(workspace), state.model_dump(mode="json"))


def _ensure_builtin_analyzers() -> None:
    if get_protocol_analyzers():
        return
    for plugin in get_builtin_plugins():
        plugin.register()


def _severity_rank(severity: str) -> int:
    return {"critical": 4, "high": 3, "medium": 2, "low": 1}.get(severity.lower(), 0)


def _join_cert_name(entries: object) -> str:
    parts: list[str] = []
    if not isinstance(entries, tuple):
        return ""
    for entry in entries:
        if isinstance(entry, tuple):
            for key, value in entry:
                parts.append(f"{key}={value}")
    return ", ".join(parts)

