#!/usr/bin/env python3
"""Dependency-free strict JSON loader and Draft 2020-12 validator for package schemas.

Supported keywords are deliberately limited to those used by this package. An
unknown assertion keyword fails closed rather than being ignored.
"""
from __future__ import annotations

import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any


class StrictJSONError(ValueError):
    pass


def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
    out: dict[str, Any] = {}
    for key, value in pairs:
        if key in out:
            raise StrictJSONError(f"duplicate JSON object key: {key}")
        out[key] = value
    return out


def load_json(path: Path) -> Any:
    try:
        return json.loads(
            path.read_text(encoding="utf-8"),
            object_pairs_hook=_object,
            parse_constant=lambda value: (_ for _ in ()).throw(
                StrictJSONError(f"non-finite JSON number: {value}")
            ),
        )
    except (OSError, json.JSONDecodeError, StrictJSONError) as exc:
        raise StrictJSONError(f"{path}: invalid strict JSON: {exc}") from exc


def loads_json(text: str) -> Any:
    try:
        return json.loads(
            text,
            object_pairs_hook=_object,
            parse_constant=lambda value: (_ for _ in ()).throw(
                StrictJSONError(f"non-finite JSON number: {value}")
            ),
        )
    except (json.JSONDecodeError, StrictJSONError) as exc:
        raise StrictJSONError(f"invalid strict JSON: {exc}") from exc


def _type_ok(value: Any, expected: str) -> bool:
    return {
        "object": isinstance(value, dict),
        "array": isinstance(value, list),
        "string": isinstance(value, str),
        "integer": isinstance(value, int) and not isinstance(value, bool),
        "number": isinstance(value, (int, float)) and not isinstance(value, bool),
        "boolean": type(value) is bool,
        "null": value is None,
    }.get(expected, False)


def json_equal(left: Any, right: Any) -> bool:
    """JSON equality with type identity (unlike Python, true != 1)."""
    if left is None or right is None:
        return left is None and right is None
    if type(left) is not type(right):
        return False
    if isinstance(left, dict):
        return (
            set(left) == set(right)
            and all(json_equal(left[key], right[key]) for key in left)
        )
    if isinstance(left, list):
        return (
            len(left) == len(right)
            and all(json_equal(a, b) for a, b in zip(left, right))
        )
    return left == right


def _resolve(root: dict, ref: str) -> dict:
    if not ref.startswith("#/"):
        raise StrictJSONError(f"only local JSON Schema references are supported: {ref}")
    node: Any = root
    for token in ref[2:].split("/"):
        token = token.replace("~1", "/").replace("~0", "~")
        if not isinstance(node, dict) or token not in node:
            raise StrictJSONError(f"unresolved JSON Schema reference: {ref}")
        node = node[token]
    if not isinstance(node, dict):
        raise StrictJSONError(f"JSON Schema reference is not an object: {ref}")
    return node


def _is_valid(instance: Any, schema: dict, root: dict) -> bool:
    try:
        _validate(instance, schema, root, "$")
        return True
    except StrictJSONError:
        return False


