import itertools
import logging
import re
import waffle
import ylock

from collections import OrderedDict
from datetime import datetime
from typing import Optional

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 import Q
from django.template import loader
from django.template.base import Template
from django.template.context import Context
from django.urls.base import reverse
from django.utils import timezone
from django.utils.functional import cached_property

from intranet.femida.src.actionlog.decorators import action_logged
from intranet.femida.src.api.oebs.serializers import PersonDataSerializer
from intranet.femida.src.applications.controllers import close_vacancy_applications
from intranet.femida.src.attachments.models import Attachment
from intranet.femida.src.candidates.choices import CONSIDERATION_RESOLUTIONS
from intranet.femida.src.candidates.considerations.controllers import (
    update_consideration_extended_status,
)
from intranet.femida.src.candidates.helpers import blank_modify_candidate
from intranet.femida.src.candidates.tasks import change_reference_issue_join_at_task
from intranet.femida.src.candidates.workflow import CandidateWorkflow
from intranet.femida.src.core.controllers import update_instance, update_list_of_instances
from intranet.femida.src.core.models import Currency
from intranet.femida.src.core.switches import TemporarySwitch
from intranet.femida.src.core.workflow import WorkflowError, ActionProhibitedError
from intranet.femida.src.interviews.choices import APPLICATION_STATUSES, APPLICATION_RESOLUTIONS
from intranet.femida.src.notifications.offers import (
    OfferHRSurveyRecruiterNotification,
    OfferHRSurveyHiringManagerNotification,
)
from intranet.femida.src.notifications.utils import get_base_context
from intranet.femida.src.oebs.api import (
    BudgetPositionError,
    OebsPersonError,
    OebsHireAPI,
    OebsFormulaAPI,
    get_budget_position,
    OebsPersonExistsError,
)
from intranet.femida.src.oebs.choices import (
    BUDGET_POSITION_STATUSES,
    BUDGET_POSITION_PAYMENT_SYSTEMS,
)
from intranet.femida.src.oebs.tasks import (
    update_bank_login_task,
    update_oebs_login_task,
    update_oebs_assignment_task,
)
from intranet.femida.src.offers import choices
from intranet.femida.src.offers.bp_registry.serializers import (
    BPRegistryOfferSerializer,
    BPAssignmentOfferSerializer,
)
from intranet.femida.src.offers.bp_registry.checks import check_placement_existence
from intranet.femida.src.offers.helpers import (
    get_join_isoweekday,
    get_offer_contract_end,
    is_adaptation_needed,
)
from intranet.femida.src.offers.newhire.forms import (
    NewhireOfferRemoteForm,
    NewhirePreprofileRemoteForm,
)
from intranet.femida.src.offers.newhire.serializers import (
    NewhireOfferBaseSerializer,
    NewhireOfferUpdateSerializer,
    NewhireOfferRemoteSerializer,
    NewhireOfferStoredSerializer,
    PreprofileSerializer,
    NewhirePreprofileRemoteSerializer,
)
from intranet.femida.src.offers.models import (
    InternalOffer,
    Link,
    Offer,
    OfferAttachment,
    OfferProfile,
    OfferProfileComment,
    OfferRejection,
    OfferSchemesData,
    Preprofile,
    PreprofileAttachment,
    RawTemplate,
)
from intranet.femida.src.offers.oebs.serializers import OebsFormulaOfferSerializer
from intranet.femida.src.offers.signals import (
    offer_confirmed,
    offer_newhire_approved,
    offer_newhire_ready,
    offer_closed,
)
from intranet.femida.src.offers.startrek.issues import execute_adaptation_issue_transition
from intranet.femida.src.offers.startrek.serializers import (
    serialize_offer_for_adaptation_issue_update,
)
from intranet.femida.src.offers.tasks import (
    sync_newhire_offer_task,
    accept_preprofile_task,
    create_preprofile_hr_issue_task,
    create_preprofile_eds_issue_task,
    update_preprofile_issues_task,
    push_preprofile_bank_details,
    check_oebs_login_task,
    link_offer_issues_task,
    generate_offer_pdf_task,
)
from intranet.femida.src.publications.controllers import archive_vacancy_publications
from intranet.femida.src.staff.achievements import AcceptedOfferReferenceAchievement
from intranet.femida.src.staff.bp_registry import (
    BPRegistryAPI,
    BPRegistryError,
    BPRegistryUnrecoverableError,
    BPRegistryOebsError,
    RewardSchemeRequest,
)
from intranet.femida.src.staff.serializers import BPRegistryIssueSerializer
from intranet.femida.src.startrek.operations import (
    IssueTransitionOperation,
    IssueCommentOperation,
    IssueUpdateOperation,
)
from intranet.femida.src.startrek.tasks import (
    update_issue_task,
    add_issue_comment_task,
)
from intranet.femida.src.startrek.utils import (
    get_issue,
    TransitionEnum,
    StatusEnum,
    ApprovementStatusEnum,
)
from intranet.femida.src.utils.datetime import shifted_now
from intranet.femida.src.utils.newhire import NewhireAPI, NewhireError
from intranet.femida.src.utils.switches import is_rotation_enabled
from intranet.femida.src.vacancies.choices import (
    VACANCY_STATUSES,
    VACANCY_RESOLUTIONS,
    VACANCY_TYPES,
)


logger = logging.getLogger(__name__)
lock_manager = ylock.backends.create_manager(**settings.YLOCK)
User = get_user_model()


OEBS_PROFESSIONAL_LEVELS_MAP = {
    '1': choices.PROFESSIONAL_LEVELS.intern,
    '2': choices.PROFESSIONAL_LEVELS.junior,
    '3': choices.PROFESSIONAL_LEVELS.middle,
    '4': choices.PROFESSIONAL_LEVELS.middle,
    '5': choices.PROFESSIONAL_LEVELS.senior,
    '6': choices.PROFESSIONAL_LEVELS.senior,
    '7': choices.PROFESSIONAL_LEVELS.lead,
    '8': choices.PROFESSIONAL_LEVELS.expert,
}

INTERNAL_DEPARTMENT_IDS = (
    settings.YANDEX_DEPARTMENT_ID,
    settings.OUTSTAFF_DEPARTMENT_ID,
)


def _get_budget_position_data(bp_id, from_date=None, safe=False):
    """
    Получение данных для оффера из БП.
    """
    if from_date:
        from_date = from_date.strftime('%Y-%m-%d')

    data = {}

    try:
        bp_data = get_budget_position(bp_id, from_date)
    except BudgetPositionError as ex:
        logger.exception(ex)
        return data

    currency_code = bp_data.get('currency')
    try:
        data['payment_currency'] = Currency.objects.get(code=currency_code)
    except Currency.DoesNotExist:
        logger.warning('OEBS currency code is not found: %s', currency_code)

    if safe:
        return data

    fields_map = {
        'headcount': 'is_headcount',
        'join_at': 'hiringDate',
        'salary': 'salary',
        'org': 'organization',
        'office': 'office',
        'grade': 'grade',
        'profession': 'profession',
        'department': 'department',
    }

    for femida_field, bp_field in fields_map.items():
        value = bp_data.get(bp_field)
        if value:
            data[femida_field] = value

    professional_level = OEBS_PROFESSIONAL_LEVELS_MAP.get(bp_data.get('profLevel'))
    if professional_level:
        data['professional_level'] = (
            choices.PROFESSIONAL_LEVELS_TRANSLATIONS.get(professional_level)
        )

    # Поля, которые нужны только для всяких проверок
    data['pay_system'] = bp_data['paySystem']
    if not waffle.switch_is_active('disable_food_compensation_amount'):
        data['food_compensation_amount'] = bp_data.get('foodLimit', 0)

    return data


