import ast
import dataclasses
import importlib
import inspect
import os
import re
import shutil
from collections.abc import Callable, Collection, Generator, Sequence
from copy import deepcopy
from dataclasses import dataclass
from datetime import date
from enum import Enum, auto
from functools import cache
from pathlib import Path
from types import GenericAlias, LambdaType, ModuleType, NoneType
from typing import (
    Any,
    TypeAlias,
    _BaseGenericAlias,  # pyright: ignore[reportGeneralTypeIssues]
    cast,
    final,
    get_args,
    get_origin,
)

from pydantic import BaseModel, Field
from pydantic.fields import FieldInfo, ModelField
from typing_extensions import Self, assert_never

from cadwyn.structure.enums import (
    AlterEnumSubInstruction,
    EnumDidntHaveMembersInstruction,
    EnumHadMembersInstruction,
)
from cadwyn.structure.schemas import (
    AlterSchemaInstruction,
    AlterSchemaSubInstruction,
    OldSchemaFieldDidntExist,
    OldSchemaFieldExistedWith,
    OldSchemaFieldHad,
)
from cadwyn.structure.versions import Version, VersionBundle

from ._utils import Sentinel, UnionType, get_index_of_base_schema_dir_in_pythonpath
from .exceptions import CodeGenerationError, InvalidGenerationInstructionError

_LambdaFunctionName = (lambda: None).__name__  # pragma: no branch
_FieldName: TypeAlias = str
_empty_field_info = Field()
_dict_of_empty_field_info = {k: getattr(_empty_field_info, k) for k in FieldInfo.__slots__}
_RE_CAMEL_TO_SNAKE = re.compile(r"(?<!^)(?=[A-Z])")
_AUTO_GENERATION_WARNING = "# THIS FILE WAS AUTO-GENERATED BY CADWYN. DO NOT EVER TRY TO EDIT IT BY HAND\n\n"


@final
class _ImportedModule:
    __slots__ = (
        "path",
        "name",
        "alias",
        "absolute_python_path_to_origin",
        "how_far_up_is_base_schema_dir_from_current_module",
        "is_package",
    )

    def __init__(
        self,
        version_dir: str,
        import_pythonpath_template: str,
        package_name: str,
        how_far_up_is_base_schema_dir_from_current_module: int,
        absolute_python_path_template: str,
        is_package: bool,
    ) -> None:
        self.path = import_pythonpath_template.format(version_dir)
        self.name = package_name.format(version_dir)
        if self.path == "":
            self.alias = self.name
        else:
            self.alias = f"{self.path.replace('.', '_')}_{self.name}"
        self.absolute_python_path_to_origin = absolute_python_path_template.format("latest")
        self.how_far_up_is_base_schema_dir_from_current_module = how_far_up_is_base_schema_dir_from_current_module
        self.is_package = is_package

    def get_ast(self) -> ast.ImportFrom:
        module = f"{self.path}.{self.name}"
        name = ast.alias(name="*")
        level = self.how_far_up_is_base_schema_dir_from_current_module
        # TODO: Add a testcase where is_package == True and level == 3
        if self.is_package and level == 2:
            level -= 1
        return ast.ImportFrom(
            level=level,
            module=module,
            names=[name],
        )


@dataclass(slots=True)
class _ModelFieldLike:
    name: str
    original_type: Any
    annotation: Any
    field_info: FieldInfo
    import_from: str | None
    import_as: str | None


@dataclass
class _ModelFieldWrapper:
    cls: type[BaseModel]
    annotation_ast: ast.expr | None
    field: ModelField | _ModelFieldLike
    field_ast: ast.expr | None

    def get_annotation(self):  # intentionally weird to not clash with ModelField
        if self.annotation_ast:
            return PlainRepr(ast.unparse(self.annotation_ast))
        return self.field.annotation


