"""Optional schema-validation adapters for contract-first invariant checks."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any

from icsd.core.errors import InvariantFailure

__all__ = [
    "JsonSchemaInvariant",
    "PydanticModelInvariant",
]


PathPart = str | int


@dataclass(frozen=True, slots=True)
class JsonSchemaInvariant:
    """Callable invariant check backed by JSON Schema rules."""

    schema: Mapping[str, Any]
    name: str = "json_schema_contract"
    failure_code: str = "json_schema_invalid"
    _schema_copy: dict[str, Any] = field(init=False, repr=False, compare=False)

    def __post_init__(self) -> None:
        if not isinstance(self.schema, Mapping):
            msg = "schema must be a mapping."
            raise TypeError(msg)
        object.__setattr__(self, "_schema_copy", deepcopy(dict(self.schema)))

    def __call__(self, state: Mapping[str, Any]) -> bool | tuple[InvariantFailure, ...]:
        if not isinstance(state, Mapping):
            return (
                InvariantFailure(
                    invariant=self.name,
                    code=self.failure_code,
                    message="State must be a mapping.",
                    details={"path": "$"},
                ),
            )
        issues = _validate_json_schema(
            schema=self._schema_copy,
            instance=deepcopy(dict(state)),
        )
        if not issues:
            return True
        failures: list[InvariantFailure] = []
        for issue in issues:
            failures.append(
                InvariantFailure(
                    invariant=self.name,
                    code=self.failure_code,
                    message=f"{issue.message} (path: {_path_to_string(issue.path)})",
                    details={
                        "path": _path_to_string(issue.path),
                        "schema_path": _path_to_string(issue.schema_path),
                        "validator": issue.validator,
                    },
                )
            )
        return tuple(failures)


@dataclass(frozen=True, slots=True)
class PydanticModelInvariant:
    """Callable invariant check backed by a Pydantic model contract."""

    model: type[Any]
    name: str = "pydantic_model_contract"
    failure_code: str = "pydantic_model_invalid"
    _validation_error_type: type[Exception] = field(init=False, repr=False, compare=False)

    def __post_init__(self) -> None:
        runtime = _load_pydantic_runtime()
        if runtime is None:
            msg = "PydanticModelInvariant requires the optional dependency 'pydantic'."
            raise RuntimeError(msg)
        base_model_type, validation_error_type = runtime
        if not isinstance(self.model, type) or not issubclass(self.model, base_model_type):
            msg = "model must be a subclass of pydantic.BaseModel."
            raise TypeError(msg)
        object.__setattr__(self, "_validation_error_type", validation_error_type)

    def __call__(self, state: Mapping[str, Any]) -> bool | tuple[InvariantFailure, ...]:
        if not isinstance(state, Mapping):
            return (
                InvariantFailure(
                    invariant=self.name,
                    code=self.failure_code,
                    message="State must be a mapping.",
                    details={"path": "$"},
                ),
            )
        try:
            _validate_with_pydantic(model=self.model, state=deepcopy(dict(state)))
        except Exception as exc:
            if not isinstance(exc, self._validation_error_type):
                raise
            return _normalise_pydantic_failures(
                error=exc,
                invariant_name=self.name,
                failure_code=self.failure_code,
            )
        return True


@dataclass(frozen=True, slots=True)
class _SchemaIssue:
    message: str
    validator: str
    path: tuple[PathPart, ...]
    schema_path: tuple[PathPart, ...]


def _validate_with_pydantic(*, model: type[Any], state: Mapping[str, Any]) -> None:
    if hasattr(model, "model_validate"):
        model.model_validate(state)
        return
    if hasattr(model, "parse_obj"):
        model.parse_obj(state)
        return
    msg = "model must expose model_validate(...) or parse_obj(...)."
    raise TypeError(msg)


def _load_pydantic_runtime() -> tuple[type[Any], type[Exception]] | None:
    try:
        from pydantic import BaseModel, ValidationError
    except ModuleNotFoundError:
        return None
    return BaseModel, ValidationError


def _normalise_pydantic_failures(
    *,
    error: Exception,
    invariant_name: str,
    failure_code: str,
) -> tuple[InvariantFailure, ...]:
    errors_getter = getattr(error, "errors", None)
    if not callable(errors_getter):
        return (
            InvariantFailure(
                invariant=invariant_name,
                code=failure_code,
                message=str(error),
                details={"path": "$"},
            ),
        )
    failures: list[InvariantFailure] = []
    for item in errors_getter():
        if not isinstance(item, Mapping):
            continue
        raw_loc = item.get("loc", ())
        if isinstance(raw_loc, Sequence) and not isinstance(raw_loc, (str, bytes, bytearray)):
            path = _normalise_path(raw_loc)
        else:
            path = ()
        details: dict[str, Any] = {
            "path": _path_to_string(path),
            "type": str(item.get("type", "value_error")),
        }
        context = item.get("ctx")
        if isinstance(context, Mapping):
            details["ctx"] = dict(context)
        failures.append(
            InvariantFailure(
                invariant=invariant_name,
                code=failure_code,
                message=str(item.get("msg", "Pydantic validation failed.")),
                details=details,
            )
        )
    if failures:
        return tuple(failures)
    return (
        InvariantFailure(
            invariant=invariant_name,
            code=failure_code,
            message=str(error),
            details={"path": "$"},
        ),
    )


def _validate_json_schema(
    *,
    schema: Mapping[str, Any],
    instance: Any,
) -> tuple[_SchemaIssue, ...]:
    validator_type = _load_jsonschema_validator()
    if validator_type is None:
        return tuple(
            _validate_json_schema_fallback(
                schema=schema,
                instance=instance,
                path=(),
                schema_path=(),
            )
        )
    try:
        validator = validator_type(schema)
    except Exception as exc:
        msg = "Invalid JSON Schema supplied to JsonSchemaInvariant."
        raise ValueError(msg) from exc
    try:
        errors = sorted(validator.iter_errors(instance), key=_jsonschema_error_sort_key)
    except Exception as exc:
        msg = "JSON Schema validation failed due to validator error."
        raise ValueError(msg) from exc
    return tuple(
        _SchemaIssue(
            message=error.message,
            validator=str(getattr(error, "validator", "unknown")),
            path=_normalise_path(tuple(getattr(error, "path", ()))),
            schema_path=_normalise_path(tuple(getattr(error, "schema_path", ()))),
        )
        for error in errors
    )


def _load_jsonschema_validator() -> type[Any] | None:
    try:
        from jsonschema import Draft202012Validator
    except ModuleNotFoundError:
        return None
    return Draft202012Validator


def _jsonschema_error_sort_key(error: Any) -> tuple[tuple[str, ...], tuple[str, ...], str]:
    path = tuple(str(part) for part in getattr(error, "path", ()))
    schema_path = tuple(str(part) for part in getattr(error, "schema_path", ()))
    return path, schema_path, str(getattr(error, "message", ""))


def _validate_json_schema_fallback(
    *,
    schema: Mapping[str, Any],
    instance: Any,
    path: tuple[PathPart, ...],
    schema_path: tuple[PathPart, ...],
) -> list[_SchemaIssue]:
    issues: list[_SchemaIssue] = []
    raw_expected_type = schema.get("type")
    if raw_expected_type is not None and not _matches_json_type(instance, raw_expected_type):
        issues.append(
            _SchemaIssue(
                message=f"Expected type {raw_expected_type!r}, got {type(instance).__name__!r}.",
                validator="type",
                path=path,
                schema_path=schema_path + ("type",),
            )
        )
        return issues
    if "const" in schema and instance != schema["const"]:
        issues.append(
            _SchemaIssue(
                message=f"Value must be constant {schema['const']!r}.",
                validator="const",
                path=path,
                schema_path=schema_path + ("const",),
            )
        )
    if "enum" in schema and isinstance(schema["enum"], Sequence):
        if instance not in schema["enum"]:
            issues.append(
                _SchemaIssue(
                    message=f"Value must be one of {list(schema['enum'])!r}.",
                    validator="enum",
                    path=path,
                    schema_path=schema_path + ("enum",),
                )
            )
    if isinstance(instance, Mapping):
        issues.extend(
            _validate_json_schema_object(
                schema=schema,
                instance=instance,
                path=path,
                schema_path=schema_path,
            )
        )
    if isinstance(instance, list):
        issues.extend(
            _validate_json_schema_array(
                schema=schema,
                instance=instance,
                path=path,
                schema_path=schema_path,
            )
        )
    if isinstance(instance, str):
        issues.extend(
            _validate_json_schema_string(
                schema=schema,
                instance=instance,
                path=path,
                schema_path=schema_path,
            )
        )
    if _is_json_number(instance):
        issues.extend(
            _validate_json_schema_number(
                schema=schema,
                instance=instance,
                path=path,
                schema_path=schema_path,
            )
        )
    return issues


def _validate_json_schema_object(
    *,
    schema: Mapping[str, Any],
    instance: Mapping[str, Any],
    path: tuple[PathPart, ...],
    schema_path: tuple[PathPart, ...],
) -> list[_SchemaIssue]:
    issues: list[_SchemaIssue] = []
    required = schema.get("required")
    if isinstance(required, Sequence) and not isinstance(required, (str, bytes, bytearray)):
        for key in required:
            if isinstance(key, str) and key not in instance:
                issues.append(
                    _SchemaIssue(
                        message=f"Missing required property {key!r}.",
                        validator="required",
                        path=path,
                        schema_path=schema_path + ("required",),
                    )
                )
    properties = schema.get("properties")
    if not isinstance(properties, Mapping):
        properties = {}
    additional_properties = schema.get("additionalProperties", True)
    unknown_keys = sorted(key for key in instance if key not in properties)
    if additional_properties is False:
        for key in unknown_keys:
            issues.append(
                _SchemaIssue(
                    message=f"Additional property {key!r} is not allowed.",
                    validator="additionalProperties",
                    path=path + (key,),
                    schema_path=schema_path + ("additionalProperties",),
                )
            )
    if isinstance(additional_properties, Mapping):
        for key in unknown_keys:
            issues.extend(
                _validate_json_schema_fallback(
                    schema=additional_properties,
                    instance=instance[key],
                    path=path + (key,),
                    schema_path=schema_path + ("additionalProperties",),
                )
            )
    for key in sorted(properties):
        if key not in instance:
            continue
        child_schema = properties[key]
        if not isinstance(child_schema, Mapping):
            continue
        issues.extend(
            _validate_json_schema_fallback(
                schema=child_schema,
                instance=instance[key],
                path=path + (key,),
                schema_path=schema_path + ("properties", key),
            )
        )
    return issues


def _validate_json_schema_array(
    *,
    schema: Mapping[str, Any],
    instance: list[Any],
    path: tuple[PathPart, ...],
    schema_path: tuple[PathPart, ...],
) -> list[_SchemaIssue]:
    issues: list[_SchemaIssue] = []
    min_items = schema.get("minItems")
    if isinstance(min_items, int) and len(instance) < min_items:
        issues.append(
            _SchemaIssue(
                message=f"Array must contain at least {min_items} items.",
                validator="minItems",
                path=path,
                schema_path=schema_path + ("minItems",),
            )
        )
    max_items = schema.get("maxItems")
    if isinstance(max_items, int) and len(instance) > max_items:
        issues.append(
            _SchemaIssue(
                message=f"Array must contain at most {max_items} items.",
                validator="maxItems",
                path=path,
                schema_path=schema_path + ("maxItems",),
            )
        )
    items_schema = schema.get("items")
    if isinstance(items_schema, Mapping):
        for index, item in enumerate(instance):
            issues.extend(
                _validate_json_schema_fallback(
                    schema=items_schema,
                    instance=item,
                    path=path + (index,),
                    schema_path=schema_path + ("items",),
                )
            )
    return issues


def _validate_json_schema_string(
    *,
    schema: Mapping[str, Any],
    instance: str,
    path: tuple[PathPart, ...],
    schema_path: tuple[PathPart, ...],
) -> list[_SchemaIssue]:
    issues: list[_SchemaIssue] = []
    min_length = schema.get("minLength")
    if isinstance(min_length, int) and len(instance) < min_length:
        issues.append(
            _SchemaIssue(
                message=f"String length must be >= {min_length}.",
                validator="minLength",
                path=path,
                schema_path=schema_path + ("minLength",),
            )
        )
    max_length = schema.get("maxLength")
    if isinstance(max_length, int) and len(instance) > max_length:
        issues.append(
            _SchemaIssue(
                message=f"String length must be <= {max_length}.",
                validator="maxLength",
                path=path,
                schema_path=schema_path + ("maxLength",),
            )
        )
    return issues


def _validate_json_schema_number(
    *,
    schema: Mapping[str, Any],
    instance: int | float,
    path: tuple[PathPart, ...],
    schema_path: tuple[PathPart, ...],
) -> list[_SchemaIssue]:
    issues: list[_SchemaIssue] = []
    minimum = schema.get("minimum")
    if isinstance(minimum, (int, float)) and instance < minimum:
        issues.append(
            _SchemaIssue(
                message=f"Number must be >= {minimum}.",
                validator="minimum",
                path=path,
                schema_path=schema_path + ("minimum",),
            )
        )
    maximum = schema.get("maximum")
    if isinstance(maximum, (int, float)) and instance > maximum:
        issues.append(
            _SchemaIssue(
                message=f"Number must be <= {maximum}.",
                validator="maximum",
                path=path,
                schema_path=schema_path + ("maximum",),
            )
        )
    return issues


def _matches_json_type(instance: Any, expected: Any) -> bool:
    if isinstance(expected, str):
        return _matches_json_type_name(instance, expected)
    if isinstance(expected, Sequence) and not isinstance(expected, (str, bytes, bytearray)):
        return any(_matches_json_type_name(instance, str(item)) for item in expected)
    return True


def _matches_json_type_name(instance: Any, expected: str) -> bool:
    if expected == "object":
        return isinstance(instance, Mapping)
    if expected == "array":
        return isinstance(instance, list)
    if expected == "string":
        return isinstance(instance, str)
    if expected == "number":
        return _is_json_number(instance)
    if expected == "integer":
        return isinstance(instance, int) and not isinstance(instance, bool)
    if expected == "boolean":
        return isinstance(instance, bool)
    if expected == "null":
        return instance is None
    return True


def _is_json_number(value: Any) -> bool:
    return isinstance(value, (int, float)) and not isinstance(value, bool)


def _normalise_path(raw: Sequence[Any]) -> tuple[PathPart, ...]:
    path: list[PathPart] = []
    for part in raw:
        if isinstance(part, (str, int)):
            path.append(part)
        else:
            path.append(str(part))
    return tuple(path)


def _path_to_string(path: Sequence[PathPart]) -> str:
    if not path:
        return "$"
    parts: list[str] = ["$"]
    for part in path:
        if isinstance(part, int):
            parts.append(f"[{part}]")
            continue
        parts.append(f".{part}")
    return "".join(parts)
