"""Lightweight type validation system replacing Pydantic.

Provides Schema base class, dynamic schema creation, and type validation
for config values against function signatures.
"""

import inspect
import sys
from typing import Any, Optional, get_type_hints

from .typechecker import Ctx
from .typechecker import check_type as _tc2_check_type

# Optional pydantic imports — confection doesn't depend on pydantic,
# but if it's installed we can detect and convert BaseModel schemas.
try:
    from pydantic.v1 import (
        BaseModel as _PydanticV1BaseModel,  # pyright: ignore[reportMissingImports]
    )
    from pydantic.v1 import (
        ValidationError as _PydanticV1ValidationError,  # pyright: ignore[reportMissingImports]
    )
except (ImportError, ModuleNotFoundError):  # pragma: no cover
    _PydanticV1BaseModel = None  # type: ignore[assignment,misc]
    _PydanticV1ValidationError = None  # type: ignore[assignment,misc]

try:
    from pydantic import (
        BaseModel as _PydanticV2BaseModel,  # pyright: ignore[reportMissingImports]
    )
    from pydantic import (
        ValidationError as _PydanticV2ValidationError,  # pyright: ignore[reportMissingImports]
    )
except (ImportError, ModuleNotFoundError):  # pragma: no cover
    _PydanticV2BaseModel = None  # type: ignore[assignment,misc]
    _PydanticV2ValidationError = None  # type: ignore[assignment,misc]

# === Constrained Types ===


class StrictBool:
    """Only accepts actual bool values (not int 0/1)."""

    pass


class PositiveInt:
    """Only accepts positive integers (> 0, not bool)."""

    pass


class StrictFloat:
    """Only accepts actual float values (not int)."""

    pass


# === Field Info ===


class FieldInfo:
    """Information about a schema field."""

    __slots__ = ("default", "alias", "annotation")

    def __init__(self, default=..., *, alias=None):
        self.default = default
        self.alias = alias
        self.annotation: Any = None

    def is_required(self):
        return self.default is ...


def Field(default=..., *, alias=None):
    """Create a field definition."""
    return FieldInfo(default=default, alias=alias)


# === Validation Error ===


class ValidationError(Exception):
    """Raised when schema validation fails."""

    def __init__(self, error_list):
        self._errors = error_list
        msgs = "; ".join(e.get("msg", "") for e in error_list)
        super().__init__(msgs)

    def errors(self):
        return self._errors


# === Schema ===


class _ValidatedResult:
    """Attribute-accessible result from model_validate."""

    def __init__(self, data):
        self.__dict__.update(data)


