# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime
from collections import defaultdict, deque
from functools import reduce
from heapq import heappop, heappush

from trytond.model import ModelSQL, fields, tree
from trytond.pool import PoolMeta
from trytond.pyson import Eval
from trytond.wizard import StateTransition, Wizard


def intfloor(x):
    return int(round(x, 4))


class Work(tree(parent='successors'), metaclass=PoolMeta):
    __name__ = 'project.work'
    predecessors = fields.Many2Many('project.predecessor_successor',
        'successor', 'predecessor', 'Predecessors',
        domain=[
            ('parent', '=', Eval('parent', -1)),
            ('id', '!=', Eval('id', -1)),
            ])
    successors = fields.Many2Many('project.predecessor_successor',
        'predecessor', 'successor', 'Successors',
        domain=[
            ('parent', '=', Eval('parent', -1)),
            ('id', '!=', Eval('id', -1)),
            ])
    leveling_delay = fields.Float("Leveling Delay", required=True)
    back_leveling_delay = fields.Float("Back Leveling Delay", required=True)
    allocations = fields.One2Many('project.allocation', 'work', 'Allocations',
        states={
            'invisible': Eval('type') != 'task',
            })
    duration = fields.Function(
        fields.TimeDelta('Duration', 'company_work_time'),
        'get_function_fields')
    early_start_time = fields.DateTime("Early Start Time", readonly=True)
    late_start_time = fields.DateTime("Late Start Time", readonly=True)
    early_finish_time = fields.DateTime("Early Finish Time", readonly=True)
    late_finish_time = fields.DateTime("Late Finish Time", readonly=True)
    actual_start_time = fields.DateTime("Actual Start Time")
    actual_finish_time = fields.DateTime("Actual Finish Time")
    constraint_start_time = fields.DateTime("Constraint Start Time")
    constraint_finish_time = fields.DateTime("Constraint Finish Time")
    early_start_date = fields.Function(fields.Date('Early Start'),
            'get_function_fields')
    late_start_date = fields.Function(fields.Date('Late Start'),
            'get_function_fields')
    early_finish_date = fields.Function(fields.Date('Early Finish'),
            'get_function_fields')
    late_finish_date = fields.Function(fields.Date('Late Finish'),
            'get_function_fields')
    actual_start_date = fields.Function(fields.Date('Actual Start'),
            'get_function_fields', setter='set_function_fields')
    actual_finish_date = fields.Function(fields.Date('Actual Finish'),
            'get_function_fields', setter='set_function_fields')
    constraint_start_date = fields.Function(fields.Date('Constraint Start',
        depends={'type'}), 'get_function_fields',
        setter='set_function_fields')
    constraint_finish_date = fields.Function(fields.Date('Constraint Finish',
        depends={'type'}), 'get_function_fields',
        setter='set_function_fields')

    @classmethod
    def __setup__(cls):
        super().__setup__()

    @staticmethod
    def default_leveling_delay():
        return 0.0

    @staticmethod
    def default_back_leveling_delay():
        return 0.0

    @classmethod
    def get_function_fields(cls, works, names):
        '''
        Function to compute function fields
        '''
        res = {}

        ids = [w.id for w in works]
        if 'duration' in names:
            all_works = cls.search([
                    ('parent', 'child_of', ids),
                    ])
            all_works = set(all_works)

            durations = {}
            id2work = {}
            leafs = set()
            for work in all_works:
                id2work[work.id] = work
                if not work.children:
                    leafs.add(work.id)

                total_allocation = 0
                effort = work.effort_duration or datetime.timedelta()
                if not work.allocations:
                    durations[work.id] = effort
                    continue
                for allocation in work.allocations:
                    total_allocation += allocation.percentage
                durations[work.id] = datetime.timedelta(
                    seconds=effort.total_seconds()
                    / (total_allocation / 100.0))

            while leafs:
                for work_id in leafs:
                    work = id2work[work_id]
                    all_works.remove(work)
                    if work.parent and work.parent.id in durations:
                        durations[work.parent.id] += durations[work_id]
                next_leafs = set(w.id for w in all_works)
                for work in all_works:
                    if not work.parent:
                        continue
                    if work.parent.id in next_leafs and work.parent in works:
                        next_leafs.remove(work.parent.id)
                leafs = next_leafs
            res['duration'] = durations

        fun_fields = ('early_start_date', 'early_finish_date',
                      'late_start_date', 'late_finish_date',
                      'actual_start_date', 'actual_finish_date',
                      'constraint_start_date', 'constraint_finish_date')
        db_fields = ('early_start_time', 'early_finish_time',
                  'late_start_time', 'late_finish_time',
                  'actual_start_time', 'actual_finish_time',
                  'constraint_start_time', 'constraint_finish_time')

        for fun_field, db_field in zip(fun_fields, db_fields):
            if fun_field in names:
                values = {}
                for work in works:
                    values[work.id] = getattr(work, db_field) \
                        and getattr(work, db_field).date() or None
                res[fun_field] = values

        return res

    @classmethod
    def set_function_fields(cls, works, name, value):
        fun_fields = ('actual_start_date', 'actual_finish_date',
                      'constraint_start_date', 'constraint_finish_date')
        db_fields = ('actual_start_time', 'actual_finish_time',
                     'constraint_start_time', 'constraint_finish_time')
        for fun_field, db_field in zip(fun_fields, db_fields):
            if fun_field == name:
                cls.write(works, {
                        db_field: (value
                            and datetime.datetime.combine(value,
                                datetime.time())
                            or None),
                        })
                break

    @property
    def hours(self):
        if not self.duration:
            return 0
        return self.duration.total_seconds() / 60 / 60

    @classmethod
    def add_minutes(cls, company, date, minutes):
        minutes = int(round(minutes))
        minutes = date.minute + minutes

        hours = minutes // 60
        if hours:
            date = cls.add_hours(company, date, hours)

        minutes = minutes % 60

        date = datetime.datetime(
            date.year,
            date.month,
            date.day,
            date.hour,
            minutes,
            date.second)

        return date

    @classmethod
    def add_hours(cls, company, date, hours):
        while hours:
            if hours != intfloor(hours):
                minutes = (hours - intfloor(hours)) * 60
                date = cls.add_minutes(company, date, minutes)
            hours = intfloor(hours)

            hours = date.hour + hours
            days = hours // company.hours_per_work_day
            if days:
                date = cls.add_days(company, date, days)

            hours = hours % company.hours_per_work_day

            date = datetime.datetime(
                date.year,
                date.month,
                date.day,
                intfloor(hours),
                date.minute,
                date.second)

            hours = hours - intfloor(hours)

        return date

    @classmethod
    def add_days(cls, company, date, days):
        day_per_week = company.hours_per_work_week / company.hours_per_work_day

        while days:
            if days != intfloor(days):
                hours = (days - intfloor(days)) * company.hours_per_work_day
                date = cls.add_hours(company, date, hours)
            days = intfloor(days)

            days = date.weekday() + days

            weeks = days // day_per_week
            days = days % day_per_week

            if weeks:
                date = cls.add_weeks(company, date, weeks)

            date += datetime.timedelta(days=-date.weekday() + intfloor(days))

            days = days - intfloor(days)

        return date

    @classmethod
    def add_weeks(cls, company, date, weeks):
        day_per_week = company.hours_per_work_week / company.hours_per_work_day

        if weeks != intfloor(weeks):
            days = (weeks - intfloor(weeks)) * day_per_week
            if days:
                date = cls.add_days(company, date, days)

        date += datetime.timedelta(days=7 * intfloor(weeks))

        return date

    def compute_dates(self):
        values = {}

        def get_early_finish(work):
            return values.get(work, {}).get(
                'early_finish_time', work.early_finish_time)

        def get_late_start(work):
            return values.get(work, {}).get(
                'late_start_time', work.late_start_time)

        def maxdate(x, y):
            return x and y and max(x, y) or x or y

        def mindate(x, y):
            return x and y and min(x, y) or x or y

        # propagate constraint_start_time
        constraint_start = reduce(maxdate, (pred.early_finish_time
                for pred in self.predecessors), None)

        if constraint_start is None and self.parent:
            constraint_start = self.parent.early_start_time

        constraint_start = maxdate(constraint_start,
            self.constraint_start_time)

        works = deque([(self, constraint_start)])
        work2children = {}
        parent = None

        while works or parent:
            if parent:
                work = parent
                parent = None

                # Compute early_finish
                if work.children:
                    early_finish_time = reduce(
                        maxdate, map(get_early_finish, work.children), None)
                else:
                    early_finish_time = None
                    if values[work]['early_start_time']:
                        early_finish_time = self.add_hours(work.company,
                                values[work]['early_start_time'],
                                work.hours)
                values[work]['early_finish_time'] = early_finish_time

                # Propagate constraint_start on successors
                for w in work.successors:
                    works.append((w, early_finish_time))

                if not work.parent:
                    continue

                # housecleaning work2children
                if work.parent not in work2children:
                    work2children[work.parent] = set()
                work2children[work.parent].update(work.successors)

                if work in work2children[work.parent]:
                    work2children[work.parent].remove(work)

                # if no sibling continue to walk up the tree
                if not work2children.get(work.parent):
                    if work.parent not in values:
                        values[work.parent] = {}
                    parent = work.parent

                continue

            work, constraint_start = works.popleft()
            # take constraint define on the work into account
            constraint_start = maxdate(constraint_start,
                                       work.constraint_start_time)

            if constraint_start:
                early_start = self.add_hours(work.company, constraint_start,
                        work.leveling_delay)
            else:
                early_start = None

            # update values
            if work not in values:
                values[work] = {}
            values[work]['early_start_time'] = early_start

            # Loop on children if they exist
            if work.children and work not in work2children:
                work2children[work] = set(work.children)
                # Propagate constraint_start on children
                for w in work.children:
                    if w.predecessors:
                        continue
                    works.append((w, early_start))
            else:
                parent = work

        # propagate constraint_finish_time
        constraint_finish = reduce(mindate, (succ.late_start_time
                for succ in self.successors), None)

        if constraint_finish is None and self.parent:
            constraint_finish = self.parent.late_finish_time

        constraint_finish = mindate(constraint_finish,
            self.constraint_finish_time)

        works = deque([(self, constraint_finish)])
        work2children = {}
        parent = None

        while works or parent:
            if parent:
                work = parent
                parent = None

                # Compute late_start
                if work.children:
                    reduce(mindate, map(get_late_start, work.children), None)
                else:
                    late_start_time = None
                    if values[work]['late_finish_time']:
                        late_start_time = self.add_hours(work.company,
                                values[work]['late_finish_time'],
                                -work.hours)
                values[work]['late_start_time'] = late_start_time

                # Propagate constraint_finish on predecessors
                for w in work.predecessors:
                    works.append((w, late_start_time))

                if not work.parent:
                    continue

                # housecleaning work2children
                if work.parent not in work2children:
                    work2children[work.parent] = set()
                work2children[work.parent].update(work.predecessors)

                if work in work2children[work.parent]:
                    work2children[work.parent].remove(work)

                # if no sibling continue to walk up the tree
                if not work2children.get(work.parent):
                    if work.parent not in values:
                        values[work.parent] = {}
                    parent = work.parent

                continue

            work, constraint_finish = works.popleft()
            # take constraint define on the work into account
            constraint_finish = mindate(constraint_finish,
                                        work.constraint_finish_time)

            if constraint_finish:
                late_finish = self.add_hours(work.company, constraint_finish,
                        -work.back_leveling_delay)
            else:
                late_finish = None

            # update values
            if work not in values:
                values[work] = {}
            values[work]['late_finish_time'] = late_finish

            # Loop on children if they exist
            if work.children and work not in work2children:
                work2children[work] = set(work.children)
                # Propagate constraint_start on children
                for w in work.children:
                    if w.successors:
                        continue
                    works.append((w, late_finish))
            else:
                parent = work

        # write values
        write_fields = ('early_start_time', 'early_finish_time',
                        'late_start_time', 'late_finish_time')
        to_write = []
        for work, val in values.items():
            write_cond = False
            for field in write_fields:
                if field in val and getattr(work, field) != val[field]:
                    write_cond = True
                    break

            if write_cond:
                to_write.extend(([work], val))
        if to_write:
            self.write(*to_write)

    def reset_leveling(self):
        def get_key(w):
            return (
                set(p.id for p in w.predecessors),
                set(s.id for s in w.successors))

        if not self.parent:
            return
        siblings = self.search([
                ('parent', '=', self.parent.id)
                ])
        to_clean = []

        ref_key = get_key(self)
        for sibling in siblings:
            if sibling.leveling_delay == sibling.back_leveling_delay == 0:
                continue
            if get_key(sibling) == ref_key:
                to_clean.append(sibling)

        if to_clean:
            self.write(to_clean, {
                    'leveling_delay': 0,
                    'back_leveling_delay': 0,
                    })

    def create_leveling(self):
        # define some helper functions
        def get_key(w):
            return (
                set(p.id for p in w.predecessors),
                set(s.id for s in w.successors))

        def over_alloc(current_alloc, work):
            return reduce(lambda res, alloc: (
                    res
                    or (current_alloc[alloc.employee.id]
                        + alloc.percentage) > 100),
                work.allocations,
                False)

        def sum_allocs(current_alloc, work):
            res = defaultdict(float)
            for alloc in work.allocations:
                empl = alloc.employee.id
                res[empl] = current_alloc[empl] + alloc.percentage
            return res

        def compute_delays(siblings):
            # time_line is a list [[end_delay, allocations], ...], this
            # mean that allocations is valid between the preceding end_delay
            # (or 0 if it doesn't exist) and the current end_delay.
            timeline = []
            for sibling in siblings:
                delay = 0
                ignored = []
                overloaded = []
                item = None

                while timeline:
                    # item is [end_delay, allocations]
                    item = heappop(timeline)
                    if over_alloc(item[1], sibling):
                        ignored.extend(overloaded)
                        ignored.append(item)
                        delay = item[0]
                        continue
                    elif item[1] >= delay + sibling.duration:
                        overloaded.append(item)
                    else:
                        # Succes!
                        break

                heappush(timeline,
                         [delay + sibling.duration,
                          sum_allocs(defaultdict(float), sibling),
                          sibling.id])

                for i in ignored:
                    heappush(timeline, i)
                for i in overloaded:
                    i[1] = sum_allocs(i[1], sibling)
                    heappush(timeline, i)

                yield sibling, delay

        siblings = self.search([
                ('parent', '=', self.parent.id if self.parent else None)
                ])

        refkey = get_key(self)
        siblings = [s for s in siblings if get_key(s) == refkey]

        for sibling, delay in compute_delays(siblings):
            sibling.leveling_delay = delay

        siblings.reverse()
        for sibling, delay in compute_delays(siblings):
            sibling.back_leveling_delay = delay
        self.__class__.save(siblings)

        if self.parent:
            self.parent.compute_dates()

    @classmethod
    def on_modification(cls, mode, works, field_names=None):
        super().on_modification(mode, works, field_names=field_names)
        if mode in {'create', 'write'}:
            for work in works:
                if field_names is None or 'effort' in field_names:
                    work.reset_leveling()
                if field_names is None or field_names & {
                        'constraint_start_time', 'constraint_finish_time',
                        'effort'}:
                    work.compute_dates()

    @classmethod
    def on_delete(cls, works):
        callback = super().on_delete(works)
        to_update = set()
        for work in works:
            if work.parent and work.parent not in works:
                to_update.add(work.parent)
                to_update.update(
                    c for c in work.parent.children if c not in works)

        def replan():
            for work in to_update:
                work.reset_leveling()
                work.compute_dates()
        callback.append(replan)
        return callback


