package com.magistr.app.service;

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

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

@Service
public class ScheduleOverrideService {

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

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

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

    @Transactional(readOnly = true)
    public List<ScheduleOverrideDto> getAll() {
        return scheduleOverrideRepository.findAll().stream()
                .map(this::toDto)
                .toList();
    }

    @Transactional
    public ScheduleOverrideDto create(ScheduleOverrideDto request) {
        OverrideCommand command = validateAndNormalize(request);
        advisoryLock.lockScheduleOverrideDate(command.lessonDate());

        ScheduleOverride current = scheduleOverrideRepository
                .findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
                .orElse(null);
        return validateAndPersist(current, command);
    }

    @Transactional
    public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
        OverrideCommand command = validateAndNormalize(request);
        advisoryLock.lockScheduleOverrideId(id);

        LocalDate observedOldDate = scheduleOverrideRepository.findLessonDateById(id)
                .orElseThrow(() -> notFound(id));
        lockDatesInStableOrder(observedOldDate, command.lessonDate());

        ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
                .orElseThrow(() -> notFound(id));
        if (!Objects.equals(observedOldDate, current.getLessonDate())) {
            throw new ScheduleConflictException(
                    "Точечное изменение расписания было изменено параллельно. Повторите запрос"
            );
        }

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

    @Transactional
    public void delete(Long id) {
        advisoryLock.lockScheduleOverrideId(id);
        LocalDate observedDate = scheduleOverrideRepository.findLessonDateById(id)
                .orElseThrow(() -> notFound(id));
        advisoryLock.lockScheduleOverrideDate(observedDate);

        ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
                .orElseThrow(() -> notFound(id));
        if (!Objects.equals(observedDate, current.getLessonDate())) {
            throw new ScheduleConflictException(
                    "Точечное изменение расписания было изменено параллельно. Повторите запрос"
            );
        }
        scheduleOverrideRepository.delete(current);
        scheduleOverrideRepository.flush();
        scheduleGeneratorService.clearCache();
    }

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

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

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

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

    private void validateEffectiveResult(ScheduleOverride current,
                                         OverrideCommand command,
                                         ScheduleOverride candidate,
                                         List<RenderedLessonDto> baseDay,
                                         List<RenderedLessonDto> sourceOccurrences) {
        Long currentId = current == null ? null : current.getId();
        List<ScheduleOverride> effectiveOverrides = scheduleOverrideRepository
                .findByLessonDateBetweenWithDetails(command.lessonDate(), command.lessonDate())
                .stream()
                .filter(override -> currentId == null || !Objects.equals(override.getId(), currentId))
                .filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate()))
                .collect(Collectors.toCollection(ArrayList::new));
        effectiveOverrides.add(candidate);

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

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

        List<RenderedLessonDto> otherOccurrences = effectiveDay.stream()
                .filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
                .toList();
        Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences);
        validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
    }

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

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

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

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

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

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

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

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

        if (newTeacher != null
                && (newTeacher.getRole() != Role.TEACHER || !newTeacher.isActiveOn(command.lessonDate()))) {
            throw new IllegalArgumentException("Активный преподаватель с ролью TEACHER не найден");
        }
        if (newClassroom != null
                && (!newClassroom.isActiveOn(command.lessonDate())
                || !Boolean.TRUE.equals(newClassroom.getIsAvailable()))) {
            throw new IllegalArgumentException("Активная и доступная аудитория не найдена");
        }
        return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
    }

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

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

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

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

    private void lockDatesInStableOrder(LocalDate first, LocalDate second) {
        List.of(first, second).stream()
                .distinct()
                .sorted(Comparator.naturalOrder())
                .forEach(advisoryLock::lockScheduleOverrideDate);
    }

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

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

    private ScheduleOverrideDto toDto(ScheduleOverride override) {
        return new ScheduleOverrideDto(
                override.getId(),
                override.getBaseRuleSlot().getId(),
                override.getLessonDate(),
                override.getAction(),
                override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
                override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
                override.getNewTeacher() == null ? null : override.getNewTeacher().getId(),
                override.getNewLessonFormat(),
                override.getComment(),
                override.getCreatedBy(),
                override.getCreatedAt()
        );
    }

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

    private record OverrideCommand(
            Long baseRuleSlotId,
            LocalDate lessonDate,
            String action,
            Long newTimeSlotId,
            Long newClassroomId,
            Long newTeacherId,
            String newLessonFormat,
            String comment
    ) {
    }

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

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

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

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