import json
import logging
from typing import Any, Dict, List, Optional, Tuple

import xlrd
import yenv

from django.contrib.auth.decorators import permission_required
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
from django.views.decorators.http import require_http_methods

from staff.budget_position.workflow_service import OEBSError
from staff.departments.controllers.person_action_validator import PersonActionValidator, PersonActionValidationError
from staff.departments.models import ProposalMetadata
from staff.lib import waffle
from staff.lib.db import atomic
from staff.lib.decorators import paginated, responding_json
from staff.lib.forms import errors as form_errors
from staff.lib.log import log_context

from staff.departments.controllers import tickets
from staff.departments.controllers.exceptions import BpConflict, ProposalCtlError
from staff.departments.controllers.proposal import ProposalCtl
from staff.departments.edit.proposal_mongo import get_mongo_objects
from staff.lib.oebs import wrap_oebs_errors
from staff.person.models import Staff

from staff.proposal import tasks
from staff.proposal.controllers import ProposalTasks
from staff.proposal.controllers.proposal_splitting import ProposalSplitting
from staff.proposal.controllers.proposal_usecases import AddPersonChangeUseCase
from staff.proposal.forms.conversions import get_initial_old_style
from staff.proposal.forms.persons_to_split import PersonsToSplitForm
from staff.proposal.forms.proposal import ProposalForm
from staff.proposal.forms.import_forms import (
    FileImportForm,
    MassChangeDepartmentForm,
    MassRecalculateSchemesForm,
    MassChangeOfficeSchemesForm,
)


logger = logging.getLogger('new_proposal')


def _response_400_on_bp_conflict(bp_exc: BpConflict) -> Tuple[Dict, int]:
    return form_errors.sform_general_error(
        err_code='bp_conflict_error',
        params={
            'message': 'bp_conflict_error',
            'meta': bp_exc.meta,
        }
    ), 400


def _response_400_on_linking_ticket_error(ve_exc: ValueError) -> Tuple[Dict, int]:
    return form_errors.sform_single_field_error(
        field='link_to_ticket',
        err_code='proposal_with_personal_tickets_cannot_be_linked',
        params={
            'message': 'proposal with personal tickets cannot be linked to another ticket',
            'personal_ticket_logins': list(ve_exc.args),
        }
    ), 400


def check_linking_to_another_ticket(proposal_ctl: ProposalCtl) -> None:
    """
    В случае когда пытаемся привязать заявку с персональными согласованиями к существующему тикету,
        райзит ValueError c логинами сотрудников, по которым создались бы персональные тикеты.
    """
    if not proposal_ctl.proposal_object['tickets'].get('department_linked_ticket'):
        return
    dispatcher = tickets.ProposalTicketDispatcher.from_proposal_ctl(proposal_ctl)
    personal_ticket_logins = [act['login'] for act in dispatcher.personal]
    if personal_ticket_logins or not dispatcher.is_r15n_ticket_needed:
        raise ValueError(*personal_ticket_logins)


@ensure_csrf_cookie
@require_http_methods(['GET', 'POST'])
@responding_json
@wrap_oebs_errors
def add_proposal(request):
    """Создание заявки"""

    if request.method == 'GET':
        form = ProposalForm.for_user(request.user)
        return {
            'structure': form.structure_as_dict(),
            'meta': {},
        }

    try:
        new_proposal_data = json.loads(request.body)
    except ValueError:
        logger.exception('Wrong JSON: %s', request.body)
        return form_errors.invalid_json_error(request.body), 400

    author_person = request.user.get_profile()

    proposal_form = ProposalForm.for_user(request.user, data=new_proposal_data)

    if not proposal_form.is_valid():
        if author_person.login in ['dshtan', 'denis-p']:  # Remove after TOOLSUP-117741
            logger.info('Wrong form %s', new_proposal_data)
        return proposal_form.errors_as_dict(), 400

    proposal_ctl = (
        ProposalCtl(author=author_person)
        .create_from_cleaned_data(proposal_params=proposal_form.cleaned_data_old_style)
    )

    try:
        check_linking_to_another_ticket(proposal_ctl)
        proposal_id = proposal_ctl.save()
    except BpConflict as e:
        return _response_400_on_bp_conflict(e)
    except ValueError as linking_exception:
        return _response_400_on_linking_ticket_error(linking_exception)

    try:
        if not waffle.switch_is_active('proposal_skip_ticket_creating'):
            tasks.create_proposal_tickets(proposal_id)
    except Exception as e:
        proposal_ctl.delete()
        logger.exception('Error creating tickets: `%s`', str(e))
        return form_errors.sform_general_error(
            err_code='error_communicating_with_startrek',
            params={'message': str(e)},
        ), 503

    return {
        'proposal_uid': proposal_id,
    }


