"""Helper functions for creating Form classes from SQLAlchemy models."""
from collections import OrderedDict
from contextlib import suppress
from itertools import chain

from django.core.exceptions import (
    NON_FIELD_ERRORS,
    ImproperlyConfigured,
    ValidationError,
)
from django.forms import ALL_FIELDS
from django.forms.forms import (
    BaseForm as DjangoBaseForm,
    DeclarativeFieldsMetaclass,
)
from django.forms.models import (
    BaseModelForm as DjangoBaseModelForm,
    ModelFormOptions,
)
from django.forms.utils import ErrorList

from .db import meta


def _get_default_kwargs(
    info,
    session,
    fields=None,
    exclude=None,
    widgets=None,
    localized_fields=None,
    labels=None,
    help_texts=None,
    error_messages=None,
    field_classes=None,
):
    kwargs = {}
    if widgets and info.name in widgets:
        kwargs["widget"] = widgets[info.name]
    if labels and info.name in labels:
        kwargs["label"] = labels[info.name]
    if help_texts and info.name in help_texts:
        kwargs["help_text"] = help_texts[info.name]
    if error_messages and info.name in error_messages:
        kwargs["error_messages"] = error_messages[info.name]
    if field_classes and info.name in field_classes:
        kwargs["form_class"] = field_classes[info.name]

    if localized_fields == ALL_FIELDS:
        kwargs["localize"] = True
    if localized_fields and info.name in localized_fields:
        kwargs["localize"] = True

    if session is None:
        with suppress(AttributeError):
            session = info.related_model.query.session

    if isinstance(info, meta.relation_info):
        kwargs["session"] = session
    return kwargs


def fields_for_model(
    model,
    session,
    fields=None,
    exclude=None,
    widgets=None,
    formfield_callback=None,
    localized_fields=None,
    labels=None,
    help_texts=None,
    error_messages=None,
    field_classes=None,
    apply_limit_choices_to=True,
    **kwargs,
):
    """Returns a dictionary containing form fields for a given model."""

    field_list = []
    info = meta.model_info(model)

    for name, attr in chain(info.properties.items(), info.relationships.items()):

        if name.startswith("_"):
            continue

        if fields and name not in fields:
            continue

        if exclude and name in exclude:
            continue

        kwargs = _get_default_kwargs(
            attr,
            session,
            fields=fields,
            exclude=exclude,
            widgets=widgets,
            localized_fields=localized_fields,
            labels=labels,
            help_texts=help_texts,
            error_messages=error_messages,
            field_classes=field_classes,
        )
        if formfield_callback is None:
            formfield = attr.formfield(**kwargs)
        elif not callable(formfield_callback):
            raise TypeError("formfield_callback must be a function or callable")
        else:
            formfield = formfield_callback(attr, **kwargs)

        if formfield is not None:
            if apply_limit_choices_to:
                apply_limit_choices_to_form_field(formfield)
            field_list.append((name, formfield))

    return OrderedDict(field_list)


def apply_limit_choices_to_form_field(formfield):
    """Apply limit_choices_to to the formfield's query if needed."""
    if hasattr(formfield, "queryset") and hasattr(formfield, "get_limit_choices_to"):
        limit_choices_to = formfield.get_limit_choices_to()
        if limit_choices_to is not None:
            formfield.queryset = formfield.queryset.filter(*limit_choices_to)


def model_to_dict(instance, fields=None, exclude=None):
    """Return a dict containing the data in ``instance`` suitable for passing
    as a Form's ``initial`` keyword argument.

    ``fields`` is an optional list of field names. If provided, return only the
    named.

    ``exclude`` is an optional list of field names. If provided, exclude the
    named from the returned dict, even if they are listed in the ``fields``
    argument.
    """
    info = meta.model_info(type(instance))

    fields = set(
        fields or list(info.properties.keys()) + list(info.primary_keys.keys()) + list(info.relationships.keys())
    )
    exclude = set(exclude or [])
    data = {}
    for name in info.properties:

        if name.startswith("_"):
            continue

        if name not in fields:
            continue

        if name in exclude:
            continue

        data[name] = getattr(instance, name)

    for name, rel in info.relationships.items():
        related_info = meta.model_info(rel.related_model)

        if name.startswith("_"):
            continue

        if name not in fields:
            continue

        if name in exclude:
            continue

        if rel.uselist:
            for obj in getattr(instance, name):
                pks = related_info.primary_keys_from_instance(obj)
                if pks:
                    data.setdefault(name, []).append(pks)
        else:
            obj = getattr(instance, name)
            pks = related_info.primary_keys_from_instance(obj)
            if pks:
                data[name] = pks

    return data