def _get_employee_type(candidate):
    if not candidate.login:
        return choices.EMPLOYEE_TYPES.new

    related_user = (
        User.objects
        .filter(username=candidate.login)
        .select_related('department')
        .first()
    )
    if related_user is None:
        return choices.EMPLOYEE_TYPES.new

    if related_user.is_dismissed:
        return choices.EMPLOYEE_TYPES.former

    if not is_rotation_enabled():
        return choices.EMPLOYEE_TYPES.current

    if related_user.department.is_in_trees(INTERNAL_DEPARTMENT_IDS):
        return choices.EMPLOYEE_TYPES.rotation

    return choices.EMPLOYEE_TYPES.current


def create_offer_from_application(application, creator=None):
    """
    Создание оффера на основе претендентства
    """
    vacancy = application.vacancy
    candidate = application.candidate

    offer = Offer()
    offer.creator = creator
    offer.modifier = creator
    offer.application = application
    offer.geography = vacancy.geography
    offer.candidate = candidate
    offer.vacancy = vacancy
    offer.full_name = candidate.get_full_name()
    offer.profession = vacancy.profession
    offer.source = candidate.source
    offer.source_description = candidate.source_description

    offer.employee_type = _get_employee_type(candidate)
    if offer.employee_type != choices.EMPLOYEE_TYPES.new:
        offer.username = candidate.login

    if vacancy.type == VACANCY_TYPES.autohire:
        offer.department = vacancy.department

    job = candidate.jobs.order_by('-end_date', '-id').first()
    if job:
        offer.current_company = job.employer

    if vacancy.budget_position_id:
        budget_position_data = _get_budget_position_data(vacancy.budget_position_id, safe=True)
        for field_name, value in budget_position_data.items():
            setattr(offer, field_name, value)

    offer.save()
    offer.abc_services.set(vacancy.abc_services.all())
    if vacancy.value_stream and vacancy.value_stream.service:
        offer.abc_services.add(vacancy.value_stream.service)
    update_consideration_extended_status(application.consideration)
    blank_modify_candidate(offer.candidate)
    return offer


def _bp_has_fixed_pay_system(bp):
    return bp.get('paySystem') in (
        BUDGET_POSITION_PAYMENT_SYSTEMS.fixed_salary,
        BUDGET_POSITION_PAYMENT_SYSTEMS.jobprice,
    )


@action_logged('offer_confirm_by_current_team')
@transaction.atomic
def offer_confirm_by_current_team(offer, issue):
    from intranet.femida.src.offers.workflow import OfferWorkflow

    if issue.approvementStatus != ApprovementStatusEnum.approved:
        logger.warning(
            'Could not confirm internal offer by current team. '
            'Invalid issue %s approvement status: %s',
            issue.key,
            issue.approvementStatus,
        )
        raise ValidationError('salary_invalid_approvement_status')

    params = {}
    if issue.staffDate:
        params['join_at'] = datetime.strptime(issue.staffDate, '%Y-%m-%d').date()

    workflow = OfferWorkflow(offer, user=None)
    action = workflow.get_action('approve_by_future_team')
    if not action.is_available():
        raise ActionProhibitedError('action_prohibited')

    try:
        with transaction.atomic():
            action.perform(**params)
    except WorkflowError as err:
        if err.message in ('budget_position_error', 'budget_position_invalid_status'):
            logger.warning(
                'Can not approve by future team Offer %d. '
                'Budget position status invalid: bp %d',
                offer.id, action.bp_id,
            )
            context = {
                'reason': 'budget_position_invalid_status',
                'bp_id': action.bp_id,
            }
            _notify_analyst(offer.startrek_salary_key, context)
            return
        if err.message == 'startrek_transition_failed':
            logger.warning(
                'Can not approve by future team Offer %d. Startrek transition failed: %s',
                offer.id, offer.vacancy.startrek_key,
            )
            context = {
                'reason': err.message,
                'job_issue_key': offer.vacancy.startrek_key,
            }
            _notify_analyst(offer.startrek_salary_key, context)
            return
        raise


def _notify_analyst(key, context):
    comment = loader.render_to_string(
        template_name='startrek/offers/can-not-approve-by-future-team.wiki',
        context=context,
    )
    add_issue_comment_task.delay(key, comment)


def _check_bp_and_get_errors(offer):
    """
    Проверяет БП на наличие ошибок, не дающих отправить оффер кандидату, и возвращает массив ошибок.
    """
    bp_date = offer.join_at.strftime('%Y-%m-%d') if offer.join_at else None
    try:
        bp = get_budget_position(offer.vacancy.budget_position_id, bp_date)
    except BudgetPositionError as exc:
        logger.warning('Failed to get budget position, %s', exc)
        raise

    errors = []
    # 1. БП в статусе  OFFER
    if bp.get('hiring') != BUDGET_POSITION_STATUSES.offer:
        errors.append('БП не в статусе "OFFER"')

    # 2. Зарплата соответствует условиям оффера
    bp_max_salary = _bp_has_fixed_pay_system(bp) and bp.get('salary')
    if bp_max_salary and offer.salary is not None and offer.salary > bp_max_salary:
        errors.append(
            'Зарплата в БП ({}) не соответствует условиям оффера ({})'
            .format(bp_max_salary, offer.salary)
        )

    # 3. Сумма доступных средств соответствует условиям оффера
    bp_available_funds = _bp_has_fixed_pay_system(bp) and bp.get('availableFunds')
    if bp_available_funds and offer.salary is not None and offer.salary > bp_available_funds:
        errors.append(
            'Сумма доступных средств в БП ({}) не соответствует условиям оффера ({})'
            .format(bp_available_funds, offer.salary)
        )

    # 4. Дата выхода соответствует условиям оффера
    bp_join_at = bp.get('hiringDate')
    if bp_join_at:
        bp_min_date = datetime.strptime(bp_join_at, '%Y-%m-%d').date()
        if offer.join_at < bp_min_date:
            errors.append(
                'Дата выхода в БП ({}) не соответствует условиям оффера ({})'
                .format(bp_min_date, offer.join_at)
            )
    return errors


@action_logged('offer_approve_by_issue')
def offer_approve_by_issue(offer):
    issue = get_issue(offer.vacancy.startrek_key)
    is_issue_data_valid = (
        issue.status.key == StatusEnum.hr_approval
        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 offer by issue. Invalid issue %s status: %s',
            issue.key,
            issue.status.key,
        )
        return

    bp_transaction_data = BPRegistryIssueSerializer(issue).data
    bp_transaction_data.update(BPRegistryOfferSerializer(offer).data)

    try:
        check_placement_existence(offer, issue)
    except BPRegistryError:
        return

    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/offers/bp-registry-failure.wiki', context)
        operation.delay(
            comment=text,
            **{settings.STARTREK_JOB_WORKFLOW_ID_FIELD: 'creation_err'},
        )
        return

    offer.bp_transaction_id = bp_transaction_id
    offer.save(update_fields=['bp_transaction_id', 'modified'])

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


