"""Canonicalization helpers for replay matching."""

from __future__ import annotations

import gzip
import hashlib
import json
import os
import re
from typing import Iterable, Tuple
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit


IGNORED_HEADERS_DEFAULT = {
    "date",
    "server",
    "set-cookie",
    "x-request-id",
    "traceparent",
    "tracestate",
}

_UUID_RE = re.compile(
    r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$",
    re.IGNORECASE,
)
_ISO_TS_RE = re.compile(
    r"^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+\-]\d{2}:\d{2})?$"
)
_VOLATILE_KEYS = {
    "request_id",
    "trace_id",
    "span_id",
    "timestamp",
    "created_at",
    "updated_at",
}
_ORDER_INSENSITIVE_ARRAY_KEYS = {
    "ids",
    "id_list",
    "tags",
    "labels",
    "roles",
    "scopes",
    "features",
}


def _volatile_key_patterns() -> list[re.Pattern[str]]:
    """Load optional volatile key regex patterns from env (comma-separated)."""
    raw = os.environ.get("AUTODEBUG_VOLATILE_JSON_KEY_PATTERNS", "").strip()
    if not raw:
        return []
    patterns: list[re.Pattern[str]] = []
    for part in raw.split(","):
        token = part.strip()
        if not token:
            continue
        try:
            patterns.append(re.compile(token, re.IGNORECASE))
        except re.error:
            continue
    return patterns


def canonical_url(url: str, normalize_localhost_port: bool = True) -> str:
    """Canonicalize a URL for comparison.

    Args:
        url: The URL to canonicalize
        normalize_localhost_port: If True, strip port from localhost/127.0.0.1 URLs
            to handle tests that use random ports for local servers
    """
    parts = urlsplit(url)
    query = urlencode(sorted(parse_qsl(parts.query, keep_blank_values=True)))
    hostname = parts.hostname.lower() if parts.hostname else ""

    # Normalize localhost variants
    if hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1"):
        hostname = "localhost"
        # Strip port for localhost to handle random port assignments
        if normalize_localhost_port:
            netloc = hostname
        else:
            netloc = f"{hostname}:{parts.port}" if parts.port else hostname
    else:
        netloc = f"{hostname}:{parts.port}" if parts.port else hostname

    return urlunsplit((parts.scheme.lower(), netloc, parts.path, query, parts.fragment))


def _looks_volatile_key(key: str) -> bool:
    k = key.lower()
    if k in _VOLATILE_KEYS:
        return True
    for pattern in _volatile_key_patterns():
        if pattern.search(k):
            return True
    return (
        "timestamp" in k
        or "uuid" in k
        or k.endswith("_id")
        or k.endswith("id")
        or k.endswith("_at")
    )


def _is_order_insensitive_array(path: str, values: list[object]) -> bool:
    leaf = path.split(".")[-1].replace("[]", "")
    if leaf in _ORDER_INSENSITIVE_ARRAY_KEYS:
        return True
    # Primitive arrays are commonly set-like in payloads.
    return all(not isinstance(v, (dict, list)) for v in values)


def _canonicalize_json(value, *, ignore_volatile: bool, path: str = "$") -> object:
    if isinstance(value, dict):
        out = {}
        for k in sorted(value.keys()):
            if ignore_volatile and _looks_volatile_key(k):
                continue
            child_path = f"{path}.{k}" if path else k
            out[k] = _canonicalize_json(value[k], ignore_volatile=ignore_volatile, path=child_path)
        return out
    if isinstance(value, list):
        normalized = [
            _canonicalize_json(v, ignore_volatile=ignore_volatile, path=f"{path}[]")
            for v in value
        ]
        if _is_order_insensitive_array(path, normalized):
            return sorted(normalized, key=lambda x: json.dumps(x, sort_keys=True, default=str))
        return normalized
    if isinstance(value, str):
        s = value.strip()
        if _UUID_RE.match(s):
            return "<UUID>"
        if _ISO_TS_RE.match(s):
            return "<TIMESTAMP>"
    return value


def _json_mismatch_paths(expected, actual, path: str = "") -> list[str]:
    mismatches: list[str] = []
    if type(expected) is not type(actual):
        mismatches.append(path or "$")
        return mismatches
    if isinstance(expected, dict):
        keys = sorted(set(expected.keys()) | set(actual.keys()))
        for key in keys:
            child_path = f"{path}.{key}" if path else key
            if key not in expected or key not in actual:
                mismatches.append(child_path)
            else:
                mismatches.extend(_json_mismatch_paths(expected[key], actual[key], child_path))
        return mismatches
    if isinstance(expected, list):
        if len(expected) != len(actual):
            mismatches.append(path or "$")
            return mismatches
        for idx, (lval, rval) in enumerate(zip(expected, actual)):
            child_path = f"{path}[{idx}]" if path else f"[{idx}]"
            mismatches.extend(_json_mismatch_paths(lval, rval, child_path))
        return mismatches
    if expected != actual:
        mismatches.append(path or "$")
    return mismatches


