package tech.easyflow.agent.runtime.artifact;

import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import tech.easyflow.agent.config.AgentWorkspaceProperties;
import tech.easyflow.agent.entity.AgentArtifact;
import tech.easyflow.agent.mapper.AgentArtifactMapper;
import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;

import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.springframework.web.server.ResponseStatusException;

/**
 * Agent Artifact 发布、安全返回和失败补偿测试。
 */
public class AgentArtifactServiceTest {

    /** 临时工作区。 */
    @Rule
    public TemporaryFolder temporaryFolder = new TemporaryFolder();

    /**
     * 验证发布主链流式计算摘要并只返回安全字段。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldReturnSafeAvailableView() throws Exception {
        Path file = file("report.csv", "name,value\na,1\n");
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenAnswer(invocation -> {
                    invocation.<java.io.InputStream>getArgument(1).readAllBytes();
                    return "etag-1";
                });
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        AgentArtifactView view = fixture.service.publish(
                file.getParent(), "report.csv", null, AgentArtifactService.MODE_DRAFT, context("draft-session"));

        Assert.assertEquals("AVAILABLE", view.status());
        Assert.assertEquals(Files.size(file), view.size());
        Assert.assertEquals(64, view.sha256().length());
        Assert.assertEquals("text/plain", view.mimeType());
        Map<String, Object> safe = view.toMap();
        Assert.assertFalse(safe.containsKey("objectKey"));
        Assert.assertFalse(safe.containsKey("storagePlatform"));
        Assert.assertTrue(view.downloadUrl().contains(view.artifactId()));
        ArgumentCaptor<AgentArtifact> inserted = ArgumentCaptor.forClass(AgentArtifact.class);
        Mockito.verify(fixture.mapper).insert(inserted.capture());
        Assert.assertTrue(inserted.getValue().getNextRetryAt().after(new java.util.Date()));
    }

    /**
     * 验证对象已写入但状态提交失败、补偿删除也失败时进入可重试删除失败态。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldRecordDeleteFailedWhenCompensationDeleteFails() throws Exception {
        Path file = file("report.txt", "content");
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenAnswer(invocation -> {
                    invocation.<java.io.InputStream>getArgument(1).readAllBytes();
                    return "etag-1";
                });
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(0);
        Mockito.doThrow(new AgentArtifactOperationException("STORAGE", "delete failed", true))
                .when(fixture.storage).delete(Mockito.anyString());

        Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
                file.getParent(), "report.txt", null, AgentArtifactService.MODE_DRAFT, context("draft-session")));

        ArgumentCaptor<AgentArtifact> updates = ArgumentCaptor.forClass(AgentArtifact.class);
        Mockito.verify(fixture.mapper, Mockito.atLeast(2)).updateByQuery(updates.capture(), Mockito.any());
        AgentArtifact compensation = updates.getAllValues().get(updates.getAllValues().size() - 1);
        Assert.assertEquals(AgentArtifactStatus.DELETE_FAILED.name(), compensation.getStatus());
        Assert.assertNotNull(compensation.getNextRetryAt());
    }

    /**
     * 验证对象可能已经写入但 put 响应抛错时仍会执行幂等补偿删除。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldCompensateWhenPutThrowsAfterPossibleWrite() throws Exception {
        Path file = file("uncertain.txt", "content");
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenThrow(new AgentArtifactOperationException("STORAGE_TIMEOUT", "response lost", true));
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
                file.getParent(), "uncertain.txt", null,
                AgentArtifactService.MODE_DRAFT, context("draft-session")));

        Mockito.verify(fixture.storage).delete(Mockito.anyString());
        ArgumentCaptor<AgentArtifact> update = ArgumentCaptor.forClass(AgentArtifact.class);
        Mockito.verify(fixture.mapper).updateByQuery(update.capture(), Mockito.any());
        Assert.assertEquals(AgentArtifactStatus.FAILED.name(), update.getValue().getStatus());
    }

    /**
     * 验证展示扩展名不能伪造 MIME，未知二进制安全回退为 octet-stream。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldDetectMimeFromContentInsteadOfExtension() throws Exception {
        Path file = temporaryFolder.newFile("forged.png").toPath();
        Files.write(file, new byte[]{0, 1, 2, 3});
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenAnswer(invocation -> {
                    invocation.<java.io.InputStream>getArgument(1).readAllBytes();
                    return "etag-1";
                });
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        AgentArtifactView view = fixture.service.publish(
                file.getParent(), "forged.png", null, AgentArtifactService.MODE_DRAFT, context("draft-session"));

        Assert.assertEquals("application/octet-stream", view.mimeType());
    }

    /**
     * 验证正式产物只从可信 RuntimeContext 绑定当前聊天轮次。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void formalPublishShouldPersistTrustedRoundId() throws Exception {
        Path file = file("report.txt", "content");
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenAnswer(invocation -> {
                    invocation.<java.io.InputStream>getArgument(1).readAllBytes();
                    return "etag-1";
                });
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
        AgentToolContext context = context("100");
        context.getRuntimeContext().getMetadata().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, "200");
        context.getRuntimeContext().getMetadata().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 1);

        fixture.service.publish(file.getParent(), "report.txt", null,
                AgentArtifactService.MODE_FORMAL, context);

        ArgumentCaptor<AgentArtifact> inserted = ArgumentCaptor.forClass(AgentArtifact.class);
        Mockito.verify(fixture.mapper).insert(inserted.capture());
        Assert.assertEquals(BigInteger.valueOf(100), inserted.getValue().getChatSessionId());
        Assert.assertEquals(BigInteger.valueOf(200), inserted.getValue().getRoundId());
        Assert.assertEquals(Integer.valueOf(1), inserted.getValue().getVariantIndex());
    }

    /**
     * 验证历史投影只查询一次账本，并以当前状态覆盖旧 AVAILABLE 快照。
     */
    @Test
    public void historyProjectionShouldUseCurrentLedgerStatusWithoutStorageFields() {
        Fixture fixture = fixture(Path.of("unused"));
        Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(
                ledger("available", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(200)),
                ledger("deleted", AgentArtifactStatus.DELETED, BigInteger.valueOf(200)),
                ledger("delete-failed", AgentArtifactStatus.DELETE_FAILED, BigInteger.valueOf(200))));
        ChatMessageRecord message = new ChatMessageRecord();
        message.setSessionId(BigInteger.valueOf(100));
        message.setRoundId(BigInteger.valueOf(200));
        message.setSenderRole("assistant");
        message.setVariantIndex(1);
        message.setContentPayload(new LinkedHashMap<>(Map.of("artifacts", List.of(
                oldView("available"), oldView("deleted"), oldView("delete-failed"), oldView("missing")))));

