"""Server discovery — find running Radiens servers and resolve service ports.

Discovery flow:
1. OS-level scan (lsof / tasklist+netstat) finds radiensserver listener ports.
2. Probe each discovered port with ``RadiensLifecycleStub.Healthcheck`` to
   find the lifecycle port.
3. Call ``GetRadiensServers`` on the lifecycle port to get the full
   service→port mapping for every ``ServiceEndpoint``.

Nothing in this module is part of the public API.
"""

from __future__ import annotations

import logging
import re
import subprocess
import sys
from typing import TYPE_CHECKING

import grpc

from radiens_core._constants import (
    ALLEGO_CORE_PORT,
    KPI_PORT,
    NEURONS1_PORT,
    PCACHE_PORT,
)
from radiens_core._generated import common_pb2, radiensserver_pb2, radiensserver_pb2_grpc
from radiens_core.exceptions import ServerDiscoveryError

if TYPE_CHECKING:
    from radiens_core._transport._channel_pool import ChannelPool

_log = logging.getLogger(__name__)

# Ports reserved for allego — never returned by radiensserver discovery.
_ALLEGO_PORTS: frozenset[int] = frozenset({ALLEGO_CORE_PORT, PCACHE_PORT, KPI_PORT, NEURONS1_PORT})

_RADIENSSERVER_PROCESS_PATTERN: str = "radienss"


# ---------------------------------------------------------------------------
# 1. OS-level process scanning
# ---------------------------------------------------------------------------


def discover_radiensserver_processes() -> list[str]:
    """Find listening ``radiensserver`` addresses via OS utilities.

    Returns a list of ``"host:port"`` strings, excluding allego-reserved ports.
    Returns an empty list if no servers are found or the OS is unsupported.
    """
    if sys.platform in ("linux", "darwin"):
        return _discover_unix()
    if sys.platform == "win32":
        return _discover_windows()
    _log.warning("Server discovery not supported on platform: %s", sys.platform)
    return []


