import enum
import inspect
import logging
import typing
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Any, Literal, Sequence

import pydantic
from typing_extensions import Self

from stable_pydantic.model_graph import ModelNode
from stable_pydantic.source import SchemaEntry
from stable_pydantic.utils import (
    BASE_TYPES,
    COMPOSITE_TYPES_EXT,
    UNION_TYPES,
    Unreachable,
    get_default,
    get_discriminator_str,
    get_extra,
    get_field_serialization_name,
    timedelta_adapter,
)

_log = logging.getLogger(__name__)


class Compat(pydantic.BaseModel):
    type: Literal["forward", "backward"]  # Just for debug assertions
    compatible: bool
    reasons: list[str] = []
    children: dict[str, "Compat"] = {}

    def __and__(self, other: "Compat") -> "Compat":
        self._assert()
        other._assert()

        assert self.type == other.type
        assert (
            self.children is other.children
            or self.children == {}
            or other.children == {}
        )
        return Compat(
            type=self.type,
            compatible=self.compatible and other.compatible,
            reasons=self.reasons + other.reasons,
            children=self.children if self.children else other.children,
        )

    def __or__(self, other: "Compat") -> "Compat":
        self._assert()
        other._assert()

        assert self.type == other.type

        children = {}

        match (self.compatible, other.compatible):
            case (True, True):
                reasons = []
            case (True, False):
                reasons = []
            case (False, True):
                reasons = []
            case (False, False):
                reasons = self.reasons + other.reasons
                for key, value in self.children.items():
                    children[key] = value
                if self.children is not other.children:
                    for key, value in other.children.items():
                        if key not in children:
                            children[key] = value
                        else:
                            children[key] = children[key] & value
            case _:
                raise Unreachable()

        return Compat(
            type=self.type,
            compatible=self.compatible or other.compatible,
            reasons=reasons,
            children=children,
        )

    def with_context(self, reason: str) -> "Compat":
        self._assert()
        assert not self.compatible
        return Compat(
            type=self.type,
            compatible=self.compatible,
            reasons=self.reasons + [reason],
            children=self.children,
        )

    def with_child(self, name: str, child: "Compat") -> "Compat":
        self._assert()
        child._assert()

        assert self.children.get(name) is None

        if child.compatible:
            return self

        return Compat(
            type=self.type,
            compatible=self.compatible and child.compatible,
            reasons=self.reasons,
            children=self.children | {name: child},
        )

    @staticmethod
    def any(type: Literal["forward", "backward"], seq: Sequence["Compat"]) -> "Compat":
        compat = Compat(type=type, compatible=False)
        for item in seq:
            item._assert()
            compat = compat | item
        return compat

    @staticmethod
    def all(type: Literal["forward", "backward"], seq: Sequence["Compat"]) -> "Compat":
        compat = Compat(type=type, compatible=True)
        for item in seq:
            item._assert()
            compat = compat & item
        return compat

    def _assert(self):
        if self.compatible:
            assert self.reasons == []
            assert self.children == {}

    def tree(self, name: str = "root") -> str:
        """Return a tree-shaped display of the compatibility result."""
        lines: list[str] = []
        self._build_tree(lines, name, "", True)
        return "\n".join(lines)

    def _build_tree(
        self, lines: list[str], name: str, prefix: str, is_last: bool
    ) -> None:
        connector = "└── " if is_last else "├── "
        status = "✓" if self.compatible else "✗"
        lines.append(f"{prefix}{connector}{name} [{status}]")

        child_prefix = prefix + ("    " if is_last else "│   ")
        total = len(self.reasons) + len(self.children)
        idx = 0

        for reason in self.reasons:
            is_last_item = idx == total - 1
            conn = "└── " if is_last_item else "├── "
            lines.append(f"{child_prefix}{conn}⚠ {reason}")
            idx += 1

        for child_name, child in self.children.items():
            is_last_item = idx == total - 1
            child._build_tree(lines, child_name, child_prefix, is_last_item)
            idx += 1