        fixture.service.projectHistoryArtifacts(
                List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));

        Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> projected =
                (List<Map<String, Object>>) message.getContentPayload().get("artifacts");
        Assert.assertEquals("AVAILABLE", projected.get(0).get("status"));
        Assert.assertNotNull(projected.get(0).get("downloadUrl"));
        Assert.assertEquals("DELETED", projected.get(1).get("status"));
        Assert.assertNull(projected.get(1).get("downloadUrl"));
        Assert.assertEquals("DELETE_FAILED", projected.get(2).get("status"));
        Assert.assertNull(projected.get(2).get("downloadUrl"));
        Assert.assertEquals("UNAVAILABLE", projected.get(3).get("status"));
        Assert.assertNull(projected.get(3).get("downloadUrl"));
        for (Map<String, Object> item : projected) {
            Assert.assertFalse(item.containsKey("objectKey"));
            Assert.assertFalse(item.containsKey("storagePlatform"));
        }
    }

    /**
     * 验证 AVAILABLE 已落账但消息 payload 尚未持久化时可按当前页轮次补回。
     */
    @Test
    public void historyProjectionShouldRecoverLedgerArtifactMissingFromPayload() {
        Fixture fixture = fixture(Path.of("unused"));
        Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(
                ledger("recovered", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201))));
        ChatMessageRecord message = new ChatMessageRecord();
        message.setSessionId(BigInteger.valueOf(100));
        message.setRoundId(BigInteger.valueOf(201));
        message.setSenderRole("assistant");
        message.setVariantIndex(1);
        message.setContentPayload(new LinkedHashMap<>(Map.of("answer", "done")));

        fixture.service.projectHistoryArtifacts(
                List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));

        @SuppressWarnings("unchecked")
        List<Map<String, Object>> projected =
                (List<Map<String, Object>>) message.getContentPayload().get("artifacts");
        Assert.assertEquals(1, projected.size());
        Assert.assertEquals("recovered", projected.get(0).get("artifactId"));
        Assert.assertEquals("AVAILABLE", projected.get(0).get("status"));
        Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
    }

    /**
     * 验证同一答案版本已有部分 payload 时仍可合并账本中已发布但尚未持久化的产物。
     */
    @Test
    public void historyProjectionShouldMergeMissingLedgerArtifactIntoExistingPayload() {
        Fixture fixture = fixture(Path.of("unused"));
        Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(
                ledger("persisted", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201)),
                ledger("late", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201))));
        ChatMessageRecord message = assistantMessage(BigInteger.valueOf(201), 1);
        message.setContentPayload(new LinkedHashMap<>(Map.of(
                "artifacts", List.of(oldView("persisted")))));

        fixture.service.projectHistoryArtifacts(
                List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));

        Assert.assertEquals(List.of("persisted", "late"), projectedArtifacts(message).stream()
                .map(item -> String.valueOf(item.get("artifactId")))
                .toList());
        Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
    }

    /**
     * 验证同一轮重答的两个答案版本只恢复各自绑定的产物。
     */
    @Test
    public void historyProjectionShouldIsolateArtifactsByVariantIndex() {
        Fixture fixture = fixture(Path.of("unused"));
        AgentArtifact first = ledger("variant-one", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201));
        first.setVariantIndex(1);
        AgentArtifact second = ledger("variant-two", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201));
        second.setVariantIndex(2);
        Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(first, second));
        ChatMessageRecord firstMessage = assistantMessage(BigInteger.valueOf(201), 1);
        ChatMessageRecord secondMessage = assistantMessage(BigInteger.valueOf(201), 2);

        fixture.service.projectHistoryArtifacts(List.of(firstMessage, secondMessage),
                BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));

        Assert.assertEquals("variant-one", projectedArtifacts(firstMessage).get(0).get("artifactId"));
        Assert.assertEquals("variant-two", projectedArtifacts(secondMessage).get(0).get("artifactId"));
        Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
    }

    /**
     * 验证 MinIO 实际对象大小不一致时发布失败并幂等删除。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldCompensateWhenStoredSizeMismatches() throws Exception {
        Path file = file("size.txt", "content");
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenReturn("etag-1");
        Mockito.when(fixture.storage.stat(Mockito.anyString()))
                .thenReturn(new AgentArtifactObjectStorage.StoredObjectMetadata(1L, "etag-1"));
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
                file.getParent(), file.getFileName().toString(), null,
                AgentArtifactService.MODE_DRAFT, context("draft-session")));

        Mockito.verify(fixture.storage).delete(Mockito.anyString());
    }

    /**
     * 验证 MinIO 回读对象摘要不一致时发布失败并幂等删除。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldCompensateWhenStoredHashMismatches() throws Exception {
        Path file = file("hash.txt", "content");
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenReturn("etag-1");
        Mockito.when(fixture.storage.open(Mockito.anyString()))
                .thenReturn(new java.io.ByteArrayInputStream("changed".getBytes(StandardCharsets.UTF_8)));
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
                file.getParent(), file.getFileName().toString(), null,
                AgentArtifactService.MODE_DRAFT, context("draft-session")));

        Mockito.verify(fixture.storage).delete(Mockito.anyString());
    }

    /**
     * 验证 OOXML 文件依据 ZIP 内部结构识别 MIME。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldRecognizeDocxFromZipStructure() throws Exception {
        Path file = temporaryFolder.newFile("report.bin").toPath();
        try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) {
            output.putNextEntry(new ZipEntry("[Content_Types].xml"));
            output.write("<Types/>".getBytes(StandardCharsets.UTF_8));
            output.closeEntry();
            output.putNextEntry(new ZipEntry("word/document.xml"));
            output.write("<document/>".getBytes(StandardCharsets.UTF_8));
            output.closeEntry();
        }
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenAnswer(invocation -> {
                    invocation.<java.io.InputStream>getArgument(1).readAllBytes();
                    return "etag-1";
                });
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null,
                AgentArtifactService.MODE_DRAFT, context("draft-session"));

        Assert.assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                view.mimeType());
    }

    /**
     * 验证高压缩比条目仅通过 central directory 元数据拒绝，不展开正文进行 MIME 探测。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldTreatZipBombLikeArchiveAsGenericZip() throws Exception {
        Path file = temporaryFolder.newFile("compressed.bin").toPath();
        try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) {
            output.putNextEntry(new ZipEntry("payload.bin"));
            byte[] zeros = new byte[8192];
            for (int index = 0; index < 1024; index++) {
                output.write(zeros);
            }
            output.closeEntry();
            output.putNextEntry(new ZipEntry("[Content_Types].xml"));
            output.write("<Types/>".getBytes(StandardCharsets.UTF_8));
            output.closeEntry();
            output.putNextEntry(new ZipEntry("word/document.xml"));
            output.write("<document/>".getBytes(StandardCharsets.UTF_8));
            output.closeEntry();
        }
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenAnswer(invocation -> {
                    invocation.<java.io.InputStream>getArgument(1).readAllBytes();
                    return "etag-1";
                });
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null,
                AgentArtifactService.MODE_DRAFT, context("draft-session"));

        Assert.assertEquals("application/zip", view.mimeType());
    }

    /**
     * 验证海量空条目在 ZipFile 构造前由 EOCD/中央目录预检拒绝。
     *
     * @throws Exception 文件准备失败
     */
    @Test
    public void publishShouldRejectExcessiveCentralDirectoryEntries() throws Exception {
        Path file = temporaryFolder.newFile("many-entries.bin").toPath();
        try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) {
            output.putNextEntry(new ZipEntry("[Content_Types].xml"));
            output.write("<Types/>".getBytes(StandardCharsets.UTF_8));
            output.closeEntry();
            output.putNextEntry(new ZipEntry("word/document.xml"));
            output.write("<document/>".getBytes(StandardCharsets.UTF_8));
            output.closeEntry();
            for (int index = 0; index < 4096; index++) {
                output.putNextEntry(new ZipEntry("empty/" + index));
                output.closeEntry();
            }
        }
        Fixture fixture = fixture(file);
        Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
                .thenAnswer(invocation -> {
                    invocation.<java.io.InputStream>getArgument(1).readAllBytes();
                    return "etag-1";
                });
        Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);

        AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null,
                AgentArtifactService.MODE_DRAFT, context("draft-session"));

        Assert.assertEquals("application/zip", view.mimeType());
    }

    /**
     * 验证正式产物下载必须同时匹配当前 Agent、模式、会话和存活会话归属。
     */
    @Test
    public void formalDownloadShouldRejectCrossAgentModeAndSessionReplay() {
        Fixture fixture = fixture(Path.of("unused"));
        AgentArtifact artifact = downloadableArtifact(AgentArtifactService.MODE_FORMAL);
        Mockito.when(fixture.mapper.selectOneByQuery(Mockito.any())).thenReturn(artifact);
        ChatSessionQueryService queryService = Mockito.mock(ChatSessionQueryService.class);
        ChatSessionSummary summary = new ChatSessionSummary();
        summary.setId(BigInteger.valueOf(100));
        summary.setTenantId(BigInteger.ONE);
        summary.setUserId(BigInteger.valueOf(2));
        summary.setAssistantId(BigInteger.valueOf(3));
        summary.setAssistantCode("AGENT");
        Mockito.when(queryService.getSessionSummary(BigInteger.valueOf(100))).thenReturn(summary);
        fixture.service.setChatSessionQueryService(queryService);
        LoginAccount account = account();

        Assert.assertSame(artifact, fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), null));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(4), "FORMAL", BigInteger.valueOf(100), null));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "DRAFT", null, "100"));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(101), null));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), "draft"));
    }

    /**
     * 验证草稿产物下载必须匹配当前 Agent 和 Runtime 会话。
     */
    @Test
    public void draftDownloadShouldRejectCrossAgentAndRuntimeSessionReplay() {
        Fixture fixture = fixture(Path.of("unused"));
        AgentArtifact artifact = downloadableArtifact(AgentArtifactService.MODE_DRAFT);
        Mockito.when(fixture.mapper.selectOneByQuery(Mockito.any())).thenReturn(artifact);
        LoginAccount account = account();

        Assert.assertSame(artifact, fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "DRAFT", null, "draft-session"));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(4), "DRAFT", null, "draft-session"));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "DRAFT", null, "another-session"));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), null));
        Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
                "artifact", account, BigInteger.valueOf(3), "DRAFT", BigInteger.valueOf(100), "draft-session"));
    }

    private ChatMessageRecord assistantMessage(BigInteger roundId, int variantIndex) {
        ChatMessageRecord message = new ChatMessageRecord();
        message.setSessionId(BigInteger.valueOf(100));
        message.setRoundId(roundId);
        message.setVariantIndex(variantIndex);
        message.setSenderRole("assistant");
        message.setContentPayload(new LinkedHashMap<>());
        return message;
    }

    private AgentArtifact downloadableArtifact(String mode) {
        AgentArtifact artifact = new AgentArtifact();
        artifact.setArtifactId("artifact");
        artifact.setTenantId(BigInteger.ONE);
        artifact.setOwnerUserId(BigInteger.valueOf(2));
        artifact.setAgentId(BigInteger.valueOf(3));
        artifact.setChatMode(mode);
        artifact.setChatSessionId(AgentArtifactService.MODE_FORMAL.equals(mode)
                ? BigInteger.valueOf(100) : null);
        artifact.setRuntimeSessionId(AgentArtifactService.MODE_DRAFT.equals(mode)
                ? "draft-session" : "100");
        artifact.setStatus(AgentArtifactStatus.AVAILABLE.name());
        return artifact;
    }

    private LoginAccount account() {
        LoginAccount account = new LoginAccount();
        account.setTenantId(BigInteger.ONE);
        account.setId(BigInteger.valueOf(2));
        return account;
    }

    @SuppressWarnings("unchecked")
    private List<Map<String, Object>> projectedArtifacts(ChatMessageRecord message) {
        return (List<Map<String, Object>>) message.getContentPayload().get("artifacts");
    }

    private Fixture fixture(Path file) {
        AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class);
        AgentArtifactObjectStorage storage = Mockito.mock(AgentArtifactObjectStorage.class);
        AgentWorkspaceResolver resolver = Mockito.mock(AgentWorkspaceResolver.class);
        Mockito.when(resolver.resolveExistingFile(Mockito.any(), Mockito.anyString())).thenReturn(file);
        Mockito.when(storage.stat(Mockito.anyString())).thenAnswer(invocation ->
                new AgentArtifactObjectStorage.StoredObjectMetadata(Files.size(file), "etag-1"));
        try {
            Mockito.when(storage.open(Mockito.anyString())).thenAnswer(invocation -> Files.newInputStream(file));
        } catch (java.io.IOException error) {
            throw new IllegalStateException(error);
        }
        Mockito.doAnswer(invocation -> {
            invocation.<AgentArtifact>getArgument(0).setId(BigInteger.valueOf(99));
            return 1;
        }).when(mapper).insert(Mockito.any());
        AgentWorkspaceProperties properties = new AgentWorkspaceProperties();
        return new Fixture(mapper, storage,
                new AgentArtifactService(mapper, storage, resolver, properties));
    }

    private Path file(String name, String content) throws Exception {
        Path file = temporaryFolder.newFile(name).toPath();
        Files.writeString(file, content, StandardCharsets.UTF_8);
        return file;
    }

    private AgentToolContext context(String sessionId) {
        AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
        runtimeContext.setTenantId("1");
        runtimeContext.setUserId("2");
        runtimeContext.setSessionId(sessionId);
        AgentToolContext context = new AgentToolContext();
        context.setAgentId("3");
        context.setSessionId(sessionId);
        context.setRequestId("request-1");
        context.setToolCallId("tool-1");
        context.setRuntimeContext(runtimeContext);
        return context;
    }

    private AgentArtifact ledger(String artifactId,
                                 AgentArtifactStatus status,
                                 BigInteger roundId) {
        AgentArtifact artifact = new AgentArtifact();
        artifact.setArtifactId(artifactId);
        artifact.setRoundId(roundId);
        artifact.setVariantIndex(1);
        artifact.setFileName(artifactId + ".txt");
        artifact.setMimeType("text/plain");
        artifact.setSizeBytes(10L);
        artifact.setSha256("sha-" + artifactId);
        artifact.setStatus(status.name());
        artifact.setStoragePlatform("secret-platform");
        artifact.setObjectKey("secret/object/key");
        return artifact;
    }

    private Map<String, Object> oldView(String artifactId) {
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("schemaVersion", 1);
        view.put("artifactId", artifactId);
        view.put("fileName", artifactId + ".txt");
        view.put("mimeType", "text/plain");
        view.put("size", 10L);
        view.put("sha256", "old-sha");
        view.put("downloadUrl", "/stale-download");
        view.put("status", "AVAILABLE");
        view.put("objectKey", "must-not-leak");
        view.put("storagePlatform", "must-not-leak");
        return view;
    }

    private record Fixture(AgentArtifactMapper mapper,
                           AgentArtifactObjectStorage storage,
                           AgentArtifactService service) {
    }
}
