"""Helpers for inferring URL schemes from user-supplied device addresses.

When a user types a bare host (``192.168.1.42``, ``wled-desk.local``, or just
``wled``) we need to pick an HTTP scheme on their behalf. Local-network
targets are addressed over ``http://`` and public hostnames over ``https://``,
matching how WLED is actually reachable in practice.

Intended for HTTP-style local devices (e.g. WLED). Do not call this helper
for protocols that aren't HTTP — serial, OpenRGB, MQTT, etc. each carry
their own scheme and would be silently passed through unchanged but the
helper would still strip whitespace and react to user input in ways those
callers don't want.

This module shares its host-classification surface with
:mod:`ledgrab.utils.safe_source`, which applies the *opposite* policy
(blocking private/loopback/link-local for SSRF protection). Keep the
predicates in sync if either side is updated.
"""

from __future__ import annotations

import ipaddress
import re

from ledgrab.utils.net_classify import is_local_for_http_default


# RFC 3986 scheme grammar: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed
# by ":". Used to recognise URIs like ``data:...``, ``javascript:...``,
# ``mailto:...`` that omit the ``//`` authority component — we must not
# coerce those into ``http(s)://data:...``.
_URI_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+\-.]*):(.*)$")


_LOCAL_HOSTNAME_SUFFIXES: tuple[str, ...] = (
    ".local",
    ".lan",
    ".home",
    ".internal",
)

# Characters that must never appear inside a bare host (C0 + DEL control
# characters, plus the userinfo separator ``@``). Anything containing these
# is treated as unparseable so the downstream validator reports a clean
# error instead of httpx silently chewing on a malformed string.
_FORBIDDEN_HOST_CHARS: frozenset[str] = frozenset(chr(c) for c in range(0x00, 0x20)) | frozenset(
    {chr(0x7F), "@"}
)


def _extract_hostname(host_part: str) -> str:
    """Pull just the hostname out of ``host[:port]`` (or ``[ipv6]:port``).

    Returns the empty string when *host_part* contains forbidden characters
    so the caller can route the input untouched and let the next layer
    surface a precise validation error.
    """
    if any(ch in _FORBIDDEN_HOST_CHARS for ch in host_part):
        return ""
    if host_part.startswith("["):
        end = host_part.find("]")
        if end != -1:
            return host_part[1:end]
        return host_part[1:]
    # Bracketless IPv6 literal (``fe80::1``) — has multiple ``:`` so the
    # ``host:port`` shortcut below would misparse it. Probe ipaddress first.
    try:
        ipaddress.ip_address(host_part)
        return host_part
    except ValueError:
        pass
    if host_part.count(":") == 1:
        return host_part.split(":", 1)[0]
    return host_part


def _is_local_host(host: str) -> bool:
    """Return True when *host* refers to a private/loopback/link-local target.

    A dotless label (``wled``, ``kitchen-strip``) is treated as local because
    those map to mDNS / NetBIOS resolution on a LAN. This is a deliberately
    permissive heuristic appropriate for a LAN-only LED controller, not a
    general-purpose host classifier.

    IP-literal classification is delegated to
    :func:`ledgrab.utils.net_classify.is_local_for_http_default` so this
    module can't drift away from the SSRF / auth predicates that share the
    same address taxonomy.
    """
    host = host.strip().lower()
    if not host:
        return False
    if host == "localhost":
        return True
    if host.endswith(_LOCAL_HOSTNAME_SUFFIXES):
        return True
    try:
        ipaddress.ip_address(host)
    except ValueError:
        # Bare label with no dot ⇒ treat as a local mDNS-style hostname.
        return "." not in host
    return is_local_for_http_default(host)


def infer_http_scheme(url: str | None) -> str | None:
    """Return *url* with an inferred ``http(s)://`` scheme when none is present.

    Local hosts (private/loopback/link-local IPs, ``localhost``, ``.local``
    style hostnames, and bare single-label names) get ``http://``; everything
    else gets ``https://``. Inputs that already carry a scheme — including
    non-HTTP ones like ``ws://`` or ``openrgb://`` — are returned unchanged
    apart from surrounding whitespace.

    Callers may pass ``None`` (e.g. an absent ``url`` field on a partial
    ``DeviceUpdate``); the value round-trips untouched so the request
    validator can complain with the right error.
    """
    if not url:
        return url
    trimmed = url.strip()
    if not trimmed:
        return trimmed
    if trimmed.lower().startswith(("http://", "https://")):
        return trimmed
    # Any other explicit scheme with ``//`` authority: leave the caller in
    # control (ws://, openrgb://, file://, gopher://, …).
    if "://" in trimmed:
        return trimmed
    host_part, _, _rest = trimmed.partition("/")
    # Authority-less URI schemes (``data:``, ``javascript:``, ``mailto:``):
    # the part after the ``:`` is not a port number. We must NOT coerce
    # these into ``http://data:...`` — that would produce an attacker-
    # controlled fetch URL. ``localhost:8080`` / ``192.168.1.1:8080`` still
    # work because the second group is all digits (a port), not a path.
    # IPv6 literals (e.g. ``fe80::1``) syntactically resemble ``scheme:rest``
    # — probe ipaddress first to disambiguate before applying the regex.
    try:
        ipaddress.ip_address(host_part)
    except ValueError:
        scheme_match = _URI_SCHEME_RE.match(host_part)
        if scheme_match and not scheme_match.group(2).isdigit():
            return trimmed
    hostname = _extract_hostname(host_part)
    if not hostname:
        # Forbidden characters or a host we can't safely classify — return
        # the input unchanged so the downstream validator can surface a
        # precise error rather than guessing a scheme.
        return trimmed
    scheme = "http" if _is_local_host(hostname) else "https"
    return f"{scheme}://{trimmed}"
