# -*- coding: utf-8 -*-

from collections import defaultdict
from datetime import datetime, timezone
from lxml.builder import E

from odoo import _, api, models
from odoo.exceptions import UserError


class Base(models.AbstractModel):
    _inherit = 'base'

    _start_name = 'date_start'       # start field to use for default gantt view
    _stop_name = 'date_stop'         # stop field to use for default gantt view

    # action_gantt_reschedule utils
    _WEB_GANTT_RESCHEDULE_FORWARD = 'forward'
    _WEB_GANTT_RESCHEDULE_BACKWARD = 'backward'
    _WEB_GANTT_LOOP_ERROR = 'loop_error'

    @api.model
    def _get_default_gantt_view(self):
        """ Generates a default gantt view by trying to infer
        time-based fields from a number of pre-set attribute names

        :returns: a gantt view
        :rtype: etree._Element
        """
        view = E.gantt(string=self._description)

        gantt_field_names = {
            '_start_name': ['date_start', 'start_date', 'x_date_start', 'x_start_date'],
            '_stop_name': ['date_stop', 'stop_date', 'date_end', 'end_date', 'x_date_stop', 'x_stop_date', 'x_date_end', 'x_end_date'],
        }
        for name in gantt_field_names.keys():
            if getattr(self, name) not in self._fields:
                for dt in gantt_field_names[name]:
                    if dt in self._fields:
                        setattr(self, name, dt)
                        break
                else:
                    raise UserError(_("Insufficient fields for Gantt View!"))
        view.set('date_start', self._start_name)
        view.set('date_stop', self._stop_name)

        return view

    @api.model
    def web_gantt_reschedule(
        self,
        direction,
        master_record_id, slave_record_id,
        dependency_field_name, dependency_inverted_field_name,
        start_date_field_name, stop_date_field_name
    ):
        """ Reschedule a record according to the provided parameters.

            :param direction: The direction of the rescheduling 'forward' or 'backward'
            :param master_record_id: The record that the other one is depending on.
            :param slave_record_id: The record that is depending on the other one.
            :param dependency_field_name: The field name of the relation between the master and slave records.
            :param dependency_inverted_field_name: The field name of the relation between the slave and the parent
                   records.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :return: True if Successful, a client action of notification type if not.
        """

        if direction not in (self._WEB_GANTT_RESCHEDULE_FORWARD, self._WEB_GANTT_RESCHEDULE_BACKWARD):
            raise ValueError("Invalid direction %r" % direction)

        master_record, slave_record = self.env[self._name].browse([master_record_id, slave_record_id])

        search_domain = [(dependency_field_name, 'in', master_record.id), ('id', '=', slave_record.id)]
        if not self.env[self._name].search(search_domain, limit=1):
            raise ValueError("Record '%r' is not a parent record of '%r'" % (master_record.name, slave_record.name))

        if not self._web_web_gantt_reschedule_is_relation_candidate(
                master_record, slave_record, start_date_field_name, stop_date_field_name):
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'type': 'warning',
                    'message': _('You cannot reschedule %s towards %s.',
                                 master_record.name, slave_record.name),
                }
            }

        is_master_prior_to_slave = master_record[stop_date_field_name] <= slave_record[start_date_field_name]

        # When records are in conflict, record that is moved is the other one than when there is no conflict.
        # This might seem strange at first sight but has been decided during first implementation as when in conflict,
        # and especially when the distance between the pills is big, the arrow is interpreted differently as it comes
        # from the right to the left (instead of from the left to the right).
        if is_master_prior_to_slave ^ (direction == self._WEB_GANTT_RESCHEDULE_BACKWARD):
            trigger_record = master_record
            related_record = slave_record
        else:
            trigger_record = slave_record
            related_record = master_record

        cache = self._web_gantt_reschedule_get_empty_cache()

        new_start_date, new_stop_date = trigger_record._web_gantt_reschedule_record(
            related_record, related_record == master_record,
            start_date_field_name, stop_date_field_name,
            cache
        )

        result = trigger_record._web_gantt_reschedule_write_new_dates(
            new_start_date, new_stop_date, start_date_field_name, stop_date_field_name,
        )

        sp = self.env.cr.savepoint()

        record_ids_to_exclude = defaultdict(list)

        result = result is True and trigger_record._web_gantt_action_reschedule_related_records(
            dependency_field_name, dependency_inverted_field_name,
            start_date_field_name, stop_date_field_name,
            direction,
            record_ids_to_exclude,
            cache
        )

        if result is not True:
            if result is False:
                notification_type = 'warning'
                message = _('Records that are in the past cannot be automatically rescheduled. They should be manually rescheduled instead.')
            elif result == self._WEB_GANTT_LOOP_ERROR:
                notification_type = 'info'
                message = _('You cannot reschedule tasks that do not follow a direct dependency path. '
                            'Only the first task has been automatically rescheduled.')
            else:
                raise ValueError('Unsupported result value')
            result = {
                    'type': 'ir.actions.client',
                    'tag': 'display_notification',
                    'params': {
                        'type': notification_type,
                        'message': message,
                    }
                }

        sp.close(rollback=result is not True)
        return result

    @api.model
    def gantt_progress_bar(self, fields, res_ids, date_start_str, date_stop_str):
        """ Get progress bar value per record.

            This method is meant to be overriden by each related model that want to
            implement this feature on Gantt groups. The progressbar is composed
            of a value and a max_value given for each groupedby field.

            Example:
                fields = ['foo', 'bar'],
                res_ids = {'foo': [1, 2], 'bar':[2, 3]}
                start_date = 01/01/2000, end_date = 01/07/2000,
                self = base()

            Result:
                {
                    'foo': {
                        1: {'value': 50, 'max_value': 100},
                        2: {'value': 25, 'max_value': 200},
                    },
                    'bar': {
                        2: {'value': 65, 'max_value': 85},
                        3: {'value': 30, 'max_value': 95},
                    }
                }

            :param list fields: fields on which there are progressbars
            :param dict res_ids: res_ids of related records for which we need to compute progress bar
            :param string date_start_str: start date
            :param string date_stop_str: stop date
            :returns: dict of value and max_value per record
        """
        return {}

    @api.model
    def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None):
        """ Get unavailabilities data to display in the Gantt view.

        This method is meant to be overriden by each model that want to
        implement this feature on a Gantt view. A subslot is considered
        unavailable (and greyed) when totally covered by an unavailability.

        Example:
            * start_date = 01/01/2000, end_date = 01/07/2000, scale = 'week',
              rows = [{
                groupedBy: ["project_id", "user_id", "stage_id"],
                records: [1, 4, 2],
                name: "My Awesome Project",
                resId: 8,
                rows: [{
                    groupedBy: ["user_id", "stage_id"],
                    records: [1, 2],
                    name: "Marcel",
                    resId: 18,
                    rows: [{
                        groupedBy: ["stage_id"],
                        records: [2],
                        name: "To Do",
                        resId: 3,
                        rows: []
                    }, {
                        groupedBy: ["stage_id"],
                        records: [1],
                        name: "Done",
                        resId: 9,
                        rows: []
                    }]
                }, {
                    groupedBy: ["user_id", "stage_id"],
                    records: [4],
                    name: "Gilbert",
                    resId: 22,
                    rows: [{
                        groupedBy: ["stage_id"],
                        records: [4],
                        name: "Done",
                        resId: 9,
                        rows: []
                    }]
                }]
            }, {
                groupedBy: ["project_id", "user_id", "stage_id"],
                records: [3, 5, 7],
                name: "My Other Project",
                resId: 9,
                rows: [{
                    groupedBy: ["user_id", "stage_id"],
                    records: [3, 5, 7],
                    name: "Undefined User",
                    resId: None,
                    rows: [{
                        groupedBy: ["stage_id"],
                        records: [3, 5, 7],
                        name: "To Do",
                        resId: 3,
                        rows: []
                    }]
            }, {
                groupedBy: ["project_id", "user_id", "stage_id"],
                records: [],
                name: "My group_expanded Project",
                resId: 27,
                rows: []
            }]

            * The expected return value of this function is the rows dict with
              a new 'unavailabilities' key in each row for which you want to
              display unavailabilities. Unavailablitities is a list
              (naturally ordered and pairwise disjoint) in the form:
              [{
                  start: <start date of first unavailabity in UTC format>,
                  stop: <stop date of first unavailabity in UTC format>
              }, {
                  start: <start date of second unavailabity in UTC format>,
                  stop: <stop date of second unavailabity in UTC format>
              }, ...]

              To display that Marcel is unavailable January 2 afternoon and
              January 4 the whole day in his To Do row, this particular row in
              the rows dict should look like this when returning the dict at the
              end of this function :
              { ...
                {
                    groupedBy: ["stage_id"],
                    records: [2],
                    name: "To Do",
                    resId: 3,
                    rows: []
                    unavailabilities: [{
                        'start': '2018-01-02 14:00:00',
                        'stop': '2018-01-02 18:00:00'
                    }, {
                        'start': '2018-01-04 08:00:00',
                        'stop': '2018-01-04 18:00:00'
                    }]
                }
                ...
              }



        :param datetime start_date: start date
        :param datetime stop_date: stop date
        :param string scale: among "day", "week", "month" and "year"
        :param None | list[str] group_bys: group_by fields
        :param dict rows: dict describing the current rows of the gantt view
        :returns: dict of unavailability
        """
        return rows

    def _web_gantt_action_reschedule_related_records(
        self,
        dependency_field_name, dependency_inverted_field_name,
        start_date_field_name, stop_date_field_name,
        direction,
        record_ids_to_exclude,
        cache
    ):
        """ Reschedule the related records, that is the records available in both fields dependency_field_name and
            dependency_inverted_field_name and which satisfies some conditions which are tested in
            _web_gantt_get_rescheduling_candidates

            :param dependency_field_name: The field name of the relation between the master and slave records.
            :param dependency_inverted_field_name: The field name of the relation between the slave and the parent
                   records.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :param direction: The direction of the rescheduling 'forward' or 'backward'
            :param record_ids_to_exclude: The record Ids that have to be excluded from the return candidates.
            :param cache: An object that contains reusable information in the context of gantt record rescheduling.
            :return: True if successful, False if not.
            :rtype: bool
        """
        rescheduling_candidates = self._web_gantt_get_rescheduling_candidates(
            dependency_field_name, dependency_inverted_field_name,
            start_date_field_name, stop_date_field_name,
            direction,
            record_ids_to_exclude
        )

        if rescheduling_candidates is False:
            return self._WEB_GANTT_LOOP_ERROR

        if not rescheduling_candidates:
            return True

        result = True
        records_to_propagate = self.env[self._name]
        for rescheduling_candidate in rescheduling_candidates:
            record, related_record, is_related_record_master = rescheduling_candidate

            new_start_date, new_stop_date = record._web_gantt_reschedule_record(
                related_record, is_related_record_master,
                start_date_field_name, stop_date_field_name,
                cache
            )
            record_write_result = record._web_gantt_reschedule_write_new_dates(
                new_start_date, new_stop_date,
                start_date_field_name, stop_date_field_name,
            )

            if record_write_result:
                records_to_propagate |= record
                record_ids_to_exclude[record.id] = record_ids_to_exclude[related_record.id] + [related_record.id]

            result &= record_write_result

        for record in self:
            record_ids_to_exclude.pop(record.id, None)

        related_records_result = records_to_propagate._web_gantt_action_reschedule_related_records(
            dependency_field_name, dependency_inverted_field_name,
            start_date_field_name, stop_date_field_name,
            direction,
            record_ids_to_exclude,
            cache
        )
        if isinstance(related_records_result, bool):
            result &= related_records_result
        else:
            result = related_records_result

        return result

    def _web_gantt_get_rescheduling_candidates(
        self,
        dependency_field_name, dependency_inverted_field_name,
        start_date_field_name, stop_date_field_name,
        direction,
        record_ids_to_exclude
    ):
        """ Get the current records' related records rescheduling candidates (the records that depend on them as well
            as the records they depend on) for the rescheduling process as well as their reference records (the
            furthest record that depends on it, as well as the furthest record it depends on).

            :param dependency_field_name: The field name of the relation between the master and slave records.
            :param dependency_inverted_field_name: The field name of the relation between the slave and the parent
                   records.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :param direction: The direction of the rescheduling 'forward' or 'backward'
            :param record_ids_to_exclude: The record Ids that have to be excluded from the return candidates.
            :return: a list of tuples (record, related_record, is_related_record_master)
                     where: - record is the record to be rescheduled
                            - related_record is the record that is the target of the rescheduling
                            - is_related_record_master informs whether the related_record is a record that the current
                              record depends on (so-called master) or a record that depends on the current record
                              (so-called slave)
            :rtype: tuple(AbstractModel, AbstractModel, bool)
        """
        rescheduling_forward = direction == self._WEB_GANTT_RESCHEDULE_FORWARD
        rescheduling_backward = direction == self._WEB_GANTT_RESCHEDULE_BACKWARD

        slave_per_record = defaultdict(lambda: self.env[self._name])
        master_per_record = defaultdict(lambda: self.env[self._name])
        records_to_reschedule = self.env[self._name]

        # The goal is to automatically exclude ids from the `dependency_field_name` and `dependency_inverted_field_name`
        # fields but not the self.ids. And the later call on _web_gantt_reschedule_is_record_candidate will ensure that
        # the self.ids are good candidates.

        for record in self:
            if record.id in record_ids_to_exclude[record.id] \
               or not record._web_gantt_reschedule_is_record_candidate(start_date_field_name, stop_date_field_name):
                continue
            for master_record in record[dependency_field_name]:
                #
                # A      B       C      D
                #   \      \   /      /
                #     ------ F ------
                #          /   \
                #    G ---       --- H
                #
                # So if we are considering we are rescheduling F towards G then, once F is moved, B will be
                # added to the candidates as it will be assessed as being in conflict with F, but A won't.
                #
                # So if we are considering we are rescheduling F towards H then, once F is moved, A, B and G
                # will be added to the candidates as we are rescheduling forward.

                if master_record.id in record_ids_to_exclude[record.id] \
                   or not master_record._web_gantt_reschedule_is_record_candidate(
                        start_date_field_name, stop_date_field_name) \
                   or not self._web_web_gantt_reschedule_is_relation_candidate(
                        master_record, record, start_date_field_name, stop_date_field_name) \
                   or not self._web_gantt_reschedule_is_in_conflict_or_force(
                            master_record, record, start_date_field_name, stop_date_field_name, rescheduling_forward):
                    continue

                # If we have two same candidates, it means that we are resolving a `loop`
                # with an even number of members.
                if master_record in slave_per_record:
                    return False

                slave_per_record[master_record] = record
                records_to_reschedule |= master_record

            for slave_record in record[dependency_inverted_field_name]:
                #
                # A      B       C      D
                #   \      \   /      /
                #     ------ F ------
                #          /   \
                #    G ---       --- H
                #
                # So if we are considering we are rescheduling F towards H then, once F is moved, C will be
                # added to the candidates as it will be assessed as being in conflict with F, but D won't.
                #
                # So if we are considering we are rescheduling F towards G then C, once F is moved, D and H
                # will be added to the candidates as we are rescheduling backward.

                if slave_record.id in record_ids_to_exclude[record.id] \
                   or not slave_record._web_gantt_reschedule_is_record_candidate(
                        start_date_field_name, stop_date_field_name) \
                   or not self._web_web_gantt_reschedule_is_relation_candidate(
                        record, slave_record, start_date_field_name, stop_date_field_name) \
                   or not self._web_gantt_reschedule_is_in_conflict_or_force(
                            record, slave_record, start_date_field_name, stop_date_field_name, rescheduling_backward):
                    continue

                # If we have two same candidates, it means that we are resolving a `loop`
                # with an even number of members.
                if slave_record in master_per_record:
                    return False

                master_per_record[slave_record] = record
                records_to_reschedule |= slave_record

        # If we have a record that is both a slave and a master candidate, it means that we are resolving a `loop`
        # with an even number of members.
        if set.intersection(set(slave_per_record.keys()), set(master_per_record.keys())):
            if set.intersection(*map(set, [record_ids_to_exclude[rec.id]for rec in self])):
                return False

        # If we have a record from self that is a slave candidate and a record from self that is a master candidate,
        # it means that we are resolving a loop with an odd number of members.
        if any(record in slave_per_record.keys() for record in self) and \
           any(record in master_per_record.keys() for record in self):
            return False

        return [
            (record_to_reschedule,
             slave_per_record[record_to_reschedule] or master_per_record[record_to_reschedule],
             bool(master_per_record[record_to_reschedule])
             ) for record_to_reschedule in records_to_reschedule
        ]

    def _web_gantt_reschedule_compute_dates(
        self, date_candidate, search_forward, start_date_field_name, stop_date_field_name, cache
    ):
        """ Compute start_date and end_date according to the provided arguments.
            This method is meant to be overridden when we need to add constraints that have to be taken into account
            in the computing of the start_date and end_date.

            :param date_candidate: The optimal date, which does not take any constraint into account.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :param cache: An object that contains reusable information in the context of gantt record rescheduling.
            :return: a tuple of (start_date, end_date)
            :rtype: tuple(datetime, datetime)
        """
        search_factor = (1 if search_forward else -1)
        duration = search_factor * (self[stop_date_field_name] - self[start_date_field_name])
        return sorted([date_candidate, date_candidate + duration])

    @api.model
    def _web_gantt_reschedule_get_empty_cache(self):
        """ Get an empty object that would be used in order to prevent successive database calls during the
            rescheduling process.

            :return: An object that contains reusable information in the context of gantt record rescheduling.
            :rtype: dict
        """
        return {}

    @api.model
    def _web_gantt_reschedule_is_in_conflict(self, master, slave, start_date_field_name, stop_date_field_name):
        """ Get whether the dependency relation between a master and a slave record is in conflict.
            This check is By-passed for slave records if moving records forwards and the for
            master records if moving records backwards (see _web_gantt_get_rescheduling_candidates and
            _web_gantt_reschedule_is_in_conflict_or_force). In order to add condition that would not be
            by-passed, rather consider _web_gantt_reschedule_is_relation_candidate.

            :param master: The master record.
            :param slave: The slave record.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :return: True if there is a conflict, False if not.
            :rtype: bool
        """
        return master[stop_date_field_name] > slave[start_date_field_name]

    @api.model
    def _web_gantt_reschedule_is_in_conflict_or_force(
            self, master, slave, start_date_field_name, stop_date_field_name, force
    ):
        """ Get whether the dependency relation between a master and a slave record is in conflict.
            This check is By-passed for slave records if moving records forwards and the for
            master records if moving records backwards. In order to add condition that would not be
            by-passed, rather consider _web_gantt_reschedule_is_relation_candidate.

            This def purpose is to be able to prevent the default behavior in some modules by overriding
            the def and forcing / preventing the rescheduling il all circumstances if needed.
            See _web_gantt_get_rescheduling_candidates.

            :param master: The master record.
            :param slave: The slave record.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :param force: Force returning True
            :return: True if there is a conflict, False if not.
            :rtype: bool
        """
        return force or self._web_gantt_reschedule_is_in_conflict(
            master, slave, start_date_field_name, stop_date_field_name
        )

    def _web_gantt_reschedule_is_record_candidate(self, start_date_field_name, stop_date_field_name):
        """ Get whether the record is a candidate for the rescheduling. This method is meant to be overridden when
            we need to add a constraint in order to prevent some records to be rescheduled. This method focuses on the
            record itself (if you need to have information on the relation (master and slave) rather override
            _web_gantt_reschedule_is_relation_candidate).

            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :return: True if record can be rescheduled, False if not.
            :rtype: bool
        """
        self.ensure_one()
        return self[start_date_field_name] and self[stop_date_field_name] \
            and self[start_date_field_name].replace(tzinfo=timezone.utc) > datetime.now(timezone.utc)

    @api.model
    def _web_web_gantt_reschedule_is_relation_candidate(self, master, slave, start_date_field_name, stop_date_field_name):
        """ Get whether the relation between master and slave is a candidate for the rescheduling. This method is meant
            to be overridden when we need to add a constraint in order to prevent some records to be rescheduled.
            This method focuses on the relation between records (if your logic is rather on one record, rather override
            _web_gantt_reschedule_is_record_candidate).

            :param master: The master record we need to evaluate whether it is a candidate for rescheduling or not.
            :param slave: The slave record.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :return: True if record can be rescheduled, False if not.
            :rtype: bool
        """
        return True

    def _web_gantt_reschedule_record(
        self, related_record, is_related_record_master, start_date_field_name, stop_date_field_name, cache
    ):
        """ Shift the record in the future or the past according to the passed arguments.

            :param related_record: The related record (either the master or slave record).
            :param is_related_record_master: Tells whether the related record is the master or slave in the dependency.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :param cache: An object that contains reusable information in the context of gantt record rescheduling.
            :return: a tuple of (start_date, end_date)
            :rtype: tuple(datetime, datetime)
        """
        self.ensure_one()
        # If the related_record is the master, then we look for a date after the value of its stop_date_field_name.
        # If the related_record is the slave, then we look for a date prior to the value of its start_date_field_name.
        search_forward = is_related_record_master
        if search_forward:
            date_candidate = related_record[stop_date_field_name].replace(tzinfo=timezone.utc)
        else:
            date_candidate = related_record[start_date_field_name].replace(tzinfo=timezone.utc)

        return self.sudo()._web_gantt_reschedule_compute_dates(
            date_candidate,
            search_forward,
            start_date_field_name, stop_date_field_name,
            cache
        )

    def _web_gantt_reschedule_write_new_dates(
        self, new_start_date, new_stop_date, start_date_field_name, stop_date_field_name
    ):
        """ Write the dates values if new_start_date is in the future.

            :param new_start_date: The start_date to write.
            :param new_stop_date: The stop_date to write.
            :param start_date_field_name: The start date field used in the gantt view.
            :param stop_date_field_name: The stop date field used in the gantt view.
            :return: True if successful, False if not.
            :rtype: bool
        """
        if new_start_date < datetime.now(timezone.utc):
            return False

        self.write({
            start_date_field_name: new_start_date.astimezone(timezone.utc).replace(tzinfo=None),
            stop_date_field_name: new_stop_date.astimezone(timezone.utc).replace(tzinfo=None)
        })
        return True
