"""
Module for abstract serializer/unserializer base classes.
"""
import pickle
from io import StringIO

from paradoxdjango.core.exceptions import ObjectDoesNotExist
from paradoxdjango.db import models

DEFER_FIELD = object()


class PickleSerializer:
    """
    Simple wrapper around pickle to be used in signing.dumps()/loads() and
    cache backends.
    """

    def __init__(self, protocol=None):
        self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol

    def dumps(self, obj):
        return pickle.dumps(obj, self.protocol)

    def loads(self, data):
        return pickle.loads(data)


class SerializerDoesNotExist(KeyError):
    """The requested serializer was not found."""

    pass


class SerializationError(Exception):
    """Something bad happened during serialization."""

    pass


class DeserializationError(Exception):
    """Something bad happened during deserialization."""

    @classmethod
    def WithData(cls, original_exc, model, fk, field_value):
        """
        Factory method for creating a deserialization error which has a more
        explanatory message.
        """
        return cls(
            "%s: (%s:pk=%s) field_value was '%s'"
            % (original_exc, model, fk, field_value)
        )


class M2MDeserializationError(Exception):
    """Something bad happened during deserialization of a ManyToManyField."""

    def __init__(self, original_exc, pk):
        self.original_exc = original_exc
        self.pk = pk


class ProgressBar:
    progress_width = 75

    def __init__(self, output, total_count):
        self.output = output
        self.total_count = total_count
        self.prev_done = 0

    def update(self, count):
        if not self.output:
            return
        perc = count * 100 // self.total_count
        done = perc * self.progress_width // 100
        if self.prev_done >= done:
            return
        self.prev_done = done
        cr = "" if self.total_count == 1 else "\r"
        self.output.write(
            cr + "[" + "." * done + " " * (self.progress_width - done) + "]"
        )
        if done == self.progress_width:
            self.output.write("\n")
        self.output.flush()


class Serializer:
    """
    Abstract serializer base class.
    """

    # Indicates if the implemented serializer is only available for
    # internal Django use.
    internal_use_only = False
    progress_class = ProgressBar
    stream_class = StringIO

    def serialize(
        self,
        queryset,
        *,
        stream=None,
        fields=None,
        use_natural_foreign_keys=False,
        use_natural_primary_keys=False,
        progress_output=None,
        object_count=0,
        **options,
    ):
        """
        Serialize a queryset.
        """
        self.options = options

        self.stream = stream if stream is not None else self.stream_class()
        self.selected_fields = fields
        self.use_natural_foreign_keys = use_natural_foreign_keys
        self.use_natural_primary_keys = use_natural_primary_keys
        progress_bar = self.progress_class(progress_output, object_count)

        self.start_serialization()
        self.first = True
        for count, obj in enumerate(queryset, start=1):
            self.start_object(obj)
            # Use the concrete parent class' _meta instead of the object's _meta
            # This is to avoid local_fields problems for proxy models. Refs #17717.
            concrete_model = obj._meta.concrete_model
            # When using natural primary keys, retrieve the pk field of the
            # parent for multi-table inheritance child models. That field must
            # be serialized, otherwise deserialization isn't possible.
            if self.use_natural_primary_keys:
                pk = concrete_model._meta.pk
                pk_parent = (
                    pk if pk.remote_field and pk.remote_field.parent_link else None
                )
            else:
                pk_parent = None
            for field in concrete_model._meta.local_fields:
                if field.serialize or field is pk_parent:
                    if field.remote_field is None:
                        if (
                            self.selected_fields is None
                            or field.attname in self.selected_fields
                        ):
                            self.handle_field(obj, field)
                    else:
                        if (
                            self.selected_fields is None
                            or field.attname[:-3] in self.selected_fields
                        ):
                            self.handle_fk_field(obj, field)
            for field in concrete_model._meta.local_many_to_many:
                if field.serialize:
                    if (
                        self.selected_fields is None
                        or field.attname in self.selected_fields
                    ):
                        self.handle_m2m_field(obj, field)
            self.end_object(obj)
            progress_bar.update(count)
            self.first = self.first and False
        self.end_serialization()
        return self.getvalue()

    def start_serialization(self):
        """
        Called when serializing of the queryset starts.
        """
        raise NotImplementedError(
            "subclasses of Serializer must provide a start_serialization() method"
        )

    def end_serialization(self):
        """
        Called when serializing of the queryset ends.
        """
        pass

    def start_object(self, obj):
        """
        Called when serializing of an object starts.
        """
        raise NotImplementedError(
            "subclasses of Serializer must provide a start_object() method"
        )

    def end_object(self, obj):
        """
        Called when serializing of an object ends.
        """
        pass

    def handle_field(self, obj, field):
        """
        Called to handle each individual (non-relational) field on an object.
        """
        raise NotImplementedError(
            "subclasses of Serializer must provide a handle_field() method"
        )

    def handle_fk_field(self, obj, field):
        """
        Called to handle a ForeignKey field.
        """
        raise NotImplementedError(
            "subclasses of Serializer must provide a handle_fk_field() method"
        )

    def handle_m2m_field(self, obj, field):
        """
        Called to handle a ManyToManyField.
        """
        raise NotImplementedError(
            "subclasses of Serializer must provide a handle_m2m_field() method"
        )

    def getvalue(self):
        """
        Return the fully serialized queryset (or None if the output stream is
        not seekable).
        """
        if callable(getattr(self.stream, "getvalue", None)):
            return self.stream.getvalue()


