package tech.easyflow.skill.store;

import org.junit.Assume;
import org.junit.Test;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Locale;
import java.util.UUID;
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.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

/**
 * {@link DBSkillContentStore} 在真实 MySQL REPEATABLE READ 下的同 hash 并发锁序集成测试。
 *
 * <p>测试仅在 {@code EASYFLOW_MYSQL_CONCURRENCY_TEST=true} 时运行。每次创建独立随机数据库，
 * 并在 finally 中删除，避免接触开发库现有表或数据。</p>
 */
public class DBSkillContentStoreMySqlConcurrencyTest {

    private static final String CONTENT_REF = "sha256:" + "7".repeat(64);
    private static final String CONTENT_HASH = "7".repeat(64);

    /**
     * 验证竞争者先等待独立 intent 预留，前一写者仍能插入 active 索引并提交；竞争者随后
     * 复用 active 内容并原子删除自己的 intent，全程不出现内容 gap lock 等待环。
     *
     * @throws Exception JDBC、并发等待或清理失败
     */
    @Test
    public void reserveBeforeRetainAvoidsRepeatableReadGapLockCycle() throws Exception {
        Assume.assumeTrue("设置 EASYFLOW_MYSQL_CONCURRENCY_TEST=true 后运行真实 MySQL 并发门禁",
                Boolean.parseBoolean(System.getenv("EASYFLOW_MYSQL_CONCURRENCY_TEST")));

        String schema = "easyflow_skill_lock_" + UUID.randomUUID().toString().replace("-", "");
        String rootUrl = environment("EASYFLOW_MYSQL_TEST_ROOT_URL", "jdbc:mysql://127.0.0.1:33306/");
        if (!rootUrl.endsWith("/")) {
            rootUrl += "/";
        }
        String options = "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia%2FShanghai";
        String user = environment("EASYFLOW_MYSQL_TEST_USER", "root");
        String password = environment("EASYFLOW_MYSQL_TEST_PASSWORD", "root");

        try (Connection admin = DriverManager.getConnection(rootUrl + "mysql" + options, user, password)) {
            execute(admin, "CREATE DATABASE `" + schema + "` CHARACTER SET utf8mb4");
            try {
                runLockOrderScenario(rootUrl + schema + options, user, password);
            } finally {
                execute(admin, "DROP DATABASE IF EXISTS `" + schema + "`");
            }
        }
    }

    /**
     * 在隔离数据库中运行两个连接的 intent 等待与 active 内容提交场景。
     *
     * @param url 隔离数据库 JDBC URL
     * @param user 数据库账号
     * @param password 数据库密码
     * @throws Exception JDBC 或并发断言失败
     */
    private void runLockOrderScenario(String url, String user, String password) throws Exception {
        try (Connection setup = DriverManager.getConnection(url, user, password);
             Connection writer = DriverManager.getConnection(url, user, password);
             Connection contender = DriverManager.getConnection(url, user, password);
             Connection observer = DriverManager.getConnection(url, user, password)) {
            createTables(setup);
            assertRepeatableRead(writer);
            assertRepeatableRead(contender);
            execute(contender, "SET SESSION innodb_lock_wait_timeout=5");

            String writerToken = "writer-token";
            String contenderToken = "contender-token";
            insertIntent(writer, writerToken);

            writer.setAutoCommit(false);
            assertEquals(1, update(writer,
                    "UPDATE tb_skill_content_write_intent SET state='WRITING' "
                            + "WHERE content_ref=? AND reservation_token=? AND state='PENDING'",
                    CONTENT_REF, writerToken));

            long contenderConnectionId = connectionId(contender);
            CountDownLatch reserveStarted = new CountDownLatch(1);
            ExecutorService executor = Executors.newSingleThreadExecutor();
            try {
                Future<Integer> reserve = executor.submit(() -> {
                    reserveStarted.countDown();
                    return insertIntent(contender, contenderToken);
                });
                assertTrue("竞争者 reserve 未启动", reserveStarted.await(2, TimeUnit.SECONDS));
                awaitIntentLockWait(observer, contenderConnectionId);

                assertEquals(1, insertActive(writer));
                assertEquals(1, update(writer,
                        "DELETE FROM tb_skill_content_write_intent WHERE content_ref=? "
                                + "AND reservation_token=? AND EXISTS (SELECT 1 FROM tb_skill_content c "
                                + "WHERE c.content_ref=tb_skill_content_write_intent.content_ref "
                                + "AND c.ref_count>0)",
                        CONTENT_REF, writerToken));
                writer.commit();

                assertEquals("前一写者删除 intent 后竞争者应取得新预留", 1,
                        (int) reserve.get(5, TimeUnit.SECONDS));
            } finally {
                executor.shutdownNow();
            }

            contender.setAutoCommit(false);
            assertEquals(1, update(contender,
                    "UPDATE tb_skill_content SET ref_count=ref_count+1 WHERE content_ref=? "
                            + "AND size=? AND ref_count>0",
                    CONTENT_REF, 32L));
            assertEquals(1, update(contender,
                    "DELETE FROM tb_skill_content_write_intent WHERE content_ref=? "
                            + "AND reservation_token=? AND EXISTS (SELECT 1 FROM tb_skill_content c "
                            + "WHERE c.content_ref=tb_skill_content_write_intent.content_ref "
                            + "AND c.ref_count>0)",
                    CONTENT_REF, contenderToken));
            contender.commit();

            assertEquals(2, queryInt(setup,
                    "SELECT ref_count FROM tb_skill_content WHERE content_ref='" + CONTENT_REF + "'"));
            assertEquals(0, queryInt(setup, "SELECT COUNT(*) FROM tb_skill_content_write_intent"));
        }
    }