@action_logged('offer_confirm_by_issue')
@transaction.atomic
def offer_confirm_by_issue(offer, issue):
    from intranet.femida.src.offers.workflow import OfferWorkflow

    context = get_base_context()
    if issue.status.key != StatusEnum.resolved:
        logger.warning(
            'Could not confirm offer by issue. Invalid issue %s status: %s',
            issue.key,
            issue.status.key,
        )

        # Для внутренних офферов и для автонайма это тупиковая ситуация,
        # лучше поретраить и узнать о ней через мониторинги.
        if offer.is_internal or offer.is_autohire:
            raise ValidationError('job_invalid_status')

        # Для внешних офферов это просто превалидация,
        # чтобы рекрутер узнал о проблемах до фактического
        # нажатия на кнопку "Отправить", поэтому игнорим
        else:
            return

    context['errors'] = _check_bp_and_get_errors(offer)
    if context['errors']:
        context['instance'] = offer
        operation = IssueTransitionOperation(issue.key)
        operation.delay(
            transition=TransitionEnum.hr_approval,
            comment={
                'text': loader.render_to_string('startrek/offers/cannot-be-sent.wiki', context),
                'summonees': [issue.analyst.id],
            },
        )
        return

    if offer.is_internal:
        # Делаем явное преобразование во внутренний оффер для полной совместимости
        offer = (
            InternalOffer.unsafe
            .select_related(
                'application',
                'vacancy',
                'creator',
            )
            .get(id=offer.id)
        )
        workflow = OfferWorkflow(offer, user=None)
        workflow.perform_action('accept_internal')

    offer_confirmed.send(Offer, offer=offer)


def generate_offer_pdf(offer):
    ctl = OfferCtl(offer)
    offer_data = OebsFormulaOfferSerializer(offer, context={'ctl': ctl}).data
    filename = f'{offer.candidate.first_name} {offer.candidate.last_name}.pdf'
    file = OebsFormulaAPI.generate_offer(filename.strip(), offer_data)
    attached_file = ctl.attach_file(file, choices.OFFER_ATTACHMENT_TYPES.offer_pdf)
    return attached_file


@action_logged('oebs_person_create')
def create_oebs_person(offer: Offer) -> Optional[str]:
    """
    :raise: OebsPersonError
    """
    oebs_data = PersonDataSerializer(offer).data
    oebs_person_id = OebsHireAPI.create_person(oebs_data)

    offer.oebs_person_id = oebs_person_id
    offer.save(update_fields=['oebs_person_id'])

    context = get_base_context()
    context['payment_currency'] = offer.payment_currency.code
    context['oebs_person_id'] = oebs_person_id
    operation = IssueCommentOperation(offer.startrek_hr_key)
    operation.delay(loader.render_to_string(
        template_name='startrek/offers/oebs-person-created.wiki',
        context=context,
    ))
    return oebs_person_id


def create_oebs_person_gracefully(offer: Offer) -> Optional[str]:
    try:
        return create_oebs_person(offer)
    except OebsPersonExistsError as exc:
        operation = IssueCommentOperation(offer.startrek_hr_key)
        operation.delay(loader.render_to_string(
            template_name='startrek/offers/oebs-person-failure.wiki',
            context={'errors': exc.message},
        ))
    except OebsPersonError as exc:
        operation = IssueCommentOperation(offer.startrek_hr_key)
        operation.delay(
            text=loader.render_to_string(
                template_name='startrek/offers/oebs-person-failure.wiki',
                context={'errors': exc.message},
            ),
        )


@action_logged('bp_assignment_create')
def create_bp_assignment(offer: Offer):
    """
    :raise: BPRegistryError
    """
    data = BPAssignmentOfferSerializer(offer).data
    result = BPRegistryAPI.create_bp_assignment(data)

    offer.bp_transaction_id = result['id']
    offer.save(update_fields=['bp_transaction_id'])


def create_bp_assignment_gracefully(offer: Offer):
    try:
        create_bp_assignment(offer)
    except BPRegistryOebsError as exc:
        operation = IssueCommentOperation(offer.startrek_hr_key)
        context = {
            'errors': exc.oebs_internal_errors,
        }
        operation.delay(
            text=loader.render_to_string(
                template_name='startrek/offers/bp-assignment-oebs-failure.wiki',
                context=context,
            ),
        )
    except BPRegistryError:
        # Note: пока даже не пытаемся ретраить, потому что неизвестно,
        # как Я.Найм себя поведёт при повторной попытке создать назначение
        # на те же самые физ.лицо и БП
        operation = IssueCommentOperation(offer.startrek_hr_key)
        operation.delay(
            text=loader.render_to_string('startrek/offers/bp-assignment-failure.wiki'),
        )
    else:
        operation = IssueCommentOperation(offer.startrek_hr_key)
        operation.delay(
            text=loader.render_to_string('startrek/offers/bp-assignment-success.wiki'),
        )


def create_oebs_person_and_assignment(offer, change_hr_issue_status=True):
    if change_hr_issue_status:
        if create_oebs_person_gracefully(offer):
            create_bp_assignment_gracefully(offer)
        return

    try:
        create_oebs_person(offer)
    except OebsPersonError:
        logger.exception('Failed to create oebs person')
        return

    try:
        create_bp_assignment(offer)
    except BPRegistryError:
        logger.exception('Failed to create bp assignment')