class SQLAModelFormOptions(ModelFormOptions):
    """Model form options for sqlalchemy."""

    def __init__(self, options=None):
        super().__init__(options=options)
        self.session = getattr(options, "session", None)


class ModelFormMetaclass(DeclarativeFieldsMetaclass):
    """ModelForm metaclass for sqlalchemy models."""

    def __new__(mcs, name, bases, attrs):
        cls = super().__new__(mcs, name, bases, attrs)

        base_formfield_callback = None
        for base in bases:
            if hasattr(base, "Meta") and hasattr(base.Meta, "formfield_callback"):
                base_formfield_callback = base.Meta.formfield_callback
                break

        formfield_callback = attrs.pop("formfield_callback", base_formfield_callback)

        if bases == (BaseModelForm,):
            return cls

        opts = cls._meta = SQLAModelFormOptions(getattr(cls, "Meta", None))

        for opt in ["fields", "exclude", "localized_fields"]:
            value = getattr(opts, opt)
            if isinstance(value, str) and value != ALL_FIELDS:
                raise TypeError(
                    "%(model)s.Meta.%(opt)s cannot be a string. Did you mean to type: ('%(value)s',)?"
                    % {"model": cls.__name__, "opt": opt, "value": value}
                )

        if opts.model:
            if opts.fields is None and opts.exclude is None:
                raise ImproperlyConfigured(
                    "Creating a ModelForm without either the 'fields' attribute or the 'exclude' attribute is "
                    "prohibited; form %s needs updating." % name
                )

            if opts.fields == ALL_FIELDS:
                opts.fields = None

            cls.base_fields = fields_for_model(
                opts.model,
                opts.session,
                error_messages=opts.error_messages,
                exclude=opts.exclude,
                field_classes=opts.field_classes,
                fields=opts.fields,
                help_texts=opts.help_texts,
                labels=opts.labels,
                localized_fields=opts.localized_fields,
                widgets=opts.widgets,
                formfield_callback=formfield_callback,
                apply_limit_choices_to=False,
            )
            cls.base_fields.update(cls.declared_fields)
        else:
            cls.base_fields = cls.declared_fields

        return cls