def get_proposal_data(proposal_ctl: ProposalCtl, can_edit: bool, author_user) -> Dict[str, Any]:
    initial_data = get_initial_old_style(proposal_ctl)

    proposal_data = ProposalForm(
        initial_old_style=initial_data,
        base_initial={
            '_id': proposal_ctl.proposal_id,
            'author_user': author_user,
            'locked_proposal': proposal_ctl.locked,
        },
    ).as_dict()

    proposal_data['tickets'] = {
        'departments': proposal_ctl.dep_ticket,
        'persons': proposal_ctl.proposal_object['tickets'].get('persons', {}),
        'restructurisation': proposal_ctl.proposal_object['tickets'].get('restructurisation', ''),
        'headcount': proposal_ctl.proposal_object['tickets'].get('headcount', ''),
        'value_stream': proposal_ctl.proposal_object['tickets'].get('value_stream', ''),
    }
    applied_at = proposal_ctl.applied_at()
    has_execute_perm = author_user.has_perm('django_intranet_stuff.can_execute_department_proposals')
    can_execute = has_execute_perm and not (applied_at or proposal_ctl.locked)
    can_split = can_execute and ProposalSplitting(proposal_ctl).can_split()
    is_locked = proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_ctl.proposal_id)

    proposal_data['state'] = {
        'applied': proposal_ctl.applied_at(),
        'locked': proposal_ctl.locked,
        'can_split': can_split,
        'permissions': {
            'execute': can_execute,
            'edit': can_edit and not (applied_at or is_locked),
            'cancel': can_edit and not (applied_at or is_locked),
        }
    }
    return proposal_data


def create_proposal_ctl(proposal_id: str) -> Tuple[Optional[ProposalCtl], bool]:
    try:
        proposal_ctl = ProposalCtl(proposal_id=proposal_id)
    except ProposalCtlError as e:
        if e.code == 'proposal_does_not_exist':
            not_found = True
            return None, not_found
        else:
            raise e

    not_found = False
    return proposal_ctl, not_found


def _can_edit(person: Staff, proposal_ctl: ProposalCtl):
    can_edit = (
        proposal_ctl.author == person or
        person.user.has_perm('django_intranet_stuff.can_manage_department_proposals')
    )
    return can_edit