@dataclass(slots=True)
class _ModelWrapper:
    schema: type[BaseModel]
    name: str
    fields: dict[_FieldName, _ModelFieldWrapper]
    _parents: list[Self] | None = dataclasses.field(init=False, default=None)

    def _get_parents(self, schemas: "dict[str, _ModelWrapper]"):
        if self._parents is not None:
            return self._parents
        parents = []
        for base in self.schema.mro()[1:]:
            schema_path = f"{base.__module__}.{base.__name__}"

            if schema_path in schemas:
                parents.append(schemas[schema_path])
            elif issubclass(base, BaseModel):
                parents.append(type(self)(base, base.__name__, _get_fields_from_model(base)))
        self._parents = parents
        return parents

    def _get_defined_fields(self, schemas: "dict[str, _ModelWrapper]") -> dict[str, _ModelFieldWrapper]:
        fields = {}

        for parent in reversed(self._get_parents(schemas)):
            fields |= parent.fields

        return fields | self.fields

    def change(self, alter_schema_instruction: AlterSchemaInstruction, version_change_name: str):
        # We only handle names right now so we just go ahead and check
        if alter_schema_instruction.name == self.name:
            raise InvalidGenerationInstructionError(
                f'You tried to change the name of "{self.name}" in "{version_change_name}" '
                "but it already has the name you tried to assign.",
            )
        self.name = alter_schema_instruction.name

    def add_field(
        self,
        schemas: "dict[str, _ModelWrapper]",
        alter_schema_instruction: OldSchemaFieldExistedWith,
        version_change_name: str,
    ):
        defined_fields = self._get_defined_fields(schemas)
        if alter_schema_instruction.field_name in defined_fields:
            raise InvalidGenerationInstructionError(
                f'You tried to add a field "{alter_schema_instruction.field_name}" to "{self.name}" '
                f'in "{version_change_name}" but there is already a field with that name.',
            )
        if alter_schema_instruction.import_as is not None:
            annotation = alter_schema_instruction.import_as
        else:
            annotation = alter_schema_instruction.type
        self.fields[alter_schema_instruction.field_name] = _ModelFieldWrapper(
            alter_schema_instruction.schema,
            annotation_ast=None,  # TODO: Get this from migration
            field=_ModelFieldLike(
                name=alter_schema_instruction.field_name,
                original_type=alter_schema_instruction.type,
                annotation=annotation,
                field_info=alter_schema_instruction.field,
                import_from=alter_schema_instruction.import_from,
                import_as=alter_schema_instruction.import_as,
            ),
            field_ast=None,  # TODO: Get this from migration
        )

    def change_field(
        self,
        schemas: "dict[str, _ModelWrapper]",
        alter_schema_instruction: OldSchemaFieldHad,
        version_change_name: str,
        annotation_transformer: "_AnnotationTransformer",
    ):
        defined_fields = self._get_defined_fields(schemas)
        if alter_schema_instruction.field_name not in defined_fields:
            raise InvalidGenerationInstructionError(
                f'You tried to change the type of field "{alter_schema_instruction.field_name}" from '
                f'"{self.name}" in "{version_change_name}" but it doesn\'t have such a field.',
            )

        model_field_wrapper = defined_fields[alter_schema_instruction.field_name]
        model_field = model_field_wrapper.field
        self.fields[alter_schema_instruction.field_name] = model_field_wrapper

        current_field_is_constrained_type = _is_pydantic_constrained_type(model_field.annotation)
        if alter_schema_instruction.type is not Sentinel:
            if model_field.annotation == alter_schema_instruction.type:
                raise InvalidGenerationInstructionError(
                    f'You tried to change the type of field "{alter_schema_instruction.field_name}" to '
                    f'"{alter_schema_instruction.type}" from "{self.name}" in "{version_change_name}" '
                    f'but it already has type "{model_field.annotation}"',
                )
            model_field.annotation = alter_schema_instruction.type

            model_field_wrapper.annotation_ast = None
            if current_field_is_constrained_type:
                model_field_wrapper.field_ast = None

        if alter_schema_instruction.new_name is not Sentinel:
            if alter_schema_instruction.new_name == alter_schema_instruction.field_name:
                raise InvalidGenerationInstructionError(
                    f'You tried to change the name of field "{alter_schema_instruction.field_name}" '
                    f'from "{self.name}" in "{version_change_name}" '
                    "but it already has that name.",
                )
            self.fields[alter_schema_instruction.new_name] = self.fields.pop(
                alter_schema_instruction.field_name,
            )
        field_info = model_field.field_info

        dict_of_field_info = {k: getattr(field_info, k) for k in field_info.__slots__}
        if dict_of_field_info == _dict_of_empty_field_info:
            field_info = FieldInfo()
            model_field.field_info = field_info
        for attr_name in alter_schema_instruction.field_changes.__dataclass_fields__:
            attr_value = getattr(alter_schema_instruction.field_changes, attr_name)
            if attr_value is not Sentinel:
                if getattr(field_info, attr_name) == attr_value:
                    raise InvalidGenerationInstructionError(
                        f'You tried to change the attribute "{attr_name}" of field '
                        f'"{alter_schema_instruction.field_name}" '
                        f'from "{self.name}" to {attr_value!r} in "{version_change_name}" '
                        "but it already has that value.",
                    )

                if hasattr(model_field.annotation, attr_name) and current_field_is_constrained_type:
                    setattr(model_field.annotation, attr_name, attr_value)
                    ann_ast = model_field_wrapper.annotation_ast
                    if ann_ast is not None and isinstance(ann_ast, ast.Call):
                        _add_keyword_to_call(annotation_transformer, attr_name, attr_value, ann_ast)
                    else:
                        model_field_wrapper.field_ast = None
                        model_field_wrapper.annotation_ast = None
                else:
                    setattr(field_info, attr_name, attr_value)
                    field_ast = model_field_wrapper.field_ast
                    if isinstance(field_ast, ast.Call):
                        _add_keyword_to_call(annotation_transformer, attr_name, attr_value, field_ast)
                    else:
                        model_field_wrapper.field_ast = None

    def delete_field(self, field_name: str, version_change_name: str):
        if field_name not in self.fields:
            raise InvalidGenerationInstructionError(
                f'You tried to delete a field "{field_name}" from "{self.name}" '
                f'in "{version_change_name}" but it doesn\'t have such a field.',
            )
        self.fields.pop(field_name)