class BaseModelForm(DjangoBaseModelForm):
    """Base ModelForm for sqlalchemy models."""

    def __init__(
        self,
        data=None,
        files=None,
        auto_id="id_%s",
        prefix=None,
        initial=None,
        error_class=ErrorList,
        label_suffix=None,
        empty_permitted=False,
        instance=None,
        use_required_attribute=None,
        renderer=None,
        session=None,
    ):
        opts = self._meta
        opts.session = opts.session or session
        if opts.model is None:
            raise ValueError("ModelForm has no model class specified.")

        if opts.session is None:
            raise ValueError("ModelForm has no session specified.")

        self.instance = opts.model() if instance is None else instance
        object_data = self.model_to_dict()
        object_data.update(initial or {})
        self._validate_unique = False
        DjangoBaseForm.__init__(
            self,
            data=data,
            files=files,
            auto_id=auto_id,
            prefix=prefix,
            initial=object_data,
            error_class=error_class,
            label_suffix=label_suffix,
            empty_permitted=empty_permitted,
            use_required_attribute=use_required_attribute,
            renderer=renderer,
        )
        for field in self.fields.values():
            apply_limit_choices_to_form_field(field)

    def model_to_dict(self):
        """Returns a dict containing the data in ``instance`` suitable for
        passing as forms ``initial`` keyword argument."""
        opts = self._meta
        return model_to_dict(self.instance, opts.fields, opts.exclude)

    def _update_errors(self, errors):
        custom_errors = self._meta.error_messages or {}
        error_dict = getattr(errors, "error_dict", None) or {NON_FIELD_ERRORS: errors}

        for field, messages in error_dict.items():
            error_messages = {}
            if field == NON_FIELD_ERRORS and NON_FIELD_ERRORS in custom_errors:
                error_messages = custom_errors[NON_FIELD_ERRORS]
            elif field in self.fields:
                error_messages = self.fields[field].error_messages

            for message in messages:
                if isinstance(message, ValidationError) and message.code in error_messages:
                    message.message = error_messages[message.code]

        self.add_error(None, errors)

    def is_valid(self, rollback=True):
        """Return True if the form has no errors, or False otherwise.

        Will also rollback the session transaction.
        """
        is_valid = super().is_valid()

        if not is_valid and rollback:
            self._meta.session.rollback()

        return is_valid

    def save(self, flush=True, **kwargs):
        """Makes form's self.instance model persistent and flushes the
        session."""
        opts = self._meta

        if self.errors:
            raise ValueError(
                "The {} could not be saved because the data didn't validate.".format(self.instance.__class__.__name__)
            )

        if self.instance not in opts.session:
            opts.session.add(self.instance)

        if flush:
            opts.session.flush()

        return self.instance

    def _post_clean(self):
        """Hook for performing additional cleaning after form cleaning is
        complete.

        Used for model validation in model forms.
        """
        try:
            self.instance = self.save_instance()
        except ValidationError as e:
            self._update_errors(e)

        try:
            getattr(self.instance, "full_clean", bool)()
        except ValidationError as e:
            self._update_errors(e)

    def save_instance(self, instance=None, cleaned_data=None):
        """Updates form's instance with cleaned data."""
        instance = instance or self.instance
        cleaned_data = cleaned_data or self.cleaned_data

        for name, field in self.fields.items():
            if name in cleaned_data:
                self.update_attribute(self.instance, name, field, cleaned_data[name])

        return instance

    def update_attribute(self, instance, name, field, value):
        """Provides hooks for updating form instance's attribute for a field
        with value."""
        field_setter = getattr(self, "set_" + name, None)
        if field_setter:
            field_setter(instance, name, field, value)
        else:
            setattr(instance, name, value)


class ModelForm(BaseModelForm, metaclass=ModelFormMetaclass):
    """ModelForm base class for sqlalchemy models."""


def modelform_factory(model, form=ModelForm, formfield_callback=None, **kwargs):
    """Return a ModelForm class containing form fields for the given model."""
    defaults = [
        "fields",
        "exclude",
        "widgets",
        "localized_fields",
        "labels",
        "help_texts",
        "error_messages",
        "field_classes",
        "session",
    ]

    attrs = {"model": model}
    for key in defaults:
        value = kwargs.get(key)
        if value is not None:
            attrs[key] = value

    bases = (form.Meta,) if hasattr(form, "Meta") else ()
    meta_ = type("Meta", bases, attrs)
    if formfield_callback:
        meta_.formfield_callback = staticmethod(formfield_callback)

    class_name = model.__name__ + "Form"

    if getattr(meta_, "fields", None) is None and getattr(meta_, "exclude", None) is None:
        raise ImproperlyConfigured(
            "Calling modelform_factory without defining 'fields' or 'exclude' explicitly is prohibited."
        )

    return type(form)(str(class_name), (form,), {"Meta": meta_, "formfield_callback": formfield_callback})

"""Helper functions for creating Form classes from SQLAlchemy models."""
from collections import OrderedDict
from contextlib import suppress
from itertools import chain

from django.core.exceptions import (
    NON_FIELD_ERRORS,
    ImproperlyConfigured,
    ValidationError,
)
from django.forms import ALL_FIELDS
from django.forms.forms import (
    BaseForm as DjangoBaseForm,
    DeclarativeFieldsMetaclass,
)
from django.forms.models import (
    BaseModelForm as DjangoBaseModelForm,
    ModelFormOptions,
)
from django.forms.utils import ErrorList