class PredecessorSuccessor(ModelSQL):
    __name__ = 'project.predecessor_successor'
    predecessor = fields.Many2One(
        'project.work', "Predecessor", ondelete='CASCADE', required=True)
    successor = fields.Many2One(
        'project.work', "Successor", ondelete='CASCADE', required=True)

    @classmethod
    def on_modification(cls, mode, records, field_names=None):
        super().on_modification(mode, records, field_names=field_names)
        if mode == 'create':
            if field_names is None or field_names & {
                    'predecessor', 'successor', 'parent'}:
                for record in records:
                    record.predecessor.reset_leveling()
                    record.successor.reset_leveling()
                    if record.predecessor.parent:
                        record.predecessor.parent.compute_dates()

    @classmethod
    def on_delete(cls, records):
        callback = super().on_delete(records)
        works = set()
        parents = set()
        for record in records:
            works.add(record.predecessor)
            works.add(record.successor)
            if record.predecessor.parent:
                parents.add(record.predecessor.parent)

        def replan():
            for work in works:
                work.reset_leveling()
            for parent in parents:
                parent.compute_dates()
        callback.append(replan)
        return callback


class Leveling(Wizard):
    __name__ = 'project_plan.work.leveling'
    start_state = 'leveling'
    leveling = StateTransition()

    def transition_leveling(self):
        self.record.create_leveling()
        return 'end'