    /**
     * 创建与生产锁关键字段一致的最小测试表。
     *
     * @param connection 测试数据库连接
     * @throws SQLException DDL 失败
     */
    private void createTables(Connection connection) throws SQLException {
        execute(connection, "CREATE TABLE tb_skill_content ("
                + "content_ref VARCHAR(128) NOT NULL PRIMARY KEY,content_hash VARCHAR(128) NOT NULL,"
                + "file_path VARCHAR(2048) NOT NULL,storage_locator VARCHAR(2048) NULL,"
                + "media_type VARCHAR(128) NULL,size BIGINT NOT NULL,ref_count INT NOT NULL,"
                + "created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
                + "modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
                + ") ENGINE=InnoDB");
        execute(connection, "CREATE TABLE tb_skill_content_write_intent ("
                + "content_ref VARCHAR(128) NOT NULL PRIMARY KEY,reservation_token VARCHAR(128) NOT NULL,"
                + "content_hash VARCHAR(128) NOT NULL,storage_locator VARCHAR(2048) NOT NULL,"
                + "media_type VARCHAR(128) NULL,size BIGINT NOT NULL,state VARCHAR(16) NOT NULL,"
                + "created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
                + "modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
                + ") ENGINE=InnoDB");
    }

    /**
     * 插入一条 PENDING 写入意图。
     *
     * @param connection 执行连接
     * @param token 预留令牌
     * @return 插入行数
     * @throws SQLException 插入失败
     */
    private int insertIntent(Connection connection, String token) throws SQLException {
        return update(connection,
                "INSERT INTO tb_skill_content_write_intent(content_ref,reservation_token,content_hash,"
                        + "storage_locator,media_type,size,state) VALUES(?,?,?,?,?,?,'PENDING')",
                CONTENT_REF, token, CONTENT_HASH, "test-locator", "application/octet-stream", 32L);
    }

    /**
     * 插入首个活动内容索引。
     *
     * @param connection 写者事务连接
     * @return 插入行数
     * @throws SQLException 插入失败
     */
    private int insertActive(Connection connection) throws SQLException {
        return update(connection,
                "INSERT INTO tb_skill_content(content_ref,content_hash,file_path,storage_locator,"
                        + "media_type,size,ref_count) VALUES(?,?,?,?,?,?,1)",
                CONTENT_REF, CONTENT_HASH, "/attachment/test.bin", "test-locator",
                "application/octet-stream", 32L);
    }

    /**
     * 等待 performance_schema 确认竞争连接正在等待 intent 行锁。
     *
     * @param observer 观察连接
     * @param connectionId 竞争连接 ID
     * @throws Exception 查询失败或两秒内未观察到锁等待
     */
    private void awaitIntentLockWait(Connection observer, long connectionId) throws Exception {
        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
        while (System.nanoTime() < deadline) {
            try (PreparedStatement statement = observer.prepareStatement(
                    "SELECT COUNT(*) FROM performance_schema.data_lock_waits w "
                            + "JOIN performance_schema.threads t "
                            + "ON t.THREAD_ID=w.REQUESTING_THREAD_ID WHERE t.PROCESSLIST_ID=?")) {
                statement.setLong(1, connectionId);
                try (ResultSet result = statement.executeQuery()) {
                    if (result.next() && result.getInt(1) > 0) {
                        return;
                    }
                }
            }
            Thread.sleep(20L);
        }
        throw new AssertionError("未观察到竞争者对 intent 主键的锁等待");
    }

    /**
     * 断言连接使用 MySQL 默认的 REPEATABLE READ 隔离级别。
     *
     * @param connection 数据库连接
     * @throws SQLException 查询失败
     */
    private void assertRepeatableRead(Connection connection) throws SQLException {
        try (Statement statement = connection.createStatement();
             ResultSet result = statement.executeQuery("SELECT @@transaction_isolation")) {
            assertTrue(result.next());
            assertEquals("REPEATABLE-READ", result.getString(1).toUpperCase(Locale.ROOT));
        }
    }

    /**
     * 返回当前 JDBC 连接的 MySQL 连接 ID。
     *
     * @param connection 数据库连接
     * @return MySQL 连接 ID
     * @throws SQLException 查询失败
     */
    private long connectionId(Connection connection) throws SQLException {
        try (Statement statement = connection.createStatement();
             ResultSet result = statement.executeQuery("SELECT CONNECTION_ID()")) {
            if (!result.next()) {
                throw new SQLException("无法读取 MySQL CONNECTION_ID");
            }
            return result.getLong(1);
        }
    }

    /**
     * 执行无参数 SQL。
     *
     * @param connection 数据库连接
     * @param sql SQL 文本
     * @throws SQLException 执行失败
     */
    private void execute(Connection connection, String sql) throws SQLException {
        try (Statement statement = connection.createStatement()) {
            statement.execute(sql);
        }
    }

    /**
     * 执行参数化更新。
     *
     * @param connection 数据库连接
     * @param sql SQL 文本
     * @param parameters 绑定参数
     * @return 影响行数
     * @throws SQLException 执行失败
     */
    private int update(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]);
            }
            return statement.executeUpdate();
        }
    }

    /**
     * 查询单个整数。
     *
     * @param connection 数据库连接
     * @param sql SQL 文本
     * @return 第一列整数
     * @throws SQLException 查询失败
     */
    private int queryInt(Connection connection, String sql) throws SQLException {
        try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
            if (!result.next()) {
                throw new SQLException("查询未返回结果");
            }
            return result.getInt(1);
        }
    }

    /**
     * 读取非空环境变量或返回默认值。
     *
     * @param name 环境变量名
     * @param defaultValue 默认值
     * @return 配置值
     */
    private String environment(String name, String defaultValue) {
        String value = System.getenv(name);
        return value == null || value.isBlank() ? defaultValue : value.trim();
    }
}