from .db import meta


def _get_default_kwargs(
    info,
    session,
    fields=None,
    exclude=None,
    widgets=None,
    localized_fields=None,
    labels=None,
    help_texts=None,
    error_messages=None,
    field_classes=None,
):
    kwargs = {}
    if widgets and info.name in widgets:
        kwargs["widget"] = widgets[info.name]
    if labels and info.name in labels:
        kwargs["label"] = labels[info.name]
    if help_texts and info.name in help_texts:
        kwargs["help_text"] = help_texts[info.name]
    if error_messages and info.name in error_messages:
        kwargs["error_messages"] = error_messages[info.name]
    if field_classes and info.name in field_classes:
        kwargs["form_class"] = field_classes[info.name]

    if localized_fields == ALL_FIELDS:
        kwargs["localize"] = True
    if localized_fields and info.name in localized_fields:
        kwargs["localize"] = True

    if session is None:
        with suppress(AttributeError):
            session = info.related_model.query.session

    if isinstance(info, meta.relation_info):
        kwargs["session"] = session
    return kwargs


def fields_for_model(
    model,
    session,
    fields=None,
    exclude=None,
    widgets=None,
    formfield_callback=None,
    localized_fields=None,
    labels=None,
    help_texts=None,
    error_messages=None,
    field_classes=None,
    apply_limit_choices_to=True,
    **kwargs,
):
    """Returns a dictionary containing form fields for a given model."""

    field_list = []
    info = meta.model_info(model)

    for name, attr in chain(info.properties.items(), info.relationships.items()):

        if name.startswith("_"):
            continue

        if fields and name not in fields:
            continue

        if exclude and name in exclude:
            continue

        kwargs = _get_default_kwargs(
            attr,
            session,
            fields=fields,
            exclude=exclude,
            widgets=widgets,
            localized_fields=localized_fields,
            labels=labels,
            help_texts=help_texts,
            error_messages=error_messages,
            field_classes=field_classes,
        )
        if formfield_callback is None:
            formfield = attr.formfield(**kwargs)
        elif not callable(formfield_callback):
            raise TypeError("formfield_callback must be a function or callable")
        else:
            formfield = formfield_callback(attr, **kwargs)

        if formfield is not None:
            if apply_limit_choices_to:
                apply_limit_choices_to_form_field(formfield)
            field_list.append((name, formfield))

    return OrderedDict(field_list)


def apply_limit_choices_to_form_field(formfield):
    """Apply limit_choices_to to the formfield's query if needed."""
    if hasattr(formfield, "queryset") and hasattr(formfield, "get_limit_choices_to"):
        limit_choices_to = formfield.get_limit_choices_to()
        if limit_choices_to is not None:
            formfield.queryset = formfield.queryset.filter(*limit_choices_to)


def model_to_dict(instance, fields=None, exclude=None):
    """Return a dict containing the data in ``instance`` suitable for passing
    as a Form's ``initial`` keyword argument.

    ``fields`` is an optional list of field names. If provided, return only the
    named.

    ``exclude`` is an optional list of field names. If provided, exclude the
    named from the returned dict, even if they are listed in the ``fields``
    argument.
    """
    info = meta.model_info(type(instance))

    fields = set(
        fields or list(info.properties.keys()) + list(info.primary_keys.keys()) + list(info.relationships.keys())
    )
    exclude = set(exclude or [])
    data = {}
    for name in info.properties:

        if name.startswith("_"):
            continue

        if name not in fields:
            continue

        if name in exclude:
            continue

        data[name] = getattr(instance, name)

    for name, rel in info.relationships.items():
        related_info = meta.model_info(rel.related_model)

        if name.startswith("_"):
            continue

        if name not in fields:
            continue

        if name in exclude:
            continue

        if rel.uselist:
            for obj in getattr(instance, name):
                pks = related_info.primary_keys_from_instance(obj)
                if pks:
                    data.setdefault(name, []).append(pks)
        else:
            obj = getattr(instance, name)
            pks = related_info.primary_keys_from_instance(obj)
            if pks:
                data[name] = pks

    return data