class Deserializer:
    """
    Abstract base deserializer class.
    """

    def __init__(self, stream_or_string, **options):
        """
        Init this serializer given a stream or a string
        """
        self.options = options
        if isinstance(stream_or_string, str):
            self.stream = StringIO(stream_or_string)
        else:
            self.stream = stream_or_string

    def __iter__(self):
        return self

    def __next__(self):
        """Iteration interface -- return the next item in the stream"""
        raise NotImplementedError(
            "subclasses of Deserializer must provide a __next__() method"
        )


class DeserializedObject:
    """
    A deserialized model.

    Basically a container for holding the pre-saved deserialized data along
    with the many-to-many data saved with the object.

    Call ``save()`` to save the object (with the many-to-many data) to the
    database; call ``save(save_m2m=False)`` to save just the object fields
    (and not touch the many-to-many stuff.)
    """

    def __init__(self, obj, m2m_data=None, deferred_fields=None):
        self.object = obj
        self.m2m_data = m2m_data
        self.deferred_fields = deferred_fields

    def __repr__(self):
        return "<%s: %s(pk=%s)>" % (
            self.__class__.__name__,
            self.object._meta.label,
            self.object.pk,
        )

    def save(self, save_m2m=True, using=None, **kwargs):
        # Call save on the Model baseclass directly. This bypasses any
        # model-defined save. The save is also forced to be raw.
        # raw=True is passed to any pre/post_save signals.
        models.Model.save_base(self.object, using=using, raw=True, **kwargs)
        if self.m2m_data and save_m2m:
            for accessor_name, object_list in self.m2m_data.items():
                getattr(self.object, accessor_name).set(object_list)

        # prevent a second (possibly accidental) call to save() from saving
        # the m2m data twice.
        self.m2m_data = None

    def save_deferred_fields(self, using=None):
        self.m2m_data = {}
        for field, field_value in self.deferred_fields.items():
            opts = self.object._meta
            label = opts.app_label + "." + opts.model_name
            if isinstance(field.remote_field, models.ManyToManyRel):
                try:
                    values = deserialize_m2m_values(
                        field, field_value, using, handle_forward_references=False
                    )
                except M2MDeserializationError as e:
                    raise DeserializationError.WithData(
                        e.original_exc, label, self.object.pk, e.pk
                    )
                self.m2m_data[field.name] = values
            elif isinstance(field.remote_field, models.ManyToOneRel):
                try:
                    value = deserialize_fk_value(
                        field, field_value, using, handle_forward_references=False
                    )
                except Exception as e:
                    raise DeserializationError.WithData(
                        e, label, self.object.pk, field_value
                    )
                setattr(self.object, field.attname, value)
        self.save()


def build_instance(Model, data, db):
    """
    Build a model instance.

    If the model instance doesn't have a primary key and the model supports
    natural keys, try to retrieve it from the database.
    """
    default_manager = Model._meta.default_manager
    pk = data.get(Model._meta.pk.attname)
    if (
        pk is None
        and hasattr(default_manager, "get_by_natural_key")
        and hasattr(Model, "natural_key")
    ):
        natural_key = Model(**data).natural_key()
        try:
            data[Model._meta.pk.attname] = Model._meta.pk.to_python(
                default_manager.db_manager(db).get_by_natural_key(*natural_key).pk
            )
        except Model.DoesNotExist:
            pass
    return Model(**data)


def deserialize_m2m_values(field, field_value, using, handle_forward_references):
    model = field.remote_field.model
    if hasattr(model._default_manager, "get_by_natural_key"):

        def m2m_convert(value):
            if hasattr(value, "__iter__") and not isinstance(value, str):
                return (
                    model._default_manager.db_manager(using)
                    .get_by_natural_key(*value)
                    .pk
                )
            else:
                return model._meta.pk.to_python(value)

    else:

        def m2m_convert(v):
            return model._meta.pk.to_python(v)

    try:
        pks_iter = iter(field_value)
    except TypeError as e:
        raise M2MDeserializationError(e, field_value)
    try:
        values = []
        for pk in pks_iter:
            values.append(m2m_convert(pk))
        return values
    except Exception as e:
        if isinstance(e, ObjectDoesNotExist) and handle_forward_references:
            return DEFER_FIELD
        else:
            raise M2MDeserializationError(e, pk)


def deserialize_fk_value(field, field_value, using, handle_forward_references):
    if field_value is None:
        return None
    model = field.remote_field.model
    default_manager = model._default_manager
    field_name = field.remote_field.field_name
    if (
        hasattr(default_manager, "get_by_natural_key")
        and hasattr(field_value, "__iter__")
        and not isinstance(field_value, str)
    ):
        try:
            obj = default_manager.db_manager(using).get_by_natural_key(*field_value)
        except ObjectDoesNotExist:
            if handle_forward_references:
                return DEFER_FIELD
            else:
                raise
        value = getattr(obj, field_name)
        # If this is a natural foreign key to an object that has a FK/O2O as
        # the foreign key, use the FK value.
        if model._meta.pk.remote_field:
            value = value.pk
        return value
    return model._meta.get_field(field_name).to_python(field_value)