@ensure_csrf_cookie
@require_http_methods(['GET', 'POST'])
@responding_json
@wrap_oebs_errors
def edit_proposal(request, proposal_id):
    """Редактирование заявки"""
    proposal_ctl, not_found = create_proposal_ctl(proposal_id)
    if not_found:
        return {'error': 'proposal_does_not_exist'}, 404

    if proposal_ctl.is_deleted:
        return {'error': 'proposal_is_deleted'}, 404

    person = request.user.get_profile()
    can_edit = _can_edit(person, proposal_ctl)

    if not can_edit:
        raise PermissionDenied()

    with log_context(proposal_id=proposal_id, expected_exceptions=[OEBSError]):
        applied_at = proposal_ctl.applied_at()

        if request.method == 'GET':
            return get_proposal_data(proposal_ctl, can_edit=can_edit, author_user=request.user)

        # POST
        # Пока что считаем выполнение SyncProposalTickets тоже залоченным состоянием.
        is_locked = proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)
        if is_locked:
            return form_errors.sform_general_error(
                err_code='proposal_is_locked',
                params={'locked': proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)}
            ), 400

        if applied_at:
            raise PermissionDenied()

        try:
            new_proposal_data = json.loads(request.body)
        except ValueError:
            logger.exception('Wrong JSON: %s', request.body)
            return form_errors.invalid_json_error(request.body), 400

        initial_data = get_initial_old_style(proposal_ctl)

        proposal_form = ProposalForm(
            initial_old_style=initial_data,
            data=new_proposal_data,
            base_initial={
                '_id': proposal_id,
                'author_user': request.user,
                'locked_proposal': is_locked,
            },
        )

        if not proposal_form.is_valid():
            proposal_form.errors_as_dict()
            return proposal_form.errors_as_dict(), 400

        proposal_diff = proposal_ctl.update(proposal_form.cleaned_data_old_style)
        # person and vacancies can preserve even if empty
        # need to pop them to check if diff for other actions exists

        person_diff = proposal_diff.pop('person_actions_diff', {'created': [], 'deleted': [], 'updated': []})

        try:
            proposal_ctl.save()
        except BpConflict as e:
            return _response_400_on_bp_conflict(e)

        if proposal_diff or any(person_diff.values()):
            kwargs = {
                'author_login': request.user.username,
                'proposal_id': proposal_id,
                'proposal_diff': proposal_diff,
                'updated_logins': person_diff['updated'],
                'deleted_logins': person_diff['deleted'],
            }

            if yenv.type == 'development':
                tasks.SyncProposalTickets(**kwargs)
            else:
                proposal_metadata = ProposalMetadata.objects.get(proposal_id=proposal_id)
                ProposalTasks.schedule_ordered_task(
                    proposal_metadata.id,
                    tasks.SyncProposalTickets,
                    kwargs,
                )

        return {'proposal_uid': proposal_ctl.proposal_id}


@ensure_csrf_cookie
@require_http_methods(['GET'])
@responding_json
def debug_flow_context(request, proposal_id):
    """Редактирование заявки"""
    proposal_ctl, not_found = create_proposal_ctl(proposal_id)
    if not_found:
        return {'error': 'proposal_does_not_exist'}, 404

    if proposal_ctl.is_deleted:
        return {'error': 'proposal_is_deleted'}, 404

    person = request.user.get_profile()
    can_edit = _can_edit(person, proposal_ctl)

    if not can_edit:
        raise PermissionDenied()

    with log_context(proposal_id=proposal_id):
        return {'params': {
            'ticket_type': '',
            'ticket_person': '',
            **proposal_ctl.get_all_actions().as_dict()
        }}


def _validate_person_actions(proposal_ctl, person_action_validator: PersonActionValidator):
    errors = dict()

    for index, action in enumerate(proposal_ctl.person_actions):
        try:
            person_action_validator.validate(action)
        except PersonActionValidationError as e:
            errors[f'persons[actions][{index}]{e.field}'] = [{'code': e.code}]
            if e.params:
                errors[f'persons[actions][{index}]{e.field}'][0]['params'] = e.params

    if errors:
        return {'errors': errors}

    return None


