"""RFC 8785 (JSON Canonicalization Scheme) — the deterministic canonical encoding under the A5 gate.

This is the single canonicalization for everything that enters `content_hash`/`artifact_hash`, so its bytes
are pinned by golden vectors (A5). Implemented for the value subset the VEB/PAC-RO model uses — `str`, `int`,
`bool`, `None`, `dict[str, ...]`, `list`.

**Safe-number guard (Canon Lock v1, Phase 2 — fail-closed):** `float`, `decimal.Decimal`, and any `int` with
`abs(value) > MAX_SAFE_INTEGER` (`2**53 - 1`, the ECMAScript/I-JSON safe-integer boundary) are rejected with
`CanonicalizationError`, applied recursively to every value. `bool` is allowed (Python `bool` is an `int`
subclass, but `True`/`False` are not numbers and never trip the guard). Monetary/quantitative values, 64-bit
IDs, and nanosecond timestamps are carried as strings/integers, never IEEE-754 and never as unsafe ints, so
the hardest part of JCS number canonicalization cannot make a hash non-reproducible. Object keys are ordered
by their UTF-16 code units (the RFC 8785 rule); ASCII identifiers sort identically under it.
"""

from __future__ import annotations

import decimal
import re
from typing import Any


# Canon Lock v1 drift-guard token. This module is the single canonicalization for the whole
# stack and is VENDORED byte-for-byte by offline consumers (see docs/contracts/VENDORED_CONSUMERS.md
# and CANON_LOCK_V1.md §1.2.1).
#
# The token answers exactly ONE question: **do producers emit different canonical bytes?**
# So: **any change to this file that alters the canonical bytes — string escaping, key ordering,
# number handling, the value subset — MUST bump JCS_CANON_VERSION in the SAME PR**, and vendored
# consumers then re-pin (a deliberate, reviewed change).
#
# It is NOT a source-code revision counter. An implementation-only change — a faster route to
# byte-identical output — leaves the token alone, because bumping it would tell every consumer
# "the canon moved, go re-pin" about a change that moved nothing. A future reader seeing "v2"
# must be able to trust that some serialized byte really is different.
#
# The `canon-drift-guard` CI job enforces both halves: it fails a PR touching this file without a
# bump, UNLESS the PR is labelled `kernel-change-approved` + `canon-implementation-only` AND the
# byte-identity suite passes in that same job — so an implementation-only claim is machine-proven,
# never merely asserted.
JCS_CANON_VERSION = "1"


# ECMAScript Number.MAX_SAFE_INTEGER (RFC 8785 / I-JSON safe-integer boundary): 2**53 - 1.
# Integers with abs(value) > MAX_SAFE_INTEGER cannot survive a JSON round-trip through an
# IEEE-754 double losslessly, so they are rejected here (carry them as strings instead).
MAX_SAFE_INTEGER = 2**53 - 1


class CanonicalizationError(ValueError):
    """A value cannot be canonicalized deterministically (e.g. a float, NaN, a non-string key)."""


# RFC 8785 string escapes for the C0 control set + the two mandatory escapes.
_SHORT_ESCAPES = {
    0x08: "\\b",
    0x09: "\\t",
    0x0A: "\\n",
    0x0C: "\\f",
    0x0D: "\\r",
    0x22: '\\"',
    0x5C: "\\\\",
}


# The exact set of code points the escaping loop below treats as anything other than literal:
# the whole C0 range (short escapes + \u00xx fallback) plus the two mandatory escapes " and \.
# Every other code point — including DEL (0x7f), non-BMP characters and lone surrogates — is
# emitted literally by the loop, so a string containing none of these escapes to itself.
_NEEDS_ESCAPE = re.compile(r'[\x00-\x1f"\\]')


def _escape_string(s: str) -> str:
    # Canon v1 implementation fast path (performance only — the canonical bytes are v1's, unchanged).
    # Almost no string in practice contains a
    # character that needs escaping, and the loop below costs one ord() + one list append per
    # character. A single C-level regex scan answers "does anything here need escaping?"; when the
    # answer is no, the RFC 8785 result is the string itself between quotes, which is exactly what
    # the loop would have produced character by character. Measured 2.18x on end-to-end CL ingest.
    # Any string that DOES contain an escapable character falls through to the unchanged loop.
    if _NEEDS_ESCAPE.search(s) is None:
        return '"' + s + '"'
    out = ['"']
    for ch in s:
        code = ord(ch)
        if code in _SHORT_ESCAPES:
            out.append(_SHORT_ESCAPES[code])
        elif code < 0x20:
            out.append(f"\\u{code:04x}")
        else:
            out.append(ch)  # all other code points are literal UTF-8 (RFC 8785 minimal escaping)
    out.append('"')
    return "".join(out)


def _canon(value: Any, path: str = "$") -> str:
    if value is None:
        return "null"
    # bool MUST be checked before int (bool is an int subclass) and is always allowed —
    # True/False are not numbers and never trip the safe-integer guard.
    if value is True:
        return "true"
    if value is False:
        return "false"
    if isinstance(value, str):
        return _escape_string(value)
    if isinstance(value, bool):  # defensive: any non-singleton bool also short-circuits here
        return "true" if value else "false"
    if isinstance(value, float):
        raise CanonicalizationError(
            f"floats are not canonicalizable here at {path} — carry monetary/quantitative "
            "values as strings or integers"
        )
    if isinstance(value, decimal.Decimal):
        raise CanonicalizationError(
            f"Decimal values are not canonicalizable here at {path} — carry "
            "monetary/quantitative values as strings or integers"
        )
    if isinstance(value, int):  # note: bool handled above (bool is an int subclass)
        if abs(value) > MAX_SAFE_INTEGER:
            raise CanonicalizationError(
                f"integer out of safe range at {path}: abs(value) > MAX_SAFE_INTEGER "
                f"(2**53-1); carry 64-bit IDs / nanosecond timestamps / quantities as strings"
            )
        return str(value)
    if isinstance(value, dict):
        items = []
        for key in sorted(value.keys(), key=lambda k: _utf16_key(k)):
            items.append(_escape_string(key) + ":" + _canon(value[key], f"{path}.{key}"))
        return "{" + ",".join(items) + "}"
    if isinstance(value, (list, tuple)):
        return "[" + ",".join(_canon(v, f"{path}[{i}]") for i, v in enumerate(value)) + "]"
    raise CanonicalizationError(f"non-canonicalizable type at {path}: {type(value).__name__}")


def _utf16_key(key: Any) -> bytes:
    if not isinstance(key, str):
        raise CanonicalizationError(f"object keys must be strings, got {type(key).__name__}")
    return key.encode("utf-16-be")


def canonical_bytes(value: Any) -> bytes:
    """The RFC 8785 canonical UTF-8 bytes of `value`. Deterministic and reproducible — the A5 gate pins it."""
    return _canon(value).encode("utf-8")
