"""RFC 8785 JSON Canonicalization Scheme (JCS) + SHA-256 hashing.

Implements deterministic JSON serialization per RFC 8785 and produces
stable SHA-256 hex digests. No external dependencies beyond stdlib.
"""

from __future__ import annotations

import hashlib
import json
import math
from typing import Any


def _serialize_number(value: int | float) -> str:
    """Serialize a number per RFC 8785 / ES6 Number serialization.

    - Integers (and integer-valued floats) serialize without decimal point.
    - Special values (NaN, Infinity) are not valid JSON — we reject them.
    - Floats use shortest representation matching ES6 Number.toString().
    """
    if isinstance(value, bool):
        raise TypeError("Booleans are not numbers in JCS context")

    if isinstance(value, int):
        return str(value)

    # float path
    if math.isnan(value) or math.isinf(value):
        raise ValueError(f"JCS does not support {value}")

    if value == 0.0:
        # Handle -0.0 → "0"
        return "0"

    # If it's an integer-valued float, serialize as integer
    if value == int(value) and abs(value) < 2**53:
        return str(int(value))

    # Use ES6 Number serialization rules.
    # Python's repr gives us a good starting point but we need to match
    # the exact ES6 algorithm for edge cases. The key rule: use the
    # shortest decimal representation that round-trips to the same
    # IEEE 754 double.
    #
    # Python 3.1+ uses David Gay's dtoa which produces shortest-representation
    # for repr(), matching ES6 behavior for most values.
    r = repr(value)

    # Python uses 'e' notation like '1.5e+308', ES6 uses 'e+308' too.
    # Python may produce things like '1e-07' which is fine.
    # But Python uses 'e-07' not 'e-7' — ES6 doesn't pad with zeros.
    # Actually Python doesn't pad either in modern versions.

    # Handle scientific notation formatting to match ES6
    if "e" in r or "E" in r:
        mantissa, exp = r.lower().split("e")
        exp_val = int(exp)
        # Remove trailing zeros from mantissa after decimal point
        if "." in mantissa:
            mantissa = mantissa.rstrip("0").rstrip(".")
        r = f"{mantissa}e{'+' if exp_val >= 0 else ''}{exp_val}"

    return r


def _serialize_string(value: str) -> str:
    """Serialize a string per RFC 8785 — minimal escaping."""
    # Use json.dumps which handles the required escaping correctly.
    # RFC 8785 requires escaping: \", \\, and control chars (U+0000-U+001F).
    # json.dumps does exactly this with ensure_ascii=False.
    return json.dumps(value, ensure_ascii=False)


def _jcs_serialize(value: Any) -> str:
    """Serialize a value to JCS canonical form (RFC 8785)."""
    if value is None:
        return "null"

    if isinstance(value, bool):
        return "true" if value else "false"

    if isinstance(value, (int, float)):
        return _serialize_number(value)

    if isinstance(value, str):
        return _serialize_string(value)

    if isinstance(value, list):
        elements = ",".join(_jcs_serialize(item) for item in value)
        return f"[{elements}]"

    if isinstance(value, dict):
        # RFC 8785: sort keys by UTF-16 code unit order.
        # For strings within BMP (most JSON keys), this is identical to
        # Python's default string sort. For supplementary characters,
        # we need to sort by UTF-16 encoding.
        sorted_keys = sorted(value.keys(), key=_utf16_sort_key)
        pairs = ",".join(
            f"{_serialize_string(k)}:{_jcs_serialize(value[k])}" for k in sorted_keys
        )
        return f"{{{pairs}}}"

    raise TypeError(f"JCS cannot serialize type: {type(value).__name__}")


def _utf16_sort_key(s: str) -> list[int]:
    """Return UTF-16 code units for sorting per RFC 8785."""
    result: list[int] = []
    for char in s:
        cp = ord(char)
        if cp < 0x10000:
            result.append(cp)
        else:
            # Encode as surrogate pair
            cp -= 0x10000
            result.append(0xD800 + (cp >> 10))
            result.append(0xDC00 + (cp & 0x3FF))
    return result


def jcs_serialize(obj: Any) -> bytes:
    """Serialize a JSON-compatible Python object to JCS canonical bytes (UTF-8).

    Args:
        obj: JSON-serializable Python object.

    Returns:
        UTF-8 encoded canonical JSON bytes per RFC 8785.
    """
    return _jcs_serialize(obj).encode("utf-8")


