"""mDNS/Zeroconf discovery helpers shared by the CLI and SDK."""

from __future__ import annotations

import ipaddress
import logging
import platform
import re
import subprocess  # nosec B404
import threading
import time
from concurrent.futures import ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Optional

from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf

LOGGER = logging.getLogger(__name__)

DEFAULT_TIMEOUT_SECONDS = 5  # balances discovery completeness with CLI responsiveness
DEFAULT_SERVICE_TYPES = ["_http._tcp.local.", "_psia._tcp.local.", "_CGI._tcp.local."]
DEFAULT_DEVICE_KEYWORDS = ["HIKVISION", "CAMERA", "AXIS", "DAHUA", "IP CAM", "IPCAM"]


class DiscoveryError(Exception):
    """Raised when the Zeroconf discovery process fails."""


@dataclass
class DiscoveredDevice:
    """Representation of a discovered device."""

    service_name: str
    hostname: str
    ip: str
    port: str
    serial: str
    mac: Optional[str] = None


class DeviceServiceListener:
    """Listener for mDNS service discovery events."""

    def __init__(self, zeroconf: Zeroconf) -> None:
        self.zeroconf = zeroconf
        self.services: Dict[str, ServiceInfo] = {}
        self.lock = threading.Lock()

    def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
        try:
            info = zc.get_service_info(type_, name)
            if info:
                with self.lock:
                    self.services[name] = info
        except Exception as exc:  # pragma: no cover - defensive
            LOGGER.debug("Failed to add service %s of type %s: %s", name, type_, exc)

    def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
        self.add_service(zc, type_, name)

    def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
        with self.lock:
            removed = self.services.pop(name, None)
            if removed is None:
                LOGGER.debug("Attempted to remove unknown service %s", name)

    def get_services(self) -> List[ServiceInfo]:
        with self.lock:
            return list(self.services.values())


def normalize_mac(mac: str) -> Optional[str]:
    """Normalize MAC address format."""

    hex_digits = re.sub(r"[^0-9A-Fa-f]", "", mac)
    if len(hex_digits) != 12:
        return None

    pairs = [hex_digits[i : i + 2].upper() for i in range(0, 12, 2)]
    return ":".join(pairs)


class MacResolver:
    """Resolve MAC addresses for IPs across Linux, macOS, and Windows."""

    def __init__(self, platform_name: Optional[str] = None) -> None:
        self.platform_name = platform_name or platform.system()

    def resolve(self, ip: str) -> Optional[str]:
        if not _is_valid_ip(ip):
            LOGGER.debug("Skipping MAC resolution for invalid IP %s", ip)
            return None
        mac = self._query_arp_tables(ip)
        if mac:
            return mac

        self._ping_ip(ip)
        return self._query_arp_tables(ip)

    def _query_arp_tables(self, ip: str) -> Optional[str]:
        for command in self._commands_for_platform(ip):
            mac = self._run_and_extract(command)
            if mac:
                return mac
        return None

    def _run_and_extract(self, command: List[str]) -> Optional[str]:
        try:
            result = subprocess.run(  # nosec B603 - Command uses validated IP address with hardcoded system commands
                command,
                capture_output=True,
                text=True,
                timeout=5,
            )
        except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
            LOGGER.debug("Command %s failed during MAC resolution: %s", command, exc)
            return None

        if result.returncode != 0:
            LOGGER.debug("Command %s exited with %s", command, result.returncode)
            return None

        mac = self._extract_mac(result.stdout)
        if mac:
            return normalize_mac(mac)
        return None

    def _commands_for_platform(self, ip: str) -> Iterable[List[str]]:
        system = self.platform_name
        if system == "Linux":
            yield ["ip", "neighbor", "show", ip]
            yield ["arp", "-n", ip]
        elif system == "Darwin":
            yield ["arp", "-n", ip]
            yield ["arp", ip]
        elif system == "Windows":
            yield ["arp", "-a", ip]
        else:
            yield ["arp", "-n", ip]

    def _ping_ip(self, ip: str) -> None:
        command: List[str]
        system = self.platform_name
        if system == "Linux":
            command = ["ping", "-c", "1", "-W", "1", ip]
        elif system == "Darwin":
            command = ["ping", "-c", "1", ip]
        elif system == "Windows":
            command = ["ping", "-n", "1", "-w", "1000", ip]
        else:
            command = ["ping", "-c", "1", ip]

        try:
            subprocess.run(command, capture_output=True, timeout=3)  # nosec B603
        except (subprocess.TimeoutExpired, FileNotFoundError):
            return

    @staticmethod
    def _extract_mac(output: str) -> Optional[str]:
        pattern = re.compile(r"([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})")
        match = pattern.search(output)
        if match:
            return match.group(1)

        compact = re.compile(r"([0-9A-Fa-f]{12})")
        match = compact.search(output)
        if match:
            return match.group(1)
        return None


