package com.magistr.app.service;

import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Autowired;

import java.time.LocalDate;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;

@Service
public class ScheduleOverrideService {

    private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
    private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");

    private final ScheduleOverrideRepository scheduleOverrideRepository;
    private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
    private final TimeSlotRepository timeSlotRepository;
    private final ClassroomRepository classroomRepository;
    private final UserRepository userRepository;
    private final SubgroupRepository subgroupRepository;
    private final ScheduleQueryService scheduleQueryService;
    private final ScheduleGeneratorService scheduleGeneratorService;
    private final PostgreSqlAdvisoryLock advisoryLock;
    private final AcademicDateService academicDateService;
    private final EffectiveTimeSlotService effectiveTimeSlotService;

    @Autowired
    public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
                                   ScheduleRuleSlotRepository scheduleRuleSlotRepository,
                                   TimeSlotRepository timeSlotRepository,
                                   ClassroomRepository classroomRepository,
                                   UserRepository userRepository,
                                   SubgroupRepository subgroupRepository,
                                   ScheduleQueryService scheduleQueryService,
                                   ScheduleGeneratorService scheduleGeneratorService,
                                   PostgreSqlAdvisoryLock advisoryLock,
                                   AcademicDateService academicDateService,
                                   EffectiveTimeSlotService effectiveTimeSlotService) {
        this.scheduleOverrideRepository = scheduleOverrideRepository;
        this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
        this.timeSlotRepository = timeSlotRepository;
        this.classroomRepository = classroomRepository;
        this.userRepository = userRepository;
        this.subgroupRepository = subgroupRepository;
        this.scheduleQueryService = scheduleQueryService;
        this.scheduleGeneratorService = scheduleGeneratorService;
        this.advisoryLock = advisoryLock;
        this.academicDateService = academicDateService;
        this.effectiveTimeSlotService = effectiveTimeSlotService;
    }

    ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
                            ScheduleRuleSlotRepository scheduleRuleSlotRepository,
                            TimeSlotRepository timeSlotRepository,
                            ClassroomRepository classroomRepository,
                            UserRepository userRepository,
                            SubgroupRepository subgroupRepository,
                            ScheduleQueryService scheduleQueryService,
                            ScheduleGeneratorService scheduleGeneratorService,
                            PostgreSqlAdvisoryLock advisoryLock) {
        this(
                scheduleOverrideRepository,
                scheduleRuleSlotRepository,
                timeSlotRepository,
                classroomRepository,
                userRepository,
                subgroupRepository,
                scheduleQueryService,
                scheduleGeneratorService,
                advisoryLock,
                null,
                null
        );
    }

    @Transactional(readOnly = true)
    public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
        if ((startDate == null) != (endDate == null)) {
            throw new IllegalArgumentException("Дата начала и дата окончания должны быть указаны вместе");
        }
        if (startDate != null) {
            validateRange(startDate, endDate);
        }
        List<ScheduleOverride> overrides = startDate == null
                ? scheduleOverrideRepository.findAllWithRegistryDetails()
                : scheduleOverrideRepository.findForRegistry(startDate, endDate);
        return overrides.stream()
                .map(this::toDto)
                .toList();
    }

    @Transactional(readOnly = true)
    public List<ScheduleOverrideDto> getAll() {
        return getAll(null, null);
    }

    @Transactional(readOnly = true)
    public ScheduleOverrideAvailabilityDto getAvailability(Long baseRuleSlotId, LocalDate lessonDate) {
        if (baseRuleSlotId == null || lessonDate == null) {
            throw new IllegalArgumentException("Базовый слот и исходная дата пары обязательны");
        }
        ScheduleRuleSlot slot = scheduleRuleSlotRepository.findById(baseRuleSlotId)
                .orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
        ensureSourceOccurrenceExists(baseRuleSlotId, lessonDate);
        var rule = slot.getScheduleRule();
        if (rule == null || rule.getSemester() == null) {
            throw new IllegalArgumentException("Для базового слота не найден семестр");
        }
        var semester = rule.getSemester();
        if (lessonDate.isBefore(semester.getStartDate()) || lessonDate.isAfter(semester.getEndDate())) {
            throw new IllegalArgumentException("Исходная дата пары находится вне семестра правила");
        }
        if (academicDateService == null) {
            throw new IllegalStateException("Сервис учебных дат не настроен");
        }
        List<com.magistr.app.model.StudentGroup> groups = rule.getGroups().stream()
                .sorted(Comparator.comparing(com.magistr.app.model.StudentGroup::getName))
                .toList();
        var snapshot = academicDateService.createScheduleSnapshot(
                groups,
                List.of(semester),
                semester.getEndDate()
        );
        List<LocalDate> availableDates = semester.getStartDate()
                .datesUntil(semester.getEndDate().plusDays(1))
                .filter(date -> rule.isActiveOn(date))
                .filter(date -> rule.getSubject().isActiveOn(date))
                .filter(date -> groups.stream().allMatch(group -> group.isActiveOn(date)
                        && snapshot.isTheoryDay(group, semester, date)))
                .toList();
        return new ScheduleOverrideAvailabilityDto(
                semester.getId(),
                semester.getStartDate(),
                semester.getEndDate(),
                availableDates
        );
    }

    @Transactional
    public ScheduleOverrideDto create(ScheduleOverrideDto request) {
        OverrideCommand command = validateAndNormalize(request);
        lockDatesInStableOrder(command.lessonDate(), command.effectiveDate());

        scheduleOverrideRepository
                .findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
                .ifPresent(existing -> {
                    throw new ScheduleConflictException(
                            "Для выбранной базовой пары и даты уже существует точечное изменение"
                    );
                });
        return validateAndPersist(null, command);
    }

    @Transactional
    public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
        OverrideCommand command = validateAndNormalize(request);
        advisoryLock.lockScheduleOverrideId(id);
        ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
                .orElseThrow(() -> notFound(id));
        lockDatesInStableOrder(
                current.getLessonDate(),
                effectiveDate(current),
                command.lessonDate(),
                command.effectiveDate()
        );

        scheduleOverrideRepository
                .findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
                .filter(other -> !Objects.equals(other.getId(), current.getId()))
                .ifPresent(other -> {
                    throw new ScheduleConflictException(
                            "Для выбранной базовой пары и даты уже существует точечное изменение"
                    );
                });
        return validateAndPersist(current, command);
    }

    @Transactional
    public void delete(Long id) {
        advisoryLock.lockScheduleOverrideId(id);
        ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
                .orElseThrow(() -> notFound(id));
        lockDatesInStableOrder(current.getLessonDate(), effectiveDate(current));
        scheduleOverrideRepository.delete(current);
        scheduleOverrideRepository.flush();
        scheduleGeneratorService.clearCache();
    }

    private ScheduleOverrideDto validateAndPersist(ScheduleOverride current, OverrideCommand command) {
        List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
        List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
                .filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
                .filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
                .toList();
        if (sourceOccurrences.isEmpty()) {
            throw new IllegalArgumentException(
                    "На выбранную дату базовая пара не формируется по правилам расписания"
            );
        }
        ResolvedReferences references = resolveReferences(command, sourceOccurrences);
        validateTargetDate(command, references.baseRuleSlot(), sourceOccurrences);

        ScheduleOverride candidate = buildCandidate(command, references);
        if (!"CANCEL".equals(command.action())) {
            validateEffectiveResult(current, command, candidate, baseDay, sourceOccurrences);
        }

        ScheduleOverride target = current == null ? new ScheduleOverride() : current;
        copyCandidate(candidate, target);
        target.setComment(command.comment());
        target.setCreatedBy(AuthContext.currentUserId());

        ScheduleOverride saved = scheduleOverrideRepository.saveAndFlush(target);
        scheduleGeneratorService.clearCache();
        return toDto(saved);
    }

    private void validateEffectiveResult(ScheduleOverride current,
                                         OverrideCommand command,
                                         ScheduleOverride candidate,
                                         List<RenderedLessonDto> baseDay,
                                         List<RenderedLessonDto> sourceOccurrences) {
        Long currentId = current == null ? null : current.getId();
        Set<LocalDate> affectedDates = new TreeSet<>(List.of(command.lessonDate(), command.effectiveDate()));
        List<ScheduleOverride> storedOverrides = affectedDates.size() == 1
                ? scheduleOverrideRepository.findByLessonDateBetweenWithDetails(
                        command.lessonDate(),
                        command.lessonDate()
                )
                : scheduleOverrideRepository.findAffectingDatesWithDetails(affectedDates);
        List<ScheduleOverride> effectiveOverrides = Optional.ofNullable(storedOverrides)
                .orElseGet(List::of)
                .stream()
                .filter(override -> currentId == null || !Objects.equals(override.getId(), currentId))
                .filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate()))
                .collect(Collectors.toCollection(ArrayList::new));
        effectiveOverrides.add(candidate);

        List<RenderedLessonDto> effectiveSchedule = Objects.equals(command.lessonDate(), command.effectiveDate())
                ? scheduleQueryService.buildEffectiveDayForAllGroups(
                        baseDay,
                        command.lessonDate(),
                        effectiveOverrides
                )
                : scheduleQueryService.buildEffectiveDaysForAllGroups(affectedDates, effectiveOverrides);
        List<RenderedLessonDto> candidateOccurrences = effectiveSchedule.stream()
                .filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
                .filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate()))
                .filter(lesson -> Objects.equals(lesson.originalLessonDate(), command.lessonDate())
                        || (Objects.equals(command.lessonDate(), command.effectiveDate())
                        && lesson.originalLessonDate() == null))
                .toList();
        if (candidateOccurrences.isEmpty()) {
            throw new IllegalArgumentException("Точечное изменение не формирует результирующую пару");
        }

        Set<EffectiveSourceTuple> sourceTuples = sourceOccurrences.stream()
                .map(EffectiveSourceTuple::from)
                .collect(Collectors.toSet());
        Set<EffectiveSourceTuple> candidateTuples = candidateOccurrences.stream()
                .map(EffectiveSourceTuple::from)
                .collect(Collectors.toSet());
        validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
        if (sourceTuples.equals(candidateTuples)) {
            throw new IllegalArgumentException(
                    "Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
            );
        }

        List<RenderedLessonDto> otherOccurrences = effectiveSchedule.stream()
                .filter(lesson -> !isCandidateOccurrence(lesson, command))
                .filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate()))
                .toList();
        Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences);
        validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
    }

    private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) {
        return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
                && (Objects.equals(lesson.originalLessonDate(), command.lessonDate())
                || (Objects.equals(command.lessonDate(), command.effectiveDate())
                && lesson.originalLessonDate() == null))
                && Objects.equals(lesson.date(), command.effectiveDate());
    }

    private void validateActionSpecificChange(String action,
                                              List<RenderedLessonDto> sourceOccurrences,
                                              List<RenderedLessonDto> candidateOccurrences) {
        if ("MOVE".equals(action)) {
            Set<MoveTuple> source = sourceOccurrences.stream()
                    .map(MoveTuple::from)
                    .collect(Collectors.toSet());
            Set<MoveTuple> candidate = candidateOccurrences.stream()
                    .map(MoveTuple::from)
                    .collect(Collectors.toSet());
            if (source.equals(candidate)) {
                throw new IllegalArgumentException(
                        "Перенос должен фактически менять дату, время пары или аудиторию"
                );
            }
        }
        if ("REPLACE".equals(action)) {
            Set<ReplacementTuple> source = sourceOccurrences.stream()
                    .map(ReplacementTuple::from)
                    .collect(Collectors.toSet());
            Set<ReplacementTuple> candidate = candidateOccurrences.stream()
                    .map(ReplacementTuple::from)
                    .collect(Collectors.toSet());
            if (source.equals(candidate)) {
                throw new IllegalArgumentException(
                        "Замена должна фактически менять преподавателя, аудиторию или формат пары"
                );
            }
        }
    }

    private void validateResourceConflicts(List<RenderedLessonDto> candidates,
                                           List<RenderedLessonDto> others,
                                           Map<Long, Long> subgroupToGroup) {
        for (RenderedLessonDto candidate : candidates) {
            for (RenderedLessonDto other : others) {
                if (!timeOverlaps(candidate, other)) {
                    continue;
                }
                if (candidate.teacherId() != null && Objects.equals(candidate.teacherId(), other.teacherId())) {
                    throw new ScheduleConflictException(
                            "Конфликт расписания: преподаватель уже занят в указанное время"
                    );
                }
                if (candidate.classroomId() != null && Objects.equals(candidate.classroomId(), other.classroomId())) {
                    throw new ScheduleConflictException(
                            "Конфликт расписания: аудитория уже занята в указанное время"
                    );
                }
                if (audiencesOverlap(candidate, other, subgroupToGroup)) {
                    throw new ScheduleConflictException(
                            "Конфликт расписания: группа или подгруппа уже занята в указанное время"
                    );
                }
            }
        }
    }

    private boolean timeOverlaps(RenderedLessonDto first, RenderedLessonDto second) {
        LocalTime firstStart = first.startTime();
        LocalTime firstEnd = first.endTime();
        LocalTime secondStart = second.startTime();
        LocalTime secondEnd = second.endTime();
        if (firstStart == null || firstEnd == null || secondStart == null || secondEnd == null) {
            throw new IllegalStateException("Невозможно проверить конфликт: у пары не задано время");
        }
        if (!firstStart.isBefore(firstEnd) || !secondStart.isBefore(secondEnd)) {
            throw new IllegalStateException("Невозможно проверить конфликт: границы времени пары некорректны");
        }
        return firstStart.isBefore(secondEnd) && secondStart.isBefore(firstEnd);
    }

    private boolean audiencesOverlap(RenderedLessonDto first,
                                     RenderedLessonDto second,
                                     Map<Long, Long> subgroupToGroup) {
        Set<Long> commonGroupIds = new HashSet<>(safeIds(first.groupIds()));
        commonGroupIds.retainAll(safeIds(second.groupIds()));
        for (Long groupId : commonGroupIds) {
            Set<Long> firstSubgroups = subgroupsForGroup(first, groupId, subgroupToGroup);
            Set<Long> secondSubgroups = subgroupsForGroup(second, groupId, subgroupToGroup);
            if (firstSubgroups.isEmpty() || secondSubgroups.isEmpty()) {
                return true;
            }
            if (!java.util.Collections.disjoint(firstSubgroups, secondSubgroups)) {
                return true;
            }
        }
        return false;
    }

    private Set<Long> subgroupsForGroup(RenderedLessonDto lesson,
                                        Long groupId,
                                        Map<Long, Long> subgroupToGroup) {
        return safeIds(lesson.subgroupIds()).stream()
                .filter(subgroupId -> Objects.equals(subgroupToGroup.get(subgroupId), groupId))
                .collect(Collectors.toSet());
    }

    private Map<Long, Long> loadSubgroupGroupMap(Collection<RenderedLessonDto> first,
                                                 Collection<RenderedLessonDto> second) {
        LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
        first.forEach(lesson -> subgroupIds.addAll(safeIds(lesson.subgroupIds())));
        second.forEach(lesson -> subgroupIds.addAll(safeIds(lesson.subgroupIds())));
        if (subgroupIds.isEmpty()) {
            return Map.of();
        }
        Map<Long, Long> result = new HashMap<>();
        subgroupRepository.findGroupReferencesByIdIn(List.copyOf(subgroupIds))
                .forEach(reference -> result.put(reference.getSubgroupId(), reference.getGroupId()));
        return result;
    }

    private Set<Long> safeIds(List<Long> values) {
        if (values == null || values.isEmpty()) {
            return Set.of();
        }
        return values.stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
    }

    private ResolvedReferences resolveReferences(OverrideCommand command,
                                                  List<RenderedLessonDto> sourceOccurrences) {
        ScheduleRuleSlot baseRuleSlot = scheduleRuleSlotRepository.findById(command.baseRuleSlotId())
                .orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
        TimeSlot newTimeSlot = command.newTimeSlotId() == null
                ? null
                : timeSlotRepository.findById(command.newTimeSlotId())
                .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
        Classroom newClassroom = command.newClassroomId() == null
                ? null
                : classroomRepository.findById(command.newClassroomId())
                .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
        User newTeacher = command.newTeacherId() == null
                ? null
                : userRepository.findById(command.newTeacherId())
                .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));

        if ("CANCEL".equals(command.action())) {
            return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
        }

        LocalDate effectiveDate = command.effectiveDate();
        User effectiveTeacher = newTeacher == null ? baseRuleSlot.getTeacher() : newTeacher;
        Classroom effectiveClassroom = newClassroom == null ? baseRuleSlot.getClassroom() : newClassroom;
        if (effectiveTeacher == null
                || effectiveTeacher.getRole() != Role.TEACHER
                || !effectiveTeacher.isActiveOn(effectiveDate)) {
            throw new IllegalArgumentException("Активный преподаватель с ролью TEACHER не найден");
        }
        if (effectiveClassroom == null
                || !effectiveClassroom.isActiveOn(effectiveDate)
                || !Boolean.TRUE.equals(effectiveClassroom.getIsAvailable())) {
            throw new IllegalArgumentException("Активная и доступная аудитория не найдена");
        }
        if (newTimeSlot != null
                && effectiveTimeSlotService != null
                && !effectiveTimeSlotService.isEffectiveOnDate(newTimeSlot.getId(), effectiveDate)) {
            throw new IllegalArgumentException("Выбранный временной слот не действует на целевую дату");
        }
        return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
    }

    private void validateTargetDate(OverrideCommand command,
                                    ScheduleRuleSlot baseRuleSlot,
                                    List<RenderedLessonDto> sourceOccurrences) {
        if (command.targetLessonDate() == null) {
            return;
        }
        if (academicDateService == null) {
            throw new IllegalStateException("Сервис учебных дат не настроен");
        }
        if (baseRuleSlot.getScheduleRule() == null
                || baseRuleSlot.getScheduleRule().getSemester() == null) {
            throw new IllegalArgumentException("Для базового слота не найден семестр");
        }
        var rule = baseRuleSlot.getScheduleRule();
        var semester = rule.getSemester();
        LocalDate targetDate = command.targetLessonDate();
        if (targetDate.isBefore(semester.getStartDate()) || targetDate.isAfter(semester.getEndDate())) {
            throw new IllegalArgumentException("Перенести занятие можно только в пределах того же семестра");
        }
        if (!rule.isActiveOn(targetDate) || !rule.getSubject().isActiveOn(targetDate)) {
            throw new IllegalArgumentException("Правило или дисциплина не действуют на целевую дату");
        }
        Set<Long> affectedGroupIds = sourceOccurrences.stream()
                .flatMap(lesson -> safeIds(lesson.groupIds()).stream())
                .collect(Collectors.toSet());
        List<com.magistr.app.model.StudentGroup> affectedGroups = rule.getGroups().stream()
                .filter(group -> affectedGroupIds.contains(group.getId()))
                .toList();
        if (affectedGroups.size() != affectedGroupIds.size()
                || affectedGroups.stream().anyMatch(group -> !group.isActiveOn(targetDate)
                || !academicDateService.isTheoryDay(group, semester, targetDate))) {
            throw new IllegalArgumentException(
                    "Целевая дата должна быть учебной для всех групп исходного занятия"
            );
        }
    }

    private ScheduleOverride buildCandidate(OverrideCommand command, ResolvedReferences references) {
        ScheduleOverride candidate = new ScheduleOverride();
        candidate.setBaseRuleSlot(references.baseRuleSlot());
        candidate.setLessonDate(command.lessonDate());
        candidate.setTargetLessonDate(command.targetLessonDate());
        candidate.setAction(command.action());
        candidate.setNewTimeSlot(references.newTimeSlot());
        candidate.setNewClassroom(references.newClassroom());
        candidate.setNewTeacher(references.newTeacher());
        candidate.setNewLessonFormat(command.newLessonFormat());
        return candidate;
    }

    private void copyCandidate(ScheduleOverride source, ScheduleOverride target) {
        target.setBaseRuleSlot(source.getBaseRuleSlot());
        target.setLessonDate(source.getLessonDate());
        target.setTargetLessonDate(source.getTargetLessonDate());
        target.setAction(source.getAction());
        target.setNewTimeSlot(source.getNewTimeSlot());
        target.setNewClassroom(source.getNewClassroom());
        target.setNewTeacher(source.getNewTeacher());
        target.setNewLessonFormat(source.getNewLessonFormat());
    }

    private OverrideCommand validateAndNormalize(ScheduleOverrideDto request) {
        if (request == null) {
            throw new IllegalArgumentException("Передайте данные изменения расписания");
        }
        if (request.baseRuleSlotId() == null) {
            throw new IllegalArgumentException("Базовый слот расписания обязателен");
        }
        if (request.lessonDate() == null) {
            throw new IllegalArgumentException("Дата пары обязательна");
        }
        String action = clean(request.action());
        action = action == null ? null : action.toUpperCase(Locale.ROOT);
        if (action == null || !ACTIONS.contains(action)) {
            throw new IllegalArgumentException("Недопустимое действие изменения расписания");
        }
        String lessonFormat = clean(request.newLessonFormat());
        LocalDate targetLessonDate = request.targetLessonDate();
        if (lessonFormat != null && !LESSON_FORMATS.contains(lessonFormat)) {
            throw new IllegalArgumentException("Формат пары должен быть «Очно» или «Онлайн»");
        }

        if ("CANCEL".equals(action)
                && (targetLessonDate != null
                || request.newTimeSlotId() != null
                || request.newClassroomId() != null
                || request.newTeacherId() != null
                || lessonFormat != null)) {
            throw new IllegalArgumentException("Для отмены пары новые параметры должны быть пустыми");
        }
        if (targetLessonDate != null && Objects.equals(targetLessonDate, request.lessonDate())) {
            throw new IllegalArgumentException("Целевая дата должна отличаться от исходной даты пары");
        }
        if (targetLessonDate != null && request.newTimeSlotId() == null) {
            throw new IllegalArgumentException("Для переноса даты выберите временной слот целевого дня");
        }
        if (targetLessonDate != null && !"MOVE".equals(action)) {
            throw new IllegalArgumentException("Перенос даты должен использовать действие MOVE");
        }
        if ("MOVE".equals(action)
                && request.newTimeSlotId() == null) {
            throw new IllegalArgumentException("Для переноса укажите новое время");
        }
        if ("REPLACE".equals(action) && request.newTimeSlotId() != null) {
            throw new IllegalArgumentException("Изменение времени должно использовать действие MOVE");
        }
        if ("REPLACE".equals(action)
                && request.newTeacherId() == null
                && request.newClassroomId() == null
                && lessonFormat == null) {
            throw new IllegalArgumentException(
                    "Для замены укажите нового преподавателя, аудиторию или формат пары"
            );
        }
        return new OverrideCommand(
                request.baseRuleSlotId(),
                request.lessonDate(),
                targetLessonDate,
                action,
                request.newTimeSlotId(),
                request.newClassroomId(),
                request.newTeacherId(),
                lessonFormat,
                clean(request.comment())
        );
    }

    private void lockDatesInStableOrder(LocalDate... dates) {
        java.util.Arrays.stream(dates)
                .filter(Objects::nonNull)
                .distinct()
                .sorted(Comparator.naturalOrder())
                .forEach(advisoryLock::lockScheduleOverrideDate);
    }

    private boolean sameKey(ScheduleOverride override, Long baseRuleSlotId, LocalDate lessonDate) {
        return override.getBaseRuleSlot() != null
                && Objects.equals(override.getBaseRuleSlot().getId(), baseRuleSlotId)
                && Objects.equals(override.getLessonDate(), lessonDate);
    }

    private LocalDate effectiveDate(ScheduleOverride override) {
        return override.getTargetLessonDate() == null
                ? override.getLessonDate()
                : override.getTargetLessonDate();
    }

    private void ensureSourceOccurrenceExists(Long baseRuleSlotId, LocalDate lessonDate) {
        boolean exists = scheduleQueryService.buildBaseDayForAllGroups(lessonDate).stream()
                .anyMatch(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), baseRuleSlotId)
                        && Objects.equals(lesson.date(), lessonDate));
        if (!exists) {
            throw new IllegalArgumentException(
                    "На выбранную дату базовая пара не формируется по правилам расписания"
            );
        }
    }

    private void validateRange(LocalDate startDate, LocalDate endDate) {
        if (endDate.isBefore(startDate)) {
            throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
        }
        if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) {
            throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней");
        }
    }

    private NoSuchElementException notFound(Long id) {
        return new NoSuchElementException("Точечное изменение расписания с идентификатором " + id + " не найдено");
    }

    private ScheduleOverrideDto toDto(ScheduleOverride override) {
        ScheduleRuleSlot baseSlot = override.getBaseRuleSlot();
        var rule = baseSlot == null ? null : baseSlot.getScheduleRule();
        var semester = rule == null ? null : rule.getSemester();
        TimeSlot sourceTimeSlot = baseSlot == null ? null : baseSlot.getTimeSlot();
        User sourceTeacher = baseSlot == null ? null : baseSlot.getTeacher();
        Classroom sourceClassroom = baseSlot == null ? null : baseSlot.getClassroom();
        List<String> groupNames = rule == null || rule.getGroups() == null
                ? List.of()
                : rule.getGroups().stream()
                .map(com.magistr.app.model.StudentGroup::getName)
                .filter(Objects::nonNull)
                .sorted()
                .toList();
        return new ScheduleOverrideDto(
                override.getId(),
                baseSlot == null ? null : baseSlot.getId(),
                override.getLessonDate(),
                override.getTargetLessonDate(),
                override.getAction(),
                override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
                override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
                override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
                override.getNewLessonFormat(),
                override.getComment(),
                override.getCreatedBy(),
                override.getCreatedAt(),
                semester == null ? null : semester.getId(),
                semester == null ? null : semester.getStartDate(),
                semester == null ? null : semester.getEndDate(),
                sourceTimeSlot == null ? null : sourceTimeSlot.getId(),
                sourceTimeSlot == null ? null : sourceTimeSlot.getOrderNumber(),
                sourceTimeSlot == null ? null : sourceTimeSlot.getStartTime(),
                sourceTimeSlot == null ? null : sourceTimeSlot.getEndTime(),
                sourceTeacher == null ? null : sourceTeacher.getId(),
                sourceTeacher == null ? null : ScheduleGeneratorService.displayUserName(sourceTeacher),
                sourceClassroom == null ? null : sourceClassroom.getId(),
                sourceClassroom == null ? null : sourceClassroom.getName(),
                baseSlot == null ? null : baseSlot.getLessonFormat(),
                rule == null || rule.getSubject() == null ? null : rule.getSubject().getName(),
                baseSlot == null || baseSlot.getLessonType() == null
                        ? null
                        : baseSlot.getLessonType().getLessonType(),
                groupNames
        );
    }

    private String clean(String value) {
        return value == null || value.isBlank() ? null : value.trim();
    }

    private record OverrideCommand(
            Long baseRuleSlotId,
            LocalDate lessonDate,
            LocalDate targetLessonDate,
            String action,
            Long newTimeSlotId,
            Long newClassroomId,
            Long newTeacherId,
            String newLessonFormat,
            String comment
    ) {
        private LocalDate effectiveDate() {
            return targetLessonDate == null ? lessonDate : targetLessonDate;
        }
    }

    private record ResolvedReferences(
            ScheduleRuleSlot baseRuleSlot,
            TimeSlot newTimeSlot,
            Classroom newClassroom,
            User newTeacher
    ) {
    }

    private record EffectiveSourceTuple(
            LocalDate date,
            Long timeSlotId,
            LocalTime startTime,
            LocalTime endTime,
            Long teacherId,
            Long classroomId,
            String lessonFormat
    ) {
        private static EffectiveSourceTuple from(RenderedLessonDto lesson) {
            return new EffectiveSourceTuple(
                    lesson.date(),
                    lesson.timeSlotId(),
                    lesson.startTime(),
                    lesson.endTime(),
                    lesson.teacherId(),
                    lesson.classroomId(),
                    lesson.lessonFormat()
            );
        }
    }

    private record MoveTuple(
            LocalDate date,
            LocalTime startTime,
            LocalTime endTime,
            Long classroomId
    ) {
        private static MoveTuple from(RenderedLessonDto lesson) {
            return new MoveTuple(lesson.date(), lesson.startTime(), lesson.endTime(), lesson.classroomId());
        }
    }

    private record ReplacementTuple(
            Long teacherId,
            Long classroomId,
            String lessonFormat
    ) {
        private static ReplacementTuple from(RenderedLessonDto lesson) {
            return new ReplacementTuple(lesson.teacherId(), lesson.classroomId(), lesson.lessonFormat());
        }
    }
}
