package cn.nianxx.thhotel.platform.debug.control;

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiTraceEvent;
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentOpenApiException;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.platform.debug.service.DebugEmlSuperAgentRunService;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.web.client.RestClientResponseException;

@SpringBootTest(
        classes = ThHotelApplication.class,
        properties = {
                "debug.eml-upload.enabled=true",
                "debug.eml-upload.access-key=test-debug-upload-key",
                "debug.eml-upload.max-file-bytes=1048576",
                "debug.eml-upload.sse-heartbeat-interval=25ms",
                "aliyun.oss.debug-eml-prefix=debug/eml/",
                "superagent.open-api.enabled=true",
                "superagent.open-api.external-subject-id=test-debug-eml"
        })
@AutoConfigureMockMvc
@ActiveProfiles("test")
@ExtendWith(OutputCaptureExtension.class)
class DebugEmlSuperAgentControllerTest {

    private static final String ENDPOINT = "/api/system/debug/eml-superagent-runs";

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    private DebugEmlSuperAgentRunService runService;

    @MockBean
    private ObjectStorageService objectStorageService;

    @MockBean
    private SuperAgentOpenApiClient superAgentOpenApiClient;

    @Test
    void shouldRejectUploadWhenDebugKeyMissing() throws Exception {
        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST"))
                .andExpect(status().isUnauthorized())
                .andExpect(content().string(not(containsString("test-debug-upload-key"))));
    }

    @Test
    void shouldRejectStreamUploadWhenDebugKeyMissingBeforeStartingSse() throws Exception {
        mockMvc.perform(multipart(ENDPOINT + "/stream")
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST"))
                .andExpect(status().isUnauthorized())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.error_code").value("DEBUG_UPLOAD_KEY_INVALID"))
                .andExpect(content().string(not(containsString("test-debug-upload-key"))));
    }

    @Test
    void shouldUploadEmlToOssCaptureSourceMessageAndReturnSuperAgentResult() throws Exception {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult(
                "session-debug-001",
                "run-debug-001",
                "profile-debug",
                "profile-version-debug",
                "debug-model",
                "{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}",
                11,
                7,
                18,
                List.of("metadata", "values", "end")));

        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .param("run_label", "controller-test")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.debug_run_id").isNotEmpty())
                .andExpect(jsonPath("$.source_message_id").isNotEmpty())
                .andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD"))
                .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")))
                .andExpect(jsonPath("$.original_eml_oss_url", containsString("/raw/debug-booking.eml")))
                .andExpect(jsonPath("$.uploaded_media", hasSize(greaterThanOrEqualTo(3))))
                .andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/")))
                .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:inline-001"))))
                .andExpect(jsonPath("$.html_body_sanitized", containsString("https://oss.example.test/")))
                .andExpect(jsonPath("$.html_sanitize_required").value(true))
                .andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML"))
                .andExpect(jsonPath("$.agentbus_like_payload.schema_version").value("debug-eml-upload-v1"))
                .andExpect(jsonPath("$.agentbus_like_payload.source.provider").value("DEBUG_EML_UPLOAD"))
                .andExpect(jsonPath("$.agentbus_like_payload.source.original_message_id")
                        .value("debug-controller-message-001@example.test"))
                .andExpect(jsonPath("$.agentbus_like_payload.reply_policy.mode").value("debug_only"))
                .andExpect(jsonPath("$.superagent_session_id").value("session-debug-001"))
                .andExpect(jsonPath("$.superagent_run_id").value("run-debug-001"))
                .andExpect(jsonPath("$.superagent_raw_answer", containsString("ai_task_results")))
                .andExpect(jsonPath("$.superagent_parsed_json.ai_task_results[0].task_type").value("New Booking"))
                .andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED"))
                .andExpect(content().string(not(containsString("test-debug-upload-key"))));

        Long sourceCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_source_message_inbox inbox
                JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
                WHERE inbox.hotel_id = 'HOTEL-TEST'
                  AND inbox.provider = 'DEBUG_EML_UPLOAD'
                  AND inbox.channel = 'EMAIL'
                  AND payload.schema_version = 'debug-eml-upload-v1'
                  AND payload.payload_json LIKE '%controller-test%'
                """, Long.class);
        org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L);

        Long originalEmailMediaCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_source_message_media media
                JOIN platform_source_message_inbox inbox ON inbox.id = media.inbox_id
                JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
                WHERE inbox.provider = 'DEBUG_EML_UPLOAD'
                  AND media.media_type = 'ORIGINAL_EMAIL'
                  AND payload.payload_json LIKE '%controller-test%'
                """, Long.class);
        org.assertj.core.api.Assertions.assertThat(originalEmailMediaCount).isEqualTo(1L);