@dataclass(slots=True)
class _SchemaBundle:
    version: date
    schemas: dict[str, _ModelWrapper]


@cache
def _get_fields_from_model(cls: type):
    if not isinstance(cls, type) or not issubclass(cls, BaseModel):
        raise CodeGenerationError(f"Model {cls} is not a subclass of BaseModel")
    try:
        source = inspect.getsource(cls)
    except OSError:
        current_field_defs = {
            field_name: _ModelFieldWrapper(cls, None, field, None) for field_name, field in cls.__fields__.items()
        }
    else:
        cls_ast = cast(ast.ClassDef, ast.parse(source).body[0])
        current_field_defs = {
            node.target.id: _ModelFieldWrapper(cls, node.annotation, cls.__fields__[node.target.id], node.value)
            for node in cls_ast.body
            if isinstance(node, ast.AnnAssign)
            and isinstance(node.target, ast.Name)
            and node.target.id in cls.__fields__
        }

    return current_field_defs


def generate_code_for_versioned_packages(
    template_module: ModuleType,
    versions: VersionBundle,
    *,
    ignore_coverage_for_latest_aliases: bool = True,
):
    """
    Args:
        template_module: The latest package from which we will generate the versioned packages
        versions: Version bundle to generate versions from
        ignore_coverage_for_latest_aliases: Add a pragma: no cover comment to the star imports in the generated
        version of the latest module.
    """
    schemas = {
        k: _ModelWrapper(v, v.__name__, _get_fields_from_model(v))
        for k, v in deepcopy(versions.versioned_schemas).items()
    }
    enums = {k: (v, {member.name: member.value for member in v}) for k, v in deepcopy(versions.versioned_enums).items()}
    schemas_per_version: list[_SchemaBundle] = []
    version_list = list(versions)

    # The first one is for the "latest"
    schemas_per_version: list[_SchemaBundle] = []
    version_list = list(versions)

    schemas_per_version.append(_SchemaBundle(version_list[0].value, schemas))
    # Special casing for the first first because it is equivalent to latest
    _generate_latest_version_alias_directory(
        version_list[0].value,
        template_module,
        ignore_coverage_for_latest_aliases,
    )
    schemas = deepcopy(schemas)
    _apply_migrations(version_list[0], schemas, enums)
    for version in version_list[1:]:
        schemas_per_version.append(_SchemaBundle(version.value, schemas))
        _generate_versioned_directory(template_module, schemas, enums, version.value)
        schemas = deepcopy(schemas)
        _apply_migrations(version, schemas, enums)


def _generate_latest_version_alias_directory(
    version: date,
    template_module: ModuleType,
    ignore_coverage_for_latest_aliases: bool,
):
    version_dir_name = _get_version_dir_name(version)
    template_dir = _get_package_path_from_module(template_module)
    version_dir = template_dir.with_name(version_dir_name)
    index_of_latest_schema_dir_in_pythonpath = get_index_of_base_schema_dir_in_pythonpath(
        template_module,
        version_dir,
    )
    for _, original_module, parallel_file in _generate_parallel_directory(template_module, version_dir):
        imports = _prepare_imports_from_version_dirs(
            original_module,
            ["latest"],
            index_of_latest_schema_dir_in_pythonpath,
        )

        import_text = _AUTO_GENERATION_WARNING + ast.unparse(imports[0].get_ast()) + " # noqa: F403"
        if ignore_coverage_for_latest_aliases:
            import_text += " # pragma: no cover"

        parallel_file.write_text(import_text)


