package tech.easyflow.skill.store;

import com.easyagents.skill.store.SkillContentStage;
import com.easyagents.skill.util.SkillHashes;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InOrder;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillContent;
import tech.easyflow.skill.entity.SkillContentWriteIntent;
import tech.easyflow.skill.mapper.SkillContentMapper;
import tech.easyflow.skill.mapper.SkillContentWriteIntentMapper;

import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * {@link DBSkillContentStore} 事务、恢复意图、引用计数与清理状态机测试。
 */
public class DBSkillContentStoreTest {

    private SkillContentMapper contentMapper;
    private SkillContentWriteIntentMapper writeIntentMapper;
    private FileStorageService fileStorageService;
    private PlatformTransactionManager transactionManager;
    private DBSkillContentStore contentStore;

    /**
     * 初始化隔离的存储依赖。
     */
    @Before
    public void setUp() {
        contentMapper = mock(SkillContentMapper.class);
        writeIntentMapper = mock(SkillContentWriteIntentMapper.class);
        fileStorageService = mock(FileStorageService.class);
        transactionManager = mock(PlatformTransactionManager.class);
        when(transactionManager.getTransaction(any(TransactionDefinition.class)))
                .thenAnswer(invocation -> new SimpleTransactionStatus());
        when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of());
        when(contentMapper.findStalePending(any(Date.class), anyInt())).thenReturn(List.of());
        when(contentMapper.findReleasedBefore(any(Date.class), anyInt())).thenReturn(List.of());
        contentStore = new DBSkillContentStore(
                contentMapper, writeIntentMapper, fileStorageService, transactionManager);
    }

    /**
     * 清除测试线程上的事务同步状态。
     */
    @After
    public void tearDown() {
        TransactionSynchronizationManager.clear();
    }

    /**
     * 验证新内容先提交恢复意图，再在业务事务中写文件、激活索引并原子删除意图。
     */
    @Test
    public void stagedContentCommitsRecoverableWriteIntentAndActiveIndex() {
        byte[] bytes = "stream-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        String contentRef = SkillHashes.sha256Ref(bytes);
        FileStorageWriteHandle handle = stubNewContentWrite(bytes, "/attachment/stream.bin");

        SkillContentStage stage = contentStore.stage(new ByteArrayInputStream(bytes), bytes.length);
        String actual = contentStore.commit(stage);

        assertEquals(contentRef, actual);
        verify(transactionManager, atLeastOnce()).getTransaction(any(TransactionDefinition.class));
        verify(transactionManager, atLeastOnce()).commit(any(TransactionStatus.class));
        verify(writeIntentMapper).reserve(eq(contentRef), anyString(), eq(contentHash(contentRef)),
                eq(handle.encodeLocator()), eq("application/octet-stream"), eq((long) bytes.length));
        verify(writeIntentMapper).claimForWrite(eq(contentRef), anyString());
        verify(fileStorageService).saveRecoverable(any(MultipartFile.class), eq(handle));
        verify(contentMapper).insertActive(eq(contentRef), eq(contentHash(contentRef)),
                eq("/attachment/stream.bin"), eq(handle.encodeLocator()),
                eq("application/octet-stream"), eq((long) bytes.length));
        verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString());
    }

    /**
     * 验证已完成内容通过 hash 与大小匹配的原子 retain 复用，不重复写物理文件。
     */
    @Test
    public void putExistingContentRetainsWithoutDuplicateFile() {
        byte[] bytes = "same-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        String contentRef = SkillHashes.sha256Ref(bytes);
        FileStorageWriteHandle handle = handleFor(contentRef);
        when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
        when(writeIntentMapper.reserve(
                eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
                .thenReturn(1);
        when(contentMapper.retainMatching(contentRef, bytes.length)).thenReturn(1);
        when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1);

        assertEquals(contentRef, contentStore.put(bytes));

        InOrder order = inOrder(writeIntentMapper, contentMapper);
        order.verify(writeIntentMapper).reserve(
                eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong());
        order.verify(contentMapper).retainMatching(contentRef, bytes.length);
        order.verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString());
        verify(contentMapper).retainMatching(contentRef, bytes.length);
        verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
    }

    /**
     * 验证同一内容引用出现不同大小时显式拒绝，不创建恢复意图。
     */
    @Test
    public void mismatchedExistingContentIsRejectedBeforePhysicalWrite() {
        byte[] bytes = "hash-collision-check".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        String contentRef = SkillHashes.sha256Ref(bytes);
        FileStorageWriteHandle handle = handleFor(contentRef);
        when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
        when(writeIntentMapper.reserve(
                eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
                .thenReturn(1);
        when(contentMapper.selectForUpdate(contentRef))
                .thenReturn(content(contentRef, "/attachment/existing.bin", null, 1, bytes.length + 1));
        when(writeIntentMapper.deletePending(eq(contentRef), anyString())).thenReturn(1);

        assertThrows(BusinessException.class, () -> contentStore.put(bytes));

        verify(writeIntentMapper).reserve(
                eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong());
        verify(writeIntentMapper).deletePending(eq(contentRef), anyString());
        verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
    }

    /**
     * 验证另一节点已持有写入意图时返回可重试冲突，且不会重复上传。
     */
    @Test
    public void concurrentWriteIntentPreventsDuplicatePhysicalWrite() {
        byte[] bytes = "pending-write".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        String contentRef = SkillHashes.sha256Ref(bytes);
        FileStorageWriteHandle handle = handleFor(contentRef);
        when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
        when(writeIntentMapper.reserve(
                eq(contentRef), anyString(), anyString(), anyString(), anyString(), anyLong()))
                .thenThrow(new DuplicateKeyException("duplicate intent"));

        assertThrows(BusinessException.class, () -> contentStore.put(bytes));

        verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
        verify(contentMapper).retainMatching(contentRef, bytes.length);
    }

    /**
     * 验证旧版零引用索引只有在物理大小与完整哈希重新校验通过后才可恢复。
     */
    @Test
    public void verifiedLegacyZeroReferenceContentCanBeResurrected() throws Exception {
        byte[] bytes = "verified-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        String contentRef = SkillHashes.sha256Ref(bytes);
        String filePath = "/legacy/verified.bin";
        FileStorageWriteHandle handle = handleFor(contentRef);
        SkillContent legacy = content(contentRef, filePath, null, 0, bytes.length);
        when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
        when(writeIntentMapper.reserve(
                eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
                .thenReturn(1);
        when(contentMapper.selectForUpdate(contentRef)).thenReturn(legacy);
        when(fileStorageService.getFileSize(filePath)).thenReturn((long) bytes.length);
        when(fileStorageService.readStream(filePath)).thenReturn(new ByteArrayInputStream(bytes));
        when(contentMapper.resurrectVerifiedLegacy(
                contentRef, contentHash(contentRef), filePath, bytes.length)).thenReturn(1);
        when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1);

        assertEquals(contentRef, contentStore.put(bytes));

        verify(contentMapper).resurrectVerifiedLegacy(
                contentRef, contentHash(contentRef), filePath, bytes.length);
        verify(writeIntentMapper).deleteIfActiveExists(eq(contentRef), anyString());
        verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
    }

    /**
     * 验证旧版物理内容哈希不一致时拒绝恢复，并立即删除尚未产生物理写入的 PENDING 意图。
     *
     * @throws Exception 模拟旧文件读取失败
     */
    @Test
    public void mismatchedLegacyPhysicalContentIsNotResurrected() throws Exception {
        byte[] bytes = "expected-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        byte[] changed = "tampered-legacy-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        String contentRef = SkillHashes.sha256Ref(bytes);
        String filePath = "/legacy/tampered.bin";
        FileStorageWriteHandle handle = handleFor(contentRef);
        SkillContent legacy = content(contentRef, filePath, null, 0, bytes.length);
        when(fileStorageService.prepareRecoverableWrite(anyString(), anyString())).thenReturn(handle);
        when(writeIntentMapper.reserve(
                eq(contentRef), anyString(), anyString(), eq(handle.encodeLocator()), anyString(), anyLong()))
                .thenReturn(1);
        when(contentMapper.selectForUpdate(contentRef)).thenReturn(legacy);
        when(fileStorageService.getFileSize(filePath)).thenReturn((long) bytes.length);
        when(fileStorageService.readStream(filePath)).thenReturn(new ByteArrayInputStream(changed));
        when(writeIntentMapper.deletePending(eq(contentRef), anyString())).thenReturn(1);

        assertThrows(BusinessException.class, () -> contentStore.put(bytes));

        verify(writeIntentMapper).deletePending(eq(contentRef), anyString());
        verify(contentMapper, never()).resurrectVerifiedLegacy(
                anyString(), anyString(), anyString(), anyLong());
        verify(fileStorageService, never()).saveRecoverable(any(MultipartFile.class), any());
    }

    /**
     * 验证索引激活失败时不删除恢复意图，事务回滚后可由定时任务精确回收物理对象。
     */
    @Test
    public void failedActiveInsertLeavesRecoverableIntentForCleanup() {
        byte[] bytes = "rollback-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        String contentRef = SkillHashes.sha256Ref(bytes);
        FileStorageWriteHandle handle = stubNewContentWrite(bytes, "/attachment/rollback.bin");
        when(contentMapper.insertActive(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()))
                .thenReturn(0);

        assertThrows(BusinessException.class, () -> contentStore.put(bytes));

        verify(transactionManager).rollback(any(TransactionStatus.class));
        verify(writeIntentMapper, never()).deleteIfActiveExists(eq(contentRef), anyString());
        verify(fileStorageService, never()).deleteRecoverable(handle);

        SkillContentWriteIntent stale = intent(contentRef, handle, "reservation", "PENDING", bytes.length);
        when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale));
        when(writeIntentMapper.deleteIfActiveExists(contentRef, "reservation")).thenReturn(0);
        when(writeIntentMapper.claimForCleanup(
                eq(contentRef), eq("reservation"), eq("PENDING"), any(Date.class))).thenReturn(1);
        when(writeIntentMapper.deleteClaimed(contentRef, "reservation")).thenReturn(1);

        contentStore.cleanupStaleContent(new Date(), 100);

        verify(fileStorageService).deleteRecoverable(handle);
        verify(fileStorageService).existsRecoverable(handle);
        verify(writeIntentMapper).deleteClaimed(contentRef, "reservation");
    }

    /**
     * 验证正式内容已存在时只删除残留意图，绝不删除正式物理对象。
     */
    @Test
    public void staleIntentWithActiveContentOnlyRemovesIntent() {
        String contentRef = "sha256:" + "a".repeat(64);
        FileStorageWriteHandle handle = handleFor(contentRef);
        SkillContentWriteIntent stale = intent(contentRef, handle, "active-token", "WRITING", 10);
        when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale));
        when(writeIntentMapper.deleteIfActiveExists(contentRef, "active-token")).thenReturn(1);

        contentStore.cleanupStaleContent(new Date(), 100);

        verify(writeIntentMapper, never()).claimForCleanup(
                anyString(), anyString(), anyString(), any(Date.class));
        verify(fileStorageService, never()).deleteRecoverable(any());
    }

    /**
     * 验证物理删除失败时保留 CLEANING 意图，后续轮次仍可重试。
     */
    @Test
    public void failedIntentPhysicalDeleteKeepsClaimedIntent() {
        String contentRef = "sha256:" + "b".repeat(64);
        FileStorageWriteHandle handle = handleFor(contentRef);
        SkillContentWriteIntent stale = intent(contentRef, handle, "retry-token", "CLEANING", 10);
        when(writeIntentMapper.findStale(any(Date.class), anyInt())).thenReturn(List.of(stale));
        when(writeIntentMapper.claimForCleanup(
                eq(contentRef), eq("retry-token"), eq("CLEANING"), any(Date.class))).thenReturn(1);
        doThrow(new RuntimeException("storage unavailable"))
                .when(fileStorageService).deleteRecoverable(handle);

        contentStore.cleanupStaleContent(new Date(), 100);

        verify(writeIntentMapper, never()).deleteClaimed(contentRef, "retry-token");
    }

    /**
     * 验证 PENDING 与零引用记录不会被读取、判断存在或重新持有。
     */
    @Test
    public void pendingAndZeroReferenceContentAreInvisible() throws Exception {
        String contentRef = "sha256:" + "c".repeat(64);
        when(contentMapper.countVisible(contentRef)).thenReturn(0);
        when(contentMapper.selectOneById(contentRef))
                .thenReturn(content(contentRef, "__PENDING__:reservation", null, 0, 12));
        when(contentMapper.retain(contentRef)).thenReturn(0);

        assertFalse(contentStore.exists(contentRef));
        assertThrows(BusinessException.class, () -> contentStore.open(contentRef));
        assertThrows(BusinessException.class, () -> contentStore.retain(contentRef));
        verify(fileStorageService, never()).readStream(anyString());
    }

    /**
     * 验证最后一份可恢复内容只在事务提交后删除物理对象与零引用索引。
     */
    @Test
    public void lastReleasePurgesRecoverableObjectOnlyAfterCommit() {
        String contentRef = "sha256:" + "d".repeat(64);
        String filePath = "/attachment/final.bin";
        FileStorageWriteHandle handle = handleFor(contentRef);
        SkillContent content = content(contentRef, filePath, handle.encodeLocator(), 1, 10);
        when(contentMapper.releaseShared(contentRef)).thenReturn(0);
        when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
        when(contentMapper.markReleased(contentRef, filePath, handle.encodeLocator())).thenReturn(1);
        TransactionSynchronizationManager.initSynchronization();

        contentStore.release(contentRef);
        List<TransactionSynchronization> synchronizations = currentSynchronizations();

        verify(fileStorageService, never()).deleteRecoverable(handle);
        synchronizations.forEach(TransactionSynchronization::afterCommit);
        verify(fileStorageService).deleteRecoverable(handle);
        verify(fileStorageService).existsRecoverable(handle);
        verify(contentMapper).deleteReleased(contentRef, filePath, handle.encodeLocator());
    }

    /**
     * 验证事务回滚不会触发最后引用的物理删除。
     */
    @Test
    public void lastReleaseRollbackNeverPurgesPhysicalObject() {
        String contentRef = "sha256:" + "e".repeat(64);
        FileStorageWriteHandle handle = handleFor(contentRef);
        SkillContent content = content(contentRef, "/attachment/rollback.bin", handle.encodeLocator(), 1, 10);
        when(contentMapper.releaseShared(contentRef)).thenReturn(0);
        when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
        when(contentMapper.markReleased(
                contentRef, content.getFilePath(), handle.encodeLocator())).thenReturn(1);
        TransactionSynchronizationManager.initSynchronization();

        contentStore.release(contentRef);
        currentSynchronizations().forEach(synchronization ->
                synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));

        verify(fileStorageService, never()).deleteRecoverable(any());
        verify(contentMapper, never()).deleteReleased(anyString(), anyString(), anyString());
    }

    /**
     * 验证缺少 locator 的旧内容释放后保留零引用索引，不执行无法证明正确的 URL 删除。
     */
    @Test
    public void legacyReleaseWithoutLocatorKeepsTrackedIndex() {
        String contentRef = "sha256:" + "f".repeat(64);
        SkillContent content = content(contentRef, "/legacy/random.bin", null, 1, 10);
        when(contentMapper.releaseShared(contentRef)).thenReturn(0);
        when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
        when(contentMapper.markReleased(contentRef, content.getFilePath(), null)).thenReturn(1);
        TransactionSynchronizationManager.initSynchronization();

        contentStore.release(contentRef);

        assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
        verify(fileStorageService, never()).delete(anyString());
        verify(fileStorageService, never()).deleteRecoverable(any());
        verify(contentMapper, never()).deleteReleased(anyString(), anyString(), anyString());
    }

    /**
     * 验证提交后删除失败会保留零引用索引，并由定时清理再次尝试。
     */
    @Test
    public void failedAfterCommitDeleteIsRetriedByCleanup() {
        String contentRef = "sha256:" + "1".repeat(64);
        FileStorageWriteHandle handle = handleFor(contentRef);
        SkillContent content = content(
                contentRef, "/attachment/retry.bin", handle.encodeLocator(), 1, 10);
        when(contentMapper.releaseShared(contentRef)).thenReturn(0);
        when(contentMapper.selectForUpdate(contentRef)).thenReturn(content);
        when(contentMapper.markReleased(
                contentRef, content.getFilePath(), handle.encodeLocator())).thenReturn(1);
        doThrow(new RuntimeException("storage unavailable"))
                .doNothing()
                .when(fileStorageService).deleteRecoverable(handle);
        TransactionSynchronizationManager.initSynchronization();

        contentStore.release(contentRef);
        currentSynchronizations().forEach(TransactionSynchronization::afterCommit);
        verify(contentMapper, never()).deleteReleased(
                contentRef, content.getFilePath(), handle.encodeLocator());

        content.setRefCount(0);
        when(contentMapper.findReleasedBefore(any(Date.class), anyInt())).thenReturn(List.of(content));
        contentStore.cleanupStaleContent(new Date(), 100);

        verify(fileStorageService, times(2)).deleteRecoverable(handle);
        verify(contentMapper).deleteReleased(contentRef, content.getFilePath(), handle.encodeLocator());
    }

    /**
     * 验证旧版超时 PENDING 占位仍通过条件删除安全回收。
     */
    @Test
    public void staleLegacyPendingReservationIsCleaned() {
        String contentRef = "sha256:" + "2".repeat(64);
        String pendingPath = "__PENDING__:stale";
        SkillContent pending = content(contentRef, pendingPath, null, 0, 10);
        when(contentMapper.findStalePending(any(Date.class), anyInt())).thenReturn(List.of(pending));

        contentStore.cleanupStaleContent(new Date(), 100);

        verify(contentMapper).deleteStalePending(eq(contentRef), eq(pendingPath), any(Date.class));
    }

    /**
     * 验证提交前会重新校验暂存内容，阻止内容被替换后写入错误 hash。
     *
     * @throws Exception 文件操作失败
     */
    @Test
    public void changedStageIsRejectedBeforeCommit() throws Exception {
        byte[] original = "original".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        SkillContentStage stage = contentStore.stage(new ByteArrayInputStream(original), original.length);
        Path stagePath = Path.of(stage.getStageId());
        Files.writeString(stagePath, "changed!");

        assertThrows(BusinessException.class, () -> contentStore.commit(stage));

        assertFalse(Files.exists(stagePath));
        verify(contentMapper, never()).retainMatching(anyString(), anyLong());
        verify(writeIntentMapper, never()).reserve(
                anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
    }

    /**
     * 配置一次成功的新内容意图、物理写入与索引激活。
     *
     * @param bytes 模拟内容
     * @param fileUrl 模拟读取 URL
     * @return 确定性恢复句柄
     */
    private FileStorageWriteHandle stubNewContentWrite(byte[] bytes, String fileUrl) {
        String contentRef = SkillHashes.sha256Ref(bytes);
        FileStorageWriteHandle handle = handleFor(contentRef);
        when(fileStorageService.prepareRecoverableWrite(
                "skill-content/" + contentHash(contentRef).substring(0, 2),
                contentHash(contentRef) + ".bin")).thenReturn(handle);
        when(writeIntentMapper.reserve(
                eq(contentRef), anyString(), eq(contentHash(contentRef)), eq(handle.encodeLocator()),
                anyString(), eq((long) bytes.length))).thenReturn(1);
        when(writeIntentMapper.claimForWrite(eq(contentRef), anyString())).thenReturn(1);
        when(fileStorageService.saveRecoverable(any(MultipartFile.class), eq(handle)))
                .thenReturn(new FileStorageWriteResult(fileUrl, handle.encodeLocator()));
        when(contentMapper.insertActive(
                eq(contentRef), eq(contentHash(contentRef)), eq(fileUrl), eq(handle.encodeLocator()),
                anyString(), eq((long) bytes.length))).thenReturn(1);
        when(writeIntentMapper.deleteIfActiveExists(eq(contentRef), anyString())).thenReturn(1);
        return handle;
    }

    /**
     * 为内容引用创建测试用确定性本地恢复句柄。
     *
     * @param contentRef 内容引用
     * @return 恢复句柄
     */
    private FileStorageWriteHandle handleFor(String contentRef) {
        String hash = contentHash(contentRef);
        return new FileStorageWriteHandle(
                "local", "", "/tmp/easyflow-content-test",
                "skill-content/" + hash.substring(0, 2), hash + ".bin");
    }

    /**
     * 创建指定状态的内容索引测试对象。
     *
     * @param contentRef 内容引用
     * @param filePath 文件或占位路径
     * @param locator 稳定恢复定位符
     * @param refCount 引用数
     * @param size 内容大小
     * @return 内容索引
     */
    private SkillContent content(String contentRef, String filePath, String locator, int refCount, long size) {
        SkillContent content = new SkillContent();
        content.setContentRef(contentRef);
        content.setContentHash(contentHash(contentRef));
        content.setFilePath(filePath);
        content.setStorageLocator(locator);
        content.setMediaType("application/octet-stream");
        content.setSize(size);
        content.setRefCount(refCount);
        content.setCreated(new Date());
        content.setModified(new Date());
        return content;
    }

    /**
     * 创建指定状态的写入意图测试对象。
     *
     * @param contentRef 内容引用
     * @param handle 恢复句柄
     * @param token 预留令牌
     * @param state 意图状态
     * @param size 内容大小
     * @return 写入意图
     */
    private SkillContentWriteIntent intent(
            String contentRef, FileStorageWriteHandle handle, String token, String state, long size) {
        SkillContentWriteIntent intent = new SkillContentWriteIntent();
        intent.setContentRef(contentRef);
        intent.setReservationToken(token);
        intent.setContentHash(contentHash(contentRef));
        intent.setStorageLocator(handle.encodeLocator());
        intent.setMediaType("application/octet-stream");
        intent.setSize(size);
        intent.setState(state);
        intent.setCreated(new Date(0));
        intent.setModified(new Date(0));
        return intent;
    }

    /**
     * 从标准内容引用取得十六进制哈希。
     *
     * @param contentRef 内容引用
     * @return 十六进制哈希
     */
    private String contentHash(String contentRef) {
        return contentRef.substring("sha256:".length());
    }

    /**
     * 获取当前测试事务已注册的同步回调。
     *
     * @return 同步回调副本
     */
    private List<TransactionSynchronization> currentSynchronizations() {
        List<TransactionSynchronization> synchronizations =
                new ArrayList<>(TransactionSynchronizationManager.getSynchronizations());
        assertTrue("应注册事务同步回调", !synchronizations.isEmpty());
        return synchronizations;
    }
}