class Compatibility(pydantic.BaseModel):
    forward: Compat
    """Old clients keep working with new data."""

    backward: Compat
    """New clients keep working with old data."""

    def __and__(self, other: "Compatibility") -> "Compatibility":
        return Compatibility(
            forward=self.forward & other.forward,
            backward=self.backward & other.backward,
        )

    def __or__(self, other: "Compatibility") -> "Compatibility":
        return Compatibility(
            forward=self.forward | other.forward,
            backward=self.backward | other.backward,
        )

    def context(self, reason: str) -> "Compatibility":
        return Compatibility(
            forward=self.forward.with_context(reason),
            backward=self.backward.with_context(reason),
        )

    def context_forward(self, reason: str) -> "Compatibility":
        assert not self.forward.compatible
        return Compatibility(
            forward=self.forward.with_context(reason),
            backward=self.backward,
        )

    def context_backward(self, reason: str) -> "Compatibility":
        assert not self.backward.compatible
        return Compatibility(
            forward=self.forward,
            backward=self.backward.with_context(reason),
        )

    def with_child(self, key: str, child: "Compatibility") -> "Compatibility":
        return Compatibility(
            forward=self.forward.with_child(key, child.forward),
            backward=self.backward.with_child(key, child.backward),
        )

    @staticmethod
    def any(iter: list["Compatibility"]) -> "Compatibility":
        assert iter != []
        return Compatibility(
            forward=Compat.any("forward", [compat.forward for compat in iter]),
            backward=Compat.any("backward", [compat.backward for compat in iter]),
        )

    @staticmethod
    def all(iter: list["Compatibility"]) -> "Compatibility":
        assert iter != []
        return Compatibility(
            forward=Compat.all("forward", [compat.forward for compat in iter]),
            backward=Compat.all("backward", [compat.backward for compat in iter]),
        )


COMPATIBLE = Compatibility(
    forward=Compat(type="forward", compatible=True),
    backward=Compat(type="backward", compatible=True),
)
INCOMPATIBLE = Compatibility(
    forward=Compat(type="forward", compatible=False),
    backward=Compat(type="backward", compatible=False),
)
FORWARD = Compatibility(
    forward=Compat(type="forward", compatible=True),
    backward=Compat(type="backward", compatible=False),
)
BACKWARD = Compatibility(
    forward=Compat(type="forward", compatible=False),
    backward=Compat(type="backward", compatible=True),
)


def check(a: SchemaEntry, b: SchemaEntry) -> Compatibility:
    """Checks if a schema is more restrictive than another."""
    if _CONFIG_DICT_ERROR:
        raise UnimplementedError(_CONFIG_DICT_ERROR_MESSAGE)
    if _FIELD_INFO_ERROR:
        raise UnimplementedError(_FIELD_INFO_ERROR_MESSAGE)
    return _model_compatibility(a.isolated_model(), b.isolated_model(), {})


def _model_compatibility(
    a: ModelNode,
    b: ModelNode,
    seen: dict[
        tuple[type[pydantic.BaseModel], type[pydantic.BaseModel]], Compatibility | None
    ],
) -> Compatibility:
    def get_field_infos(
        node: ModelNode,
    ) -> dict[str, tuple[pydantic.fields.FieldInfo, type[Any] | None]]:
        """Returns serialization { name -> (field info, resolved type hint) }."""
        hints = typing.get_type_hints(node.node, globalns=node.module.__dict__)
        config = node.node.model_config

        result = {}
        for original_name, info in node.node.model_fields.items():
            ser_name = get_field_serialization_name(original_name, info, config)
            if ser_name is None:
                continue

            result[ser_name] = (info, hints[original_name])

        return result

    if (a.node, b.node) in seen:
        compat = seen[(a.node, b.node)]
        if compat is None:
            # If we are recursing, we return that it's compatible and it'll be merged with
            # the actual compatibility result as it walks back up the tree.
            return COMPATIBLE
        else:
            return compat

    seen[(a.node, b.node)] = None

    a_fields = get_field_infos(a)
    b_fields = get_field_infos(b)

    all_names = set(a_fields.keys()) | set(b_fields.keys())

    all_models = {model: node for model, node in a.all.items()} | {
        model: node for model, node in b.all.items()
    }

    compat = COMPATIBLE

    compat = compat & _config_compatibility(a.node.model_config, b.node.model_config)

    a_extra = get_extra(a.node.model_config)
    b_extra = get_extra(b.node.model_config)

    for name in all_names:
        a_entry = a_fields.get(name)
        b_entry = b_fields.get(name)

        if a_entry is None and b_entry is None:
            raise Unreachable()
        elif a_entry is None:
            assert b_entry is not None
            b_field, b_annotation = b_entry
            inner_compat = _new_field_compatibility(b_field, a_extra, b_extra)
        elif b_entry is None:
            a_field, a_annotation = a_entry
            inner_compat = _dropped_field_compatibility(a_field, a_extra, b_extra)
        else:
            a_field, a_annotation = a_entry
            b_field, b_annotation = b_entry
            inner_compat = _field_compatibility(
                a_field,
                b_field,
                a_annotation,
                b_annotation,
                a_extra,
                b_extra,
                seen,
                all_models,
            )

        compat = compat.with_child(name, inner_compat)

    seen[(a.node, b.node)] = compat

    return compat