def _prepare_imports_from_version_dirs(
    original_module: ModuleType,
    version_dir_names: Collection[str],
    index_of_latest_schema_dir_in_pythonpath: int,
) -> list[_ImportedModule]:
    # package.latest                     -> from .. import latest
    # package.latest.module              -> from ...latest import module
    # package.latest.subpackage          -> from ...latest import subpackage
    # package.latest.subpackage.module   -> from ....subpackage import module

    # package.latest                    -> from ..latest import *
    # package.latest.module             -> from ..latest.module import *
    # package.latest.subpackage         -> from ...latest.subpackage import *
    # package.latest.subpackage.module  -> from ...latest.subpackage.module import *

    original_module_parts = original_module.__name__.split(".")
    original_module_parts[index_of_latest_schema_dir_in_pythonpath] = "{}"
    how_far_up_is_base_schema_dir_from_current_module = (
        len(original_module_parts) - index_of_latest_schema_dir_in_pythonpath
    )
    is_package = original_module_parts[-1] == "__init__"
    if is_package:
        original_module_parts.pop(-1)

    package_name = original_module_parts[-1]
    package_path = original_module_parts[index_of_latest_schema_dir_in_pythonpath:-1]
    import_pythonpath_template = ".".join(package_path)
    absolute_python_path_template = ".".join(original_module_parts)
    return [
        _ImportedModule(
            version_dir,
            import_pythonpath_template,
            package_name,
            how_far_up_is_base_schema_dir_from_current_module,
            absolute_python_path_template,
            is_package,
        )
        for version_dir in version_dir_names
    ]


def _apply_migrations(
    version: Version,
    schemas: dict[
        str,
        _ModelWrapper,
    ],
    enums: dict[str, tuple[type[Enum], dict[str, Any]]],
):
    for version_change in version.version_changes:
        _apply_alter_schema_instructions(
            schemas,
            version_change.alter_schema_instructions,
            version_change.__name__,
        )
        _apply_alter_enum_instructions(
            enums,
            version_change.alter_enum_instructions,
            version_change.__name__,
        )


def _apply_alter_schema_instructions(
    modified_schemas: dict[str, _ModelWrapper],
    alter_schema_instructions: Sequence[AlterSchemaSubInstruction | AlterSchemaInstruction],
    version_change_name: str,
):
    annotation_transformer = _AnnotationTransformer()
    # TODO: If we have a request migration for an endpoint instead of a schema and we haven't found that endpoint
    # during codegen -- raise an error or maybe add an argument that controlls that. Or maybe this is overengineering..
    for alter_schema_instruction in alter_schema_instructions:
        schema = alter_schema_instruction.schema
        schema_path = _get_cls_pythonpath(schema)
        mutable_schema_info = modified_schemas[schema_path]
        if isinstance(alter_schema_instruction, OldSchemaFieldDidntExist):
            mutable_schema_info.delete_field(alter_schema_instruction.field_name, version_change_name)
        elif isinstance(alter_schema_instruction, OldSchemaFieldHad):
            mutable_schema_info.change_field(
                modified_schemas,
                alter_schema_instruction,
                version_change_name,
                annotation_transformer,
            )
        elif isinstance(alter_schema_instruction, OldSchemaFieldExistedWith):
            mutable_schema_info.add_field(modified_schemas, alter_schema_instruction, version_change_name)
        elif isinstance(alter_schema_instruction, AlterSchemaInstruction):
            mutable_schema_info.change(alter_schema_instruction, version_change_name)
        else:
            assert_never(alter_schema_instruction)


def _apply_alter_enum_instructions(
    enums: dict[str, tuple[type[Enum], dict[str, Any]]],
    alter_enum_instructions: Sequence[AlterEnumSubInstruction],
    version_change_name: str,
):
    for alter_enum_instruction in alter_enum_instructions:
        enum = alter_enum_instruction.enum
        enum_path = _get_cls_pythonpath(enum)
        enum_member_to_value = enums[enum_path]
        if isinstance(alter_enum_instruction, EnumDidntHaveMembersInstruction):
            for member in alter_enum_instruction.members:
                if member not in enum_member_to_value[1]:
                    raise InvalidGenerationInstructionError(
                        f'You tried to delete a member "{member}" from "{enum.__name__}" '
                        f'in "{version_change_name}" but it doesn\'t have such a member.',
                    )
                enum_member_to_value[1].pop(member)
        elif isinstance(alter_enum_instruction, EnumHadMembersInstruction):
            for member, member_value in alter_enum_instruction.members.items():
                if member in enum_member_to_value[1] and enum_member_to_value[1][member] == member_value:
                    raise InvalidGenerationInstructionError(
                        f'You tried to add a member "{member}" to "{enum.__name__}" '
                        f'in "{version_change_name}" but there is already a member with that name and value.',
                    )
                enum_member_to_value[1][member] = member_value
        else:
            assert_never(alter_enum_instruction)


def _get_version_dir_path(template_module: ModuleType, version: date) -> Path:
    template_dir = _get_package_path_from_module(template_module)
    return template_dir.with_name(_get_version_dir_name(version))


def _get_version_dir_name(version: date):
    return "v" + version.isoformat().replace("-", "_")