def _discover_unix() -> list[str]:
    """Use ``lsof -i -n -P`` to find radiensserver listener ports."""
    addresses: list[str] = []
    try:
        proc = subprocess.run(
            ["lsof", "-i", "-n", "-P"],
            capture_output=True,
            text=True,
            timeout=10,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        _log.debug("lsof unavailable or timed out")
        return []

    for line in proc.stdout.splitlines():
        if re.search(_RADIENSSERVER_PROCESS_PATTERN, line) is None:
            continue
        if re.search(r"\(LISTEN\)", line) is None:
            continue
        # Format: "... TCP <ip>:<port> (LISTEN)"
        tcp_match = re.search(r"TCP\s+(\S+):(\d+)\s+\(LISTEN\)", line)
        if tcp_match is None:
            continue
        ip_raw = tcp_match.group(1)
        port_str = tcp_match.group(2)
        try:
            port = int(port_str)
        except ValueError:
            continue
        if port in _ALLEGO_PORTS:
            continue
        ip = "localhost" if ip_raw == "*" else ip_raw
        addresses.append(f"{ip}:{port}")

    return addresses


def _discover_windows() -> list[str]:
    """Use ``tasklist`` + ``netstat`` to find radiensserver listener ports."""
    # Step 1: find PIDs of radiensserver.exe
    pids: list[int] = []
    try:
        proc = subprocess.run(
            ["tasklist.exe", "/FI", "IMAGENAME eq radiensserver.exe"],
            capture_output=True,
            text=True,
            timeout=10,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        _log.debug("tasklist unavailable or timed out")
        return []

    for line in proc.stdout.splitlines():
        if "radiensserver.exe" not in line.lower():
            continue
        pid_match = re.search(r"\d+", line)
        if pid_match is not None:
            pids.append(int(pid_match.group()))

    if not pids:
        return []

    # Step 2: find LISTENING ports for those PIDs
    addresses: list[str] = []
    try:
        proc = subprocess.run(
            ["netstat.exe", "-a", "-o", "-n", "-p", "TCP"],
            capture_output=True,
            text=True,
            timeout=10,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        _log.debug("netstat unavailable or timed out")
        return []

    for line in proc.stdout.splitlines():
        for pid in pids:
            if re.search(rf"LISTENING\s+{pid}\b", line) is None:
                continue
            port_match = re.search(r":(\d+)", line)
            if port_match is None:
                continue
            try:
                port = int(port_match.group(1))
            except ValueError:
                continue
            if port in _ALLEGO_PORTS:
                continue
            addresses.append(f"localhost:{port}")

    return addresses


# ---------------------------------------------------------------------------
# 2. Lifecycle port probing
# ---------------------------------------------------------------------------


def _find_lifecycle_address(pool: ChannelPool, candidates: list[str]) -> str | None:
    """Probe candidate addresses to find the RadiensLifecycle port.

    Tries a ``Healthcheck`` RPC on each candidate using the
    ``RadiensLifecycleStub``.  Returns the first address that responds,
    or ``None`` if none do.
    """

    for addr in candidates:
        try:
            channel = pool.get_channel(addr)
            stub = radiensserver_pb2_grpc.RadiensLifecycleStub(channel)
            stub.Healthcheck(
                radiensserver_pb2.RadiensHealthcheckRequest(),
                timeout=3,
            )
            return addr
        except grpc.RpcError:
            _log.debug("RadiensLifecycle probe failed on %s", addr)
            continue
    return None


# ---------------------------------------------------------------------------
# 3. RPC-based service port resolution
# ---------------------------------------------------------------------------


def _resolve_via_lifecycle(
    pool: ChannelPool,
    lifecycle_addr: str,
) -> dict[str, str]:
    """Call ``GetRadiensServers`` on a known lifecycle address.

    Returns a ``ServiceEndpoint``-keyed ``dict[str, str]`` mapping every
    discovered service to its ``"host:port"`` address.
    """

    host = lifecycle_addr.split(":")[0]
    channel = pool.get_channel(lifecycle_addr)
    stub = radiensserver_pb2_grpc.RadiensLifecycleStub(channel)
    reply = stub.GetRadiensServers(
        common_pb2.GetRadiensServersRequest(hostIPaddress=host),
    )

    result: dict[str, str] = {}

    for spec in reply.radiensserver:
        spec_host = spec.host or host
        for svc_name, grpc_svc in spec.service.items():
            result[svc_name] = f"{spec_host}:{grpc_svc.port}"

    return result


# ---------------------------------------------------------------------------
# 4. High-level discovery entry point
# ---------------------------------------------------------------------------


def discover_radiens_addresses(pool: ChannelPool) -> dict[str, str]:
    """Discover running radiens servers and resolve all service ports.

    1. OS-scan for radiensserver listener ports.
    2. Probe each to find the ``RadiensLifecycle`` port.
    3. Call ``GetRadiensServers`` to get the full service→port map.

    Returns
    -------
    dict[str, str]
        ``ServiceEndpoint`` value → ``"host:port"`` for every discovered service.

    Raises
    ------
    radiens_core.exceptions.ServerDiscoveryError
        If no running radiensserver processes are found or none respond.
    """

    candidates = discover_radiensserver_processes()
    if not candidates:
        raise ServerDiscoveryError("No running radiensserver processes found. Start the Radiens application.")

    lifecycle_addr = _find_lifecycle_address(pool, candidates)
    if lifecycle_addr is None:
        raise ServerDiscoveryError("Found radiensserver processes but none responded to RadiensLifecycle healthcheck.")

    try:
        result = _resolve_via_lifecycle(pool, lifecycle_addr)
    except grpc.RpcError:
        raise ServerDiscoveryError(
            f"RadiensLifecycle at {lifecycle_addr} responded to healthcheck but GetRadiensServers RPC failed."
        ) from None

    if not result:
        raise ServerDiscoveryError(f"GetRadiensServers on {lifecycle_addr} returned an empty service map.")

    return result