def _validate(instance: Any, schema: dict, root: dict, at: str) -> None:
    annotations = {"$schema", "$id", "title", "description", "$defs"}
    assertions = {
        "$ref", "type", "const", "enum", "required", "properties",
        "additionalProperties", "items", "minItems", "maxItems", "uniqueItems",
        "minLength", "maxLength", "pattern", "minimum", "maximum", "format",
        "allOf", "if", "then", "else", "minProperties", "maxProperties",
    }
    unknown = set(schema) - annotations - assertions
    if unknown:
        raise StrictJSONError(f"{at}: unsupported JSON Schema keywords: {sorted(unknown)}")
    if "$ref" in schema:
        _validate(instance, _resolve(root, schema["$ref"]), root, at)
    if "type" in schema:
        types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
        if not types or not all(isinstance(t, str) for t in types):
            raise StrictJSONError(f"{at}: malformed type assertion")
        if not any(_type_ok(instance, t) for t in types):
            raise StrictJSONError(f"{at}: expected type {types}")
    if "const" in schema and not json_equal(instance, schema["const"]):
        raise StrictJSONError(f"{at}: value differs from const")
    if "enum" in schema and not any(json_equal(instance, value) for value in schema["enum"]):
        raise StrictJSONError(f"{at}: value not in enum")
    if isinstance(instance, dict):
        required = schema.get("required", [])
        if not isinstance(required, list) or not all(isinstance(x, str) for x in required):
            raise StrictJSONError(f"{at}: malformed required assertion")
        missing = [key for key in required if key not in instance]
        if missing:
            raise StrictJSONError(f"{at}: missing required keys {missing}")
        props = schema.get("properties", {})
        if not isinstance(props, dict):
            raise StrictJSONError(f"{at}: malformed properties assertion")
        extra = set(instance) - set(props)
        additional = schema.get("additionalProperties", True)
        if additional is False and extra:
            raise StrictJSONError(f"{at}: unexpected keys {sorted(extra)}")
        if isinstance(additional, dict):
            for key in extra:
                _validate(instance[key], additional, root, f"{at}.{key}")
        for key, subschema in props.items():
            if key in instance:
                _validate(instance[key], subschema, root, f"{at}.{key}")
        if len(instance) < schema.get("minProperties", 0):
            raise StrictJSONError(f"{at}: too few properties")
        if "maxProperties" in schema and len(instance) > schema["maxProperties"]:
            raise StrictJSONError(f"{at}: too many properties")
    if isinstance(instance, list):
        if len(instance) < schema.get("minItems", 0):
            raise StrictJSONError(f"{at}: too few items")
        if "maxItems" in schema and len(instance) > schema["maxItems"]:
            raise StrictJSONError(f"{at}: too many items")
        if schema.get("uniqueItems"):
            seen: set[bytes] = set()
            for value in instance:
                encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
                if encoded in seen:
                    raise StrictJSONError(f"{at}: duplicate array item")
                seen.add(encoded)
        if "items" in schema:
            for index, value in enumerate(instance):
                _validate(value, schema["items"], root, f"{at}[{index}]")
    if isinstance(instance, str):
        if len(instance) < schema.get("minLength", 0):
            raise StrictJSONError(f"{at}: string shorter than minLength")
        if "maxLength" in schema and len(instance) > schema["maxLength"]:
            raise StrictJSONError(f"{at}: string longer than maxLength")
        if "pattern" in schema and re.search(schema["pattern"], instance) is None:
            raise StrictJSONError(f"{at}: string does not match pattern")
        if schema.get("format") == "date-time":
            if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", instance):
                raise StrictJSONError(f"{at}: date-time must be canonical RFC3339 UTC seconds")
            try:
                datetime.fromisoformat(instance.replace("Z", "+00:00"))
            except ValueError as exc:
                raise StrictJSONError(f"{at}: invalid date-time") from exc
        elif "format" in schema:
            raise StrictJSONError(f"{at}: unsupported format {schema['format']}")
    if isinstance(instance, (int, float)) and not isinstance(instance, bool):
        if "minimum" in schema and instance < schema["minimum"]:
            raise StrictJSONError(f"{at}: below minimum")
        if "maximum" in schema and instance > schema["maximum"]:
            raise StrictJSONError(f"{at}: above maximum")
    for index, subschema in enumerate(schema.get("allOf", [])):
        _validate(instance, subschema, root, f"{at}.allOf[{index}]")
    if "if" in schema:
        branch = "then" if _is_valid(instance, schema["if"], root) else "else"
        if branch in schema:
            _validate(instance, schema[branch], root, f"{at}.{branch}")


def validate(instance: Any, schema: dict) -> None:
    if not isinstance(schema, dict):
        raise StrictJSONError("schema top level must be an object")
    if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema":
        raise StrictJSONError("schema must declare Draft 2020-12")
    _validate(instance, schema, schema, "$")


def validate_files(instance_path: Path, schema_path: Path) -> Any:
    instance = load_json(instance_path)
    schema = load_json(schema_path)
    validate(instance, schema)
    return instance