# TODO OPTIMIZATION: This is called way too many times. Make it so we call it only once
def _get_package_path_from_module(template_module: ModuleType) -> Path:
    file = inspect.getsourcefile(template_module)

    # I am too lazy to reproduce this error correctly
    if file is None:  # pragma: no cover
        raise CodeGenerationError(f'Module "{template_module}" has no source file')
    file = Path(file)
    if not file.name == "__init__.py":
        raise CodeGenerationError(f'Module "{template_module}" is not a package')
    return file.parent


def _generate_versioned_directory(
    template_module: ModuleType,
    schemas: dict[str, _ModelWrapper],
    enums: dict[str, tuple[type[Enum], dict[str, Any]]],
    version: date,
):
    version_dir = _get_version_dir_path(template_module, version)
    for (
        _relative_path_to_file,
        original_module,
        parallel_file,
    ) in _generate_parallel_directory(
        template_module,
        version_dir,
    ):
        new_module_text = _migrate_module_to_another_version(
            original_module,
            schemas,
            enums,
        )
        parallel_file.write_text(_AUTO_GENERATION_WARNING + new_module_text)


def _generate_parallel_directory(
    template_module: ModuleType,
    parallel_dir: Path,
) -> Generator[tuple[Path, ModuleType, Path], Any, None]:
    if template_module.__file__ is None:  # pragma: no cover
        raise ValueError(
            f"You passed a {template_module=} but it doesn't have a file "
            "so it is impossible to generate its counterpart.",
        )
    dir = _get_package_path_from_module(template_module)
    parallel_dir.mkdir(exist_ok=True)
    # >>> [cadwyn, structure, schemas]
    template_module_python_path_parts = template_module.__name__.split(".")
    # >>> [home, foo, bar, cadwyn, structure, schemas]
    template_module_path_parts = Path(template_module.__file__).parent.parts
    # >>> [home, foo, bar] = [home, foo, bar, cadwyn, structure, schemas][:-3]
    root_module_path = Path(
        *template_module_path_parts[: -len(template_module_python_path_parts)],
    )
    for subroot, dirnames, filenames in os.walk(dir):
        original_subroot = Path(subroot)
        parallel_subroot = parallel_dir / original_subroot.relative_to(dir)
        if "__pycache__" in dirnames:
            dirnames.remove("__pycache__")
        for dirname in dirnames:
            (parallel_subroot / dirname).mkdir(exist_ok=True)
        for filename in filenames:
            original_file = (original_subroot / filename).absolute()
            parallel_file = (parallel_subroot / filename).absolute()

            if filename.endswith(".py"):
                original_module_path = ".".join(
                    original_file.relative_to(root_module_path).with_suffix("").parts,
                )
                original_module = importlib.import_module(original_module_path)
                yield original_subroot.relative_to(dir), original_module, parallel_file
            else:
                shutil.copyfile(original_file, parallel_file)


def _parse_python_module(module: ModuleType) -> ast.Module:
    try:
        return ast.parse(inspect.getsource(module))
    except OSError as e:
        if module.__file__ is None:  # pragma: no cover
            raise CodeGenerationError("Failed to get file path to the module") from e

        path = Path(module.__file__)
        if path.is_file() and path.read_text() == "":
            return ast.Module([])
        # Not sure how to get here so this is just a precaution
        raise CodeGenerationError(
            "Failed to get source code for module",
        ) from e  # pragma: no cover


def _migrate_module_to_another_version(
    module: ModuleType,
    modified_schemas: dict[str, _ModelWrapper],
    modified_enums: dict[str, tuple[type[Enum], dict[str, Any]]],
) -> str:
    transformer = _AnnotationTransformer()
    parsed_file = _parse_python_module(module)
    if module.__name__.endswith(".__init__"):
        module_name = module.__name__.removesuffix(".__init__")
    else:
        module_name = module.__name__
    all_names_in_file = _get_all_names_defined_in_module(parsed_file, module_name)
    # TODO: Does this play well with renaming?
    extra_field_imports = [
        ast.ImportFrom(
            module=field.field.import_from,
            names=[
                ast.alias(name=transformer.visit(field.field.original_type).strip("'"), asname=field.field.import_as),
            ],
            level=0,
        )
        for val in modified_schemas.values()
        for field in val.fields.values()
        if isinstance(field.field, _ModelFieldLike) and field.field.import_from is not None
    ]

    body = ast.Module(
        [
            ast.ImportFrom(
                module="pydantic",
                names=[
                    ast.alias(name="Field"),
                    ast.alias(name="conbytes"),
                    ast.alias(name="conlist"),
                    ast.alias(name="conset"),
                    ast.alias(name="constr"),
                    ast.alias(name="conint"),
                    ast.alias(name="confloat"),
                    ast.alias(name="condecimal"),
                    ast.alias(name="condate"),
                ],
                level=0,
            ),
            ast.Import(names=[ast.alias(name="typing")], level=0),
            ast.ImportFrom(module="typing", names=[ast.alias(name="Any")], level=0),
        ]
        + extra_field_imports
        + [
            _migrate_ast_node_to_another_version(
                all_names_in_file,
                n,
                module_name,
                modified_schemas,
                modified_enums,
            )
            for n in parsed_file.body
        ],
        [],
    )

    return ast.unparse(body)