@ensure_csrf_cookie
@require_http_methods(['POST'])
@responding_json
@permission_required('django_intranet_stuff.can_execute_department_proposals')
@wrap_oebs_errors
def execute(request, proposal_id):
    """Выполнение заявки"""

    proposal_ctl, not_found = create_proposal_ctl(proposal_id)
    if not_found:
        return {'error': 'proposal_does_not_exist'}, 404

    if proposal_ctl.locked:
        return form_errors.sform_general_error(
            err_code='proposal_is_locked',
            params={'locked': proposal_ctl.locked}
        ), 400

    if proposal_ctl.applied_at():
        return form_errors.sform_general_error(
            err_code='proposal_is_applied',
            params={'applied': True}
        ), 400

    proposal_ctl.lock()
    proposal_errors = _get_current_proposal_errors(request, proposal_ctl)

    if proposal_errors:
        proposal_ctl.unlock()
        return proposal_errors, 400

    proposal_metadata = ProposalMetadata.objects.get(proposal_id=proposal_id)
    ProposalTasks.schedule_ordered_task(
        proposal_metadata.id,
        tasks.ExecuteProposal,
        {
            'proposal_id': proposal_id,
            'author_login': request.user.username,
        }
    )

    return {'proposal_id': proposal_id}


def _get_current_proposal_errors(request, proposal_ctl):
    proposal_form = ProposalForm(
        initial_old_style=get_initial_old_style(proposal_ctl),
        base_initial={
            '_id': proposal_ctl.proposal_id,
            'author_user': request.user,
            'locked_proposal': proposal_ctl.locked,
        },
    )

    if not proposal_form.is_valid():
        return proposal_form.errors_as_dict()

    return _validate_person_actions(proposal_ctl, PersonActionValidator())


@ensure_csrf_cookie
@require_http_methods(['GET', 'POST'])
@responding_json
@permission_required('django_intranet_stuff.can_execute_department_proposals')
def split_and_execute(request, proposal_id, person_action_validator: PersonActionValidator = None):
    """Выполнение заявки с расщеплением"""

    person_action_validator = person_action_validator or PersonActionValidator()

    proposal_ctl, not_found = create_proposal_ctl(proposal_id)
    if not_found:
        return {'error': 'proposal_does_not_exist'}, 404

    proposal_splitting = ProposalSplitting(proposal_ctl)

    can_split = proposal_splitting.can_split()
    if not can_split:
        return {}, 400

    splitting_data = proposal_splitting.splitting_data()

    if len(splitting_data.persons + splitting_data.departments) == len(list(proposal_ctl.all_actions)):
        return form_errors.sform_general_error(
            err_code='proposal_has_no_actions_to_execute',
        ), 400

    if request.method == 'GET':
        form = PersonsToSplitForm(initial={
            'persons': splitting_data.persons,
            'departments': splitting_data.departments,
        })
        return form.as_dict(), 200

    if proposal_ctl.locked:
        return form_errors.sform_general_error(
            err_code='proposal_is_locked',
            params={'locked': proposal_ctl.locked},
        ), 400

    if proposal_ctl.applied_at():
        return form_errors.sform_general_error(
            err_code='proposal_is_applied',
            params={'applied': True},
        ), 400

    proposal_ctl.lock()

    validation = _validate_person_actions(proposal_ctl, person_action_validator)
    if validation:
        proposal_ctl.unlock()
        return validation, 400

    proposal_ctl.split_actions_for_new_proposal(splitting_data)
    if splitting_data.departments:
        # update restructurisation ticket
        ProposalTasks.schedule_ordered_task(
            ProposalMetadata.objects.get(proposal_id=proposal_id).id,
            callable_or_task=tasks.SyncProposalTickets,
            kwargs={
                'author_login': request.user.username,
                'proposal_id': proposal_id,
                'proposal_diff': {},
                'updated_logins': [],
                'deleted_logins': [],
            }
        )

    ProposalTasks.schedule_ordered_task(
        ProposalMetadata.objects.get(proposal_id=proposal_id).id,
        tasks.ExecuteProposal,
        {'proposal_id': proposal_id, 'author_login': request.user.username},
    )


