import json
import logging
from typing import List, Union

import waffle

from constance import config

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models.query import QuerySet
from django.template import loader

from intranet.femida.src.actionlog.decorators import action_logged
from intranet.femida.src.applications.controllers import close_vacancy_applications
from intranet.femida.src.core.controllers import update_instance, update_list_of_instances
from intranet.femida.src.core.models import City
from intranet.femida.src.core.signals import post_bulk_create
from intranet.femida.src.notifications.utils import get_base_context
from intranet.femida.src.notifications.vacancies import VacancyApprovedNotification
from intranet.femida.src.oebs.api import get_budget_position, EmptyBudgetPositionError
from intranet.femida.src.oebs.choices import BUDGET_POSITION_STATUSES
from intranet.femida.src.publications.controllers import archive_vacancy_publications
from intranet.femida.src.staff.bp_registry import (
    BPRegistryAPI,
    BPRegistryError,
    BPRegistryUnrecoverableError,
)
from intranet.femida.src.staff.choices import DEPARTMENT_AUTO_OBSERVER_ROLES
from intranet.femida.src.staff.models import DepartmentUser
from intranet.femida.src.staff.serializers import BPRegistryIssueSerializer
from intranet.femida.src.startrek.operations import (
    IssueTransitionOperation,
    IssueCommentOperation,
    IssueUpdateOperation,
)
from intranet.femida.src.vacancies import models, choices
from intranet.femida.src.vacancies.bp_registry.serializers import BPRegistryVacancySerializer
from intranet.femida.src.vacancies.bp_registry.checks import (
    check_if_reward_category_changed,
)
from intranet.femida.src.vacancies.signals import vacancy_approved, vacancy_unapproved
from intranet.femida.src.vacancies.tasks import create_job_issue_task
from intranet.femida.src.vacancies.startrek.issues import create_job_issue
from intranet.femida.src.vacancies.startrek.serializers import (
    IssueContextSerializer,
    IssueFieldsSerializer,
)
from intranet.femida.src.startrek.utils import (
    get_issue,
    StartrekError,
    ResolutionEnum,
    InternshipEnum,
    StatusEnum,
    TransitionEnum,
    ApprovementStatusEnum,
)


User = get_user_model()
logger = logging.getLogger(__name__)


def collect_issue_data(vacancy, data):
    """
    Собирает данные, которые необходимо прокинуть при создании тикета в ST
    """
    data['id'] = vacancy.id
    data['name'] = vacancy.name
    data['created'] = vacancy.created

    context = IssueContextSerializer(data).data
    context.update(get_base_context())

    fields = IssueFieldsSerializer(data).data
    return {
        'fields': fields,
        'context': context,
    }


def _update_vacancy_memberships(data, instance):
    memberships = []

    flat_roles_map = {
        'hiring_manager': choices.VACANCY_ROLES.hiring_manager,
        'head': choices.VACANCY_ROLES.head,
        'main_recruiter': choices.VACANCY_ROLES.main_recruiter,
    }
    list_roles_map = {
        'recruiters': choices.VACANCY_ROLES.recruiter,
        'responsibles': choices.VACANCY_ROLES.responsible,
        'interviewers': choices.VACANCY_ROLES.interviewer,
        'observers': choices.VACANCY_ROLES.observer,
    }
    roles_map = dict(flat_roles_map, **list_roles_map)
    roles = [roles_map.get(k) for k in data]

    for key, role in flat_roles_map.items():
        if data.get(key) is not None:
            memberships.append({
                'member_id': data[key].id,
                'role': role,
                'vacancy_id': instance.id,
            })

    for key, role in list_roles_map.items():
        if data.get(key) is not None:
            for member in data[key]:
                memberships.append({
                    'member_id': member.id,
                    'role': role,
                    'vacancy_id': instance.id,
                })

    update_list_of_instances(
        model=models.VacancyMembership,
        queryset=instance.memberships.filter(role__in=roles),
        data=memberships,
        identifier=('vacancy_id', 'member_id', 'role'),
    )


