# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

import datetime
import logging
import math
import threading
import random

from ast import literal_eval

from odoo import api, exceptions, fields, models, _
from odoo.osv import expression

_logger = logging.getLogger(__name__)


class TeamMember(models.Model):
    _inherit = 'crm.team.member'

    # assignment
    assignment_enabled = fields.Boolean(related="crm_team_id.assignment_enabled")
    assignment_domain = fields.Char('Assignment Domain', tracking=True)
    assignment_optout = fields.Boolean('Skip auto assignment')
    assignment_max = fields.Integer('Average Leads Capacity (on 30 days)', default=30)
    lead_month_count = fields.Integer(
        'Leads (30 days)', compute='_compute_lead_month_count',
        help='Lead assigned to this member those last 30 days')

    @api.depends('user_id', 'crm_team_id')
    def _compute_lead_month_count(self):
        for member in self:
            if member.user_id.id and member.crm_team_id.id:
                member.lead_month_count = self.env['crm.lead'].with_context(active_test=False).search_count(
                    member._get_lead_month_domain()
                )
            else:
                member.lead_month_count = 0

    @api.constrains('assignment_domain')
    def _constrains_assignment_domain(self):
        for member in self:
            try:
                domain = literal_eval(member.assignment_domain or '[]')
                if domain:
                    self.env['crm.lead'].search(domain, limit=1)
            except Exception:
                raise exceptions.ValidationError(_(
                    'Member assignment domain for user %(user)s and team %(team)s is incorrectly formatted',
                    user=member.user_id.name, team=member.crm_team_id.name
                ))

    def _get_lead_month_domain(self):
        limit_date = fields.Datetime.now() - datetime.timedelta(days=30)
        return [
            ('user_id', '=', self.user_id.id),
            ('team_id', '=', self.crm_team_id.id),
            ('date_open', '>=', limit_date),
        ]

    # ------------------------------------------------------------
    # LEAD ASSIGNMENT
    # ------------------------------------------------------------

    def _assign_and_convert_leads(self, work_days=1):
        """ Main processing method to assign leads to sales team members. It also
        converts them into opportunities. This method should be called after
        ``_allocate_leads`` as this method assigns leads already allocated to
        the member's team. Its main purpose is therefore to distribute team
        workload on its members based on their capacity.

        Preparation

          * prepare lead domain for each member. It is done using a logical
            AND with team's domain and member's domain. Member domains further
            restricts team domain;
          * prepare a set of available leads for each member by searching for
            leads matching domain with a sufficient limit to ensure all members
            will receive leads;
          * prepare a weighted population sample. Population are members that
            should received leads. Initial weight is the number of leads to
            assign to that specific member. This is minimum value between
            * remaining this month: assignment_max - number of lead already
              assigned this month;
            * days-based assignment: assignment_max with a ratio based on
              ``work_days`` parameter (see ``CrmTeam.action_assign_leads()``)
            * e.g. Michel Poilvache (max: 30 - currently assigned: 15) limit
              for 2 work days: min(30-15, 30/15) -> 2 leads assigned
            * e.g. Michel Tartopoil (max: 30 - currently assigned: 26) limit
              for 10 work days: min(30-26, 30/3) -> 4 leads assigned

        This method then follows the following heuristic

          * take a weighted random choice in population;
          * find first available (not yet assigned) lead in its lead set;
          * if found:
            * convert it into an opportunity and assign member as salesperson;
            * lessen member's weight so that other members have an higher
              probability of being picked up next;
          * if not found: consider this member is out of assignment process,
            remove it from population so that it is not picked up anymore;

        Assignment is performed one lead at a time for fairness purpose. Indeed
        members may have overlapping domains within a given team. To ensure
        some fairness in process once a member receives a lead, a new choice is
        performed with updated weights. This is not optimal from performance
        point of view but increases probability leads are correctly distributed
        within the team.

        :param float work_days: see ``CrmTeam.action_assign_leads()``;

        :return members_data: dict() with each member assignment result:
          membership: {
            'assigned': set of lead IDs directly assigned to the member;
          }, ...

        """
        if work_days < 0.2 or work_days > 30:
            raise ValueError(
                _('Leads team allocation should be done for at least 0.2 or maximum 30 work days, not %.2f.', work_days)
            )

        members_data, population, weights = dict(), list(), list()
        members = self.filtered(lambda member: not member.assignment_optout and member.assignment_max > 0)
        if not members:
            return members_data

        # prepare a global lead count based on total leads to assign to salespersons
        lead_limit = sum(
            member._get_assignment_quota(work_days=work_days)
            for member in members
        )

        # could probably be optimized
        for member in members:
            lead_domain = expression.AND([
                literal_eval(member.assignment_domain or '[]'),
                ['&', '&', ('user_id', '=', False), ('date_open', '=', False), ('team_id', '=', member.crm_team_id.id)]
            ])

            leads = self.env["crm.lead"].search(lead_domain, order='probability DESC, id', limit=lead_limit)

            to_assign = member._get_assignment_quota(work_days=work_days)
            members_data[member.id] = {
                "team_member": member,
                "max": member.assignment_max,
                "to_assign": to_assign,
                "leads": leads,
                "assigned": self.env["crm.lead"],
            }
            population.append(member.id)
            weights.append(to_assign)

        leads_done_ids = set()
        counter = 0
        # auto-commit except in testing mode
        auto_commit = not getattr(threading.current_thread(), 'testing', False)
        commit_bundle_size = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.commit.bundle', 100))
        while population and any(weights):
            counter += 1
            member_id = random.choices(population, weights=weights, k=1)[0]
            member_index = population.index(member_id)
            member_data = members_data[member_id]

            lead = next((lead for lead in member_data['leads'] if lead.id not in leads_done_ids), False)
            if lead:
                leads_done_ids.add(lead.id)
                members_data[member_id]["assigned"] += lead
                weights[member_index] = weights[member_index] - 1

                lead.with_context(mail_auto_subscribe_no_notify=True).convert_opportunity(
                    lead.partner_id,
                    user_ids=member_data['team_member'].user_id.ids
                )

                if auto_commit and counter % commit_bundle_size == 0:
                    self._cr.commit()
            else:
                weights[member_index] = 0

            if weights[member_index] <= 0:
                population.pop(member_index)
                weights.pop(member_index)

            # failsafe
            if counter > 100000:
                population = list()

        if auto_commit:
            self._cr.commit()
        # log results and return
        result_data = dict(
            (member_info["team_member"], {"assigned": member_info["assigned"]})
            for member_id, member_info in members_data.items()
        )
        _logger.info('Assigned %s leads to %s salesmen', len(leads_done_ids), len(members))
        for member, member_info in result_data.items():
            _logger.info('-> member %s: assigned %d leads (%s)', member.id, len(member_info["assigned"]), member_info["assigned"])
        return result_data

    def _get_assignment_quota(self, work_days=1):
        """ Compute assignment quota based on work_days. This quota includes
        a compensation to speedup getting to the lead average (``assignment_max``).
        As this field is a counter for "30 days" -> divide by requested work
        days in order to have base assign number then add compensation.

        :param float work_days: see ``CrmTeam.action_assign_leads()``;
        """
        assign_ratio = work_days / 30.0
        to_assign = self.assignment_max * assign_ratio
        compensation = max(0, self.assignment_max - (self.lead_month_count + to_assign)) * 0.2
        return round(to_assign + compensation)