def _new_field_compatibility(
    b: pydantic.fields.FieldInfo,
    a_extra: Literal["allow", "forbid", "ignore"],
    b_extra: Literal["allow", "forbid", "ignore"],
) -> Compatibility:
    default = get_default(b)
    compat = COMPATIBLE

    if not default.present:
        NO_BACKWARD = "New field has no default and field added (old data might have missing values)."
        compat = compat & FORWARD.context_backward(NO_BACKWARD)

    if a_extra == "forbid":
        NO_FORWARD = "Old client forbids extra/unknown fields and field added."
        compat = compat & BACKWARD.context_forward(NO_FORWARD)

    return compat


def _dropped_field_compatibility(
    a: pydantic.fields.FieldInfo,
    a_extra: Literal["allow", "forbid", "ignore"],
    b_extra: Literal["allow", "forbid", "ignore"],
) -> Compatibility:
    default = get_default(a)
    compat = COMPATIBLE

    if not default.present:
        NO_FORWARD = "Old field has no default and field dropped (new data might have missing values)."
        compat = compat & BACKWARD.context_forward(NO_FORWARD)

    if b_extra == "forbid":
        NO_BACKWARD = "New client forbids extra/unknown fields and field dropped."
        compat = compat & FORWARD.context_backward(NO_BACKWARD)

    return compat


def _field_compatibility(
    a: pydantic.fields.FieldInfo,
    b: pydantic.fields.FieldInfo,
    a_annotation: type[Any] | None,
    b_annotation: type[Any] | None,
    a_extra: Literal["allow", "forbid", "ignore"],
    b_extra: Literal["allow", "forbid", "ignore"],
    seen: dict[
        tuple[type[pydantic.BaseModel], type[pydantic.BaseModel]], Compatibility | None
    ],
    all_models: dict[type[pydantic.BaseModel], ModelNode],
) -> Compatibility:
    return (
        _annotation_compatibility(a_annotation, b_annotation, seen, all_models)
        & _default_compatibility(a, b)
        & _discriminator_compatibility(a, b)
        & _alias_compatibility(a, b)
        & _unimplemented_field_params_compatibility(a, b)
        & _metadata_compatibility(a, b)
    )


def _discriminator_compatibility(
    a: pydantic.fields.FieldInfo,
    b: pydantic.fields.FieldInfo,
) -> Compatibility:
    """Check if discriminators are compatible between two fields."""

    a_disc = get_discriminator_str(a)
    b_disc = get_discriminator_str(b)

    if a_disc == b_disc:
        return COMPATIBLE
    elif a_disc is None:
        # Adding a discriminator could change the behaviour.
        return INCOMPATIBLE.context(
            "Old field has no discriminator, but new field does."
        )
    elif b_disc is None:
        # Adding a discriminator could change the behaviour.
        return INCOMPATIBLE.context(
            "New field has no discriminator, but old field does."
        )
    else:
        return INCOMPATIBLE.context("Discriminator is different.")


def _alias_compatibility(
    a: pydantic.fields.FieldInfo,
    b: pydantic.fields.FieldInfo,
) -> Compatibility:
    # We are already matching the fields by their serialization name.
    return COMPATIBLE


def _unimplemented_field_params_compatibility(
    a: pydantic.fields.FieldInfo, b: pydantic.fields.FieldInfo
) -> Compatibility:
    # Handled to varying extents in other functions.
    IMPLEMENTED = {
        "alias_priority",  # int | None
        "alias",  # str | None
        "annotation",  # type[Any] | None
        "default_factory",  # Callable[[], Any] | Callable[[dict[str, Any]], Any] | None
        "default",  # Any
        "discriminator",  # str | types.Discriminator | None
        "exclude",  # bool | None
        "metadata",  # list[Any]
        "serialization_alias",  # str | None
    }
    # Fields that do not affect compatibility.
    IGNORE = {
        "deprecated",  # Deprecated | str | bool | None
        "description",  # str | None
        "examples",  # list[Any] | None
        "field_title_generator",  # Callable[[str, FieldInfo], str] | None
        "frozen",  # bool | None
        "init_var",  # bool | None
        "init",  # bool | None
        "kw_only",  # bool | None
        "repr",  # bool
        "title",  # str | None
        "validate_default",  # bool | None
        "evaluated",
    }
    # Fields that need to be checked for compatibility.
    CHECK = {
        "exclude_if",  # Callable[[Any], bool] | None
        "json_schema_extra",  # JsonDict | Callable[[JsonDict], None] | None
        "validation_alias",  # str | AliasPath | AliasChoices | None
    }
    INTERNAL = {
        "_attributes_set",
        "_complete",
        "_final",
        "_original_annotation",
        "_original_assignment",
        "_qualifiers",
    }

    UNHANDLED = _ACTUAL_FIELD_INFO_FIELDS - IMPLEMENTED - IGNORE - CHECK - INTERNAL

    for field in CHECK | UNHANDLED:
        a_value = getattr(a, field, None)
        b_value = getattr(b, field, None)
        if a_value is None and b_value is None:
            continue
        elif a_value == b_value:
            _log.warning(
                f"Field parameter '{field}' is unsupported, but allowed because unchanged."
            )
            continue
        else:
            raise UnimplementedError(
                f"Field parameter '{field}' is not yet supported for compatibility checking.\n"
                + f"Got values: {a_value!r} and {b_value!r}"
            )

    return COMPATIBLE