def _update_auto_observers_and_head(vacancy, old_department):
    if old_department is not None:
        memberships_to_delete = (
            vacancy.memberships
            .filter(
                role__in=(choices.VACANCY_ROLES.auto_observer, choices.VACANCY_ROLES.head),
                department_user__department=old_department,
            )
        )
        memberships_to_delete.delete()

    department_users = DepartmentUser.objects.filter(
        department=vacancy.department,
        role__in=DEPARTMENT_AUTO_OBSERVER_ROLES._db_values,
    )
    head_du = None
    for du in department_users:
        if du.role == DEPARTMENT_AUTO_OBSERVER_ROLES.chief and du.is_closest:
            head_du = du
            break

    memberships_to_create = [
        models.VacancyMembership(
            vacancy=vacancy,
            member_id=department_user.user_id,
            role=choices.VACANCY_ROLES.auto_observer,
            department_user_id=department_user.id,
        ) for department_user in department_users
    ]
    if head_du:
        memberships_to_create.append(
            models.VacancyMembership(
                vacancy=vacancy,
                member_id=head_du.user_id,
                role=choices.VACANCY_ROLES.head,
                department_user=head_du,
            )
        )

    created_memberships = models.VacancyMembership.objects.bulk_create(memberships_to_create)
    post_bulk_create.send(
        sender=models.VacancyMembership,
        queryset=created_memberships,
    )


def update_vacancy_through_model(vacancy, data, field_name, through_model, through_attr,
                                 additional_data=None):
    if data is None:
        return

    additional_data = additional_data or {}
    through_model_data = [
        {
            'vacancy_id': vacancy.id,
            f'{field_name}_id': item.id,
            **additional_data,
        }
        for item in data
    ]
    update_list_of_instances(
        model=through_model,
        queryset=getattr(vacancy, through_attr).all(),
        data=through_model_data,
        identifier=('vacancy_id', f'{field_name}_id'),
    )


def fill_cities(
    locations: QuerySet,
    work_mode: QuerySet,
) -> List[City]:
    """
    Backwards compatability for the cities field.

    Mainly used for the jobs website.
    It allows using old filters, vacancy pages etc.

    Using only english names for matching.
    """

    cities_obj = list(City.objects.all())
    cities_id = set()

    if work_mode and work_mode.filter(slug='remote').exists():
        cities_id.add(settings.CITY_HOMEWORKER_ID)

    if locations:
        for loc in locations:
            cities_id.update([c.id for c in cities_obj if c.name_en in loc.name_en])

    return list(City.objects.filter(id__in=cities_id))


def _cast(cities: Union[QuerySet, List[City], None]) -> List[City]:
    if type(cities) == QuerySet:
        return list(cities)
    elif cities is None:
        return []

    return cities


