"""Single source of truth for IP / hostname categorisation.

Three modules used to encode their own copy of "is this address local"
logic with subtly different policies:

* :mod:`ledgrab.utils.safe_source` — *blocks* private/loopback/link-local/
  reserved/multicast hosts for SSRF protection.
* :mod:`ledgrab.utils.url_scheme` — *prefers* ``http://`` for the same
  set (private/loopback/link-local) when inferring schemes for WLED.
* :mod:`ledgrab.api.auth` — *exempts* loopback hosts from API-key
  enforcement.

When those three implementations drift, the resulting inconsistencies are
security-relevant (a host the SSRF guard treats as public could be the
same host the auth gate treats as loopback). Centralise the IP-category
predicate here; the callers express their *policy* on top of the shared
enum so future audits only need to look in one place.

The module is intentionally tiny and pure — no I/O, no logging — so it
can be imported anywhere without dragging dependencies in.
"""

from __future__ import annotations

import enum
import ipaddress


class HostCategory(str, enum.Enum):
    """Mutually-exclusive categorisation of a host literal.

    Use as ``str`` for cheap comparison / logging; downstream callers map
    the enum to their own policy (block, prefer-http, allow-anon, …).
    """

    LOOPBACK = "loopback"  # 127.0.0.0/8, ::1
    PRIVATE = "private"  # RFC1918 + ULA
    LINK_LOCAL = "link_local"  # 169.254.0.0/16, fe80::/10
    RESERVED = "reserved"  # IANA-reserved ranges
    MULTICAST = "multicast"  # 224.0.0.0/4, ff00::/8
    UNSPECIFIED = "unspecified"  # 0.0.0.0, ::
    PUBLIC = "public"  # routable on the public internet
    UNPARSEABLE = "unparseable"  # not a literal IP — caller decides


def classify_ip(host: str) -> HostCategory:
    """Categorise an IP literal *host*.

    Falls back to :data:`HostCategory.UNPARSEABLE` for anything that isn't
    a literal address (hostnames, bare labels, garbage). The category is
    derived strictly from Python's :mod:`ipaddress` module, so any future
    upstream change to the well-known-address registry propagates
    automatically.
    """
    try:
        addr = ipaddress.ip_address(host)
    except ValueError:
        return HostCategory.UNPARSEABLE

    # Order matters — the categories below are not strictly disjoint in
    # Python's predicates (e.g. ``127.0.0.1`` is both ``is_private`` and
    # ``is_loopback``), so we test the most specific labels first.
    if addr.is_loopback:
        return HostCategory.LOOPBACK
    if addr.is_link_local:
        return HostCategory.LINK_LOCAL
    if addr.is_unspecified:
        return HostCategory.UNSPECIFIED
    if addr.is_multicast:
        return HostCategory.MULTICAST
    if addr.is_private:
        return HostCategory.PRIVATE
    if addr.is_reserved:
        return HostCategory.RESERVED
    return HostCategory.PUBLIC


_NON_PUBLIC_FOR_SSRF: frozenset[HostCategory] = frozenset(
    {
        HostCategory.LOOPBACK,
        HostCategory.PRIVATE,
        HostCategory.LINK_LOCAL,
        HostCategory.RESERVED,
        HostCategory.MULTICAST,
        HostCategory.UNSPECIFIED,
        HostCategory.UNPARSEABLE,
    }
)


def is_blocked_for_ssrf(host: str) -> bool:
    """Return True when *host* must NOT be reached from outbound fetches.

    Used by :mod:`ledgrab.utils.safe_source` to enforce SSRF protection.
    The reverse policy lives in :func:`is_local_for_http_default`.
    """
    return classify_ip(host) in _NON_PUBLIC_FOR_SSRF


_LOCAL_FOR_HTTP_DEFAULT: frozenset[HostCategory] = frozenset(
    {
        HostCategory.LOOPBACK,
        HostCategory.PRIVATE,
        HostCategory.LINK_LOCAL,
        HostCategory.UNSPECIFIED,
    }
)


def is_local_for_http_default(host: str) -> bool:
    """Return True when *host* should default to ``http://`` for LAN devices.

    Used by :mod:`ledgrab.utils.url_scheme`. Returns False on
    :data:`HostCategory.UNPARSEABLE` — callers (e.g. ``url_scheme``) layer
    their own mDNS / bare-label heuristics on top.
    """
    cat = classify_ip(host)
    return cat in _LOCAL_FOR_HTTP_DEFAULT


def is_loopback(host: str) -> bool:
    """Return True when *host* is a loopback literal.

    Used by :mod:`ledgrab.api.auth` for the "unauthenticated loopback"
    policy. Also accepts the textual placeholders ``localhost`` and
    Starlette's TestClient marker ``testclient``.
    """
    if not host:
        return False
    h = host.strip().lower()
    if h.startswith("[") and h.endswith("]"):
        h = h[1:-1]
    h = h.split("%", 1)[0]  # strip IPv6 zone id
    if h in {"localhost", "testclient"}:
        return True
    return classify_ip(h) is HostCategory.LOOPBACK


_LAN_CATEGORIES: frozenset[HostCategory] = frozenset(
    {
        HostCategory.LOOPBACK,
        HostCategory.PRIVATE,
        HostCategory.LINK_LOCAL,
        HostCategory.UNSPECIFIED,
    }
)


def validate_lan_host(host: str) -> None:
    """Raise ``ValueError`` when *host* is a literal public IP address.

    Used by LED-device providers to reject ``wiz://1.1.1.1:38899`` style
    URLs that an authenticated (or anonymous, given the default no-auth
    config) caller could otherwise weaponise into a network-scan oracle.

    Non-IP-literal hostnames (``bulb.local``, ``office-strip``) pass
    through untouched -- they resolve via mDNS at connect time, where
    failure to reach a real device produces the right "device unreachable"
    error path anyway. The check is deliberately a syntax filter, not a
    DNS-resolving SSRF guard: LedGrab is LAN-bound by design, and
    aggressive DNS-time rebinding protection is the job of the platform's
    network policy, not a per-driver validator.
    """
    if not host:
        return
    h = host.strip().lower()
    if h.startswith("[") and h.endswith("]"):
        h = h[1:-1]
    h = h.split("%", 1)[0]  # strip IPv6 zone id
    cat = classify_ip(h)
    if cat is HostCategory.UNPARSEABLE:
        return  # hostname / mDNS label -- accept
    if cat not in _LAN_CATEGORIES:
        raise ValueError(
            f"Refusing to add device at {host!r}: literal {cat.value} IP "
            "addresses are rejected. LedGrab is LAN-only; if your device is "
            "genuinely on a public address, use a hostname or tunnel."
        )
