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.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 ScheduleOverrideMigrationIntegrationTest {

    private static final String[] OVERRIDE_CONSTRAINTS = {
            "chk_schedule_overrides_cancel_payload",
            "chk_schedule_overrides_move_payload",
            "chk_schedule_overrides_replace_payload",
            "chk_schedule_overrides_format"
    };

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

    @Test
    @DisplayName("V3 принимает допустимые варианты и отклоняет недопустимые данные")
    void migrationEnforcesScheduleOverrideInvariants() throws SQLException {
        Flyway flyway = targetThreeFlyway();
        flyway.clean();
        flyway.migrate();

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

        try (Connection connection = POSTGRES.createConnection("")) {
            SeedIds seedIds = loadSeedIds(connection);
            LocalDate validDate = LocalDate.of(2026, 3, 2);

            insertOverride(connection, seedIds, validDate, "CANCEL", null, null, null);
            insertOverride(connection, seedIds, validDate.plusDays(1), "MOVE", seedIds.classroomId(), null, null);
            insertOverride(connection, seedIds, validDate.plusDays(2), "REPLACE", null, seedIds.teacherId(), null);
            insertOverride(connection, seedIds, validDate.plusDays(3), "REPLACE", null, null, "Онлайн");

            assertThat(queryLong(connection, "SELECT count(*) FROM schedule_overrides"))
                    .as("Все допустимые варианты точечного изменения должны сохраниться")
                    .isEqualTo(4L);

            LocalDate invalidDate = LocalDate.of(2026, 4, 6);
            assertCheckViolation(
                    connection,
                    seedIds,
                    invalidDate,
                    "CANCEL",
                    seedIds.classroomId(),
                    null,
                    null,
                    "chk_schedule_overrides_cancel_payload"
            );
            assertCheckViolation(
                    connection,
                    seedIds,
                    invalidDate.plusDays(1),
                    "MOVE",
                    null,
                    null,
                    null,
                    "chk_schedule_overrides_move_payload"
            );
            assertCheckViolation(
                    connection,
                    seedIds,
                    invalidDate.plusDays(2),
                    "REPLACE",
                    null,
                    null,
                    null,
                    "chk_schedule_overrides_replace_payload"
            );
            assertCheckViolation(
                    connection,
                    seedIds,
                    invalidDate.plusDays(3),
                    "REPLACE",
                    null,
                    seedIds.teacherId(),
                    "Смешанный",
                    "chk_schedule_overrides_format"
            );
        }
    }

    @Test
    @DisplayName("V3 останавливается на legacy-данных до добавления ограничений")
    void migrationFailsPreflightWithoutPartialConstraints() throws SQLException {
        Flyway targetTwo = targetTwoFlyway();
        targetTwo.clean();
        targetTwo.migrate();

        try (Connection connection = POSTGRES.createConnection("")) {
            SeedIds seedIds = loadSeedIds(connection);
            insertOverride(
                    connection,
                    seedIds,
                    LocalDate.of(2026, 5, 4),
                    "REPLACE",
                    null,
                    null,
                    null
            );
        }

        Flyway targetThree = targetThreeFlyway();
        Throwable migrationFailure = catchThrowable(targetThree::migrate);

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

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

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

    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 Flyway targetTwoFlyway() {
        return Flyway.configure()
                .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
                .locations("classpath:db/migration")
                .target(MigrationVersion.fromVersion("2"))
                .cleanDisabled(false)
                .load();
    }

    private SeedIds loadSeedIds(Connection connection) throws SQLException {
        return new SeedIds(
                queryLong(connection, "SELECT id FROM schedule_rule_slots ORDER BY id LIMIT 1"),
                queryLong(connection, "SELECT id FROM classrooms ORDER BY id LIMIT 1"),
                queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1")
        );
    }

    private void insertOverride(
            Connection connection,
            SeedIds seedIds,
            LocalDate lessonDate,
            String action,
            Long classroomId,
            Long teacherId,
            String lessonFormat
    ) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement("""
                INSERT INTO schedule_overrides (
                    base_rule_slot_id,
                    lesson_date,
                    action,
                    new_classroom_id,
                    new_teacher_id,
                    new_lesson_format
                ) VALUES (?, ?, ?, ?, ?, ?)
                """)) {
            statement.setLong(1, seedIds.baseRuleSlotId());
            statement.setDate(2, Date.valueOf(lessonDate));
            statement.setString(3, action);
            setNullableLong(statement, 4, classroomId);
            setNullableLong(statement, 5, teacherId);
            statement.setString(6, lessonFormat);
            statement.executeUpdate();
        }
    }

    private void assertCheckViolation(
            Connection connection,
            SeedIds seedIds,
            LocalDate lessonDate,
            String action,
            Long classroomId,
            Long teacherId,
            String lessonFormat,
            String expectedConstraint
    ) {
        Throwable failure = catchThrowable(() -> insertOverride(
                connection,
                seedIds,
                lessonDate,
                action,
                classroomId,
                teacherId,
                lessonFormat
        ));

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

    private void setNullableLong(PreparedStatement statement, int index, Long value) throws SQLException {
        if (value == null) {
            statement.setObject(index, null);
        } else {
            statement.setLong(index, value);
        }
    }

    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 baseRuleSlotId, long classroomId, long teacherId) {
    }
}