def _migrate_ast_node_to_another_version(
    all_names_in_module: dict[str, str],
    node: ast.stmt,
    module_python_path: str,
    modified_schemas: dict[str, _ModelWrapper],
    modified_enums: dict[str, tuple[type[Enum], dict[str, Any]]],
):
    if isinstance(node, ast.ClassDef):
        return _migrate_cls_to_another_version(
            all_names_in_module,
            node,
            module_python_path,
            modified_schemas,
            modified_enums,
        )
    elif isinstance(node, ast.ImportFrom):
        python_path = _get_absolute_python_path_of_import(node, module_python_path)
        node.names = [
            name
            if (name_path := f"{python_path}.{name.name}") not in modified_schemas
            else ast.alias(name=modified_schemas[name_path].name, asname=name.asname)
            for name in node.names
        ]

    return node


def _get_absolute_python_path_of_import(node: ast.ImportFrom, module_python_path: str):
    python_path = ".".join(module_python_path.split(".")[0 : -node.level])
    result = []
    if node.module:
        result.append(node.module)
    if python_path:
        result.append(python_path)
    return ".".join(result)


def _migrate_cls_to_another_version(
    all_names_in_module: dict[str, str],
    cls_node: ast.ClassDef,
    module_python_path: str,
    modified_schemas: dict[str, _ModelWrapper],
    modified_enums: dict[str, tuple[type[Enum], dict[str, Any]]],
) -> ast.ClassDef:
    cls_python_path = f"{module_python_path}.{cls_node.name}"
    cls_node = _modify_schema_cls(
        all_names_in_module,
        cls_node,
        modified_schemas,
        module_python_path,
        cls_python_path,
    )
    if cls_python_path in modified_enums:
        cls_node = _modify_enum_cls(cls_node, modified_enums[cls_python_path][1])

    if not cls_node.body:
        cls_node.body = [ast.Pass()]
    return cls_node


# TODO: Make sure that cadwyn doesn't remove OLD property definitions
def _modify_schema_cls(
    all_names_in_module: dict[str, str],
    cls_node: ast.ClassDef,
    modified_schemas: dict[str, _ModelWrapper],
    module_python_path: str,
    cls_python_path: str,
) -> ast.ClassDef:
    object_renamer = _AnnotationTransformer()
    ast_renamer = _AnnotationASTNodeTransformerWithSchemaRenaming(
        modified_schemas,
        all_names_in_module,
        module_python_path,
    )
    if cls_python_path in modified_schemas:
        model_info = modified_schemas[cls_python_path]
        # This is for possible schema renaming
        cls_node.name = model_info.name

        field_definitions = [
            ast.AnnAssign(
                target=ast.Name(name, ctx=ast.Store()),
                annotation=ast.Name(object_renamer.visit(field.get_annotation())),
                value=_generate_field_ast(field, object_renamer),
                simple=1,
            )
            for name, field in model_info.fields.items()
        ]
    else:
        field_definitions = [field for field in cls_node.body if isinstance(field, ast.AnnAssign)]
    old_body = [n for n in cls_node.body if not isinstance(n, ast.AnnAssign | ast.Pass | ast.Ellipsis)]
    docstring = _pop_docstring_from_cls_body(old_body)
    cls_node.body = docstring + field_definitions + old_body
    if not cls_node.body:
        cls_node.body = [ast.Pass()]

    return ast_renamer.visit(ast.parse(ast.unparse(cls_node)).body[0])


def _generate_field_ast(
    field: _ModelFieldWrapper,
    annotation_transformer: "_AnnotationTransformer",
):
    if field.field_ast is not None:
        return field.field_ast
    passed_attrs = {
        attr: _get_attribute_from_field_info(field.field, attr)
        for attr in _get_passed_attributes_to_field(field.field.field_info)
    }
    # TODO: This is None check feels buggy
    if _is_pydantic_constrained_type(field.field.annotation) and field.annotation_ast is None:
        (
            attrs_that_are_only_in_contype,
            attrs_that_are_only_in_field,
        ) = _get_attrs_that_are_not_from_field_and_that_are_from_field(field.field.annotation)
        if not attrs_that_are_only_in_contype:
            passed_attrs |= attrs_that_are_only_in_field

    return ast.Call(
        func=ast.Name("Field"),
        args=[],
        keywords=[
            _get_ast_keyword_from_field_arg(attr, attr_value, annotation_transformer)
            for attr, attr_value in passed_attrs.items()
        ],
    )


