import dataclasses
import datetime
from collections import defaultdict, deque
from collections.abc import Callable, Mapping as AbcMapping, Sequence as AbcSequence
from decimal import Decimal
from enum import Enum
from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network
from pathlib import Path, PurePath
from re import Pattern
from types import GeneratorType
from typing import Any, Literal, Optional, Union
from uuid import UUID

from pydantic import BaseModel
from pydantic.networks import AnyUrl, NameEmail
from pydantic.types import SecretBytes, SecretStr
from pydantic_core import Url


def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any) -> Any:
    return model.model_dump(mode=mode, **kwargs)


# Taken from Pydantic v1 as is
def isoformat(o: Union[datetime.date, datetime.time]) -> str:
    return o.isoformat()


# Taken from Pydantic v1 as is
# TODO: pv2 should this return strings instead?
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
    """
    Encodes a Decimal as int of there's no exponent, otherwise float

    This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
    where a integer (but not int typed) is used. Encoding this as a float
    results in failed round-tripping between encode and parse.
    Our Id type is a prime example of this.

    >>> decimal_encoder(Decimal("1.0"))
    1.0

    >>> decimal_encoder(Decimal("1"))
    1
    """
    if dec_value.as_tuple().exponent >= 0:  # type: ignore[operator]
        return int(dec_value)
    else:
        return float(dec_value)


ENCODERS_BY_TYPE: dict[type[Any], Callable[[Any], Any]] = {
    bytes: lambda o: o.decode(),
    datetime.date: isoformat,
    datetime.datetime: isoformat,
    datetime.time: isoformat,
    datetime.timedelta: lambda td: td.total_seconds(),
    Decimal: decimal_encoder,
    Enum: lambda o: o.value,
    frozenset: list,
    deque: list,
    GeneratorType: list,
    IPv4Address: str,
    IPv4Interface: str,
    IPv4Network: str,
    IPv6Address: str,
    IPv6Interface: str,
    IPv6Network: str,
    NameEmail: str,
    Path: str,
    Pattern: lambda o: o.pattern,
    SecretBytes: str,
    SecretStr: str,
    set: list,
    UUID: str,
    Url: str,
    AnyUrl: str,
}


def generate_encoders_by_class_tuples(
    type_encoder_map: dict[Any, Callable[[Any], Any]],
) -> dict[Callable[[Any], Any], tuple[Any, ...]]:
    encoders_by_class_tuples: dict[Callable[[Any], Any], tuple[Any, ...]] = defaultdict(tuple)
    for type_, encoder in type_encoder_map.items():
        encoders_by_class_tuples[encoder] += (type_,)
    return encoders_by_class_tuples


encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE)


