import json
import time
import logging
from collections import defaultdict

from constance import config
from django.db.models import Q, F
from django.utils import timezone

from intranet.femida.src.core.controllers import (
    copy_model_instance_from_many,
)
from intranet.femida.src.candidates import models
from intranet.femida.src.candidates.choices import (
    CANDIDATE_RESPONSIBLE_ROLES,
    CANDIDATE_STATUSES,
    CONSIDERATION_EXTENDED_STATUSES,
    CONTACT_SOURCES,
    DUPLICATION_CASE_STATUSES,
    DUPLICATION_CASE_RESOLUTIONS,
)
from intranet.femida.src.candidates.controllers import CandidateCostHistory
from intranet.femida.src.candidates.deduplication.exceptions import MergeConflictError
from intranet.femida.src.candidates.helpers import CandidatePolyglot
from intranet.femida.src.candidates.tasks import (
    actualize_reference_issues,
    send_challenges_onedayoffer_event_task,
)
from intranet.femida.src.communications.models import Message
from intranet.femida.src.applications.models import Application
from intranet.femida.src.core.signals import post_update, post_bulk_create
from intranet.femida.src.hire_orders.choices import HIRE_ORDER_STATUSES
from intranet.femida.src.hire_orders.models import HireOrder
from intranet.femida.src.interviews.models import Interview, InterviewRound
from intranet.femida.src.monitoring.utils import alarm
from intranet.femida.src.offers.models import Offer
from intranet.femida.src.permissions.helpers import get_manager

logger = logging.getLogger(__name__)


def log_merge_stage(func):
    def wrapper(self, *args, **kwargs):
        start = time.time()
        result = func(self, *args, **kwargs)
        logger.info(
            '[CANDIDATE MERGE] candidates: %s, method: %s, time: %s',
            self.cand_ids, func.__name__, time.time() - start,
        )
        return result
    return wrapper