def body_hash(body: bytes, content_type: str | None, *, normalize_volatile: bool = False) -> str:
    if content_type and "json" in content_type.lower():
        try:
            payload = json.loads(body.decode("utf-8"))
            canonical = _canonicalize_json(payload, ignore_volatile=normalize_volatile)
            normalized = json.dumps(canonical, separators=(",", ":"), sort_keys=True).encode("utf-8")
            return hashlib.sha256(normalized).hexdigest()
        except (ValueError, UnicodeDecodeError):
            pass
    return hashlib.sha256(body).hexdigest()


def json_mismatch_report(
    expected_body: bytes,
    actual_body: bytes,
    content_type: str | None,
    *,
    ignore_volatile: bool = True,
) -> dict:
    """Return structured mismatch details for JSON payloads."""
    if not content_type or "json" not in content_type.lower():
        return {"is_json": False, "mismatch_count": 0, "field_mismatches": [], "volatile_only": False}

    try:
        expected = json.loads(expected_body.decode("utf-8"))
        actual = json.loads(actual_body.decode("utf-8"))
    except (ValueError, UnicodeDecodeError):
        return {"is_json": False, "mismatch_count": 0, "field_mismatches": [], "volatile_only": False}

    strict_left = _canonicalize_json(expected, ignore_volatile=False)
    strict_right = _canonicalize_json(actual, ignore_volatile=False)
    strict_mismatches = _json_mismatch_paths(strict_left, strict_right)

    left = _canonicalize_json(expected, ignore_volatile=ignore_volatile)
    right = _canonicalize_json(actual, ignore_volatile=ignore_volatile)
    mismatches = _json_mismatch_paths(left, right)

    return {
        "is_json": True,
        "mismatch_count": len(mismatches),
        "field_mismatches": mismatches,
        "volatile_only": bool(strict_mismatches and not mismatches),
        "strict_mismatch_count": len(strict_mismatches),
    }


def json_mismatch_paths(
    expected_body: bytes,
    actual_body: bytes,
    content_type: str | None,
    *,
    ignore_volatile: bool = True,
) -> list[str]:
    report = json_mismatch_report(
        expected_body,
        actual_body,
        content_type,
        ignore_volatile=ignore_volatile,
    )
    return list(report.get("field_mismatches", []))


def sql_fingerprint(sql: str) -> str:
    """Generate a structure-focused SQL fingerprint.

    This is parser-lite normalization (token/literal normalization + projection
    sorting for simple SELECTs) to reduce false mismatches without adding heavy deps.
    """
    q = str(sql or "").strip().rstrip(";")
    if not q:
        return ""
    q = q.lower()

    # Normalize placeholder styles and common literal forms.
    q = re.sub(r"\$\d+\b", "?", q)         # PostgreSQL numbered placeholders ($1)
    q = re.sub(r":[a-z_]\w*", "?", q)      # named placeholders (:user_id)
    q = re.sub(r"%s", "?", q)              # psycopg param placeholders
    q = re.sub(r"'(?:''|[^'])*'", "?", q)  # quoted strings
    q = re.sub(r"\s+", " ", q).strip()

    m = re.match(r"^select\s+(.+?)\s+from\s+(.+)$", q, flags=re.IGNORECASE)
    if m:
        projection = m.group(1).strip()
        tail = m.group(2).strip()
        if projection != "*" and "(" not in projection and ")" not in projection:
            cols = [c.strip() for c in projection.split(",") if c.strip()]
            if len(cols) > 1:
                projection = ", ".join(sorted(cols))
                q = f"select {projection} from {tail}"

        # Normalize simple AND predicate ordering to reduce false mismatches.
        where_match = re.match(
            r"^(select .+? from .+?)\s+where\s+(.+?)(\s+group by .+|\s+order by .+|\s+limit .+|)$",
            q,
            flags=re.IGNORECASE,
        )
        if where_match:
            prefix = where_match.group(1).strip()
            where_expr = where_match.group(2).strip()
            suffix = where_match.group(3).strip()
            if " or " not in where_expr and "(" not in where_expr and ")" not in where_expr:
                predicates = [p.strip() for p in re.split(r"\s+and\s+", where_expr) if p.strip()]
                if len(predicates) > 1:
                    where_expr = " and ".join(sorted(predicates))
                    q = f"{prefix} where {where_expr}"
                    if suffix:
                        q = f"{q} {suffix}"

    return q[:500]


def decode_gzip(body: bytes) -> Tuple[bytes, bool]:
    try:
        return gzip.decompress(body), True
    except OSError:
        return body, False


def normalize_headers(headers: Iterable[Tuple[str, str]]) -> Tuple[dict, dict, list[str]]:
    raw = {}
    canon = {}
    ignored = []
    for key, value in headers:
        lower = key.lower()
        raw[lower] = value
        if lower in IGNORED_HEADERS_DEFAULT:
            ignored.append(key)
            continue
        canon[lower] = value
    return canon, raw, ignored
