"""Validateur JSON-Schema draft-07 (sous-ensemble) · zéro dépendance pip.

Pourquoi un validateur maison plutôt que `jsonschema` ?
-----------------------------------------------------
Le gate CI (Gitea Actions, cf. .gitea/workflows/ci.yml) tourne sur un runner
`act_runner` sans installation `pip` (les guards Sprint 1 sont du bash pur).
Ce validateur couvre EXACTEMENT les constructions utilisées par les deux schémas
du contrat Faisabilité↔Publiciste (`version.schema.json`,
`projets_master.schema.json`) : type, required, additionalProperties, enum,
pattern, minLength, minItems, maxItems, uniqueItems, minimum, maximum, items,
properties, $ref (interne « #/definitions/... »), allOf, if/then, const, format.

Les tests (`tests/test_publiciste.py`) utilisent en plus la bibliothèque
`jsonschema` comme oracle *quand elle est disponible*, pour se prémunir d'un
écart entre ce validateur et la spec draft-07. En production le validateur
maison suffit et reste autonome.

API : validate(instance, schema) -> list[str]  (liste d'erreurs, vide si OK).
"""

from __future__ import annotations

import re
from typing import Any


class SchemaError(Exception):
    """Schéma mal formé (bug du schéma, pas de la donnée)."""


def _resolve_ref(ref: str, root: dict) -> dict:
    if not ref.startswith("#/"):
        raise SchemaError(f"$ref non supporté (interne uniquement) : {ref}")
    node: Any = root
    for part in ref[2:].split("/"):
        part = part.replace("~1", "/").replace("~0", "~")
        node = node[part]
    return node


def _type_ok(value: Any, expected: str) -> bool:
    if expected == "object":
        return isinstance(value, dict)
    if expected == "array":
        return isinstance(value, list)
    if expected == "string":
        return isinstance(value, str)
    if expected == "integer":
        # bool est un int en Python — on l'exclut explicitement.
        return isinstance(value, int) and not isinstance(value, bool)
    if expected == "number":
        return isinstance(value, (int, float)) and not isinstance(value, bool)
    if expected == "boolean":
        return isinstance(value, bool)
    if expected == "null":
        return value is None
    raise SchemaError(f"type inconnu dans le schéma : {expected}")


# Formats vérifiés (les autres sont acceptés sans contrôle, comme le veut draft-07).
_DATE_TIME = re.compile(
    r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$"
)


def _check_format(value: str, fmt: str, path: str, errors: list[str]) -> None:
    if fmt == "date-time" and not _DATE_TIME.match(value):
        errors.append(f"{path}: format date-time invalide ({value!r})")


def _validate(value: Any, schema: dict, root: dict, path: str, errors: list[str]) -> None:
    if "$ref" in schema:
        schema = _resolve_ref(schema["$ref"], root)

    # const
    if "const" in schema and value != schema["const"]:
        errors.append(f"{path}: attendu const={schema['const']!r}, reçu {value!r}")

    # enum
    if "enum" in schema and value not in schema["enum"]:
        errors.append(f"{path}: {value!r} hors enum {schema['enum']}")

    # type (peut être une liste de types alternatifs)
    if "type" in schema:
        types = schema["type"]
        types = [types] if isinstance(types, str) else types
        if not any(_type_ok(value, t) for t in types):
            errors.append(f"{path}: type {type(value).__name__} ∉ {types}")
            # Inutile d'aller plus loin si le type de base est faux.
            return

    if isinstance(value, str):
        if "minLength" in schema and len(value) < schema["minLength"]:
            errors.append(f"{path}: chaîne trop courte (min {schema['minLength']})")
        if "pattern" in schema and not re.search(schema["pattern"], value):
            errors.append(f"{path}: {value!r} ne matche pas /{schema['pattern']}/")
        if "format" in schema:
            _check_format(value, schema["format"], path, errors)

    if isinstance(value, (int, float)) and not isinstance(value, bool):
        if "minimum" in schema and value < schema["minimum"]:
            errors.append(f"{path}: {value} < minimum {schema['minimum']}")
        if "maximum" in schema and value > schema["maximum"]:
            errors.append(f"{path}: {value} > maximum {schema['maximum']}")

    if isinstance(value, list):
        if "minItems" in schema and len(value) < schema["minItems"]:
            errors.append(f"{path}: {len(value)} items < minItems {schema['minItems']}")
        if "maxItems" in schema and len(value) > schema["maxItems"]:
            errors.append(f"{path}: {len(value)} items > maxItems {schema['maxItems']}")
        if schema.get("uniqueItems") and _has_duplicates(value):
            errors.append(f"{path}: items non uniques")
        if "items" in schema:
            for i, item in enumerate(value):
                _validate(item, schema["items"], root, f"{path}[{i}]", errors)

    if isinstance(value, dict):
        props = schema.get("properties", {})
        for req in schema.get("required", []):
            if req not in value:
                errors.append(f"{path}: propriété requise absente « {req} »")
        if schema.get("additionalProperties") is False:
            extra = set(value) - set(props)
            if extra:
                errors.append(f"{path}: propriétés interdites {sorted(extra)}")
        for key, sub in props.items():
            if key in value:
                _validate(value[key], sub, root, f"{path}.{key}", errors)

    # Combinateurs
    for sub in schema.get("allOf", []):
        _validate(value, sub, root, path, errors)

    if "if" in schema:
        cond_errors: list[str] = []
        _validate(value, schema["if"], root, path, cond_errors)
        branch = "then" if not cond_errors else "else"
        if branch in schema:
            _validate(value, schema[branch], root, path, errors)


def _has_duplicates(items: list) -> bool:
    seen: list = []
    for it in items:
        if it in seen:
            return True
        seen.append(it)
    return False


def validate(instance: Any, schema: dict) -> list[str]:
    """Valide `instance` contre `schema`. Retourne la liste des erreurs (vide = OK)."""
    errors: list[str] = []
    _validate(instance, schema, schema, "$", errors)
    return errors


def is_valid(instance: Any, schema: dict) -> bool:
    return not validate(instance, schema)