def jsonable_encoder(
    obj: Any,
    by_alias: bool = True,
    exclude_unset: bool = False,
    exclude_defaults: bool = False,
    exclude_none: bool = False,
    custom_encoder: Optional[dict[Any, Callable[[Any], Any]]] = None,
    sqlalchemy_safe: bool = True,
    *,
    _seen: set[int] | None = None,
) -> Any:
    """Convert an arbitrary object to JSON-serializable Python primitives.

    This is a fork/port of the FastAPI/Pydantic v1-style `jsonable_encoder`,
    adjusted for Pydantic v2, with extra safety:
    - avoids invalid `isinstance(x, A|B)` patterns
    - handles sequences/mappings deterministically
    - guards against circular references
    """

    if _seen is None:
        _seen = set()

    # Guard against circular references for container-ish objects.
    obj_id = id(obj)
    if obj_id in _seen:
        return "<circular_ref>"

    custom_encoder = custom_encoder or {}
    if custom_encoder:
        exact = custom_encoder.get(type(obj))
        if exact is not None:
            return exact(obj)
        for encoder_type, encoder_instance in custom_encoder.items():
            try:
                if isinstance(obj, encoder_type):
                    return encoder_instance(obj)
            except TypeError:
                # Some keys might not be valid for isinstance
                continue

    if isinstance(obj, BaseModel):
        obj_dict = _model_dump(
            obj,
            mode="json",
            include=None,
            exclude=None,
            by_alias=by_alias,
            exclude_unset=exclude_unset,
            exclude_none=exclude_none,
            exclude_defaults=exclude_defaults,
        )
        if "__root__" in obj_dict:
            obj_dict = obj_dict["__root__"]
        _seen.add(obj_id)
        return jsonable_encoder(
            obj_dict,
            exclude_none=exclude_none,
            exclude_defaults=exclude_defaults,
            sqlalchemy_safe=sqlalchemy_safe,
            _seen=_seen,
        )

    # Dataclass instance
    if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
        _seen.add(obj_id)
        return jsonable_encoder(
            dataclasses.asdict(obj),
            by_alias=by_alias,
            exclude_unset=exclude_unset,
            exclude_defaults=exclude_defaults,
            exclude_none=exclude_none,
            custom_encoder=custom_encoder,
            sqlalchemy_safe=sqlalchemy_safe,
            _seen=_seen,
        )

    if isinstance(obj, Enum):
        return obj.value
    if isinstance(obj, PurePath):
        return str(obj)

    # Primitives
    if obj is None or isinstance(obj, (str, int, float, bool)):
        return obj

    # Bytes: keep existing behavior (decode to str) but be safer on non-utf8
    if isinstance(obj, (bytes, bytearray, memoryview)):
        try:
            return bytes(obj).decode()
        except Exception:
            # Fall back to latin-1 to preserve bytes 1:1
            return bytes(obj).decode("latin-1")

    if isinstance(obj, Decimal):
        return format(obj, "f")

    # Mapping first (dict-like)
    if isinstance(obj, AbcMapping):
        _seen.add(obj_id)
        encoded_dict: dict[Any, Any] = {}
        for key, value in obj.items():
            if sqlalchemy_safe and isinstance(key, str) and key.startswith("_sa"):
                continue
            if value is None and exclude_none:
                continue
            encoded_key = jsonable_encoder(
                key,
                by_alias=by_alias,
                exclude_unset=exclude_unset,
                exclude_none=exclude_none,
                custom_encoder=custom_encoder,
                sqlalchemy_safe=sqlalchemy_safe,
                _seen=_seen,
            )
            encoded_value = jsonable_encoder(
                value,
                by_alias=by_alias,
                exclude_unset=exclude_unset,
                exclude_defaults=exclude_defaults,
                exclude_none=exclude_none,
                custom_encoder=custom_encoder,
                sqlalchemy_safe=sqlalchemy_safe,
                _seen=_seen,
            )
            encoded_dict[encoded_key] = encoded_value
        return encoded_dict

    # Sequence-ish (but not string/bytes)
    if isinstance(obj, (list, tuple, set, frozenset, deque, GeneratorType)) or (
        isinstance(obj, AbcSequence) and not isinstance(obj, (str, bytes, bytearray))
    ):
        _seen.add(obj_id)
        return [
            jsonable_encoder(
                item,
                by_alias=by_alias,
                exclude_unset=exclude_unset,
                exclude_defaults=exclude_defaults,
                exclude_none=exclude_none,
                custom_encoder=custom_encoder,
                sqlalchemy_safe=sqlalchemy_safe,
                _seen=_seen,
            )
            for item in obj
        ]

    if type(obj) in ENCODERS_BY_TYPE:
        return ENCODERS_BY_TYPE[type(obj)](obj)

    for encoder, classes_tuple in encoders_by_class_tuples.items():
        if isinstance(obj, classes_tuple):
            return encoder(obj)

    # Fallback: try dict(obj) then vars(obj)
    try:
        _seen.add(obj_id)
        data = dict(obj)  # type: ignore[arg-type]
    except Exception as e:
        errors: list[Exception] = [e]
        try:
            data = vars(obj)
        except Exception as e2:
            errors.append(e2)
            raise ValueError(errors) from e2

    return jsonable_encoder(
        data,
        by_alias=by_alias,
        exclude_unset=exclude_unset,
        exclude_defaults=exclude_defaults,
        exclude_none=exclude_none,
        custom_encoder=custom_encoder,
        sqlalchemy_safe=sqlalchemy_safe,
        _seen=_seen,
    )
