import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';

const DAY_OPTIONS = [
    { value: 1, label: 'Понедельник' },
    { value: 2, label: 'Вторник' },
    { value: 3, label: 'Среда' },
    { value: 4, label: 'Четверг' },
    { value: 5, label: 'Пятница' },
    { value: 6, label: 'Суббота' },
    { value: 7, label: 'Воскресенье' }
];

const PARITY_LABELS = {
    BOTH: 'каждую неделю',
    ODD: 'нечётная',
    EVEN: 'чётная'
};

const CELL_PARITY_LAYOUT = [
    { parity: 'ODD', label: 'Нечётная' },
    { parity: 'EVEN', label: 'Чётная' }
];

const SEMESTER_LABELS = {
    autumn: 'осенний',
    spring: 'весенний'
};

const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2;

export function scheduleSlotActionLabel(slotCount) {
    return slotCount === 1 ? 'Очистить' : 'Удалить';
}

const LESSON_TYPE_LIMITS = [
    {
        key: 'lecture',
        label: 'Лекции',
        hoursField: 'lectureAcademicHours',
        startWeekField: 'lectureStartWeek',
        hoursInputId: 'schedule-rule-lecture-hours',
        startWeekInputId: 'schedule-rule-lecture-start-week'
    },
    {
        key: 'laboratory',
        label: 'Лабораторные',
        hoursField: 'laboratoryAcademicHours',
        startWeekField: 'laboratoryStartWeek',
        hoursInputId: 'schedule-rule-laboratory-hours',
        startWeekInputId: 'schedule-rule-laboratory-start-week'
    },
    {
        key: 'practice',
        label: 'Практики',
        hoursField: 'practiceAcademicHours',
        startWeekField: 'practiceStartWeek',
        hoursInputId: 'schedule-rule-practice-hours',
        startWeekInputId: 'schedule-rule-practice-start-week'
    }
];