@ensure_csrf_cookie
@require_http_methods(['POST'])
def delete(request, proposal_id):
    """Удаление заявки"""
    with log_context(proposal_id=proposal_id):
        person = request.user.get_profile()
        proposal_ctl, not_found = create_proposal_ctl(proposal_id)
        if not_found:
            return JsonResponse(data={'error': 'proposal_does_not_exist'}, status=404)

        can_edit = (
            proposal_ctl.author == person or
            request.user.has_perm('django_intranet_stuff.can_execute_department_proposals')
        )

        if not can_edit:
            raise PermissionDenied()

        if proposal_ctl.locked or proposal_ctl.applied_at():
            return JsonResponse(
                data=form_errors.sform_general_error(
                    err_code='proposal_is_locked',
                    params={'locked': proposal_ctl.locked},
                ),
                status=400,
            )

        proposal_metadata = ProposalMetadata.objects.get(proposal_id=proposal_id)
        ProposalTasks.schedule_ordered_task(
            proposal_metadata.id,
            callable_or_task=tasks.delete_proposal_task,
            kwargs={'login': person.login, 'proposal_id': proposal_metadata.id}
        )

        return JsonResponse(data={}, status=202)


def _get_proposals_from_mongo(proposals_filter: Dict, offset: int, limit: int) -> List[Dict]:
    return [
        {
            'id': str(ob['_id']),
            'author': ob.get('author'),
            'created_at': ob.get('created_at') or (ob.get('creation_time') and ob['creation_time'].isoformat()),
        }
        for ob in get_mongo_objects(proposals_filter, sort=('-created_at',), skip=offset, limit=limit)
    ]


def _replace_author_id_with_login(results: List[Dict]) -> List[Dict]:
    authors_ids = {
        item['author']
        for item in results
        if isinstance(item['author'], int)
    }
    authors = dict(Staff.objects.filter(id__in=authors_ids).values_list('id', 'login'))
    return [
        {
            **item,
            'author': (
                authors.get(item['author'])
                if isinstance(item['author'], int)
                else item['author']
            ),
        }
        for item in results
    ]


@responding_json
@require_http_methods(['GET'])
@paginated
def proposals_list(request, paginator):
    can_view_all_proposals = request.user.has_perm('django_intranet_stuff.can_execute_department_proposals')
    offset = (paginator.page - 1) * paginator.limit
    author_id = request.user.get_profile().id

    proposals_filter = {} if can_view_all_proposals else {'author': author_id}

    total = get_mongo_objects(proposals_filter).count()
    result = _get_proposals_from_mongo(proposals_filter, offset=offset, limit=paginator.limit)
    result = _replace_author_id_with_login(result)

    paginator.total = total
    paginator.result = result
    return paginator.as_dict()


def _parse_mass_change_file(file, fields):
    book = xlrd.open_workbook(file_contents=file.read())
    sheet = book.sheet_by_index(0)

    result = []
    for idx, row in enumerate(sheet.get_rows()):
        if idx == 0:
            continue

        result.append(
            dict(zip(fields, (cell.value for cell in row)))
        )

    return {'rows': result}


def parse_department_mass_change(import_file):
    fields = ('person', 'department', 'comment')
    return _parse_mass_change_file(import_file, fields)


def parse_mass_schemes_recalculation(import_file):
    fields = ('person', 'comment')
    return _parse_mass_change_file(import_file, fields)


def parse_mass_schemes_change_office(import_file):
    fields = ('person', 'office', 'comment')
    return _parse_mass_change_file(import_file, fields)