def _metadata_compatibility(
    a: pydantic.fields.FieldInfo,
    b: pydantic.fields.FieldInfo,
) -> Compatibility:
    def _extract_metadata_dict(metadata: list[Any]) -> dict[str, Any]:
        """Convert metadata list to a dict keyed by constraint name."""
        result = {}
        for item in metadata:
            if hasattr(item, "__dict__") and item.__dict__:
                # _PydanticGeneralMetadata: stores in __dict__
                result.update(vars(item))
            else:
                # annotated_types classes: use __slots__
                for slot in getattr(type(item), "__slots__", []):
                    result[slot] = getattr(item, slot)
        return result

    def forward(kind: str) -> Compatibility:
        return FORWARD.context_backward(f"{kind} has been tightened.")

    def backward(kind: str) -> Compatibility:
        return BACKWARD.context_forward(f"{kind} has been loosened.")

    def lower_bound(a: Any | None, b: Any | None) -> Compatibility:
        if a == b:
            return COMPATIBLE
        elif a is None:
            return FORWARD.context_backward("Lower bound has been added.")
        elif b is None:
            return BACKWARD.context_forward("Lower bound has been removed.")
        elif a < b:
            # -----------
            # ---[+++++++ a
            # -------[+++ b
            return forward("Lower bound")
        else:
            return backward("Lower bound")

    def upper_bound(a: Any | None, b: Any | None) -> Compatibility:
        if a == b:
            return COMPATIBLE
        elif a is None:
            return FORWARD.context_backward("Upper bound has been added.")
        elif b is None:
            return BACKWARD.context_forward("Upper bound has been removed.")
        elif a < b:
            # -----------
            # +++]------- a
            # +++++++]--- b
            return backward("Upper bound")
        else:
            return forward("Upper bound")

    def bool_relax(
        name: str, a: bool | None, b: bool | None, default: bool
    ) -> Compatibility:
        """Results in a MORE relaxed constraint if true."""
        if a is None:
            a = default
        if b is None:
            b = default

        match (a, b):
            case (True, True):
                return COMPATIBLE
            case (True, False):
                return forward(f"Constraint {name}")
            case (False, True):
                return backward(f"Constraint {name}")
            case (False, False):
                return COMPATIBLE
            case _:
                raise Unreachable()

    def bool_constrain(
        name: str, a: bool | None, b: bool | None, default: bool
    ) -> Compatibility:
        """Results in a LESS relaxed constraint if true."""
        if a is None:
            a = default
        if b is None:
            b = default

        match (a, b):
            case (True, True):
                return COMPATIBLE
            case (True, False):
                return backward(f"Constraint {name}")
            case (False, True):
                return forward(f"Constraint {name}")
            case (False, False):
                return COMPATIBLE
            case _:
                raise Unreachable()

    def cannot_compare(name: str, a: Any | None, b: Any | None) -> Compatibility:
        """Fall back to just checking for equality, or whether a constrain is introduced/removed."""

        if a == b:
            return COMPATIBLE
        elif a is None:
            return FORWARD.context_backward(f"Constraint {name} has been added.")
        elif b is None:
            return BACKWARD.context_forward(f"Constraint {name} has been removed.")
        else:
            return INCOMPATIBLE.context(f"Cannot compare constraint: {name}.")

    def multiple_of(a: Any | None, b: Any | None) -> Compatibility:
        if type(a) is int:
            zero = 0
        elif type(a) is float:
            zero = 0.0
        elif type(a) is Decimal:
            zero = Decimal(0)
        else:
            zero = None

        #
        if a == b:
            return COMPATIBLE

        elif type(a) is not type(b):
            return INCOMPATIBLE.context(f"Cannot compare multiple_of: {a} and {b}.")

        elif a is None:
            return FORWARD.context_backward("Constraint multiple_of has been added.")
        elif b is None:
            return BACKWARD.context_forward("Constraint multiple_of has been removed.")

        if (
            a == 0
            or a == 0.0
            or a == Decimal(0)
            or b == 0
            or b == 0.0
            or b == Decimal(0)
        ):
            return INCOMPATIBLE.context(
                "A multiple_of of 0 is only compatible with itself."
            )

        elif a % b == zero:
            return backward("multiple_of")
        elif b % a == zero:
            return forward("multiple_of")

        else:
            return INCOMPATIBLE.context(f"Cannot compare multiple_of: {a} and {b}.")

    IMPLEMENTED = {
        "allow_inf_nan": lambda a, b: bool_relax("allow_inf_nan", a, b, default=True),
        "coerce_numbers_to_str": lambda a, b: bool_relax(
            "coerce_numbers_to_str", a, b, default=False
        ),
        "decimal_places": upper_bound,
        "ge": lower_bound,
        "gt": lower_bound,
        "le": upper_bound,
        "lt": upper_bound,
        "max_digits": upper_bound,
        "max_length": upper_bound,
        "min_length": lower_bound,
        "multiple_of": multiple_of,
        "pattern": lambda a, b: cannot_compare("pattern", a, b),
        "strict": lambda a, b: bool_constrain(
            "strict", a, b, default=False
        ),  # types.Strict,
        "union_mode": lambda a, b: cannot_compare("union_mode", a, b),
    }
    IGNORE = {
        "fail_fast",  # types.FailFast,
    }

    a_meta = _extract_metadata_dict(a.metadata)
    b_meta = _extract_metadata_dict(b.metadata)

    compat = COMPATIBLE

    for field, f in IMPLEMENTED.items():
        compat = compat & f(a_meta.get(field, None), b_meta.get(field, None))

    seen = set(a_meta.keys()) | set(b_meta.keys())
    for field in seen - set(IMPLEMENTED.keys()) - IGNORE:
        if a_meta.get(field, None) == b_meta.get(field, None):
            _log.warning(
                f"Field metadata parameter '{field}' is unsupported, but allowed because unchanged."
            )
        else:
            raise UnimplementedError(
                f"Field metadata parameter '{field}' is not yet supported for compatibility checking.\n"
                + f"Got values: {a_meta.get(field, None)!r} and {b_meta.get(field, None)!r}"
            )

    return compat


