from __future__ import annotations

import asyncio
from collections import defaultdict
from dataclasses import dataclass
from ipaddress import IPv4Address, IPv6Address, ip_address
import itertools
import logging
import socket
from typing import TYPE_CHECKING, Any, cast

from zeroconf import DNSQuestionType, IPVersion
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf

from .core import ResolveAPIError, ResolveTimeoutAPIError
from .util import (
    address_is_local,
    asyncio_timeout,
    create_eager_task,
    host_is_name_part,
)
from .zeroconf import ZeroconfManager

if TYPE_CHECKING:
    from collections.abc import Coroutine

_LOGGER = logging.getLogger(__name__)


SERVICE_TYPE = "_esphomelib._tcp.local."
RESOLVE_TIMEOUT = 30.0


@dataclass(frozen=True)
class Sockaddr:
    """Base socket address."""

    address: str
    port: int


@dataclass(frozen=True)
class IPv4Sockaddr(Sockaddr):
    """IPv4 socket address."""


@dataclass(frozen=True)
class IPv6Sockaddr(Sockaddr):
    """IPv6 socket address."""

    flowinfo: int
    scope_id: int


@dataclass(frozen=True)
class AddrInfo:
    family: int
    type: int
    proto: int
    sockaddr: IPv4Sockaddr | IPv6Sockaddr


async def _async_zeroconf_get_service_info(
    aiozc: AsyncZeroconf,
    short_host: str,
    timeout: float,
) -> AsyncServiceInfo:
    info = _make_service_info_for_short_host(short_host)
    try:
        # Use QM (multicast) questions to ensure multicast responses that all
        # zeroconf instances on the network can observe and cache. RFC 6762 Section
        # 5.4 specifies that responders SHOULD send multicast responses to QU
        # (unicast) questions if they haven't multicast recently (within 1/4 TTL),
        # but ESP32 mDNS doesn't implement this - it always sends unicast to QU.
        # By using QM, we ensure the dashboard observes responses when the CLI does
        # resolution, preventing the dashboard's cache from going stale.
        await info.async_request(
            aiozc.zeroconf, int(timeout * 1000), question_type=DNSQuestionType.QM
        )
    except Exception as exc:
        msg = f"Error resolving mDNS {short_host} via mDNS: {exc}"
        raise ResolveAPIError(msg) from exc
    return info


def _scope_id_to_int(value: str | None) -> int:
    """Convert a scope id to int if possible."""
    if value is None:
        return 0
    try:
        return int(value)
    except ValueError:
        return socket.if_nametoindex(value)


def _make_service_info_for_short_host(host: str) -> AsyncServiceInfo:
    """Make service info for an ESPHome host."""
    service_name = f"{host}.{SERVICE_TYPE}"
    server = f"{host}.local."
    return AsyncServiceInfo(SERVICE_TYPE, service_name, server=server)


async def _async_resolve_short_host_zeroconf(
    aiozc: AsyncZeroconf,
    short_host: str,
    port: int,
    *,
    timeout: float = 3.0,
) -> list[AddrInfo]:
    _LOGGER.debug("Resolving host %s via mDNS", short_host)
    service_info = await _async_zeroconf_get_service_info(aiozc, short_host, timeout)
    return service_info_to_addr_info(service_info, port)


def service_info_to_addr_info(info: AsyncServiceInfo, port: int) -> list[AddrInfo]:
    return [
        _async_ip_address_to_addrinfo(ip, port)
        for version in (IPVersion.V6Only, IPVersion.V4Only)
        for ip in info.ip_addresses_by_version(version)
    ]


def _remove_local_suffix(hostname: str) -> str:
    """Remove .local or .local. suffix from hostname (case-insensitive)."""
    lower = hostname.lower()
    if lower.endswith(".local."):
        return hostname[: -len(".local.")]
    if lower.endswith(".local"):
        return hostname[: -len(".local")]
    return hostname