class Schema:
    """Base class for config validation schemas. Replaces pydantic.BaseModel."""

    model_config: dict = {"extra": "allow", "arbitrary_types_allowed": True}
    model_fields: dict = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        fields = {}
        all_hints = {}
        for base in reversed(cls.__mro__):
            base_annotations = getattr(base, "__annotations__", {})
            all_hints.update(base_annotations)

        for name, annotation in all_hints.items():
            if name in ("model_config", "model_fields") or name.startswith("_"):
                continue
            default = ...
            alias = None
            for klass in cls.__mro__:
                if name in klass.__dict__:
                    val = klass.__dict__[name]
                    if isinstance(val, FieldInfo):
                        default = val.default
                        alias = val.alias
                    elif not isinstance(
                        val, (type, classmethod, staticmethod, property)
                    ):
                        if not callable(val):
                            default = val
                    break
            field = FieldInfo(default=default, alias=alias)
            field.annotation = annotation
            fields[name] = field

        cls.model_fields = fields

    def __init__(self, **kwargs):
        for name, field in self.__class__.model_fields.items():
            if name in kwargs:
                setattr(self, name, kwargs[name])
            elif not field.is_required():
                setattr(self, name, field.default)

    @classmethod
    def model_validate(cls, data):
        """Validate a dict against this schema."""
        alias_gen = cls.model_config.get("alias_generator")
        errors = _validate_schema(data, cls.model_fields, cls.model_config, alias_gen)
        if errors:
            raise ValidationError(errors)
        # Build result with defaults filled in
        result_data = dict(data)
        for name, field in cls.model_fields.items():
            data_key = name
            if field.alias is not None:
                data_key = field.alias
            elif alias_gen:
                data_key = alias_gen(name)
            if data_key not in result_data and not field.is_required():
                result_data[data_key] = field.default
        return _ValidatedResult(result_data)

    @classmethod
    def from_function(
        cls,
        func,
        *,
        config=None,
    ):
        """Build a Schema subclass from a function's signature.

        Each parameter becomes a field.  The annotation is used as the type
        (defaulting to ``Any`` when missing) and the default value is
        preserved (parameters without defaults become required fields).

        ``*args`` parameters are wrapped in ``Sequence[annotation]`` and
        stored under the ``VARIABLE_POSITIONAL_ARGS`` field name.

        Forward-reference annotations are resolved via
        ``typing.get_type_hints`` against the function's module namespace.
        """
        from typing import Sequence as _Seq

        if config is None:
            config = {"extra": "forbid", "arbitrary_types_allowed": True}

        resolved = resolve_type_hints(func)
        fields = {}
        for param in inspect.signature(func).parameters.values():
            annotation = resolved.get(param.name, param.annotation)
            if annotation is inspect.Parameter.empty:
                annotation = Any
            if param.default is inspect.Parameter.empty:
                default = ...
            else:
                default = param.default
            if param.kind == inspect.Parameter.VAR_POSITIONAL:
                annotation = _Seq[annotation]  # type: ignore[valid-type]
            if param.kind == inspect.Parameter.VAR_KEYWORD:
                continue
            field = FieldInfo(default=default)
            field.annotation = annotation
            fields[param.name] = field

        return create_schema(
            func.__name__,
            __config__=config,
            **{name: (f.annotation, f) for name, f in fields.items()},
        )

    def model_dump(self):
        """Convert instance to dict."""
        result = {}
        for name in self.__class__.model_fields:
            if hasattr(self, name):
                val = getattr(self, name)
                if isinstance(val, Schema):
                    result[name] = val.model_dump()
                else:
                    result[name] = val
        return result


def create_schema(__name, __config__=None, **fields):
    """Dynamically create a Schema subclass.

    Each field value should be a (annotation, FieldInfo) tuple.
    """
    if __config__ is None:
        __config__ = {"extra": "allow"}

    processed = {}
    annotations = {}
    defaults = {}

    for name, field_def in fields.items():
        if isinstance(field_def, tuple) and len(field_def) == 2:
            annotation, field_info = field_def
            if not isinstance(field_info, FieldInfo):
                field_info = FieldInfo(default=field_info)
        else:
            raise ValueError(f"Field {name} must be (annotation, FieldInfo) tuple")

        field_info.annotation = annotation
        processed[name] = field_info
        annotations[name] = annotation
        if not field_info.is_required():
            defaults[name] = field_info.default

    namespace = {
        "__annotations__": annotations,
        "model_config": __config__,
    }
    namespace.update(defaults)

    cls = type(__name, (Schema,), namespace)
    # Apply alias_generator to fields that don't have explicit aliases
    alias_gen = __config__.get("alias_generator") if __config__ else None
    if alias_gen and callable(alias_gen):
        for name, field in processed.items():
            if field.alias is None:
                field.alias = alias_gen(name)
    # Override with our processed fields (preserving aliases)
    cls.model_fields = processed
    return cls


# === Resolve forward references ===


def resolve_type_hints(func):
    """Resolve type hints for a function, handling forward references.

    Falls back to raw annotations if resolution fails.
    """
    try:
        mod_name = getattr(func, "__module__", None)
        module = sys.modules.get(mod_name) if mod_name else None
        globalns = vars(module) if module else None
        return get_type_hints(func, globalns=globalns)
    except (NameError, AttributeError, TypeError, RecursionError):
        # NameError: unresolvable forward reference
        # AttributeError: module without expected attributes
        # TypeError: invalid annotation object
        # RecursionError: self-referential types (Python 3.13+)
        return {}


# === Type Validation ===


def _error_type_for(annotation):
    """Get an error type string for an annotation."""
    if annotation is int or annotation is PositiveInt:
        return "int_parsing"
    elif annotation is str:
        return "string_type"
    elif annotation is float or annotation is StrictFloat:
        return "float_parsing"
    elif annotation is bool or annotation is StrictBool:
        return "bool_type"
    return "value_error"