def _get_ast_keyword_from_field_arg(attr_name: str, attr_value: Any, annotation_transformer: "_AnnotationTransformer"):
    return ast.keyword(
        arg=attr_name,
        value=ast.parse(annotation_transformer.visit(attr_value), mode="eval").body,
    )


def _add_keyword_to_call(
    annotation_transformer: "_AnnotationTransformer",
    attr_name: str,
    attr_value: Any,
    field_ast: ast.Call,
):
    new_keyword = _get_ast_keyword_from_field_arg(attr_name, attr_value, annotation_transformer)
    for i, keyword in enumerate(field_ast.keywords):
        if keyword.arg == attr_name:
            field_ast.keywords[i] = new_keyword
            break
    else:
        field_ast.keywords.append(new_keyword)


def _get_attribute_from_field_info(field: ModelField | _ModelFieldLike, attr: str) -> Any:
    field_value = getattr(field.field_info, attr, Sentinel)
    if field_value is Sentinel:
        field_value = field.field_info.extra.get(attr, Sentinel)
    if field_value is Sentinel:  # pragma: no cover # This is just a safeguard that will most likely never be triggered
        raise CodeGenerationError(f'Field "{attr}" is not present in "{field.name}"')
    return field_value


def _modify_enum_cls(cls_node: ast.ClassDef, enum_info: dict[str, Any]) -> ast.ClassDef:
    transformer = _AnnotationTransformer()
    new_body = [
        ast.Assign(
            targets=[ast.Name(member, ctx=ast.Store())],
            value=ast.Name(transformer.visit(member_value)),
            lineno=0,
        )
        for member, member_value in enum_info.items()
    ]

    old_body = [n for n in cls_node.body if not isinstance(n, ast.AnnAssign | ast.Assign | ast.Pass | ast.Ellipsis)]
    docstring = _pop_docstring_from_cls_body(old_body)

    cls_node.body = docstring + new_body + old_body
    return cls_node


def _pop_docstring_from_cls_body(old_body: list[ast.stmt]) -> list[ast.stmt]:
    if (
        len(old_body) > 0
        and isinstance(old_body[0], ast.Expr)
        and isinstance(old_body[0].value, ast.Constant)
        and isinstance(old_body[0].value.value, str)
    ):
        return [old_body.pop(0)]
    else:
        return []


def _get_passed_attributes_to_field(field_info: FieldInfo):
    for attr_name, attr_val in _dict_of_empty_field_info.items():
        if attr_name == "extra":
            continue
        if getattr(field_info, attr_name) != attr_val:
            yield attr_name
    yield from field_info.extra


class _AnnotationASTNodeTransformerWithSchemaRenaming(ast.NodeTransformer):
    def __init__(
        self,
        modified_schemas: dict[str, _ModelWrapper],
        all_names_in_module: dict[str, str],
        module_python_path: str,
    ):
        super().__init__()
        self.modified_schemas = modified_schemas
        self.module_python_path = module_python_path
        self.all_names_in_module = all_names_in_module

    def visit_Name(self, node: ast.Name) -> Any:  # noqa: N802
        return self._get_name(node, node.id)

    def _get_name(self, node: ast.AST, name: str):
        model_info = self.modified_schemas.get(f"{self.all_names_in_module.get(name, self.module_python_path)}.{name}")
        if model_info is not None:
            return ast.Name(model_info.name)
        return node


