"""Deterministic resource normalization and rule matching."""

from __future__ import annotations

import ipaddress
import posixpath
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit

from .errors import ResourceError
from .models import ActionProposal, ScopeRule

_ENCODED_SEPARATOR = re.compile(r"%(?:2f|5c|00)", re.IGNORECASE)
_ENCODED_DOT = re.compile(r"%2e", re.IGNORECASE)
_INVALID_PERCENT = re.compile(r"%(?![0-9a-fA-F]{2})")
_ALLOWED_SCHEMES = {
    "http",
    "https",
    "mcp",
    "shell",
    "browser",
    "cloud",
    "credential",
    "publish",
    "package",
}


@dataclass(frozen=True)
class NormalizedResource:
    value: str
    scheme: str
    host: str | None
    port: int | None
    path: str
    is_private_host: bool


def _normalize_path(path: str) -> str:
    if _INVALID_PERCENT.search(path):
        raise ResourceError("invalid percent escape in resource path")
    if _ENCODED_SEPARATOR.search(path) or _ENCODED_DOT.search(path):
        raise ResourceError("encoded separators, dots, or NUL are not allowed")
    if "\\" in path or any(ord(character) < 0x20 for character in path):
        raise ResourceError("backslashes and control characters are not allowed")
    try:
        decoded = bytes(path, "ascii").decode("ascii")
    except UnicodeEncodeError as exc:
        raise ResourceError("resource path must be percent-encoded ASCII") from exc
    segments = decoded.split("/")
    if any(segment in {".", ".."} or segment.lower() in {"%2e", "%2e%2e"} for segment in segments):
        raise ResourceError("dot path segments are not allowed")
    normalized = posixpath.normpath("/" + decoded.lstrip("/"))
    if decoded.endswith("/") and not normalized.endswith("/"):
        normalized += "/"
    return quote(normalized, safe="/-._~!$&'()*+,;=:@%")


def _normalize_host(host: str) -> str:
    host = host.rstrip(".")
    if not host:
        raise ResourceError("resource host cannot be blank")
    if host.startswith("*."):
        suffix = host[2:]
        if "*" in suffix or not suffix:
            raise ResourceError("only one left-most host wildcard is allowed")
        try:
            suffix = suffix.encode("idna").decode("ascii").lower()
        except UnicodeError as exc:
            raise ResourceError("invalid internationalized host") from exc
        return f"*.{suffix}"
    if "*" in host:
        raise ResourceError("host wildcard must be a complete left-most label")
    try:
        return str(ipaddress.ip_address(host))
    except ValueError:
        try:
            return host.encode("idna").decode("ascii").lower()
        except UnicodeError as exc:
            raise ResourceError("invalid internationalized host") from exc


def _host_is_private(host: str | None) -> bool:
    if host is None or host.startswith("*."):
        return False
    try:
        address = ipaddress.ip_address(host)
    except ValueError:
        return False
    return any(
        (
            address.is_private,
            address.is_loopback,
            address.is_link_local,
            address.is_multicast,
            address.is_reserved,
            address.is_unspecified,
        )
    )


def normalize_resource(value: str, *, pattern: bool = False) -> NormalizedResource:
    if not isinstance(value, str) or not value.strip():
        raise ResourceError("resource must be a non-empty URI")
    raw = value.strip()
    prefix = False
    if raw.endswith("/**"):
        if not pattern:
            raise ResourceError("wildcards are not allowed in proposed resources")
        prefix = True
        raw = raw[:-3] or "/"
    if "*" in raw:
        parsed_for_wildcard = urlsplit(raw)
        if not pattern or not (parsed_for_wildcard.hostname or "").startswith("*."):
            raise ResourceError("unsupported resource wildcard")

    parsed = urlsplit(raw)
    scheme = parsed.scheme.lower()
    if scheme not in _ALLOWED_SCHEMES:
        raise ResourceError(f"unsupported resource scheme: {scheme or '<missing>'}")
    if parsed.username is not None or parsed.password is not None:
        raise ResourceError("userinfo is not allowed in resource URIs")
    if parsed.fragment:
        raise ResourceError("URI fragments are not part of executable authority")
    if not parsed.hostname:
        raise ResourceError("resource URI must include an authority host")

    host = _normalize_host(parsed.hostname)
    try:
        port = parsed.port
    except ValueError as exc:
        raise ResourceError("invalid resource port") from exc
    if scheme == "http" and port == 80:
        port = None
    if scheme == "https" and port == 443:
        port = None

    path = _normalize_path(parsed.path or "/")
    query = ""
    if parsed.query:
        try:
            query_pairs = parse_qsl(
                parsed.query,
                keep_blank_values=True,
                strict_parsing=True,
                max_num_fields=128,
            )
        except ValueError as exc:
            raise ResourceError("invalid resource query") from exc
        query = urlencode(sorted(query_pairs), doseq=True)

    netloc = host
    if ":" in host and not host.startswith("*."):
        try:
            if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
                netloc = f"[{host}]"
        except ValueError:
            pass
    if port is not None:
        netloc = f"{netloc}:{port}"
    normalized = urlunsplit((scheme, netloc, path, query, ""))
    if prefix:
        normalized = normalized.rstrip("/") + "/**"
    return NormalizedResource(
        value=normalized,
        scheme=scheme,
        host=host,
        port=port,
        path=path,
        is_private_host=_host_is_private(host),
    )