def canonical_hash_jcs_sha256(obj: Any) -> str:
    """Compute JCS + SHA-256 hash of a JSON-serializable object.

    Args:
        obj: JSON-serializable Python object.

    Returns:
        Hex digest string (64 lowercase hex chars).
    """
    canonical = jcs_serialize(obj)
    return hashlib.sha256(canonical).hexdigest()


def _resolve_pointer(obj: Any, pointer: str) -> tuple[Any, str | int | None, Any]:
    """Resolve a JSON Pointer (RFC 6901) to its value and parent context.

    Args:
        obj: Root JSON object.
        pointer: JSON Pointer path (e.g., "/components/run_manifest").

    Returns:
        Tuple of (parent_obj, last_key, value) where:
        - parent_obj is the container holding the final key
        - last_key is the final key/index in the pointer
        - value is the resolved value (or None if not found)

    Raises:
        ValueError: If pointer is malformed or path doesn't exist.
    """
    if not pointer.startswith("/"):
        raise ValueError(f"JSON Pointer must start with '/': {pointer}")

    if pointer == "/":
        return None, None, obj

    tokens = pointer[1:].split("/")
    current = obj
    parent = None
    last_key = None

    for _i, token in enumerate(tokens):
        # Unescape JSON Pointer tokens (~ becomes ~0, / becomes ~1)
        token = token.replace("~1", "/").replace("~0", "~")

        parent = current
        last_key = token

        if isinstance(current, dict):
            if token not in current:
                raise ValueError(f"Pointer path not found: {pointer} (missing key: {token})")
            current = current[token]
        elif isinstance(current, list):
            try:
                idx = int(token)
                if idx < 0 or idx >= len(current):
                    raise ValueError(f"Pointer index out of range: {pointer} (index: {idx})")
                last_key = idx
                current = current[idx]
            except ValueError as e:
                msg = f"Invalid array index in pointer: {pointer} (token: {token})"
                raise ValueError(msg) from e
        else:
            raise ValueError(f"Cannot traverse into {type(current).__name__} at {pointer}")

    return parent, last_key, current


def canonical_hash_excluding_metadata(
    obj: Any,
    metadata_fields: list[str] | None = None,
) -> str:
    """Compute deterministic hash excluding metadata fields.

    This function produces a canonical hash that excludes non-deterministic
    metadata fields (timestamps, UUIDs, etc.) to enable comparison of
    evidence packs across replays where content is identical but metadata varies.

    Args:
        obj: JSON-serializable Python object (typically an evidence pack manifest).
        metadata_fields: List of JSON Pointer paths (RFC 6901) to exclude from hashing.
                        If None, uses default metadata fields from evidence_pack_manifest.

    Returns:
        Hex digest string (64 lowercase hex chars) of the canonical content.

    Example:
        >>> manifest1 = {
        ...     "run_id": "abc12345",
        ...     "created_at": "2024-01-01T00:00:00Z",
        ...     "hashes": {...}
        ... }
        >>> manifest2 = {
        ...     "run_id": "def67890",
        ...     "created_at": "2024-01-02T00:00:00Z",
        ...     "hashes": {...}
        ... }
        >>> # If hashes are identical, these will produce the same canonical hash:
        >>> canonical_hash_excluding_metadata(manifest1, ["/run_id", "/created_at"])
        >>> canonical_hash_excluding_metadata(manifest2, ["/run_id", "/created_at"])
    """
    import copy

    if metadata_fields is None:
        # Default metadata fields from evidence_pack_manifest schema
        metadata_fields = [
            "/created_at",
            "/run_id",
            "/summary/total_files",
            "/summary/total_artifacts",
        ]

    # Deep copy to avoid mutating original
    obj_copy = copy.deepcopy(obj)

    # Remove metadata fields in reverse order of depth to avoid index shifting issues
    sorted_pointers = sorted(metadata_fields, key=lambda p: p.count("/"), reverse=True)

    for pointer in sorted_pointers:
        try:
            parent, last_key, _ = _resolve_pointer(obj_copy, pointer)
            if parent is not None and last_key is not None:
                if isinstance(parent, dict) and last_key in parent:
                    del parent[last_key]
                elif isinstance(parent, list) and isinstance(last_key, int):
                    parent.pop(last_key)
        except ValueError:
            # Path doesn't exist in this object, skip
            continue

    return canonical_hash_jcs_sha256(obj_copy)


# Cross-repo compatibility aliases
canonical_serialize = jcs_serialize
canonical_hash = canonical_hash_jcs_sha256
