from typing import Any, Dict, List, Optional

from django.db import Error, models, transaction
from django.forms import ValidationError

from smoothglue.duplication.exceptions import DuplicationError


def unassignable_field(field):
    """
    Checks to see if a field on a model is assignable based on our rules

    Args:
        field: Django field we are checking the properties of

    Returns:
        bool: Whether or not the field can be assigned
    """
    return getattr(field, "primary_key", False) or isinstance(
        field, (models.ManyToManyField, models.OneToOneField)
    )


def check_fields(original, included_fields, field_mapper):
    """
    Checks to see if the provided fields are suitable for duplication

    Args:
        original: The original object instance we are copying from
        included_fields: Fields we want to directly copy
        field_mapper: Fields we define the values of

    Returns:
        bool: Whether or not the provided fields are valid
    """
    field_names = []
    for field in original._meta.get_fields():
        field_names.append(field.name)
        if unassignable_field(field) and (
            field.name in included_fields or field.name in field_mapper.keys()
        ):
            return False
    valid_included_fields = set(included_fields).issubset(field_names)
    valid_field_mapper = set(field_mapper.keys()).issubset(field_names)
    field_conflicts = list(set(field_mapper.keys()) & set(included_fields))
    return valid_included_fields and valid_field_mapper and len(field_conflicts) == 0


def setup_fields_to_duplicate(
    original: models.Model,
    include_children: bool,
    included_fields: Optional[List[str]],
    excluded_fields: Optional[List[str]],
    field_mapper: Optional[Dict[str, Any]],
):
    """
    Cleans/sets up the fields we are going to be duplicating for the original object.

    Args:
        original: The original object instance we are copying from
        include_children (bool): Boolean whether we want to add
                                and duplicate the children relationships as well.
        included_fields(list[str] | None): Fields we want to directly copy, defaults to all fields
        excluded_fields(list[str] | None): Fields we want to exclude from copy.
        field_mapper(dict[str, Any] | None): Fields we define the values of.

    Returns:
        included_fields, field_mapper: The cleaned version of the provided arguments
    """
    if field_mapper is None:
        field_mapper = {}
    if included_fields is None:
        auto_included_fields = (
            original._meta.get_fields() if include_children else original._meta.fields
        )  # get_fields() includes relationships, fields is only what is directly on the model.

        included_fields = [
            field.name
            for field in auto_included_fields
            if not unassignable_field(field) and field.name not in field_mapper.keys()
        ]
    if excluded_fields is not None:
        included_fields = list(set(included_fields) - set(excluded_fields))

    return included_fields, field_mapper


def get_parent_field_name(original: models.Model, child_model: models.Model):
    """
    Gets the name for the field that represents the parent from it's child relationship

    Args:
        original (models.Model): The original object instance we are copying from
        child_model (models.Model): The child model in the relationship

    Returns:
        str | None: The related name for the parent on the child model, if one is found.
    """
    for field in child_model._meta.get_fields():
        if isinstance(field, models.ForeignKey) and isinstance(
            original, field.related_model
        ):  # Should we add one-2-one here too?
            return field.name
    return None


def duplicate_children(
    duplicated_object: models.Model, one_to_many_relationship_list: List[Dict[str, Any]]
):
    """
    Loops through and duplicates the children from the original object
    and swaps the parent to the duplicate of the original.

    Args:
        duplicated_object (models.Model): The newly duplicated parent
        one_to_many_relationship_list (List[Dict[str, Any]]):
            List of dictionaries containing the related name and queryset of children
    """
    for related_object in one_to_many_relationship_list:
        child_field_mapper = {related_object["parent_field"]: duplicated_object}
        for child_object in related_object.get("related_query", []):
            new_child_object = duplicate_object(
                child_object, True, field_mapper=child_field_mapper
            )
            new_child_object.save()


def duplicate_object(
    original: models.Model,
    include_children: bool,
    included_fields: Optional[List[str]] = None,
    excluded_fields: Optional[List[str]] = None,
    field_mapper: Optional[Dict[str, Any]] = None,
):
    """
    Creates a new object instance based on an original object to duplicate from
    and defined fields.

    Args:
        original: The original object instance we are copying from
        include_children (bool): Boolean whether we want to add
                                and duplicate the children relationships as well.
        included_fields(list[str] | None): Fields we want to directly copy, defaults to all fields
        excluded_fields(list[str] | None): Fields we want to exclude from copy.
        field_mapper(dict[str, Any] | None): Fields we define the values of.

    Returns:
        new_object | None: The newly created instance or None if an issue is found
    """
    copied_fields = {}
    included_fields, field_mapper = setup_fields_to_duplicate(
        original, include_children, included_fields, excluded_fields, field_mapper
    )

    valid_fields = check_fields(original, included_fields, field_mapper)
    if valid_fields:
        one_to_many_relationship_list = []
        for field in included_fields:
            field_instance = original._meta.get_field(field)
            if field_instance.one_to_many:
                parent_field = get_parent_field_name(
                    original, field_instance.related_model
                )
                if parent_field:
                    one_to_many_relationship_list.append(
                        {
                            "parent_field": parent_field,
                            "related_query": getattr(original, field).all(),
                        }
                    )
            else:
                copied_fields[field] = getattr(original, field)
        with transaction.atomic():
            try:
                new_object = original._meta.model(**{**copied_fields, **field_mapper})
                new_object.save()
            except (Error, ValidationError, ValueError) as ex:
                raise DuplicationError(f"Error during duplication: {ex.message}")

            if include_children:
                duplicate_children(new_object, one_to_many_relationship_list)

        return new_object
    raise DuplicationError("Invalid fields provided for duplication.")
