package com.magistr.app.service;

import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleLessonCategory;
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.Subgroup;
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.GroupRepository;
import com.magistr.app.repository.LessonTypesRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.SubjectRepository;
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 java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class ScheduleRuleService {

    private static final String APPLY_MODE_DEFAULT = "DEFAULT";
    private static final int ACADEMIC_HOURS_PER_SLOT = 2;
    private static final Set<String> VALID_LESSON_FORMATS = Set.of("Очно", "Онлайн");
    private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
            ScheduleParity.BOTH,
            ScheduleParity.ODD,
            ScheduleParity.EVEN
    );

    private final ScheduleRuleRepository scheduleRuleRepository;
    private final SubjectRepository subjectRepository;
    private final SemesterRepository semesterRepository;
    private final GroupRepository groupRepository;
    private final TimeSlotRepository timeSlotRepository;
    private final UserRepository userRepository;
    private final ClassroomRepository classroomRepository;
    private final LessonTypesRepository lessonTypesRepository;
    private final SubgroupRepository subgroupRepository;
    private final ScheduleGeneratorService scheduleGeneratorService;

    public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
                               SubjectRepository subjectRepository,
                               SemesterRepository semesterRepository,
                               GroupRepository groupRepository,
                               TimeSlotRepository timeSlotRepository,
                               UserRepository userRepository,
                               ClassroomRepository classroomRepository,
                               LessonTypesRepository lessonTypesRepository,
                               SubgroupRepository subgroupRepository,
                               ScheduleGeneratorService scheduleGeneratorService) {
        this.scheduleRuleRepository = scheduleRuleRepository;
        this.subjectRepository = subjectRepository;
        this.semesterRepository = semesterRepository;
        this.groupRepository = groupRepository;
        this.timeSlotRepository = timeSlotRepository;
        this.userRepository = userRepository;
        this.classroomRepository = classroomRepository;
        this.lessonTypesRepository = lessonTypesRepository;
        this.subgroupRepository = subgroupRepository;
        this.scheduleGeneratorService = scheduleGeneratorService;
    }

    @Transactional(readOnly = true)
    public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
        return scheduleRuleRepository.findAllWithDetails().stream()
                .filter(ScheduleRule::isActiveRecord)
                .filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
                .filter(rule -> groupId == null || rule.getGroups().stream()
                        .anyMatch(group -> Objects.equals(group.getId(), groupId)))
                .map(this::toDto)
                .toList();
    }

    @Transactional(readOnly = true)
    public ScheduleRuleDto getById(Long id) {
        ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id)
                .orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
        return toDto(rule);
    }

    @Transactional
    public ScheduleRuleDto create(ScheduleRuleDto request) {
        validateBasic(request);

        // Это должна быть первая операция с БД: блокировка сериализует все записи
        // правил одного семестра, включая запросы из разных экземпляров backend.
        Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
                .orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));

        ScheduleRule candidate = buildCandidate(request, semester);
        validateCandidate(candidate);
        validateAgainstSavedRules(candidate, null);

        ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
        scheduleGeneratorService.clearCache();
        return toDto(saved);
    }

    @Transactional
    public ScheduleRuleDto update(Long id, ScheduleRuleDto request) {
        validateBasic(request);

        // Сначала блокируется сама строка правила. Это гарантирует, что old semester
        // определён по актуальному состоянию и два update одного правила не расходятся.
        ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
                .orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
        Long oldSemesterId = managedRule.getSemester().getId();

        Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
                List.of(oldSemesterId, request.semesterId())
        );
        Semester targetSemester = lockedSemesters.get(request.semesterId());
        if (targetSemester == null) {
            throw new IllegalArgumentException("Семестр не найден");
        }

        ScheduleRule candidate = buildCandidate(request, targetSemester);
        validateCandidate(candidate);
        validateAgainstSavedRules(candidate, managedRule.getId());

        applyCandidate(managedRule, candidate);
        ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
        scheduleGeneratorService.clearCache();
        return toDto(saved);
    }

    @Transactional
    public void archive(Long id) {
        ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
                .orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
        Long semesterId = rule.getSemester().getId();
        semesterRepository.findByIdForUpdate(semesterId)
                .orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));

        rule.archive();
        scheduleRuleRepository.saveAndFlush(rule);
        scheduleGeneratorService.clearCache();
    }

    private Map<Long, Semester> lockSemestersInStableOrder(Collection<Long> semesterIds) {
        List<Long> orderedIds = new ArrayList<>(new TreeSet<>(semesterIds));
        return semesterRepository.findAllByIdForUpdateOrderById(orderedIds).stream()
                .collect(Collectors.toMap(
                        Semester::getId,
                        Function.identity(),
                        (first, ignored) -> first,
                        LinkedHashMap::new
                ));
    }

    private void validateBasic(ScheduleRuleDto request) {
        if (request == null) {
            throw new IllegalArgumentException("Данные правила обязательны");
        }
        if (request.subjectId() == null) {
            throw new IllegalArgumentException("Дисциплина обязательна");
        }
        if (request.semesterId() == null) {
            throw new IllegalArgumentException("Семестр обязателен");
        }

        validateHours("лекций", request.lectureAcademicHours());
        validateHours("лабораторных занятий", request.laboratoryAcademicHours());
        validateHours("практических занятий", request.practiceAcademicHours());
        long totalHours = (long) request.lectureAcademicHours()
                + request.laboratoryAcademicHours()
                + request.practiceAcademicHours();
        if (totalHours <= 0) {
            throw new IllegalArgumentException("Укажите часы хотя бы для одного типа занятий");
        }

        validateStartWeek("лекций", request.lectureStartWeek());
        validateStartWeek("лабораторных занятий", request.laboratoryStartWeek());
        validateStartWeek("практических занятий", request.practiceStartWeek());

        if (request.groupIds() == null || request.groupIds().isEmpty()) {
            throw new IllegalArgumentException("Нужно выбрать хотя бы одну группу");
        }
        if (request.groupIds().stream().anyMatch(Objects::isNull)) {
            throw new IllegalArgumentException("Идентификаторы групп не могут быть пустыми");
        }
        if (new HashSet<>(request.groupIds()).size() != request.groupIds().size()) {
            throw new IllegalArgumentException("Группы в правиле не должны повторяться");
        }
        if (request.slots() == null || request.slots().isEmpty()) {
            throw new IllegalArgumentException("Добавьте хотя бы один слот занятия");
        }

        for (ScheduleRuleSlotDto slot : request.slots()) {
            validateBasicSlot(slot);
        }
    }

    private void validateHours(String typeName, Integer hours) {
        if (hours == null) {
            throw new IllegalArgumentException("Укажите количество часов для " + typeName);
        }
        if (hours < 0) {
            throw new IllegalArgumentException("Количество часов для " + typeName + " не может быть отрицательным");
        }
        if (hours % ACADEMIC_HOURS_PER_SLOT != 0) {
            throw new IllegalArgumentException("Количество часов для " + typeName + " должно быть чётным");
        }
    }

    private void validateStartWeek(String typeName, Integer startWeek) {
        if (startWeek == null || startWeek <= 0) {
            throw new IllegalArgumentException("Неделя начала для " + typeName + " должна быть больше нуля");
        }
    }

    private void validateBasicSlot(ScheduleRuleSlotDto slot) {
        if (slot == null) {
            throw new IllegalArgumentException("Данные слота занятия обязательны");
        }
        if (slot.dayOfWeek() == null || slot.dayOfWeek() < 1 || slot.dayOfWeek() > 7) {
            throw new IllegalArgumentException("День недели должен быть от 1 до 7");
        }
        if (slot.parity() == null || !VALID_SLOT_PARITIES.contains(slot.parity())) {
            throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
        }
        if (slot.timeSlotId() == null) {
            throw new IllegalArgumentException("Временной слот обязателен");
        }
        if (slot.teacherId() == null) {
            throw new IllegalArgumentException("Преподаватель обязателен");
        }
        if (slot.classroomId() == null) {
            throw new IllegalArgumentException("Аудитория обязательна");
        }
        if (slot.lessonTypeId() == null) {
            throw new IllegalArgumentException("Тип занятия обязателен");
        }
        normalizeLessonFormat(slot.lessonFormat());
    }

    private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester) {
        Subject subject = subjectRepository.findById(request.subjectId())
                .orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
        if (subject.isArchivedRecord()) {
            throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
        }

        List<StudentGroup> groups = new ArrayList<>(groupRepository.findAllById(request.groupIds()));
        if (groups.size() != request.groupIds().size()) {
            throw new IllegalArgumentException("Одна или несколько групп не найдены");
        }
        if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
            throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
        }
        groups.sort(Comparator.comparing(StudentGroup::getId));

        ScheduleRule candidate = new ScheduleRule();
        candidate.setSubject(subject);
        candidate.setSemester(semester);
        candidate.setLectureAcademicHours(request.lectureAcademicHours());
        candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
        candidate.setPracticeAcademicHours(request.practiceAcademicHours());
        candidate.setLectureStartWeek(request.lectureStartWeek());
        candidate.setLaboratoryStartWeek(request.laboratoryStartWeek());
        candidate.setPracticeStartWeek(request.practiceStartWeek());
        candidate.setGroups(new LinkedHashSet<>(groups));

        List<ScheduleRuleSlot> slots = new ArrayList<>();
        for (ScheduleRuleSlotDto slotDto : request.slots()) {
            slots.add(buildSlot(candidate, slotDto));
        }
        candidate.setSlots(new LinkedHashSet<>(slots));
        validateTypeCoverage(candidate, slots);
        return candidate;
    }

    private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
        TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
                .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
        if (timeSlot.getTimeSlotScope() == null
                || !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
            throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
        }

        User teacher = userRepository.findById(slotDto.teacherId())
                .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
        if (teacher.isArchivedRecord()) {
            throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
        }
        if (teacher.getRole() != Role.TEACHER) {
            throw new IllegalArgumentException("В слот расписания можно назначить только пользователя с ролью преподавателя");
        }

        Classroom classroom = classroomRepository.findById(slotDto.classroomId())
                .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
        if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
            throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
        }

        LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
                .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
        ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
        Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);

        ScheduleRuleSlot slot = new ScheduleRuleSlot();
        slot.setScheduleRule(rule);
        slot.setDayOfWeek(slotDto.dayOfWeek());
        slot.setParity(slotDto.parity());
        slot.setTimeSlot(timeSlot);
        slot.setSubgroups(subgroups);
        slot.setTeacher(teacher);
        slot.setClassroom(classroom);
        slot.setLessonType(lessonType);
        slot.setLessonFormat(normalizeLessonFormat(slotDto.lessonFormat()));
        return slot;
    }

    private Set<Subgroup> resolveSubgroups(ScheduleRule rule,
                                           ScheduleRuleSlotDto slotDto,
                                           ScheduleLessonCategory category) {
        LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
        if (slotDto.subgroupIds() != null) {
            slotDto.subgroupIds().stream()
                    .filter(Objects::nonNull)
                    .forEach(subgroupIds::add);
        }
        if (slotDto.subgroupId() != null) {
            subgroupIds.add(slotDto.subgroupId());
        }
        if (subgroupIds.isEmpty()) {
            return new LinkedHashSet<>();
        }
        if (category != ScheduleLessonCategory.LABORATORY) {
            throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
        }

        Set<Long> ruleGroupIds = rule.getGroups().stream()
                .map(StudentGroup::getId)
                .collect(Collectors.toSet());
        Set<Long> subgroupGroupIds = new HashSet<>();
        LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();

        for (Long subgroupId : subgroupIds) {
            Subgroup subgroup = subgroupRepository.findById(subgroupId)
                    .orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
            if (subgroup.isArchivedRecord()) {
                throw new IllegalArgumentException("Архивную подгруппу нельзя использовать в расписании");
            }
            Long subgroupGroupId = subgroup.getStudentGroup().getId();
            if (!ruleGroupIds.contains(subgroupGroupId)) {
                throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
            }
            if (!subgroupGroupIds.add(subgroupGroupId)) {
                throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
            }
            subgroups.add(subgroup);
        }
        return subgroups;
    }

    private String normalizeLessonFormat(String lessonFormat) {
        if (lessonFormat == null || lessonFormat.isBlank()) {
            throw new IllegalArgumentException("Формат занятия обязателен");
        }
        String normalized = lessonFormat.trim();
        if (!VALID_LESSON_FORMATS.contains(normalized)) {
            throw new IllegalArgumentException("Формат занятия должен быть «Очно» или «Онлайн»");
        }
        return normalized;
    }

    private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
        EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
        for (ScheduleRuleSlot slot : slots) {
            categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
        }

        for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
            int hours = rule.academicHoursFor(category);
            boolean hasSlots = categoriesWithSlots.contains(category);
            if (hours > 0 && !hasSlots) {
                throw new IllegalArgumentException(
                        "Для типа «" + category.getDisplayName() + "» добавьте хотя бы один слот"
                );
            }
            if (hours == 0 && hasSlots) {
                throw new IllegalArgumentException(
                        "Для типа «" + category.getDisplayName() + "» укажите часы или удалите слоты этого типа"
                );
            }
        }
    }

    private void validateCandidate(ScheduleRule candidate) {
        List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
        validateExactDuplicates(slots);
        validateInternalConflicts(slots);
    }

    private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {
        Set<SlotIdentity> seen = new HashSet<>();
        for (ScheduleRuleSlot slot : slots) {
            if (!seen.add(slotIdentity(slot))) {
                throw new ScheduleRuleConflictException(
                        "Невозможно сохранить правило: в списке есть дублирующиеся слоты",
                        null,
                        List.of("slot"),
                        List.of("Дублирующийся слот")
                );
            }
        }
    }

    private SlotIdentity slotIdentity(ScheduleRuleSlot slot) {
        List<Long> subgroupIds = slot.getSubgroups().stream()
                .map(Subgroup::getId)
                .sorted()
                .toList();
        return new SlotIdentity(
                slot.getDayOfWeek(),
                slot.getParity(),
                timeSlotId(slot),
                subgroupIds,
                teacherId(slot),
                classroomId(slot),
                slot.getLessonType().getId(),
                slot.getLessonFormat()
        );
    }

    private void validateInternalConflicts(List<ScheduleRuleSlot> slots) {
        Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = activeWeeksBySlot(slots);
        for (int firstIndex = 0; firstIndex < slots.size(); firstIndex++) {
            for (int secondIndex = firstIndex + 1; secondIndex < slots.size(); secondIndex++) {
                ScheduleRuleSlot first = slots.get(firstIndex);
                ScheduleRuleSlot second = slots.get(secondIndex);
                if (!sameTimeSlot(first, second)
                        || !activeWeeksOverlap(activeWeeks.get(first), activeWeeks.get(second))) {
                    continue;
                }

                List<String> fields = new ArrayList<>(conflictFields(first, second));
                if (!fields.isEmpty()) {
                    throw new ScheduleRuleConflictException(
                            "Невозможно сохранить правило: слоты внутри правила конфликтуют",
                            null,
                            fields,
                            conflictReasons(fields)
                    );
                }
            }
        }
    }

    private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
        Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
        ScheduleRuleConflict conflict = scheduleRuleRepository
                .findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
                .stream()
                .filter(ScheduleRule::isActiveRecord)
                .filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
                .filter(rule -> rule.getSemester() != null
                        && Objects.equals(rule.getSemester().getId(), candidate.getSemester().getId()))
                .map(rule -> toConflict(rule, candidate.getSlots(), candidateActiveWeeks, rule.getSlots()))
                .filter(Objects::nonNull)
                .min(Comparator.comparing(conflictResult -> conflictResult.rule().getId()))
                .orElse(null);
        if (conflict == null) {
            return;
        }

        List<String> fields = new ArrayList<>(conflict.fields());
        throw new ScheduleRuleConflictException(
                "Невозможно сохранить правило: слот занят",
                toDto(conflict.rule()),
                fields,
                conflictReasons(fields)
        );
    }

    private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
                                            Collection<ScheduleRuleSlot> candidateSlots,
                                            Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks,
                                            Collection<ScheduleRuleSlot> existingSlots) {
        Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
        LinkedHashSet<String> fields = new LinkedHashSet<>();
        for (ScheduleRuleSlot candidateSlot : candidateSlots) {
            for (ScheduleRuleSlot existingSlot : existingSlots) {
                if (sameTimeSlot(candidateSlot, existingSlot)
                        && activeWeeksOverlap(
                        candidateActiveWeeks.get(candidateSlot),
                        existingActiveWeeks.get(existingSlot)
                )) {
                    fields.addAll(conflictFields(candidateSlot, existingSlot));
                }
            }
        }
        return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
    }

    private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
        LinkedHashSet<String> fields = new LinkedHashSet<>();
        if (Objects.equals(teacherId(first), teacherId(second))) {
            fields.add("teacher");
        }
        if (Objects.equals(classroomId(first), classroomId(second))) {
            fields.add("classroom");
        }
        if (sameAudience(first, second)) {
            fields.add("group");
        }
        return fields;
    }

    private List<String> conflictReasons(Collection<String> fields) {
        return fields.stream().map(this::conflictFieldLabel).toList();
    }

    private String conflictFieldLabel(String field) {
        return switch (field) {
            case "teacher" -> "Преподаватель";
            case "classroom" -> "Аудитория";
            case "group" -> "Группа";
            case "slot" -> "Дублирующийся слот";
            default -> field;
        };
    }

    private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
        return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
                && parityOverlaps(first.getParity(), second.getParity())
                && Objects.equals(timeSlotId(first), timeSlotId(second));
    }

    private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
        if (Objects.equals(first, second)) {
            return true;
        }
        return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
    }

    private Long teacherId(ScheduleRuleSlot slot) {
        return slot.getTeacher() == null ? null : slot.getTeacher().getId();
    }

    private Long classroomId(ScheduleRuleSlot slot) {
        return slot.getClassroom() == null ? null : slot.getClassroom().getId();
    }

    private Long timeSlotId(ScheduleRuleSlot slot) {
        return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
    }

    private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
        Set<Long> commonGroupIds = groupIds(first);
        commonGroupIds.retainAll(groupIds(second));

        for (Long groupId : commonGroupIds) {
            if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
                return true;
            }
            Set<Long> commonSubgroupIds = subgroupIdsForGroup(first, groupId);
            commonSubgroupIds.retainAll(subgroupIdsForGroup(second, groupId));
            if (!commonSubgroupIds.isEmpty()) {
                return true;
            }
        }
        return false;
    }

    private Set<Long> groupIds(ScheduleRuleSlot slot) {
        if (!slot.getSubgroups().isEmpty()) {
            return slot.getSubgroups().stream()
                    .filter(subgroup -> subgroup.getStudentGroup() != null)
                    .map(subgroup -> subgroup.getStudentGroup().getId())
                    .collect(Collectors.toCollection(HashSet::new));
        }
        if (slot.getScheduleRule() == null) {
            return new HashSet<>();
        }
        return slot.getScheduleRule().getGroups().stream()
                .map(StudentGroup::getId)
                .collect(Collectors.toCollection(HashSet::new));
    }

    private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
        return slot.getSubgroups().isEmpty() && groupIds(slot).contains(groupId);
    }

    private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
        return slot.getSubgroups().stream()
                .filter(subgroup -> subgroup.getStudentGroup() != null)
                .filter(subgroup -> Objects.equals(subgroup.getStudentGroup().getId(), groupId))
                .map(Subgroup::getId)
                .collect(Collectors.toCollection(HashSet::new));
    }

    private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
        Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
        slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
        if (slots.isEmpty()) {
            return activeWeeks;
        }

        ScheduleRule rule = slots.iterator().next().getScheduleRule();
        if (rule == null || rule.getSemester() == null) {
            return activeWeeks;
        }

        int maxWeeks = maxWeeksForRule(rule);
        for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
            int totalHours = rule.academicHoursFor(category);
            if (totalHours <= 0) {
                continue;
            }

            List<ScheduleRuleSlot> categorySlots = slots.stream()
                    .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
                    .sorted(this::compareSlotsForGeneration)
                    .toList();
            if (categorySlots.isEmpty()) {
                continue;
            }

            Map<String, Integer> consumedHours = new HashMap<>();
            int startWeek = Math.max(1, rule.startWeekFor(category));
            for (int week = startWeek; week <= maxWeeks; week++) {
                for (ScheduleRuleSlot slot : categorySlots) {
                    if (!slotAppliesToWeek(slot, week)) {
                        continue;
                    }

                    for (String scopeKey : consumptionScopes(slot, category)) {
                        int consumed = consumedHours.getOrDefault(scopeKey, 0);
                        if (consumed >= totalHours) {
                            continue;
                        }
                        activeWeeks.get(slot).add(week);
                        consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
                    }
                }
            }
        }
        return activeWeeks;
    }

    private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
        if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
            return false;
        }
        Set<Integer> weeks = new HashSet<>(first);
        weeks.retainAll(second);
        return !weeks.isEmpty();
    }

    private int maxWeeksForRule(ScheduleRule rule) {
        Semester semester = rule.getSemester();
        if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
            long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
            return Math.max(1, (int) Math.ceil(days / 7.0));
        }
        return 18;
    }

    private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
        int dayCompare = Integer.compare(
                first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
                second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
        );
        if (dayCompare != 0) {
            return dayCompare;
        }
        int orderCompare = Integer.compare(
                first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null
                        ? 0 : first.getTimeSlot().getOrderNumber(),
                second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null
                        ? 0 : second.getTimeSlot().getOrderNumber()
        );
        if (orderCompare != 0) {
            return orderCompare;
        }
        return Long.compare(
                first.getId() == null ? 0L : first.getId(),
                second.getId() == null ? 0L : second.getId()
        );
    }

    private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
        ScheduleParity parity = slot.getParity();
        if (parity == null || parity == ScheduleParity.BOTH) {
            return true;
        }
        ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
        return parity == weekParity;
    }

    private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
        if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
            return slot.getSubgroups().stream()
                    .map(subgroup -> "subgroup:" + subgroup.getId())
                    .toList();
        }
        if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
            return List.of("rule");
        }
        return slot.getScheduleRule().getGroups().stream()
                .map(group -> "group:" + group.getId())
                .toList();
    }

    private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) {
        managedRule.setSubject(candidate.getSubject());
        managedRule.setSemester(candidate.getSemester());
        managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
        managedRule.setLaboratoryAcademicHours(candidate.getLaboratoryAcademicHours());
        managedRule.setPracticeAcademicHours(candidate.getPracticeAcademicHours());
        managedRule.setLectureStartWeek(candidate.getLectureStartWeek());
        managedRule.setLaboratoryStartWeek(candidate.getLaboratoryStartWeek());
        managedRule.setPracticeStartWeek(candidate.getPracticeStartWeek());

        managedRule.getGroups().clear();
        managedRule.getGroups().addAll(candidate.getGroups());
        managedRule.getSlots().clear();
        for (ScheduleRuleSlot slot : candidate.getSlots()) {
            slot.setScheduleRule(managedRule);
            managedRule.getSlots().add(slot);
        }
    }

    private ScheduleRuleDto toDto(ScheduleRule rule) {
        List<StudentGroup> groups = rule.getGroups().stream()
                .sorted(Comparator.comparing(StudentGroup::getName))
                .toList();
        List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
                .sorted(Comparator
                        .comparing(ScheduleRuleSlot::getDayOfWeek)
                        .thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
                        .thenComparing(ScheduleRuleSlot::getId, Comparator.nullsLast(Comparator.naturalOrder())))
                .map(this::toSlotDto)
                .toList();

        return new ScheduleRuleDto(
                rule.getId(),
                rule.getSubject().getId(),
                rule.getSubject().getName(),
                rule.getSemester().getId(),
                rule.getSemester().getAcademicYear().getTitle(),
                rule.getSemester().getSemesterType(),
                rule.getLectureAcademicHours(),
                rule.getLaboratoryAcademicHours(),
                rule.getPracticeAcademicHours(),
                rule.getLectureStartWeek(),
                rule.getLaboratoryStartWeek(),
                rule.getPracticeStartWeek(),
                groups.stream().map(StudentGroup::getId).toList(),
                groups.stream().map(StudentGroup::getName).toList(),
                slots
        );
    }

    private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
        TimeSlot timeSlot = slot.getTimeSlot();
        List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
        return new ScheduleRuleSlotDto(
                slot.getId(),
                slot.getDayOfWeek(),
                dayName(slot.getDayOfWeek()),
                slot.getParity(),
                timeSlot.getId(),
                timeSlot.getOrderNumber(),
                formatTimeSlot(timeSlot),
                sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
                sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
                sortedSubgroups.stream().map(Subgroup::getId).toList(),
                sortedSubgroups.stream().map(this::formatSubgroup).toList(),
                slot.getTeacher().getId(),
                ScheduleGeneratorService.displayUserName(slot.getTeacher()),
                slot.getClassroom().getId(),
                slot.getClassroom().getName(),
                slot.getLessonType().getId(),
                slot.getLessonType().getLessonType(),
                slot.getLessonFormat()
        );
    }

    private String dayName(Integer dayOfWeek) {
        if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
            return "Неизвестно";
        }
        return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
    }

    private String formatTimeSlot(TimeSlot timeSlot) {
        return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
    }

    private String formatSubgroup(Subgroup subgroup) {
        return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
    }

    private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
        return slot.getSubgroups().stream()
                .sorted(Comparator
                        .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
                        .thenComparing(Subgroup::getName))
                .toList();
    }

    private record SlotIdentity(Integer dayOfWeek,
                                ScheduleParity parity,
                                Long timeSlotId,
                                List<Long> subgroupIds,
                                Long teacherId,
                                Long classroomId,
                                Long lessonTypeId,
                                String lessonFormat) {
    }

    private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
    }
}