export async function initSchedule() {
    const ruleForm = document.getElementById('schedule-rule-form');
    const ruleFormTitle = document.getElementById('schedule-rule-form-title');
    const ruleIdInput = document.getElementById('schedule-rule-id');
    const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
    const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
    const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
        hours: document.getElementById(config.hoursInputId),
        startWeek: document.getElementById(config.startWeekInputId)
    }]));
    const ruleGroupsBox = document.getElementById('schedule-rule-groups-box');
    const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
    const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
    const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
    const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
    const ruleResetButton = document.getElementById('schedule-rule-reset');
    const slotAddButton = document.getElementById('schedule-slot-add');
    const rulesTbody = document.getElementById('schedule-tbody');
    const refreshRulesButton = document.getElementById('schedule-refresh');
    const visualToggleButton = document.getElementById('schedule-visual-toggle');
    const visualCloseButton = document.getElementById('schedule-visual-close');
    const visualDrawer = document.getElementById('schedule-visual-drawer');
    const visualBackdrop = document.getElementById('schedule-visual-backdrop');
    const visualStage = document.getElementById('schedule-visual-stage');
    const visualSummary = document.getElementById('schedule-visual-summary');
    const visualYearSelect = document.getElementById('schedule-visual-year');
    const visualSemesterSelect = document.getElementById('schedule-visual-semester');
    const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
    const visualUseFormButton = document.getElementById('schedule-visual-use-form');
    const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
    const conflictModal = document.getElementById('schedule-conflict-modal');
    const conflictForm = document.getElementById('schedule-conflict-form');
    const conflictCloseButton = document.getElementById('schedule-conflict-close');
    const conflictCancelButton = document.getElementById('schedule-conflict-cancel');
    const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
    const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
    const conflictSummary = document.getElementById('schedule-conflict-summary');
    const conflictReasons = document.getElementById('schedule-conflict-reasons');
    const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
    const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
    const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
    const conflictExistingRuleMeta = document.getElementById('schedule-conflict-existing-rule-meta');
    const visualContextMenu = document.getElementById('schedule-visual-context-menu');
    const moveSlotModal = document.getElementById('schedule-move-slot-modal');
    const moveSlotForm = document.getElementById('schedule-move-slot-form');
    const moveSlotCloseButton = document.getElementById('schedule-move-slot-close');
    const moveSlotCancelButton = document.getElementById('schedule-move-slot-cancel');
    const moveSlotSubmitButton = document.getElementById('schedule-move-slot-submit');
    const moveSlotTitle = document.getElementById('schedule-move-slot-title');
    const moveSlotSummary = document.getElementById('schedule-move-slot-summary');
    const moveSlotDaySelect = document.getElementById('schedule-move-slot-day');
    const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');

    let rules = [];
    let subjects = [];
    let groups = [];
    let teachers = [];
    let classrooms = [];
    let lessonTypes = [];
    let subgroups = [];
    let timeSlots = [];
    let academicYears = [];
    let semesters = [];
    let selectedVisualYearId = '';
    let selectedVisualSemesterId = '';
    let selectedVisualGroupIds = new Set();
    let visualGroupFilterTouched = false;
    let visualPeriodTouched = false;
    let syncingVisualPeriod = false;
    let pendingConflictResolution = null;
    let visualContextTarget = null;
    let pendingSlotMove = null;

    bindEvents();

    try {
        await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
        renderRuleGroups([]);
        renderRuleSlots([{}]);
        await loadRules();
    } catch (error) {
        showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
    }

    function bindEvents() {
        refreshRulesButton?.addEventListener('click', loadRules);
        ruleResetButton?.addEventListener('click', resetRuleForm);
        slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
        visualToggleButton?.addEventListener('click', toggleVisualDrawer);
        visualCloseButton?.addEventListener('click', closeVisualDrawer);
        visualBackdrop?.addEventListener('click', closeVisualDrawer);
        visualYearSelect?.addEventListener('change', handleVisualYearChange);
        visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
        visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
        visualStage?.addEventListener('click', handleVisualStageClick);
        conflictCloseButton?.addEventListener('click', closeConflictModal);
        conflictCancelButton?.addEventListener('click', closeConflictModal);
        conflictModal?.addEventListener('click', (event) => {
            if (event.target === conflictModal) closeConflictModal();
        });
        conflictForm?.addEventListener('submit', resolveScheduleRuleConflict);
        visualContextMenu?.addEventListener('click', handleVisualContextMenuClick);
        moveSlotCloseButton?.addEventListener('click', closeMoveSlotModal);
        moveSlotCancelButton?.addEventListener('click', closeMoveSlotModal);
        moveSlotModal?.addEventListener('click', (event) => {
            if (event.target === moveSlotModal) closeMoveSlotModal();
        });
        moveSlotForm?.addEventListener('submit', saveVisualSlotMove);
        visualUseFormButton?.addEventListener('click', () => {
            const formSemesterId = ruleSemesterSelect.value;
            if (formSemesterId) {
                visualPeriodTouched = true;
                setVisualPeriodBySemesterId(formSemesterId);
            }
            visualGroupFilterTouched = false;
            selectedVisualGroupIds = new Set();
            renderVisualSchedule();
        });
        visualSelectAllButton?.addEventListener('click', () => {
            selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id)));
            visualGroupFilterTouched = true;
            renderVisualSchedule();
        });
        ruleSemesterSelect?.addEventListener('change', () => {
            if (visualPeriodTouched) return;
            selectDefaultVisualPeriod();
            renderVisualSchedule();
        });
        document.addEventListener('keydown', (event) => {
            if (event.key !== 'Escape') return;
            if (moveSlotModal?.classList.contains('open')) {
                closeMoveSlotModal();
                return;
            }
            if (conflictModal?.classList.contains('open')) {
                closeConflictModal();
                return;
            }
            if (visualContextMenu && !visualContextMenu.hidden) {
                closeVisualContextMenu();
                return;
            }
            closeVisualDrawer();
        });
        document.addEventListener('click', (event) => {
            if (visualContextMenu?.hidden) return;
            if (event.target.closest('#schedule-visual-context-menu') || event.target.closest('.schedule-visual-edit-rule')) return;
            closeVisualContextMenu();
        });

        ruleSlotsContainer.addEventListener('click', (event) => {
            const removeButton = event.target.closest('.schedule-slot-remove');
            const subgroupBox = event.target.closest('.slot-subgroup-box');
            const subgroupMenu = event.target.closest('.slot-subgroup-menu');

            if (removeButton) {
                const slots = readRuleSlots(false);
                slots.splice(Number(removeButton.dataset.index), 1);
                renderRuleSlots(slots.length ? slots : [{}]);
                return;
            }

            if (subgroupBox) {
                toggleSlotSubgroupMenu(event, subgroupBox);
                return;
            }

            if (subgroupMenu) {
                event.stopPropagation();
            }
        });

        ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
        ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
        ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
        ruleGroupCheckboxes.addEventListener('change', () => {
            updateRuleGroupsText();
            renderRuleSlots(readRuleSlots(false));
        });
        document.addEventListener('click', () => {
            closeRuleGroupsMenu();
            closeSlotSubgroupMenus();
        });
        ruleSlotsContainer.addEventListener('change', (event) => {
            if (event.target.closest('.slot-lesson-type')) {
                renderRuleSlots(readRuleSlots(false));
                return;
            }
            if (event.target.closest('.slot-subgroup-checkboxes')) {
                updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
            }
        });
        ruleForm.addEventListener('submit', saveRule);
        rulesTbody.addEventListener('click', handleRuleTableClick);
    }

    async function loadBaseLists() {
        [subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
            api.get('/api/subjects'),
            api.get('/api/groups'),
            api.get('/api/users/teachers'),
            api.get('/api/classrooms'),
            api.get('/api/lesson-types'),
            api.get('/api/subgroups')
        ]);
        populateSubjectSelects();
    }

    async function loadYears() {
        academicYears = await api.get('/api/admin/calendar/years');
        semesters = academicYears.flatMap(year => year.semesters || []);
        populateSemesterSelects();
        selectDefaultVisualPeriod();
    }

    async function loadRules() {
        rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
        try {
            rules = await api.get('/api/admin/schedule-rules');
            if (!visualPeriodTouched) {
                selectDefaultVisualPeriod();
            }
            renderRules();
            renderVisualSchedule();
        } catch (error) {
            rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
            setVisualState('Ошибка загрузки правил расписания');
        }
    }

    function renderRules() {
        if (!rules.length) {
            rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
            renderVisualSchedule();
            return;
        }
        rulesTbody.innerHTML = rules.map(rule => `
            <tr>
                <td>${rule.id}</td>
                <td>${escapeHtml(rule.subjectName || '-')}</td>
                <td>${escapeHtml(formatSemester(rule))}</td>
                <td>${escapeHtml((rule.groupNames || []).join(', ') || '-')}</td>
                <td>${renderRuleLoadSummary(rule)}</td>
                <td>${renderRuleSlotsSummary(rule.slots)}</td>
                <td>
                    <div class="schedule-rule-table-actions">
                        <button type="button" class="btn btn-sm btn-secondary btn-edit-rule" data-id="${rule.id}">Изменить</button>
                        <button type="button" class="btn btn-sm btn-danger btn-delete-rule" data-id="${rule.id}">Удалить</button>
                    </div>
                </td>
            </tr>
        `).join('');
    }

    function toggleVisualDrawer() {
        if (!visualDrawer) return;
        if (visualDrawer.classList.contains('open')) {
            closeVisualDrawer();
        } else {
            openVisualDrawer();
        }
    }

    function openVisualDrawer() {
        if (!visualDrawer) return;
        visualDrawer.classList.add('open');
        visualDrawer.setAttribute('aria-hidden', 'false');
        visualToggleButton?.classList.add('open');
        visualToggleButton?.setAttribute('aria-expanded', 'true');
        visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
        if (visualBackdrop) visualBackdrop.hidden = false;
    }

    function closeVisualDrawer() {
        if (!visualDrawer) return;
        visualDrawer.classList.remove('open');
        visualDrawer.setAttribute('aria-hidden', 'true');
        visualToggleButton?.classList.remove('open');
        visualToggleButton?.setAttribute('aria-expanded', 'false');
        visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
        if (visualBackdrop) visualBackdrop.hidden = true;
    }

    function renderVisualSchedule() {
        if (!visualStage || !visualSummary) return;
        if (!selectedVisualSemesterId) {
            syncVisualGroupFilter([]);
            visualSummary.textContent = 'Выберите учебный год и семестр';
            setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
            return;
        }
        if (!rules.length) {
            syncVisualGroupFilter([]);
            visualSummary.textContent = 'Нет сохранённых правил';
            setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
            return;
        }

        const visualRules = visualRulesForSelectedPeriod();
        const periodLabel = visualPeriodLabel();
        if (!visualRules.length) {
            syncVisualGroupFilter([]);
            visualSummary.textContent = `${periodLabel} · правил нет`;
            setVisualState('В выбранном семестре нет сохранённых правил расписания');
            return;
        }

        const allGroups = collectVisualGroups(visualRules);
        syncVisualGroupFilter(allGroups);
        const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
        const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
        if (!allGroups.length || !slots.length) {
            visualSummary.textContent = 'Недостаточно данных для построения';
            setVisualState('У правил должны быть группы и слоты занятий');
            return;
        }
        if (!columns.length) {
            visualSummary.textContent = 'Выберите группы для просмотра';
            setVisualState('Отметьте одну или несколько групп в фильтре');
            return;
        }

        const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
        const rows = buildVisualRows(columns, slots, lessonsByCell);
        if (!rows.length) {
            visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
            setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
            return;
        }

        visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
        visualStage.innerHTML = `
            <div class="schedule-visual-table-wrap">
                <table class="schedule-visual-table">
                    <thead>
                        <tr>
                            <th class="schedule-visual-day-head">День</th>
                            <th class="schedule-visual-time-head">Время</th>
                            ${columns.map(group => `<th>${escapeHtml(group.name)}</th>`).join('')}
                        </tr>
                    </thead>
                    <tbody>${rows.map(renderVisualRow).join('')}</tbody>
                </table>
            </div>
        `;
    }

    function buildVisualRows(columns, slots, lessonsByCell) {
        const rows = [];
        DAY_OPTIONS.forEach(day => {
            const dayRows = slots
                .map(slot => ({
                    day,
                    slot,
                    cells: columns.map(group => ({
                        group,
                        lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
                    }))
                }))
                .filter(row => row.cells.some(cell => cell.lessons.length));

            dayRows.forEach((row, index) => {
                rows.push({
                    ...row,
                    dayRowspan: index === 0 ? dayRows.length : 0
                });
            });
        });
        return rows;
    }

    function renderVisualRow(row) {
        return `
            <tr>
                ${row.dayRowspan ? `<td class="schedule-visual-day-cell" rowspan="${row.dayRowspan}">${escapeHtml(row.day.label)}</td>` : ''}
                <td class="schedule-visual-time-cell">
                    <strong>${escapeHtml(row.slot.orderLabel)}</strong>
                    <span>${escapeHtml(row.slot.timeLabel)}</span>
                </td>
                ${row.cells.map(cell => {
                    const emptyClass = cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty';
                    return `<td class="schedule-visual-lesson-cell${emptyClass}">${renderVisualCellContent(cell.lessons)}</td>`;
                }).join('')}
            </tr>
        `;
    }

    function buildVisualLessonsByCell(allowedGroupIds, visualRules) {
        const grouped = new Map();
        const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
        visualRules.forEach(rule => {
            (rule.slots || []).forEach((slot, index) => {
                const slotKey = visualRuleSlotKey(slot, index);
                (rule.groupIds || []).forEach((groupId, groupIndex) => {
                    if (!allowedGroupIds.has(String(groupId))) return;
                    const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
                    if (!weeks.length) return;
                    const lesson = {
                        id: `${rule.id}:${slotKey}:${groupId}`,
                        ruleId: rule.id,
                        slotId: slot.id || '',
                        slotIndex: index,
                        dayOfWeek: slot.dayOfWeek,
                        timeSlotId: slot.timeSlotId,
                        parity: slot.parity || 'BOTH',
                        subjectName: rule.subjectName || 'Без дисциплины',
                        teacherName: slot.teacherName || '',
                        classroomName: slot.classroomName || '',
                        lessonTypeName: slot.lessonTypeName || '',
                        lessonFormat: slot.lessonFormat || '',
                        subgroupNames: slotSubgroupNames(slot),
                        weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
                        groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
                    };
                    const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
                    if (!grouped.has(key)) grouped.set(key, []);
                    grouped.get(key).push(lesson);
                });
            });
        });
        grouped.forEach(items => items.sort(compareVisualLessons));
        return grouped;
    }

    function buildActiveWeeksByRuleSlot(visualRules) {
        const activeWeeks = new Map();
        visualRules.forEach(rule => {
            const maxWeeks = maxWeeksForRule(rule);
            (rule.groupIds || []).forEach(groupId => {
                LESSON_TYPE_LIMITS.forEach(config => {
                    const totalHours = Number(rule[config.hoursField] || 0);
                    if (totalHours <= 0) return;
                    const categorySlots = (rule.slots || [])
                        .map((slot, index) => ({ slot, index }))
                        .filter(item => lessonTypeKeyById(item.slot.lessonTypeId) === config.key)
                        .sort(compareSlotItems);
                    if (!categorySlots.length) return;

                    let consumedHours = 0;
                    const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
                    for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
                        for (const item of categorySlots) {
                            if (consumedHours >= totalHours) break;
                            if (!slotAppliesToWeek(item.slot, week)) continue;
                            const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
                            if (!activeWeeks.has(key)) activeWeeks.set(key, []);
                            activeWeeks.get(key).push(week);
                            consumedHours += ACADEMIC_HOURS_PER_SLOT;
                        }
                    }
                });
            });
        });
        return activeWeeks;
    }

    function renderVisualCellContent(lessons) {
        if (!lessons.length) return '';
        const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
        const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
        const shouldSplit = lessons.some(l => l.parity !== 'BOTH');

        if (!shouldSplit) {
            return `<div class="schedule-visual-unified">${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}</div>`;
        }

        return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
            const parityLessons = parity === 'ODD' ? oddLessons : evenLessons;
            return `
                <div class="schedule-visual-half schedule-visual-half-${parity.toLowerCase()}">
                    <span class="schedule-visual-half-label">${escapeHtml(label)}</span>
                    ${parityLessons.length ? parityLessons.map(renderVisualLesson).join('') : ''}
                </div>
            `;
        }).join('');
    }

    function uniqueVisualLessons(lessons) {
        const unique = new Map();
        lessons.forEach(lesson => {
            unique.set(visualLessonSignature([lesson]), lesson);
        });
        return Array.from(unique.values()).sort(compareVisualLessons);
    }

    function renderVisualLesson(lesson) {
        const meta = [
            lesson.lessonTypeName,
            lesson.lessonFormat,
            lesson.teacherName,
            lesson.classroomName
        ].filter(Boolean).join(' · ');
        const subgroupText = lesson.subgroupNames.join(', ');
        return `
            <article class="schedule-visual-lesson">
                <strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
                ${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
                ${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
                <button type="button"
                        class="schedule-visual-edit-rule"
                        data-rule-id="${escapeHtml(String(lesson.ruleId))}"
                        data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
                        data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
                        title="Действия со слотом"
                        aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">&#8942;</button>
            </article>
        `;
    }

    function collectVisualGroups(visualRules = rules) {
        const byId = new Map();
        visualRules.forEach(rule => {
            (rule.groupIds || []).forEach((id, index) => {
                if (!byId.has(String(id))) {
                    byId.set(String(id), {
                        id,
                        name: (rule.groupNames || [])[index] || groupNameById(id)
                    });
                }
            });
        });
        return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
    }

    function collectVisualSlotsFromRules(visualRules = rules) {
        const byKey = new Map();
        visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
            const key = visualSlotKey(slot);
            if (!byKey.has(key)) {
                byKey.set(key, {
                    key,
                    orderNumber: slot.timeSlotOrder,
                    orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
                    timeLabel: slot.timeSlotLabel || 'Время не указано'
                });
            }
        });
        return Array.from(byKey.values()).sort(compareVisualSlots);
    }

    function visualSlotFromTimeSlot(slot) {
        return {
            key: String(slot.id),
            orderNumber: slot.orderNumber,
            orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
            timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
        };
    }

    function setVisualState(message) {
        if (!visualStage) return;
        visualStage.innerHTML = `<div class="loading-row schedule-visual-state">${escapeHtml(message)}</div>`;
    }

    function handleVisualGroupFilterChange(event) {
        if (!event.target.matches('input[type="checkbox"]')) return;
        selectedVisualGroupIds = new Set(Array.from(visualGroupFilter.querySelectorAll('input[type="checkbox"]:checked'))
            .map(input => String(input.value)));
        visualGroupFilterTouched = true;
        renderVisualSchedule();
    }

    function handleVisualYearChange() {
        if (syncingVisualPeriod) return;
        selectedVisualYearId = visualYearSelect?.value || '';
        selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id
            ? String(firstSemesterForYear(selectedVisualYearId).id)
            : '';
        visualPeriodTouched = true;
        visualGroupFilterTouched = false;
        populateVisualSemesterSelect();
        renderVisualSchedule();
    }

    function handleVisualSemesterChange() {
        if (syncingVisualPeriod) return;
        selectedVisualSemesterId = visualSemesterSelect?.value || '';
        visualPeriodTouched = true;
        visualGroupFilterTouched = false;
        renderVisualSchedule();
    }

    function handleVisualStageClick(event) {
        const editButton = event.target.closest('.schedule-visual-edit-rule');
        if (!editButton) return;
        event.stopPropagation();
        openVisualContextMenu(editButton);
    }

    function openVisualContextMenu(button) {
        const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
        if (!rule) {
            showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
            return;
        }
        visualContextTarget = {
            ruleId: button.dataset.ruleId,
            slotId: button.dataset.slotId || '',
            slotIndex: button.dataset.slotIndex || ''
        };
        if (!visualContextMenu) return;
        visualContextMenu.hidden = false;
        visualContextMenu.querySelector('button')?.focus();
    }

    function closeVisualContextMenu() {
        visualContextTarget = null;
        if (!visualContextMenu) return;
        visualContextMenu.hidden = true;
    }

    async function handleVisualContextMenuClick(event) {
        const actionButton = event.target.closest('button[data-action]');
        if (!actionButton || !visualContextTarget) return;
        const action = actionButton.dataset.action;
        const target = { ...visualContextTarget };
        const rule = rules.find(item => String(item.id) === String(target.ruleId));
        if (!rule) {
            closeVisualContextMenu();
            showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
            return;
        }

        if (action === 'edit-rule') {
            closeVisualContextMenu();
            fillRuleForm(rule);
            closeVisualDrawer();
            showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
            return;
        }

        if (action === 'move-slot') {
            openMoveSlotModal(target);
            return;
        }

        if (action === 'delete-rule') {
            await deleteRuleFromVisual(rule);
        }
    }

    async function deleteRuleFromVisual(rule) {
        closeVisualContextMenu();
        if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
        try {
            await api.delete('/api/admin/schedule-rules/' + rule.id);
            showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
            await loadRules();
        } catch (error) {
            showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
        }
    }

    function openMoveSlotModal(target) {
        const rule = rules.find(item => String(item.id) === String(target.ruleId));
        const slotIndex = findRuleSlotIndex(rule, target);
        const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
        if (!rule || !slot) {
            closeVisualContextMenu();
            showAlert('schedule-rule-alert', 'Слот правила не найден. Обновите список правил.', 'error');
            return;
        }

        pendingSlotMove = {
            ruleId: String(rule.id),
            slotIndex
        };
        if (moveSlotTitle) {
            moveSlotTitle.textContent = rule.subjectName || 'Изменить день и пару';
        }
        if (moveSlotSummary) {
            moveSlotSummary.textContent = `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`;
        }
        if (moveSlotDaySelect) {
            moveSlotDaySelect.innerHTML = options(DAY_OPTIONS, slot.dayOfWeek);
        }
        if (moveSlotTimeSelect) {
            moveSlotTimeSelect.innerHTML = options(timeSlots.map(toTimeSlotOption), slot.timeSlotId);
        }
        hideAlert('schedule-move-slot-alert');
        closeVisualContextMenu();
        moveSlotModal?.classList.add('open');
        moveSlotModal?.setAttribute('aria-hidden', 'false');
        moveSlotDaySelect?.focus();
    }

    function closeMoveSlotModal() {
        pendingSlotMove = null;
        moveSlotModal?.classList.remove('open');
        moveSlotModal?.setAttribute('aria-hidden', 'true');
        hideAlert('schedule-move-slot-alert');
    }

    async function saveVisualSlotMove(event) {
        event.preventDefault();
        if (!pendingSlotMove) return;
        hideAlert('schedule-move-slot-alert');
        const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
        const slot = rule?.slots?.[pendingSlotMove.slotIndex];
        if (!rule || !slot) {
            showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
            return;
        }
        const dayOfWeek = Number(moveSlotDaySelect?.value);
        const timeSlotId = Number(moveSlotTimeSelect?.value);
        if (!dayOfWeek || !timeSlotId) {
            showAlert('schedule-move-slot-alert', 'Выберите день и пару', 'error');
            return;
        }

        const previousButtonText = moveSlotSubmitButton?.textContent || '';
        if (moveSlotSubmitButton) {
            moveSlotSubmitButton.disabled = true;
            moveSlotSubmitButton.textContent = 'Сохраняем...';
        }
        try {
            const slots = (rule.slots || []).map(ruleSlotToPayload);
            slots[pendingSlotMove.slotIndex] = {
                ...slots[pendingSlotMove.slotIndex],
                dayOfWeek,
                timeSlotId
            };
            const payload = buildRulePayloadFromRule(rule, slots);
            const saved = await api.put('/api/admin/schedule-rules/' + rule.id, payload);
            closeMoveSlotModal();
            showAlert('schedule-rule-alert', `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, 'success');
            await loadRules();
        } catch (error) {
            if (isScheduleRuleConflictError(error)) {
                showAlert(
                    'schedule-move-slot-alert',
                    `Новое место занято правилом "${ruleTitle(error.data.conflictRule)}". Выберите другой день или пару.`,
                    'error'
                );
                return;
            }
            showAlert('schedule-move-slot-alert', error.message || 'Ошибка переноса слота', 'error');
        } finally {
            if (moveSlotSubmitButton) {
                moveSlotSubmitButton.disabled = false;
                moveSlotSubmitButton.textContent = previousButtonText || 'Сохранить перенос';
            }
        }
    }

    function findRuleSlotIndex(rule, target) {
        if (!rule?.slots?.length) return -1;
        if (target.slotId) {
            const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId));
            if (byId >= 0) return byId;
        }
        const index = Number(target.slotIndex);
        return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1;
    }

    function ruleSlotToPayload(slot) {
        return {
            id: slot.id || null,
            dayOfWeek: Number(slot.dayOfWeek),
            parity: slot.parity || 'BOTH',
            timeSlotId: Number(slot.timeSlotId),
            subgroupId: slot.subgroupId || null,
            subgroupIds: slotSubgroupIds(slot),
            teacherId: Number(slot.teacherId),
            classroomId: Number(slot.classroomId),
            lessonTypeId: Number(slot.lessonTypeId),
            lessonFormat: slot.lessonFormat || 'Очно'
        };
    }

    function syncVisualGroupFilter(allGroups) {
        ensureVisualGroupSelection(allGroups);
        if (!visualGroupFilter) return;
        if (!allGroups.length) {
            visualGroupFilter.innerHTML = '<span class="muted">Нет групп в правилах</span>';
            return;
        }
        visualGroupFilter.innerHTML = allGroups.map(group => {
            const id = String(group.id);
            return `
                <label class="schedule-visual-group-chip">
                    <input type="checkbox" value="${escapeHtml(id)}" ${selectedVisualGroupIds.has(id) ? 'checked' : ''}>
                    <span>${escapeHtml(group.name)}</span>
                </label>
            `;
        }).join('');
    }

    function ensureVisualGroupSelection(allGroups) {
        const availableIds = new Set(allGroups.map(group => String(group.id)));
        selectedVisualGroupIds = new Set(Array.from(selectedVisualGroupIds).filter(id => availableIds.has(id)));
        if (visualGroupFilterTouched) return;

        const formIds = selectedRuleGroupIds()
            .map(String)
            .filter(id => availableIds.has(id));
        if (!visualGroupFilterTouched && formIds.length) {
            selectedVisualGroupIds = new Set(formIds);
            return;
        }

        if (!selectedVisualGroupIds.size && allGroups.length) {
            selectedVisualGroupIds = new Set([String(allGroups[0].id)]);
        }
    }

    function selectDefaultVisualPeriod() {
        const formSemester = semesterById(ruleSemesterSelect?.value);
        const ruleSemester = firstSemesterWithRules();
        const fallbackSemester = semesters[0];
        const semester = formSemester || ruleSemester || fallbackSemester;
        setVisualPeriodBySemesterId(semester?.id || '');
    }

    function setVisualPeriodBySemesterId(semesterId) {
        const semester = semesterById(semesterId);
        selectedVisualSemesterId = semester ? String(semester.id) : '';
        selectedVisualYearId = semester ? String(semester.academicYearId) : '';
        populateVisualYearSelect();
        populateVisualSemesterSelect();
    }

    function populateVisualYearSelect() {
        if (!visualYearSelect) return;
        visualYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
            academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
        if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) {
            visualYearSelect.value = selectedVisualYearId;
        } else if (academicYears.length) {
            selectedVisualYearId = String(academicYears[0].id);
            visualYearSelect.value = selectedVisualYearId;
        } else {
            selectedVisualYearId = '';
            visualYearSelect.value = '';
        }
        syncVisualPeriodSelects(visualYearSelect);
    }

    function populateVisualSemesterSelect() {
        if (!visualSemesterSelect) return;
        const yearSemesters = semestersForYear(selectedVisualYearId);
        visualSemesterSelect.innerHTML = yearSemesters.length
            ? '<option value="">Выберите семестр</option>' + yearSemesters
                .map(semester => `<option value="${semester.id}">${escapeHtml(shortSemesterLabel(semester))}</option>`)
                .join('')
            : '<option value="">Нет семестров</option>';

        if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) {
            visualSemesterSelect.value = selectedVisualSemesterId;
        } else if (yearSemesters.length) {
            selectedVisualSemesterId = String(yearSemesters[0].id);
            visualSemesterSelect.value = selectedVisualSemesterId;
        } else {
            selectedVisualSemesterId = '';
            visualSemesterSelect.value = '';
        }
        syncVisualPeriodSelects(visualSemesterSelect);
    }

    function syncVisualPeriodSelects(...selects) {
        syncingVisualPeriod = true;
        syncSelects(...selects);
        syncingVisualPeriod = false;
    }

    function visualRulesForSelectedPeriod() {
        if (!selectedVisualSemesterId) return [];
        return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
    }

    function firstSemesterWithRules() {
        const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId)));
        return semesters.find(semester => ruleSemesterIds.has(String(semester.id)));
    }

    function semesterById(id) {
        return semesters.find(semester => String(semester.id) === String(id));
    }

    function semestersForYear(yearId) {
        return semesters.filter(semester => String(semester.academicYearId) === String(yearId));
    }

    function firstSemesterForYear(yearId) {
        return semestersForYear(yearId)[0] || null;
    }

    function visualPeriodLabel() {
        const semester = semesterById(selectedVisualSemesterId);
        return semester ? shortSemesterLabel(semester) : 'Семестр не выбран';
    }

    function renderRuleSlotsSummary(slots) {
        if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
        return slots.map(slot => {
            const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
            const parts = [
                slot.dayName,
                slot.timeSlotLabel,
                parity,
                slotSubgroupNames(slot).join(', '),
                slot.teacherName,
                slot.classroomName,
                slot.lessonTypeName,
                slot.lessonFormat
            ].filter(Boolean);
            return `<div class="schedule-slot-summary">${escapeHtml(parts.join(' · '))}</div>`;
        }).join('');
    }

    function renderRuleLoadSummary(rule) {
        const rows = LESSON_TYPE_LIMITS
            .map(config => ({
                label: config.label,
                hours: Number(rule[config.hoursField] || 0),
                startWeek: Number(rule[config.startWeekField] || 1)
            }))
            .filter(item => item.hours > 0);
        if (!rows.length) return '<span class="muted">Часы не указаны</span>';
        return rows.map(item => (
            `<div class="schedule-slot-summary">${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}</div>`
        )).join('');
    }

    async function handleRuleTableClick(event) {
        const editButton = event.target.closest('.btn-edit-rule');
        const deleteButton = event.target.closest('.btn-delete-rule');

        if (editButton) {
            const rule = rules.find(item => item.id == editButton.dataset.id)
                || await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
            fillRuleForm(rule);
            return;
        }

        if (deleteButton) {
            if (!confirm('Удалить правило расписания?')) return;
            try {
                await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
                showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
                await loadRules();
            } catch (error) {
                showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
            }
        }
    }

    async function saveRule(event) {
        event.preventDefault();
        hideAlert('schedule-rule-alert');
        let payload = null;
        let id = '';
        try {
            payload = buildRulePayload();
            id = ruleIdInput.value;
            const saved = await saveRulePayload(id, payload);
            await finishSuccessfulRuleSave(saved);
        } catch (error) {
            if (payload && isScheduleRuleConflictError(error)) {
                openConflictModal(error, payload, id);
                return;
            }
            showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
        }
    }

    async function saveRulePayload(id, payload) {
        return id
            ? api.put('/api/admin/schedule-rules/' + id, payload)
            : api.post('/api/admin/schedule-rules', payload);
    }

    async function finishSuccessfulRuleSave(saved) {
        resetRuleForm();
        showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
        await loadRules();
    }

    function openConflictModal(error, pendingPayload, pendingRuleId) {
        const conflictRule = error.data.conflictRule;
        pendingConflictResolution = {
            conflictRule: clonePlain(conflictRule),
            conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [],
            pendingPayload: clonePlain(pendingPayload),
            pendingRuleId: pendingRuleId || ''
        };
        if (conflictSummary) {
            conflictSummary.textContent = error.message || 'Ранее созданное правило занимает выбранный слот.';
        }
        if (conflictNewRuleTitle) {
            conflictNewRuleTitle.textContent = ruleDraftTitle(pendingPayload);
        }
        if (conflictNewRuleMeta) {
            conflictNewRuleMeta.textContent = ruleDraftMeta(pendingPayload);
        }
        if (conflictExistingRuleTitle) {
            conflictExistingRuleTitle.textContent = ruleTitle(conflictRule);
        }
        if (conflictExistingRuleMeta) {
            conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
        }
        renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons);
        renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields);
        hideAlert('schedule-conflict-alert');
        conflictModal?.classList.add('open');
        conflictModal?.setAttribute('aria-hidden', 'false');
    }

    function closeConflictModal() {
        pendingConflictResolution = null;
        conflictModal?.classList.remove('open');
        conflictModal?.setAttribute('aria-hidden', 'true');
        if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
        if (conflictReasons) {
            conflictReasons.hidden = true;
            conflictReasons.innerHTML = '';
        }
        hideAlert('schedule-conflict-alert');
    }

    async function resolveScheduleRuleConflict(event) {
        event.preventDefault();
        if (!pendingConflictResolution) return;
        hideAlert('schedule-conflict-alert');
        const previousButtonText = conflictSubmitButton?.textContent || '';
        if (conflictSubmitButton) {
            conflictSubmitButton.disabled = true;
            conflictSubmitButton.textContent = 'Сохраняем...';
        }

        try {
            const currentResolution = pendingConflictResolution;
            const conflictPayload = buildRulePayloadFromRule(
                currentResolution.conflictRule,
                readConflictSlots(true)
            );
            await api.put('/api/admin/schedule-rules/' + currentResolution.conflictRule.id, conflictPayload);

            try {
                const saved = await saveRulePayload(
                    currentResolution.pendingRuleId,
                    currentResolution.pendingPayload
                );
                closeConflictModal();
                await finishSuccessfulRuleSave(saved);
            } catch (retryError) {
                if (isScheduleRuleConflictError(retryError)) {
                    await loadRules();
                    openConflictModal(
                        retryError,
                        currentResolution.pendingPayload,
                        currentResolution.pendingRuleId
                    );
                    showAlert(
                        'schedule-conflict-alert',
                        'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.',
                        'error'
                    );
                    return;
                }
                throw retryError;
            }
        } catch (error) {
            if (isScheduleRuleConflictError(error)) {
                showAlert(
                    'schedule-conflict-alert',
                    `Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule)}. Выберите другой день, пару, преподавателя или аудиторию.`,
                    'error'
                );
                return;
            }
            showAlert('schedule-conflict-alert', error.message || 'Ошибка разрешения конфликта', 'error');
        } finally {
            if (conflictSubmitButton) {
                conflictSubmitButton.disabled = false;
                conflictSubmitButton.textContent = previousButtonText || 'Сохранить и повторить';
            }
        }
    }

    function renderConflictReasons(fields, reasons) {
        if (!conflictReasons) return;
        const labels = Array.isArray(reasons) && reasons.length
            ? reasons
            : (fields || []).map(conflictFieldLabel).filter(Boolean);
        if (!labels.length) {
            conflictReasons.hidden = true;
            conflictReasons.innerHTML = '';
            return;
        }
        conflictReasons.hidden = false;
        conflictReasons.innerHTML = `
            <span>Причина:</span>
            ${labels.map(label => `<strong>${escapeHtml(label)}</strong>`).join('')}
        `;
    }

    function renderConflictSlots(rule, conflictFields = []) {
        const slots = rule.slots || [];
        if (!conflictSlotsContainer) return;
        if (!slots.length) {
            conflictSlotsContainer.innerHTML = '<div class="loading-row">У конфликтующего правила нет слотов</div>';
            return;
        }
        conflictSlotsContainer.innerHTML = slots.map((slot, index) => {
            const fixedParts = [
                slot.lessonTypeName,
                slot.lessonFormat,
                slotSubgroupNames(slot).join(', ')
            ].filter(Boolean);
            const groupHitClass = conflictFieldClass('group', conflictFields);
            return `
                <section class="schedule-conflict-slot-row" data-index="${index}">
                    <div class="schedule-conflict-slot-fixed ${groupHitClass}">
                        <span>Слот ${index + 1}</span>
                        <strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
                        <small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
                    </div>
                    <div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
                    <div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
                    <div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
                    <div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
                    <div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
                </section>
            `;
        }).join('');
    }

    function conflictFieldClass(field, fields) {
        return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
    }

    function conflictFieldLabel(field) {
        if (field === 'teacher') return 'Преподаватель';
        if (field === 'classroom') return 'Аудитория';
        if (field === 'group') return 'Группа';
        return field;
    }

    function readConflictSlots(validate) {
        const conflictRule = pendingConflictResolution?.conflictRule;
        const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
        return rows.map((row, index) => {
            const original = conflictRule?.slots?.[index] || {};
            const slot = {
                id: original.id || null,
                dayOfWeek: Number(row.querySelector('.conflict-slot-day').value),
                parity: row.querySelector('.conflict-slot-parity').value,
                timeSlotId: Number(row.querySelector('.conflict-slot-time').value),
                subgroupId: original.subgroupId || null,
                subgroupIds: slotSubgroupIds(original),
                teacherId: Number(row.querySelector('.conflict-slot-teacher').value),
                classroomId: Number(row.querySelector('.conflict-slot-classroom').value),
                lessonTypeId: Number(original.lessonTypeId),
                lessonFormat: original.lessonFormat || 'Очно'
            };
            if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
                || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
                throw new Error(`Заполните слот ${index + 1} конфликтующего правила`);
            }
            return slot;
        });
    }

    function buildRulePayloadFromRule(rule, slots) {
        const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
        if (!rule.id) throw new Error('Конфликтующее правило не найдено');
        if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
        if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
        if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
        if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
        return {
            id: Number(rule.id),
            subjectId: Number(rule.subjectId),
            semesterId: Number(rule.semesterId),
            lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
            laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
            practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
            lectureStartWeek: Number(rule.lectureStartWeek || 1),
            laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
            practiceStartWeek: Number(rule.practiceStartWeek || 1),
            groupIds,
            slots
        };
    }

    function isScheduleRuleConflictError(error) {
        return error?.status === 409 && Boolean(error.data?.conflictRule);
    }

    function clonePlain(value) {
        return JSON.parse(JSON.stringify(value));
    }

    function ruleDraftTitle(payload) {
        const subject = subjects.find(item => String(item.id) === String(payload.subjectId));
        return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
    }

    function ruleTitle(rule) {
        return rule?.subjectName || ruleDraftTitle(rule || {});
    }

    function ruleDraftMeta(payload) {
        const groupNames = (payload.groupIds || []).map(groupNameById).join(', ') || 'группы не выбраны';
        return [semesterTextById(payload.semesterId), groupNames, typeLoadText(payload)]
            .filter(Boolean)
            .join(' · ');
    }

    function ruleMeta(rule) {
        const groupNames = (rule.groupNames || []).join(', ')
            || (rule.groupIds || []).map(groupNameById).join(', ')
            || 'группы не выбраны';
        return [formatSemester(rule), groupNames, typeLoadText(rule)]
            .filter(Boolean)
            .join(' · ');
    }

    function typeLoadText(rule) {
        return LESSON_TYPE_LIMITS
            .map(config => {
                const hours = Number(rule?.[config.hoursField] || 0);
                const startWeek = Number(rule?.[config.startWeekField] || 1);
                return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
            })
            .filter(Boolean)
            .join('; ');
    }

    function semesterTextById(semesterId) {
        const semester = semesterById(semesterId);
        return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
    }

    function conflictSlotCurrentText(slot) {
        const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
        return [
            slot.dayName,
            slot.timeSlotLabel,
            parity,
            slot.teacherName,
            slot.classroomName
        ].filter(Boolean).join(' · ');
    }

    function buildRulePayload() {
        const groupIds = selectedRuleGroupIds();
        const slots = readRuleSlots(true);
        if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
        if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
        const typeLimits = readTypeLimits();
        validateTypeLimits(typeLimits, slots);
        if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
        if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
        return {
            id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
            subjectId: Number(ruleSubjectSelect.value),
            semesterId: Number(ruleSemesterSelect.value),
            ...typeLimits,
            groupIds,
            slots
        };
    }

    function fillRuleForm(rule) {
        ruleFormTitle.textContent = 'Редактирование правила расписания';
        ruleIdInput.value = rule.id;
        ruleSubjectSelect.value = rule.subjectId || '';
        ruleSemesterSelect.value = rule.semesterId || '';
        fillTypeLimits(rule);
        syncSelects(ruleSubjectSelect, ruleSemesterSelect);
        renderRuleGroups(rule.groupIds || []);
        renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
        hideAlert('schedule-rule-alert');
        ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }

    function resetRuleForm() {
        ruleForm.reset();
        ruleFormTitle.textContent = 'Новое правило расписания';
        ruleIdInput.value = '';
        fillTypeLimits({});
        renderRuleGroups([]);
        renderRuleSlots([{}]);
        syncSelects(ruleSubjectSelect, ruleSemesterSelect);
        hideAlert('schedule-rule-alert');
    }

    function renderRuleGroups(selectedIds) {
        if (!groups.length) {
            ruleGroupCheckboxes.innerHTML = '<div class="loading-row">Нет групп</div>';
            updateRuleGroupsText();
            return;
        }
        const selected = new Set((selectedIds || []).map(String));
        ruleGroupCheckboxes.innerHTML = groups.map(group => `
            <label class="checkbox-item">
                <input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
                <span class="checkmark"></span>
                <span>${escapeHtml(group.name)}</span>
            </label>
        `).join('');
        updateRuleGroupsText();
    }

    function renderRuleSlots(slots) {
        const normalizedSlots = slots?.length ? slots : [{}];
        const isSingleSlot = normalizedSlots.length === 1;
        const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
        const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
        ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
            <div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
                <div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
                <div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
                <div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
                <div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
                <div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
                <div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
                ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
                <div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
                <button type="button" class="btn btn-md ${actionClass} schedule-slot-remove" data-index="${index}">${actionLabel}</button>
            </div>
        `).join('');
    }

    function readRuleSlots(validate) {
        const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
        return rows.map((row, index) => {
            const subgroupIds = readSlotSubgroupIds(row);
            const slot = {
                id: null,
                dayOfWeek: Number(row.querySelector('.slot-day').value),
                parity: row.querySelector('.slot-parity').value,
                timeSlotId: Number(row.querySelector('.slot-time').value),
                subgroupId: subgroupIds[0] || null,
                subgroupIds,
                teacherId: Number(row.querySelector('.slot-teacher').value),
                classroomId: Number(row.querySelector('.slot-classroom').value),
                lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
                lessonFormat: row.querySelector('.slot-format').value
            };
            if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
                || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
                throw new Error(`Заполните слот ${index + 1}`);
            }
            if (validate) {
                validateSlotSubgroup(slot, index);
            }
            return slot;
        });
    }

    function readTypeLimits() {
        return LESSON_TYPE_LIMITS.reduce((payload, config) => {
            const inputs = ruleTypeInputs[config.key];
            payload[config.hoursField] = Number(inputs.hours.value || 0);
            payload[config.startWeekField] = Number(inputs.startWeek.value || 1);
            return payload;
        }, {});
    }

    function fillTypeLimits(rule) {
        LESSON_TYPE_LIMITS.forEach(config => {
            const inputs = ruleTypeInputs[config.key];
            inputs.hours.value = rule[config.hoursField] ?? 0;
            inputs.startWeek.value = rule[config.startWeekField] ?? 1;
        });
    }

    function validateTypeLimits(typeLimits, slots) {
        const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean));
        let hasHours = false;
        for (const config of LESSON_TYPE_LIMITS) {
            const hours = typeLimits[config.hoursField];
            const startWeek = typeLimits[config.startWeekField];
            if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
            if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
            if (hours > 0) hasHours = true;
            if (hours > 0 && !selectedTypeKeys.has(config.key)) {
                throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
            }
            if (hours === 0 && selectedTypeKeys.has(config.key)) {
                throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
            }
        }
        if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
    }

    function validateSlotSubgroup(slot, index) {
        const typeKey = lessonTypeKeyById(slot.lessonTypeId);
        const subgroupIds = slotSubgroupIds(slot);
        if (subgroupIds.length && typeKey !== 'laboratory') {
            throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
        }
        if (!subgroupIds.length) return;

        const selectedGroups = new Set(selectedRuleGroupIds().map(String));
        const subgroupGroupIds = new Set();
        for (const subgroupId of subgroupIds) {
            const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId));
            if (!selectedSubgroup) {
                throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
            }
            if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
                throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
            }
            if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
                throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
            }
            subgroupGroupIds.add(String(selectedSubgroup.groupId));
        }
    }

    async function loadTimeSlots() {
        const slots = await api.get('/api/admin/time-slots');
        timeSlots = slots
            .filter(slot => slot.scopeApplyMode === 'DEFAULT')
            .sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
    }

    function populateSubjectSelects() {
        ruleSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
            subjects.map(subject => `<option value="${subject.id}">${escapeHtml(subject.name)}</option>`).join('');
        syncSelects(ruleSubjectSelect);
    }

    function populateSemesterSelects() {
        const semesterOptions = '<option value="">Выберите семестр</option>' +
            semesters.map(semester => `<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>`).join('');
        ruleSemesterSelect.innerHTML = semesterOptions;
        syncSelects(ruleSemesterSelect);
    }

    function options(items, selectedValue) {
        const selected = selectedValue == null ? '' : String(selectedValue);
        return '<option value="">Выберите</option>' + items.map(item => {
            const value = String(item.value);
            return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(item.label)}</option>`;
        }).join('');
    }

    function subgroupOptions(slot) {
        if (!isLaboratorySlot(slot)) {
            return '<option value="">Только для лабораторных</option>';
        }
        const selectedGroupIds = selectedRuleGroupIds();
        if (selectedGroupIds.length !== 1) {
            return '<option value="">Выберите группу правила</option>';
        }
        const selected = slotSubgroupIds(slot)[0] == null
            ? WHOLE_GROUP_SUBGROUP_VALUE
            : String(slotSubgroupIds(slot)[0]);
        const selectedGroups = new Set(selectedGroupIds.map(String));
        const matchingSubgroups = subgroups
            .filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
            .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
        return `<option value="${WHOLE_GROUP_SUBGROUP_VALUE}" ${selected === WHOLE_GROUP_SUBGROUP_VALUE ? 'selected' : ''}>Вся группа</option>` + matchingSubgroups.map(subgroup => {
            const value = String(subgroup.id);
            return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
        }).join('');
    }

    function renderSubgroupField(slot, index) {
        return selectedRuleGroupIds().length > 1
            ? renderSubgroupMultiSelect(slot, index)
            : `<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup">${subgroupOptions(slot)}</select></div>`;
    }

    function renderSubgroupMultiSelect(slot, index) {
        const selected = new Set(slotSubgroupIds(slot).map(String));
        const selectedGroups = new Set(selectedRuleGroupIds().map(String));
        const matchingSubgroups = subgroups
            .filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
            .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
        const items = matchingSubgroups.length
            ? matchingSubgroups.map(subgroup => {
                const value = String(subgroup.id);
                return `
                    <label class="checkbox-item">
                        <input type="checkbox" value="${escapeHtml(value)}" ${selected.has(value) ? 'checked' : ''}>
                        <span class="checkmark"></span>
                        <span>${escapeHtml(subgroupLabel(subgroup))}</span>
                    </label>
                `;
            }).join('')
            : '<div class="loading-row">Нет подгрупп выбранных групп</div>';

        return `
            <div class="form-group slot-subgroup-multi">
                <label>Подгруппы</label>
                <div class="custom-multi-select">
                    <div class="select-box slot-subgroup-box" role="button" tabindex="0" aria-expanded="false">
                        <span class="select-text">${escapeHtml(slotSubgroupText(slot))}</span>
                        <svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
                            <path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                        </svg>
                    </div>
                    <div class="dropdown-menu slot-subgroup-menu">
                        <div class="checkbox-group-vertical slot-subgroup-checkboxes" data-index="${index}">
                            ${items}
                        </div>
                    </div>
                </div>
            </div>
        `;
    }

    function readSlotSubgroupIds(row) {
        const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
        const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked'))
            .map(input => Number(input.value))
            .filter(Boolean);
        return checkboxValues.length
            ? checkboxValues
            : (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []);
    }

    function slotSubgroupIds(slot) {
        if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
            return slot.subgroupIds.map(Number).filter(Boolean);
        }
        return slot.subgroupId ? [Number(slot.subgroupId)] : [];
    }

    function slotSubgroupNames(slot) {
        if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
            return slot.subgroupNames.filter(Boolean);
        }
        return slot.subgroupName ? [slot.subgroupName] : [];
    }

    function slotSubgroupText(slot) {
        const selectedIds = slotSubgroupIds(slot);
        if (!selectedIds.length) return 'Все выбранные группы';
        if (selectedIds.length === 1) {
            const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
            return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
        }
        return `Выбрано подгрупп: ${selectedIds.length}`;
    }

    function selectedRuleGroupIds() {
        return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked'))
            .map(input => Number(input.value));
    }

    function toggleRuleGroupsMenu(event) {
        event.stopPropagation();
        const isOpen = ruleGroupsMenu.classList.contains('open');
        document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
        document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
        if (isOpen) {
            closeRuleGroupsMenu();
            return;
        }
        ruleGroupsMenu.classList.add('open');
        ruleGroupsBox.classList.add('active');
        ruleGroupsBox.setAttribute('aria-expanded', 'true');
    }

    function handleRuleGroupsKeydown(event) {
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault();
            toggleRuleGroupsMenu(event);
        }
        if (event.key === 'Escape') {
            closeRuleGroupsMenu();
        }
    }

    function closeRuleGroupsMenu() {
        ruleGroupsMenu.classList.remove('open');
        ruleGroupsBox.classList.remove('active');
        ruleGroupsBox.setAttribute('aria-expanded', 'false');
    }

    function toggleSlotSubgroupMenu(event, box) {
        event.stopPropagation();
        const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu');
        if (!menu) return;
        const isOpen = menu.classList.contains('open');
        document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open'));
        document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active'));
        if (isOpen) {
            box.setAttribute('aria-expanded', 'false');
            return;
        }
        menu.classList.add('open');
        box.classList.add('active');
        box.setAttribute('aria-expanded', 'true');
    }

    function closeSlotSubgroupMenus() {
        ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open'));
        ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => {
            box.classList.remove('active');
            box.setAttribute('aria-expanded', 'false');
        });
    }

    function updateSlotSubgroupText(row) {
        if (!row) return;
        const text = row.querySelector('.slot-subgroup-box .select-text');
        if (!text) return;
        const selectedIds = readSlotSubgroupIds(row);
        if (!selectedIds.length) {
            text.textContent = 'Все выбранные группы';
            return;
        }
        if (selectedIds.length === 1) {
            const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
            text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
            return;
        }
        text.textContent = `Выбрано подгрупп: ${selectedIds.length}`;
    }

    function updateRuleGroupsText() {
        const selectedIds = selectedRuleGroupIds();
        if (!groups.length) {
            ruleGroupsText.textContent = 'Нет групп';
            return;
        }
        if (!selectedIds.length) {
            ruleGroupsText.textContent = 'Выберите группы...';
            return;
        }
        if (selectedIds.length === 1) {
            const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0]));
            ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа';
            return;
        }
        ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`;
    }

    function toTimeSlotOption(slot) {
        return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
    }

    function toTeacherOption(teacher) {
        return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
    }

    function toClassroomOption(classroom) {
        const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
        return { value: classroom.id, label: `${classroom.name}${capacity}` };
    }

    function toLessonTypeOption(lessonType) {
        return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
    }

    function orderedLessonTypes() {
        return [...lessonTypes].sort(compareLessonTypes);
    }

    function compareLessonTypes(first, second) {
        return lessonTypeRank(first) - lessonTypeRank(second)
            || naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label);
    }

    function lessonTypeRank(lessonType) {
        const key = lessonTypeKey(lessonType);
        if (key === 'lecture') return 0;
        if (key === 'laboratory') return 1;
        if (key === 'practice') return 2;
        return 3;
    }

    function lessonTypeKeyById(lessonTypeId) {
        const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
        return lessonType ? lessonTypeKey(lessonType) : null;
    }

    function isLaboratorySlot(slot) {
        return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory';
    }

    function subgroupLabel(subgroup) {
        return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
    }

    function lessonTypeKey(lessonType) {
        const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
        if (name.includes('лекц')) return 'lecture';
        if (name.includes('лаб')) return 'laboratory';
        if (name.includes('практ')) return 'practice';
        return null;
    }

    function semesterLabel(semester) {
        const type = normalizeSemesterType(semester.semesterType);
        return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
    }

    function shortSemesterLabel(semester) {
        const type = normalizeSemesterType(semester.semesterType);
        return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
    }

    function formatSemester(rule) {
        const type = normalizeSemesterType(rule.semesterType);
        return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
    }

    function normalizeSemesterType(type) {
        return String(type || '').toLowerCase();
    }

    function trimTime(value) {
        if (!value) return '';
        return String(value).slice(0, 5);
    }

    function syncSelects(...selects) {
        selects.filter(Boolean).forEach(select => {
            select.dispatchEvent(new Event('change', { bubbles: true }));
        });
    }

    function visualCellKey(groupId, dayOfWeek, slotKey) {
        return `${groupId}:${dayOfWeek}:${slotKey}`;
    }

    function visualSlotKey(slot) {
        return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
    }

    function visualRuleSlotKey(slot, index) {
        return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
    }

    function activeWeekKey(ruleId, groupId, slotKey) {
        return `${ruleId}:${groupId}:${slotKey}`;
    }

    function slotAppliesToWeek(slot, week) {
        if (!slot.parity || slot.parity === 'BOTH') return true;
        const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
        return slot.parity === weekParity;
    }

    function maxWeeksForRule(rule) {
        const semester = semesters.find(item => String(item.id) === String(rule.semesterId));
        if (semester?.startDate && semester?.endDate) {
            const start = parseIsoDate(semester.startDate);
            const end = parseIsoDate(semester.endDate);
            const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
            return Math.max(1, Math.ceil(days / 7));
        }
        return 18;
    }

    function parseIsoDate(value) {
        const [year, month, day] = String(value).split('-').map(Number);
        return new Date(year, month - 1, day);
    }

    function compactWeekRanges(weeks, maxWeeks, parity) {
        const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
        if (!uniqueWeeks.length) return '';
        const start = uniqueWeeks[0];
        const end = uniqueWeeks[uniqueWeeks.length - 1];
        const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
        const semesterStartWeek = firstApplicableSemesterWeek(parity);
        if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
        if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
        return weekRangeText(start, end);
    }

    function weekRangeText(start, end) {
        return start === end
            ? `(${start} нед.)`
            : `(с ${start} по ${end} нед.)`;
    }

    function firstApplicableSemesterWeek(parity) {
        return parity === 'EVEN' ? 2 : 1;
    }

    function lastApplicableSemesterWeek(maxWeeks, parity) {
        const totalWeeks = Number(maxWeeks || 0);
        if (!totalWeeks) return 0;
        if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
        if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
        return totalWeeks;
    }

    function compareSlotItems(first, second) {
        return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
            || compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
            || Number(first.index || 0) - Number(second.index || 0);
    }

    function slotDescriptor(slot) {
        return {
            key: visualSlotKey(slot),
            orderNumber: slot.timeSlotOrder,
            timeLabel: slot.timeSlotLabel
        };
    }

    function compareVisualSlots(first, second) {
        const firstOrder = Number(first.orderNumber || first.key);
        const secondOrder = Number(second.orderNumber || second.key);
        if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
            return firstOrder - secondOrder;
        }
        return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
    }

    function compareVisualLessons(first, second) {
        return naturalCompare(first.subjectName, second.subjectName)
            || naturalCompare(first.weekText, second.weekText)
            || naturalCompare(first.lessonTypeName, second.lessonTypeName)
            || naturalCompare(first.teacherName, second.teacherName);
    }

    function visualLessonSignature(lessons) {
        return lessons
            .map(lesson => [
                lesson.id,
                lesson.subjectName,
                lesson.weekText,
                lesson.lessonTypeName,
                lesson.teacherName,
                lesson.classroomName,
                lesson.subgroupNames.join(',')
            ].join(':'))
            .sort((a, b) => naturalCompare(a, b))
            .join('|');
    }

    function groupNameById(id) {
        return groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
    }

    function naturalCompare(a, b) {
        return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
    }

    function ruleCountLabel(count) {
        const tail = count % 100;
        if (tail >= 11 && tail <= 14) return 'правил';
        if (count % 10 === 1) return 'правило';
        if ([2, 3, 4].includes(count % 10)) return 'правила';
        return 'правил';
    }

    function groupCountLabel(count) {
        const tail = count % 100;
        if (tail >= 11 && tail <= 14) return 'групп';
        if (count % 10 === 1) return 'группа';
        if ([2, 3, 4].includes(count % 10)) return 'группы';
        return 'групп';
    }

    function rowCountLabel(count) {
        const tail = count % 100;
        if (tail >= 11 && tail <= 14) return 'строк';
        if (count % 10 === 1) return 'строка';
        if ([2, 3, 4].includes(count % 10)) return 'строки';
        return 'строк';
    }
}