async def _async_resolve_host_getaddrinfo(host: str, port: int) -> list[AddrInfo]:
    loop = asyncio.get_running_loop()
    _LOGGER.debug("Attempting to resolve %s via getaddrinfo", host)
    try:
        # Limit to TCP IP protocol and SOCK_STREAM
        res = await loop.getaddrinfo(
            host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
        )
    except OSError as err:
        _LOGGER.debug("Failed to resolve %s via getaddrinfo: %s", host, err)
        msg = f"Error resolving {host} to IP address: {err}"
        raise ResolveAPIError(msg) from err

    _LOGGER.debug("Successfully resolved %s via getaddrinfo", host)

    addrs: list[AddrInfo] = []
    skipped_unspecified = False
    for family, type_, proto, _, raw in res:
        sockaddr: IPv4Sockaddr | IPv6Sockaddr
        if family == socket.AF_INET:
            raw = cast("tuple[str, int]", raw)
            address, port = raw
            sockaddr = IPv4Sockaddr(address=address, port=port)
        elif family == socket.AF_INET6:
            raw = cast("tuple[str, int, int, int]", raw)
            address, port, flowinfo, scope_id = raw
            sockaddr = IPv6Sockaddr(
                address=address, port=port, flowinfo=flowinfo, scope_id=scope_id
            )
        else:
            # Unknown family
            continue

        # DNS sinkholes answer with unspecified addresses (0.0.0.0 / ::);
        # connecting to them is never useful, so treat them as a miss
        if ip_address(address).is_unspecified:
            _LOGGER.debug(
                "Ignoring unspecified address %s for %s from getaddrinfo",
                address,
                host,
            )
            skipped_unspecified = True
            continue

        addrs.append(
            AddrInfo(family=family, type=type_, proto=proto, sockaddr=sockaddr)
        )

    if not addrs and skipped_unspecified:
        msg = f"getaddrinfo for {host} returned only unspecified addresses"
        raise ResolveAPIError(msg)
    return addrs


def _async_ip_address_to_addrinfo(ip: IPv4Address | IPv6Address, port: int) -> AddrInfo:
    """Convert an ipaddress to AddrInfo."""
    is_ipv6 = ip.version == 6
    sockaddr: IPv6Sockaddr | IPv4Sockaddr
    if is_ipv6:
        if TYPE_CHECKING:
            assert isinstance(ip, IPv6Address)
        sockaddr = IPv6Sockaddr(
            address=str(ip).partition("%")[0],
            port=port,
            flowinfo=0,
            scope_id=_scope_id_to_int(ip.scope_id),
        )
    else:
        sockaddr = IPv4Sockaddr(
            address=str(ip),
            port=port,
        )

    return AddrInfo(
        family=socket.AF_INET6 if is_ipv6 else socket.AF_INET,
        type=socket.SOCK_STREAM,
        proto=socket.IPPROTO_TCP,
        sockaddr=sockaddr,
    )


def host_is_local_name(host: str) -> bool:
    """Check if the host is a local name."""
    return host_is_name_part(host) or address_is_local(host)


def _try_ip_address_fastpath(
    host: str,
    port: int,
    resolve_results: defaultdict[str, list[AddrInfo]],
) -> bool:
    """Record an AddrInfo if ``host`` is an IP literal; return True when handled."""
    try:
        ip_addr_info = _async_ip_address_to_addrinfo(ip_address(host), port)
    except ValueError:
        return False
    resolve_results[host].append(ip_addr_info)
    return True


def _try_zeroconf_cache(
    host: str,
    port: int,
    aiozc: AsyncZeroconf,
    resolve_results: defaultdict[str, list[AddrInfo]],
) -> None:
    """Populate ``resolve_results`` from the zeroconf cache if a hit is available."""
    short_host = host.partition(".")[0]
    service_info = _make_service_info_for_short_host(short_host)
    if service_info.load_from_cache(aiozc.zeroconf) and (
        addr_infos := service_info_to_addr_info(service_info, port)
    ):
        resolve_results[host].extend(addr_infos)


def _ensure_aiozc(
    zeroconf_manager: ZeroconfManager | None,
    host: str,
    exceptions: list[BaseException],
) -> tuple[ZeroconfManager, bool, AsyncZeroconf | None]:
    """Lazy-init the zeroconf manager; capture startup failures into ``exceptions``."""
    manager = zeroconf_manager or ZeroconfManager()
    had_instance = manager.has_instance
    try:
        return manager, had_instance, manager.get_async_zeroconf()
    except Exception as original_exc:  # noqa: BLE001
        new_exc = ResolveAPIError(
            f"Cannot start mDNS sockets while resolving {host}: "
            f"{original_exc}, is this a docker container "
            "without host network mode? "
        )
        new_exc.__cause__ = original_exc
        exceptions.append(new_exc)
        return manager, had_instance, None