class SQLAModelFormOptions(ModelFormOptions):
    """Model form options for sqlalchemy."""

    def __init__(self, options=None):
        super().__init__(options=options)
        self.session = getattr(options, "session", None)


class ModelFormMetaclass(DeclarativeFieldsMetaclass):
    """ModelForm metaclass for sqlalchemy models."""

    def __new__(mcs, name, bases, attrs):
        cls = super().__new__(mcs, name, bases, attrs)

        base_formfield_callback = None
        for base in bases:
            if hasattr(base, "Meta") and hasattr(base.Meta, "formfield_callback"):
                base_formfield_callback = base.Meta.formfield_callback
                break

        formfield_callback = attrs.pop("formfield_callback", base_formfield_callback)

        if bases == (BaseModelForm,):
            return cls

        opts = cls._meta = SQLAModelFormOptions(getattr(cls, "Meta", None))

        for opt in ["fields", "exclude", "localized_fields"]:
            value = getattr(opts, opt)
            if isinstance(value, str) and value != ALL_FIELDS:
                raise TypeError(
                    "%(model)s.Meta.%(opt)s cannot be a string. Did you mean to type: ('%(value)s',)?"
                    % {"model": cls.__name__, "opt": opt, "value": value}
                )

        if opts.model:
            if opts.fields is None and opts.exclude is None:
                raise ImproperlyConfigured(
                    "Creating a ModelForm without either the 'fields' attribute or the 'exclude' attribute is "
                    "prohibited; form %s needs updating." % name
                )

            if opts.fields == ALL_FIELDS:
                opts.fields = None

            cls.base_fields = fields_for_model(
                opts.model,
                opts.session,
                error_messages=opts.error_messages,
                exclude=opts.exclude,
                field_classes=opts.field_classes,
                fields=opts.fields,
                help_texts=opts.help_texts,
                labels=opts.labels,
                localized_fields=opts.localized_fields,
                widgets=opts.widgets,
                formfield_callback=formfield_callback,
                apply_limit_choices_to=False,
            )
            cls.base_fields.update(cls.declared_fields)
        else:
            cls.base_fields = cls.declared_fields

        return cls


class BaseModelForm(DjangoBaseModelForm):
    """Base ModelForm for sqlalchemy models."""

    def __init__(
        self,
        data=None,
        files=None,
        auto_id="id_%s",
        prefix=None,
        initial=None,
        error_class=ErrorList,
        label_suffix=None,
        empty_permitted=False,
        instance=None,
        use_required_attribute=None,
        renderer=None,
        session=None,
    ):
        opts = self._meta
        opts.session = opts.session or session
        if opts.model is None:
            raise ValueError("ModelForm has no model class specified.")

        if opts.session is None:
            raise ValueError("ModelForm has no session specified.")

        self.instance = opts.model() if instance is None else instance
        object_data = self.model_to_dict()
        object_data.update(initial or {})
        self._validate_unique = False
        DjangoBaseForm.__init__(
            self,
            data=data,
            files=files,
            auto_id=auto_id,
            prefix=prefix,
            initial=object_data,
            error_class=error_class,
            label_suffix=label_suffix,
            empty_permitted=empty_permitted,
            use_required_attribute=use_required_attribute,
            renderer=renderer,
        )
        for field in self.fields.values():
            apply_limit_choices_to_form_field(field)

    def model_to_dict(self):
        """Returns a dict containing the data in ``instance`` suitable for
        passing as forms ``initial`` keyword argument."""
        opts = self._meta
        return model_to_dict(self.instance, opts.fields, opts.exclude)

    def _update_errors(self, errors):
        custom_errors = self._meta.error_messages or {}
        error_dict = getattr(errors, "error_dict", None) or {NON_FIELD_ERRORS: errors}

        for field, messages in error_dict.items():
            error_messages = {}
            if field == NON_FIELD_ERRORS and NON_FIELD_ERRORS in custom_errors:
                error_messages = custom_errors[NON_FIELD_ERRORS]
            elif field in self.fields:
                error_messages = self.fields[field].error_messages

            for message in messages:
                if isinstance(message, ValidationError) and message.code in error_messages:
                    message.message = error_messages[message.code]

        self.add_error(None, errors)

    def is_valid(self, rollback=True):
        """Return True if the form has no errors, or False otherwise.

        Will also rollback the session transaction.
        """
        is_valid = super().is_valid()

        if not is_valid and rollback:
            self._meta.session.rollback()

        return is_valid

    def save(self, flush=True, **kwargs):
        """Makes form's self.instance model persistent and flushes the
        session."""
        opts = self._meta

        if self.errors:
            raise ValueError(
                "The {} could not be saved because the data didn't validate.".format(self.instance.__class__.__name__)
            )

        if self.instance not in opts.session:
            opts.session.add(self.instance)

        if flush:
            opts.session.flush()

        return self.instance

    def _post_clean(self):
        """Hook for performing additional cleaning after form cleaning is
        complete.

        Used for model validation in model forms.
        """
        try:
            self.instance = self.save_instance()
        except ValidationError as e:
            self._update_errors(e)

        try:
            getattr(self.instance, "full_clean", bool)()
        except ValidationError as e:
            self._update_errors(e)

    def save_instance(self, instance=None, cleaned_data=None):
        """Updates form's instance with cleaned data."""
        instance = instance or self.instance
        cleaned_data = cleaned_data or self.cleaned_data

        for name, field in self.fields.items():
            if name in cleaned_data:
                self.update_attribute(self.instance, name, field, cleaned_data[name])

        return instance

    def update_attribute(self, instance, name, field, value):
        """Provides hooks for updating form instance's attribute for a field
        with value."""
        field_setter = getattr(self, "set_" + name, None)
        if field_setter:
            field_setter(instance, name, field, value)
        else:
            setattr(instance, name, value)