def validate_type(value, annotation):
    """Validate value against a type annotation.

    Returns None if valid, or an error message string if invalid.
    """
    ctx = Ctx()
    if _tc2_check_type(value, annotation, ctx=ctx):
        return None
    if ctx.errors:
        return str(ctx.errors[0])
    return f"{value!r} does not match {annotation}"  # pragma: no cover -- defensive fallback


# === Schema Validation ===


def _validate_schema(data, fields, config, alias_generator=None):
    """Validate a data dict against schema fields.

    Returns list of error dicts (empty if valid).
    """
    errors = []
    extra_mode = config.get("extra", "allow")

    # Build mapping: data_key -> (field_name, FieldInfo)
    key_to_field = {}
    known_keys = set()

    for name, field in fields.items():
        if field.alias is not None:
            data_key = field.alias
        elif alias_generator:
            data_key = alias_generator(name)
        else:
            data_key = name

        known_keys.add(data_key)
        key_to_field[data_key] = (name, field)

    # Check extra fields
    if extra_mode == "forbid":
        for key in data:
            if key not in known_keys:
                errors.append(
                    {
                        "loc": (key,),
                        "msg": "Extra inputs are not permitted",
                        "type": "extra_forbidden",
                    }
                )

    # Validate each field
    for data_key, (name, field) in key_to_field.items():
        if data_key in data:
            value = data[data_key]
            err = validate_type(value, field.annotation)
            if err:
                errors.append(
                    {
                        "loc": (data_key,),
                        "msg": err,
                        "type": _error_type_for(field.annotation),
                    }
                )
        elif field.is_required():
            errors.append(
                {
                    "loc": (data_key,),
                    "msg": "Field required",
                    "type": "missing",
                }
            )

    return errors


# === Pydantic Compatibility Shim ===

_pydantic_cache: dict = {}


def _get_pydantic_validation_error():
    """Return the pydantic ValidationError class(es) to catch.

    Tries both pydantic.v1 and pydantic so we catch the right exception
    regardless of which API the caller's model was built with.
    """
    errors = []
    if _PydanticV1ValidationError is not None:
        errors.append(_PydanticV1ValidationError)
    if _PydanticV2ValidationError is not None:
        errors.append(_PydanticV2ValidationError)
    if errors:
        return tuple(errors)
    # Should never happen — we only get here if someone passed a pydantic
    # model, which means pydantic is installed.  Fall back to Exception so
    # the except clause still works rather than crashing.
    return (Exception,)  # pragma: no cover


def _is_pydantic_model(cls):
    """Check if cls is a pydantic BaseModel class (v1 or v2) without hard-depending
    on pydantic. Returns False if pydantic is not installed."""
    if not isinstance(cls, type):
        return False
    if issubclass(cls, Schema):
        return False
    if _PydanticV1BaseModel is not None and issubclass(cls, _PydanticV1BaseModel):
        return True
    if _PydanticV2BaseModel is not None and issubclass(cls, _PydanticV2BaseModel):
        return True
    return False


def _pydantic_instance_to_dict(obj):
    """Convert a pydantic model instance to a dict."""
    if hasattr(obj, "model_dump"):
        return obj.model_dump()
    if hasattr(obj, "dict"):
        return obj.dict()
    return obj


def _extract_pydantic_fields(pydantic_cls):
    """Extract field definitions from a pydantic BaseModel class (v1 or v2)."""
    fields = {}

    if hasattr(pydantic_cls, "model_fields"):
        # pydantic v2 interface (check first — v2 also exposes __fields__
        # as a deprecated shim, so we must not fall into the v1 branch)
        for name, pyd_field in pydantic_cls.model_fields.items():
            annotation = pyd_field.annotation
            if pyd_field.is_required():
                default = ...
            else:
                default = pyd_field.default
            alias = pyd_field.alias

            if isinstance(annotation, type) and _is_pydantic_model(annotation):
                annotation = ensure_schema(annotation)
            if default is not ... and hasattr(default, "model_dump"):
                default = _pydantic_instance_to_dict(default)

            field = FieldInfo(default=default, alias=alias)
            field.annotation = annotation
            fields[name] = field

    elif hasattr(pydantic_cls, "__fields__"):
        # pydantic v1 interface
        for name, pyd_field in pydantic_cls.__fields__.items():
            annotation = pyd_field.outer_type_
            # pydantic v1 unwraps Optional[X] into outer_type_=X +
            # allow_none=True.  Re-wrap so our validator sees the Union.
            if getattr(pyd_field, "allow_none", False):
                annotation = Optional[annotation]
            if pyd_field.required:
                default = ...
            else:
                default = pyd_field.default
            alias = pyd_field.alias if pyd_field.alias != name else None

            # Recursively convert nested pydantic model annotations
            if isinstance(annotation, type) and _is_pydantic_model(annotation):
                annotation = ensure_schema(annotation)
            # Convert pydantic instance defaults to dicts
            if default is not ... and hasattr(default, "__fields__"):
                default = _pydantic_instance_to_dict(default)

            field = FieldInfo(default=default, alias=alias)
            field.annotation = annotation
            fields[name] = field

    return fields


