-- ==========================================
-- Инициализация расширений
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- ===============================
-- Справочники высшего уровня
-- ===============================
CREATE TABLE IF NOT EXISTS departments (
    id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
    name VARCHAR(255) NOT NULL,
    code BIGINT UNIQUE NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

INSERT INTO departments (name, code) VALUES
    ('Кафедра ИБ', 1),
    ('Кафедра ВТ', 2),
    ('Кафедра КТ', 3);

CREATE TABLE IF NOT EXISTS specialties (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    specialty_code VARCHAR(255) UNIQUE NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

INSERT INTO specialties (name, specialty_code) VALUES
    ('Информационная безопасность', '10.03.01'),
    ('Информатика и вычислительная техника', '09.03.01'),
    ('Программная инженерия', '09.03.04')
ON CONFLICT (specialty_code) DO NOTHING;

CREATE TABLE IF NOT EXISTS specialty_profiles (
    id BIGSERIAL PRIMARY KEY,
    specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
    name VARCHAR(500) NOT NULL,
    description TEXT,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT,
    CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
);

INSERT INTO specialty_profiles (specialty_id, name)
SELECT id, 'Без профиля'
FROM specialties
ON CONFLICT (specialty_id, name) DO NOTHING;

-- ==========================================
-- Пользователи и роли
-- ==========================================
CREATE TABLE IF NOT EXISTS users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    role VARCHAR(30) NOT NULL DEFAULT 'STUDENT',
    full_name VARCHAR(255) NOT NULL,
    job_title VARCHAR(255) NOT NULL,
    department_id BIGINT NOT NULL REFERENCES departments(id),
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
       ('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
       ('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
       ('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
       ('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
ON CONFLICT (username) DO NOTHING;

CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    tenant VARCHAR(100) NOT NULL,
    token_hash VARCHAR(64) UNIQUE NOT NULL,
    issued_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMPTZ NOT NULL,
    revoked_at TIMESTAMPTZ,
    rotated_to_token_hash VARCHAR(64),
    user_agent VARCHAR(512),
    ip_address VARCHAR(64)
);

CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_user
    ON auth_refresh_tokens(user_id);

CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_tenant_expires
    ON auth_refresh_tokens(tenant, expires_at);

CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_cleanup_expires
    ON auth_refresh_tokens(expires_at);

CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_cleanup_revoked
    ON auth_refresh_tokens(revoked_at)
    WHERE revoked_at IS NOT NULL;

CREATE TABLE IF NOT EXISTS auth_login_rate_limits (
    id BIGSERIAL PRIMARY KEY,
    tenant VARCHAR(100) NOT NULL,
    username_normalized VARCHAR(100) NOT NULL,
    client_ip VARCHAR(64) NOT NULL,
    failure_count INTEGER NOT NULL DEFAULT 0,
    window_started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_failure_at TIMESTAMPTZ,
    blocked_until TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_auth_login_rate_limit_key
        UNIQUE (tenant, username_normalized, client_ip),
    CONSTRAINT chk_auth_login_rate_limit_failure_count
        CHECK (failure_count >= 0)
);

CREATE INDEX IF NOT EXISTS idx_auth_login_rate_limits_blocked_until
    ON auth_login_rate_limits(blocked_until)
    WHERE blocked_until IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_auth_login_rate_limits_updated_at
    ON auth_login_rate_limits(updated_at, id);

CREATE TABLE IF NOT EXISTS auth_login_attempt_audit (
    id BIGSERIAL PRIMARY KEY,
    tenant VARCHAR(100) NOT NULL,
    username_normalized VARCHAR(100) NOT NULL,
    client_ip VARCHAR(64) NOT NULL,
    outcome VARCHAR(20) NOT NULL,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    retry_after_seconds INTEGER,
    CONSTRAINT chk_auth_login_attempt_audit_outcome
        CHECK (outcome IN ('FAILURE', 'BLOCKED')),
    CONSTRAINT chk_auth_login_attempt_audit_retry_after
        CHECK (retry_after_seconds IS NULL OR retry_after_seconds > 0)
);

CREATE INDEX IF NOT EXISTS idx_auth_login_attempt_audit_occurred
    ON auth_login_attempt_audit(occurred_at, id);

CREATE INDEX IF NOT EXISTS idx_auth_login_attempt_audit_key
    ON auth_login_attempt_audit(tenant, username_normalized, client_ip, occurred_at DESC);

CREATE TABLE IF NOT EXISTS teacher_department_assignments (
    id BIGSERIAL PRIMARY KEY,
    teacher_id BIGINT NOT NULL REFERENCES users(id),
    department_id BIGINT NOT NULL REFERENCES departments(id),
    valid_from DATE NOT NULL,
    valid_to DATE,
    is_primary BOOLEAN NOT NULL DEFAULT TRUE,
    comment TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_by BIGINT REFERENCES users(id),
    CONSTRAINT chk_teacher_department_dates CHECK (valid_to IS NULL OR valid_to >= valid_from)
);

CREATE INDEX IF NOT EXISTS idx_teacher_department_teacher_dates
    ON teacher_department_assignments(teacher_id, valid_from, valid_to);

CREATE INDEX IF NOT EXISTS idx_teacher_department_department_dates
    ON teacher_department_assignments(department_id, valid_from, valid_to);

CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
    ON teacher_department_assignments(teacher_id)
    WHERE is_primary = TRUE AND valid_to IS NULL;

CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
    ON teacher_department_assignments(teacher_id, department_id)
    WHERE valid_to IS NULL;

INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u
WHERE u.role = 'TEACHER'
  AND NOT EXISTS (
      SELECT 1
      FROM teacher_department_assignments existing
      WHERE existing.teacher_id = u.id
  );

CREATE TABLE IF NOT EXISTS teacher_creation_requests (
    id BIGSERIAL PRIMARY KEY,
    department_id BIGINT NOT NULL REFERENCES departments(id),
    username VARCHAR(50) NOT NULL,
    full_name VARCHAR(255) NOT NULL,
    job_title VARCHAR(255) NOT NULL,
    comment TEXT,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    requested_by BIGINT REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    reviewed_by BIGINT REFERENCES users(id),
    reviewed_at TIMESTAMPTZ,
    review_comment TEXT,
    created_teacher_id BIGINT REFERENCES users(id),
    CONSTRAINT chk_teacher_creation_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED'))
);

CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_department
    ON teacher_creation_requests(department_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_status
    ON teacher_creation_requests(status, created_at DESC);

CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_creation_requests_pending_username
    ON teacher_creation_requests(username)
    WHERE status = 'PENDING';

-- ==========================================
-- Образовательные формы
-- ==========================================
CREATE TABLE IF NOT EXISTS education_forms (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL,
    description TEXT,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

INSERT INTO education_forms (name) VALUES 
    ('Бакалавриат'), 
    ('Магистратура'), 
    ('Специалитет')
ON CONFLICT (name) DO NOTHING;

-- ==========================================
-- Учебные группы
-- ==========================================
CREATE TABLE IF NOT EXISTS student_groups (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    group_size BIGINT NOT NULL
        CONSTRAINT chk_student_groups_group_size_positive CHECK (group_size > 0),
    education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
    department_id BIGINT NOT NULL REFERENCES departments(id),
    specialty_id BIGINT NOT NULL REFERENCES specialties(id),
    specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
    year_start_study BIGINT NOT NULL
        CONSTRAINT chk_student_groups_year_start_positive CHECK (year_start_study > 0),
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
FROM (VALUES
    ('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
    ('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
JOIN education_forms ef ON ef.name = data.education_form_name
JOIN departments dept ON dept.name = data.department_name
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);

-- ==========================================
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
-- ==========================================
CREATE TABLE IF NOT EXISTS subgroups (
    id BIGSERIAL PRIMARY KEY,
    group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    student_capacity INT NOT NULL
        CONSTRAINT chk_subgroups_student_capacity_positive CHECK (student_capacity > 0),
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT,
    UNIQUE(group_id, name)
);

CREATE OR REPLACE FUNCTION validate_student_group_subgroup_capacity()
RETURNS TRIGGER AS $$
DECLARE
    active_capacity BIGINT;
BEGIN
    SELECT COALESCE(SUM(student_capacity), 0)
    INTO active_capacity
    FROM subgroups
    WHERE group_id = NEW.id
      AND status <> 'ARCHIVED';

    IF active_capacity > NEW.group_size THEN
        RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы'
            USING ERRCODE = '23514',
                  CONSTRAINT = 'chk_student_group_subgroup_capacity';
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER validate_student_group_subgroup_capacity
    BEFORE UPDATE OF group_size ON student_groups
    FOR EACH ROW
    EXECUTE FUNCTION validate_student_group_subgroup_capacity();

CREATE OR REPLACE FUNCTION validate_subgroup_student_capacity()
RETURNS TRIGGER AS $$
DECLARE
    group_capacity BIGINT;
    allocated_capacity BIGINT;
BEGIN
    SELECT group_size
    INTO group_capacity
    FROM student_groups
    WHERE id = NEW.group_id
    FOR UPDATE;

    IF group_capacity IS NULL OR NEW.status = 'ARCHIVED' THEN
        RETURN NEW;
    END IF;

    IF TG_OP = 'INSERT' THEN
        SELECT COALESCE(SUM(student_capacity), 0)
        INTO allocated_capacity
        FROM subgroups
        WHERE group_id = NEW.group_id
          AND status <> 'ARCHIVED';
    ELSE
        SELECT COALESCE(SUM(student_capacity), 0)
        INTO allocated_capacity
        FROM subgroups
        WHERE group_id = NEW.group_id
          AND status <> 'ARCHIVED'
          AND id <> OLD.id;
    END IF;

    IF allocated_capacity + NEW.student_capacity > group_capacity THEN
        RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы'
            USING ERRCODE = '23514',
                  CONSTRAINT = 'chk_student_group_subgroup_capacity';
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER validate_subgroup_student_capacity
    BEFORE INSERT OR UPDATE OF group_id, student_capacity, status ON subgroups
    FOR EACH ROW
    EXECUTE FUNCTION validate_subgroup_student_capacity();

INSERT INTO subgroups (group_id, name, student_capacity)
SELECT
    sg.id,
    data.name,
    CASE
        WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
        ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
    END
FROM student_groups sg
CROSS JOIN (VALUES
    ('Подгруппа 1', 1),
    ('Подгруппа 2', 2)
) AS data(name, part_number)
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, name) DO NOTHING;

-- ==========================================
-- Справочники
-- ==========================================

-- Дисциплины
CREATE TABLE IF NOT EXISTS subjects (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    code VARCHAR(20),
    department_id BIGINT NOT NULL REFERENCES departments(id),
    description TEXT,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
    ON subjects (LOWER(name));
COMMENT ON INDEX uq_subjects_name_ci IS
    'Глобально уникальное название дисциплины без учёта регистра';

INSERT INTO subjects (name, department_id) VALUES 
    ('Высшая математика', 1), 
    ('Философия', 1), 
    ('Информатика', 1), 
    ('Базы данных', 1),
    ('Английский язык', 1)
ON CONFLICT DO NOTHING;

CREATE TABLE IF NOT EXISTS subject_comments (
    id BIGSERIAL PRIMARY KEY,
    subject_id BIGINT NOT NULL REFERENCES subjects(id),
    author_id BIGINT REFERENCES users(id),
    comment TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_subject_comments_subject
    ON subject_comments(subject_id, created_at DESC);

-- Типы занятий
CREATE TABLE IF NOT EXISTS lesson_types (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL,
    color_code VARCHAR(7) DEFAULT '#3788d8',
    duration_minutes INT DEFAULT 90
);

INSERT INTO lesson_types (name, color_code) VALUES 
    ('Лекция', '#FF6B6B'),
    ('Практика', '#4ECDC4'),
    ('Лабораторная работа', '#45B7D1')
ON CONFLICT (name) DO NOTHING;

-- Оборудование
CREATE TABLE IF NOT EXISTS equipments (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL,
    description TEXT,
    inventory_number VARCHAR(50),
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

INSERT INTO equipments (name) VALUES 
    ('Проектор'), 
    ('ПК'), 
    ('Лаборатория'),
    ('Интерактивная доска'),
    ('Документ-камера'),
    ('Аудиосистема')
ON CONFLICT (name) DO NOTHING;

-- Аудитории
CREATE TABLE IF NOT EXISTS classrooms (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL,
    capacity INT NOT NULL CHECK (capacity > 0),
    building VARCHAR(50),
    floor INT,
    is_available BOOLEAN DEFAULT TRUE,
    description TEXT,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    active_from DATE,
    active_to DATE,
    archived_at TIMESTAMPTZ,
    archived_by BIGINT,
    archive_reason TEXT
);

INSERT INTO classrooms (name, capacity, building, floor) VALUES 
    ('101 Ленинская', 120, 'Главный корпус', 1),
    ('202 IT Lab', 20, 'Корпус IT', 2),
    ('303 Обычная', 30, 'Главный корпус', 3)
ON CONFLICT (name) DO NOTHING;

-- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments (
    classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
    equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
    quantity INT DEFAULT 1 CHECK (quantity > 0),
    notes TEXT,
    PRIMARY KEY (classroom_id, equipment_id)
);

INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
SELECT c.id, e.id, 
    CASE 
        WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
        WHEN e.name = 'ПК' THEN 1
        ELSE 1
    END
FROM classrooms c, equipments e 
WHERE 
    (c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
    OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
    OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;

CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
CREATE INDEX IF NOT EXISTS idx_education_forms_status ON education_forms(status);
CREATE INDEX IF NOT EXISTS idx_student_groups_status ON student_groups(status);
CREATE INDEX IF NOT EXISTS idx_subgroups_status ON subgroups(status);
CREATE INDEX IF NOT EXISTS idx_subjects_status ON subjects(status);
CREATE INDEX IF NOT EXISTS idx_equipments_status ON equipments(status);
CREATE INDEX IF NOT EXISTS idx_classrooms_status ON classrooms(status);

-- ==========================================
-- Связи для преподавателей
-- ==========================================

CREATE TABLE IF NOT EXISTS teacher_subjects (
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
    qualification_level VARCHAR(50),
    experience_years INT,
    PRIMARY KEY(user_id, subject_id)
);

CREATE TABLE IF NOT EXISTS teacher_lesson_types (
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
    lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
    PRIMARY KEY (user_id, subject_id, lesson_type_id)
);

-- ==========================================
-- Динамическое расписание: календарь, слоты и правила
-- ==========================================

CREATE TABLE IF NOT EXISTS time_slot_scopes (
    id BIGSERIAL PRIMARY KEY,
    code VARCHAR(50) NOT NULL UNIQUE,
    name VARCHAR(120) NOT NULL,
    apply_mode VARCHAR(20) NOT NULL,
    day_of_week INT NULL,
    system_scope BOOLEAN NOT NULL DEFAULT FALSE,
    display_order INT NOT NULL DEFAULT 100,
    CONSTRAINT chk_time_slot_scopes_mode CHECK (apply_mode IN ('DEFAULT', 'WEEKDAY', 'MANUAL')),
    CONSTRAINT chk_time_slot_scopes_day CHECK (day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7),
    CONSTRAINT chk_time_slot_scopes_weekday CHECK (
        (apply_mode = 'WEEKDAY' AND day_of_week IS NOT NULL)
        OR (apply_mode <> 'WEEKDAY' AND day_of_week IS NULL)
    )
);

CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_default
    ON time_slot_scopes(apply_mode)
    WHERE apply_mode = 'DEFAULT';

CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
    ON time_slot_scopes(day_of_week)
    WHERE apply_mode = 'WEEKDAY';

INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
    ('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
    ('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
ON CONFLICT (code) DO NOTHING;

CREATE TABLE IF NOT EXISTS time_slots (
    id BIGSERIAL PRIMARY KEY,
    time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
    order_number INT NOT NULL,
    start_time TIME NOT NULL,
    end_time TIME NOT NULL,
    duration_minutes INT NOT NULL,
    CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0),
    CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time),
    CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0)
);

CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
    ON time_slots(time_slot_scope_id, order_number);

CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);

INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
FROM (VALUES
    (1, '08:00', '09:30', 90),
    (2, '09:40', '11:10', 90),
    (3, '11:40', '13:10', 90),
    (4, '13:20', '14:50', 90),
    (5, '15:00', '16:30', 90),
    (6, '16:50', '18:20', 90),
    (7, '18:30', '20:00', 90)
) AS data(order_number, start_time, end_time, duration_minutes)
CROSS JOIN time_slot_scopes scope
WHERE scope.code IN ('default', 'saturday')
ON CONFLICT DO NOTHING;

CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
    id BIGSERIAL PRIMARY KEY,
    assignment_date DATE NOT NULL UNIQUE,
    time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_time_slot_date_assignments_scope
    ON time_slot_date_assignments(time_slot_scope_id);

CREATE TABLE IF NOT EXISTS academic_years (
    id BIGSERIAL PRIMARY KEY,
    title VARCHAR(20) NOT NULL UNIQUE,
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
);

INSERT INTO academic_years (title, start_date, end_date) VALUES
    ('2024-2025', '2024-09-01', '2025-06-30'),
    ('2025-2026', '2025-09-01', '2026-06-30')
ON CONFLICT (title) DO NOTHING;

CREATE TABLE IF NOT EXISTS semesters (
    id BIGSERIAL PRIMARY KEY,
    academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
    semester_type VARCHAR(20) NOT NULL,
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    CONSTRAINT chk_semesters_type CHECK (semester_type IN ('autumn', 'spring')),
    CONSTRAINT chk_semesters_dates CHECK (start_date <= end_date),
    CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
);

INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;

INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;

CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);

CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
    id BIGSERIAL PRIMARY KEY,
    code VARCHAR(10) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL,
    allow_schedule BOOLEAN NOT NULL DEFAULT FALSE,
    color_code VARCHAR(7) NOT NULL DEFAULT '#64748b',
    display_order INT NOT NULL DEFAULT 100,
    description TEXT
);

INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
    ('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
    ('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
    ('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
    ('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
    ('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
    ('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
    ('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
    ('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
    ('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
    ('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
    ('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
    ('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
    ('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
ON CONFLICT (code) DO NOTHING;

CREATE TABLE IF NOT EXISTS academic_calendars (
    id BIGSERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
    specialty_id BIGINT NOT NULL REFERENCES specialties(id),
    specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
    study_form_id BIGINT NOT NULL REFERENCES education_forms(id),
    course_count INT NOT NULL DEFAULT 4,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT chk_academic_calendars_course_count CHECK (course_count BETWEEN 1 AND 8),
    CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title)
);

CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
    ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);

INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
SELECT
    specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
    ay.id,
    specialty.id,
    profile.id,
    study_form.id,
    4
FROM academic_years ay
CROSS JOIN education_forms study_form
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
WHERE ay.title = '2025-2026'
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;

CREATE TABLE IF NOT EXISTS academic_calendar_days (
    id BIGSERIAL PRIMARY KEY,
    calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
    course_number INT NOT NULL,
    date DATE NOT NULL,
    week_number INT NOT NULL,
    day_of_week INT NOT NULL,
    activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id),
    CONSTRAINT chk_calendar_days_course_positive CHECK (course_number > 0),
    CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0),
    CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7),
    CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date)
);

CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup
    ON academic_calendar_days(calendar_id, course_number, date);

INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id)
SELECT
    calendar.id,
    course.course_number,
    days.date::DATE,
    ((days.date::DATE - ay.start_date) / 7) + 1,
    EXTRACT(ISODOW FROM days.date::DATE)::INT,
    activity.id
FROM academic_calendars calendar
JOIN academic_years ay ON ay.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date)
ON CONFLICT (calendar_id, course_number, date) DO NOTHING;

CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
    id BIGSERIAL PRIMARY KEY,
    calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
    semester_number INT NOT NULL,
    subject_id BIGINT NOT NULL REFERENCES subjects(id),
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT chk_academic_calendar_subjects_semester_positive CHECK (semester_number > 0),
    CONSTRAINT uq_academic_calendar_subject UNIQUE (calendar_id, semester_number, subject_id)
);

CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_calendar_semester
    ON academic_calendar_subjects(calendar_id, semester_number);

CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_subject
    ON academic_calendar_subjects(subject_id);

CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
    id BIGSERIAL PRIMARY KEY,
    group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
    academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
    calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_group_calendar_year UNIQUE (group_id, academic_year_id)
);

CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
    ON student_group_calendar_assignments(group_id, academic_year_id);

INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
SELECT
    sg.id,
    ay.id,
    calendar.id
FROM student_groups sg
JOIN academic_years ay ON ay.title = '2025-2026'
JOIN academic_calendars calendar
    ON calendar.academic_year_id = ay.id
   AND calendar.specialty_id = sg.specialty_id
   AND calendar.specialty_profile_id = sg.specialty_profile_id
   AND calendar.study_form_id = sg.education_form_id
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, academic_year_id) DO NOTHING;

CREATE TABLE IF NOT EXISTS schedule_rules (
    id BIGSERIAL PRIMARY KEY,
    subject_id BIGINT NOT NULL REFERENCES subjects(id),
    semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
    lecture_academic_hours INT NOT NULL,
    laboratory_academic_hours INT NOT NULL,
    practice_academic_hours INT NOT NULL,
    lecture_start_week INT NOT NULL,
    laboratory_start_week INT NOT NULL,
    practice_start_week INT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    valid_from DATE,
    valid_to DATE,
    version_group_id BIGINT,
    change_reason TEXT,
    created_by BIGINT REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK (
        lecture_academic_hours >= 0
        AND laboratory_academic_hours >= 0
        AND practice_academic_hours >= 0
    ),
    CONSTRAINT chk_schedule_rules_has_type_hours CHECK (
        lecture_academic_hours + laboratory_academic_hours + practice_academic_hours > 0
    ),
    CONSTRAINT chk_schedule_rules_start_weeks_positive CHECK (
        lecture_start_week > 0
        AND laboratory_start_week > 0
        AND practice_start_week > 0
    ),
    CONSTRAINT chk_schedule_rules_dates CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from)
);

CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rules_subject ON schedule_rules(subject_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rules_status_dates
    ON schedule_rules(status, valid_from, valid_to);

CREATE TABLE IF NOT EXISTS schedule_rule_groups (
    schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
    group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
    PRIMARY KEY (schedule_rule_id, group_id)
);

CREATE INDEX IF NOT EXISTS idx_schedule_rule_groups_group ON schedule_rule_groups(group_id);

CREATE TABLE IF NOT EXISTS schedule_rule_slots (
    id BIGSERIAL PRIMARY KEY,
    schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
    day_of_week INT NOT NULL,
    parity VARCHAR(10) NOT NULL,
    time_slot_id BIGINT NOT NULL REFERENCES time_slots(id),
    teacher_id BIGINT NOT NULL REFERENCES users(id),
    classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
    lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
    lesson_format VARCHAR(30) NOT NULL,
    time_locked BOOLEAN NOT NULL DEFAULT FALSE,
    classroom_locked BOOLEAN NOT NULL DEFAULT FALSE,
    teacher_locked BOOLEAN NOT NULL DEFAULT FALSE,
    locked_by BIGINT REFERENCES users(id),
    locked_at TIMESTAMPTZ,
    lock_comment TEXT,
    CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7),
    CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')),
    CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн'))
);

CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id);

CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups (
    schedule_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id) ON DELETE CASCADE,
    subgroup_id BIGINT NOT NULL REFERENCES subgroups(id) ON DELETE CASCADE,
    PRIMARY KEY (schedule_rule_slot_id, subgroup_id)
);

CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id);

CREATE TABLE IF NOT EXISTS schedule_overrides (
    id BIGSERIAL PRIMARY KEY,
    base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
    lesson_date DATE NOT NULL,
    target_lesson_date DATE,
    action VARCHAR(20) NOT NULL,
    new_time_slot_id BIGINT REFERENCES time_slots(id),
    new_classroom_id BIGINT REFERENCES classrooms(id),
    new_teacher_id BIGINT REFERENCES users(id),
    new_lesson_format VARCHAR(30),
    comment TEXT,
    created_by BIGINT REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT chk_schedule_overrides_action CHECK (action IN ('MOVE', 'CANCEL', 'REPLACE')),
    CONSTRAINT uq_schedule_overrides_slot_date UNIQUE (base_rule_slot_id, lesson_date)
);

CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date
    ON schedule_overrides(lesson_date);

CREATE INDEX IF NOT EXISTS idx_schedule_overrides_target_date
    ON schedule_overrides(target_lesson_date)
    WHERE target_lesson_date IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher
    ON schedule_overrides(new_teacher_id);

CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups()
RETURNS TRIGGER AS $$
DECLARE
    lesson_type_name VARCHAR(50);
    rule_id BIGINT;
    subgroup_group_id BIGINT;
    has_group_duplicate BOOLEAN;
BEGIN
    SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name
    FROM schedule_rule_slots slot
    JOIN lesson_types type ON type.id = slot.lesson_type_id
    WHERE slot.id = NEW.schedule_rule_slot_id;

    IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
        RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
    END IF;

    SELECT group_id INTO subgroup_group_id
    FROM subgroups
    WHERE id = NEW.subgroup_id;

    IF subgroup_group_id IS NULL THEN
        RAISE EXCEPTION 'Подгруппа не найдена';
    END IF;

    IF NOT EXISTS (
        SELECT 1
        FROM schedule_rule_groups rule_group
        WHERE rule_group.schedule_rule_id = rule_id
          AND rule_group.group_id = subgroup_group_id
    ) THEN
        RAISE EXCEPTION 'Подгруппа должна относиться к одной из групп правила';
    END IF;

    IF TG_OP = 'UPDATE' THEN
        SELECT EXISTS (
            SELECT 1
            FROM schedule_rule_slot_subgroups slot_subgroup
            JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
            WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
              AND subgroup.group_id = subgroup_group_id
              AND NOT (
                  slot_subgroup.schedule_rule_slot_id = OLD.schedule_rule_slot_id
                  AND slot_subgroup.subgroup_id = OLD.subgroup_id
              )
        ) INTO has_group_duplicate;
    ELSE
        SELECT EXISTS (
            SELECT 1
            FROM schedule_rule_slot_subgroups slot_subgroup
            JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
            WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
              AND subgroup.group_id = subgroup_group_id
        ) INTO has_group_duplicate;
    END IF;

    IF has_group_duplicate THEN
        RAISE EXCEPTION 'В одном слоте можно выбрать не больше одной подгруппы каждой группы';
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER validate_schedule_rule_slot_subgroups
    BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups
    FOR EACH ROW
    EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();

CREATE TEMP TABLE tmp_default_rules AS
SELECT
    NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
    rule_key,
    subject_id,
    semester_id,
    lecture_academic_hours,
    laboratory_academic_hours,
    practice_academic_hours,
    lecture_start_week,
    laboratory_start_week,
    practice_start_week
FROM (
    SELECT
        data.rule_key,
        subj.id AS subject_id,
        sem.id AS semester_id,
        data.lecture_academic_hours,
        data.laboratory_academic_hours,
        data.practice_academic_hours,
        data.lecture_start_week,
        data.laboratory_start_week,
        data.practice_start_week
    FROM (VALUES
        ('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
        ('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
        ('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
        ('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
    ) AS data(
        rule_key,
        subject_name,
        year_title,
        semester_type,
        lecture_academic_hours,
        laboratory_academic_hours,
        practice_academic_hours,
        lecture_start_week,
        laboratory_start_week,
        practice_start_week
    )
    JOIN subjects subj ON subj.name = data.subject_name
    JOIN academic_years ay ON ay.title = data.year_title
    JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
) prepared_rules;

INSERT INTO schedule_rules (
    id,
    subject_id,
    semester_id,
    lecture_academic_hours,
    laboratory_academic_hours,
    practice_academic_hours,
    lecture_start_week,
    laboratory_start_week,
    practice_start_week
)
SELECT
    schedule_rule_id,
    subject_id,
    semester_id,
    lecture_academic_hours,
    laboratory_academic_hours,
    practice_academic_hours,
    lecture_start_week,
    laboratory_start_week,
    practice_start_week
FROM tmp_default_rules;

INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
SELECT
    rules.schedule_rule_id,
    sg.id
FROM (VALUES
    ('stream_math', 'ИВТ-21-1'),
    ('stream_math', 'ИБ-41м'),
    ('ivt_informatics_lab', 'ИВТ-21-1'),
    ('ivt_databases_practice', 'ИВТ-21-1'),
    ('ib_philosophy', 'ИБ-41м')
) AS data(rule_key, group_name)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN student_groups sg ON sg.name = data.group_name
ON CONFLICT DO NOTHING;

INSERT INTO schedule_rule_slots (
    schedule_rule_id,
    day_of_week,
    parity,
    time_slot_id,
    teacher_id,
    classroom_id,
    lesson_type_id,
    lesson_format
)
SELECT
    rules.schedule_rule_id,
    data.day_of_week,
    data.parity,
    ts.id,
    teacher.id,
    classroom.id,
    lt.id,
    data.lesson_format
FROM (VALUES
    ('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
    ('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
    ('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
    ('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN time_slot_scopes scope ON scope.code = 'default'
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
JOIN users teacher ON teacher.username = data.teacher_username
JOIN classrooms classroom ON classroom.name = data.classroom_name
JOIN lesson_types lt ON lt.name = data.lesson_type_name;

-- ==========================================
-- Функция обновления timestamp
-- ==========================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_users_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

CREATE TRIGGER update_academic_calendars_updated_at
    BEFORE UPDATE ON academic_calendars
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- ==========================================
-- Комментарии к таблицам и полям (для документации)
-- ==========================================
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
COMMENT ON TABLE departments IS 'Кафедры';
COMMENT ON TABLE specialties IS 'Специальности';
COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности';
COMMENT ON TABLE time_slot_scopes IS 'Именованные сетки времени: базовая, автоматическая субботняя и ручные сетки';
COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий';
COMMENT ON TABLE time_slot_date_assignments IS 'Ручное назначение сетки времени на конкретную дату';
COMMENT ON TABLE academic_years IS 'Учебные годы';
COMMENT ON TABLE semesters IS 'Семестры учебного года';
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика';
COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
COMMENT ON COLUMN schedule_overrides.lesson_date IS 'Исходная дата занятия, сформированного базовым правилом';
COMMENT ON COLUMN schedule_overrides.target_lesson_date IS 'Новая дата единственного занятия при действии MOVE';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре';
COMMENT ON COLUMN schedule_rules.laboratory_start_week IS 'Неделя начала лабораторных работ в семестре';
COMMENT ON COLUMN schedule_rules.practice_start_week IS 'Неделя начала практик в семестре';
COMMENT ON COLUMN schedule_rules.valid_from IS 'Дата начала действия версии правила расписания';
COMMENT ON COLUMN schedule_rules.valid_to IS 'Дата окончания действия версии правила расписания';

COMMENT ON TABLE education_forms IS 'Формы обучения';
COMMENT ON TABLE subgroups IS 'Подгруппы';
COMMENT ON TABLE lesson_types IS 'Типы занятий';
COMMENT ON TABLE equipments IS 'Оборудование';
COMMENT ON TABLE classrooms IS 'Аудитории';
COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям';
COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам';
COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя';

COMMENT ON COLUMN users.id IS 'ID пользователя';
COMMENT ON COLUMN users.username IS 'Логин пользователя';
COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя';
COMMENT ON COLUMN users.role IS 'Роль пользователя';
COMMENT ON COLUMN users.status IS 'Жизненный цикл пользователя: ACTIVE или ARCHIVED';
COMMENT ON COLUMN users.created_at IS 'Дата и время создания';
COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления';
COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
COMMENT ON COLUMN users.job_title IS 'Должность пользователя';
COMMENT ON COLUMN users.department_id IS 'ID кафедры';

COMMENT ON TABLE auth_refresh_tokens IS 'Отзывные refresh-сессии JWT. Сырые refresh-токены не хранятся, только SHA-256 хэш';
COMMENT ON COLUMN auth_refresh_tokens.user_id IS 'ID пользователя, которому выдан refresh-токен';
COMMENT ON COLUMN auth_refresh_tokens.tenant IS 'Тенант, в рамках которого выдан refresh-токен';
COMMENT ON COLUMN auth_refresh_tokens.token_hash IS 'SHA-256 хэш refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.issued_at IS 'Дата и время выдачи refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.expires_at IS 'Дата и время истечения refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.revoked_at IS 'Дата и время отзыва refresh-токена';
COMMENT ON COLUMN auth_refresh_tokens.rotated_to_token_hash IS 'Хэш следующего refresh-токена после ротации';
COMMENT ON COLUMN auth_refresh_tokens.user_agent IS 'User-Agent клиента при выдаче токена';
COMMENT ON COLUMN auth_refresh_tokens.ip_address IS 'IP-адрес клиента при выдаче токена';
COMMENT ON TABLE auth_login_rate_limits IS 'Общий для backend-pod счётчик неудачных попыток входа';
COMMENT ON COLUMN auth_login_rate_limits.username_normalized IS 'Нормализованное имя пользователя для ключа rate limit';
COMMENT ON COLUMN auth_login_rate_limits.client_ip IS 'IP клиента, принятый только от настроенного доверенного proxy';
COMMENT ON COLUMN auth_login_rate_limits.blocked_until IS 'Момент окончания временной блокировки комбинации tenant, username и IP';
COMMENT ON TABLE auth_login_attempt_audit IS 'Аудит отклонённых и заблокированных попыток входа без паролей';
COMMENT ON COLUMN auth_login_attempt_audit.outcome IS 'Результат попытки: FAILURE или BLOCKED';
COMMENT ON COLUMN auth_login_attempt_audit.retry_after_seconds IS 'Возвращённый клиенту срок Retry-After в секундах';

COMMENT ON COLUMN education_forms.id IS 'ID формы обучения';
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
COMMENT ON COLUMN education_forms.description IS 'Описание';
COMMENT ON COLUMN education_forms.created_at IS 'Дата и время создания';

COMMENT ON COLUMN student_groups.id IS 'ID учебной группы';
COMMENT ON COLUMN student_groups.name IS 'Название группы';
COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов';
COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа';
COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры';
COMMENT ON COLUMN student_groups.specialty_id IS 'ID специальности';
COMMENT ON COLUMN student_groups.specialty_profile_id IS 'ID профиля обучения';
COMMENT ON COLUMN student_groups.year_start_study IS 'Год начала обучения';
COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания';

COMMENT ON COLUMN subgroups.id IS 'ID подгруппы';
COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа';
COMMENT ON COLUMN subgroups.name IS 'Название подгруппы';
COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе';

COMMENT ON COLUMN subjects.id IS 'ID предмета';
COMMENT ON COLUMN subjects.name IS 'Название предмета';
COMMENT ON COLUMN subjects.code IS 'Код предмета';
COMMENT ON COLUMN subjects.department_id IS 'ID кафедры';
COMMENT ON COLUMN subjects.description IS 'Описание предмета';
COMMENT ON COLUMN subjects.created_at IS 'Дата и время создания';

COMMENT ON COLUMN lesson_types.id IS 'ID урока';
COMMENT ON COLUMN lesson_types.name IS 'Название типа урока';
COMMENT ON COLUMN lesson_types.color_code IS 'Цветовой код для типа урока';
COMMENT ON COLUMN lesson_types.duration_minutes IS 'Длительность урока в минутах';

COMMENT ON COLUMN equipments.id IS 'ID оборудования';
COMMENT ON COLUMN equipments.name IS 'Название оборудования';
COMMENT ON COLUMN equipments.description IS 'Описание оборудования';
COMMENT ON COLUMN equipments.inventory_number IS 'Инвентарный номер оборудования';

COMMENT ON COLUMN classrooms.id IS 'ID аудитории';
COMMENT ON COLUMN classrooms.name IS 'Название аудитории';
COMMENT ON COLUMN classrooms.capacity IS 'Вместимость аудитории';
COMMENT ON COLUMN classrooms.building IS 'Корпус';
COMMENT ON COLUMN classrooms.floor IS 'Этаж';
COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории';
COMMENT ON COLUMN classrooms.description IS 'Описание аудитории';
COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания';
COMMENT ON COLUMN classrooms.status IS 'Жизненный цикл аудитории: ACTIVE или ARCHIVED';

COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории';
COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования';
COMMENT ON COLUMN classroom_equipments.quantity IS 'Количество единиц оборудования';
COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи';

COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя';
COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета';
COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя';
COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания';

COMMENT ON COLUMN departments.id IS 'ID кафедры';
COMMENT ON COLUMN departments.name IS 'Название кафедры';
COMMENT ON COLUMN departments.code IS 'Код кафедры';

COMMENT ON COLUMN specialties.id IS 'ID специальности';
COMMENT ON COLUMN specialties.name IS 'Название специальности';
COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности';

COMMENT ON COLUMN specialty_profiles.id IS 'ID профиля обучения';
COMMENT ON COLUMN specialty_profiles.specialty_id IS 'ID специальности';
COMMENT ON COLUMN specialty_profiles.name IS 'Название профиля обучения';
COMMENT ON COLUMN specialty_profiles.description IS 'Описание профиля обучения';

COMMENT ON COLUMN academic_calendar_activity_types.code IS 'Код активности из календарного графика';
COMMENT ON COLUMN academic_calendar_activity_types.name IS 'Название активности';
COMMENT ON COLUMN academic_calendar_activity_types.allow_schedule IS 'Разрешает ли код генерацию обычного расписания';

COMMENT ON COLUMN academic_calendars.title IS 'Название календарного учебного графика';
COMMENT ON COLUMN academic_calendars.academic_year_id IS 'ID учебного года';
COMMENT ON COLUMN academic_calendars.specialty_id IS 'ID специальности';
COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля обучения';
COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms';
COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике';

COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса';
COMMENT ON COLUMN academic_calendar_days.date IS 'Дата';
COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году';
COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO';
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности';

COMMENT ON COLUMN academic_calendar_subjects.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_subjects.semester_number IS 'Номер учебного семестра внутри графика: 1, 2, 3 ...';
COMMENT ON COLUMN academic_calendar_subjects.subject_id IS 'ID дисциплины из справочника subjects';
COMMENT ON COLUMN academic_calendar_subjects.created_at IS 'Дата и время создания привязки';

COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы';
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года';
COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';

COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя';
COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета';
COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия';

-- ==========================================
-- Ограничения активных подгрупп
-- ==========================================
ALTER TABLE subgroups
    DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;

CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
    ON subgroups (group_id, lower(name))
    WHERE status <> 'ARCHIVED';

-- ==========================================
-- Инварианты точечных изменений расписания
-- ==========================================
DO $$
DECLARE
    cancel_violations BIGINT;
    move_violations BIGINT;
    replace_violations BIGINT;
    format_violations BIGINT;
    target_date_violations BIGINT;
BEGIN
    SELECT count(*)
    INTO cancel_violations
    FROM schedule_overrides
    WHERE action = 'CANCEL'
      AND (target_lesson_date IS NOT NULL
        OR new_time_slot_id IS NOT NULL
        OR new_classroom_id IS NOT NULL
        OR new_teacher_id IS NOT NULL
        OR new_lesson_format IS NOT NULL);

    SELECT count(*)
    INTO move_violations
    FROM schedule_overrides
    WHERE action = 'MOVE'
      AND new_time_slot_id IS NULL;

    SELECT count(*)
    INTO replace_violations
    FROM schedule_overrides
    WHERE action = 'REPLACE'
      AND (new_time_slot_id IS NOT NULL
        OR (new_teacher_id IS NULL
          AND new_classroom_id IS NULL
          AND new_lesson_format IS NULL));

    SELECT count(*)
    INTO format_violations
    FROM schedule_overrides
    WHERE new_lesson_format IS NOT NULL
      AND new_lesson_format NOT IN ('Очно', 'Онлайн');

    SELECT count(*)
    INTO target_date_violations
    FROM schedule_overrides
    WHERE target_lesson_date IS NOT NULL
      AND (target_lesson_date = lesson_date
        OR action <> 'MOVE'
        OR new_time_slot_id IS NULL);

    IF cancel_violations > 0
        OR move_violations > 0
        OR replace_violations > 0
        OR format_violations > 0
        OR target_date_violations > 0 THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = format(
                'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
                cancel_violations,
                move_violations,
                replace_violations,
                format_violations,
                target_date_violations
            );
    END IF;
END $$;

ALTER TABLE schedule_overrides
    ADD CONSTRAINT chk_schedule_overrides_cancel_payload
        CHECK (
            action <> 'CANCEL'
            OR (
                target_lesson_date IS NULL
                AND new_time_slot_id IS NULL
                AND new_classroom_id IS NULL
                AND new_teacher_id IS NULL
                AND new_lesson_format IS NULL
            )
        ) NOT VALID,
    ADD CONSTRAINT chk_schedule_overrides_move_payload
        CHECK (
            action <> 'MOVE'
            OR new_time_slot_id IS NOT NULL
        ) NOT VALID,
    ADD CONSTRAINT chk_schedule_overrides_replace_payload
        CHECK (
            action <> 'REPLACE'
            OR (
                new_time_slot_id IS NULL
                AND (
                    new_teacher_id IS NOT NULL
                    OR new_classroom_id IS NOT NULL
                    OR new_lesson_format IS NOT NULL
                )
            )
        ) NOT VALID,
    ADD CONSTRAINT chk_schedule_overrides_format
        CHECK (
            new_lesson_format IS NULL
            OR new_lesson_format IN ('Очно', 'Онлайн')
        ) NOT VALID,
    ADD CONSTRAINT chk_schedule_overrides_target_date
        CHECK (
            target_lesson_date IS NULL
            OR (
                target_lesson_date <> lesson_date
                AND action = 'MOVE'
                AND new_time_slot_id IS NOT NULL
            )
        ) NOT VALID;

ALTER TABLE schedule_overrides
    VALIDATE CONSTRAINT chk_schedule_overrides_cancel_payload;

ALTER TABLE schedule_overrides
    VALIDATE CONSTRAINT chk_schedule_overrides_move_payload;

ALTER TABLE schedule_overrides
    VALIDATE CONSTRAINT chk_schedule_overrides_replace_payload;

ALTER TABLE schedule_overrides
    VALIDATE CONSTRAINT chk_schedule_overrides_format;

ALTER TABLE schedule_overrides
    VALIDATE CONSTRAINT chk_schedule_overrides_target_date;

-- ==========================================
-- Инварианты правил расписания
-- ==========================================
DO $$
DECLARE
    lecture_violations BIGINT;
    laboratory_violations BIGINT;
    practice_violations BIGINT;
BEGIN
    SELECT count(*)
    INTO lecture_violations
    FROM schedule_rules
    WHERE mod(lecture_academic_hours, 2) <> 0;

    SELECT count(*)
    INTO laboratory_violations
    FROM schedule_rules
    WHERE mod(laboratory_academic_hours, 2) <> 0;

    SELECT count(*)
    INTO practice_violations
    FROM schedule_rules
    WHERE mod(practice_academic_hours, 2) <> 0;

    IF lecture_violations > 0
        OR laboratory_violations > 0
        OR practice_violations > 0 THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = format(
                'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
                lecture_violations,
                laboratory_violations,
                practice_violations
            );
    END IF;
END $$;

DO $$
DECLARE
    duplicate_slot_groups BIGINT;
BEGIN
    SELECT count(*)
    INTO duplicate_slot_groups
    FROM (
        SELECT
            schedule_rule_id,
            day_of_week,
            parity,
            time_slot_id,
            teacher_id,
            classroom_id,
            lesson_type_id,
            lesson_format
        FROM schedule_rule_slots
        GROUP BY
            schedule_rule_id,
            day_of_week,
            parity,
            time_slot_id,
            teacher_id,
            classroom_id,
            lesson_type_id,
            lesson_format
        HAVING count(*) > 1
    ) duplicates;

    IF duplicate_slot_groups > 0 THEN
        RAISE EXCEPTION USING
            ERRCODE = '23505',
            MESSAGE = format(
                'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
                duplicate_slot_groups
            );
    END IF;
END $$;

ALTER TABLE schedule_rules
    ADD CONSTRAINT chk_schedule_rules_academic_hours_even
        CHECK (
            mod(lecture_academic_hours, 2) = 0
            AND mod(laboratory_academic_hours, 2) = 0
            AND mod(practice_academic_hours, 2) = 0
        ) NOT VALID;

ALTER TABLE schedule_rules
    VALIDATE CONSTRAINT chk_schedule_rules_academic_hours_even;

ALTER TABLE schedule_rule_slots
    ADD CONSTRAINT uq_schedule_rule_slots_exact_payload
        UNIQUE (
            schedule_rule_id,
            day_of_week,
            parity,
            time_slot_id,
            teacher_id,
            classroom_id,
            lesson_type_id,
            lesson_format
        );

-- ==========================================
-- Инварианты временных слотов
-- ==========================================
DO $$
DECLARE
    duration_violations BIGINT;
    overlap_violations BIGINT;
    scope_violations BIGINT;
BEGIN
    SELECT count(*)
    INTO duration_violations
    FROM time_slots
    WHERE duration_minutes <> floor(extract(epoch FROM (end_time - start_time)) / 60)::INT;

    SELECT count(*)
    INTO overlap_violations
    FROM time_slots first_slot
    JOIN time_slots second_slot
      ON second_slot.time_slot_scope_id = first_slot.time_slot_scope_id
     AND second_slot.id > first_slot.id
     AND first_slot.start_time < second_slot.end_time
     AND second_slot.start_time < first_slot.end_time;

    SELECT count(*)
    INTO scope_violations
    FROM schedule_rule_slots rule_slot
    JOIN time_slots time_slot ON time_slot.id = rule_slot.time_slot_id
    JOIN time_slot_scopes scope ON scope.id = time_slot.time_slot_scope_id
    WHERE scope.apply_mode <> 'DEFAULT';

    IF duration_violations > 0
        OR overlap_violations > 0
        OR scope_violations > 0 THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = format(
                'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
                duration_violations,
                overlap_violations,
                scope_violations
            );
    END IF;
END $$;

CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE time_slots
    ADD CONSTRAINT chk_time_slots_duration_matches_range
        CHECK (
            duration_minutes = floor(extract(epoch FROM (end_time - start_time)) / 60)::INT
        ) NOT VALID;

ALTER TABLE time_slots
    VALIDATE CONSTRAINT chk_time_slots_duration_matches_range;

ALTER TABLE time_slots
    ADD CONSTRAINT ex_time_slots_scope_no_overlap
        EXCLUDE USING gist (
            time_slot_scope_id WITH =,
            (numrange(
                extract(epoch FROM start_time),
                extract(epoch FROM end_time),
                '[)'
            )) WITH &&
        );

CREATE OR REPLACE FUNCTION enforce_schedule_rule_slot_default_scope()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    target_apply_mode VARCHAR(20);
BEGIN
    SELECT scope.apply_mode
    INTO target_apply_mode
    FROM time_slots time_slot
    JOIN time_slot_scopes scope ON scope.id = time_slot.time_slot_scope_id
    WHERE time_slot.id = NEW.time_slot_id
    FOR KEY SHARE OF time_slot, scope;

    IF target_apply_mode IS NOT NULL AND target_apply_mode <> 'DEFAULT' THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'В правилах расписания можно использовать только слоты базовой сетки времени';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_schedule_rule_slots_default_scope
    BEFORE INSERT OR UPDATE OF time_slot_id
    ON schedule_rule_slots
    FOR EACH ROW
    EXECUTE FUNCTION enforce_schedule_rule_slot_default_scope();

CREATE OR REPLACE FUNCTION prevent_used_time_slot_scope_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    target_apply_mode VARCHAR(20);
BEGIN
    SELECT apply_mode
    INTO target_apply_mode
    FROM time_slot_scopes
    WHERE id = NEW.time_slot_scope_id;

    IF target_apply_mode <> 'DEFAULT'
        AND EXISTS (
            SELECT 1
            FROM schedule_rule_slots
            WHERE time_slot_id = OLD.id
        ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Слот, используемый в правилах расписания, нельзя переносить из базовой сетки времени';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_time_slots_protect_used_scope
    BEFORE UPDATE OF time_slot_scope_id
    ON time_slots
    FOR EACH ROW
    WHEN (OLD.time_slot_scope_id IS DISTINCT FROM NEW.time_slot_scope_id)
    EXECUTE FUNCTION prevent_used_time_slot_scope_change();

CREATE OR REPLACE FUNCTION prevent_used_time_slot_scope_mode_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.apply_mode <> 'DEFAULT'
        AND EXISTS (
            SELECT 1
            FROM time_slots time_slot
            JOIN schedule_rule_slots rule_slot ON rule_slot.time_slot_id = time_slot.id
            WHERE time_slot.time_slot_scope_id = OLD.id
        ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Сетку со слотами, используемыми в правилах расписания, нельзя сделать небазовой';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_time_slot_scopes_protect_used_mode
    BEFORE UPDATE OF apply_mode
    ON time_slot_scopes
    FOR EACH ROW
    WHEN (OLD.apply_mode IS DISTINCT FROM NEW.apply_mode)
    EXECUTE FUNCTION prevent_used_time_slot_scope_mode_change();

-- ==========================================
-- Инварианты учебных периодов
-- ==========================================
DO $$
DECLARE
    academic_year_overlap_violations BIGINT;
    semester_bounds_violations BIGINT;
    semester_overlap_violations BIGINT;
BEGIN
    SELECT count(*)
    INTO academic_year_overlap_violations
    FROM academic_years first_year
    JOIN academic_years second_year
      ON second_year.id > first_year.id
     AND first_year.start_date <= second_year.end_date
     AND second_year.start_date <= first_year.end_date;

    SELECT count(*)
    INTO semester_bounds_violations
    FROM semesters semester
    JOIN academic_years academic_year ON academic_year.id = semester.academic_year_id
    WHERE semester.start_date < academic_year.start_date
       OR semester.end_date > academic_year.end_date;

    SELECT count(*)
    INTO semester_overlap_violations
    FROM semesters first_semester
    JOIN semesters second_semester
      ON second_semester.academic_year_id = first_semester.academic_year_id
     AND second_semester.id > first_semester.id
     AND first_semester.start_date <= second_semester.end_date
     AND second_semester.start_date <= first_semester.end_date;

    IF academic_year_overlap_violations > 0
        OR semester_bounds_violations > 0
        OR semester_overlap_violations > 0 THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = format(
                'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
                academic_year_overlap_violations,
                semester_bounds_violations,
                semester_overlap_violations
            );
    END IF;
END $$;

ALTER TABLE academic_years
    ADD CONSTRAINT ex_academic_years_no_overlap
        EXCLUDE USING gist (
            (daterange(start_date, end_date, '[]')) WITH &&
        );

ALTER TABLE semesters
    ADD CONSTRAINT ex_semesters_year_no_overlap
        EXCLUDE USING gist (
            academic_year_id WITH =,
            (daterange(start_date, end_date, '[]')) WITH &&
        );

CREATE OR REPLACE FUNCTION enforce_semester_within_academic_year()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    academic_year_start DATE;
    academic_year_end DATE;
BEGIN
    SELECT start_date, end_date
    INTO academic_year_start, academic_year_end
    FROM academic_years
    WHERE id = NEW.academic_year_id
    FOR KEY SHARE;

    IF academic_year_start IS NOT NULL
        AND (
            NEW.start_date < academic_year_start
            OR NEW.end_date > academic_year_end
        ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Семестр должен полностью находиться в границах учебного года';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_semesters_within_academic_year
    BEFORE INSERT OR UPDATE OF academic_year_id, start_date, end_date
    ON semesters
    FOR EACH ROW
    EXECUTE FUNCTION enforce_semester_within_academic_year();

CREATE OR REPLACE FUNCTION prevent_academic_year_excluding_semesters()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF EXISTS (
        SELECT 1
        FROM semesters semester
        WHERE semester.academic_year_id = OLD.id
          AND (
              semester.start_date < NEW.start_date
              OR semester.end_date > NEW.end_date
          )
    ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Новые границы учебного года не включают все его семестры';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_academic_years_include_semesters
    BEFORE UPDATE OF start_date, end_date
    ON academic_years
    FOR EACH ROW
    WHEN (OLD.start_date IS DISTINCT FROM NEW.start_date OR OLD.end_date IS DISTINCT FROM NEW.end_date)
    EXECUTE FUNCTION prevent_academic_year_excluding_semesters();

-- ==========================================
-- Инварианты зависимостей календарных графиков
-- ==========================================
DO $$
DECLARE
    assignment_violations BIGINT;
    calendar_day_violations BIGINT;
    calendar_subject_violations BIGINT;
BEGIN
    SELECT count(*)
    INTO assignment_violations
    FROM student_group_calendar_assignments assignment
    JOIN student_groups student_group ON student_group.id = assignment.group_id
    JOIN academic_years academic_year ON academic_year.id = assignment.academic_year_id
    JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
    WHERE assignment.academic_year_id <> calendar.academic_year_id
       OR student_group.specialty_id <> calendar.specialty_id
       OR student_group.specialty_profile_id <> calendar.specialty_profile_id
       OR student_group.education_form_id <> calendar.study_form_id
       OR CASE
              WHEN extract(month FROM academic_year.start_date) >= 9
                  THEN extract(year FROM academic_year.start_date)::INT
                      - student_group.year_start_study::INT + 1
              ELSE extract(year FROM academic_year.start_date)::INT
                  - student_group.year_start_study::INT
          END NOT BETWEEN 1 AND calendar.course_count;

    SELECT count(*)
    INTO calendar_day_violations
    FROM academic_calendar_days calendar_day
    JOIN academic_calendars calendar ON calendar.id = calendar_day.calendar_id
    JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
    WHERE calendar_day.course_number > calendar.course_count
       OR calendar_day.date < academic_year.start_date
       OR calendar_day.date > academic_year.end_date;

    SELECT count(*)
    INTO calendar_subject_violations
    FROM academic_calendar_subjects calendar_subject
    JOIN academic_calendars calendar ON calendar.id = calendar_subject.calendar_id
    WHERE calendar_subject.semester_number > calendar.course_count * 2;

    IF assignment_violations > 0
        OR calendar_day_violations > 0
        OR calendar_subject_violations > 0 THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = format(
                'Невозможно создать baseline: несовместимые назначения графиков=%s, строки сетки вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
                assignment_violations,
                calendar_day_violations,
                calendar_subject_violations
            );
    END IF;
END $$;

CREATE OR REPLACE FUNCTION calculate_group_course_for_academic_year(
    academic_year_start DATE,
    group_start_year BIGINT
)
RETURNS INTEGER
LANGUAGE SQL
IMMUTABLE
STRICT
AS $$
    SELECT CASE
        WHEN extract(month FROM academic_year_start) >= 9
            THEN extract(year FROM academic_year_start)::INT - group_start_year::INT + 1
        ELSE extract(year FROM academic_year_start)::INT - group_start_year::INT
    END
$$;

CREATE OR REPLACE FUNCTION enforce_calendar_assignment_compatibility()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    calendar_academic_year_id BIGINT;
    calendar_specialty_id BIGINT;
    calendar_profile_id BIGINT;
    calendar_study_form_id BIGINT;
    calendar_course_count INTEGER;
    group_specialty_id BIGINT;
    group_profile_id BIGINT;
    group_study_form_id BIGINT;
    group_course INTEGER;
BEGIN
    SELECT
        calendar.academic_year_id,
        calendar.specialty_id,
        calendar.specialty_profile_id,
        calendar.study_form_id,
        calendar.course_count,
        student_group.specialty_id,
        student_group.specialty_profile_id,
        student_group.education_form_id,
        calculate_group_course_for_academic_year(
            academic_year.start_date,
            student_group.year_start_study
        )
    INTO
        calendar_academic_year_id,
        calendar_specialty_id,
        calendar_profile_id,
        calendar_study_form_id,
        calendar_course_count,
        group_specialty_id,
        group_profile_id,
        group_study_form_id,
        group_course
    FROM student_groups student_group
    CROSS JOIN academic_calendars calendar
    JOIN academic_years academic_year ON academic_year.id = NEW.academic_year_id
    WHERE student_group.id = NEW.group_id
      AND calendar.id = NEW.calendar_id
    FOR NO KEY UPDATE OF student_group
    FOR KEY SHARE OF calendar, academic_year;

    IF calendar_academic_year_id IS NOT NULL
        AND (
            NEW.academic_year_id <> calendar_academic_year_id
            OR group_specialty_id <> calendar_specialty_id
            OR group_profile_id <> calendar_profile_id
            OR group_study_form_id <> calendar_study_form_id
            OR group_course NOT BETWEEN 1 AND calendar_course_count
        ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Календарный график не соответствует учебному году, параметрам или курсу группы';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_calendar_assignments_compatible
    BEFORE INSERT OR UPDATE OF group_id, academic_year_id, calendar_id
    ON student_group_calendar_assignments
    FOR EACH ROW
    EXECUTE FUNCTION enforce_calendar_assignment_compatibility();

CREATE OR REPLACE FUNCTION prevent_incompatible_academic_calendar_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    academic_year_start DATE;
    academic_year_end DATE;
BEGIN
    SELECT start_date, end_date
    INTO academic_year_start, academic_year_end
    FROM academic_years
    WHERE id = NEW.academic_year_id
    FOR KEY SHARE;

    IF EXISTS (
        SELECT 1
        FROM student_group_calendar_assignments assignment
        JOIN student_groups student_group ON student_group.id = assignment.group_id
        JOIN academic_years assigned_year ON assigned_year.id = assignment.academic_year_id
        WHERE assignment.calendar_id = OLD.id
          AND (
              assignment.academic_year_id <> NEW.academic_year_id
              OR student_group.specialty_id <> NEW.specialty_id
              OR student_group.specialty_profile_id <> NEW.specialty_profile_id
              OR student_group.education_form_id <> NEW.study_form_id
              OR calculate_group_course_for_academic_year(
                     assigned_year.start_date,
                     student_group.year_start_study
                 ) NOT BETWEEN 1 AND NEW.course_count
          )
    ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Нельзя изменить график: сохранённые назначения групп станут несовместимыми';
    END IF;

    IF EXISTS (
        SELECT 1
        FROM academic_calendar_days calendar_day
        WHERE calendar_day.calendar_id = OLD.id
          AND (
              calendar_day.course_number > NEW.course_count
              OR calendar_day.date < academic_year_start
              OR calendar_day.date > academic_year_end
          )
    ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Нельзя изменить график: сохранённая сетка выходит за новые измерения';
    END IF;

    IF EXISTS (
        SELECT 1
        FROM academic_calendar_subjects calendar_subject
        WHERE calendar_subject.calendar_id = OLD.id
          AND calendar_subject.semester_number > NEW.course_count * 2
    ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Нельзя изменить график: дисциплины выходят за новые измерения';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_academic_calendars_protect_dependencies
    BEFORE UPDATE OF academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count
    ON academic_calendars
    FOR EACH ROW
    EXECUTE FUNCTION prevent_incompatible_academic_calendar_change();

CREATE OR REPLACE FUNCTION prevent_incompatible_student_group_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF EXISTS (
        SELECT 1
        FROM student_group_calendar_assignments assignment
        JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
        JOIN academic_years academic_year ON academic_year.id = assignment.academic_year_id
        WHERE assignment.group_id = OLD.id
          AND (
              assignment.academic_year_id <> calendar.academic_year_id
              OR NEW.specialty_id <> calendar.specialty_id
              OR NEW.specialty_profile_id <> calendar.specialty_profile_id
              OR NEW.education_form_id <> calendar.study_form_id
              OR calculate_group_course_for_academic_year(
                     academic_year.start_date,
                     NEW.year_start_study
                 ) NOT BETWEEN 1 AND calendar.course_count
          )
    ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_student_groups_protect_calendar_assignments
    BEFORE UPDATE OF education_form_id, specialty_id, specialty_profile_id, year_start_study
    ON student_groups
    FOR EACH ROW
    EXECUTE FUNCTION prevent_incompatible_student_group_change();

CREATE OR REPLACE FUNCTION enforce_calendar_day_dimensions()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    calendar_course_count INTEGER;
    academic_year_start DATE;
    academic_year_end DATE;
BEGIN
    SELECT calendar.course_count, academic_year.start_date, academic_year.end_date
    INTO calendar_course_count, academic_year_start, academic_year_end
    FROM academic_calendars calendar
    JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
    WHERE calendar.id = NEW.calendar_id
    FOR KEY SHARE OF calendar, academic_year;

    IF calendar_course_count IS NOT NULL
        AND (
            NEW.course_number > calendar_course_count
            OR NEW.date < academic_year_start
            OR NEW.date > academic_year_end
        ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Строка сетки выходит за измерения календарного графика';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_calendar_days_dimensions
    BEFORE INSERT OR UPDATE OF calendar_id, course_number, date
    ON academic_calendar_days
    FOR EACH ROW
    EXECUTE FUNCTION enforce_calendar_day_dimensions();

CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    calendar_course_count INTEGER;
BEGIN
    SELECT course_count
    INTO calendar_course_count
    FROM academic_calendars
    WHERE id = NEW.calendar_id
    FOR KEY SHARE;

    IF calendar_course_count IS NOT NULL
        AND NEW.semester_number > calendar_course_count * 2 THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Номер семестра дисциплины выходит за измерения календарного графика';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_calendar_subjects_dimensions
    BEFORE INSERT OR UPDATE OF calendar_id, semester_number
    ON academic_calendar_subjects
    FOR EACH ROW
    EXECUTE FUNCTION enforce_calendar_subject_dimensions();

CREATE OR REPLACE FUNCTION prevent_academic_year_breaking_calendar_dependencies()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF EXISTS (
        SELECT 1
        FROM academic_calendars calendar
        JOIN academic_calendar_days calendar_day ON calendar_day.calendar_id = calendar.id
        WHERE calendar.academic_year_id = OLD.id
          AND (
              calendar_day.date < NEW.start_date
              OR calendar_day.date > NEW.end_date
          )
    ) OR EXISTS (
        SELECT 1
        FROM student_group_calendar_assignments assignment
        JOIN student_groups student_group ON student_group.id = assignment.group_id
        JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
        WHERE assignment.academic_year_id = OLD.id
          AND calculate_group_course_for_academic_year(
                  NEW.start_date,
                  student_group.year_start_study
              ) NOT BETWEEN 1 AND calendar.course_count
    ) THEN
        RAISE EXCEPTION USING
            ERRCODE = '23514',
            MESSAGE = 'Новые границы учебного года нарушают календарные сетки или назначения групп';
    END IF;

    RETURN NEW;
END $$;

CREATE TRIGGER trg_academic_years_protect_calendar_dependencies
    BEFORE UPDATE OF start_date, end_date
    ON academic_years
    FOR EACH ROW
    WHEN (OLD.start_date IS DISTINCT FROM NEW.start_date OR OLD.end_date IS DISTINCT FROM NEW.end_date)
    EXECUTE FUNCTION prevent_academic_year_breaking_calendar_dependencies();

-- ==========================================
-- Историческая принадлежность преподавателей к кафедрам
-- ==========================================
ALTER TABLE teacher_department_assignments
    ADD CONSTRAINT ex_teacher_primary_department_no_overlap
        EXCLUDE USING gist (
            teacher_id WITH =,
            (daterange(valid_from, COALESCE(valid_to, 'infinity'::DATE), '[]')) WITH &&
        )
        WHERE (is_primary = TRUE);

COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap
    ON teacher_department_assignments
    IS 'У преподавателя не могут пересекаться периоды двух основных кафедр';