class OfferCtl:
    """
    Вся бизнес-логика, связанная с анкетой
    """
    def __init__(self, offer_instance, modifier=None):
        super().__init__()
        self.instance = offer_instance
        self.modifier = modifier
        self.link = LinkCtl(self._link_instance)

    @property
    def _link_instance(self):
        try:
            return self.instance.link
        except Link.DoesNotExist:
            return None

    @property
    def profile(self):
        try:
            return self.instance.profile
        except OfferProfile.DoesNotExist:
            return None

    @property
    def schemes_data(self):
        try:
            return self.instance.schemes_data
        except OfferSchemesData.DoesNotExist:
            return None

    def get_or_create_profile(self):
        if self.profile:
            return self.profile
        return OfferProfile.objects.create(offer=self.instance)

    @cached_property
    def budget_position_data(self):
        return _get_budget_position_data(
            bp_id=self.instance.vacancy.budget_position_id,
            from_date=self.instance.join_at,
        )

    @property
    def has_food_compensation(self):
        if self.schemes_data:
            return self.schemes_data.has_food_compensation
        return bool(self.budget_position_data.get('food_compensation_amount'))

    def _sync_newhire(self, data):
        """
        Запускает синк с Наниматором, если что-то в БД отличается от data.
        """
        assert self.instance.newhire_id is not None
        remote_data = NewhireOfferRemoteSerializer(data, partial=True).data
        stored_data = NewhireOfferStoredSerializer(self.instance).data
        is_anything_changed = any(
            (stored_data[k] or v) and stored_data[k] != v
            for k, v in remote_data.items()
        )
        if is_anything_changed:
            sync_newhire_offer_task.delay(self.instance.id)

    @property
    def _newhire_raw_data(self):
        """
        Сырые данные из Наниматора.
        Кэшируем прямо на уровне инстанса модели,
        чтобы не ходить несколько раз за запрос в Наниматор.
        """
        if hasattr(self.instance, '_newhire_raw_data'):
            return self.instance._newhire_raw_data
        data = {}
        if self.instance.newhire_id:
            data, status_code = NewhireAPI.get_preprofile(self.instance.newhire_id)
            data = data or {}
            self._sync_newhire(data)

        self.instance._newhire_raw_data = data
        return self.instance._newhire_raw_data

    @property
    def newhire_data(self):
        """
        Данные по офферу из Наниматора.
        Если не удалось получить данные, отдает данные из Фемиды.
        :return:
        """
        remote_data = NewhireOfferRemoteSerializer(self._newhire_raw_data, partial=True).data
        stored_data = NewhireOfferStoredSerializer(self.instance).data
        return dict(stored_data, **remote_data)

    @property
    def generated_offer_text(self):
        raw_template = RawTemplate.objects.first()
        context = Context({
            'offer': self.instance,
            'join_isoweekday': get_join_isoweekday(self.instance),
            'fill_form_url': self.link.url,
            'currency': self.budget_position_data.get('payment_currency'),
            'org': self.budget_position_data.get('org'),
            'headcount': self.budget_position_data.get('headcount'),
            'has_food_compensation': self.has_food_compensation,
        })
        text = Template(raw_template.text).render(context)
        text = re.sub(r'\r', '', text)
        text = re.sub(r'\n{2,}', '\n\n', text)

        return text.strip()

    @property
    def offer_text(self):
        raw_template = RawTemplate.objects.first()
        prepared_template = self.instance.templates.filter(
            raw_template=raw_template,
            is_active=True,
        ).first()

        if prepared_template:
            text = prepared_template.text
        else:
            text = self.generated_offer_text

        return text.strip()

    @offer_text.setter
    def offer_text(self, value):
        if self.offer_text == value:
            return

        raw_template = RawTemplate.objects.first()
        self.instance.templates.filter(
            raw_template=raw_template,
            is_active=True,
        ).update(is_active=False)
        self.instance.templates.create(
            raw_template=raw_template,
            text=value,
        )

    def reset_offer_text(self):
        self.instance.templates.update(is_active=False)

    def has_custom_offer_text(self):
        raw_template = RawTemplate.objects.first()
        return self.instance.templates.filter(
            raw_template=raw_template,
            is_active=True,
        ).exists()

    def need_beauty_offer(self):
        beauty_offer_max_grade = 18
        is_yandex_department = (
            self.instance.department and
            self.instance.department.is_in_tree(settings.YANDEX_DEPARTMENT_ID)
        )
        need = (
            self.instance.grade and self.instance.grade <= beauty_offer_max_grade and
            self.instance.org and self.instance.org.is_russian
            and is_yandex_department
        )
        return need

    def set_modifier(self):
        if self.modifier and not self.modifier.is_anonymous:
            self.instance.modifier = self.modifier

    def remove_link(self):
        self.set_modifier()
        self.link.remove()

    def create_link(self):
        assert self.instance.is_external
        self.remove_link()
        self.link = LinkCtl.create(self.instance)

    def activate_link(self, expiration_time=None):
        self.link.activate(expiration_time)

    def update_link_counter(self):
        self.link.update_counter()
        if self.link.is_overused():
            self.remove_link()

    def add_comment(self, text, **comment_data):
        assert self.profile is not None
        return OfferProfileComment.objects.create(
            offer_profile=self.profile,
            text=text,
            **comment_data
        )

    def update_profile(self, data):
        photos = data.pop('photo', [])
        passport_pages = data.pop('passport_pages', [])
        snils = data.pop('snils', [])
        documents = data.pop('documents', [])

        attachment_types_map = {
            choices.OFFER_ATTACHMENT_TYPES.photo: photos,
            choices.OFFER_ATTACHMENT_TYPES.document: documents,
            choices.OFFER_ATTACHMENT_TYPES.passport_page: passport_pages,
            choices.OFFER_ATTACHMENT_TYPES.snils: snils,
        }

        if data:
            update_instance(self.profile, data)

        if any([photos, passport_pages, snils, documents]):
            attachments_data = []
            types_to_delete = set()
            for _type in attachment_types_map:
                for attachment in attachment_types_map[_type]:
                    attachments_data.append({
                        'attachment': attachment,
                        'offer': self.instance,
                        'type': _type,
                    })
                    types_to_delete.add(_type)

            attachments_to_delete = self.instance.offer_attachments.filter(
                Q(type__in=types_to_delete)
                | Q(is_active=False)
            )
            update_list_of_instances(
                model=OfferAttachment,
                queryset=attachments_to_delete,
                data=attachments_data,
            )

    def update(self, data):
        profile_data = data.pop('profile', {})

        # В анкете храним языки храним в ISO 639-1
        main_language = profile_data.pop('main_language', None)
        spoken_languages = profile_data.pop('spoken_languages', None)
        if main_language is not None:
            profile_data['main_language'] = main_language.tag
        if spoken_languages is not None:
            profile_data['spoken_languages'] = [x.tag for x in spoken_languages]

        preferred_first_and_last_name = profile_data.pop('preferred_first_and_last_name', None)
        if preferred_first_and_last_name is not None:
            profile_data['preferred_first_and_last_name'] = " ".join(preferred_first_and_last_name.split())

        comment = data.pop('comment', None)
        abc_serivces = data.pop('abc_services', None)
        schemes_data = data.pop('schemes_data', None)

        if schemes_data:
            data['vmi'] = schemes_data.get('has_health_insurance')

        self.set_modifier()
        self.instance = update_instance(self.instance, data)
        if abc_serivces:
            self.instance.abc_services.set(abc_serivces)

        self.instance.application.save(update_fields=['modified'])
        need_to_update_candidate = (
            self.instance.source != self.instance.candidate.source
            or self.instance.source_description != self.instance.candidate.source_description
        )
        candidate_update_fields = ['modified']
        if need_to_update_candidate:
            self.instance.candidate.source = self.instance.source
            self.instance.candidate.source_description = self.instance.source_description
            candidate_update_fields.extend(['source', 'source_description'])
        self.instance.candidate.save(update_fields=candidate_update_fields)

        if self.profile:
            self.update_profile(profile_data)
            if comment:
                self.add_comment(comment)

        if schemes_data:
            OfferSchemesData.objects.update_or_create(
                offer=self.instance,
                defaults=schemes_data,
            )

        self._remove_attachments([choices.OFFER_ATTACHMENT_TYPES.offer_pdf])
        generate_offer_pdf_task.delay(self.instance.id)

    def attach_file(self, attachment, attachment_type, is_active=True):
        filename, attachment.name = attachment.name, 'file'
        _attachment = Attachment.objects.create(
            name=filename,
            attached_file=attachment,
            uploader=self.instance.modifier
        )
        return OfferAttachment.unsafe.create(
            offer=self.instance,
            attachment=_attachment,
            type=attachment_type,
            is_active=is_active,
        )

    def _remove_attachments(self, types):
        attachments = OfferAttachment.unsafe.filter(
            offer=self.instance,
            type__in=types,
        )
        attachments.delete()

    def remove_documents(self):
        """
        Удаляем все документы.
        К ним относятся как аттачи с типом document,
        так и passport_page и snils.
        """
        self._remove_attachments([
            choices.OFFER_ATTACHMENT_TYPES.document,
            choices.OFFER_ATTACHMENT_TYPES.passport_page,
            choices.OFFER_ATTACHMENT_TYPES.snils,
        ])

    def save_in_newhire(self):
        assert self.instance.newhire_id is None
        data = NewhireOfferBaseSerializer.serialize(self.instance)
        result, status_code = NewhireAPI.create_preprofile(data)

        # При успешном создании препрофайла Наниматор отдает 200.
        # В случае, если мы повторно пытаемся создать препрофайл
        # с уже существующим femida_offer_id (например, при ретрае),
        # Наниматор отдает 409 и id ранее созданного препрофайла.
        if status_code in (200, 409):
            update_instance(self.instance, {
                'newhire_id': result.get('id'),
            })
        else:
            errors = result and result.get('errors')
            raise NewhireError(f'Error while saving offer in newhire: {status_code}, {errors}')

    def update_in_newhire(self, data, sync=True):
        assert self.instance.newhire_id is not None
        # При редактировании оффера в Наниматоре важно иметь свежие данные
        if not self._newhire_raw_data:
            raise NewhireError('newhire_get_preprofile_error')

        # Костыль. Мы все данные берем на лету из Наниматора,
        # а подразделение всегда берем из Фемиды.
        data.setdefault('department', self.instance.department)
        # Если изменилась дата выхода, также пересчитываем дату окончания труд.договора
        if 'join_at' in data and self.instance.is_internship:
            data['contract_end'] = get_offer_contract_end(self.instance, data['join_at'])

        _data = NewhireOfferUpdateSerializer(data, partial=True).data
        newhire_data = dict(self._newhire_raw_data, **_data)
        result, status_code = NewhireAPI.update_preprofile(self.instance.newhire_id, newhire_data)

        if status_code != 200:
            raise NewhireError('newhire_update_preprofile_error')

        self.instance._newhire_raw_data.update(_data)
        if sync:
            self._sync_newhire(_data)

    def cancel_in_newhire(self):
        assert self.instance.newhire_id is not None
        if self.newhire_data['newhire_status'] == choices.OFFER_NEWHIRE_STATUSES.cancelled:
            return
        result, status_code = NewhireAPI.cancel_preprofile(self.instance.newhire_id)
        if status_code != 200:
            raise NewhireError('newhire_cancel_preprofile_error')
        sync_newhire_offer_task.delay(self.instance.id)

    @action_logged('offer_close')
    def _close(self):
        user = User.objects.get(uid=settings.FEMIDA_ROBOT_UID)

        self.instance.status = choices.OFFER_STATUSES.closed
        self.instance.closed_at = timezone.now()
        self.instance.save()

        vacancy_resolutions_map = {
            choices.EMPLOYEE_TYPES.rotation: VACANCY_RESOLUTIONS.rotation,
            choices.EMPLOYEE_TYPES.intern: VACANCY_RESOLUTIONS.intern_transfer,
        }
        vacancy_resolution = vacancy_resolutions_map.get(
            self.instance.employee_type,
            VACANCY_RESOLUTIONS.hire,
        )

        self.instance.vacancy.status = VACANCY_STATUSES.closed
        self.instance.vacancy.resolution = vacancy_resolution
        self.instance.vacancy.save()

        archive_vacancy_publications(self.instance.vacancy)

        self.instance.application.status = APPLICATION_STATUSES.closed
        if self.instance.is_internal:
            self.instance.application.resolution = APPLICATION_RESOLUTIONS.rotated
        else:
            self.instance.application.resolution = APPLICATION_RESOLUTIONS.offer_accepted
        self.instance.application.save()

        self.remove_documents()

        close_candidate = CandidateWorkflow(
            instance=self.instance.candidate,
            user=user,
        ).get_action('close')

        if close_candidate.is_available():
            if self.instance.is_internal:
                consideration_resolution = CONSIDERATION_RESOLUTIONS.rotated
            else:
                consideration_resolution = CONSIDERATION_RESOLUTIONS.offer_accepted
            close_candidate.perform(
                application_resolution=APPLICATION_RESOLUTIONS.no_offer,
                consideration_resolution=consideration_resolution,
                is_hired=True,
            )

        # Закрываем все оставшиеся на вакансии прет-ва
        close_vacancy_applications(self.instance.vacancy)

        self.instance.candidate.login = self.instance.username
        self.instance.candidate.save()

        context = get_base_context()
        context['offer'] = self.instance
        comment = loader.render_to_string('startrek/offers/closed.wiki', context)

        execute_adaptation_issue_transition(
            offer=self.instance,
            transition=TransitionEnum.added_to_staff,
            comment=comment,
        )

        # Отправляем коммент о выводе на Стафф
        issues_to_comment = (
            'startrek_relocation_key',
            'startrek_signup_key',
            'startrek_salary_key',
            'startrek_bootcamp_key',
            'startrek_eds_key',
            'startrek_bonus_key',
        )
        for field in issues_to_comment:
            key = getattr(self.instance, field)
            if key:
                add_issue_comment_task.delay(key, comment)

        is_yandex_or_outstaff = self.instance.department.is_in_trees(INTERNAL_DEPARTMENT_IDS)
        if is_yandex_or_outstaff:
            OfferHRSurveyRecruiterNotification(self.instance).send()
            OfferHRSurveyHiringManagerNotification(self.instance).send()

        # TODO: Убрать проверку свитча после релиза FEMIDA-7118
        if waffle.switch_is_active(TemporarySwitch.REFERENCE_ACHIEVEMENTS):
            AcceptedOfferReferenceAchievement(offer_id=self.instance.id).give_all_delay()

        offer_closed.send(Offer, offer=self.instance)

    @action_logged('offer_confirm_by_boss')
    def _confirm_by_boss(self):
        if self.instance.startrek_hr_key:
            operation = IssueTransitionOperation(self.instance.startrek_hr_key)
            tasks_chain = (
                operation.si(transition=TransitionEnum.new)
                | check_oebs_login_task.si(self.instance.id)
            )
            tasks_chain.delay()
        elif self.instance.is_external:
            raise WorkflowError('hr_issue_does_not_exist')

        execute_adaptation_issue_transition(
            offer=self.instance,
            transition=TransitionEnum.approved_by_head,
            bossConfirmationDate=timezone.now().strftime('%Y-%m-%d'),
        )

        offer_newhire_approved.send(Offer, offer=self.instance)

    @action_logged('offer_confirm_by_helpdesk')
    def _confirm_by_helpdesk(self):
        comment = loader.render_to_string('startrek/offers/newhire-ready.wiki')
        add_issue_comment_task.delay(self.instance.startrek_adaptation_key, comment)
        offer_newhire_ready.send(Offer, offer=self.instance)

    def _process_newhire_status_change(self, old_status):
        old_status = old_status or choices.OFFER_NEWHIRE_STATUSES.new
        new_status = self.instance.newhire_status

        handlers_map = OrderedDict((
            (choices.OFFER_NEWHIRE_STATUSES.new, lambda: None),
            (choices.OFFER_NEWHIRE_STATUSES.approved, self._confirm_by_boss),
            (choices.OFFER_NEWHIRE_STATUSES.ready, self._confirm_by_helpdesk),
            (choices.OFFER_NEWHIRE_STATUSES.closed, self._close),
        ))

        if new_status not in handlers_map or old_status not in handlers_map:
            return

        newhire_statuses = list(handlers_map.keys())
        old_status_index = newhire_statuses.index(old_status)
        new_status_index = newhire_statuses.index(new_status)
        for s in newhire_statuses[old_status_index + 1: new_status_index + 1]:
            handler = handlers_map[s]
            handler()

    def _process_username_change(self, old_username):
        context = get_base_context()
        context['instance'] = self.instance
        comment = loader.render_to_string('startrek/offers/username-changed.wiki', context)

        issues_to_comment = (
            'startrek_adaptation_key',
            'startrek_relocation_key',
            'startrek_signup_key',
            'startrek_bonus_key',
            'startrek_bootcamp_key',
        )
        for field in issues_to_comment:
            key = getattr(self.instance, field)
            if key:
                add_issue_comment_task.delay(key, comment)

        issues_to_update = (
            'startrek_hr_key',
            'startrek_eds_key',
        )
        for field in issues_to_update:
            key = getattr(self.instance, field)
            if not key:
                continue
            operation = IssueUpdateOperation(key)
            operation.delay(
                comment=comment,
                userLogin=self.instance.username,
            )

        # Обновляем логин в Я.Банках
        update_bank_login_task.delay(old_username, self.instance.username)

        # Обновляем логин в Я.Найме
        if self.instance.oebs_person_id:
            update_oebs_login_task.delay(
                person_id=self.instance.oebs_person_id,
                login=self.instance.username,
            )

    def _process_join_at_change(self, old_join_at):
        start = self.instance.join_at.strftime('%Y-%m-%d')
        context = get_base_context()
        context['instance'] = self.instance
        comment = loader.render_to_string('startrek/offers/join-at-changed.wiki', context)

        # В JOB-тикете не нужен коммент
        operation = IssueUpdateOperation(self.instance.vacancy.startrek_key)
        operation.delay(start=start)

        issues_to_update = (
            'startrek_hr_key',
            'startrek_relocation_key',
            'startrek_signup_key',
            'startrek_bonus_key',
            'startrek_bootcamp_key',
            'startrek_eds_key',
        )
        for field in issues_to_update:
            key = getattr(self.instance, field)
            if key:
                update_issue_task.delay(
                    keys=key,
                    start=start,
                    comment=comment,
                )

        change_reference_issue_join_at_task.delay(self.instance.candidate_id, start)

        if self.instance.startrek_adaptation_key:
            adaptation_fields = serialize_offer_for_adaptation_issue_update(self.instance)
            update_issue_task.delay(self.instance.startrek_adaptation_key, **adaptation_fields)

        if (self.instance.startrek_salary_key
                and waffle.switch_is_active('enable_salary_issue_update')):
            update_issue_task.delay(
                keys=self.instance.startrek_salary_key,
                staffDate=start,
            )

        # Обновляем дату выхода в тикетах ROTATION и MYROTATION
        rotation = self.instance.application.consideration.rotation
        if rotation:
            for key in (rotation.startrek_rotation_key, rotation.startrek_myrotation_key):
                update_issue_task.delay(
                    keys=key,
                    start=start,
                )

        # Обновляем дату выхода в Я.Найме
        if self.instance.oebs_person_id:
            update_oebs_assignment_task.delay(self.instance.id)

    def _process_startrek_hdrfs_key_change(self, old_value):
        is_issues_linking_needed = bool(
            self.instance.startrek_hdrfs_key
            and self.instance.is_external
            and is_adaptation_needed(self.instance)
        )
        if is_issues_linking_needed:
            link_offer_issues_task.delay(
                offer_id=self.instance.id,
                attr1='startrek_hdrfs_key',
                attr2='startrek_adaptation_key',
            )

    @transaction.atomic
    def _update_from_newhire(self, data):
        remote_data = NewhireOfferRemoteSerializer(data, partial=True).data
        stored_data = NewhireOfferStoredSerializer(self.instance).data
        raw_changed_data = {
            k: v for k, v in remote_data.items()
            if (v or stored_data[k]) and v != stored_data[k]
        }
        if not raw_changed_data:
            return

        # Тип вывода и логин для внутреннего оффера меняться не могут,
        # но на всякий случай явно их убираем
        if self.instance.is_internal:
            raw_changed_data.pop('username', None)
            raw_changed_data.pop('employee_type', None)

        validator = NewhireOfferRemoteForm(raw_changed_data, partial=True)
        if not validator.is_valid():
            raise ValidationError('Error validating newhire data: %s' % raw_changed_data)
        changed_data = validator.cleaned_data

        field_change_handlers = OrderedDict((
            ('username', self._process_username_change),
            ('join_at', self._process_join_at_change),
            ('newhire_status', self._process_newhire_status_change),
            ('startrek_hdrfs_key', self._process_startrek_hdrfs_key_change),
        ))

        update_instance(self.instance, changed_data)
        for field, handler in field_change_handlers.items():
            if field in changed_data:
                handler(stored_data[field])

    @classmethod
    def update_from_newhire_with_lock(cls, offer_id, data, lock_timeout=10, retry_delay=60):
        """
        Синк с Наниматором через lock, чтобы не синкать одновременно один и тот же оффер.
        Если залочено, все равно синкаем, но отложено (через 1 минуту).
        Оффер из БД получаем уже под локом, чтобы избежать одновременных синков
        актуальной и устаревшей версии оффера.
        """
        lock_name = f'{settings.DEPLOY_STAGE_ID}.update_offer_from_newhire.{offer_id}'
        logger.info('Acquiring lock `%s`', lock_name)
        with lock_manager.lock(lock_name, lock_timeout) as acquired:
            if acquired:
                instance = Offer.unsafe.get(id=offer_id)
                ctl = cls(instance)
                ctl._update_from_newhire(data)
            else:
                logger.info('Lock `%s` NOT acquired', lock_name)
                sync_newhire_offer_task.apply_async(
                    countdown=retry_delay,
                    kwargs={
                        'offer_id': offer_id,
                    },
                )

    def reject(self, data, initiator):
        data['creator'] = initiator
        data['modifier'] = initiator

        rejection, created = OfferRejection.objects.get_or_create(
            offer=self.instance,
            defaults=data,
        )

        if not created:
            data.pop('creator')
            for k, v in data.items():
                setattr(rejection, k, v)
            rejection.save()

        return rejection


