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.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
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 AcademicPeriodInvariantMigrationIntegrationTest {

    private static final String YEAR_OVERLAP_CONSTRAINT = "ex_academic_years_no_overlap";
    private static final String SEMESTER_OVERLAP_CONSTRAINT = "ex_semesters_year_no_overlap";

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

    @Test
    @DisplayName("V1 разрешает соседние периоды и запрещает overlap или выход семестра за год")
    void migrationEnforcesAcademicPeriodInvariants() throws SQLException {
        Flyway flyway = flyway("1");
        flyway.clean();
        flyway.migrate();

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

        try (Connection connection = POSTGRES.createConnection("")) {
            long academicYearId = insertAcademicYear(
                    connection,
                    "2026-2027",
                    LocalDate.of(2026, 7, 1),
                    LocalDate.of(2027, 6, 30)
            );
            long autumnId = insertSemester(
                    connection,
                    academicYearId,
                    "autumn",
                    LocalDate.of(2026, 7, 1),
                    LocalDate.of(2027, 1, 31)
            );

            assertSqlState(
                    () -> insertAcademicYear(
                            connection,
                            "2027-overlap",
                            LocalDate.of(2027, 6, 30),
                            LocalDate.of(2028, 6, 30)
                    ),
                    "23P01",
                    YEAR_OVERLAP_CONSTRAINT
            );
            assertSqlState(
                    () -> insertSemester(
                            connection,
                            academicYearId,
                            "spring",
                            LocalDate.of(2026, 6, 30),
                            LocalDate.of(2027, 6, 30)
                    ),
                    "23514",
                    "Семестр должен полностью находиться в границах учебного года"
            );

            long springId = insertSemester(
                    connection,
                    academicYearId,
                    "spring",
                    LocalDate.of(2027, 2, 1),
                    LocalDate.of(2027, 6, 30)
            );
            assertSqlState(
                    () -> updateDate(
                            connection,
                            "UPDATE semesters SET start_date = ? WHERE id = ?",
                            LocalDate.of(2027, 1, 31),
                            springId
                    ),
                    "23P01",
                    SEMESTER_OVERLAP_CONSTRAINT
            );
            assertSqlState(
                    () -> updateDate(
                            connection,
                            "UPDATE academic_years SET start_date = ? WHERE id = ?",
                            LocalDate.of(2026, 7, 2),
                            academicYearId
                    ),
                    "23514",
                    "Новые границы учебного года не включают все его семестры"
            );

            assertThat(autumnId).isPositive();
            assertThat(constraintCount(connection, "academic_years", YEAR_OVERLAP_CONSTRAINT)).isOne();
            assertThat(constraintCount(connection, "semesters", SEMESTER_OVERLAP_CONSTRAINT)).isOne();
        }
    }

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

        ExecutorService executor = Executors.newFixedThreadPool(2);
        CountDownLatch ready = new CountDownLatch(2);
        CountDownLatch start = new CountDownLatch(1);
        try {
            List<Future<InsertOutcome>> futures = List.of(
                    executor.submit(() -> concurrentYearInsert(
                            "2090-2091-a",
                            LocalDate.of(2090, 9, 1),
                            LocalDate.of(2091, 6, 30),
                            ready,
                            start
                    )),
                    executor.submit(() -> concurrentYearInsert(
                            "2091-2092-b",
                            LocalDate.of(2091, 6, 30),
                            LocalDate.of(2092, 6, 30),
                            ready,
                            start
                    ))
            );

            assertThat(ready.await(5, TimeUnit.SECONDS)).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(YEAR_OVERLAP_CONSTRAINT);
                        }
                    });
        } finally {
            start.countDown();
            executor.shutdownNow();
        }
    }

    private InsertOutcome concurrentYearInsert(String title,
                                               LocalDate startDate,
                                               LocalDate endDate,
                                               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 {
                insertAcademicYear(connection, title, startDate, endDate);
                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 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 insertAcademicYear(Connection connection,
                                    String title,
                                    LocalDate startDate,
                                    LocalDate endDate) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                INSERT INTO academic_years (title, start_date, end_date)
                VALUES (?, ?, ?)
                RETURNING id
                """)) {
            statement.setString(1, title);
            statement.setDate(2, Date.valueOf(startDate));
            statement.setDate(3, Date.valueOf(endDate));
            try (ResultSet resultSet = statement.executeQuery()) {
                assertThat(resultSet.next()).isTrue();
                return resultSet.getLong(1);
            }
        }
    }

    private long insertSemester(Connection connection,
                                long academicYearId,
                                String semesterType,
                                LocalDate startDate,
                                LocalDate endDate) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                INSERT INTO semesters (
                    academic_year_id,
                    semester_type,
                    start_date,
                    end_date
                ) VALUES (?, ?, ?, ?)
                RETURNING id
                """)) {
            statement.setLong(1, academicYearId);
            statement.setString(2, semesterType);
            statement.setDate(3, Date.valueOf(startDate));
            statement.setDate(4, Date.valueOf(endDate));
            try (ResultSet resultSet = statement.executeQuery()) {
                assertThat(resultSet.next()).isTrue();
                return resultSet.getLong(1);
            }
        }
    }

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

    private long constraintCount(Connection connection,
                                 String tableName,
                                 String constraintName) throws SQLException {
        return queryLong(
                connection,
                "SELECT count(*) FROM pg_constraint WHERE conrelid = (?::TEXT)::regclass AND conname = ?",
                tableName,
                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) {
    }
}
