import datetime
import logging
from dataclasses import dataclass

from collections import defaultdict
from dateutil.parser import parse
from sqlalchemy import or_, and_
from sqlalchemy.orm import Session, joinedload
from typing import List, Dict, Iterable, Set, Optional

from sqlalchemy import func
from watcher import enums
from watcher.db import Shift, Staff, Schedule, Gap, ManualGap
from watcher.config import settings
from watcher.logic.shift import is_subshift
from watcher.logic.manual_gap import is_applicable
from watcher.logic.timezone import make_localized_datetime, localize, now
from watcher.logic.schedule import get_people_allocation_start_date
from watcher.logic.shift import shift_total_points, find_other_subshifts
from watcher.crud.rating import get_existing_ratings_by_schedule_staff_ids

logger = logging.getLogger(__name__)


@dataclass
class OnDutyGap:
    start: datetime.datetime
    end: datetime.datetime


def get_participants_ratings_from_sequence(
    db: Session,
    schedules_participants: Dict[int, Dict[int, Iterable[Staff]]],
    shifts_sequence: List[Shift],
    schedules_to_calculate: Set[Schedule],
) -> Dict[int, Dict[str, float]]:
    """ Для каждого расписания находит текущие рейтинги людей """
    need_schedules_ids = {schedule.id for schedule in schedules_to_calculate}

    staff_by_schedule = {
        schedule_id: set(staff for slot_staffs in schedule_slots.values() for staff in slot_staffs)
        for schedule_id, schedule_slots in schedules_participants.items()
    }

    unique_staff = set(staff for staff_list in staff_by_schedule.values() for staff in staff_list)
    ratings = get_existing_ratings_by_schedule_staff_ids(
        db=db,
        staff_ids=(staff.id for staff in unique_staff),
        schedule_ids=need_schedules_ids,
    )
    participants_ratings = defaultdict(lambda: defaultdict(float))
    for rating in ratings:
        participants_ratings[rating.schedule_id][rating.staff.login] = float(rating.rating)

    unfinished_shifts = db.query(Shift).filter(
        Shift.schedule_id.in_(need_schedules_ids),
        Shift.end <= shifts_sequence[0].start,
        Shift.staff_id.isnot(None),
        ~Shift.sub_shifts.any(),
        or_(
            Shift.status == enums.ShiftStatus.active,
            and_(
                Shift.status == enums.ShiftStatus.scheduled,
                Shift.end > now(),
            )
        ),
    ).options(joinedload(Shift.staff))

    for shift in unfinished_shifts:
        participants_ratings[shift.schedule_id][shift.staff.login] += shift_total_points(shift)

    # Если не для всех расписаний были найдены рейтинги - запишем 0 для них
    for schedule_id, staffs in staff_by_schedule.items():
        for staff in staffs:
            if staff.login not in participants_ratings[schedule_id]:
                participants_ratings[schedule_id][staff.login] = 0

    return participants_ratings


def get_participants_last_shift_ends(
    session: Session,
    to_shift: Shift,
    schedules_participants: Dict[int, Dict[int, Iterable[Staff]]],
    schedules_to_calculate: Set[Schedule],
) -> Dict[int, Dict[str, Dict[bool, datetime.datetime]]]:
    participants_last_shift = defaultdict(
        lambda: defaultdict(
            lambda: defaultdict(
                lambda: datetime.datetime.min.replace(tzinfo=settings.DEFAULT_TIMEZONE)
            )
        )
    )

    unique_staff = set()
    for schedule_id, schedule_slots in schedules_participants.items():
        for slot_staffs in schedule_slots.values():
            unique_staff.update(slot_staffs)
    for schedule in schedules_to_calculate:
        last_shifts = (
            session.query(Shift.staff_id, Staff.login, Shift.is_primary, func.max(Shift.end).label('end'))
            .join(Staff, Shift.staff_id == Staff.id)
            .filter(
                Shift.schedule_id == schedule.id,
                Shift.staff_id.in_({staff.id for staff in unique_staff}),
                Shift.end <= to_shift.start,
                ~Shift.sub_shifts.any(),
            )
            .group_by(Shift.staff_id, Staff.login, Shift.is_primary)
        ).all()
        for shift in last_shifts:
            participants_last_shift[schedule.id][shift.login][shift.is_primary] = shift.end

    return participants_last_shift


def check_participant_can_duty(
    shifts: Iterable[Shift],
    participant_gaps: list[Gap | ManualGap | OnDutyGap],
    timeout_between_shifts: Optional[datetime.datetime] = None
) -> bool:
    """
    Проверяем, может ли сотрудник продежурить группу смен по его отсутствиям.
    :param shifts: список шифтов для которого нужно выяснить
    :param participant_gaps: отсутствия сотрудника
    :param timeout_between_shifts: перерыв между сменами (прибавляем к концу гэпов)
    :return:
    """
    can_duty = True
    for shift in shifts:
        can_duty &= find_intersecting_gap(
            shift=shift, participant_gaps=participant_gaps, timeout_between_shifts=timeout_between_shifts,
        ) is None
    return can_duty


