package com.magistr.app.service;

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.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.Semester;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subject;
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.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ScheduleOverrideServiceTest {

    private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
    private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
    private static final Long BASE_SLOT_ID = 101L;
    private static final Long OTHER_SLOT_ID = 202L;
    private static final Long SOURCE_TIME_SLOT_ID = 1L;
    private static final Long NEW_TIME_SLOT_ID = 2L;
    private static final Long SOURCE_TEACHER_ID = 11L;
    private static final Long NEW_TEACHER_ID = 12L;
    private static final Long SOURCE_CLASSROOM_ID = 21L;
    private static final Long NEW_CLASSROOM_ID = 22L;
    private static final Long GROUP_ID = 31L;
    private static final Long OTHER_GROUP_ID = 32L;
    private static final Long SUBGROUP_A_ID = 41L;
    private static final Long SUBGROUP_B_ID = 42L;

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

    private ScheduleOverrideService service;
    private ScheduleRuleSlot baseSlot;
    private TimeSlot sourceTimeSlot;
    private TimeSlot newTimeSlot;
    private Classroom sourceClassroom;
    private Classroom newClassroom;
    private User sourceTeacher;
    private User newTeacher;
    private RenderedLessonDto sourceLesson;

    @BeforeEach
    void setUp() {
        baseSlot = ruleSlot(BASE_SLOT_ID);
        sourceTimeSlot = timeSlot(SOURCE_TIME_SLOT_ID, 1, "09:00", "10:30");
        newTimeSlot = timeSlot(NEW_TIME_SLOT_ID, 2, "10:40", "12:10");
        sourceClassroom = classroom(SOURCE_CLASSROOM_ID, "А-101");
        newClassroom = classroom(NEW_CLASSROOM_ID, "Б-202");
        sourceTeacher = teacher(SOURCE_TEACHER_ID, "Исходный преподаватель");
        newTeacher = teacher(NEW_TEACHER_ID, "Новый преподаватель");
        baseSlot.setTimeSlot(sourceTimeSlot);
        baseSlot.setTeacher(sourceTeacher);
        baseSlot.setClassroom(sourceClassroom);
        baseSlot.setLessonFormat("Очно");
        sourceLesson = lesson(
                BASE_SLOT_ID,
                SOURCE_TIME_SLOT_ID,
                "09:00",
                "10:30",
                SOURCE_TEACHER_ID,
                SOURCE_CLASSROOM_ID,
                List.of(GROUP_ID),
                List.of(),
                "Очно"
        );

        service = new ScheduleOverrideService(
                scheduleOverrideRepository,
                scheduleRuleSlotRepository,
                timeSlotRepository,
                classroomRepository,
                userRepository,
                subgroupRepository,
                scheduleQueryService,
                scheduleGeneratorService,
                advisoryLock,
                academicDateService,
                effectiveTimeSlotService
        );

        when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
        when(timeSlotRepository.findById(SOURCE_TIME_SLOT_ID)).thenReturn(Optional.of(sourceTimeSlot));
        when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.of(newTimeSlot));
        when(classroomRepository.findById(SOURCE_CLASSROOM_ID)).thenReturn(Optional.of(sourceClassroom));
        when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.of(newClassroom));
        when(userRepository.findById(SOURCE_TEACHER_ID)).thenReturn(Optional.of(sourceTeacher));
        when(userRepository.findById(NEW_TEACHER_ID)).thenReturn(Optional.of(newTeacher));
        when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
                .thenReturn(Optional.empty());
        when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE)).thenReturn(List.of());
        when(scheduleOverrideRepository.saveAndFlush(any(ScheduleOverride.class)))
                .thenAnswer(invocation -> invocation.getArgument(0));
        when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(sourceLesson));
        when(effectiveTimeSlotService.isEffectiveOnDate(any(Long.class), any(LocalDate.class))).thenReturn(true);
        when(subgroupRepository.findGroupReferencesByIdIn(anyList())).thenAnswer(invocation -> {
            List<Long> subgroupIds = invocation.getArgument(0);
            return subgroupIds.stream()
                    .map(id -> subgroupReference(id, GROUP_ID))
                    .toList();
        });
    }

    @Test
    void rejectsNullRequestWithRussianMessage() {
        assertThatThrownBy(() -> service.create(null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Передайте данные изменения расписания");
    }

    @Test
    void registryRangeReturnsOverrideWhoseTargetDateIsInsidePeriod() {
        ScheduleOverride moved = override(720L, baseSlot, "MOVE", newTimeSlot);
        moved.setTargetLessonDate(TARGET_DATE);
        when(scheduleOverrideRepository.findForRegistry(TARGET_DATE, TARGET_DATE)).thenReturn(List.of(moved));

        List<ScheduleOverrideDto> result = service.getAll(TARGET_DATE, TARGET_DATE);

        assertThat(result).singleElement().satisfies(item -> {
            assertThat(item.lessonDate()).isEqualTo(DATE);
            assertThat(item.targetLessonDate()).isEqualTo(TARGET_DATE);
        });
        verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE);
    }

    @Test
    void availabilityContainsOnlyStudyDatesOfSourceSemester() {
        Semester semester = configureRuleForTarget();
        StudentGroup group = baseSlot.getScheduleRule().getGroups().iterator().next();
        AcademicDateService.ScheduleSnapshot snapshot = org.mockito.Mockito.mock(
                AcademicDateService.ScheduleSnapshot.class
        );
        when(academicDateService.createScheduleSnapshot(
                any(Collection.class),
                any(Collection.class),
                eq(semester.getEndDate())
        )).thenReturn(snapshot);
        when(snapshot.isTheoryDay(eq(group), eq(semester), any(LocalDate.class)))
                .thenAnswer(invocation -> {
                    LocalDate date = invocation.getArgument(2);
                    return date.equals(DATE) || date.equals(TARGET_DATE);
                });

        ScheduleOverrideAvailabilityDto result = service.getAvailability(BASE_SLOT_ID, DATE);

        assertThat(result.semesterId()).isEqualTo(semester.getId());
        assertThat(result.semesterStartDate()).isEqualTo(semester.getStartDate());
        assertThat(result.semesterEndDate()).isEqualTo(semester.getEndDate());
        assertThat(result.availableDates()).containsExactly(DATE, TARGET_DATE);
    }

    @Test
    void rejectsMissingRequiredRequestReferences() {
        assertThatThrownBy(() -> service.create(request(null, DATE, "CANCEL", null, null, null, null)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Базовый слот расписания обязателен");

        assertThatThrownBy(() -> service.create(request(BASE_SLOT_ID, null, "CANCEL", null, null, null, null)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Дата пары обязательна");
    }

    @Test
    void rejectsMissingDatabaseReferencesWithRussianMessages() {
        when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.empty());
        assertThatThrownBy(() -> service.create(cancelRequest()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Базовый слот расписания не найден");

        when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
        when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.empty());
        assertThatThrownBy(() -> service.create(moveRequest()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Временной слот не найден");

        when(timeSlotRepository.findById(NEW_TIME_SLOT_ID)).thenReturn(Optional.of(newTimeSlot));
        when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.empty());
        assertThatThrownBy(() -> service.create(replaceRequest()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Аудитория не найдена");

        when(classroomRepository.findById(NEW_CLASSROOM_ID)).thenReturn(Optional.of(newClassroom));
        when(userRepository.findById(NEW_TEACHER_ID)).thenReturn(Optional.empty());
        assertThatThrownBy(() -> service.create(replaceRequest()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Преподаватель не найден");
    }

    @Test
    void enforcesCancelMoveReplaceActionMatrix() {
        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "SKIP", null, null, null, null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Недопустимое действие изменения расписания");

        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "CANCEL", NEW_TIME_SLOT_ID, null, null, null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Для отмены пары новые параметры должны быть пустыми");

        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "MOVE", null, null, NEW_TEACHER_ID, null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Для переноса укажите новое время");

        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "REPLACE", null, null, null, null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Для замены укажите нового преподавателя, аудиторию или формат пары");
    }

    @Test
    void rejectsUnsupportedLessonFormat() {
        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "REPLACE", null, NEW_CLASSROOM_ID, NEW_TEACHER_ID, "Гибрид"
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Формат пары должен быть «Очно» или «Онлайн»");
    }

    @Test
    void acceptsCancelMoveAndReplaceAndNormalizesText() {
        ScheduleOverrideDto cancelled = service.create(cancelRequest());
        assertThat(cancelled.action()).isEqualTo("CANCEL");

        stubEffectiveDay(movedLesson());
        ScheduleOverrideDto moved = service.create(request(
                BASE_SLOT_ID, DATE, " move ", NEW_TIME_SLOT_ID, null, null, null
        ));
        assertThat(moved.action()).isEqualTo("MOVE");
        assertThat(moved.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);

        stubEffectiveDay(replacedLesson());
        ScheduleOverrideDto replaced = service.create(request(
                BASE_SLOT_ID, DATE, " replace ", null, NEW_CLASSROOM_ID, NEW_TEACHER_ID, " Онлайн "
        ));
        assertThat(replaced.action()).isEqualTo("REPLACE");
        assertThat(replaced.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
        assertThat(replaced.newClassroomId()).isEqualTo(NEW_CLASSROOM_ID);
        assertThat(replaced.newLessonFormat()).isEqualTo("Онлайн");

        verify(scheduleGeneratorService, org.mockito.Mockito.times(3)).clearCache();
    }

    @Test
    void acceptsReplaceWithOnlyOneReplacementField() {
        RenderedLessonDto teacherOnlyReplacement = lesson(
                BASE_SLOT_ID,
                SOURCE_TIME_SLOT_ID,
                "09:00",
                "10:30",
                NEW_TEACHER_ID,
                SOURCE_CLASSROOM_ID,
                List.of(GROUP_ID),
                List.of(),
                "Очно"
        );
        stubEffectiveDay(teacherOnlyReplacement);

        ScheduleOverrideDto replaced = service.create(request(
                BASE_SLOT_ID, DATE, "REPLACE", null, null, NEW_TEACHER_ID, null
        ));

        assertThat(replaced.action()).isEqualTo("REPLACE");
        assertThat(replaced.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
        assertThat(replaced.newClassroomId()).isNull();
        assertThat(replaced.newLessonFormat()).isNull();
    }

    @Test
    void rejectsOverrideWhenActualSourceLessonDoesNotExist() {
        when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(
                lesson(OTHER_SLOT_ID, SOURCE_TIME_SLOT_ID, "09:00", "10:30",
                        SOURCE_TEACHER_ID, SOURCE_CLASSROOM_ID, List.of(GROUP_ID), List.of(), "Очно")
        ));

        assertThatThrownBy(() -> service.create(moveRequest()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("На выбранную дату базовая пара не формируется по правилам расписания");

        verify(scheduleOverrideRepository, never()).saveAndFlush(any());
    }

    @Test
    void rejectsActualNoOpMove() {
        stubEffectiveDay(sourceLesson);

        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "MOVE", SOURCE_TIME_SLOT_ID, SOURCE_CLASSROOM_ID, null, null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
    }

    @Test
    void rejectsDifferentTimeSlotIdWithSameActualInterval() {
        TimeSlot sameIntervalSlot = timeSlot(3L, 3, "09:00", "10:30");
        when(timeSlotRepository.findById(3L)).thenReturn(Optional.of(sameIntervalSlot));
        stubEffectiveDay(lesson(
                BASE_SLOT_ID,
                3L,
                "09:00",
                "10:30",
                SOURCE_TEACHER_ID,
                SOURCE_CLASSROOM_ID,
                List.of(GROUP_ID),
                List.of(),
                "Очно"
        ));

        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "MOVE", 3L, null, null, null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
    }

    @Test
    void rejectsActualNoOpReplace() {
        stubEffectiveDay(sourceLesson);

        assertThatThrownBy(() -> service.create(request(
                BASE_SLOT_ID, DATE, "REPLACE", null,
                SOURCE_CLASSROOM_ID, SOURCE_TEACHER_ID, "Очно"
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Замена должна фактически менять преподавателя, аудиторию или формат пары");
    }

    @Test
    void rejectsTeacherConflictFromEffectiveDay() {
        RenderedLessonDto candidate = movedLesson();
        RenderedLessonDto occupied = lesson(
                OTHER_SLOT_ID, 8L, "11:00", "12:30",
                SOURCE_TEACHER_ID, NEW_CLASSROOM_ID,
                List.of(OTHER_GROUP_ID), List.of(), "Очно"
        );
        stubEffectiveDay(candidate, occupied);

        assertThatThrownBy(() -> service.create(moveRequest()))
                .isInstanceOf(ScheduleConflictException.class)
                .hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
    }

    @Test
    void rejectsClassroomConflictFromEffectiveDay() {
        RenderedLessonDto candidate = movedLesson();
        RenderedLessonDto occupied = lesson(
                OTHER_SLOT_ID, 8L, "11:00", "12:30",
                NEW_TEACHER_ID, SOURCE_CLASSROOM_ID,
                List.of(OTHER_GROUP_ID), List.of(), "Очно"
        );
        stubEffectiveDay(candidate, occupied);

        assertThatThrownBy(() -> service.create(moveRequest()))
                .isInstanceOf(ScheduleConflictException.class)
                .hasMessage("Конфликт расписания: аудитория уже занята в указанное время");
    }

    @Test
    void rejectsWholeGroupConflictWithSubgroupLesson() {
        RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of());
        RenderedLessonDto occupied = lesson(
                OTHER_SLOT_ID, 8L, "11:00", "12:30",
                NEW_TEACHER_ID, NEW_CLASSROOM_ID,
                List.of(GROUP_ID), List.of(SUBGROUP_A_ID), "Очно"
        );
        stubEffectiveDay(candidate, occupied);

        assertThatThrownBy(() -> service.create(moveRequest()))
                .isInstanceOf(ScheduleConflictException.class)
                .hasMessage("Конфликт расписания: группа или подгруппа уже занята в указанное время");
    }

    @Test
    void rejectsConflictOfSameSubgroup() {
        RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of(SUBGROUP_A_ID));
        RenderedLessonDto occupied = lesson(
                OTHER_SLOT_ID, 8L, "11:00", "12:30",
                NEW_TEACHER_ID, NEW_CLASSROOM_ID,
                List.of(GROUP_ID), List.of(SUBGROUP_A_ID), "Очно"
        );
        stubEffectiveDay(candidate, occupied);

        assertThatThrownBy(() -> service.create(moveRequest()))
                .isInstanceOf(ScheduleConflictException.class)
                .hasMessage("Конфликт расписания: группа или подгруппа уже занята в указанное время");
    }

    @Test
    void allowsDifferentSubgroupsOfSameGroup() {
        RenderedLessonDto candidate = movedLesson(List.of(GROUP_ID), List.of(SUBGROUP_A_ID));
        RenderedLessonDto occupied = lesson(
                OTHER_SLOT_ID, 8L, "11:00", "12:30",
                NEW_TEACHER_ID, NEW_CLASSROOM_ID,
                List.of(GROUP_ID), List.of(SUBGROUP_B_ID), "Очно"
        );
        stubEffectiveDay(candidate, occupied);

        ScheduleOverrideDto saved = service.create(moveRequest());

        assertThat(saved.action()).isEqualTo("MOVE");
        verify(scheduleOverrideRepository).saveAndFlush(any(ScheduleOverride.class));
    }

    @Test
    void allowsAdjacentIntervalsForSameResources() {
        RenderedLessonDto candidate = movedLesson();
        RenderedLessonDto adjacent = lesson(
                OTHER_SLOT_ID, 9L, "12:10", "13:40",
                SOURCE_TEACHER_ID, SOURCE_CLASSROOM_ID,
                List.of(GROUP_ID), List.of(), "Очно"
        );
        stubEffectiveDay(candidate, adjacent);

        ScheduleOverrideDto saved = service.create(moveRequest());

        assertThat(saved.action()).isEqualTo("MOVE");
    }

    @Test
    void ignoresExistingCancellationBecauseConflictIsCalculatedFromEffectiveDay() {
        ScheduleOverride cancelledOtherLesson = override(700L, ruleSlot(OTHER_SLOT_ID), "CANCEL", null);
        when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
                .thenReturn(List.of(cancelledOtherLesson));
        stubEffectiveDay(movedLesson());

        service.create(moveRequest());

        ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
        verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
        assertThat(captor.getValue())
                .extracting(ScheduleOverride::getAction)
                .containsExactlyInAnyOrder("CANCEL", "MOVE");
    }

    @Test
    void updateExcludesItselfAndDoesNotMutateStoredEntityOnConflict() {
        ScheduleOverride stored = override(500L, baseSlot, "CANCEL", null);
        when(scheduleOverrideRepository.findByIdForUpdate(500L)).thenReturn(Optional.of(stored));
        when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
                .thenReturn(Optional.of(stored));
        when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
                .thenReturn(List.of(stored));
        RenderedLessonDto occupied = lesson(
                OTHER_SLOT_ID, 8L, "11:00", "12:30",
                SOURCE_TEACHER_ID, NEW_CLASSROOM_ID,
                List.of(OTHER_GROUP_ID), List.of(), "Очно"
        );
        stubEffectiveDay(movedLesson(), occupied);

        assertThatThrownBy(() -> service.update(500L, moveRequest()))
                .isInstanceOf(ScheduleConflictException.class)
                .hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");

        ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
        verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
        assertThat(captor.getValue()).hasSize(1);
        assertThat(captor.getValue().iterator().next()).isNotSameAs(stored);
        assertThat(stored.getAction()).isEqualTo("CANCEL");
        assertThat(stored.getNewTimeSlot()).isNull();
        verify(scheduleOverrideRepository, never()).saveAndFlush(stored);
        verify(scheduleGeneratorService, never()).clearCache();
    }

    @Test
    void idempotentUpdateExcludesStoredOverrideFromConflictCheck() {
        ScheduleOverride stored = override(501L, baseSlot, "MOVE", newTimeSlot);
        when(scheduleOverrideRepository.findByIdForUpdate(501L)).thenReturn(Optional.of(stored));
        when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
                .thenReturn(Optional.of(stored));
        when(scheduleOverrideRepository.findByLessonDateBetweenWithDetails(DATE, DATE))
                .thenReturn(List.of(stored));
        stubEffectiveDay(movedLesson());

        ScheduleOverrideDto result = service.update(501L, moveRequest());

        assertThat(result.id()).isEqualTo(501L);
        assertThat(result.action()).isEqualTo("MOVE");
        assertThat(result.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
        ArgumentCaptor<Collection<ScheduleOverride>> captor = collectionCaptor();
        verify(scheduleQueryService).buildEffectiveDayForAllGroups(eq(List.of(sourceLesson)), eq(DATE), captor.capture());
        assertThat(captor.getValue()).singleElement().isNotSameAs(stored);
        verify(scheduleOverrideRepository).saveAndFlush(stored);
    }

    @Test
    void acceptsCombinedMoveToStudyDateInSameSemester() {
        Semester semester = configureRuleForTarget();
        when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
                .thenReturn(true);
        when(scheduleOverrideRepository.findAffectingDatesWithDetails(any(Collection.class))).thenReturn(List.of());
        when(scheduleQueryService.buildEffectiveDaysForAllGroups(any(Collection.class), any(Collection.class)))
                .thenReturn(List.of(relocatedLesson(
                        BASE_SLOT_ID,
                        NEW_TIME_SLOT_ID,
                        NEW_TEACHER_ID,
                        NEW_CLASSROOM_ID,
                        "Онлайн"
                )));

        ScheduleOverrideDto saved = service.create(targetRequest(
                TARGET_DATE,
                "MOVE",
                NEW_TIME_SLOT_ID,
                NEW_CLASSROOM_ID,
                NEW_TEACHER_ID,
                "Онлайн"
        ));

        assertThat(saved.targetLessonDate()).isEqualTo(TARGET_DATE);
        assertThat(saved.action()).isEqualTo("MOVE");
        assertThat(saved.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
        assertThat(saved.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
        assertThat(saved.newClassroomId()).isEqualTo(NEW_CLASSROOM_ID);
        assertThat(saved.newLessonFormat()).isEqualTo("Онлайн");
        verify(advisoryLock).lockScheduleOverrideDate(DATE);
        verify(advisoryLock).lockScheduleOverrideDate(TARGET_DATE);
    }

    @Test
    void rejectsTargetDateOutsideSourceSemester() {
        configureRuleForTarget();
        LocalDate outsideSemester = LocalDate.of(2027, 2, 1);

        assertThatThrownBy(() -> service.create(targetRequest(
                outsideSemester,
                "MOVE",
                NEW_TIME_SLOT_ID,
                null,
                null,
                null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Перенести занятие можно только в пределах того же семестра");
    }

    @Test
    void rejectsTargetDateThatIsNotStudyDayForEveryGroup() {
        Semester semester = configureRuleForTarget();
        when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
                .thenReturn(false);

        assertThatThrownBy(() -> service.create(targetRequest(
                TARGET_DATE,
                "MOVE",
                NEW_TIME_SLOT_ID,
                null,
                null,
                null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Целевая дата должна быть учебной для всех групп исходного занятия");
    }

    @Test
    void rejectsTimeSlotThatIsNotEffectiveOnTargetDate() {
        configureRuleForTarget();
        when(effectiveTimeSlotService.isEffectiveOnDate(NEW_TIME_SLOT_ID, TARGET_DATE)).thenReturn(false);

        assertThatThrownBy(() -> service.create(targetRequest(
                TARGET_DATE,
                "MOVE",
                NEW_TIME_SLOT_ID,
                null,
                null,
                null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Выбранный временной слот не действует на целевую дату");
    }

    @Test
    void rejectsResourceThatIsInactiveOnTargetDate() {
        configureRuleForTarget();
        sourceTeacher.setActiveTo(DATE);

        assertThatThrownBy(() -> service.create(targetRequest(
                TARGET_DATE,
                "MOVE",
                NEW_TIME_SLOT_ID,
                null,
                null,
                null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Активный преподаватель с ролью TEACHER не найден");
    }

    @Test
    void rejectsResourceConflictInEffectiveTargetDay() {
        Semester semester = configureRuleForTarget();
        when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
                .thenReturn(true);
        when(scheduleOverrideRepository.findAffectingDatesWithDetails(any(Collection.class))).thenReturn(List.of());
        when(scheduleQueryService.buildEffectiveDaysForAllGroups(any(Collection.class), any(Collection.class)))
                .thenReturn(List.of(
                        relocatedLesson(
                                BASE_SLOT_ID,
                                NEW_TIME_SLOT_ID,
                                SOURCE_TEACHER_ID,
                                SOURCE_CLASSROOM_ID,
                                "Очно"
                        ),
                        relocatedLesson(
                                OTHER_SLOT_ID,
                                NEW_TIME_SLOT_ID,
                                SOURCE_TEACHER_ID,
                                NEW_CLASSROOM_ID,
                                "Очно"
                        )
                ));

        assertThatThrownBy(() -> service.create(targetRequest(
                TARGET_DATE,
                "MOVE",
                NEW_TIME_SLOT_ID,
                null,
                null,
                null
        )))
                .isInstanceOf(ScheduleConflictException.class)
                .hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
    }

    @Test
    void rejectsTargetDateWithReplaceAction() {
        assertThatThrownBy(() -> service.create(targetRequest(
                TARGET_DATE,
                "REPLACE",
                NEW_TIME_SLOT_ID,
                NEW_CLASSROOM_ID,
                null,
                null
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Перенос даты должен использовать действие MOVE");
    }

    @Test
    void deleteMovedOverrideLocksBothDatesAndReturnsRuleAfterCacheRefresh() {
        ScheduleOverride stored = override(710L, baseSlot, "MOVE", newTimeSlot);
        stored.setTargetLessonDate(TARGET_DATE);
        when(scheduleOverrideRepository.findByIdForUpdate(710L)).thenReturn(Optional.of(stored));

        service.delete(710L);

        verify(advisoryLock).lockScheduleOverrideDate(DATE);
        verify(advisoryLock).lockScheduleOverrideDate(TARGET_DATE);
        verify(scheduleOverrideRepository).delete(stored);
        verify(scheduleOverrideRepository).flush();
        verify(scheduleGeneratorService).clearCache();
    }

    private Semester configureRuleForTarget() {
        Semester semester = new Semester();
        semester.setId(81L);
        semester.setStartDate(LocalDate.of(2026, 9, 1));
        semester.setEndDate(LocalDate.of(2027, 1, 31));

        Subject subject = new Subject();
        subject.setId(501L);
        subject.setName("Тестовая дисциплина");

        StudentGroup group = new StudentGroup();
        group.setId(GROUP_ID);
        group.setName("Группа 31");

        ScheduleRule rule = new ScheduleRule();
        rule.setId(900L);
        rule.setSemester(semester);
        rule.setSubject(subject);
        rule.setGroups(Set.of(group));
        baseSlot.setScheduleRule(rule);
        return semester;
    }

    private ScheduleOverrideDto targetRequest(LocalDate targetDate,
                                              String action,
                                              Long newTimeSlotId,
                                              Long newClassroomId,
                                              Long newTeacherId,
                                              String format) {
        return new ScheduleOverrideDto(
                null,
                BASE_SLOT_ID,
                DATE,
                targetDate,
                action,
                newTimeSlotId,
                newClassroomId,
                newTeacherId,
                format,
                "Перенос конкретного занятия",
                null,
                null
        );
    }

    private RenderedLessonDto relocatedLesson(Long ruleSlotId,
                                              Long timeSlotId,
                                              Long teacherId,
                                              Long classroomId,
                                              String format) {
        return new RenderedLessonDto(
                900L,
                ruleSlotId,
                TARGET_DATE,
                4,
                "Четверг",
                2,
                ScheduleParity.EVEN,
                timeSlotId,
                2,
                LocalTime.of(10, 40),
                LocalTime.of(12, 10),
                501L,
                "Тестовая дисциплина",
                teacherId,
                "Тестовый преподаватель",
                classroomId,
                "Тестовая аудитория",
                601L,
                "Лекция",
                format,
                null,
                null,
                List.of(),
                List.of(),
                List.of(GROUP_ID),
                List.of("Группа 31"),
                "Лекция",
                2,
                0,
                0,
                ScheduleParity.BOTH,
                null,
                "MOVE",
                DATE
        );
    }

    private void stubEffectiveDay(RenderedLessonDto... lessons) {
        when(scheduleQueryService.buildEffectiveDayForAllGroups(
                eq(List.of(sourceLesson)),
                eq(DATE),
                any(Collection.class)
        )).thenReturn(List.of(lessons));
    }

    private ScheduleOverrideDto cancelRequest() {
        return request(BASE_SLOT_ID, DATE, "CANCEL", null, null, null, null);
    }

    private ScheduleOverrideDto moveRequest() {
        return request(BASE_SLOT_ID, DATE, "MOVE", NEW_TIME_SLOT_ID, null, null, null);
    }

    private ScheduleOverrideDto replaceRequest() {
        return request(
                BASE_SLOT_ID, DATE, "REPLACE", null,
                NEW_CLASSROOM_ID, NEW_TEACHER_ID, "Онлайн"
        );
    }

    private ScheduleOverrideDto request(Long baseRuleSlotId,
                                        LocalDate date,
                                        String action,
                                        Long newTimeSlotId,
                                        Long newClassroomId,
                                        Long newTeacherId,
                                        String format) {
        return new ScheduleOverrideDto(
                null,
                baseRuleSlotId,
                date,
                action,
                newTimeSlotId,
                newClassroomId,
                newTeacherId,
                format,
                "  Тестовый комментарий  ",
                null,
                null
        );
    }

    private RenderedLessonDto movedLesson() {
        return movedLesson(List.of(GROUP_ID), List.of());
    }

    private RenderedLessonDto movedLesson(List<Long> groupIds, List<Long> subgroupIds) {
        return lesson(
                BASE_SLOT_ID,
                NEW_TIME_SLOT_ID,
                "10:40",
                "12:10",
                SOURCE_TEACHER_ID,
                SOURCE_CLASSROOM_ID,
                groupIds,
                subgroupIds,
                "Очно"
        );
    }

    private RenderedLessonDto replacedLesson() {
        return lesson(
                BASE_SLOT_ID,
                SOURCE_TIME_SLOT_ID,
                "09:00",
                "10:30",
                NEW_TEACHER_ID,
                NEW_CLASSROOM_ID,
                List.of(GROUP_ID),
                List.of(),
                "Онлайн"
        );
    }

    private RenderedLessonDto lesson(Long ruleSlotId,
                                     Long timeSlotId,
                                     String start,
                                     String end,
                                     Long teacherId,
                                     Long classroomId,
                                     List<Long> groupIds,
                                     List<Long> subgroupIds,
                                     String format) {
        return new RenderedLessonDto(
                900L,
                ruleSlotId,
                DATE,
                2,
                "Вторник",
                1,
                ScheduleParity.BOTH,
                timeSlotId,
                1,
                LocalTime.parse(start),
                LocalTime.parse(end),
                501L,
                "Тестовая дисциплина",
                teacherId,
                "Тестовый преподаватель",
                classroomId,
                "Тестовая аудитория",
                601L,
                "Лекция",
                format,
                subgroupIds.size() == 1 ? subgroupIds.get(0) : null,
                subgroupIds.size() == 1 ? "Подгруппа" : null,
                subgroupIds,
                subgroupIds.stream().map(id -> "Подгруппа " + id).toList(),
                groupIds,
                groupIds.stream().map(id -> "Группа " + id).toList(),
                "Лекция",
                2,
                0,
                0,
                ScheduleParity.BOTH
        );
    }

    private ScheduleRuleSlot ruleSlot(Long id) {
        ScheduleRuleSlot slot = new ScheduleRuleSlot();
        slot.setId(id);
        return slot;
    }

    private TimeSlot timeSlot(Long id, int order, String start, String end) {
        TimeSlot slot = new TimeSlot();
        slot.setId(id);
        slot.setOrderNumber(order);
        slot.setStartTime(LocalTime.parse(start));
        slot.setEndTime(LocalTime.parse(end));
        slot.setDurationMinutes((int) java.time.Duration.between(slot.getStartTime(), slot.getEndTime()).toMinutes());
        return slot;
    }

    private Classroom classroom(Long id, String name) {
        Classroom classroom = new Classroom();
        classroom.setId(id);
        classroom.setName(name);
        classroom.setIsAvailable(true);
        return classroom;
    }

    private User teacher(Long id, String name) {
        User teacher = new User();
        teacher.setId(id);
        teacher.setRole(Role.TEACHER);
        teacher.setFullName(name);
        return teacher;
    }

    private ScheduleOverride override(Long id,
                                      ScheduleRuleSlot slot,
                                      String action,
                                      TimeSlot replacementTimeSlot) {
        ScheduleOverride override = new ScheduleOverride();
        ReflectionTestUtils.setField(override, "id", id);
        override.setBaseRuleSlot(slot);
        override.setLessonDate(DATE);
        override.setAction(action);
        override.setNewTimeSlot(replacementTimeSlot);
        return override;
    }

    private SubgroupRepository.SubgroupGroupReference subgroupReference(Long subgroupId, Long groupId) {
        SubgroupRepository.SubgroupGroupReference reference = org.mockito.Mockito.mock(
                SubgroupRepository.SubgroupGroupReference.class
        );
        when(reference.getSubgroupId()).thenReturn(subgroupId);
        when(reference.getGroupId()).thenReturn(groupId);
        return reference;
    }

    @SuppressWarnings({"unchecked", "rawtypes"})
    private ArgumentCaptor<Collection<ScheduleOverride>> collectionCaptor() {
        return ArgumentCaptor.forClass((Class) Collection.class);
    }
}