class ModelForm(BaseModelForm, metaclass=ModelFormMetaclass):
    """ModelForm base class for sqlalchemy models."""


def modelform_factory(model, form=ModelForm, formfield_callback=None, **kwargs):
    """Return a ModelForm class containing form fields for the given model."""
    defaults = [
        "fields",
        "exclude",
        "widgets",
        "localized_fields",
        "labels",
        "help_texts",
        "error_messages",
        "field_classes",
        "session",
    ]

    attrs = {"model": model}
    for key in defaults:
        value = kwargs.get(key)
        if value is not None:
            attrs[key] = value

    bases = (form.Meta,) if hasattr(form, "Meta") else ()
    meta_ = type("Meta", bases, attrs)
    if formfield_callback:
        meta_.formfield_callback = staticmethod(formfield_callback)

    class_name = model.__name__ + "Form"

    if getattr(meta_, "fields", None) is None and getattr(meta_, "exclude", None) is None:
        raise ImproperlyConfigured(
            "Calling modelform_factory without defining 'fields' or 'exclude' explicitly is prohibited."
        )

    return type(form)(str(class_name), (form,), {"Meta": meta_, "formfield_callback": formfield_callback})

Helper functions for creating Form classes from SQLAlchemy models. Returns a dictionary containing form fields for a given model. Apply limit_choices_to to the formfield's query if needed. Return a dict containing the data in ``instance`` suitable for passing     as a Form's ``initial`` keyword argument.      ``fields`` is an optional list of field names. If provided, return only the     named.      ``exclude`` is an optional list of field names. If provided, exclude the     named from the returned dict, even if they are listed in the ``fields``     argument. Model form options for sqlalchemy. ModelForm metaclass for sqlalchemy models. Base ModelForm for sqlalchemy models. Returns a dict containing the data in ``instance`` suitable for         passing as forms ``initial`` keyword argument. Return True if the form has no errors, or False otherwise.          Will also rollback the session transaction. Makes form's self.instance model persistent and flushes the         session. Hook for performing additional cleaning after form cleaning is         complete.          Used for model validation in model forms. Updates form's instance with cleaned data. Provides hooks for updating form instance's attribute for a field         with value. ModelForm base class for sqlalchemy models. Return a ModelForm class containing form fields for the given model.