def find_intersecting_gap(
    shift: Shift,
    participant_gaps: Iterable[Gap | ManualGap | OnDutyGap],
    timeout_between_shifts: Optional[datetime.datetime] = None
) -> None | Gap | ManualGap:
    """
    Пытаемся найти отсутствие, которое не дает продежурить конкретную смену
    :param shift: шифт для которого нужно выяснить
    :param participant_gaps: занятые ячейки сотрудника
    :param timeout_between_shifts: перерыв между сменами (прибавляем к концу гэпов)
    :return: Gap | ManualGap - если нашлось, или None
    """
    schedule_id = shift.schedule_id
    service_id = shift.schedule.service_id
    for gap in participant_gaps:
        if isinstance(gap, ManualGap) and not is_applicable(
            gap=gap, schedule_id=schedule_id, service_id=service_id
        ):
            continue

        gap_end = gap.end
        gap_start = gap.start
        if isinstance(gap, Gap) and gap.type == enums.GapType.vacation:
            gap_end += shift.schedule.days_after_vacation
            gap_start -= shift.schedule.days_before_vacation

        if isinstance(gap, OnDutyGap) and timeout_between_shifts:
            gap_end += timeout_between_shifts

        if shift.start < gap_end and shift.end > gap_start:
            # проверим длину пересечения со сменой
            latest_start = max(gap_start, shift.start)
            earliest_end = min(gap_end, shift.end)
            if earliest_end - latest_start > shift.schedule.length_of_absences:
                return gap
    return None


def forge_gaps_for_duty_shift(
    participant: Staff,
    shifts: Iterable[Shift],
) -> list[OnDutyGap]:
    """
    Создаем фиктивные гэпы на группу смен - чтобы дежурного не распределило на параллельные смены
    :param participant: Выбранный алгоритмом дежурный
    :param shifts: Смены для которых создаем гэпы
    :return:
    """
    new_gaps = []
    if not participant:
        return new_gaps

    for shift in shifts:
        new_gaps.append(OnDutyGap(start=shift.start, end=shift.end))

    return new_gaps


def sort_possible_participants(
    shift: Shift,
    possible_participants: Iterable[Staff],
    participant_ratings: dict[str, float],
    participant_last_shift_ends: Dict[int, Dict[str, Dict[bool, datetime.datetime]]],
) -> list[Staff]:
    return sorted(possible_participants,
                  key=lambda staff: (
                      participant_ratings[staff.login] if shift.slot.points_per_hour else True,
                      participant_last_shift_ends[shift.schedule_id][staff.login][shift.is_primary],
                      participant_last_shift_ends[shift.schedule_id][staff.login][not shift.is_primary])
                  )


def find_staff_for_shift(
    shift: Shift,
    shifts_sequence: list[Shift],
    possible_participants: Iterable[Staff],
    participants_gaps: dict[int, list[Gap | ManualGap| OnDutyGap]],
    participant_ratings: dict[str, float],
    participant_last_shift_ends: Dict[int, Dict[str, Dict[bool, datetime.datetime]]],
    already_found: Dict[int, Staff],
    timeout_between_shifts: Optional[datetime.timedelta] = None,
) -> Staff:
    """
    Ищем дежурного с наименьшим рейтингом, который может продежурить смену

    :param shift: Смена для которого производится поиск
    :param shifts_sequence: все прочие смены, для которых сейчас ищем дежурных
    :param possible_participants: Возможные дежурные
    :param participant_ratings: Рейтинги людей на момент подбора дежурного шифту
    :param participants_gaps: Занятые ячейки у дежурных отсортированные по start
    :param participant_last_shift_ends: Даты последнего дежурства людей в зависимости от статуса(is_primary) шифта
    :param already_found: смены с уже назначенными дежурными. Нужен для распределения подсмен
    :param timeout_between_shifts: интервал перерыва между дежурствами
    одного дежурного в рамках группы графиков
    :return: Подходящий сотрудник, если есть
    """
    if already_found.get(shift.id):
        return already_found[shift.id]

    priority_participants = []

    possible_participants = sort_possible_participants(
        shift=shift,
        possible_participants=possible_participants,
        participant_ratings=participant_ratings,
        participant_last_shift_ends=participant_last_shift_ends,

    )
    # Настройка ротации - обладает бОльшим приоритетом, чем обычное распределение
    related_shift = find_rotation_related_shift(shift=shift, rotation=shift.schedule.rotation)
    if related_shift and related_shift.staff:
        priority_participants.append(related_shift.staff)

    shifts_group = [shift]
    if is_subshift(shift):
        # найдем все подсмены главной смены для данной и распределим их вместе
        shifts_group.extend(find_other_subshifts(shift=shift, shifts_sequence=shifts_sequence))

    duty_participant = None
    for participant in priority_participants:
        if check_participant_can_duty(
            shifts=shifts_group,
            participant_gaps=participants_gaps[participant.id],
        ):
            if not duty_participant:
                duty_participant = participant
                break

    if not duty_participant:
        for participant in possible_participants:
            if check_participant_can_duty(
                shifts=shifts_group,
                participant_gaps=participants_gaps[participant.id],
                timeout_between_shifts=timeout_between_shifts,
            ):
                max_datetime = max(
                    participant_last_shift_ends[shift.schedule_id][participant.login][shift.is_primary],
                    participant_last_shift_ends[shift.schedule_id][participant.login][not shift.is_primary]
                )
                if max_datetime >= shift.start:
                    if not duty_participant:
                        # нашли дежурного, но он дежурил прошлую смену,
                        # у следующих кандидатов больше рейтинг, пока что оставляем этого кандидата
                        duty_participant = participant
                else:
                    duty_participant = participant
                    break

    if duty_participant:
        for shift in shifts_group:
            already_found[shift.id] = duty_participant

        participants_gaps[duty_participant.id].extend(
            forge_gaps_for_duty_shift(
                participant=duty_participant,
                shifts=shifts_group,
            )
        )
    return duty_participant


