"""Port availability detection and adaptive port finding.

Uses connect-based checking (not bind-based) because on Windows,
``bind(0.0.0.0, port)`` can succeed even when another app (e.g. Cursor IDE)
holds a specific ``127.0.0.1`` binding on the same port — making
``localhost`` requests route to the wrong process.

Every ``llmhosts`` entry point (``up``, ``serve``, ``doctor``) uses this
module so port behaviour is consistent across platforms.
"""

from __future__ import annotations

import asyncio
import contextlib
import logging
import platform
import socket

logger = logging.getLogger(__name__)

# Default port for ``llmhosts up`` (customer-facing autopilot).
DEFAULT_UP_PORT = 11400

# How many ports to scan upward before giving up.
_SCAN_RANGE = 100


def is_port_free(port: int) -> bool:
    """Return *True* if no process is listening on *port*.

    Checks both IPv4 (``127.0.0.1``) and IPv6 (``::1``) loopback addresses
    because an app can bind to either independently.
    """
    for addr in ("127.0.0.1", "::1"):
        try:
            family = socket.AF_INET6 if ":" in addr else socket.AF_INET
            with socket.socket(family, socket.SOCK_STREAM) as s:
                s.settimeout(0.3)
                s.connect((addr, port))
                # Connection succeeded → something is listening → NOT free
                return False
        except (OSError, ConnectionRefusedError):
            # Connection refused or timeout → nothing listening → free
            pass
    return True


def find_available_port(preferred: int, *, quiet: bool = False) -> int:
    """Find an available port starting from *preferred*.

    If *preferred* is free, returns it immediately.  Otherwise scans
    upward through ``preferred + 1 .. preferred + 99``.

    Returns ``0`` (OS-assigned) as a last resort — should never happen
    in practice since 100 consecutive ports are almost never all taken.
    """
    if is_port_free(preferred):
        return preferred

    logger.info("Port %d is in use — scanning for an available port...", preferred)
    for candidate in range(preferred + 1, preferred + _SCAN_RANGE):
        if is_port_free(candidate):
            if not quiet:
                logger.info("Auto-selected port %d", candidate)
            return candidate

    logger.warning("No free port found in %d-%d, falling back to OS-assigned", preferred, preferred + _SCAN_RANGE - 1)
    return 0


# ---------------------------------------------------------------------------
# IPv6 loopback bridge (Windows anti-squatting)
# ---------------------------------------------------------------------------


async def start_ipv6_bridge(port: int) -> asyncio.Server | None:
    """Start a TCP bridge from ``[::1]:port`` to ``127.0.0.1:port``.

    On Windows, ``localhost`` resolves to ``::1`` (IPv6) first.  Apps like
    Cursor IDE dynamically bind ``[::1]:port`` *after* another service starts
    on ``127.0.0.1:port``, hijacking ``localhost`` traffic.

    This bridge claims the IPv6 loopback address and forwards connections to
    the real IPv4 server, preventing squatting.

    On non-Windows platforms, returns ``None`` (not needed).
    """
    if platform.system() != "Windows":
        return None

    async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
        try:
            while True:
                data = await reader.read(65536)
                if not data:
                    break
                writer.write(data)
                await writer.drain()
        except (ConnectionError, asyncio.CancelledError, OSError):
            pass
        finally:
            with contextlib.suppress(Exception):
                writer.close()

    async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter) -> None:
        try:
            upstream_reader, upstream_writer = await asyncio.open_connection("127.0.0.1", port)
            await asyncio.gather(
                _pipe(client_reader, upstream_writer),
                _pipe(upstream_reader, client_writer),
            )
        except (ConnectionError, OSError):
            with contextlib.suppress(Exception):
                client_writer.close()

    try:
        server = await asyncio.start_server(_handle, "::1", port, family=socket.AF_INET6)
        logger.info("IPv6 bridge active on [::1]:%d -> 127.0.0.1:%d", port, port)
        return server
    except OSError as exc:
        logger.debug("IPv6 bridge not started (port %d): %s", port, exc)
        return None
