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 TeacherDepartmentInvariantIntegrationTest {

    private static final String OVERLAP_CONSTRAINT = "ex_teacher_primary_department_no_overlap";

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

    @Test
    @DisplayName("V1 разрешает соседний перевод и совместительство, но запрещает пересечение основных кафедр")
    void baselineEnforcesTeacherDepartmentTimeline() throws SQLException {
        Flyway flyway = baselineFlyway();
        flyway.clean();
        flyway.migrate();

        try (Connection connection = POSTGRES.createConnection("")) {
            long teacherId = seedTeacherId(connection);
            long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
            long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
            updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31));

            insertAssignment(
                    connection,
                    teacherId,
                    secondDepartmentId,
                    LocalDate.of(2026, 8, 1),
                    null,
                    true
            );
            insertAssignment(
                    connection,
                    teacherId,
                    firstDepartmentId,
                    LocalDate.of(2026, 8, 1),
                    null,
                    false
            );

            assertSqlState(
                    () -> insertAssignment(
                            connection,
                            teacherId,
                            firstDepartmentId,
                            LocalDate.of(2026, 8, 15),
                            LocalDate.of(2026, 9, 1),
                            true
                    ),
                    "23P01",
                    OVERLAP_CONSTRAINT
            );
            assertThat(queryLong(
                    connection,
                    "SELECT count(*) FROM pg_constraint WHERE conname = ?",
                    OVERLAP_CONSTRAINT
            )).isOne();
        }
    }

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

        long teacherId;
        long firstDepartmentId;
        long secondDepartmentId;
        try (Connection connection = POSTGRES.createConnection("")) {
            teacherId = seedTeacherId(connection);
            firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
            secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
            updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31));
        }

        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(
                            teacherId,
                            firstDepartmentId,
                            LocalDate.of(2090, 1, 1),
                            LocalDate.of(2090, 12, 31),
                            ready,
                            start
                    )),
                    executor.submit(() -> concurrentInsert(
                            teacherId,
                            secondDepartmentId,
                            LocalDate.of(2090, 6, 1),
                            LocalDate.of(2091, 5, 31),
                            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(OVERLAP_CONSTRAINT);
                        }
                    });
        } finally {
            start.countDown();
            executor.shutdownNow();
        }
    }

    private InsertOutcome concurrentInsert(long teacherId,
                                           long departmentId,
                                           LocalDate validFrom,
                                           LocalDate validTo,
                                           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 {
                insertAssignment(connection, teacherId, departmentId, validFrom, validTo, true);
                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.configure()
                .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
                .locations("classpath:db/migration")
                .target(MigrationVersion.fromVersion("1"))
                .cleanDisabled(false)
                .load();
    }

    private long seedTeacherId(Connection connection) throws SQLException {
        return queryLong(
                connection,
                "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1"
        );
    }

    private void updateOpenPrimaryEnd(Connection connection,
                                      long teacherId,
                                      LocalDate validTo) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                UPDATE teacher_department_assignments
                SET valid_to = ?
                WHERE teacher_id = ?
                  AND is_primary = TRUE
                  AND valid_to IS NULL
                """)) {
            statement.setDate(1, Date.valueOf(validTo));
            statement.setLong(2, teacherId);
            assertThat(statement.executeUpdate()).isEqualTo(1);
        }
    }

    private void insertAssignment(Connection connection,
                                  long teacherId,
                                  long departmentId,
                                  LocalDate validFrom,
                                  LocalDate validTo,
                                  boolean primary) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                INSERT INTO teacher_department_assignments (
                    teacher_id,
                    department_id,
                    valid_from,
                    valid_to,
                    is_primary
                ) VALUES (?, ?, ?, ?, ?)
                """)) {
            statement.setLong(1, teacherId);
            statement.setLong(2, departmentId);
            statement.setDate(3, Date.valueOf(validFrom));
            statement.setObject(4, validTo == null ? null : Date.valueOf(validTo));
            statement.setBoolean(5, primary);
            statement.executeUpdate();
        }
    }

    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()).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) {
    }
}