def _default_compatibility(
    a_info: pydantic.fields.FieldInfo,
    b_info: pydantic.fields.FieldInfo,
) -> Compatibility:
    a = get_default(a_info)
    b = get_default(b_info)

    match (a.present, b.present):
        case (True, True):
            if isinstance(a.value, pydantic.BaseModel):
                a_default = a.value.model_dump_json()
            else:
                a_default = a.value
            if isinstance(b.value, pydantic.BaseModel):
                b_default = b.value.model_dump_json()
            else:
                b_default = b.value
            if a_default == b_default:
                return COMPATIBLE
            else:
                return INCOMPATIBLE.context(f"Default is different for {a} and {b}.")
        case (True, False):
            # You had a default, which means values could be missing.
            # Now you don't have a default, so values must be present (though they can be None).
            return FORWARD.context_backward("Default has been removed.")
        case (False, True):
            # You didn't have a default, so values had to be present (though they could be None).
            # Now you have a default, so values can be missing.
            return BACKWARD.context_forward("Default has been added.")
        case (False, False):
            return COMPATIBLE
        case _:
            raise Unreachable()


def _annotation_compatibility(
    a: type[Any] | Any | None,
    b: type[Any] | Any | None,
    seen: dict[
        tuple[type[pydantic.BaseModel], type[pydantic.BaseModel]], Compatibility | None
    ],
    all_models: dict[type[pydantic.BaseModel], ModelNode],
) -> Compatibility:
    if a is None:
        raise UnimplementedError(f"Field type is {a}.")

    elif b is None:
        raise UnimplementedError(f"Field type is {b}.")

    elif typing.get_origin(a) in UNION_TYPES and typing.get_origin(b) in UNION_TYPES:
        subtypes_a = list(typing.get_args(a))
        subtypes_b = list(typing.get_args(b))

        assert len(subtypes_a) > 0
        assert len(subtypes_b) > 0

        a_compats_first = [
            [
                _annotation_compatibility(subtype_a, subtype_b, seen, all_models)
                for subtype_b in subtypes_b
            ]
            for subtype_a in subtypes_a
        ]

        b_compats_first = [
            [
                _annotation_compatibility(subtype_a, subtype_b, seen, all_models)
                for subtype_a in subtypes_a
            ]
            for subtype_b in subtypes_b
        ]

        # All new subtypes must be compatible with at least one old subtype to maintain forward compatibility.
        forward = Compatibility.all(
            [
                Compatibility.any([compat for compat in compats])
                for compats in b_compats_first
            ]
        ).forward

        # All old subtypes must be compatible with at least one new subtype to maintain backward compatibility.
        backward = Compatibility.all(
            [
                Compatibility.any([compat for compat in compats])
                for compats in a_compats_first
            ]
        ).backward

        assert forward is not None
        assert backward is not None

        compat = Compatibility(forward=forward, backward=backward)

        if not forward.compatible:
            compat = compat.context_forward(
                "Not all new union subtypes are compatible with at least one old subtype."
            )
        if not backward.compatible:
            compat = compat.context_backward(
                "Not all old union subtypes are compatible with at least one new subtype."
            )

        return compat

    elif typing.get_origin(a) in UNION_TYPES:
        subtypes = list(typing.get_args(a))
        compats = [
            _annotation_compatibility(subtype, b, seen, all_models)
            for subtype in subtypes
        ]
        compat = Compatibility(
            # At least one old subtype must be compatible with the new one to maintain forward compatibility.
            forward=Compatibility.any(compats).forward,
            # All old subtypes must be compatible with the new one to maintain backward compatibility.
            backward=Compatibility.all(compats).backward,
        )

        if not compat.forward.compatible:
            compat = compat.context_forward(
                "None of the old union subtypes are compatible with the new type."
            )
        if not compat.backward.compatible:
            compat = compat.context_backward(
                "At least one of the old union subtypes is not compatible with the new type."
            )

        return compat

    elif typing.get_origin(b) in UNION_TYPES:
        subtypes = list(typing.get_args(b))
        compats = [
            _annotation_compatibility(a, subtype, seen, all_models)
            for subtype in subtypes
        ]
        compat = Compatibility(
            # All new subtypes must be compatible with the old one to maintain forward compatibility.
            forward=Compatibility.all(compats).forward,
            # At least one new subtype must be compatible with the old one to maintain backward compatibility.
            backward=Compatibility.any(compats).backward,
        )

        if not compat.forward.compatible:
            compat = compat.context_forward(
                "At least one of the new union subtypes is not compatible with the old type."
            )
        if not compat.backward.compatible:
            compat = compat.context_backward(
                "None of the new union subtypes are compatible with the old type."
            )

        return compat

    elif (a, b) in _BASE_TYPES_COMPAT:
        return _BASE_TYPES_COMPAT[(a, b)]

    elif a in BASE_TYPES and b in BASE_TYPES:
        if a is b:
            return COMPATIBLE
        else:
            return INCOMPATIBLE.context(f"Type is different: {a} and {b}.")

    elif a in BASE_TYPES or b in BASE_TYPES:
        return INCOMPATIBLE.context(f"Type is different: {a} and {b}.")

    elif (
        typing.get_origin(a) in COMPOSITE_TYPES_EXT
        and typing.get_origin(b) in COMPOSITE_TYPES_EXT
    ):
        if typing.get_origin(a) is not typing.get_origin(b):
            return INCOMPATIBLE.context(f"Composite type is different: {a} and {b}.")

        a_args = typing.get_args(a)
        b_args = typing.get_args(b)
        if len(a_args) != len(b_args):
            return INCOMPATIBLE.context(f"Composite type length mismatch: {a} and {b}.")

        compats = [
            _annotation_compatibility(a, b, seen, all_models)
            for a, b in zip(a_args, b_args, strict=True)
        ]
        return Compatibility(
            forward=Compatibility.all(compats).forward,
            backward=Compatibility.all(compats).backward,
        )

    elif typing.get_origin(a) is Literal and typing.get_origin(b) is Literal:
        values_a = list(typing.get_args(a))
        values_b = list(typing.get_args(b))

        for v in values_a:
            if type(v) not in BASE_TYPES:
                raise UnimplementedError(f"Unsupported type in Literal {v}")
        for v in values_b:
            if type(v) not in BASE_TYPES:
                raise UnimplementedError(f"Unsupported type in Literal {v}")

        set_a = set(values_a)
        set_b = set(values_b)

        forward = all(b in set_a for b in set_b)
        backward = all(a in set_b for a in set_a)

        match (forward, backward):
            case (True, True):
                compat = COMPATIBLE
            case (True, False):
                compat = FORWARD
            case (False, True):
                compat = BACKWARD
            case (False, False):
                compat = INCOMPATIBLE
            case _:
                raise Unreachable()

        if not forward:
            compat = compat.context_forward(f"Literal values added ({set_b - set_a}).")
        if not backward:
            compat = compat.context_backward(
                f"Literal values removed ({set_a - set_b})."
            )

        return compat

    elif a is Self and b is Self:
        return COMPATIBLE
    elif a is Self or b is Self:
        return INCOMPATIBLE.context(f"Type is different: {a} and {b}.")

    elif a is ... and b is ...:
        return COMPATIBLE

    elif not inspect.isclass(a) or not inspect.isclass(b):
        raise UnimplementedError(f"Unsupported non-class type {a} or {b}")

    elif issubclass(a, enum.Enum) or issubclass(b, enum.Enum):
        raise UnimplementedError("Enum type is todo")

    elif issubclass(a, pydantic.BaseModel) and issubclass(b, pydantic.BaseModel):
        return _model_compatibility(all_models[a], all_models[b], seen)

    else:
        raise UnimplementedError(f"Unsupported type {a} or {b}")