def update_or_create_vacancy(data, initiator=None, instance=None):
    skills = data.get('skills')
    offices = data.get('offices')
    locations = data.get('locations')
    work_mode = data.get('work_mode')

    cities = _cast(data.get('cities'))
    cities_from_location = fill_cities(locations, work_mode)
    cities.extend(cities_from_location)

    membership_roles = (
        'hiring_manager',
        'recruiters',
        'main_recruiter',
        'responsibles',
        'interviewers',
    )
    memberships_data = {}

    for role in membership_roles:
        if role in data:
            memberships_data[role] = data.pop(role)

    if 'observers' in data:
        memberships_data['observers'] = list(data.pop('observers'))
    elif 'followers' in data:
        memberships_data['observers'] = list(data['followers'])

    vacancy_data = {
        field_name: data[field_name]
        for field_name in [f.name for f in models.Vacancy._meta.fields]
        if field_name in data
    }

    if vacancy_data.get('profession'):
        vacancy_data['professional_sphere'] = vacancy_data['profession'].professional_sphere

    old_department = None
    if instance:
        is_creation = False
        old_department = instance.department
        update_instance(instance, vacancy_data, extra_update_fields=['modified'])
    else:
        is_creation = True
        vacancy_data['created_by'] = initiator
        if 'observers' not in memberships_data:
            memberships_data['observers'] = []
        if initiator not in memberships_data['observers']:
            memberships_data['observers'].append(initiator)
        vacancy_data['status'] = choices.VACANCY_STATUSES.on_approval
        instance = models.Vacancy.objects.create(**vacancy_data)

    through_models_data = [
        {
            'through_attr': 'vacancy_skills',
            'through_model': models.VacancySkill,
            'data': skills,
            'field_name': 'skill',
            'additional_data': {'is_required': False},
        },
        {
            'through_attr': 'vacancy_cities',
            'through_model': models.VacancyCity,
            'data': cities,
            'field_name': 'city',
        },
        {
            'through_attr': 'vacancy_offices',
            'through_model': models.VacancyOffice,
            'data': offices,
            'field_name': 'office',
        },
        {
            'through_attr': 'vacancy_locations',
            'through_model': models.VacancyLocation,
            'data': locations,
            'field_name': 'location',
        },
        {
            'through_attr': 'vacancy_work_mode',
            'through_model': models.VacancyWorkMode,
            'data': work_mode,
            'field_name': 'work_mode',
        },
    ]
    for item in through_models_data:
        update_vacancy_through_model(instance, **item)

    abc_services = data.get('abc_services')
    if abc_services is not None:
        instance.abc_services.set(abc_services)

    # Если у вакансии изменилось подразделение или это новая вакансия,
    # проставляем auto_observers и руководителя
    if old_department != instance.department:
        _update_auto_observers_and_head(instance, old_department)
    _update_vacancy_memberships(memberships_data, instance)

    if is_creation:
        instance.raw_issue_data = json.dumps(collect_issue_data(instance, data))
        instance.save()

        try:
            create_job_issue(instance)
        except StartrekError:
            logger.exception('Failed to create job issue. Trying to create it asynchronously.')
            create_job_issue_task.delay(instance.id)
    return instance


def update_or_create_vacancy_group(data, initiator=None, instance=None):
    """
    Создание/изменение группы вакансий
    """

    recruiters = data.pop('recruiters', [])
    vacancies = data.pop('vacancies', [])

    if instance:
        update_instance(instance, data)
    else:
        instance = models.VacancyGroup.objects.create(
            created_by=initiator,
            **data
        )

    memberships = [
        {'vacancy_group_id': instance.id, 'member_id': recruiter.id}
        for recruiter in recruiters
    ]

    update_list_of_instances(
        model=models.VacancyGroupMembership,
        queryset=instance.memberships.all(),
        data=memberships,
        identifier=('vacancy_group_id', 'member_id'),
    )

    instance.vacancies.set(vacancies)

    return instance


def get_aa_prof_sphere_ids(aa_type):
    sphere_ids = getattr(config, f'AA_{aa_type.upper()}_PROF_SPHERE_IDS', None)
    if sphere_ids is None:
        raise ValueError('Invalid aa_type: %s', aa_type)

    ids = sphere_ids.strip()
    return [int(i) for i in ids.split(',')] if ids else []


def get_relevant_prof_sphere_ids_qs(user):
    return (
        models.VacancyMembership.unsafe
        .filter(
            role__in=(choices.VACANCY_ROLES.hiring_manager, choices.VACANCY_ROLES.responsible),
            member=user,
            vacancy__status__in=(
                choices.VACANCY_STATUSES.in_progress,
                choices.VACANCY_STATUSES.suspended,
                choices.VACANCY_STATUSES.offer_processing,
            ),
            vacancy__professional_sphere_id__isnull=False,
        )
        .values_list('vacancy__professional_sphere', flat=True)
    )