def _extract_pydantic_config(pydantic_cls):
    """Extract model config from a pydantic BaseModel class (v1 or v2)."""
    config = {"extra": "allow"}

    if hasattr(pydantic_cls, "__config__"):
        # pydantic v1: inner class Config
        cfg = pydantic_cls.__config__
        extra = getattr(cfg, "extra", "allow")
        # v1 may use an enum (e.g. Extra.forbid); extract the .value
        if hasattr(extra, "value"):
            extra = extra.value  # pyright: ignore[reportAttributeAccessIssue]
        config["extra"] = extra if isinstance(extra, str) else str(extra)
        if hasattr(cfg, "arbitrary_types_allowed"):
            config["arbitrary_types_allowed"] = cfg.arbitrary_types_allowed
    elif hasattr(pydantic_cls, "model_config") and isinstance(
        pydantic_cls.model_config, dict
    ):
        # pydantic v2: dict
        config = dict(pydantic_cls.model_config)

    return config


def ensure_schema(schema_cls):
    """Ensure *schema_cls* satisfies the Schema interface.

    If it already is a Schema subclass, return it unchanged.
    If it is a pydantic BaseModel (v1 or v2), build a thin Schema wrapper
    that exposes the same ``model_fields`` / ``model_config`` and delegates
    ``model_validate`` to the original pydantic class so that pydantic
    validators, strict types, constrained types etc. keep working.

    This allows downstream libraries (spaCy, thinc, …) to keep passing
    pydantic schemas to ``registry.resolve()`` / ``registry.fill()`` even
    though confection itself no longer depends on pydantic.
    """
    if isinstance(schema_cls, type) and issubclass(schema_cls, Schema):
        return schema_cls
    if not _is_pydantic_model(schema_cls):
        return schema_cls

    # Return cached conversion if available
    if schema_cls in _pydantic_cache:
        return _pydantic_cache[schema_cls]

    fields = _extract_pydantic_fields(schema_cls)
    config = _extract_pydantic_config(schema_cls)

    # Build wrapper class that inherits from Schema
    pyd_cls = schema_cls  # capture for closure

    wrapper = type(pydantic_cls_name(schema_cls), (Schema,), {})
    wrapper.model_fields = fields
    wrapper.model_config = config

    # Delegate model_validate to the original pydantic model so that
    # pydantic-level validators / strict types / constraints keep working.
    @classmethod  # type: ignore[misc]
    def _pydantic_model_validate(cls, data):
        # Resolve the concrete pydantic ValidationError class once so the
        # except clause is as narrow as possible.
        pyd_validation_err = _get_pydantic_validation_error()

        try:
            if hasattr(pyd_cls, "model_validate"):
                pyd_cls.model_validate(data)
            elif hasattr(pyd_cls, "parse_obj"):
                pyd_cls.parse_obj(data)
            else:  # pragma: no cover -- all pydantic versions have model_validate or parse_obj
                pyd_cls(**data)  # pragma: no cover
        except pyd_validation_err as e:
            raise ValidationError(
                e.errors()  # pyright: ignore[reportAttributeAccessIssue]
            ) from None
        # Return attribute-accessible result with defaults filled in
        result_data = dict(data)
        for name, field in cls.model_fields.items():
            data_key = field.alias if field.alias is not None else name
            if data_key not in result_data and not field.is_required():
                result_data[data_key] = field.default
        return _ValidatedResult(result_data)

    wrapper.model_validate = _pydantic_model_validate

    _pydantic_cache[schema_cls] = wrapper
    return wrapper


def pydantic_cls_name(cls):
    return getattr(cls, "__name__", "PydanticSchema")