def _finalize_resolve_results(
    hosts: list[str],
    resolve_results: defaultdict[str, list[AddrInfo]],
    exceptions: list[BaseException],
) -> list[AddrInfo]:
    """Deduplicate accumulated results or raise the most informative error."""
    # Deduplicate addresses since we may have gotten the same results from multiple sources
    # (e.g., both "example.local" and "example" might resolve to the same IP)
    if all_addrs := list(itertools.chain.from_iterable(resolve_results.values())):
        # Use dict.fromkeys() to preserve order while removing duplicates
        return list(dict.fromkeys(all_addrs))

    if exceptions:
        raise ResolveAPIError(", ".join(map(str, exceptions)))
    msg = f"Could not resolve host {hosts} - got no results from OS"
    raise ResolveAPIError(msg)


async def async_resolve_host(
    hosts: list[str],
    port: int,
    zeroconf_manager: ZeroconfManager | None = None,
    timeout: float = RESOLVE_TIMEOUT,
) -> list[AddrInfo]:
    """Resolve hosts in parallel.

    We will try to resolve the host in the following order:
    - If the host is an IP address, we will return that and skip
      trying to resolve it at all.

    - If the host is a local name, we will try to resolve it via mDNS
    - Otherwise, we will use getaddrinfo to resolve it as well

    Once we know which hosts to resolve and which methods, all
    resolution runs in parallel and we will return the first
    result we get for each host.
    """
    manager: ZeroconfManager | None = None
    had_zeroconf_instance: bool = False
    resolve_results: defaultdict[str, list[AddrInfo]] = defaultdict(list)
    aiozc: AsyncZeroconf | None = None
    tried_to_create_zeroconf: bool = False
    exceptions: list[BaseException] = []

    # First try to handle the cases where we do not need to
    # do any network calls at all.
    # - If the host is an IP address, we can just return that
    # - If we have a zeroconf manager and the host is in the cache
    #   we can return that as well
    for host in hosts:
        if _try_ip_address_fastpath(host, port, resolve_results):
            continue

        if not host_is_local_name(host):
            continue

        # If its a local name, we can try to fetch it from the zeroconf cache
        if not tried_to_create_zeroconf:
            tried_to_create_zeroconf = True
            manager, had_zeroconf_instance, aiozc = _ensure_aiozc(
                zeroconf_manager, host, exceptions
            )

        if aiozc:
            _try_zeroconf_cache(host, port, aiozc, resolve_results)

    try:
        if len(resolve_results) != len(hosts):
            # If we have not resolved all hosts yet, we need to do some network calls
            try:
                async with asyncio_timeout(timeout):
                    await _async_resolve_host(
                        hosts, port, resolve_results, exceptions, aiozc, timeout
                    )
            except TimeoutError as err:
                # If we already have some results, don't fail on timeout
                if not resolve_results:
                    msg = f"Timeout while resolving IP address for {hosts}"
                    raise ResolveTimeoutAPIError(msg) from err
                _LOGGER.debug(
                    "Timeout while resolving some hosts, but got results for: %s",
                    list(resolve_results.keys()),
                )
    finally:
        if manager and not had_zeroconf_instance:
            await asyncio.shield(create_eager_task(manager.async_close()))

    return _finalize_resolve_results(hosts, resolve_results, exceptions)


def _make_resolution_coros(
    host: str,
    port: int,
    aiozc: AsyncZeroconf | None,
    timeout: float,
) -> list[Coroutine[Any, Any, list[AddrInfo]]]:
    """Build the resolution coroutines to race for a single host."""
    coros: list[Coroutine[Any, Any, list[AddrInfo]]] = []
    if aiozc and host_is_local_name(host):
        short_host = host.partition(".")[0]
        coros.append(
            _async_resolve_short_host_zeroconf(aiozc, short_host, port, timeout=timeout)
        )

    _LOGGER.debug("Adding DNS resolution task for %s", host)
    coros.append(_async_resolve_host_getaddrinfo(host, port))

    # If the host ends with .local, also try without the suffix in parallel
    # This handles cases where mDNS resolution might fail but the hostname
    # is resolvable via regular DNS (violates RFC 6762 but improves compatibility)
    if address_is_local(host):
        stripped = _remove_local_suffix(host)
        # Skip getaddrinfo for purely numeric hostnames - they get
        # misinterpreted as decimal IP addresses by the socket library
        # (e.g., "1234" becomes 0.0.4.210)
        if not stripped.isdigit():
            _LOGGER.debug(
                "Also adding DNS resolution without .local suffix for %s",
                stripped,
            )
            coros.append(_async_resolve_host_getaddrinfo(stripped, port))

    return coros