@csrf_exempt
@require_http_methods(['POST'])
@responding_json
def add_department_mass_change_to_proposal(request, proposal_id):
    proposal_ctl, not_found = create_proposal_ctl(proposal_id)
    if not_found:
        return {'error': 'proposal_does_not_exist'}, 404

    is_locked = proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)
    if is_locked:
        return form_errors.sform_general_error(
            err_code='proposal_is_locked',
            params={'locked': proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)}
        ), 400

    form = FileImportForm(request.POST, request.FILES)
    if not form.is_valid():
        return form_errors.sform_general_error('missing_file'), 400

    try:
        parsed_mass_change_data = parse_department_mass_change(request.FILES['import_file'])
    except Exception:
        return form_errors.sform_general_error('incorrect_template'), 400

    form = MassChangeDepartmentForm(data=parsed_mass_change_data)
    if not form.is_valid():
        errs = form.errors_as_dict()
        return errs, 400

    use_case = AddPersonChangeUseCase(proposal_id)
    try:
        with atomic():
            for row in form.cleaned_data['rows']:
                person = row['person']
                dep = row['department']
                comment = row['comment']
                use_case.change_person_department(person.login, dep.url, comment)

            tasks.create_proposal_tickets(proposal_id)
    except BpConflict as e:
        return _response_400_on_bp_conflict(e)
    except ValidationError as e:
        return form_errors.sform_general_error(
            err_code=e.code,
            params={
                'message': str(e),
            }
        ), 400

    return 200


@csrf_exempt
@require_http_methods(['POST'])
@responding_json
def add_mass_schemes_recalculation_to_proposal(request, proposal_id):
    proposal_ctl, not_found = create_proposal_ctl(proposal_id)
    if not_found:
        return {'error': 'proposal_does_not_exist'}, 404

    is_locked = proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)
    if is_locked:
        return form_errors.sform_general_error(
            err_code='proposal_is_locked',
            params={'locked': proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)}
        ), 400

    form = FileImportForm(request.POST, request.FILES)
    if not form.is_valid():
        return form_errors.sform_general_error('missing_file'), 400

    try:
        parsed_mass_recalculation_data = parse_mass_schemes_recalculation(request.FILES['import_file'])
    except Exception:
        return form_errors.sform_general_error('incorrect_template'), 400

    form = MassRecalculateSchemesForm(data=parsed_mass_recalculation_data)
    if not form.is_valid():
        errs = form.errors_as_dict()
        return errs, 400

    use_case = AddPersonChangeUseCase(proposal_id)
    try:
        with atomic():
            for row in form.cleaned_data['rows']:
                person = row['person']
                comment = row['comment']
                use_case.recalculate_person_schemes(person.login, comment)

            tasks.create_proposal_tickets(proposal_id)
    except BpConflict as e:
        return _response_400_on_bp_conflict(e)
    except ValidationError as e:
        return form_errors.sform_general_error(
            err_code=e.code,
            params={
                'message': str(e),
            }
        ), 400

    return 200


@csrf_exempt
@require_http_methods(['POST'])
@responding_json
def add_mass_change_office_to_proposal(request, proposal_id):
    proposal_ctl, not_found = create_proposal_ctl(proposal_id)
    if not_found:
        return {'error': 'proposal_does_not_exist'}, 404

    is_locked = proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)
    if is_locked:
        return form_errors.sform_general_error(
            err_code='proposal_is_locked',
            params={'locked': proposal_ctl.locked or tasks.SyncProposalTickets.is_running(proposal_id)}
        ), 400

    form = FileImportForm(request.POST, request.FILES)
    if not form.is_valid():
        return form_errors.sform_general_error('missing_file'), 400

    try:
        parsed_mass_change_office_data = parse_mass_schemes_change_office(request.FILES['import_file'])
    except Exception:
        return form_errors.sform_general_error('incorrect_template'), 400

    form = MassChangeOfficeSchemesForm(data=parsed_mass_change_office_data)
    if not form.is_valid():
        errs = form.errors_as_dict()
        return errs, 400

    use_case = AddPersonChangeUseCase(proposal_id)
    try:
        with atomic():
            for row in form.cleaned_data['rows']:
                person = row['person']
                office = row['office']
                comment = row['comment']
                use_case.change_office_person_schemes(person.login, office.id, comment)

            tasks.create_proposal_tickets(proposal_id)
    except BpConflict as e:
        return _response_400_on_bp_conflict(e)
    except ValidationError as e:
        return form_errors.sform_general_error(
            err_code=e.code,
            params={
                'message': str(e),
            }
        ), 400

    return 200