@action_logged('vacancy_close_by_issue')
def vacancy_close_by_issue(vacancy, issue):
    resolutions_map = {
        ResolutionEnum.wont_fix: choices.VACANCY_RESOLUTIONS.cancelled,
        ResolutionEnum.transfer: choices.VACANCY_RESOLUTIONS.move,
    }

    if issue.resolution.key not in resolutions_map:
        return

    vacancy.status = choices.VACANCY_STATUSES.closed
    vacancy.resolution = resolutions_map[issue.resolution.key]
    vacancy.save()
    archive_vacancy_publications(vacancy)
    close_vacancy_applications(vacancy)
    vacancy_unapproved.send(models.Vacancy, vacancy=vacancy)


@action_logged('vacancy_change_type_by_issue')
def vacancy_change_type_by_issue(vacancy, issue):
    if not issue.internship:
        return

    if issue.internship.key == InternshipEnum.no:
        logger.warning('Changed issue %s internship type to "no"', issue.key)
        return

    if issue.internship.key == InternshipEnum.yes:
        vacancy.type = choices.VACANCY_TYPES.internship
        vacancy.pro_level_min = choices.VACANCY_PRO_LEVELS.intern
        vacancy.pro_level_max = choices.VACANCY_PRO_LEVELS.intern
        vacancy.save()


def _check_issue_and_get_context(issue, vacancy):
    """
    Проверяет JOB-тикет на наличие ошибок, не дающих перевести вакансию в работу, и возвращает
    словарь параметров context, который будет использован в комментарии к JOB-тикету и при
    обновлении параметров вакансии.
    """
    context = get_base_context()
    context['errors'] = []

    if not issue.bpNumber:
        logger.warning('Missing "bpNumber" parameter in issue')
        context['errors'].append('Не указан Номер БП')
    else:
        try:
            bp = get_budget_position(int(issue.bpNumber))
        except ValueError:
            logger.warning('bpNumber %s should be a number', issue.bpNumber)
            context['errors'].append('Номер БП должен быть числом')
        except EmptyBudgetPositionError:
            logger.warning('Budget position %s is in EMPTY status', issue.bpNumber)
            context['errors'].append('Не удалось получить данные по БП %s' % issue.bpNumber)
        else:
            context['bp'] = bp
            if bp.get('hiring') != BUDGET_POSITION_STATUSES.vacancy:
                logger.warning('Budget position %s is not in VACANCY status', bp['id'])
                context['errors'].append('БП не в статусе "Вакансия разрешена"')
            conflict_vacancy = (
                models.Vacancy.unsafe
                .exclude(id=vacancy.id)
                .filter(
                    budget_position_id=bp['id'],
                    status__in=choices.OPEN_VACANCY_STATUSES._db_values,
                )
                .values('id', 'startrek_key')
                .first()
            )
            if conflict_vacancy:
                logger.warning(
                    'BP `%s` is already on vacancy `%s`', conflict_vacancy['id'], bp['id']
                )
                context['errors'].append(
                    'Перевести вакансию в работу не удалось, '
                    'так как БП %s указана в другой незакрытой вакансии %s.'
                    'Для решения вопроса обратитесь на rec-org@' % (
                        bp['id'],
                        conflict_vacancy['startrek_key'],
                    ),
                )

    if not issue.recruitmentPartner:
        logger.warning('Missing "recruitmentPartner" parameter in issue')
        context['errors'].append('Не указан Рекрутмент-партнёр')
    else:
        try:
            context['user'] = User.objects.get(username=issue.recruitmentPartner.id)
        except User.DoesNotExist:
            logger.warning('User "%s% does not exist', issue.recruitmentPartner.id)
            context['errors'].append('Не найден Рекрутмент-партнёр')

    return context


@action_logged('change_issue_bp_by_vacancy')
def change_issue_bp_by_vacancy_new_bp(vacancy: models.Vacancy, new_bp_id: int) -> None:
    vacancy.budget_position_id = new_bp_id
    vacancy.save(update_fields=['budget_position_id'])

    operation = IssueUpdateOperation(vacancy.startrek_key)
    operation.delay(bpNumber=new_bp_id)