        Long debugRunCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_debug_eml_superagent_run
                WHERE hotel_id = 'HOTEL-TEST'
                  AND run_status = 'SUPERAGENT_SUCCEEDED'
                  AND superagent_session_id = 'session-debug-001'
                  AND superagent_run_id = 'run-debug-001'
                  AND run_label = 'controller-test'
                  AND safe_error_summary IS NULL
                """, Long.class);
        org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L);
    }

    @Test
    void shouldStreamDebugStagesSuperAgentTraceAndFinalResult() throws Exception {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any(), any())).thenAnswer(invocation -> {
            @SuppressWarnings("unchecked")
            Consumer<SuperAgentOpenApiTraceEvent> traceConsumer = invocation.getArgument(1);
            traceConsumer.accept(new SuperAgentOpenApiTraceEvent(
                    "reasoning.summary",
                    "run-stream-001",
                    null,
                    null,
                    null,
                    "正在分析 Debug 邮件。",
                    null,
                    null,
                    null,
                    "2026-07-11T10:00:01Z"));
            traceConsumer.accept(new SuperAgentOpenApiTraceEvent(
                    "tool.call.started",
                    "run-stream-001",
                    "ai-1",
                    "tool-1",
                    "th_hotel_query_case_context",
                    null,
                    "{\"group_code\":\"G001\"}",
                    null,
                    null,
                    "2026-07-11T10:00:02Z"));
            return new SuperAgentOpenApiResult(
                    "session-stream-001",
                    "run-stream-001",
                    "profile-debug",
                    "profile-version-debug",
                    "debug-model",
                    "{\"route_code\":\"S10\"}",
                    11,
                    7,
                    18,
                    List.of("metadata", "trace", "values", "end"),
                    List.of());
        });

        MvcResult mvcResult = mockMvc.perform(multipart(ENDPOINT + "/stream")
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .param("run_label", "stream-debug-upload")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(request().asyncStarted())
                .andReturn();

        mockMvc.perform(asyncDispatch(mvcResult))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM))
                .andExpect(content().string(containsString("event: debug_stage")))
                .andExpect(content().string(containsString("\"status\":\"PARSING_EML\"")))
                .andExpect(content().string(containsString("\"status\":\"CALLING_SUPERAGENT\"")))
                .andExpect(content().string(containsString("event: superagent_trace")))
                .andExpect(content().string(containsString("\"event\":\"reasoning.summary\"")))
                .andExpect(content().string(containsString("\"event\":\"tool.call.started\"")))
                .andExpect(content().string(containsString("\"tool_name\":\"th_hotel_query_case_context\"")))
                .andExpect(content().string(containsString("event: superagent_result")))
                .andExpect(content().string(containsString("\"superagent_run_id\":\"run-stream-001\"")))
                .andExpect(content().string(containsString("\"superagent_parsed_json\":{\"route_code\":\"S10\"}")))
                .andExpect(content().string(containsString("event: done")))
                .andExpect(content().string(not(containsString("test-debug-upload-key"))));
    }

    @Test
    void shouldSendHeartbeatWhileWaitingForSuperAgentStreamResult() throws Exception {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any(), any())).thenAnswer(invocation -> {
            Thread.sleep(120);
            return new SuperAgentOpenApiResult(
                    "session-heartbeat-001",
                    "run-heartbeat-001",
                    "profile-debug",
                    "profile-version-debug",
                    "debug-model",
                    "{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}",
                    11,
                    7,
                    18,
                    List.of("metadata", "values", "end"),
                    List.of());
        });

        MvcResult mvcResult = mockMvc.perform(multipart(ENDPOINT + "/stream")
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .param("run_label", "stream-heartbeat")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(request().asyncStarted())
                .andReturn();

        mockMvc.perform(asyncDispatch(mvcResult))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM))
                .andExpect(content().string(containsString("event: debug_heartbeat")))
                .andExpect(content().string(containsString("\"status\":\"CALLING_SUPERAGENT\"")))
                .andExpect(content().string(containsString("event: superagent_result")))
                .andExpect(content().string(containsString("event: done")))
                .andExpect(content().string(not(containsString("test-debug-upload-key"))));
    }

    @Test
    void shouldKeepBusinessRunStatusWhenSseClientDisconnects() {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any(), any())).thenAnswer(invocation -> {
            @SuppressWarnings("unchecked")
            Consumer<SuperAgentOpenApiTraceEvent> traceConsumer = invocation.getArgument(1);
            traceConsumer.accept(new SuperAgentOpenApiTraceEvent(
                    "reasoning.summary",
                    "run-stream-disconnect",
                    null,
                    null,
                    null,
                    "正在分析 Debug 邮件。",
                    null,
                    null,
                    null,
                    "2026-07-11T10:00:01Z"));
            return new SuperAgentOpenApiResult(
                    "session-stream-disconnect",
                    "run-stream-disconnect",
                    "profile-debug",
                    "profile-version-debug",
                    "debug-model",
                    "{\"route_code\":\"S10\"}",
                    11,
                    7,
                    18,
                    List.of("metadata", "trace", "values", "end"),
                    List.of());
        });

        org.assertj.core.api.Assertions.assertThatCode(() -> runService.uploadAndRunStream(
                        "test-debug-upload-key",
                        emlFile(),
                        "HOTEL-TEST",
                        "stream-client-disconnected",
                        new FailingOutputStream()))
                .doesNotThrowAnyException();

        Long debugRunCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_debug_eml_superagent_run
                WHERE hotel_id = 'HOTEL-TEST'
                  AND run_status = 'SUPERAGENT_SUCCEEDED'
                  AND superagent_session_id = 'session-stream-disconnect'
                  AND superagent_run_id = 'run-stream-disconnect'
                  AND run_label = 'stream-client-disconnected'
                  AND safe_error_summary IS NULL
                """, Long.class);
        org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L);
    }

    @Test
    void shouldLogSafeRootCauseWhenSuperAgentSseReadFails(CapturedOutput output) {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any(), any()))
                .thenThrow(new SuperAgentOpenApiException(
                        "SuperAgent SSE 读取失败。",
                        new IOException("simulated stream reset Authorization: Bearer should-not-log")));

        runService.uploadAndRunStream(
                "test-debug-upload-key",
                emlFile(),
                "HOTEL-TEST",
                "stream-read-failed-log",
                new ByteArrayOutputStream());

        org.assertj.core.api.Assertions.assertThat(output.getOut())
                .contains("Debug EML SuperAgent Open API failed")
                .contains("SuperAgentOpenApiException: SuperAgent SSE 读取失败。")
                .contains("IOException: simulated stream reset")
                .doesNotContain("should-not-log")
                .doesNotContain("Bearer should-not-log");
    }

    @Test
    void shouldRecordPhaseBeforeUploadingOriginalEmlToOss() throws Exception {
        AtomicInteger uploadIndex = new AtomicInteger();
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            if (uploadIndex.incrementAndGet() == 1) {
                Long phaseCount = jdbcTemplate.queryForObject("""
                        SELECT COUNT(*)
                        FROM platform_debug_eml_superagent_run
                        WHERE run_label = 'phase-debug-upload'
                          AND run_status = 'UPLOADING_ORIGINAL_EML'
                          AND safe_error_summary LIKE '%上传原始 EML%'
                        """, Long.class);
                org.assertj.core.api.Assertions.assertThat(phaseCount).isEqualTo(1L);
            }
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any())).thenAnswer(invocation -> {
            Long phaseCount = jdbcTemplate.queryForObject("""
                    SELECT COUNT(*)
                    FROM platform_debug_eml_superagent_run
                    WHERE run_label = 'phase-debug-upload'
                      AND run_status = 'CALLING_SUPERAGENT'
                      AND safe_error_summary LIKE '%调用 SuperAgent Open API%'
                    """, Long.class);
            org.assertj.core.api.Assertions.assertThat(phaseCount).isEqualTo(1L);
            return new SuperAgentOpenApiResult(
                    "session-debug-phase",
                    "run-debug-phase",
                    "profile-debug",
                    "profile-version-debug",
                    "debug-model",
                    "{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}",
                    11,
                    7,
                    18,
                    List.of("metadata", "values", "end"));
        });

        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .param("run_label", "phase-debug-upload")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED"));
    }

    @Test
    void shouldCreateIndependentSourceMessageForRepeatedDebugUpload() throws Exception {
        mockStorageAndSuperAgentSuccess();

        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .param("run_label", "repeat-debug-upload")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));

        reset(objectStorageService, superAgentOpenApiClient);
        mockStorageAndSuperAgentSuccess();

        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .param("run_label", "repeat-debug-upload")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));

        Long sourceCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_source_message_inbox inbox
                JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
                WHERE inbox.hotel_id = 'HOTEL-TEST'
                  AND inbox.provider = 'DEBUG_EML_UPLOAD'
                  AND inbox.channel = 'EMAIL'
                  AND inbox.external_message_id LIKE 'debug-eml-run-%'
                  AND payload.payload_json LIKE '%repeat-debug-upload%'
                """, Long.class);
        org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(2L);
    }

    @Test
    void shouldUseSystemHotelWhenDebugUploadOmitsHotelId() throws Exception {
        mockStorageAndSuperAgentSuccess();

        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("run_label", "system-hotel-debug-upload")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD"))
                .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));

        Long sourceCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_source_message_inbox inbox
                JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
                WHERE inbox.hotel_id = 'HOTEL-TEST'
                  AND inbox.provider = 'DEBUG_EML_UPLOAD'
                  AND payload.payload_json LIKE '%system-hotel-debug-upload%'
                """, Long.class);
        org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L);
    }

    @Test
    void shouldSanitizeDebugHtmlAndReplaceUpperCaseCidReferences() throws Exception {
        mockStorageAndSuperAgentSuccess();

        mockMvc.perform(multipart(ENDPOINT)
                        .file(unsafeHtmlEmlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/")))
                .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("CID:inline-001"))))
                .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline-001%3E"))))
                .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline%2Bplus%3E"))))
                .andExpect(jsonPath("$.html_body_sanitized", not(containsString("<script"))))
                .andExpect(jsonPath("$.html_body_sanitized", not(containsString("onerror"))))
                .andExpect(jsonPath("$.html_body_sanitized", not(containsString("javascript:"))))
                .andExpect(jsonPath("$.html_body_sanitized", containsString("https://oss.example.test/")))
                .andExpect(jsonPath("$.html_sanitize_required").value(true))
                .andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML"));
    }

    @Test
    void shouldTreatSuperAgentS000AnswerAsRecognizedEntryResult() throws Exception {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult(
                "session-debug-s000",
                "run-debug-s000",
                "profile-debug",
                "profile-version-debug",
                "debug-model",
                "S000,debug-eml-run-source",
                11,
                3,
                14,
                List.of("metadata", "values", "end")));

        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .param("run_label", "s000-debug-upload")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.superagent_raw_answer").value("S000,debug-eml-run-source"))
                .andExpect(jsonPath("$.superagent_parsed_json.entry_result_code").value("S000"))
                .andExpect(jsonPath("$.superagent_parsed_json.entry_result_source_message_id")
                        .value("debug-eml-run-source"))
                .andExpect(jsonPath("$.superagent_parsed_json.entry_result_meaning").value("PURE_INFORMATION"))
                .andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED"))
                .andExpect(content().string(not(containsString("不是合法 JSON"))));
    }

    @Test
    void shouldKeepCapturedSourceMessageWhenSuperAgentFails() throws Exception {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any()))
                .thenThrow(new SuperAgentOpenApiException(
                        "SuperAgent Open API 调用失败。",
                        new RestClientResponseException(
                                "401 Unauthorized",
                                401,
                                "Unauthorized",
                                null,
                                new byte[0],
                                StandardCharsets.UTF_8)));

        mockMvc.perform(multipart(ENDPOINT)
                        .file(emlFile())
                        .param("hotel_id", "HOTEL-TEST")
                        .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
                .andExpect(status().isBadGateway())
                .andExpect(jsonPath("$.error_code").value("SUPERAGENT_OPEN_API_FAILED"))
                .andExpect(content().string(not(containsString("test-debug-upload-key"))))
                .andExpect(content().string(not(containsString("401"))))
                .andExpect(content().string(not(containsString("Please create booking"))));

        Long linkedFailedRunCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_debug_eml_superagent_run
                WHERE hotel_id = 'HOTEL-TEST'
                  AND run_status = 'SUPERAGENT_FAILED'
                  AND source_message_id IS NOT NULL
                  AND original_eml_oss_url IS NOT NULL
                  AND safe_error_summary = 'SuperAgent Open API HTTP 调用失败，HTTP 状态：401。'
                """, Long.class);
        org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L);
    }

    private void mockStorageAndSuperAgentSuccess() {
        when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
            ObjectStoragePutRequest request = invocation.getArgument(0);
            return new ObjectStoragePutResult(
                    request.objectKey(),
                    "https://oss.example.test/" + request.objectKey(),
                    request.contentType(),
                    request.sizeBytes());
        });
        when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult(
                "session-debug-001",
                "run-debug-001",
                "profile-debug",
                "profile-version-debug",
                "debug-model",
                "{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}",
                11,
                7,
                18,
                List.of("metadata", "values", "end")));
    }

    private MockMultipartFile emlFile() {
        return new MockMultipartFile(
                "file",
                "debug-booking.eml",
                MediaType.TEXT_PLAIN_VALUE,
                emlBytes());
    }

    private MockMultipartFile unsafeHtmlEmlFile() {
        return new MockMultipartFile(
                "file",
                "unsafe-debug-booking.eml",
                MediaType.TEXT_PLAIN_VALUE,
                unsafeHtmlEmlBytes());
    }

    private static final class FailingOutputStream extends OutputStream {

        @Override
        public void write(int b) throws IOException {
            throw new IOException("client disconnected");
        }

        @Override
        public void write(byte[] b, int off, int len) throws IOException {
            throw new IOException("client disconnected");
        }
    }

    private byte[] emlBytes() {
        return """
                From: Guest <guest@example.test>
                To: Reservations <reservations@example.test>
                Subject: Debug Booking
                Date: Thu, 09 Jul 2026 01:30:00 +0000
                Message-ID: <debug-controller-message-001@example.test>
                MIME-Version: 1.0
                Content-Type: multipart/related; boundary="rel-boundary"

                --rel-boundary
                Content-Type: multipart/alternative; boundary="alt-boundary"

                --alt-boundary
                Content-Type: text/plain; charset=UTF-8

                Please create booking from debug email.

                --alt-boundary
                Content-Type: text/html; charset=UTF-8

                <html><body><p>Please create booking.</p><img src="cid:inline-001"></body></html>

                --alt-boundary--
                --rel-boundary
                Content-Type: image/png; name="inline.png"
                Content-Transfer-Encoding: base64
                Content-ID: <inline-001>
                Content-Disposition: inline; filename="inline.png"

                aW5saW5lLWltYWdl
                --rel-boundary
                Content-Type: application/pdf; name="booking.pdf"
                Content-Transfer-Encoding: base64
                Content-Disposition: attachment; filename="booking.pdf"

                cGRmLWNvbnRlbnQ=
                --rel-boundary--
                """.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
    }

    private byte[] unsafeHtmlEmlBytes() {
        return """
                From: Guest <guest@example.test>
                To: Reservations <reservations@example.test>
                Subject: Unsafe Debug Booking
                Date: Thu, 09 Jul 2026 01:30:00 +0000
                Message-ID: <unsafe-debug-controller-message-001@example.test>
                MIME-Version: 1.0
                Content-Type: multipart/related; boundary="rel-boundary"

                --rel-boundary
                Content-Type: text/html; charset=UTF-8

                <html><body><img src="CID:inline-001" onerror="alert(1)"><img src="cid:%3Cinline-001%3E"><img src="cid:%3Cinline%2Bplus%3E"><a href="javascript:alert(2)">bad</a><script>alert(3)</script></body></html>

                --rel-boundary
                Content-Type: image/png; name="inline.png"
                Content-Transfer-Encoding: base64
                Content-ID: <inline-001>
                Content-Disposition: inline; filename="inline.png"

                aW5saW5lLWltYWdl
                --rel-boundary
                Content-Type: image/png; name="inline-plus.png"
                Content-Transfer-Encoding: base64
                Content-ID: <inline+plus>
                Content-Disposition: inline; filename="inline-plus.png"

                aW5saW5lLXBsdXM=
                --rel-boundary--
                """.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
    }
}