def _action_matches(pattern: str, action: str) -> bool:
    if pattern.endswith(".*"):
        return action.startswith(pattern[:-1])
    return pattern == action


def _host_matches(pattern_host: str | None, target_host: str | None) -> bool:
    if pattern_host is None or target_host is None:
        return pattern_host == target_host
    if pattern_host.startswith("*."):
        suffix = pattern_host[1:]
        return target_host.endswith(suffix) and target_host != pattern_host[2:]
    return pattern_host == target_host


def _resource_matches(pattern: NormalizedResource, target: NormalizedResource) -> bool:
    if pattern.scheme != target.scheme:
        return False
    if not _host_matches(pattern.host, target.host) or pattern.port != target.port:
        return False
    pattern_value = pattern.value
    if pattern_value.endswith("/**"):
        base_path = pattern.path.rstrip("/")
        return target.path == base_path or target.path.startswith(base_path + "/")
    return pattern.value == target.value


def _attributes_match(required: dict[str, Any], actual: dict[str, Any]) -> bool:
    for key, expected in required.items():
        if key not in actual:
            return False
        observed = actual[key]
        if isinstance(expected, list):
            if isinstance(observed, list):
                if not all(any(item == allowed for allowed in expected) for item in observed):
                    return False
            elif observed not in expected:
                return False
        elif observed != expected:
            return False
    return True


def _resolved_ips_allowed(rule: ScopeRule, resolved_ips: Iterable[str]) -> bool:
    addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
    try:
        addresses = [ipaddress.ip_address(value) for value in resolved_ips]
    except ValueError:
        return False
    if not addresses and rule.resolved_ip_cidrs:
        return False
    if not addresses:
        return True
    if not rule.allow_private_network and any(
        address.is_private
        or address.is_loopback
        or address.is_link_local
        or address.is_multicast
        or address.is_reserved
        or address.is_unspecified
        for address in addresses
    ):
        return False
    if rule.resolved_ip_cidrs:
        try:
            networks = [
                ipaddress.ip_network(value, strict=False) for value in rule.resolved_ip_cidrs
            ]
        except ValueError:
            return False
        if not all(any(address in network for network in networks) for address in addresses):
            return False
    return True


def rule_matches(
    rule: ScopeRule,
    proposal: ActionProposal,
    normalized: NormalizedResource,
) -> bool:
    if not _action_matches(rule.action, proposal.action):
        return False
    pattern = normalize_resource(rule.resource, pattern=True)
    if not _resource_matches(pattern, normalized):
        return False
    if rule.methods and (proposal.method is None or proposal.method not in rule.methods):
        return False
    if not _attributes_match(rule.required_attributes, proposal.attributes):
        return False
    resolved = proposal.attributes.get("resolved_ips", [])
    if isinstance(resolved, str):
        resolved = [resolved]
    if not isinstance(resolved, list) or not all(isinstance(item, str) for item in resolved):
        return False
    if rule.effect.value == "allow":
        if not _resolved_ips_allowed(rule, resolved):
            return False
        if normalized.is_private_host and not rule.allow_private_network:
            return False
    return True