@action_logged('vacancy_approve_by_issue')
def vacancy_approve_by_issue(vacancy, issue):
    if issue.status.key != StatusEnum.in_progress:
        logger.warning(
            'Could not approve vacancy by issue. Invalid issue %s status: %s',
            issue.key,
            issue.status.key,
        )
        raise ValidationError('job_invalid_status')

    context = _check_issue_and_get_context(issue, vacancy)
    context['instance'] = vacancy
    if context['errors']:
        operation = IssueTransitionOperation(issue.key)
        operation.delay(
            transition=TransitionEnum.open,
            assignee=issue.analyst.id,
            comment={
                'text': loader.render_to_string('startrek/vacancies/open.wiki', context),
                'summonees': [issue.analyst.id],
            },
        )
        return

    with transaction.atomic():
        vacancy.status = choices.VACANCY_STATUSES.in_progress
        vacancy.set_main_recruiter(context['user'])
        if vacancy.budget_position_id and vacancy.budget_position_id != context['bp']['id']:
            logger.warning(
                'Old budget position differs from new. Old: %d, New: %d',
                vacancy.budget_position_id,
                context['bp']['id'],
            )
        vacancy.budget_position_id = context['bp']['id']
        vacancy.save(update_fields=['status', 'budget_position_id', 'modified'])

        operation = IssueCommentOperation(vacancy.startrek_key)
        operation.delay(loader.render_to_string('startrek/vacancies/approve.wiki', context))

        notification = VacancyApprovedNotification(vacancy, context['user'])
        notification.send()

        vacancy_approved.send(models.Vacancy, vacancy=vacancy)


@action_logged('vacancy_approve_bp_by_issue')
def vacancy_approve_bp_by_issue(vacancy):
    issue = get_issue(vacancy.startrek_key)

    is_issue_data_valid = (
        issue.status.key == StatusEnum.open
        and issue.approvementStatus == ApprovementStatusEnum.approved
    )

    switch_name = 'disable_ticket_status_check_before_bp_transaction'
    if not is_issue_data_valid and not waffle.switch_is_active(switch_name):
        logger.warning(
            'Could not approve vacancy BP by issue %s. '
            'Invalid issue data: status - %s, approvementStatus - %s, bpNumber - %s',
            issue.key,
            issue.status.key,
            issue.approvementStatus,
            issue.bpNumber,
        )
        return

    try:
        int(issue.bpNumber)
    except ValueError:
        logger.warning('Issue %s does not contain a valid BP number', issue.key)
        return

    bp_transaction_data = BPRegistryIssueSerializer(issue).data

    vacancy.budget_position_id = bp_transaction_data['budget_position_id']

    bp_transaction_data.update(BPRegistryVacancySerializer(vacancy).data)

    try:
        bp_transaction_id = BPRegistryAPI.create_transaction(bp_transaction_data)
    except BPRegistryUnrecoverableError as e:
        operation = IssueUpdateOperation(issue.key)
        context = {
            'bp_errors_ru': e.error_messages_ru,
            'bp_errors_en': e.error_messages_en,
            'bp_errors_response': e.staff_response,
        }
        text = loader.render_to_string('startrek/vacancies/bp-registry-failure.wiki', context)
        operation.delay(
            comment=text,
            **{settings.STARTREK_JOB_WORKFLOW_ID_FIELD: 'creation_err'},
        )
        return

    try:
        check_if_reward_category_changed(vacancy, issue)
    except BPRegistryError:
        return

    vacancy.is_approved = True
    vacancy.bp_transaction_id = bp_transaction_id
    vacancy.save(update_fields=[
        'budget_position_id',
        'bp_transaction_id',
        'is_approved',
        'modified',
    ])

    if waffle.switch_is_active('enable_bp_registry'):
        text = loader.render_to_string(
            template_name='startrek/vacancies/bp-registry-created.wiki',
            context={
                'instance': vacancy,
            },
        )
        operation = IssueUpdateOperation(issue.key)
        operation.delay(
            comment=text,
            **{settings.STARTREK_JOB_WORKFLOW_ID_FIELD: bp_transaction_id},
        )