def _drain_done_tasks(
    done: set[asyncio.Task[list[AddrInfo]]],
    resolve_task_to_host: dict[asyncio.Task[list[AddrInfo]], str],
    host_tasks: defaultdict[str, set[asyncio.Task[list[AddrInfo]]]],
    resolve_results: defaultdict[str, list[AddrInfo]],
    exceptions: list[BaseException],
) -> set[str]:
    """Drain completed tasks into results/exceptions and return finished hosts."""
    finished_hosts: set[str] = set()
    for task in done:
        host = resolve_task_to_host.pop(task)
        host_tasks[host].discard(task)
        if exc := task.exception():
            exceptions.append(exc)
        elif result := task.result():
            resolve_results[host].extend(result)
            finished_hosts.add(host)
    return finished_hosts


def _cancel_sibling_tasks(
    finished_hosts: set[str],
    host_tasks: defaultdict[str, set[asyncio.Task[list[AddrInfo]]]],
    resolve_task_to_host: dict[asyncio.Task[list[AddrInfo]], str],
) -> list[asyncio.Task[list[AddrInfo]]]:
    """Cancel any still-pending tasks for hosts that already produced a result."""
    tasks_to_cleanup: list[asyncio.Task[list[AddrInfo]]] = []
    for host in finished_hosts:
        for task in host_tasks.pop(host, ()):
            resolve_task_to_host.pop(task, None)
            _LOGGER.debug("Cancelling remaining resolution task for %s", host)
            task.cancel()
            tasks_to_cleanup.append(task)
    return tasks_to_cleanup


async def _async_resolve_host(
    hosts: list[str],
    port: int,
    resolve_results: defaultdict[str, list[AddrInfo]],
    exceptions: list[BaseException],
    aiozc: AsyncZeroconf | None,
    timeout: float,
) -> None:
    """Resolve hosts in parallel.

    As soon as we get a result for a host, we will cancel
    all other tasks trying to resolve that host.

    This function will resolve hosts in parallel using
    both mDNS and getaddrinfo.

    This function is also designed to be cancellable, so
    if we get cancelled, we will cancel all tasks, and
    clean up after ourselves.
    """
    resolve_task_to_host: dict[asyncio.Task[list[AddrInfo]], str] = {}
    host_tasks: defaultdict[str, set[asyncio.Task[list[AddrInfo]]]] = defaultdict(set)

    try:
        for host in hosts:
            for coro in _make_resolution_coros(host, port, aiozc, timeout):
                task = create_eager_task(coro)
                if task.done():
                    if exc := task.exception():
                        exceptions.append(exc)
                    else:
                        resolve_results[host].extend(task.result())
                else:
                    resolve_task_to_host[task] = host
                    host_tasks[host].add(task)

        while resolve_task_to_host:
            done, _ = await asyncio.wait(
                resolve_task_to_host,
                return_when=asyncio.FIRST_COMPLETED,
            )
            finished_hosts = _drain_done_tasks(
                done, resolve_task_to_host, host_tasks, resolve_results, exceptions
            )
            # We got a result for a host, cancel
            # any other tasks trying to resolve
            # it as we are done with that host
            tasks_to_cleanup = _cancel_sibling_tasks(
                finished_hosts, host_tasks, resolve_task_to_host
            )
            if tasks_to_cleanup:
                # Use ``gather`` with ``return_exceptions=True`` so that
                # ``CancelledError`` from the cancelled child tasks is not
                # raised in this task. This avoids the anti-pattern of
                # ``with suppress(CancelledError): await task`` which can
                # silently swallow a parent cancellation if it arrives at
                # the same yield point and breaks ``TaskGroup`` /
                # ``asyncio.timeout`` semantics.
                await asyncio.gather(*tasks_to_cleanup, return_exceptions=True)
    finally:
        # We likely get here if we get cancelled because of a timeout.
        # Cancel any remaining tasks and gather them with
        # ``return_exceptions=True``. Shield the cleanup wait so that a
        # parent-task cancellation arriving here does not interrupt
        # draining the cancelled child tasks (which would leak them and
        # trigger ``Task was destroyed but it is pending`` warnings); if
        # cancellation does arrive during the shielded wait, finish
        # awaiting the cleanup and then re-raise so the cancellation
        # still propagates to the caller.
        if resolve_task_to_host:
            for task in resolve_task_to_host:
                task.cancel()
            cleanup = asyncio.gather(*resolve_task_to_host, return_exceptions=True)
            try:
                await asyncio.shield(cleanup)
            except asyncio.CancelledError:
                await cleanup
                raise
