# -*- coding: utf-8; -*-
################################################################################
#
#  wuttaweb -- Web App for Wutta Framework
#  Copyright © 2024 Lance Edgar
#
#  This file is part of Wutta Framework.
#
#  Wutta Framework is free software: you can redistribute it and/or modify it
#  under the terms of the GNU General Public License as published by the Free
#  Software Foundation, either version 3 of the License, or (at your option) any
#  later version.
#
#  Wutta Framework is distributed in the hope that it will be useful, but
#  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
#  more details.
#
#  You should have received a copy of the GNU General Public License along with
#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Form schema types
"""

import colander

from wuttaweb.db import Session
from wuttaweb.forms import widgets
from wuttjamaican.db.model import Person


class ObjectNode(colander.SchemaNode):
    """
    Custom schema node class which adds methods for compatibility with
    ColanderAlchemy.  This is a direct subclass of
    :class:`colander:colander.SchemaNode`.

    ColanderAlchemy will call certain methods on any node found in the
    schema.  However these methods are not "standard" and only exist
    for ColanderAlchemy nodes.

    So we must add nodes using this class, to ensure the node has all
    methods needed by ColanderAlchemy.
    """

    def dictify(self, obj):
        """
        This method is called by ColanderAlchemy when translating the
        in-app Python object to a value suitable for use in the form
        data dict.

        The logic here will look for a ``dictify()`` method on the
        node's "type" instance (``self.typ``; see also
        :class:`colander:colander.SchemaNode`) and invoke it if found.

        For an example type which is supported in this way, see
        :class:`ObjectRef`.

        If the node's type does not have a ``dictify()`` method, this
        will just convert the object to a string and return that.
        """
        if hasattr(self.typ, 'dictify'):
            return self.typ.dictify(obj)

        # TODO: this is better than raising an error, as it previously
        # did, but seems like troubleshooting problems may often lead
        # one here.. i suspect this needs to do something smarter but
        # not sure what that is yet
        return str(obj)

    def objectify(self, value):
        """
        This method is called by ColanderAlchemy when translating form
        data to the final Python representation.

        The logic here will look for an ``objectify()`` method on the
        node's "type" instance (``self.typ``; see also
        :class:`colander:colander.SchemaNode`) and invoke it if found.

        For an example type which is supported in this way, see
        :class:`ObjectRef`.

        If the node's type does not have an ``objectify()`` method,
        this will raise ``NotImplementeError``.
        """
        if hasattr(self.typ, 'objectify'):
            return self.typ.objectify(value)

        class_name = self.typ.__class__.__name__
        raise NotImplementedError(f"you must define {class_name}.objectify()")


class ObjectRef(colander.SchemaType):
    """
    Custom schema type for a model class reference field.

    This expects the incoming ``appstruct`` to be either a model
    record instance, or ``None``.

    Serializes to the instance UUID as string, or ``colander.null``;
    form data should be of the same nature.

    This schema type is not useful directly, but various other types
    will subclass it.  Each should define (at least) the
    :attr:`model_class` attribute or property.

    :param request: Current :term:`request` object.

    :param empty_option: If a select widget is used, this determines
       whether an empty option is included for the dropdown.  Set
       this to one of the following to add an empty option:

       * ``True`` to add the default empty option
       * label text for the empty option
       * tuple of ``(value, label)`` for the empty option

       Note that in the latter, ``value`` must be a string.
    """

    default_empty_option = ('', "(none)")

    def __init__(
            self,
            request,
            empty_option=None,
            session=None,
            *args,
            **kwargs,
    ):
        super().__init__(*args, **kwargs)
        self.request = request
        self.config = self.request.wutta_config
        self.app = self.config.get_app()
        self.model_instance = None
        self.session = session or Session()

        if empty_option:
            if empty_option is True:
                self.empty_option = self.default_empty_option
            elif isinstance(empty_option, tuple) and len(empty_option) == 2:
                self.empty_option = empty_option
            else:
                self.empty_option = ('', str(empty_option))
        else:
            self.empty_option = None

    @property
    def model_class(self):
        """
        Should be a reference to the model class to which this schema
        type applies
        (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`).
        """
        class_name = self.__class__.__name__
        raise NotImplementedError(f"you must define {class_name}.model_class")

    def serialize(self, node, appstruct):
        """ """
        if appstruct is colander.null:
            return colander.null

        # nb. keep a ref to this for later use
        node.model_instance = appstruct

        # serialize to uuid
        return appstruct.uuid

    def deserialize(self, node, cstruct):
        """ """
        if not cstruct:
            return colander.null

        # nb. use shortcut to fetch model instance from DB
        return self.objectify(cstruct)

    def dictify(self, obj):
        """ """

        # TODO: would we ever need to do something else?
        return obj

    def objectify(self, value):
        """
        For the given UUID value, returns the object it represents
        (based on :attr:`model_class`).

        If the value is empty, returns ``None``.

        If the value is not empty but object cannot be found, raises
        ``colander.Invalid``.
        """
        if not value:
            return

        if isinstance(value, self.model_class):
            return value

        # fetch object from DB
        model = self.app.model
        obj = self.session.query(self.model_class).get(value)

        # raise error if not found
        if not obj:
            class_name = self.model_class.__name__
            raise ValueError(f"{class_name} not found: {value}")

        return obj

    def get_query(self):
        """
        Returns the main SQLAlchemy query responsible for locating the
        dropdown choices for the select widget.

        This is called by :meth:`widget_maker()`.
        """
        query = self.session.query(self.model_class)
        query = self.sort_query(query)
        return query

    def sort_query(self, query):
        """
        TODO
        """
        return query

    def widget_maker(self, **kwargs):
        """
        This method is responsible for producing the default widget
        for the schema node.

        Deform calls this method automatically when constructing the
        default widget for a field.

        :returns: Instance of
           :class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
        """

        if 'values' not in kwargs:
            query = self.get_query()
            objects = query.all()
            values = [(obj.uuid, str(obj))
                      for obj in objects]
            if self.empty_option:
                values.insert(0, self.empty_option)
            kwargs['values'] = values

        return widgets.ObjectRefWidget(self.request, **kwargs)


class PersonRef(ObjectRef):
    """
    Custom schema type for a ``Person`` reference field.

    This is a subclass of :class:`ObjectRef`.
    """
    model_class = Person

    def sort_query(self, query):
        """ """
        return query.order_by(self.model_class.full_name)


class WuttaSet(colander.Set):
    """
    Custom schema type for :class:`python:set` fields.

    This is a subclass of :class:`colander.Set`, but adds
    Wutta-related params to the constructor.

    :param request: Current :term:`request` object.

    :param session: Optional :term:`db session` to use instead of
       :class:`wuttaweb.db.Session`.
    """

    def __init__(self, request, session=None):
        super().__init__()
        self.request = request
        self.config = self.request.wutta_config
        self.app = self.config.get_app()
        self.session = session or Session()


class RoleRefs(WuttaSet):
    """
    Form schema type for the User
    :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
    association proxy field.

    This is a subclass of :class:`WuttaSet`.  It uses a ``set`` of
    :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid``
    values for underlying data format.
    """

    def widget_maker(self, **kwargs):
        """
        Constructs a default widget for the field.

        :returns: Instance of
           :class:`~wuttaweb.forms.widgets.RoleRefsWidget`.
        """
        kwargs.setdefault('session', self.session)

        if 'values' not in kwargs:
            model = self.app.model
            auth = self.app.get_auth_handler()
            avoid = {
                auth.get_role_authenticated(self.session),
                auth.get_role_anonymous(self.session),
            }
            avoid = set([role.uuid for role in avoid])
            roles = self.session.query(model.Role)\
                                .filter(~model.Role.uuid.in_(avoid))\
                                .order_by(model.Role.name)\
                                .all()
            values = [(role.uuid, role.name) for role in roles]
            kwargs['values'] = values

        return widgets.RoleRefsWidget(self.request, **kwargs)


class Permissions(WuttaSet):
    """
    Form schema type for the Role
    :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
    association proxy field.

    This is a subclass of :class:`WuttaSet`.  It uses a ``set`` of
    :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
    values for underlying data format.

    :param permissions: Dict with all possible permissions.  Should be
       in the same format as returned by
       :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
    """

    def __init__(self, request, permissions, *args, **kwargs):
        super().__init__(request, *args, **kwargs)
        self.permissions = permissions

    def widget_maker(self, **kwargs):
        """
        Constructs a default widget for the field.

        :returns: Instance of
           :class:`~wuttaweb.forms.widgets.PermissionsWidget`.
        """
        kwargs.setdefault('session', self.session)
        kwargs.setdefault('permissions', self.permissions)

        if 'values' not in kwargs:
            values = []
            for gkey, group in self.permissions.items():
                for pkey, perm in group['perms'].items():
                    values.append((pkey, perm['label']))
            kwargs['values'] = values

        return widgets.PermissionsWidget(self.request, **kwargs)