def _config_compatibility(
    a: pydantic.config.ConfigDict,
    b: pydantic.config.ConfigDict,
) -> Compatibility:
    IMPLEMENTED = {
        "extra",  # ExtraValues | None
    }

    # Fields that do not affect compatibility.
    IGNORE = {
        "cache_strings",  # bool | Literal['all', 'keys', 'none']
        "defer_build",  # bool
        "experimental_defer_build_mode",
        "field_title_generator",  # Callable[[str, FieldInfo | ComputedFieldInfo], str] | None
        "frozen",  # bool
        "hide_input_in_errors",  # bool
        "ignored_types",  # tuple[type, ...]
        "json_schema_extra",  # JsonDict | JsonSchemaExtraCallable | None
        "json_schema_mode_override",  # # Literal['validation', 'serialization', None]
        "json_schema_serialization_defaults_required",  # bool
        "loc_by_alias",  # bool
        "model_title_generator",  # Callable[[type], str] | None
        "protected_namespaces",  # tuple[str | Pattern[str], ...]
        "regex_engine",  # Literal['rust-regex', 'python-re']
        "revalidate_instances",  # Literal['always', 'never', 'subclass-instances']
        "schema_generator",  # type[_GenerateSchema] | None
        "title",  # str | None
        "use_attribute_docstrings",  # bool
        "validate_assignment",  # bool
        "validate_default",  # bool
        "validate_return",  # bool
        "validation_error_cause",  # bool
        "from_attributes",  # bool
        "arbitrary_types_allowed",  # bool
    }

    # Fields that need to be checked for compatibility.
    CHECK = {
        "alias_generator",  # Callable[[str], str] | AliasGenerator | None
        "allow_inf_nan",  # bool
        "coerce_numbers_to_str",  # bool
        "json_encoders",  # dict[type[object], JsonEncoder] | None
        "plugin_settings",  # dict[str, object] | None
        "populate_by_name",  # bool
        "ser_json_bytes",  # Literal['utf8', 'base64', 'hex']
        "ser_json_inf_nan",  # Literal['null', 'constants', 'strings']
        "ser_json_temporal",  # Literal['iso8601', 'seconds', 'milliseconds']
        "ser_json_timedelta",  # Literal['iso8601', 'float']
        "serialize_by_alias",  # bool
        "str_max_length",  # int | None
        "str_min_length",  # int
        "str_strip_whitespace",  # bool
        "str_to_lower",  # bool
        "str_to_upper",  # bool
        "strict",  # bool
        "url_preserve_empty_path",  # bool
        "use_enum_values",  # bool
        "val_json_bytes",  # Literal['utf8', 'base64', 'hex']
        "val_temporal_unit",  # Literal['seconds', 'milliseconds', 'infer']
        "validate_by_alias",  # bool
        "validate_by_name",  # bool
    }

    UNHANDLED = _ACTUAL_CONFIG_DICT_FIELDS_EXT - IMPLEMENTED - IGNORE - CHECK

    for field in CHECK | UNHANDLED:
        a_value = a.get(field)
        b_value = b.get(field)
        if a_value is None and b_value is None:
            continue
        elif a_value == b_value:
            _log.warning(
                f"Config parameter '{field}' is unsupported, but allowed because unchanged."
            )
            continue
        else:
            raise UnimplementedError(
                f"Config parameter '{field}' is not yet supported for compatibility checking.\n"
                + f"Got values: {a_value!r} and {b_value!r}"
            )

    compat = COMPATIBLE

    match (get_extra(a), get_extra(b)):
        case ("allow", "allow") | ("ignore", "ignore") | ("forbid", "forbid"):
            pass
        case ("allow", "ignore") | ("allow", "forbid") | ("ignore", "forbid"):
            NO_BACKWARD = "The new model's `extra` setting is more restrictive than the old model."
            compat = compat & FORWARD.context_backward(NO_BACKWARD)
        case ("ignore", "allow") | ("forbid", "allow") | ("forbid", "ignore"):
            NO_FORWARD = "The new model's `extra` setting is less restrictive than the old model."
            compat = compat & BACKWARD.context_forward(NO_FORWARD)
        case _:
            raise Unreachable()

    return compat