class PreprofileCtl:
    """
    Бизнес-логика препрофайла в Наниматоре
    """
    def __init__(self, instance):
        self.instance = instance
        self.link = LinkCtl(self._link_instance)

    @property
    def _link_instance(self):
        try:
            return self.instance.link
        except Link.DoesNotExist:
            return None

    @property
    def newhire_data(self):
        """
        Данные из Наниматора.
        Кэшируем прямо на уровне инстанса модели,
        чтобы не ходить несколько раз за запрос в Наниматор.
        """
        if hasattr(self.instance, '_newhire_data'):
            return self.instance._newhire_data
        raw_data, status_code = NewhireAPI.get_preprofile(self.instance.id)
        data = dict(NewhirePreprofileRemoteSerializer(raw_data or {}, partial=True).data)
        self.instance._newhire_data = data
        return self.instance._newhire_data

    @property
    def newhire_status(self):
        return self.newhire_data.get('newhire_status')

    @classmethod
    def get_or_create(cls, data, force_generate_link=True):
        preprofile_id = data.pop('id')
        preprofile, created = Preprofile.objects.get_or_create(
            id=preprofile_id,
            defaults=data,
        )
        ctl = cls(preprofile)

        # Генерим новую ссылку
        if force_generate_link or created:
            ctl.create_link()

        return ctl

    def remove_link(self):
        self.link.remove()

    def create_link(self):
        self.remove_link()
        link_instance = Link.objects.create(preprofile=self.instance)
        self.link = LinkCtl(link_instance)
        self.link.activate()

    def update_link_counter(self):
        self.link.update_counter()
        if self.link.is_overused():
            self.remove_link()

    def attach_file(self, attachment, attachment_type, is_active=True):
        filename, attachment.name = attachment.name, 'file'
        _attachment = Attachment.objects.create(
            name=filename,
            attached_file=attachment,
        )
        return PreprofileAttachment.objects.create(
            preprofile=self.instance,
            attachment=_attachment,
            type=attachment_type,
            is_active=is_active,
        )

    def activate_attachments(self, attachment_ids):
        preprofile_attachments = (
            self.instance.preprofile_attachments
            .filter(attachment__in=attachment_ids)
        )
        preprofile_attachments.update(is_active=True)

        inactive_preprofile_attachments = (
            self.instance.preprofile_attachments
            .exclude(attachment__in=attachment_ids)
        )
        inactive_preprofile_attachments.delete()

    def accept(self, data):
        attachment_fields = (
            'photo',
            'passport_pages',
            'snils',
            'documents',
        )
        attachments = itertools.chain.from_iterable(data.pop(f, []) for f in attachment_fields)
        attachment_ids = [a.id for a in attachments]
        self.activate_attachments(attachment_ids)

        bank_details = data.pop('bank_details')
        is_bank_details_needed = self.newhire_data['org'] != settings.EXTERNAL_ORGANIZATION_ID

        # Банковские реквизиты не нужны, либо указаны все нужные
        has_bank_details = not is_bank_details_needed or all(bank_details.values())

        self.instance.is_eds_phone_verified = (
            self.instance.is_eds_phone_verified
            and data['is_eds_needed']
            and data.get('phone') == self.instance.eds_phone
        )

        self.remove_link()
        self.instance.data = PreprofileSerializer(data).data
        self.instance.save()

        valid_statuses = (
            choices.OFFER_NEWHIRE_STATUSES.new,
            choices.OFFER_NEWHIRE_STATUSES.prepared,
        )
        if self.newhire_status in valid_statuses:
            tasks_chain = (
                accept_preprofile_task.si(self.instance.id)
                | create_preprofile_hr_issue_task.si(
                    preprofile_id=self.instance.id,
                    resubmit=bool(self.instance.startrek_hr_key),
                    has_bank_details=has_bank_details,
                )
                | create_preprofile_eds_issue_task.si(preprofile_id=self.instance.id)
            )

            if is_bank_details_needed:
                tasks_chain |= push_preprofile_bank_details.si(self.instance.id, bank_details)

            tasks_chain.delay()

    def _cancel(self):
        keys = (self.instance.startrek_hr_key, self.instance.startrek_eds_key)
        for key in keys:
            if not key:
                continue
            add_issue_comment_task.delay(
                keys=key,
                text=loader.render_to_string('startrek/preprofiles/cancelled.wiki'),
            )

    def _process_newhire_status_change(self, old_status):
        old_status = old_status or choices.OFFER_NEWHIRE_STATUSES.new
        new_status = self.instance.newhire_status
        if new_status == choices.OFFER_NEWHIRE_STATUSES.cancelled:
            self._cancel()
            return

        # Note: Я пока не удаляю эту бесполезную ерунду,
        # потому что на подходе адаптеры, которые хотят тикеты,
        # и для них это, скорее всего, пригодится
        handlers_map = OrderedDict((
            (choices.OFFER_NEWHIRE_STATUSES.new, lambda: None),
            (choices.OFFER_NEWHIRE_STATUSES.prepared, lambda: None),
            (choices.OFFER_NEWHIRE_STATUSES.approved, lambda: None),
            (choices.OFFER_NEWHIRE_STATUSES.ready, lambda: None),
            (choices.OFFER_NEWHIRE_STATUSES.closed, lambda: None),
        ))

        if new_status not in handlers_map or old_status not in handlers_map:
            return

        newhire_statuses = list(handlers_map.keys())
        old_status_index = newhire_statuses.index(old_status)
        new_status_index = newhire_statuses.index(new_status)
        for s in newhire_statuses[old_status_index + 1: new_status_index + 1]:
            handler = handlers_map[s]
            handler()

    def _process_username_change(self, old_username):
        # Если username проставляется впервые, коммент в тикет не нужен
        if not old_username:
            return
        new_username = self.instance.data['username']
        context = get_base_context()
        context['instance'] = self.instance
        comment = loader.render_to_string('startrek/preprofiles/username-changed.wiki', context)
        issues_to_update = (
            'startrek_hr_key',
            'startrek_eds_key',
        )
        for field in issues_to_update:
            key = getattr(self.instance, field)
            if not key:
                continue
            operation = IssueUpdateOperation(key)
            operation.delay(
                comment=comment,
                userLogin=new_username,
            )

        # Обновляем логин в Я.Банках
        update_bank_login_task.delay(old_username, new_username)

    def _process_join_at_change(self, old_join_at):
        update_preprofile_issues_task.delay(self.instance.id)
        context = get_base_context()
        context['instance'] = self.instance
        comment = loader.render_to_string('startrek/preprofiles/join-at-changed.wiki', context)
        add_issue_comment_task.delay(self.instance.startrek_hr_key, comment)
        if self.instance.startrek_eds_key:
            add_issue_comment_task.delay(self.instance.startrek_eds_key, comment)

    @transaction.atomic
    def update_from_newhire(self, data):
        field_change_handlers = OrderedDict((
            ('username', self._process_username_change),
            ('join_at', self._process_join_at_change),
            ('newhire_status', self._process_newhire_status_change),
        ))

        # Нас интересуют только поля, для которых есть действия
        remote_data = NewhirePreprofileRemoteSerializer(data, partial=True).data
        remote_data = {k: v for k, v in remote_data.items() if k in field_change_handlers}

        stored_data = dict(self.instance.data)
        changed_data = {
            k: v for k, v in remote_data.items()
            if (v or stored_data.get(k)) and v != stored_data.get(k)
        }
        if not changed_data:
            return

        self.instance.data.update(changed_data)
        self.instance.save()

        for field, handler in field_change_handlers.items():
            if field in changed_data:
                handler(stored_data.get(field))


