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.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

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

@Testcontainers
@DisplayName("Инварианты временных слотов базовой схемы")
class TimeSlotInvariantMigrationIntegrationTest {

    private static final String DURATION_CONSTRAINT = "chk_time_slots_duration_matches_range";
    private static final String OVERLAP_CONSTRAINT = "ex_time_slots_scope_no_overlap";

    @Container
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);

    @Test
    @DisplayName("V1 вычислительно связывает длительность, запрещает пересечения и защищает базовые слоты")
    void migrationEnforcesAllTimeSlotInvariants() throws SQLException {
        Flyway flyway = baselineFlyway();
        flyway.clean();
        flyway.migrate();

        assertThat(flyway.info().current()).isNotNull();
        assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");

        try (Connection connection = POSTGRES.createConnection("")) {
            long manualScopeId = insertManualScope(connection, "migration_checks");
            long firstSlotId = insertTimeSlot(connection, manualScopeId, 101, "08:00", "09:30", 90);
            insertTimeSlot(connection, manualScopeId, 102, "09:30", "11:00", 90);

            assertSqlState(
                    () -> insertTimeSlot(connection, manualScopeId, 103, "09:00", "10:00", 60),
                    "23P01",
                    OVERLAP_CONSTRAINT
            );
            assertSqlState(
                    () -> insertTimeSlot(connection, manualScopeId, 104, "11:10", "12:10", 30),
                    "23514",
                    DURATION_CONSTRAINT
            );

            long usedBaseSlotId = queryLong(
                    connection,
                    "SELECT time_slot_id FROM schedule_rule_slots ORDER BY id LIMIT 1"
            );
            assertSqlState(
                    () -> updateLong(
                            connection,
                            "UPDATE time_slots SET time_slot_scope_id = ? WHERE id = ?",
                            manualScopeId,
                            usedBaseSlotId
                    ),
                    "23514",
                    "нельзя переносить из базовой сетки"
            );
            assertSqlState(
                    () -> copyRuleSlotWithTimeSlot(connection, firstSlotId),
                    "23514",
                    "только слоты базовой сетки"
            );

            long defaultScopeId = queryLong(
                    connection,
                    "SELECT id FROM time_slot_scopes WHERE apply_mode = 'DEFAULT'"
            );
            assertSqlState(
                    () -> updateString(
                            connection,
                            "UPDATE time_slot_scopes SET apply_mode = ? WHERE id = ?",
                            "MANUAL",
                            defaultScopeId
                    ),
                    "23514",
                    "нельзя сделать небазовой"
            );

            assertThat(constraintCount(connection, DURATION_CONSTRAINT)).isOne();
            assertThat(constraintCount(connection, OVERLAP_CONSTRAINT)).isOne();
        }
    }

    @Test
    @DisplayName("V1 отклоняет одну из двух конкурентных вставок пересекающихся интервалов")
    void migrationProtectsConcurrentWrites() throws Exception {
        Flyway flyway = baselineFlyway();
        flyway.clean();
        flyway.migrate();

        long manualScopeId;
        try (Connection connection = POSTGRES.createConnection("")) {
            manualScopeId = insertManualScope(connection, "concurrent_slots");
        }

        ExecutorService executor = Executors.newFixedThreadPool(2);
        CountDownLatch ready = new CountDownLatch(2);
        CountDownLatch start = new CountDownLatch(1);
        try {
            List<Future<InsertOutcome>> futures = List.of(
                    executor.submit(() -> concurrentInsert(
                            manualScopeId, 301, "12:00", "13:30", ready, start)),
                    executor.submit(() -> concurrentInsert(
                            manualScopeId, 302, "13:00", "14:30", ready, start))
            );

            assertThat(ready.await(5, TimeUnit.SECONDS))
                    .as("Обе конкурентные транзакции должны подготовиться")
                    .isTrue();
            start.countDown();

            List<InsertOutcome> outcomes = List.of(
                    futures.get(0).get(10, TimeUnit.SECONDS),
                    futures.get(1).get(10, TimeUnit.SECONDS)
            );
            assertThat(outcomes).filteredOn(InsertOutcome::success).hasSize(1);
            assertThat(outcomes).filteredOn(outcome -> !outcome.success()).singleElement()
                    .satisfies(outcome -> {
                        assertThat(outcome.sqlState()).isIn("23P01", "40P01");
                        if ("23P01".equals(outcome.sqlState())) {
                            assertThat(outcome.message()).contains(OVERLAP_CONSTRAINT);
                        }
                    });
        } finally {
            start.countDown();
            executor.shutdownNow();
        }
    }

    private InsertOutcome concurrentInsert(long scopeId,
                                           int orderNumber,
                                           String startTime,
                                           String endTime,
                                           CountDownLatch ready,
                                           CountDownLatch start) {
        try (Connection connection = POSTGRES.createConnection("")) {
            connection.setAutoCommit(false);
            ready.countDown();
            if (!start.await(5, TimeUnit.SECONDS)) {
                return new InsertOutcome(false, null, "Не получен общий сигнал запуска");
            }
            try {
                insertTimeSlot(connection, scopeId, orderNumber, startTime, endTime, 90);
                connection.commit();
                return new InsertOutcome(true, null, null);
            } catch (SQLException exception) {
                connection.rollback();
                return new InsertOutcome(false, exception.getSQLState(), exception.getMessage());
            }
        } catch (Exception exception) {
            return new InsertOutcome(false, null, exception.getMessage());
        }
    }

    private Flyway baselineFlyway() {
        return flyway("1");
    }

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

    private long insertManualScope(Connection connection, String code) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                INSERT INTO time_slot_scopes (
                    code,
                    name,
                    apply_mode,
                    system_scope,
                    display_order
                ) VALUES (?, ?, 'MANUAL', FALSE, 900)
                RETURNING id
                """)) {
            statement.setString(1, code);
            statement.setString(2, "Тестовая ручная сетка");
            try (ResultSet resultSet = statement.executeQuery()) {
                assertThat(resultSet.next()).isTrue();
                return resultSet.getLong(1);
            }
        }
    }

    private long insertTimeSlot(Connection connection,
                                long scopeId,
                                int orderNumber,
                                String startTime,
                                String endTime,
                                int durationMinutes) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                INSERT INTO time_slots (
                    time_slot_scope_id,
                    order_number,
                    start_time,
                    end_time,
                    duration_minutes
                ) VALUES (?, ?, ?::TIME, ?::TIME, ?)
                RETURNING id
                """)) {
            statement.setLong(1, scopeId);
            statement.setInt(2, orderNumber);
            statement.setString(3, startTime);
            statement.setString(4, endTime);
            statement.setInt(5, durationMinutes);
            try (ResultSet resultSet = statement.executeQuery()) {
                assertThat(resultSet.next()).isTrue();
                return resultSet.getLong(1);
            }
        }
    }

    private void copyRuleSlotWithTimeSlot(Connection connection, long timeSlotId) 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,
                    ?,
                    teacher_id,
                    classroom_id,
                    lesson_type_id,
                    lesson_format
                FROM schedule_rule_slots
                ORDER BY id
                LIMIT 1
                """)) {
            statement.setLong(1, timeSlotId);
            statement.executeUpdate();
        }
    }

    private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement(sql)) {
            statement.setLong(1, value);
            statement.setLong(2, id);
            statement.executeUpdate();
        }
    }

    private void updateString(Connection connection, String sql, String value, long id) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement(sql)) {
            statement.setString(1, value);
            statement.setLong(2, id);
            statement.executeUpdate();
        }
    }

    private long constraintCount(Connection connection, String constraintName) throws SQLException {
        return queryLong(
                connection,
                "SELECT count(*) FROM pg_constraint WHERE conrelid = 'time_slots'::regclass AND conname = ?",
                constraintName
        );
    }

    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 void assertSqlState(ThrowingSqlRunnable operation,
                                String expectedSqlState,
                                String expectedMessagePart) {
        Throwable failure = catchThrowable(operation::run);
        assertThat(failure).isInstanceOf(SQLException.class);
        SQLException sqlFailure = (SQLException) failure;
        assertThat(sqlFailure.getSQLState()).isEqualTo(expectedSqlState);
        assertThat(sqlFailure.getMessage()).contains(expectedMessagePart);
    }

    @FunctionalInterface
    private interface ThrowingSqlRunnable {
        void run() throws SQLException;
    }

    private record InsertOutcome(boolean success, String sqlState, String message) {
    }
}