class UnimplementedError(Exception):
    pass


_BASE_TYPES_BACKWARD_COMPATIBLE = {
    # (int, float), # Can lose precision
    (int, Decimal, lambda value: Decimal(value)),
    # to str
    (date, str, lambda value: value.isoformat()),
    (datetime, str, lambda value: value.isoformat()),
    (time, str, lambda value: value.isoformat()),
    (timedelta, str, timedelta_adapter),
    (Decimal, str, lambda value: str(value)),
    # to bytes
    (date, bytes, lambda value: value.isoformat().encode("utf-8")),
    (datetime, bytes, lambda value: value.isoformat().encode("utf-8")),
    (time, bytes, lambda value: value.isoformat().encode("utf-8")),
    (timedelta, bytes, lambda value: timedelta_adapter(value).encode("utf-8")),
    (Decimal, bytes, lambda value: str(value).encode("utf-8")),
    (str, bytes, lambda value: value.encode("utf-8")),
}
_BASE_TYPES_COMPAT = {
    **{
        (a, b): BACKWARD.context_forward(f"{a} -> {b} is not forward compatible.")
        for (a, b, _) in _BASE_TYPES_BACKWARD_COMPATIBLE
    },
    **{
        (b, a): FORWARD.context_backward(f"{b} -> {a} is not backward compatible.")
        for (a, b, _) in _BASE_TYPES_BACKWARD_COMPATIBLE
    },
}