class RemotePreprofile:
    """
    Симулирует поведение препрофайла,
    как будто он нечто, похожее на оффер, хранящееся в БД.
    """
    def __init__(self, instance):
        self._instance = instance
        self._ctl = PreprofileCtl(instance)

        data = dict(instance.data or {}, **self._ctl.newhire_data)
        validator = NewhirePreprofileRemoteForm(data)
        if not validator.is_valid():
            raise NewhireError('newhire_preprofile_invalid_data: %s' % validator.errors_as_dict())

        self._data = validator.cleaned_data

    def __getattr__(self, item):
        if item in self._data:
            return self._data[item]
        if hasattr(self._instance, item):
            return getattr(self._instance, item)
        raise AttributeError("'RemotePreprofile' object has no attribute '%s'" % item)

    @property
    def full_name(self):
        fio = [p for p in (self.last_name, self.first_name, self.middle_name) if p]
        return ' '.join(fio)

    @property
    def org_id(self):
        return self.org.id


class LinkCtl:

    def __init__(self, instance=None):
        super().__init__()
        self.instance = instance

    @classmethod
    def create(cls, offer):
        instance = Link.objects.create(offer=offer)
        return cls(instance)

    @property
    def url(self):
        if not self.instance:
            return ''
        viewname = (
            'frontend-external-offers-accept'
            if self.instance.offer_id is not None
            else 'frontend-external-preprofiles-accept'
        )
        return '{protocol}://{host}{url}'.format(
            protocol=settings.FEMIDA_PROTOCOL,
            host=settings.FEMIDA_EXT_HOST,
            url=reverse(viewname, kwargs={'uid': self.instance.uid.hex}),
        )

    def activate(self, expiration_time=None):
        assert self.instance is not None
        self.instance.version = 0
        self.instance.expiration_time = (
            expiration_time
            or shifted_now(days=settings.OFFER_LINK_LIFETIME_DAYS)
        )
        self.instance.save()

    def remove(self):
        if self.instance:
            self.instance.delete()

    def is_overused(self):
        return self.instance.version >= settings.OFFER_LINK_MAX_REUSE_COUNT

    def update_counter(self):
        assert self.instance is not None
        self.instance.version += 1
        self.instance.save(force_update=True)


