package com.magistr.app.service;

import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class ScheduleQueryService {

    private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
    private static final long MAX_RANGE_DAYS = 120;

    private final ScheduleGeneratorService scheduleGeneratorService;
    private final GroupRepository groupRepository;
    private final ScheduleOverrideRepository scheduleOverrideRepository;
    private final StudentGroupLifecycleService groupLifecycleService;
    private final AcademicDateService academicDateService;

    @Autowired
    public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
                                GroupRepository groupRepository,
                                ScheduleOverrideRepository scheduleOverrideRepository,
                                StudentGroupLifecycleService groupLifecycleService,
                                AcademicDateService academicDateService) {
        this.scheduleGeneratorService = scheduleGeneratorService;
        this.groupRepository = groupRepository;
        this.scheduleOverrideRepository = scheduleOverrideRepository;
        this.groupLifecycleService = groupLifecycleService;
        this.academicDateService = academicDateService;
    }

    ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
                         GroupRepository groupRepository,
                         ScheduleOverrideRepository scheduleOverrideRepository,
                         StudentGroupLifecycleService groupLifecycleService) {
        this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository, groupLifecycleService, null);
    }

    /**
     * Строит фактическое базовое расписание на дату для всех групп без UI-ограничения
     * количества групп, применяемого к широким пользовательским поисковым запросам.
     */
    public List<RenderedLessonDto> buildBaseDayForAllGroups(LocalDate date) {
        if (date == null) {
            throw new IllegalArgumentException("Дата расписания обязательна");
        }
        List<StudentGroup> groups = groupRepository.findAll().stream()
                .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
                .toList();
        List<RenderedLessonDto> generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, date, date);
        return deduplicate(generatedLessons);
    }

    /**
     * Строит результирующее расписание дня с переданным снимком точечных изменений.
     * Репозиторий точечных изменений здесь намеренно не читается: вызывающий код может атомарно
     * проверить ещё не сохранённое изменение внутри своей транзакции.
     */
    public List<RenderedLessonDto> buildEffectiveDayForAllGroups(
            LocalDate date,
            Collection<ScheduleOverride> overrides
    ) {
        return buildEffectiveDayForAllGroups(buildBaseDayForAllGroups(date), date, overrides);
    }

    /**
     * Вариант без повторной генерации базового дня для транзакционных проверок.
     */
    public List<RenderedLessonDto> buildEffectiveDayForAllGroups(
            List<RenderedLessonDto> baseDay,
            LocalDate date,
            Collection<ScheduleOverride> overrides
    ) {
        if (date == null) {
            throw new IllegalArgumentException("Дата расписания обязательна");
        }
        List<RenderedLessonDto> safeBaseDay = baseDay == null ? List.of() : baseDay;
        return deduplicate(applyOverrides(safeBaseDay, date, overrides));
    }

    /**
     * Строит несколько эффективных дней с учётом входящих и исходящих переносов.
     * Исходная пара достраивается даже тогда, когда её дата не входит в результирующий набор.
     */
    public List<RenderedLessonDto> buildEffectiveDaysForAllGroups(
            Collection<LocalDate> dates,
            Collection<ScheduleOverride> overrides
    ) {
        Set<LocalDate> requestedDates = Optional.ofNullable(dates)
                .orElseGet(List::of)
                .stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(TreeSet::new));
        if (requestedDates.isEmpty()) {
            return List.of();
        }
        List<RenderedLessonDto> baseLessons = requestedDates.stream()
                .flatMap(date -> buildBaseDayForAllGroups(date).stream())
                .collect(Collectors.toCollection(ArrayList::new));
        addOverrideSourceOccurrences(baseLessons, overrides, null);
        return deduplicate(applyOverrides(baseLessons, null, overrides).stream()
                .filter(lesson -> requestedDates.contains(lesson.date()))
                .toList());
    }

    public List<RenderedLessonDto> search(Long groupId,
                                          Long teacherId,
                                          Long classroomId,
                                          Long departmentId,
                                          Long subjectId,
                                          Long lessonTypeId,
                                          Long timeSlotId,
                                          String parity,
                                          LocalDate startDate,
                                          LocalDate endDate) {
        return searchInternal(
                groupId,
                teacherId,
                classroomId,
                departmentId,
                subjectId,
                lessonTypeId,
                timeSlotId,
                parity,
                startDate,
                endDate,
                true
        );
    }

    /**
     * Строит расписание для workload-агрегатов без интерактивного ограничения 50 групп.
     * Набор поддерживаемых фильтров намеренно сужен до кафедры и временного слота, чтобы
     * этот путь не превратился во второй общий поисковый API.
     */
    public List<RenderedLessonDto> searchForAggregation(Long departmentId,
                                                        Long timeSlotId,
                                                        LocalDate startDate,
                                                        LocalDate endDate) {
        return searchInternal(
                null,
                null,
                null,
                departmentId,
                null,
                null,
                timeSlotId,
                null,
                startDate,
                endDate,
                false
        );
    }

    private List<RenderedLessonDto> searchInternal(Long groupId,
                                                   Long teacherId,
                                                   Long classroomId,
                                                   Long departmentId,
                                                   Long subjectId,
                                                   Long lessonTypeId,
                                                   Long timeSlotId,
                                                   String parity,
                                                   LocalDate startDate,
                                                   LocalDate endDate,
                                                   boolean enforceInteractiveGroupLimit) {
        validateRange(startDate, endDate);
        List<RenderedLessonDto> generatedLessons;
        List<StudentGroup> scopedGroups = null;
        boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
        if (teacherOnlySearch) {
            generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
        } else {
            scopedGroups = resolveGroups(
                    groupId,
                    departmentId,
                    startDate,
                    endDate,
                    enforceInteractiveGroupLimit
            );
            generatedLessons = scheduleGeneratorService.buildScheduleForGroups(scopedGroups, startDate, endDate);
        }
        List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
                .findByLessonDateBetweenWithDetails(startDate, endDate);
        generatedLessons = new ArrayList<>(generatedLessons);
        addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
        List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
                .filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate))
                .filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
                .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
                .filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
                .filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId))
                .filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId))
                .filter(lesson -> parity == null || parity.isBlank() || lesson.parity().name().equalsIgnoreCase(parity))
                .toList();
        return deduplicate(lessons);
    }

    private void addOverrideSourceOccurrences(
            List<RenderedLessonDto> generatedLessons,
            Collection<ScheduleOverride> overrideSnapshot,
            List<StudentGroup> scopedGroups
    ) {
        Map<LocalDate, Set<Long>> relevantSlotsByDate = Optional.ofNullable(overrideSnapshot)
                .orElseGet(List::of)
                .stream()
                .filter(Objects::nonNull)
                .filter(override -> override.getLessonDate() != null)
                .filter(override -> override.getBaseRuleSlot() != null)
                .filter(override -> override.getBaseRuleSlot().getId() != null)
                .filter(override -> !"CANCEL".equals(override.getAction()))
                .collect(Collectors.groupingBy(
                        ScheduleOverride::getLessonDate,
                        TreeMap::new,
                        Collectors.mapping(
                                override -> override.getBaseRuleSlot().getId(),
                                Collectors.toCollection(LinkedHashSet::new)
                        )
                ));
        if (relevantSlotsByDate.isEmpty()) {
            return;
        }

        relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> {
            Set<Long> missingBaseRuleSlotIds = baseRuleSlotIds.stream()
                    .filter(baseRuleSlotId -> generatedLessons.stream().noneMatch(lesson ->
                            date.equals(lesson.date())
                                    && Objects.equals(baseRuleSlotId, lesson.scheduleRuleSlotId())
                    ))
                    .collect(Collectors.toCollection(LinkedHashSet::new));
            if (missingBaseRuleSlotIds.isEmpty()) {
                return;
            }
            List<RenderedLessonDto> sourceDay = scopedGroups == null
                    ? buildBaseDayForAllGroups(date)
                    : deduplicate(scheduleGeneratorService.buildScheduleForGroups(scopedGroups, date, date));
            sourceDay.stream()
                    .filter(lesson -> date.equals(lesson.date()))
                    .filter(lesson -> missingBaseRuleSlotIds.contains(lesson.scheduleRuleSlotId()))
                    .forEach(generatedLessons::add);
        });
    }

    private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons,
                                                    LocalDate exactDate,
                                                    Collection<ScheduleOverride> suppliedOverrides) {
        Map<String, ScheduleOverride> overrides = Optional.ofNullable(suppliedOverrides)
                .orElseGet(List::of)
                .stream()
                .filter(Objects::nonNull)
                .filter(override -> override.getBaseRuleSlot() != null)
                .filter(override -> override.getLessonDate() != null)
                .filter(override -> exactDate == null || exactDate.equals(override.getLessonDate()))
                .collect(Collectors.toMap(
                        override -> overrideKey(override.getBaseRuleSlot().getId(), override.getLessonDate()),
                        override -> override,
                        (first, second) -> second
                ));
        if (overrides.isEmpty()) {
            return lessons;
        }
        return lessons.stream()
                .map(lesson -> {
                    ScheduleOverride override = overrides.get(overrideKey(lesson.scheduleRuleSlotId(), lesson.date()));
                    return override == null ? lesson : applyOverride(lesson, override);
                })
                .filter(Objects::nonNull)
                .toList();
    }

    private RenderedLessonDto applyOverride(RenderedLessonDto lesson, ScheduleOverride override) {
        if ("CANCEL".equals(override.getAction())) {
            return null;
        }
        LocalDate effectiveDate = override.getTargetLessonDate() == null
                ? lesson.date()
                : override.getTargetLessonDate();
        Integer dayOfWeek = lesson.dayOfWeek();
        String dayName = lesson.dayName();
        Integer weekNumber = lesson.weekNumber();
        com.magistr.app.model.ScheduleParity parity = lesson.parity();
        if (!Objects.equals(effectiveDate, lesson.date())) {
            if (academicDateService == null
                    || override.getBaseRuleSlot() == null
                    || override.getBaseRuleSlot().getScheduleRule() == null
                    || override.getBaseRuleSlot().getScheduleRule().getSemester() == null) {
                throw new IllegalStateException("Невозможно пересчитать календарные параметры перенесённой пары");
            }
            var semester = override.getBaseRuleSlot().getScheduleRule().getSemester();
            dayOfWeek = effectiveDate.getDayOfWeek().getValue();
            dayName = dayName(effectiveDate.getDayOfWeek());
            weekNumber = academicDateService.getWeekNumber(semester, effectiveDate);
            parity = academicDateService.getParity(semester, effectiveDate);
        }
        Long timeSlotId = lesson.timeSlotId();
        Integer timeSlotOrder = lesson.timeSlotOrder();
        java.time.LocalTime startTime = lesson.startTime();
        java.time.LocalTime endTime = lesson.endTime();
        if (override.getNewTimeSlot() != null) {
            timeSlotId = override.getNewTimeSlot().getId();
            timeSlotOrder = override.getNewTimeSlot().getOrderNumber();
            startTime = override.getNewTimeSlot().getStartTime();
            endTime = override.getNewTimeSlot().getEndTime();
        }

        Long classroomId = override.getNewClassroom() == null ? lesson.classroomId() : override.getNewClassroom().getId();
        String classroomName = override.getNewClassroom() == null ? lesson.classroomName() : override.getNewClassroom().getName();
        Long teacherId = override.getNewTeacher() == null ? lesson.teacherId() : override.getNewTeacher().getId();
        String teacherName = override.getNewTeacher() == null ? lesson.teacherName() : ScheduleGeneratorService.displayUserName(override.getNewTeacher());
        String lessonFormat = override.getNewLessonFormat() == null ? lesson.lessonFormat() : override.getNewLessonFormat();

        return new RenderedLessonDto(
                lesson.scheduleRuleId(),
                lesson.scheduleRuleSlotId(),
                effectiveDate,
                dayOfWeek,
                dayName,
                weekNumber,
                parity,
                timeSlotId,
                timeSlotOrder,
                startTime,
                endTime,
                lesson.subjectId(),
                lesson.subjectName(),
                teacherId,
                teacherName,
                classroomId,
                classroomName,
                lesson.lessonTypeId(),
                lesson.lessonTypeName(),
                lessonFormat,
                lesson.subgroupId(),
                lesson.subgroupName(),
                lesson.subgroupIds(),
                lesson.subgroupNames(),
                lesson.groupIds(),
                lesson.groupNames(),
                lesson.activityType(),
                lesson.lessonTypeAcademicHours(),
                lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
                lesson.remainingLessonTypeAcademicHoursAfterLesson(),
                lesson.ruleParity(),
                override.getId(),
                override.getAction(),
                lesson.date()
        );
    }

    private String dayName(java.time.DayOfWeek dayOfWeek) {
        return switch (dayOfWeek) {
            case MONDAY -> "Понедельник";
            case TUESDAY -> "Вторник";
            case WEDNESDAY -> "Среда";
            case THURSDAY -> "Четверг";
            case FRIDAY -> "Пятница";
            case SATURDAY -> "Суббота";
            case SUNDAY -> "Воскресенье";
        };
    }

    private String overrideKey(Long scheduleRuleSlotId, LocalDate date) {
        return scheduleRuleSlotId + ":" + date;
    }

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

    private List<StudentGroup> resolveGroups(Long groupId,
                                             Long departmentId,
                                             LocalDate startDate,
                                             LocalDate endDate,
                                             boolean enforceInteractiveGroupLimit) {
        if (groupId != null) {
            return groupRepository.findById(groupId)
                    .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
                    .map(List::of)
                    .orElse(List.of());
        }
        List<StudentGroup> groups;
        if (departmentId != null) {
            groups = groupRepository.findByDepartmentId(departmentId);
        } else {
            groups = groupRepository.findAll();
        }
        groups = groups.stream()
                .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
                .toList();
        if (enforceInteractiveGroupLimit
                && departmentId == null
                && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
            throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
                    + groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
        }
        return groups;
    }

    private List<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
        Map<String, MutableLesson> unique = new LinkedHashMap<>();
        for (RenderedLessonDto lesson : lessons) {
            String key = lessonKey(lesson);
            unique.computeIfAbsent(key, ignored -> new MutableLesson(lesson)).merge(lesson);
        }
        return unique.values().stream()
                .map(MutableLesson::toDto)
                .sorted(Comparator
                        .comparing(RenderedLessonDto::date)
                        .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo))
                        .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo)))
                .toList();
    }

    private String lessonKey(RenderedLessonDto lesson) {
        return String.join(":",
                String.valueOf(lesson.date()),
                String.valueOf(lesson.scheduleRuleSlotId()),
                String.valueOf(lesson.classroomId()),
                String.valueOf(lesson.timeSlotOrder()),
                String.valueOf(lesson.teacherId()),
                String.valueOf(lesson.subjectId()),
                sortedSignature(lesson.subgroupIds()),
                String.valueOf(lesson.scheduleOverrideId()),
                String.valueOf(lesson.originalLessonDate())
        );
    }

    private String sortedSignature(List<Long> values) {
        if (values == null || values.isEmpty()) {
            return "";
        }
        return values.stream()
                .map(String::valueOf)
                .sorted()
                .collect(Collectors.joining(","));
    }

    private static class MutableLesson {
        private final RenderedLessonDto source;
        private final LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
        private final LinkedHashSet<String> groupNames = new LinkedHashSet<>();
        private final LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
        private final LinkedHashSet<String> subgroupNames = new LinkedHashSet<>();

        MutableLesson(RenderedLessonDto source) {
            this.source = source;
        }

        void merge(RenderedLessonDto lesson) {
            if (lesson.groupIds() != null) {
                groupIds.addAll(lesson.groupIds());
            }
            if (lesson.groupNames() != null) {
                groupNames.addAll(lesson.groupNames());
            }
            if (lesson.subgroupIds() != null) {
                subgroupIds.addAll(lesson.subgroupIds());
            }
            if (lesson.subgroupNames() != null) {
                subgroupNames.addAll(lesson.subgroupNames());
            }
        }

        RenderedLessonDto toDto() {
            return new RenderedLessonDto(
                    source.scheduleRuleId(),
                    source.scheduleRuleSlotId(),
                    source.date(),
                    source.dayOfWeek(),
                    source.dayName(),
                    source.weekNumber(),
                    source.parity(),
                    source.timeSlotId(),
                    source.timeSlotOrder(),
                    source.startTime(),
                    source.endTime(),
                    source.subjectId(),
                    source.subjectName(),
                    source.teacherId(),
                    source.teacherName(),
                    source.classroomId(),
                    source.classroomName(),
                    source.lessonTypeId(),
                    source.lessonTypeName(),
                    source.lessonFormat(),
                    subgroupIds.size() == 1 ? subgroupIds.iterator().next() : null,
                    subgroupNames.size() == 1 ? subgroupNames.iterator().next() : null,
                    List.copyOf(subgroupIds),
                    List.copyOf(subgroupNames),
                    List.copyOf(groupIds),
                    List.copyOf(groupNames),
                    source.activityType(),
                    source.lessonTypeAcademicHours(),
                    source.consumedLessonTypeAcademicHoursBeforeLesson(),
                    source.remainingLessonTypeAcademicHoursAfterLesson(),
                    source.ruleParity(),
                    source.scheduleOverrideId(),
                    source.overrideAction(),
                    source.originalLessonDate()
            );
        }
    }
}
