import logging
from decimal import Decimal
from itertools import chain, groupby
from typing import Dict, List

from django.db.models import Q

from staff.lib.models.mptt import get_heirarchy_filter_query
from staff.lib.models.roles_chain import get_grouped_hrbp_by_persons

from staff.budget_position.workflow_service import ProposalChange, ProposalData, WorkflowRegistryService
from staff.budget_position.workflow_service.entities.abstract_workflow import AbstractWorkflow
from staff.person.models import Staff

from staff.departments.controllers.exceptions import BpConflict
from staff.departments.models import Department, ProposalMetadata, HRProduct

logger = logging.getLogger(__name__)


class WorkflowRegistryUpdater:
    def __init__(
        self,
        proposal_ctl,
    ):
        self._cached_proposal_id: int or None = None
        self.proposal_ctl = proposal_ctl
        self.workflow_service = WorkflowRegistryService()

    def _effective_date(self):
        return self.proposal_ctl.apply_at

    def _proposal_id(self) -> int:
        if not self._cached_proposal_id:
            self._cached_proposal_id = ProposalMetadata.objects.get(proposal_id=self.proposal_ctl.proposal_id).id
        return self._cached_proposal_id

    def confirm_changes_for_proposal(self):
        logger.info('Confirm workflow changes for proposal %s', self._proposal_id())
        self.workflow_service.confirm_workflows_for_proposal(self._proposal_id())

    def cancel_all_changes_for_proposal(self):
        logger.info('Cancelling all workflow changes for proposal %s', self._proposal_id())
        self.workflow_service.cancel_workflows_for_proposal(self._proposal_id())

    def update_changes_for_proposal(
        self,
        person_actions: List,
        vacancy_actions: List,
        headcount_actions: List,
        tickets: Dict,
    ):
        logger.info('Updating workflow changes for proposal %s', self._proposal_id())
        self.cancel_all_changes_for_proposal()
        self._init_changes_for_proposal(person_actions, vacancy_actions, headcount_actions, tickets)

    def _init_changes_for_proposal(
        self,
        person_actions: List,
        vacancy_actions: List,
        headcounts_actions: List,
        tickets: Dict,
    ) -> None:
        conflicting_workflows = self._try_init_workflows(person_actions, vacancy_actions, headcounts_actions, tickets)
        if conflicting_workflows:
            self._raise_workflow_conflict(conflicting_workflows)

    def _try_init_workflows(
        self,
        person_actions: List,
        vacancies_actions: List,
        headcounts_actions: List,
        tickets: Dict,
    ) -> List[AbstractWorkflow]:
        persons_data = self._person_workflow_creation_data(person_actions, tickets)
        vacancies_data = self._vacancy_workflow_creation_data(vacancies_actions, tickets)
        headcounts_data = self._headcounts_workflow_creation_data(headcounts_actions, tickets)

        _, conflicting_workflows = self.workflow_service.try_create_workflow_from_proposal(
            data=list(chain(persons_data, vacancies_data, headcounts_data)),
        )
        return conflicting_workflows

    def _vacancy_workflow_creation_data(self, vacancy_actions: list, tickets: dict) -> List[ProposalData]:
        vacancies_changes = self._vacancies_proposal_changes(vacancy_actions, tickets)
        department_url_to_id = self._department_url_to_id(
            [meta['department_url'] for meta in vacancies_changes.values() if meta['department_url'] is not None]
        )
        value_stream_url_to_hr_product_id = dict(
            HRProduct.objects.filter(value_stream__isnull=False).values_list('value_stream__url', 'id')
        )

        result = []

        for action in vacancy_actions:
            vacancy_id = action['vacancy_id']
            vacancy_change = vacancies_changes[vacancy_id]
            hr_product_id = (
                value_stream_url_to_hr_product_id.get(vacancy_change.get('value_stream'))
                if vacancy_change.get('value_stream')
                else None
            )

            proposal_data = ProposalData(
                proposal_id=self._proposal_id(),
                vacancy_id=vacancy_id,
                is_move_with_budget_position=True,
                proposal_changes=[ProposalChange(
                    department_id=department_url_to_id.get(vacancy_change['department_url']),
                    hr_product_id=hr_product_id,
                    geography_url=vacancy_change.get('geography'),
                    oebs_date=self._effective_date(),
                    force_recalculate_schemes=vacancy_change.get('force_recalculate_schemes'),
                )],
                ticket=vacancy_change['ticket'],
            )
            result.append(proposal_data)

        return result

    def _headcounts_workflow_creation_data(self, headcounts_actions: List, tickets: dict) -> List[ProposalData]:
        headcounts_changes = self._headcounts_proposal_changes(headcounts_actions, tickets)
        department_url_to_id = self._department_url_to_id(
            [meta['department_url'] for meta in headcounts_changes.values() if meta['department_url'] is not None]
        )
        value_stream_url_to_hr_product_id = dict(
            HRProduct.objects.filter(value_stream__isnull=False).values_list('value_stream__url', 'id')
        )

        result = []

        for action in headcounts_actions:
            headcount_code = action['headcount_code']
            headcount_change = headcounts_changes[headcount_code]
            hr_product_id = (
                value_stream_url_to_hr_product_id.get(headcount_change.get('value_stream'))
                if headcount_change.get('value_stream')
                else None
            )

            proposal_data = ProposalData(
                proposal_id=self._proposal_id(),
                is_move_with_budget_position=True,
                headcount_position_code=headcount_code,
                proposal_changes=[ProposalChange(
                    department_id=department_url_to_id.get(headcount_change['department_url']),
                    oebs_date=self._effective_date(),
                    hr_product_id=hr_product_id,
                    geography_url=headcount_change.get('geography'),
                    force_recalculate_schemes=headcount_change.get('force_recalculate_schemes'),
                )],
                ticket=headcount_change['ticket'],
            )
            result.append(proposal_data)

        return result

    def _person_workflow_creation_data(self, person_actions: List, tickets: Dict) -> List[ProposalData]:
        logins = [action.get('login') for action in person_actions if action.get('login')]

        department_urls = [
            action.get('department', {}).get('department') for action in person_actions
        ]
        department_url_to_id = self._department_url_to_id([
            department for department in department_urls if department is not None
        ])
        value_stream_url_to_hr_product_id = dict(
            HRProduct.objects.filter(value_stream__isnull=False).values_list('value_stream__url', 'id')
        )

        login_to_id = dict(Staff.objects.filter(login__in=logins).values_list('login', 'id'))

        result = []

        def get_new_value(meta, name):
            return (
                meta['new_' + name]
                if meta['new_' + name] != meta['old_' + name]
                else None
            )

        def key_for_sort(v):
            return v['login']

        for login, actions in groupby(sorted(person_actions, key=key_for_sort), key_for_sort):
            actions = list(actions)
            ticket_for_change = (
                tickets['persons'].get(login) or
                tickets.get('restructurisation') or
                tickets.get('value_stream') or
                ''
            )

            proposal_data = ProposalData(
                proposal_id=self._proposal_id(),
                vacancy_id=None,
                person_id=login_to_id[login],
                ticket=ticket_for_change,
                job_ticket_url=self._vacancy_url_or_none(actions),
                is_move_with_budget_position=True,
            )

            for action in actions:
                person_changes = self._person_proposal_changes(action)

                if person_changes['from_maternity_leave'] is not None:
                    proposal_data.is_move_from_maternity = person_changes['from_maternity_leave']

                is_move_with_budget_position = bool(person_changes.get('with_budget', True))
                # If we have at least one action without moving BP, all changes considered as move without bp
                proposal_data.is_move_with_budget_position &= is_move_with_budget_position
                hr_product_id = (
                    value_stream_url_to_hr_product_id.get(person_changes['value_stream'])
                    if person_changes['value_stream']
                    else None
                )

                if not hr_product_id and person_changes.get('value_stream'):
                    logger.warning('Value stream %s not found', person_changes['value_stream'])

                rate = get_new_value(person_changes, 'rate')
                currency = get_new_value(person_changes, 'currency')
                salary = get_new_value(person_changes, 'salary')

                if any(v is not None for v in (rate, currency, salary)):
                    rate = person_changes['new_rate']
                    currency = person_changes['new_currency']
                    salary = person_changes['new_salary']

                proposal_change = ProposalChange(
                    position_id=person_changes['position_id'],
                    department_id=department_url_to_id.get(person_changes['department_url']),
                    office_id=person_changes['office_id'],
                    organization_id=person_changes['organization_id'],
                    occupation_id=person_changes['occupation_id'],  # FullStackDeveloper
                    grade_change=person_changes['grade_change'],  # +2
                    currency=currency,
                    rate=rate and Decimal(rate),
                    salary=salary,
                    wage_system=get_new_value(person_changes, 'wage_system'),
                    oebs_date=action['oebs_application_date'],
                    force_recalculate_schemes=person_changes['force_recalculate_schemes'],
                    hr_product_id=hr_product_id,
                    geography_url=person_changes.get('geography'),
                )

                proposal_data.proposal_changes.append(proposal_change)

            assert len(proposal_data.proposal_changes) > 0
            result.append(proposal_data)

        return result

    @staticmethod
    def _vacancy_url_or_none(actions) -> str or None:
        for action in actions:
            url = action.get('department', {}).get('vacancy_url')
            if url:
                return url
        return None

    @staticmethod
    def _person_proposal_changes(action):
        return {
            'department_url': action.get('department', {}).get('department'),
            'organization_id': action.get('organization', {}).get('organization'),
            'office_id': action.get('office', {}).get('office'),
            'position_id': action.get('position', {}).get('position_legal'),
            'occupation_id': action.get('grade', {}).get('occupation'),
            'grade_change': action.get('grade', {}).get('new_grade'),
            'force_recalculate_schemes': action.get('grade', {}).get('force_recalculate_schemes'),
            'new_wage_system': action.get('salary', {}).get('new_wage_system'),
            'old_wage_system': action.get('salary', {}).get('old_wage_system'),
            'new_salary': action.get('salary', {}).get('new_salary'),
            'old_salary': action.get('salary', {}).get('old_salary'),
            'new_currency': action.get('salary', {}).get('new_currency'),
            'old_currency': action.get('salary', {}).get('old_currency'),
            'new_rate': action.get('salary', {}).get('new_rate'),
            'old_rate': action.get('salary', {}).get('old_rate'),
            'from_maternity_leave': action.get('department', {}).get('from_maternity_leave'),
            'with_budget': action.get('department', {}).get('with_budget', True),
            'value_stream': action.get('value_stream', {}).get('value_stream'),
            'geography': action.get('geography', {}).get('geography'),
        }

    @staticmethod
    def _vacancies_proposal_changes(actions, tickets):
        result = {
            action['vacancy_id']: {
                'ticket': tickets.get('headcount') or tickets.get('value_stream') or tickets.get('restructurisation'),
                'department_url': action.get('department'),
                'value_stream': action.get('value_stream'),
                'geography': action.get('geography'),
            }
            for action in actions
        }

        return result

    @staticmethod
    def _headcounts_proposal_changes(actions, tickets):
        result = {
            action['headcount_code']: {
                'ticket': tickets.get('headcount') or tickets.get('value_stream') or tickets.get('restructurisation'),
                'department_url': action.get('department'),
                'value_stream': action.get('value_stream'),
                'geography': action.get('geography'),
            }
            for action in actions
        }

        return result

    def _raise_workflow_conflict(self, conflict_workflows: List[AbstractWorkflow]) -> None:
        metas = []

        for workflow in conflict_workflows:
            # Может тут заиспользовать wf коды? И обеспечить гарантию по полям без Optional
            if workflow.proposal_id is not None:
                metas.append(self._error_for_conflict_proposal(workflow))
            elif workflow.credit_management_id is not None:
                metas.append(self._error_for_conflict_credit_management(workflow))
            else:
                metas.append(self._error_for_conflict_nonassociated(workflow))

        raise BpConflict(metas)

    @staticmethod
    def _department_url_to_id(urls):
        return dict(
            Department.objects
            .filter(url__in=urls)
            .values_list('url', 'id')
        )

    def _error_for_conflict_proposal(self, workflow: AbstractWorkflow) -> Dict:
        hrbps = get_grouped_hrbp_by_persons([workflow.proposal_meta.author], fields=['login'])

        change = workflow.changes[0]  # пока во всех ченджах одни метаданные

        err_meta = {
            'bp_id': change.budget_position.code,  # тут не ошибка, показывать надо код
            'proposal_id': workflow.proposal_meta.proposal_id,
            'author': workflow.proposal_meta.author.login,
            'hrbps': [it['login'] for it in hrbps.get(workflow.proposal_meta.author_id, [])],
        }

        if change.person_id is not None:
            # это нужно для матчинга ошибки на фронте
            # в случае, если на бп сидят >1 человека, фронт не сматчит и покажет общую ошибку
            # TODO: подумать откуда доставать связку бп-логин без нескольких запросов
            # показывать нормальную ошибку, указывая кто и с кем конфликтует
            err_meta['bp_login'] = (
                Staff.objects
                .filter(
                    budget_position__code=change.budget_position.code,
                    login__in=set(self.proposal_ctl.person_logins),
                )
                .values_list('login', flat=True)
                .first()
            )
            err_meta['bp_conflict_login'] = Staff.objects.get(id=change.person_id).login
            err_meta['ticket'] = change.ticket
        elif workflow.vacancy_id is not None:
            err_meta['vacancy_id'] = workflow.vacancy_id

        return err_meta

    @staticmethod
    def _error_for_conflict_credit_management(workflow: AbstractWorkflow) -> Dict:
        return {
            'bp_id': workflow.changes[0].budget_position.code,
            'credit_management_id': workflow.credit_management_id,
            'author': workflow.credit_management.author,
            'ticket': workflow.credit_management.startrek_headcount_key,
        }

    @staticmethod
    def _error_for_conflict_nonassociated(workflow: AbstractWorkflow) -> Dict:
        return {'bp_id': workflow.changes[0].budget_position.code, 'workflow_id': workflow.id}

    @staticmethod
    def qs_for_departments_without_bp(prefix: str) -> Q:
        return get_heirarchy_filter_query(
            Department.objects.filter(url__in=['outstaff_3210', 'outstaff_3210_2533']),  # STAFF-12976
            by_children=True,
            filter_prefix=prefix,
        )