def extract_serial_from_name(name: str) -> Optional[str]:
    """Best-effort serial extractor tuned for vendor naming schemes."""

    candidates = re.findall(r"[A-Z0-9-]{8,}", name.upper())
    best: Optional[str] = None
    best_len = 0
    for candidate in candidates:
        candidate = candidate.strip("-")
        if len(candidate) < 8 or len(candidate) > 64:
            continue
        letters = sum(ch.isalpha() for ch in candidate)
        digits = sum(ch.isdigit() for ch in candidate)
        if letters >= 2 and digits >= 2 and len(candidate) > best_len:
            best = candidate
            best_len = len(candidate)
    return best


@dataclass
class DiscoveryConfig:
    """Configuration knobs for device discovery."""

    timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
    service_types: List[str] = field(default_factory=lambda: list(DEFAULT_SERVICE_TYPES))
    device_keywords: List[str] = field(default_factory=lambda: list(DEFAULT_DEVICE_KEYWORDS))
    max_mac_workers: int = 8


def discover_devices(
    *,
    config: Optional[DiscoveryConfig] = None,
    resolve_macs: bool = True,
    mac_resolver: Optional[MacResolver] = None,
) -> List[DiscoveredDevice]:
    """Discover devices on the network using mDNS/Zeroconf."""

    cfg = config or DiscoveryConfig()

    zeroconf: Optional[Zeroconf] = None
    try:
        zeroconf = Zeroconf()
        listener = DeviceServiceListener(zeroconf)
        for service_type in cfg.service_types:
            ServiceBrowser(zeroconf, service_type, listener)

        time.sleep(cfg.timeout_seconds)

        raw_services = listener.get_services()
        filtered = _filter_device_services(raw_services, cfg.device_keywords)
        devices = _build_devices(filtered)
        deduped = _deduplicate_by_ip(devices)

        if resolve_macs:
            resolver = mac_resolver or MacResolver()
            _resolve_macs(deduped, resolver, cfg)

        return deduped
    except Exception as exc:  # pragma: no cover - exceptional path
        raise DiscoveryError(f"Error during mDNS discovery: {exc}") from exc
    finally:
        if zeroconf is not None:
            zeroconf.close()


def _filter_device_services(services: List[ServiceInfo], keywords: List[str]) -> List[ServiceInfo]:
    keyword_set = {kw.upper() for kw in keywords}
    filtered: List[ServiceInfo] = []
    for info in services:
        service_name = info.name.upper()
        if any(keyword in service_name for keyword in keyword_set):
            filtered.append(info)
    return filtered


def _build_devices(services: List[ServiceInfo]) -> List[DiscoveredDevice]:
    devices: List[DiscoveredDevice] = []
    for info in services:
        addresses = info.parsed_addresses()
        if not addresses:
            continue

        ipv4_addresses = [addr for addr in addresses if ":" not in addr]
        ip = ipv4_addresses[0] if ipv4_addresses else addresses[0]

        if not _is_valid_ip(ip):
            LOGGER.debug("Skipping invalid IP from service %s: %s", info.name, ip)
            continue

        hostname = (info.server or "").rstrip(".")
        port = str(info.port or "80")
        serial = _extract_serial(info.name, hostname)

        devices.append(
            DiscoveredDevice(
                service_name=info.name,
                hostname=hostname,
                ip=ip,
                port=port,
                serial=serial or "",
            )
        )
    return devices


def _deduplicate_by_ip(devices: List[DiscoveredDevice]) -> List[DiscoveredDevice]:
    deduped: Dict[str, DiscoveredDevice] = {}
    for device in devices:
        existing = deduped.get(device.ip)
        if existing:
            if device.serial and not existing.serial:
                existing.serial = device.serial
        else:
            deduped[device.ip] = device
    return list(deduped.values())


def _resolve_macs(devices: List[DiscoveredDevice], resolver: MacResolver, cfg: DiscoveryConfig) -> None:
    with ThreadPoolExecutor(max_workers=cfg.max_mac_workers) as executor:
        futures = [executor.submit(_resolve_single_mac, resolver, device) for device in devices]
        wait(futures)


def _resolve_single_mac(resolver: MacResolver, device: DiscoveredDevice) -> None:
    try:
        device.mac = resolver.resolve(device.ip)
    except Exception as exc:  # pragma: no cover - defensive
        LOGGER.debug("Failed to resolve MAC for %s: %s", device.ip, exc)
        device.mac = None


def _extract_serial(service_name: str, hostname: str) -> Optional[str]:
    for candidate in (hostname, service_name):
        serial = extract_serial_from_name(candidate)
        if serial:
            return serial
    return None


def _is_valid_ip(ip: str) -> bool:
    try:
        ipaddress.ip_address(ip)
        return True
    except ValueError:
        return False