class _AnnotationTransformer:
    """Returns fancy and correct reprs of annotations"""

    def visit(self, value: Any):
        if isinstance(value, list | tuple | set | frozenset):
            return self.transform_collection(value)
        if isinstance(value, dict):
            return self.transform_dict(value)
        if isinstance(value, _BaseGenericAlias | GenericAlias):
            return self.transform_generic_alias(value)
        if value is None or value is NoneType:
            return self.transform_none(value)
        if isinstance(value, type):
            return self.transform_type(value)
        if isinstance(value, Enum):
            return self.transform_enum(value)
        if isinstance(value, auto):
            return self.transform_auto(value)
        if isinstance(value, UnionType):
            return self.transform_union(value)
        if isinstance(value, LambdaType) and _LambdaFunctionName == value.__name__:
            return self.transform_lambda(value)
        if inspect.isfunction(value):
            return self.transform_function(value)
        else:
            return self.transform_other(value)

    def transform_collection(self, value: list | tuple | set | frozenset) -> Any:
        return PlainRepr(value.__class__(map(self.visit, value)))

    def transform_dict(self, value: dict) -> Any:
        return PlainRepr(
            value.__class__((self.visit(k), self.visit(v)) for k, v in value.items()),
        )

    def transform_generic_alias(self, value: _BaseGenericAlias | GenericAlias) -> Any:
        return f"{self.visit(get_origin(value))}[{', '.join(self.visit(a) for a in get_args(value))}]"

    def transform_none(self, value: NoneType) -> Any:
        return "None"

    def transform_type(self, value: type) -> Any:
        # NOTE: Be wary of this hack when migrating to pydantic v2
        # This is a hack for pydantic's Constrained types
        if _is_pydantic_constrained_type(value):
            if _get_attrs_that_are_not_from_field_and_that_are_from_field(value)[0]:
                # No, get_origin and get_args don't work here. No idea why
                parent = value.mro()[1]
                snake_case = _RE_CAMEL_TO_SNAKE.sub("_", value.__name__)
                cls_name = "con" + "".join(snake_case.split("_")[1:-1])
                return (
                    cls_name.lower()
                    + "("
                    + ", ".join(
                        [
                            f"{key}={self.visit(val)}"
                            for key, val in value.__dict__.items()
                            if not key.startswith("_") and val is not None and val != parent.__dict__[key]
                        ],
                    )
                    + ")"
                )
            else:
                value = value.mro()[-2]

        return value.__name__

    def transform_enum(self, value: Enum) -> Any:
        return PlainRepr(f"{value.__class__.__name__}.{value.name}")

    def transform_auto(self, value: auto) -> Any:
        return PlainRepr("auto()")

    def transform_union(self, value: UnionType) -> Any:
        return "typing.Union[" + (", ".join(self.visit(a) for a in get_args(value))) + "]"

    def transform_lambda(self, value: LambdaType) -> Any:
        # We clean source because getsource() can return only a part of the expression which
        # on its own is not a valid expression such as: "\n  .had(default_factory=lambda: 91)"
        return _find_a_lambda(inspect.getsource(value).strip(" \n\t."))

    def transform_function(self, value: Callable) -> Any:
        return PlainRepr(value.__name__)

    def transform_other(self, value: Any) -> Any:
        return PlainRepr(repr(value))


def _is_pydantic_constrained_type(value: object):
    return isinstance(value, type) and value.__name__.startswith("Constrained") and value.__name__.endswith("Value")


def _get_attrs_that_are_not_from_field_and_that_are_from_field(value: type):
    parent_public_attrs = {k: v for k, v in value.mro()[1].__dict__.items() if not k.startswith("_")}
    value_private_attrs = {k: v for k, v in value.__dict__.items() if not k.startswith("_")}
    attrs_in_value_different_from_parent = {
        k: v for k, v in value_private_attrs.items() if k in parent_public_attrs and parent_public_attrs[k] != v
    }
    attrs_in_value_different_from_parent_that_are_not_in_field_def = {
        k: v for k, v in attrs_in_value_different_from_parent.items() if k not in _dict_of_empty_field_info
    }
    attrs_in_value_different_from_parent_that_are_in_field_def = {
        k: v for k, v in attrs_in_value_different_from_parent.items() if k in _dict_of_empty_field_info
    }

    return (
        attrs_in_value_different_from_parent_that_are_not_in_field_def,
        attrs_in_value_different_from_parent_that_are_in_field_def,
    )


class PlainRepr(str):
    """String class where repr doesn't include quotes"""

    def __repr__(self) -> str:
        return str(self)


def _find_a_lambda(source: str) -> str:
    found_lambdas: list[ast.Lambda] = []

    ast.parse(source)
    for node in ast.walk(ast.parse(source)):
        if isinstance(node, ast.keyword) and node.arg == "default_factory" and isinstance(node.value, ast.Lambda):
            found_lambdas.append(node.value)
    if len(found_lambdas) == 1:
        return ast.unparse(found_lambdas[0])
    # These two errors are really hard to cover. Not sure if even possible, honestly :)
    elif len(found_lambdas) == 0:  # pragma: no cover
        raise InvalidGenerationInstructionError(
            f"No lambda found in default_factory even though one was passed: {source}",
        )
    else:  # pragma: no cover
        raise InvalidGenerationInstructionError(
            "More than one lambda found in default_factory. This is not supported.",
        )


# Some day we will want to use this to auto-add imports for new symbols in versions. Some day...
def _get_all_names_defined_in_module(body: ast.Module, module_python_path: str) -> dict[str, str]:
    defined_names = {}
    for node in body.body:
        if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
            defined_names[node.name] = module_python_path
        elif isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Name):
                    defined_names[target.id] = module_python_path
        elif isinstance(node, ast.ImportFrom):
            for name in node.names:
                defined_names[name.name] = _get_absolute_python_path_of_import(node, module_python_path)
    return defined_names


def _get_cls_pythonpath(cls: type):
    return f"{cls.__module__}.{cls.__name__}"