def fill_shift(
    shift: Shift,
    suitable_staff: Optional[Staff],
    participant_ratings: dict[int, dict[str, float]],
    participant_last_shift_ends: Dict[int, Dict[str, Dict[bool, datetime.datetime]]],
) -> None:
    """
    Заполняет шифт дежурным.
    Для нового дежурного добавляется занятость во время смены и обновляется предсказанный рейтинг

    :param shift: Смена
    :param suitable_staff: Дежурный смены
    :param participant_ratings: Рейтинги сотрудников
    :param participant_last_shift_ends: Даты последнего дежурства людей в зависимости от статуса(is_primary) шифта
    :return:
    """
    if not suitable_staff:
        logger.warning('Для смены (id=%d) дежурный не найден', shift.id)
        shift.staff_id = None
    else:
        shift.staff = suitable_staff
        # добавляем рейтинг
        ratings_to_increase = shift_total_points(shift)
        if suitable_staff.login in participant_ratings[shift.schedule_id]:
            # если нет - то это разовая замена на человека не из композиции
            # и рейтинг для такого мы не храним
            participant_ratings[shift.schedule_id][suitable_staff.login] += ratings_to_increase

        participant_last_shift_ends[shift.schedule_id][suitable_staff.login][shift.is_primary] = shift.end


def prepare_people_allocation_start_time(
    session: Session,
    schedules_group_id: int,
    start_date: Optional[datetime.datetime] = None,
) -> datetime.datetime:
    """
    При вызове таски через delay, start_date приходит как строка, парсим в datetime.
    Если start_date не передан - будем брать время самого первого расписания, у которого не было перераспределения
    """
    if isinstance(start_date, str):
        start_date = parse(start_date)

    if not start_date:
        start_date = get_people_allocation_start_date(session, schedules_group_id)  # TODO: вот тут то нужно брать поди после первой подтвержденной смены?

    if isinstance(start_date, datetime.date):
        start_date = make_localized_datetime(start_date)
    else:
        start_date = localize(start_date)

    return start_date


def find_prev_target_shift(shift: Shift, target_is_primary: bool, same_interval: bool = False) -> Shift | None:
    if not shift:
        return
    shift_to_find = shift
    while shift.prev:
        shift = shift.prev
        if (
            (not same_interval or shift.slot.interval_id == shift_to_find.slot.interval_id)
            and (shift.slot.is_primary is target_is_primary)
            and shift.start < shift_to_find.start
            and not shift.empty
            and not (
                shift_to_find.replacement_for_id
                and shift.replacement_for_id
                and shift.replacement_for.start == shift_to_find.replacement_for.start
            )
        ):
            return shift


def find_rotation_related_shift(shift: Shift, rotation: enums.IntervalRotation) -> Shift | None:
    if rotation != enums.IntervalRotation.default:
        if shift.is_primary and rotation is enums.IntervalRotation.backup_is_next_primary:
            return find_prev_target_shift(shift=shift, target_is_primary=False)

        elif not shift.is_primary and rotation is enums.IntervalRotation.primary_is_next_backup:
            return find_prev_target_shift(shift=shift, target_is_primary=True)

        elif rotation is enums.IntervalRotation.cross_rotation:
            prev1_shift = find_prev_target_shift(shift=shift, target_is_primary=not shift.is_primary)
            prev2_shift = find_prev_target_shift(shift=prev1_shift, target_is_primary=shift.is_primary)
            if prev1_shift and not prev2_shift or prev1_shift and prev1_shift.staff != prev2_shift.staff:
                return prev1_shift

    elif shift.slot.interval.primary_rotation and shift.slot.is_primary:
        return find_prev_target_shift(shift=shift, target_is_primary=False, same_interval=True)