class OfferSchemesController:

    def __init__(self, offer_instance, params: dict):
        self._offer_instance = offer_instance
        self._offer_update_params = params

    @property
    def _updated_department(self):
        return self._offer_update_params.get('department')

    @property
    def _updated_grade(self):
        return self._offer_update_params.get('grade')

    @property
    def _updated_profession(self):
        return self._offer_update_params.get('profession')

    @property
    def _updated_is_main_work_place(self):
        return self._offer_update_params.get('is_main_work_place')

    @property
    def _updated_contract_term(self):
        return self._offer_update_params.get('contract_term')

    @property
    def _updated_contract_term_date(self):
        return self._offer_update_params.get('contract_term_date')

    @property
    def _updated_contract_type(self):
        return self._offer_update_params.get('contract_type')

    @property
    def _schemes_data(self):
        try:
            return self._offer_instance.schemes_data
        except OfferSchemesData.DoesNotExist:
            return None

    @property
    def _has_all_info_for_request(self):
        return (
            self._department_for_request
            and self._grade_for_request
            and self._occupation_for_request
        )

    @property
    def _something_changed(self):
        if self._updated_department != self._offer_instance.department:
            return True

        if self._updated_grade != self._offer_instance.grade:
            return True

        if self._updated_profession != self._offer_instance.profession:
            return True

        if self._updated_is_main_work_place != self._offer_instance.is_main_work_place:
            return True

        if self._updated_contract_term != self._offer_instance.contract_term:
            return True

        if self._updated_contract_term_date != self._offer_instance.contract_term_date:
            return True

        if self._updated_contract_type != self._offer_instance.contract_type:
            return True

        return False

    @property
    def _department_for_request(self):
        return self._updated_department or self._offer_instance.department

    @property
    def _grade_for_request(self):
        return self._updated_grade or self._offer_instance.grade

    @property
    def _occupation_for_request(self):
        return self._updated_profession or self._offer_instance.profession

    @property
    def _is_main_work_place_for_request(self):
        if self._updated_is_main_work_place is not None:
            return self._updated_is_main_work_place

        return self._offer_instance.is_main_work_place

    @property
    def _contract_type_for_request(self):
        return self._updated_contract_type or self._offer_instance.contract_type

    @property
    def _contract_term_for_request(self):
        if self._contract_type_for_request == choices.CONTRACT_TYPES.fixed_term:
            return self._updated_contract_term or self._offer_instance.contract_term

        return None

    @property
    def _contract_term_date_for_request(self):
        if self._contract_type_for_request == choices.CONTRACT_TYPES.fixed_term_date:
            return self._updated_contract_term_date or self._offer_instance.contract_term_date

        return None

    def new_schemes_should_be_requested(self) -> bool:
        if not self._has_all_info_for_request:
            return False

        return not self._schemes_data or self._something_changed

    def request_schemes_from_staff(self) -> dict:
        response_data = BPRegistryAPI.get_review_scheme(
            self._department_for_request.id,
            self._grade_for_request,
            self._occupation_for_request.staff_id,
        )
        review_scheme_data = {
            'review_scheme_id': response_data.get('scheme_id'),
            'has_review': response_data.get('has_review'),
            'review_scheme_name': response_data.get('name'),
            'review_bonus': response_data.get('review_bonus'),
        }

        response_data = BPRegistryAPI.get_bonus_scheme(
            self._department_for_request.id,
            self._grade_for_request,
            self._occupation_for_request.staff_id,
        )

        bonus_scheme_data = {
            'bonus_scheme_id': response_data.get('scheme_id'),
            'non_review_bonus': response_data.get('non_review_bonus'),
            'bonus_scheme_name': response_data.get('name'),
        }

        response_data = BPRegistryAPI.get_reward_scheme(RewardSchemeRequest(
            department_id=self._department_for_request.id,
            grade_level=self._grade_for_request,
            occupation_staff_id=self._occupation_for_request.staff_id,
            budget_position_id=self._offer_instance.vacancy.budget_position_id,
            is_internship=self._offer_instance.is_internship,
            is_main_work_place=self._is_main_work_place_for_request,
            contract_term=self._contract_term_for_request,
            contract_term_date=self._contract_term_date_for_request,
        ))

        reward_scheme_data = {
            'reward_scheme_id': response_data.get('scheme_id'),
            'has_food_compensation': response_data.get('has_food_compensation'),
            'food_scheme': response_data.get('food'),
            'has_health_insurance': response_data.get('has_health_insurance', False),
            'has_life_insurance': response_data.get('has_life_insurance', False),
            'reward_scheme_name': response_data.get('name'),
            'is_reward_category_changed': response_data.get('category_changed', False),
            'reward_category': response_data.get('category'),
            'current_reward_category': response_data.get('current_category'),
        }
        return {**review_scheme_data, **bonus_scheme_data, **reward_scheme_data}