class CandidateMerger:

    def __init__(self, candidates, extra_fields=None):
        """
        :param candidates: Список Candidate для слияния. Результатом merge станет новый кандидат
        :param extra_fields: Если какое-то поле есть в этом списке, его значение
                             и будет использовано при слиянии
        """
        assert len(candidates)
        self.candidates = candidates
        self.cand_ids = [c.id for c in self.candidates]
        self.extra_fields = extra_fields or {}
        self.split_extra_fields()
        self.new_consideration = None

    def split_extra_fields(self):
        # Из формы приходят Skill, Tag, Attachment и City
        # а нам нужны CandidateSkill, CandidateTag, CandidateAttachment и CandidateCity
        _map = {
            'skills': (models.CandidateSkill, 'skill'),
            'attachments': (models.CandidateAttachment, 'attachment'),
            'target_cities': (models.CandidateCity, 'city'),
            'tags': (models.CandidateTag, 'tag'),
            'responsibles': (models.CandidateResponsible, 'user')
        }
        for field_name, (model_class, model_field_name) in _map.items():
            if field_name in self.extra_fields:
                self.extra_fields[field_name] = (
                    get_manager(model_class, unsafe=True).filter(**{
                        '{}__in'.format(model_field_name): self.extra_fields.get(field_name, []),
                        'candidate__in': self.cand_ids,
                    })
                )

        self.extra_candidate_fields = {}
        self.extra_nested_fields = {}
        candidate_field_names = [f.name for f in models.Candidate._meta.fields]
        for key, value in self.extra_fields.items():
            if key in candidate_field_names:
                self.extra_candidate_fields[key] = value
            else:
                self.extra_nested_fields[key] = value

    @log_merge_stage
    def merge(self):
        self._merge_candidates()

        self._copy_responsibles()
        self._copy_contacts()
        self._copy_educations()
        self._copy_jobs()
        self._copy_attachments()
        self._copy_skills()
        self._copy_professions()
        self._copy_cities()
        self._copy_tags()
        self._copy_language_tags()
        self._copy_costs()

        self._move_messages()
        self._move_offers()
        self._move_submissions()
        self._move_verifications()
        self._move_hire_orders()

        # Переносим рассмотрения и связанные сущности
        self._move_considerations()
        self._move_applications()
        self._move_challenges()
        self._move_interviews()

        self._update_duplication_cases()

        logger.debug(
            'MERGE SUCCESS: %s -> %d',
            ', '.join(str(c.id) for c in self.candidates),
            self.new_candidate.id,
        )
        return self.new_candidate

    def _get_new_candidate_status(self):
        statuses = [c.status for c in self.candidates]
        for status in statuses:
            if status == CANDIDATE_STATUSES.in_progress:
                return status
        return CANDIDATE_STATUSES.closed

    @log_merge_stage
    def _merge_candidates(self):
        self.new_candidate = copy_model_instance_from_many(self.candidates)
        self._set_last_mailing_agreement()
        self.extra_candidate_fields['created'] = min(c.created for c in self.candidates)
        self.extra_candidate_fields['ah_modified_at'] = max(
            (c.ah_modified_at for c in self.candidates if c.ah_modified_at),
            default=None,
        )
        self.extra_candidate_fields['status'] = self._get_new_candidate_status()
        self.extra_candidate_fields['original'] = None
        self.extra_candidate_fields['beamery_id'] = None

        # FEMIDA-4914: Мы избавились от рейтинга,
        # поэтому на новых кандидатов рейтинг не переносим
        self.extra_candidate_fields['talent_pool_rate'] = None

        for attr_name, attr_value in self.extra_candidate_fields.items():
            setattr(self.new_candidate, attr_name, attr_value)
        self.new_candidate.save()

        for candidate in self.candidates:
            candidate.is_duplicate = True
            candidate.original = self.new_candidate
            candidate.save()

        previous_duplicates = models.Candidate.unsafe.filter(original_id__in=self.cand_ids)
        for candidate in previous_duplicates:
            candidate.original = self.new_candidate
            candidate.save()

    def _set_last_mailing_agreement(self):
        for candidate in sorted(self.candidates, key=lambda x: x.modified, reverse=True):
            if candidate.vacancies_mailing_agreement is not None:
                self.new_candidate.vacancies_mailing_agreement = candidate.vacancies_mailing_agreement
                return

    def _copy_nested_collection(self, model_class, collection_name, key=None):
        """
        :param model_class: Класс модели, которая копируется
        :param colection_name: Название коллекции,
            по которому его искать в extra_nested_fields
        :param key: функция, получающая ключ,
            по которому мы получаем уникальные записи коллекции
        :return:
        """
        if key is None:
            key = lambda x: x.id

        if collection_name in self.extra_nested_fields:
            collection = self.extra_nested_fields[collection_name]
        else:
            collection = (
                get_manager(model_class, unsafe=True)
                .filter(candidate_id__in=self.cand_ids)
                .order_by('-id')
            )

        unique_map = defaultdict(list)
        for instance in collection:
            unique_map[key(instance)].append(instance)

        for conflict_instances in unique_map.values():
            new_instance = copy_model_instance_from_many(conflict_instances)
            new_instance.candidate = self.new_candidate
            new_instance.save()

    def _move_nested_collection(self, model_class, extra_update_params=None, **extra_query_params):
        extra_update_params = extra_update_params or {}

        manager = get_manager(model_class, unsafe=True)
        queryset = manager.filter(candidate_id__in=self.cand_ids, **extra_query_params)
        queryset_ids = list(queryset.values_list('id', flat=True))

        if queryset_ids:
            queryset.update(candidate_id=self.new_candidate.id, **extra_update_params)
            post_update.send(
                sender=model_class,
                queryset=manager.filter(id__in=queryset_ids),
            )

    @log_merge_stage
    def _copy_responsibles(self):
        collection = (
            models.CandidateResponsible.objects
            .filter(candidate__in=self.cand_ids)
            .select_related('user')
        )

        main_recruiter = self.extra_nested_fields.pop('main_recruiter', None)
        if not main_recruiter:
            for item in collection:
                if item.role == CANDIDATE_RESPONSIBLE_ROLES.main_recruiter:
                    main_recruiter = item.user
                    break

        recruiters = {item.user for item in collection} - {main_recruiter}
        responsibles = [
            models.CandidateResponsible(
                user=user,
                candidate=self.new_candidate,
                role=CANDIDATE_RESPONSIBLE_ROLES.recruiter,
            ) for user in recruiters
        ]
        if main_recruiter:
            responsibles.append(
                models.CandidateResponsible(
                    user=main_recruiter,
                    candidate=self.new_candidate,
                    role=CANDIDATE_RESPONSIBLE_ROLES.main_recruiter,
                )
            )
        self.main_recruiter_id = main_recruiter.id if main_recruiter else None
        responsibles = models.CandidateResponsible.objects.bulk_create(responsibles)
        post_bulk_create.send(
            sender=models.CandidateResponsible,
            queryset=responsibles,
        )

    @log_merge_stage
    def _copy_contacts(self):
        contacts_qs = models.CandidateContact.objects.filter(candidate_id__in=self.cand_ids)

        if 'contacts' in self.extra_nested_fields:
            contacts = set(self.extra_nested_fields['contacts'])
            # Всегда включаем контакты из Staff, так как неактивные контакты нам надо хранить тоже
            contacts |= set(contacts_qs.filter(source=CONTACT_SOURCES.staff))
        else:
            contacts = contacts_qs

        contacts_map = defaultdict(list)
        for contact in contacts:
            key = (contact.type, contact.account_id)
            contacts_map[key].append(contact)

        for conflict_contacts in contacts_map.values():
            is_main = False
            is_active = False
            source = None
            for c in conflict_contacts:
                if source is None:
                    source = c.source
                if c.is_main:
                    is_main = True
                if c.is_active:
                    is_active = True
                    source = c.source

            new_contact = copy_model_instance_from_many(conflict_contacts)
            new_contact.candidate = self.new_candidate
            new_contact.is_main = is_main
            new_contact.is_active = is_active
            new_contact.source = source
            new_contact.save()

    @log_merge_stage
    def _copy_educations(self):
        self._copy_nested_collection(models.CandidateEducation, 'educations')

    @log_merge_stage
    def _copy_jobs(self):
        self._copy_nested_collection(models.CandidateJob, 'jobs')

    @log_merge_stage
    def _copy_attachments(self):
        self._copy_nested_collection(models.CandidateAttachment, 'attachments')

    @log_merge_stage
    def _copy_skills(self):
        self._copy_nested_collection(
            model_class=models.CandidateSkill,
            collection_name='skills',
            key=lambda x: x.skill.id,
        )

    @log_merge_stage
    def _copy_professions(self):
        self._copy_nested_collection(
            model_class=models.CandidateProfession,
            collection_name='candidate_professions',
            key=lambda x: x.profession.id,
        )

    @log_merge_stage
    def _copy_cities(self):
        self._copy_nested_collection(
            model_class=models.CandidateCity,
            collection_name='target_cities',
            key=lambda x: x.city_id,
        )

    @log_merge_stage
    def _copy_tags(self):
        self._copy_nested_collection(
            model_class=models.CandidateTag,
            collection_name='tags',
            key=lambda x: x.tag_id,
        )

    @log_merge_stage
    def _copy_language_tags(self):
        # TODO: придумать более умный мердж main_language.
        #       Пока считаем, что самый обновленный - самый родной
        #       Остальные отправим в spoken_languages, чтобы не потерять
        main_candidate_languages = list(models.CandidateLanguageTag.objects.filter(
            candidate_id__in=self.cand_ids,
            is_main=True,
        ).order_by('-modified').select_related('tag'))
        main_language = main_candidate_languages.pop(0).tag if main_candidate_languages else None

        spoken_candidates_tags = list(models.CandidateLanguageTag.objects.filter(
            candidate_id__in=self.cand_ids,
            is_main=False,
        ).select_related('tag'))

        polyglot = CandidatePolyglot(self.new_candidate)
        polyglot.update_known_languages(
            main_language=main_language,
            spoken_languages=[x.tag for x in spoken_candidates_tags + main_candidate_languages]
        )

    @log_merge_stage
    def _copy_costs(self):
        history = CandidateCostHistory(self.new_candidate)
        history.copy_from_candidates(self.candidates)

    @log_merge_stage
    def _move_messages(self):
        self._move_nested_collection(Message)

    def _get_max_consideration(self, considerations):
        weights = {s: i for i, s in enumerate(c[0] for c in CONSIDERATION_EXTENDED_STATUSES)}
        return max(considerations, key=lambda x: weights[x.extended_status])

    @log_merge_stage
    def _move_considerations(self):
        qs = models.Consideration.unsafe.filter(candidate_id__in=self.cand_ids, is_last=True)
        old_last_consideration_ids = list(qs.values_list('id', flat=True))
        qs.update(is_last=False)
        post_update.send(
            sender=models.Consideration,
            queryset=models.Consideration.unsafe.filter(id__in=old_last_consideration_ids),
        )
        # Все архивные рассмотрения просто переносим в нового кандидата
        self._move_nested_collection(
            model_class=models.Consideration,
            state=models.Consideration.STATES.archived,
        )

        # Среди рассмотрений "в процессе" оставляем одно с самым поздним статусом.
        active_considerations = list(
            models.Consideration.unsafe
            .filter(candidate_id__in=self.cand_ids)
            .exclude(state=models.Consideration.STATES.archived)
            .order_by('modified')
        )
        if not active_considerations:
            last_consideration = self.new_candidate.considerations.order_by('finished').last()
            if last_consideration:
                last_consideration.is_last = True
                last_consideration.save(update_fields=['is_last'])
            return

        update_fields = ['candidate', 'is_last', 'started', 'is_rotation']
        self.new_consideration = self._get_max_consideration(active_considerations)
        self.new_consideration.candidate = self.new_candidate

        is_rotation = False
        rotation_ids = []
        cons_with_rotations_ids = []
        started = active_considerations[0].started
        for cons in active_considerations:
            if cons.is_rotation:
                is_rotation = True
            if cons.rotation_id:
                rotation_ids.append(cons.rotation_id)
                cons_with_rotations_ids.append(cons.id)
            if cons.started < started:
                started = cons.started

        if rotation_ids:
            update_fields.append('rotation_id')
            self.new_consideration.rotation_id = rotation_ids[-1]
            # Note: у рассмотрения с ротацией связь 1-1,
            # поэтому очищаем ротацию на старых рассмотрениях
            considerations = models.Consideration.unsafe.filter(id__in=cons_with_rotations_ids)
            considerations.update(rotation_id=None)
            post_update.send(
                sender=models.Consideration,
                queryset=considerations,
            )
            if len(rotation_ids) > 1:
                # Note: такого по процессу происходить не должно. Это подстраховка
                alarm(
                    message=f'Multiple rotations for candidate {self.new_candidate.id} after merge',
                    data=[
                        {'consideration_id': c_id, 'rotation_id': r_id}
                        for c_id, r_id in zip(cons_with_rotations_ids, rotation_ids)
                    ],
                    on_transaction_commit=True,
                )

        self.new_consideration.is_last = True
        self.new_consideration.started = started
        self.new_consideration.is_rotation = is_rotation
        self.new_consideration.save(update_fields=update_fields)

        if self.new_candidate.status == CANDIDATE_STATUSES.in_progress:
            actualize_reference_issues.delay(self.new_candidate.id, self.main_recruiter_id)

    def _move_consideration_related_items(self, model_class):
        extra_update_params = {}
        if self.new_consideration:
            extra_update_params['consideration_id'] = self.new_consideration.id

        # Переносим сущности связанные с архивными рассмотрениями
        self._move_nested_collection(
            model_class=model_class,
            consideration__state=models.Consideration.STATES.archived,
        )

        # Переносим все остальные в нового кандидата и в новое рассмотрение
        self._move_nested_collection(
            model_class=model_class,
            extra_update_params=extra_update_params,
        )

    @log_merge_stage
    def _move_applications(self):
        self._move_consideration_related_items(Application)

    @log_merge_stage
    def _move_challenges(self):
        if self.new_consideration:
            challenge_ids = list(
                models.Challenge.objects
                .onedayoffer()
                .filter(
                    candidate_id__in=self.cand_ids,
                    consideration__state=models.Consideration.STATES.in_progress,
                )
                .exclude(
                    consideration__extended_status=self.new_consideration.extended_status,
                )
                .values_list('id', flat=True)
            )
        else:
            challenge_ids = []
        self._move_consideration_related_items(models.Challenge)
        if challenge_ids:
            send_challenges_onedayoffer_event_task.delay(
                challenge_ids,
                self.new_consideration.extended_status,
            )

    @log_merge_stage
    def _move_interviews(self):
        self._move_consideration_related_items(InterviewRound)
        self._move_consideration_related_items(Interview)

    @log_merge_stage
    def _move_offers(self):
        self._move_nested_collection(Offer)

    @log_merge_stage
    def _move_submissions(self):
        self._move_nested_collection(models.CandidateSubmission)

    @log_merge_stage
    def _move_verifications(self):
        self._move_nested_collection(models.Verification)

    @log_merge_stage
    def _move_hire_orders(self):
        active_hire_orders_count = (
            HireOrder.objects
            .exclude(status=HIRE_ORDER_STATUSES.closed)
            .filter(candidate__in=self.cand_ids)
            .count()
        )
        if active_hire_orders_count > 1:
            raise MergeConflictError('multiple_active_hire_orders')
        elif active_hire_orders_count > 0:
            if 'candidate_merge' in json.loads(config.AUTOHIRE_FORBIDDEN_ACTIONS):
                raise MergeConflictError('forbidden_for_autohire')
        self._move_nested_collection(HireOrder)

    @log_merge_stage
    def _update_duplication_cases(self):
        duplication_case_manager = models.DuplicationCase.unsafe

        processed_case_ids = set(
            duplication_case_manager
            .filter(status=DUPLICATION_CASE_STATUSES.new)
            .filter(
                Q(first_candidate__in=self.cand_ids)
                | Q(second_candidate__in=self.cand_ids)
            )
            .values_list('id', flat=True)
        )

        open_cases = duplication_case_manager.filter(status=DUPLICATION_CASE_STATUSES.new)
        cases_to_close = open_cases.filter(
            first_candidate__in=self.cand_ids,
            second_candidate__in=self.cand_ids,
        )
        cases_to_close.update(
            status=DUPLICATION_CASE_STATUSES.closed,
            resolution=DUPLICATION_CASE_RESOLUTIONS.duplicate,
            merged_to_id=self.new_candidate.id,
            modified=timezone.now(),
        )

        # Подменяем в кейсах дубликаты, на новые оригиналы
        open_cases.filter(first_candidate__in=self.cand_ids).update(
            first_candidate_id=self.new_candidate.id,
            modified=timezone.now(),
        )
        open_cases.filter(second_candidate__in=self.cand_ids).update(
            second_candidate_id=self.new_candidate.id,
            modified=timezone.now(),
        )

        # Дубли, где у кандидатов разные логины
        conflict_cases = (
            duplication_case_manager
            .filter(status=DUPLICATION_CASE_STATUSES.new)
            .filter(
                Q(first_candidate_id=self.new_candidate.id)
                | Q(second_candidate_id=self.new_candidate.id)
            )
            .exclude(
                Q(first_candidate__login='')
                | Q(second_candidate__login='')
                | Q(first_candidate__login=F('second_candidate__login'))
            )
        )
        conflict_case_ids = set(conflict_cases.values_list('id', flat=True))
        if conflict_case_ids:
            conflict_cases.update(
                status=DUPLICATION_CASE_STATUSES.closed,
                resolution=DUPLICATION_CASE_RESOLUTIONS.not_duplicate,
            )

        # Записываем все в actionlog
        post_update.send(
            sender=models.DuplicationCase,
            queryset=(
                duplication_case_manager
                .filter(id__in=(processed_case_ids | conflict_case_ids))
            ),
        )
