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 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<>(com.magistr.app.testing.TestContainerImages.POSTGRES);

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

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

        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("Ограничение чётности V1 должно быть провалидировано")
                    .isOne();

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

    private Flyway baselineFlyway() {
        return Flyway.configure()
                .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
                .locations("classpath:db/migration")
                .target(MigrationVersion.fromVersion("1"))
                .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 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 record SeedIds(long subjectId, long semesterId) {
    }
}