_KNOWN_FIELD_INFO_FIELDS = {
    "_attributes_set",
    "_complete",
    "_final",
    "_original_annotation",
    "_original_assignment",
    "_qualifiers",
    "alias_priority",
    "alias",
    "annotation",
    "default_factory",
    "default",
    "deprecated",
    "description",
    "discriminator",
    "evaluated",
    "examples",
    "exclude_if",
    "exclude",
    "field_title_generator",
    "frozen",
    "init_var",
    "init",
    "json_schema_extra",
    "kw_only",
    "metadata",
    "repr",
    "serialization_alias",
    "title",
    "validate_default",
    "validation_alias",
}
_KNOWN_FIELD_INFO_FIELDS_EXT = _KNOWN_FIELD_INFO_FIELDS | set()
_ACTUAL_FIELD_INFO_FIELDS = {str(v) for v in pydantic.fields.FieldInfo.__slots__}
_FIELD_INFO_ERROR_MESSAGE = (
    f"Unknown field info fields might affect compatibility: {_ACTUAL_FIELD_INFO_FIELDS - _KNOWN_FIELD_INFO_FIELDS}.\n"
    + "This needs to be implemented. Please report it at https://github.com/QuartzLibrary/stable_pydantic/issues"
)
_FIELD_INFO_ERROR = _ACTUAL_FIELD_INFO_FIELDS - _KNOWN_FIELD_INFO_FIELDS_EXT != set()
if _FIELD_INFO_ERROR:
    _log.warning(_FIELD_INFO_ERROR_MESSAGE)


_KNOWN_CONFIG_DICT_FIELDS = {
    "alias_generator",
    "allow_inf_nan",
    "arbitrary_types_allowed",
    "cache_strings",
    "coerce_numbers_to_str",
    "defer_build",
    "experimental_defer_build_mode",
    "extra",
    "field_title_generator",
    "from_attributes",
    "frozen",
    "hide_input_in_errors",
    "ignored_types",
    "json_encoders",
    "json_schema_extra",
    "json_schema_mode_override",
    "json_schema_serialization_defaults_required",
    "loc_by_alias",
    "model_title_generator",
    "plugin_settings",
    "populate_by_name",
    "protected_namespaces",
    "regex_engine",
    "revalidate_instances",
    "schema_generator",
    "ser_json_bytes",
    "ser_json_inf_nan",
    "ser_json_temporal",
    "ser_json_timedelta",
    "serialize_by_alias",
    "str_max_length",
    "str_min_length",
    "str_strip_whitespace",
    "str_to_lower",
    "str_to_upper",
    "strict",
    "title",
    "url_preserve_empty_path",
    "use_attribute_docstrings",
    "use_enum_values",
    "val_json_bytes",
    "val_temporal_unit",
    "validate_assignment",
    "validate_by_alias",
    "validate_by_name",
    "validate_default",
    "validate_return",
    "validation_error_cause",
}
_ACTUAL_CONFIG_DICT_FIELDS_EXT = _KNOWN_CONFIG_DICT_FIELDS | set()
_ACTUAL_CONFIG_DICT_FIELDS = {
    str(v) for v in pydantic.config.ConfigDict.__annotations__
}
_CONFIG_DICT_ERROR_MESSAGE: str = (
    f"Unknown config dict fields might affect compatibility: {_ACTUAL_CONFIG_DICT_FIELDS - _KNOWN_CONFIG_DICT_FIELDS}.\n"
    + "This needs to be implemented. Please report it at https://github.com/QuartzLibrary/stable_pydantic/issues"
)
_CONFIG_DICT_ERROR = (
    _ACTUAL_CONFIG_DICT_FIELDS - _ACTUAL_CONFIG_DICT_FIELDS_EXT != set()
)
if _CONFIG_DICT_ERROR:
    _log.warning(_CONFIG_DICT_ERROR_MESSAGE)
