package com.magistr.app.service;

import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class ScheduleGeneratorService {

    private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
    private static final int ACADEMIC_HOURS_PER_SLOT = 2;
    private static final long MAX_RANGE_DAYS = 120;
    private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";

    private final ScheduleRuleRepository scheduleRuleRepository;
    private final GroupRepository groupRepository;
    private final UserRepository userRepository;
    private final AcademicDateService academicDateService;
    private final TimeSlotRepository timeSlotRepository;
    private final TimeSlotScopeRepository timeSlotScopeRepository;
    private final TimeSlotDateAssignmentRepository dateAssignmentRepository;

    public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
                                    GroupRepository groupRepository,
                                    UserRepository userRepository,
                                    AcademicDateService academicDateService,
                                    TimeSlotRepository timeSlotRepository,
                                    TimeSlotScopeRepository timeSlotScopeRepository,
                                    TimeSlotDateAssignmentRepository dateAssignmentRepository) {
        this.scheduleRuleRepository = scheduleRuleRepository;
        this.groupRepository = groupRepository;
        this.userRepository = userRepository;
        this.academicDateService = academicDateService;
        this.timeSlotRepository = timeSlotRepository;
        this.timeSlotScopeRepository = timeSlotScopeRepository;
        this.dateAssignmentRepository = dateAssignmentRepository;
    }

    public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
        validateRange(startDate, endDate);
        StudentGroup group = groupRepository.findById(groupId)
                .orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));

        Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
        Map<String, Integer> consumedHoursProgress = new HashMap<>();
        List<RenderedLessonDto> result = new ArrayList<>();
        Set<Long> warnedAcademicYears = new HashSet<>();
        Set<Long> primedSemesterIds = new HashSet<>();

        for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
            Optional<Semester> semesterOpt = academicDateService.findSemester(date);
            if (semesterOpt.isEmpty()) {
                continue;
            }

            Semester semester = semesterOpt.get();
            if (!academicDateService.hasCalendarAssignment(group, semester)
                    && warnedAcademicYears.add(semester.getAcademicYear().getId())) {
                logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}",
                        group.getName(), semester.getAcademicYear().getTitle());
            }
            if (!academicDateService.isTheoryDay(group, semester, date)) {
                continue;
            }

            List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
                    semester.getId(),
                    semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
            );
            if (primedSemesterIds.add(semester.getId())) {
                primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
            }

            for (ScheduleRule rule : sortRules(rules)) {
                processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
            }
        }

        return sortRenderedLessons(result);
    }

    public List<RenderedLessonDto> buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate) {
        validateRange(startDate, endDate);
        if (!userRepository.existsById(teacherId)) {
            throw new IllegalArgumentException("Преподаватель не найден");
        }

        Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
        Map<String, Integer> consumedHoursProgress = new HashMap<>();
        List<RenderedLessonDto> result = new ArrayList<>();
        Set<Long> primedSemesterIds = new HashSet<>();

        for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
            Optional<Semester> semesterOpt = academicDateService.findSemester(date);
            if (semesterOpt.isEmpty()) {
                continue;
            }

            Semester semester = semesterOpt.get();
            List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
                    semester.getId(),
                    semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
            );
            if (primedSemesterIds.add(semester.getId())) {
                primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
            }

            for (ScheduleRule rule : sortRules(rules)) {
                processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
            }
        }

        return sortRenderedLessons(result);
    }

    public void clearCache() {
        // Расход часов считается в контексте одного построения расписания.
        // Метод оставлен для совместимости с контроллерами, которые сигнализируют об изменении исходных данных.
    }

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

    private List<ScheduleRule> sortRules(List<ScheduleRule> rules) {
        return rules.stream()
                .sorted(Comparator
                        .comparing((ScheduleRule rule) -> rule.getSubject().getName(), Comparator.nullsLast(String::compareTo))
                        .thenComparing(ScheduleRule::getId))
                .toList();
    }

    private List<RenderedLessonDto> sortRenderedLessons(List<RenderedLessonDto> lessons) {
        return lessons.stream()
                .sorted(Comparator
                        .comparing(RenderedLessonDto::date)
                        .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo))
                        .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo)))
                .toList();
    }

    private void primeConsumedHoursBeforeRange(Semester semester,
                                               List<ScheduleRule> rules,
                                               LocalDate rangeStart,
                                               StudentGroup targetGroup,
                                               Long targetTeacherId,
                                               Map<String, Integer> consumedHoursProgress) {
        LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
        if (!semester.getStartDate().isBefore(endExclusive)) {
            return;
        }

        List<ScheduleRule> sortedRules = sortRules(rules);
        for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
            for (ScheduleRule rule : sortedRules) {
                processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null);
            }
        }
    }

    private LocalDate minDate(LocalDate first, LocalDate second) {
        return first.isBefore(second) ? first : second;
    }

    private void processRuleForDate(ScheduleRule rule,
                                   LocalDate date,
                                   StudentGroup targetGroup,
                                   Long targetTeacherId,
                                   Map<String, Integer> consumedHoursProgress,
                                   List<RenderedLessonDto> result) {
        if (rule.getLectureAcademicHours() == null
                || rule.getLaboratoryAcademicHours() == null
                || rule.getPracticeAcademicHours() == null) {
            return;
        }

        Optional<Semester> semesterOpt = academicDateService.findSemester(date);
        if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
            return;
        }
        if (!rule.getSubject().isActiveOn(date)) {
            return;
        }
        if (!rule.isActiveOn(date)) {
            return;
        }

        Semester semester = semesterOpt.get();
        List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
        if (eligibleGroups.isEmpty()) {
            return;
        }

        ScheduleParity dateParity = academicDateService.getParity(semester, date);
        int weekNumber = academicDateService.getWeekNumber(semester, date);
        int dayOfWeek = date.getDayOfWeek().getValue();
        List<ScheduleRuleSlot> slots = rule.getSlots().stream()
                .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
                .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
                .filter(slot -> targetTeacherId == null || Objects.equals(slot.getTeacher().getId(), targetTeacherId))
                .sorted(Comparator
                        .comparing((ScheduleRuleSlot slot) -> slot.getTimeSlot().getOrderNumber(), Comparator.nullsLast(Integer::compareTo))
                        .thenComparing(ScheduleRuleSlot::getId))
                .toList();

        if (slots.isEmpty()) {
            return;
        }

        for (ScheduleRuleSlot slot : slots) {
            if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
                continue;
            }
            if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
                continue;
            }
            ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
            if (weekNumber < rule.startWeekFor(category)) {
                continue;
            }
            int totalHours = rule.academicHoursFor(category);
            if (totalHours <= 0) {
                continue;
            }
            List<LessonScope> lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups);
            if (lessonScopes.isEmpty()) {
                continue;
            }

            List<ActiveLessonScope> activeScopes = new ArrayList<>();
            for (LessonScope scope : lessonScopes) {
                String progressKey = consumptionProgressKey(rule, targetGroup, category, scope.subgroupId());
                int consumedHours = consumedHoursProgress.getOrDefault(progressKey, 0);
                if (consumedHours >= totalHours) {
                    continue;
                }
                int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
                activeScopes.add(new ActiveLessonScope(scope, consumedHours, remainingAfterLesson));
            }

            if (activeScopes.isEmpty()) {
                continue;
            }

            if (result != null) {
                Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
                Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
                activeScopes.forEach(scope -> {
                    scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
                    scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
                });
                List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
                List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
                int consumedHours = activeScopes.stream()
                        .mapToInt(ActiveLessonScope::consumedHours)
                        .min()
                        .orElse(0);
                int remainingAfterLesson = activeScopes.stream()
                        .mapToInt(ActiveLessonScope::remainingAfterLesson)
                        .min()
                        .orElse(0);

                result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
                        totalHours, consumedHours, remainingAfterLesson));
            }
            for (ActiveLessonScope scope : activeScopes) {
                consumedHoursProgress.put(
                        consumptionProgressKey(rule, targetGroup, category, scope.scope().subgroupId()),
                        scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
                );
            }
        }
    }

    private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
        if (targetGroup != null) {
            return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date)
                    ? List.of(targetGroup)
                    : List.of();
        }

        return rule.getGroups().stream()
                .filter(group -> group.isActiveOn(date))
                .filter(group -> academicDateService.isTheoryDay(group, semester, date))
                .sorted(Comparator.comparing(StudentGroup::getName))
                .toList();
    }

    private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
        return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
    }

    private String consumptionProgressKey(ScheduleRule rule,
                                          StudentGroup targetGroup,
                                          ScheduleLessonCategory category,
                                          Long subgroupId) {
        Long groupId = targetGroup == null ? null : targetGroup.getId();
        return rule.getId()
                + ":" + consumptionBucket(category, subgroupId)
                + ":" + (groupId == null ? "all" : groupId);
    }

    private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
                                                  ScheduleLessonCategory category,
                                                  List<StudentGroup> eligibleGroups) {
        if (category != ScheduleLessonCategory.LABORATORY || slot.getSubgroups().isEmpty()) {
            return eligibleGroups.isEmpty()
                    ? List.of()
                    : List.of(new LessonScope(eligibleGroups, List.of(), null));
        }

        Set<Long> eligibleGroupIds = eligibleGroups.stream()
                .map(StudentGroup::getId)
                .collect(Collectors.toSet());
        Map<Long, StudentGroup> eligibleGroupById = eligibleGroups.stream()
                .collect(Collectors.toMap(StudentGroup::getId, group -> group));

        return slot.getSubgroups().stream()
                .filter(subgroup -> eligibleGroupIds.contains(subgroup.getStudentGroup().getId()))
                .sorted(Comparator
                        .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
                        .thenComparing(Subgroup::getName))
                .map(subgroup -> new LessonScope(
                        List.of(eligibleGroupById.get(subgroup.getStudentGroup().getId())),
                        List.of(subgroup),
                        subgroup.getId()
                ))
                .toList();
    }

    private record LessonScope(List<StudentGroup> groups, List<Subgroup> subgroups, Long subgroupId) {
    }

    private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
    }

    private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
                                               ScheduleRuleSlot slot,
                                               LocalDate date,
                                               int weekNumber,
                                               ScheduleParity dateParity,
                                               List<StudentGroup> groups,
                                               List<Subgroup> subgroups,
                                               int lessonTypeAcademicHours,
                                               int consumedBeforeLesson,
                                               int remainingAfterLesson) {
        TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
        List<StudentGroup> sortedGroups = groups.stream()
                .sorted(Comparator.comparing(StudentGroup::getName))
                .toList();
        List<Subgroup> sortedSubgroups = subgroups.stream()
                .sorted(Comparator
                        .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
                        .thenComparing(Subgroup::getName))
                .toList();

        return new RenderedLessonDto(
                rule.getId(),
                slot.getId(),
                date,
                date.getDayOfWeek().getValue(),
                dayName(date.getDayOfWeek()),
                weekNumber,
                dateParity,
                timeSlot.getId(),
                timeSlot.getOrderNumber(),
                timeSlot.getStartTime(),
                timeSlot.getEndTime(),
                rule.getSubject().getId(),
                rule.getSubject().getName(),
                slot.getTeacher().getId(),
                displayUserName(slot.getTeacher()),
                slot.getClassroom().getId(),
                slot.getClassroom().getName(),
                slot.getLessonType().getId(),
                slot.getLessonType().getLessonType(),
                slot.getLessonFormat(),
                sortedSubgroups.size() == 1 ? sortedSubgroups.get(0).getId() : null,
                sortedSubgroups.size() == 1 ? formatSubgroup(sortedSubgroups.get(0)) : null,
                sortedSubgroups.stream().map(Subgroup::getId).toList(),
                sortedSubgroups.stream().map(this::formatSubgroup).toList(),
                sortedGroups.stream().map(StudentGroup::getId).toList(),
                sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
                "Т",
                lessonTypeAcademicHours,
                consumedBeforeLesson,
                remainingAfterLesson,
                slot.getParity()
        );
    }

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

    private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
        if (baseSlot == null || baseSlot.getOrderNumber() == null) {
            return baseSlot;
        }
        return effectiveScope(date)
                .flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
                        scope.getId(),
                        baseSlot.getOrderNumber()))
                .orElse(baseSlot);
    }

    private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
        return dateAssignmentRepository.findByDate(date)
                .map(TimeSlotDateAssignment::getTimeSlotScope)
                .or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
                        APPLY_MODE_WEEKDAY,
                        date.getDayOfWeek().getValue()));
    }

    public static String dayName(DayOfWeek dayOfWeek) {
        String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU"));
        return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1);
    }

    public static String displayUserName(User user) {
        if (user.getFullName() != null && !user.getFullName().isBlank()) {
            return user.getFullName();
        }
        return user.getUsername();
    }
}
