package com.magistr.app.migration;

import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;

@Testcontainers
@DisplayName("Миграция чётности академических часов правил расписания")
class ScheduleRuleHoursMigrationIntegrationTest {

    private static final String HOURS_CONSTRAINT = "chk_schedule_rules_academic_hours_even";
    private static final String EXACT_SLOT_CONSTRAINT = "uq_schedule_rule_slots_exact_payload";

    @Container
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16.3-alpine");

    @Test
    @DisplayName("V4 принимает чётные часы и отклоняет нечётное значение каждого вида занятий")
    void migrationEnforcesEvenAcademicHours() throws SQLException {
        Flyway flyway = targetFourFlyway();
        flyway.clean();
        flyway.migrate();

        assertThat(flyway.info().current())
                .as("После полной миграции должна существовать текущая версия")
                .isNotNull();
        assertThat(flyway.info().current().getVersion().getVersion())
                .as("Последней успешно применённой миграцией должна быть V4")
                .isEqualTo("4");

        try (Connection connection = POSTGRES.createConnection("")) {
            SeedIds seedIds = loadSeedIds(connection);
            long rulesBeforeInsert = queryLong(connection, "SELECT count(*) FROM schedule_rules");

            insertScheduleRule(connection, seedIds, 2, 4, 6);

            assertThat(queryLong(connection, "SELECT count(*) FROM schedule_rules"))
                    .as("Правило с чётными академическими часами должно сохраниться")
                    .isEqualTo(rulesBeforeInsert + 1);
            assertThat(queryLong(
                    connection,
                    """
                            SELECT count(*)
                            FROM pg_constraint
                            WHERE conrelid = 'schedule_rules'::regclass
                              AND conname = ?
                              AND convalidated
                            """,
                    HOURS_CONSTRAINT
            ))
                    .as("Ограничение чётности V4 должно быть провалидировано")
                    .isOne();

            assertOddHoursRejected(connection, seedIds, 1, 2, 2, "лекций");
            assertOddHoursRejected(connection, seedIds, 2, 1, 2, "лабораторных работ");
            assertOddHoursRejected(connection, seedIds, 2, 2, 1, "практик");
            assertExactSlotDuplicateRejected(connection);
        }
    }

    @Test
    @DisplayName("V4 останавливается на legacy-правиле с нечётными часами до добавления ограничения")
    void migrationFailsPreflightWithoutPartialConstraint() throws SQLException {
        Flyway targetThree = targetThreeFlyway();
        targetThree.clean();
        targetThree.migrate();

        try (Connection connection = POSTGRES.createConnection("")) {
            SeedIds seedIds = loadSeedIds(connection);
            insertScheduleRule(connection, seedIds, 1, 3, 5);
        }

        Flyway targetFour = targetFourFlyway();
        Throwable migrationFailure = catchThrowable(targetFour::migrate);

        assertThat(migrationFailure)
                .as("V4 должна остановить миграцию при нечётных часах legacy-правила")
                .isNotNull();
        assertThat(collectMessages(migrationFailure))
                .as("Ошибка preflight должна объяснять проблему на русском языке")
                .contains("Невозможно применить инвариант чётности академических часов правил расписания")
                .contains("лекции=1")
                .contains("лабораторные=1")
                .contains("практики=1")
                .contains("Исправьте данные tenant-БД и повторите миграцию");

        assertThat(targetFour.info().current())
                .as("После отката V4 должна остаться текущая успешная версия")
                .isNotNull();
        assertThat(targetFour.info().current().getVersion().getVersion())
                .as("Последней успешно применённой миграцией должна остаться V3")
                .isEqualTo("3");

        try (Connection connection = POSTGRES.createConnection("")) {
            assertThat(queryLong(
                    connection,
                    "SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
            ))
                    .as("V4 не должна отмечаться как успешно применённая")
                    .isZero();
            assertThat(queryLong(
                    connection,
                    """
                            SELECT count(*)
                            FROM pg_constraint
                            WHERE conrelid = 'schedule_rules'::regclass
                              AND conname = ?
                            """,
                    HOURS_CONSTRAINT
            ))
                    .as("Ограничение V4 не должно частично остаться после неуспешного preflight")
                    .isZero();
        }
    }

    @Test
    @DisplayName("V4 останавливается на legacy-дубле слота до добавления ограничений")
    void migrationFailsOnLegacyExactSlotDuplicate() throws SQLException {
        Flyway targetThree = targetThreeFlyway();
        targetThree.clean();
        targetThree.migrate();

        try (Connection connection = POSTGRES.createConnection("")) {
            duplicateExistingSlot(connection);
        }

        Flyway targetFour = targetFourFlyway();
        Throwable migrationFailure = catchThrowable(targetFour::migrate);

        assertThat(migrationFailure)
                .as("V4 должна остановить миграцию при точном legacy-дубле слота")
                .isNotNull();
        assertThat(collectMessages(migrationFailure))
                .as("Ошибка preflight должна объяснять проблему уникальности на русском языке")
                .contains("Невозможно применить инвариант уникальности слотов правил расписания")
                .contains("групп точных дублей=1")
                .contains("Исправьте данные tenant-БД и повторите миграцию");

        assertThat(targetFour.info().current())
                .as("После отката V4 должна остаться текущая успешная версия")
                .isNotNull();
        assertThat(targetFour.info().current().getVersion().getVersion()).isEqualTo("3");

        try (Connection connection = POSTGRES.createConnection("")) {
            assertThat(queryLong(
                    connection,
                    "SELECT count(*) FROM flyway_schema_history WHERE success AND version = '4'"
            )).isZero();
            assertConstraintAbsent(connection, HOURS_CONSTRAINT);
            assertConstraintAbsent(connection, EXACT_SLOT_CONSTRAINT);
        }
    }

    private Flyway targetFourFlyway() {
        return Flyway.configure()
                .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
                .locations("classpath:db/migration")
                .target(MigrationVersion.fromVersion("4"))
                .cleanDisabled(false)
                .load();
    }

    private Flyway targetThreeFlyway() {
        return Flyway.configure()
                .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
                .locations("classpath:db/migration")
                .target(MigrationVersion.fromVersion("3"))
                .cleanDisabled(false)
                .load();
    }

    private SeedIds loadSeedIds(Connection connection) throws SQLException {
        return new SeedIds(
                queryLong(connection, "SELECT id FROM subjects ORDER BY id LIMIT 1"),
                queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1")
        );
    }

    private void insertScheduleRule(
            Connection connection,
            SeedIds seedIds,
            int lectureHours,
            int laboratoryHours,
            int practiceHours
    ) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                INSERT INTO schedule_rules (
                    subject_id,
                    semester_id,
                    lecture_academic_hours,
                    laboratory_academic_hours,
                    practice_academic_hours,
                    lecture_start_week,
                    laboratory_start_week,
                    practice_start_week
                ) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
                """)) {
            statement.setLong(1, seedIds.subjectId());
            statement.setLong(2, seedIds.semesterId());
            statement.setInt(3, lectureHours);
            statement.setInt(4, laboratoryHours);
            statement.setInt(5, practiceHours);
            statement.executeUpdate();
        }
    }

    private void assertOddHoursRejected(
            Connection connection,
            SeedIds seedIds,
            int lectureHours,
            int laboratoryHours,
            int practiceHours,
            String lessonKind
    ) {
        Throwable failure = catchThrowable(() -> insertScheduleRule(
                connection,
                seedIds,
                lectureHours,
                laboratoryHours,
                practiceHours
        ));

        assertThat(failure)
                .as("Нечётное количество часов %s должно быть отклонено", lessonKind)
                .isInstanceOf(SQLException.class);
        SQLException sqlFailure = (SQLException) failure;
        assertThat(sqlFailure.getSQLState())
                .as("Ограничение чётности для %s должно вернуть SQLSTATE нарушения CHECK", lessonKind)
                .isEqualTo("23514");
        assertThat(sqlFailure.getMessage())
                .as("Ошибка должна содержать имя ограничения чётности")
                .contains(HOURS_CONSTRAINT);
    }

    private void assertExactSlotDuplicateRejected(Connection connection) {
        Throwable failure = catchThrowable(() -> duplicateExistingSlot(connection));

        assertThat(failure)
                .as("Точный дубль слота одного правила должен быть отклонён")
                .isInstanceOf(SQLException.class);
        SQLException sqlFailure = (SQLException) failure;
        assertThat(sqlFailure.getSQLState())
                .as("Ограничение уникальности слота должно вернуть SQLSTATE unique violation")
                .isEqualTo("23505");
        assertThat(sqlFailure.getMessage()).contains(EXACT_SLOT_CONSTRAINT);
    }

    private void duplicateExistingSlot(Connection connection) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                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
                    schedule_rule_id,
                    day_of_week,
                    parity,
                    time_slot_id,
                    teacher_id,
                    classroom_id,
                    lesson_type_id,
                    lesson_format
                FROM schedule_rule_slots
                ORDER BY id
                LIMIT 1
                """)) {
            assertThat(statement.executeUpdate())
                    .as("Для проверки должен быть скопирован один seed-слот")
                    .isEqualTo(1);
        }
    }

    private void assertConstraintAbsent(Connection connection, String constraintName) throws SQLException {
        assertThat(queryLong(
                connection,
                """
                        SELECT count(*)
                        FROM pg_constraint
                        WHERE conrelid IN ('schedule_rules'::regclass, 'schedule_rule_slots'::regclass)
                          AND conname = ?
                        """,
                constraintName
        )).as("Ограничение %s не должно частично остаться после неуспешной V4", constraintName)
                .isZero();
    }

    private long queryLong(Connection connection, String sql, Object... parameters) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement(sql)) {
            for (int index = 0; index < parameters.length; index++) {
                statement.setObject(index + 1, parameters[index]);
            }
            try (ResultSet resultSet = statement.executeQuery()) {
                assertThat(resultSet.next())
                        .as("SQL-запрос должен вернуть одну строку: %s", sql)
                        .isTrue();
                return resultSet.getLong(1);
            }
        }
    }

    private String collectMessages(Throwable failure) {
        StringBuilder messages = new StringBuilder();
        Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
        Throwable current = failure;
        while (current != null && visited.add(current)) {
            if (current.getMessage() != null) {
                messages.append(current.getMessage()).append('\n');
            }
            current = current.getCause();
        }
        return messages.toString();
    }

    private record SeedIds(long subjectId, long semesterId) {
    }
}
