package cn.nianxx.thhotel.workflows.reservation.control;

import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.not;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
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.status;

import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
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.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

@SpringBootTest(
        classes = ThHotelApplication.class,
        properties = {
                "superagent.task-result.hmac-secret=test-superagent-secret",
                "superagent.task-result.clock-skew-seconds=300",
                "superagent.task-result.nonce-ttl-seconds=600",
                "superagent.task-result.max-body-bytes=12000",
                "superagent.task-result.allow-legacy-internal-source-message-id=true"
        })
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SuperAgentTaskResultControllerTest {

    private static final String ENDPOINT = "/api/integrations/superagent/task-results";
    private static final String CLIENT_ID = "superagent-test-client";
    private static final String SECRET = "test-superagent-secret";
    private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$";

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private SourceMessageCaptureService captureService;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldRejectRequestWithoutHmacHeaders() throws Exception {
        String body = minimalBody("1", "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);

        mockMvc.perform(post(ENDPOINT)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(body))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.error_code").value("AUTH_HEADER_MISSING"))
                .andExpect(content().string(not(containsString(SECRET))));
    }

    @Test
    void shouldRejectRequestWithInvalidSignature() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-hmac-invalid-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);
        String timestamp = Instant.now().toString();

        mockMvc.perform(post(ENDPOINT)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(body)
                        .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
                        .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
                        .header("X-TH-Hotel-SuperAgent-Nonce", "nonce-invalid-signature")
                        .header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid"))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.error_code").value("AUTH_SIGNATURE_INVALID"))
                .andExpect(content().string(not(containsString(SECRET))));
    }

    @Test
    void shouldCreateTemporaryOrderTaskAndCardForNewBookingWithoutBusinessKey() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-new-booking-temp-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {
                  "booking_object_type": "FIT Reservation",
                  "arrival_date": "2026-08-01",
                  "departure_date": "2026-08-02"
                }
                """);

        mockMvc.perform(signedPost(body, "nonce-new-booking-temp-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value("mail-new-booking-temp-001"))
                .andExpect(jsonPath("$.idempotent_replay").value(false))
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].source_event_index").value(1))
                .andExpect(jsonPath("$.items[0].array_index").value(1))
                .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"))
                .andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY"))
                .andExpect(content().string(not(containsString(SECRET))));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND ai_task_type = 'New Booking'
                  AND system_task_type = 'NEW_BOOKING'
                """, Long.class, source.inboxId());
        Long temporaryOrderCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_order
                WHERE source_message_id = ?
                  AND order_status = 'TEMPORARY'
                  AND order_key_type = 'TEMPORARY'
                """, Long.class, source.inboxId());
        Long taskCardCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task_card
                WHERE ai_payload_json LIKE '%New Booking%'
                  AND field_contract_version = '20260711-p0'
                """, Long.class);

        org.assertj.core.api.Assertions.assertThat(transitionCount).isEqualTo(1L);
        org.assertj.core.api.Assertions.assertThat(temporaryOrderCount).isEqualTo(1L);
        org.assertj.core.api.Assertions.assertThat(taskCardCount).isGreaterThanOrEqualTo(1L);
    }

    @Test
    void shouldRejectLegacyAiTaskResultsParentSplitCancelBooking() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v2-parent-split-legacy-001");
        String body = minimalBody(source.inboxId().toString(), "Cancel Booking", "normal_task",
                "linked_parent_release_after_child_split", """
                "case_keys": {"group_code": "PARENT-V2-001"},
                "extracted_fields": {
                  "cancel_object_type": "group_block",
                  "parent_release_or_cancel_candidate": true,
                  "release_reason": "parent_to_child_allocation_split",
                  "parent_group_code": "PARENT-V2-001",
                  "child_group_codes": ["CHILD-V2-001"],
                  "allocation_split_from_parent": true
                }
                """);

        mockMvc.perform(signedPost(body, "nonce-v2-parent-split-legacy-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"))
                .andExpect(content().string(containsString("Cancel Booking + linked_parent_release_after_child_split")));

        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(taskCount).isZero();
    }

    @Test
    void shouldResolveTaskResultSourceMessageByExternalMessageId() throws Exception {
        String externalMessageId = "mail-external-source-id-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId);
        String body = minimalBodyWithHotel(externalMessageId, "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);

        mockMvc.perform(signedPost(body, "nonce-external-source-id-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value(externalMessageId))
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldResolveExternalSourceMessageIdWithoutHotelIdFromSystemHotel() throws Exception {
        String externalMessageId = "mail-external-without-hotel-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId);
        String body = minimalBody(externalMessageId, "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);

        mockMvc.perform(signedPost(body, "nonce-external-source-without-hotel-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value(externalMessageId))
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldRejectLegacyInternalSourceMessageIdWhenHotelDoesNotMatchSystemHotel() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage(
                "mail-legacy-source-cross-hotel-001",
                "HOTEL-OTHER");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);

        mockMvc.perform(signedPost(body, "nonce-legacy-source-cross-hotel-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("HOTEL_ID_MISMATCH"));
    }

    @Test
    void shouldReturnIdempotentReplayForSameBodyWithNewNonce() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-idempotent-replay-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);

        mockMvc.perform(signedPost(body, "nonce-idempotent-replay-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.idempotent_replay").value(false));

        mockMvc.perform(signedPost(body, "nonce-idempotent-replay-002"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.idempotent_replay").value(true))
                .andExpect(jsonPath("$.warnings[0].code").value("IDEMPOTENT_REPLAY"));

        Long batchCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_batch
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        org.assertj.core.api.Assertions.assertThat(batchCount).isEqualTo(1L);
    }

    @Test
    void shouldRejectSameNonceReplayBeforeProcessingBodyAgain() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-nonce-replay-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);

        mockMvc.perform(signedPost(body, "nonce-replay-same"))
                .andExpect(status().isCreated());

        mockMvc.perform(signedPost(body, "nonce-replay-same"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("AUTH_NONCE_REPLAY"));
    }

    @Test
    void shouldRejectDifferentPayloadForSameSourceMessage() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-idempotency-conflict-001");
        String firstBody = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {"arrival_date": "2026-08-01"}
                """);
        String changedBody = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {"arrival_date": "2026-08-03"}
                """);

        mockMvc.perform(signedPost(firstBody, "nonce-idempotency-conflict-001"))
                .andExpect(status().isCreated());

        mockMvc.perform(signedPost(changedBody, "nonce-idempotency-conflict-002"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("IDEMPOTENCY_CONFLICT"));
    }

    @Test
    void shouldRejectTaskResultWhenStableStringExceedsDatabaseLimit() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-field-too-long-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """).replace(
                        "\"catalog_code\": \"S01\"",
                        "\"catalog_code\": \"" + "S".repeat(33) + "\"");

        mockMvc.perform(signedPost(body, "nonce-field-too-long-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_RESULT_FIELD_TOO_LONG"));
    }

    @Test
    void shouldRejectSecurityHeaderWhenClientIdExceedsDatabaseLimit() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-client-id-too-long-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {}
                """);
        String longClientId = "client-" + "x".repeat(129);

        mockMvc.perform(signedPostWithClientId(body, "nonce-client-id-too-long-001", longClientId))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.error_code").value("AUTH_HEADER_INVALID"))
                .andExpect(content().string(not(containsString(SECRET))));

        Long nonceCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM integration_superagent_task_result_nonce
                WHERE nonce = 'nonce-client-id-too-long-001'
                """, Long.class);
        assertThat(nonceCount).isZero();
    }

    @Test
    void shouldEnforceOnlyOneActiveOrderPerHotelAndBusinessKey() {
        jdbcTemplate.update("""
                INSERT INTO workflow_reservation_order (
                    id, hotel_id, order_key_type, order_business_key, active_business_key,
                    temporary_order_code, order_status, display_name, source_message_id,
                    version, created_at, updated_at
                )
                VALUES (
                    910000000000000001, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-ACTIVE-UNIQUE-001',
                    'GRP-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-001', 'ACTIVE', 'GRP-ACTIVE-UNIQUE-001',
                    910000000000000101, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                )
                """);

        assertThatThrownBy(() -> jdbcTemplate.update("""
                INSERT INTO workflow_reservation_order (
                    id, hotel_id, order_key_type, order_business_key, active_business_key,
                    temporary_order_code, order_status, display_name, source_message_id,
                    version, created_at, updated_at
                )
                VALUES (
                    910000000000000002, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-ACTIVE-UNIQUE-001',
                    'GRP-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-002', 'ACTIVE', 'GRP-ACTIVE-UNIQUE-001',
                    910000000000000102, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                )
                """)).isInstanceOf(DataIntegrityViolationException.class);

        jdbcTemplate.update("""
                INSERT INTO workflow_reservation_order (
                    id, hotel_id, order_key_type, order_business_key, active_business_key,
                    temporary_order_code, order_status, display_name, source_message_id,
                    version, created_at, updated_at
                )
                VALUES (
                    910000000000000003, 'HOTEL-TEST', 'CONFIRMATION_NUMBER', 'CNF-ACTIVE-UNIQUE-001',
                    'CNF-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-003', 'ACTIVE', 'CNF-ACTIVE-UNIQUE-001',
                    910000000000000103, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                )
                """);

        assertThatThrownBy(() -> jdbcTemplate.update("""
                INSERT INTO workflow_reservation_order (
                    id, hotel_id, order_key_type, order_business_key, active_business_key,
                    temporary_order_code, order_status, display_name, source_message_id,
                    version, created_at, updated_at
                )
                VALUES (
                    910000000000000004, 'HOTEL-TEST', 'CONFIRMATION_NUMBER', 'CNF-ACTIVE-UNIQUE-001',
                    'CNF-ACTIVE-UNIQUE-001', 'TMP-UNIQUE-004', 'ACTIVE', 'CNF-ACTIVE-UNIQUE-001',
                    910000000000000104, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                )
                """)).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void shouldEnforceUniqueExecutionOrderForQueueTasksInSameOrder() {
        jdbcTemplate.update("""
                INSERT INTO workflow_reservation_order (
                    id, hotel_id, order_key_type, order_business_key, active_business_key,
                    temporary_order_code, order_status, display_name, source_message_id,
                    version, created_at, updated_at
                )
                VALUES (
                    910000000000000201, 'HOTEL-TEST', 'GROUP_CODE', 'GRP-QUEUE-UNIQUE-001',
                    'GRP-QUEUE-UNIQUE-001', 'TMP-QUEUE-UNIQUE-001', 'ACTIVE', 'GRP-QUEUE-UNIQUE-001',
                    910000000000000301, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                )
                """);
        jdbcTemplate.update("""
                INSERT INTO workflow_reservation_task (
                    id, hotel_id, order_id, source_message_id, ai_transition_id,
                    result_type, ai_task_type, system_task_type, task_card_type,
                    task_status, queue_participation, execution_order,
                    blocked_until_parent_completed, version, created_at, updated_at
                )
                VALUES (
                    910000000000000202, 'HOTEL-TEST', 910000000000000201,
                    910000000000000301, 910000000000000401, 'normal_task',
                    'New Booking', 'NEW_BOOKING', 'NEW_BOOKING',
                    'PENDING_CONFIRM', 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                )
                """);

        assertThatThrownBy(() -> jdbcTemplate.update("""
                INSERT INTO workflow_reservation_task (
                    id, hotel_id, order_id, source_message_id, ai_transition_id,
                    result_type, ai_task_type, system_task_type, task_card_type,
                    task_status, queue_participation, execution_order,
                    blocked_until_parent_completed, version, created_at, updated_at
                )
                VALUES (
                    910000000000000203, 'HOTEL-TEST', 910000000000000201,
                    910000000000000302, 910000000000000402, 'normal_task',
                    'Update Booking', 'UPDATE_BOOKING', 'UPDATE_BOOKING',
                    'PENDING_CONFIRM', 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
                )
                """)).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void shouldAttachUpdateBookingToExistingActiveOrderByGroupCode() throws Exception {
        SourceMessageCaptureResult newSource = captureSourceMessage("mail-existing-order-new-001");
        String newBookingBody = minimalBody(newSource.inboxId().toString(), "New Booking", "normal_task", "new_group_block", """
                "case_keys": {"group_code": "GRP-M002-EXIST-001"},
                "extracted_fields": {"booking_object_type": "Group Block"}
                """);

        MvcResult newBookingResult = mockMvc.perform(signedPost(newBookingBody, "nonce-existing-order-new-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.items[0].order_status").value("ACTIVE"))
                .andReturn();
        String existingOrderId = com.jayway.jsonpath.JsonPath.read(
                newBookingResult.getResponse().getContentAsString(),
                "$.items[0].order_id"
        );

        SourceMessageCaptureResult updateSource = captureSourceMessage("mail-existing-order-update-001");
        String updateBody = minimalBody(updateSource.inboxId().toString(), "Update Booking", "normal_task", "update_stay_dates", """
                "case_keys": {"group_code": "GRP-M002-EXIST-001"},
                "extracted_fields": {"update_subtypes": ["update_stay_dates"]}
                """);

        mockMvc.perform(signedPost(updateBody, "nonce-existing-order-update-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.items[0].order_id").value(existingOrderId))
                .andExpect(jsonPath("$.items[0].system_task_type").value("UPDATE_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_card_type").value("UPDATE_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"));
    }

    @Test
    void shouldCreateReadOnlyMessageNotificationOnTemporaryOrderOutsideQueue() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-message-notification-001");
        String body = minimalBody(
                source.inboxId().toString(),
                "Message Notification",
                "informational_message",
                "thank_you",
                """
                "case_keys": {},
                "informational_message": {
                  "message_type": "thank_you",
                  "summary": "Guest acknowledged the booking update."
                }
                """);

        mockMvc.perform(signedPost(body, "nonce-message-notification-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.items[0].system_task_type").value("INFORMATIONAL_MESSAGE"))
                .andExpect(jsonPath("$.items[0].task_card_type").value("MESSAGE_NOTIFICATION"))
                .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
                .andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY"));

        Long queueParticipationCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                  AND queue_participation = 0
                  AND task_status = 'COMPLETED'
                """, Long.class, source.inboxId());
        org.assertj.core.api.Assertions.assertThat(queueParticipationCount).isEqualTo(1L);
    }

    @Test
    void shouldCreateReadOnlySourceMessageOnlyTaskForS000TextResult() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-s000-entry-result-001");
        String body = "S000,mail-s000-entry-result-001";

        MvcResult result = mockMvc.perform(signedPlainPost(body, "nonce-s000-entry-result-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value("mail-s000-entry-result-001"))
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].source_event_index").value(1))
                .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_card_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
                .andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY"))
                .andExpect(content().string(not(containsString(SECRET))))
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
        String orderId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].order_id");

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.system_task_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.task_card_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.task_status").value("COMPLETED"))
                .andExpect(jsonPath("$.availability.read_only").value(true))
                .andExpect(jsonPath("$.availability.editable").value(false))
                .andExpect(jsonPath("$.availability.confirmable").value(false))
                .andExpect(jsonPath("$.availability.executable").value(false))
                .andExpect(jsonPath("$.source_message_only_result.entry_result_code").value("S000"))
                .andExpect(jsonPath("$.source_message_only_result.entry_result_meaning").value("PURE_INFORMATION"))
                .andExpect(jsonPath("$.source_message_only_result.entry_result_source_message_id")
                        .value("mail-s000-entry-result-001"))
                .andExpect(jsonPath("$.source_message_only_result.raw_answer").value(body))
                .andExpect(jsonPath("$.fields.length()").value(0))
                .andExpect(jsonPath("$.opera_operations.length()").value(0));

        mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {}
                                }
                                """))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("TASK_STATUS_NOT_EDITABLE"));

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "target_task_type": "NEW_BOOKING",
                                  "reason": "特殊入口结果不允许人工转换"
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_NOT_MANUAL_REVIEW"));

        mockMvc.perform(get("/api/reservation/tasks")
                        .param("hotel_id", "HOTEL-TEST")
                        .param("task_type", "SOURCE_MESSAGE_ONLY")
                        .param("keyword", "mail-s000-entry-result-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items[0].task_id").value(taskId))
                .andExpect(jsonPath("$.items[0].task_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].card_name").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_subtype").value("S000"))
                .andExpect(jsonPath("$.items[0].queue_participation").value(false))
                .andExpect(jsonPath("$.items[0].can_process").value(false));

        mockMvc.perform(get("/api/reservation/orders")
                        .param("hotel_id", "HOTEL-TEST")
                        .param("keyword", "mail-s000-entry-result-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.page.total").value(0));

        Long hiddenOrderCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_order
                WHERE id = ?
                  AND order_visibility = 'HIDDEN_SYSTEM'
                """, Long.class, Long.valueOf(orderId));
        Long sourceOnlyTaskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE id = ?
                  AND source_message_id = ?
                  AND system_task_type = 'SOURCE_MESSAGE_ONLY'
                  AND task_card_type = 'SOURCE_MESSAGE_ONLY'
                  AND task_subtype = 'S000'
                  AND queue_participation = 0
                  AND task_status = 'COMPLETED'
                """, Long.class, Long.valueOf(taskId), source.inboxId());
        assertThat(hiddenOrderCount).isEqualTo(1L);
        assertThat(sourceOnlyTaskCount).isEqualTo(1L);
    }

    @Test
    void shouldCreateReadOnlySourceMessageOnlyTaskForS999TextResult() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-s999-entry-result-001");
        String body = "S999,mail-s999-entry-result-001";

        MvcResult result = mockMvc.perform(signedPlainPost(body, "nonce-s999-entry-result-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value("mail-s999-entry-result-001"))
                .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_card_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");

        Long sourceOnlyTaskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE id = ?
                  AND source_message_id = ?
                  AND system_task_type = 'SOURCE_MESSAGE_ONLY'
                  AND task_subtype = 'S999'
                  AND queue_participation = 0
                  AND task_status = 'COMPLETED'
                """, Long.class, Long.valueOf(taskId), source.inboxId());
        assertThat(sourceOnlyTaskCount).isEqualTo(1L);
    }

    @Test
    void shouldReturnIdempotentReplayForSameS000TextResultWithNewNonce() throws Exception {
        captureSourceMessage("mail-s000-idempotent-001");
        String body = "S000,mail-s000-idempotent-001";

        mockMvc.perform(signedPlainPost(body, "nonce-s000-idempotent-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.idempotent_replay").value(false));

        mockMvc.perform(signedPlainPost(body, "nonce-s000-idempotent-002"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.idempotent_replay").value(true))
                .andExpect(jsonPath("$.warnings[0].code").value("IDEMPOTENT_REPLAY"));
    }

    @Test
    void shouldRejectTextResultWhenExternalSourceMessageIdNotFound() throws Exception {
        mockMvc.perform(signedPlainPost("S000,missing-source-message-001", "nonce-s000-source-missing-001"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND"));
    }

    @Test
    void shouldRejectTextResultAsAmbiguousWhenExternalSourceMessageMatchesMultipleChannels() throws Exception {
        String externalMessageId = "mail-s999-ambiguous-channel-001";
        captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
        captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");

        mockMvc.perform(signedPlainPost("S999," + externalMessageId, "nonce-s999-ambiguous-channel-001"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
    }

    @Test
    void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS10Result() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s10-entry-result-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-s10-entry-result-001",
                    "subject": null,
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "route_code": "S10",
                  "handler_type": "main_agent_outcome",
                  "result_type": "source_message_review_notification",
                  "current_or_history": "current",
                  "agent_assessment": {
                    "status": "no_booking_action_detected",
                    "reason_code": "no_booking_action_detected",
                    "automation_action": "none"
                  },
	                  "notification": {
	                    "required": true,
	                    "notification_type": "source_message_review",
	                    "show_source_message": true,
	                    "requires_user_decision": true,
	                    "visible_message": "未匹配到当前 Agent 支持的业务事件类型，请查看原邮件并决定是否需要回复或进行其他处理。"
	                  },
	                  "manual_review": null
	                }
                """;

        MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s10-entry-result-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value("mail-v3-s10-entry-result-001"))
                .andExpect(jsonPath("$.items[0].route_code").value("S10"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
                .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND result_type = 'source_message_review_notification'
                  AND route_code = 'S10'
                  AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
                """, Long.class, source.inboxId());
        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE id = ?
                  AND source_message_id = ?
                  AND result_type = 'source_message_review_notification'
                  AND task_subtype = 'S10'
                  AND queue_participation = 0
                """, Long.class, Long.valueOf(taskId), source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
        assertThat(taskCount).isEqualTo(1L);
    }

    @Test
    void shouldCreateStructuredS10ResultWhenSourceMessageStoredAsOutlookChannel() throws Exception {
        String externalMessageId = "mail-v3-s10-outlook-channel-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
        String body = structuredS10Body(externalMessageId);

        mockMvc.perform(signedPost(body, "nonce-v3-s10-outlook-channel-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value(externalMessageId))
                .andExpect(jsonPath("$.items[0].route_code").value("S10"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND result_type = 'source_message_review_notification'
                  AND route_code = 'S10'
                  AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS99Result() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s99-entry-result-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-s99-entry-result-001",
                    "subject": null,
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "route_code": "S99",
                  "handler_type": "main_agent_outcome",
                  "result_type": "source_message_review_notification",
	                  "current_or_history": "current",
	                  "agent_assessment": {
	                    "status": "material_package_unavailable",
	                    "reason_code": "material_package_unavailable",
	                    "automation_action": "none"
	                  },
	                  "notification": {
	                    "required": true,
	                    "notification_type": "source_message_review",
	                    "show_source_message": true,
	                    "requires_user_decision": true,
	                    "visible_message": "当前输入不足，无法判断是否匹配当前 Agent 支持的业务事件类型，请查看原邮件并决定后续处理。"
	                  },
	                  "manual_review": {
	                    "reason_code": "material_package_unavailable",
	                    "visible_reason": "当前输入不足，无法完成支持业务事件范围分类。",
	                    "review_record_type": "main_agent_entry_review",
	                    "missing_fields": [],
	                    "blocking_points": [],
	                    "conflicting_points": [],
	                    "suggested_human_actions": ["review_source_message"],
	                    "evidence_to_check": ["source_message"],
	                    "known_fields": {}
	                  }
	                }
                """;

        MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-entry-result-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.source_message_id").value("mail-v3-s99-entry-result-001"))
                .andExpect(jsonPath("$.items[0].route_code").value("S99"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
                .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND result_type = 'source_message_review_notification'
                  AND route_code = 'S99'
                  AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
                """, Long.class, source.inboxId());
        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE id = ?
                  AND source_message_id = ?
                  AND result_type = 'source_message_review_notification'
                  AND task_subtype = 'S99'
                  AND queue_participation = 0
                """, Long.class, Long.valueOf(taskId), source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
        assertThat(taskCount).isEqualTo(1L);
    }

    @Test
    void shouldExposeStructuredS99NotificationInTaskListAndDetail() throws Exception {
        captureSourceMessage("mail-v3-s99-frontend-visible-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-s99-frontend-visible-001",
                    "subject": null,
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "route_code": "S99",
                  "handler_type": "main_agent_outcome",
                  "result_type": "source_message_review_notification",
	                  "current_or_history": "current",
	                  "agent_assessment": {
	                    "status": "material_package_unavailable",
	                    "reason_code": "material_package_unavailable",
	                    "automation_action": "none"
	                  },
	                  "notification": {
	                    "required": true,
	                    "notification_type": "source_message_review",
	                    "show_source_message": true,
	                    "requires_user_decision": true,
	                    "visible_message": "当前输入不足，无法判断是否匹配当前 Agent 支持的业务事件类型，请查看原邮件并决定后续处理。"
	                  },
	                  "manual_review": {
	                    "reason_code": "material_package_unavailable",
	                    "visible_reason": "当前输入不足，无法完成支持业务事件范围分类。",
	                    "review_record_type": "main_agent_entry_review",
	                    "missing_fields": [],
	                    "blocking_points": [],
	                    "conflicting_points": [],
	                    "suggested_human_actions": ["review_source_message"],
	                    "evidence_to_check": ["source_message"],
	                    "known_fields": {}
	                  }
	                }
                """;

        MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-frontend-visible-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                result.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(get("/api/reservation/tasks")
                        .param("hotel_id", "HOTEL-TEST")
                        .param("task_type", "SOURCE_MESSAGE_ONLY")
                        .param("keyword", "mail-v3-s99-frontend-visible-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items[0].task_id").value(taskId))
                .andExpect(jsonPath("$.items[0].result_type").value("source_message_review_notification"))
                .andExpect(jsonPath("$.items[0].ai_task_type").value("S99"))
                .andExpect(jsonPath("$.items[0].route_code").value("S99"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result_type").value("source_message_review_notification"))
                .andExpect(jsonPath("$.ai_task_type").value("S99"))
                .andExpect(jsonPath("$.task_subtype").value("S99"))
                .andExpect(jsonPath("$.route_code").value("S99"))
                .andExpect(jsonPath("$.system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
                .andExpect(jsonPath("$.source_message_only_result.result_type")
                        .value("source_message_review_notification"))
                .andExpect(jsonPath("$.source_message_only_result.route_code").value("S99"))
                .andExpect(jsonPath("$.source_message_only_result.agent_assessment.status")
                        .value("material_package_unavailable"))
                .andExpect(jsonPath("$.source_message_only_result.notification.notification_type")
                        .value("source_message_review"))
                .andExpect(jsonPath("$.source_message_only_result.manual_review.reason_code")
                        .value("material_package_unavailable"));
    }

    @Test
    void shouldCreateBusinessTaskFromV3BusinessRootMessageEvent() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-business-root-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-business-root-001",
                    "subject": "New booking",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "New Booking",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E1",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": null,
                        "confirmation_number": "CNF-V3-NEW-001",
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Please create a new FIT reservation.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation",
                        "arrival_date": "2026-09-01"
                      },
                      "manual_review": null
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """;

        mockMvc.perform(signedPost(body, "nonce-v3-business-root-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].source_event_index").value(1))
                .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK"))
                .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"))
                .andExpect(jsonPath("$.items[0].order_status").value("ACTIVE"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND source_event_index = 1
                  AND result_type = 'normal_task'
                  AND ai_task_type = 'New Booking'
                  AND task_subtype = 'new_fit_reservation'
                  AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
                  AND system_process_category = 'BUSINESS_TASK'
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldAcceptV2TaskResultWhenSourceMessageStoredAsOutlookChannelWithoutSourceChannel() throws Exception {
        String externalMessageId = "mail-v2-task-result-outlook-channel-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
        String body = minimalBodyWithHotel(
                externalMessageId,
                "New Booking",
                "normal_task",
                "new_fit_reservation",
                """
                        "case_keys": {"confirmation_number": "CNF-V2-OUTLOOK-001"},
                        "extracted_fields": {"booking_object_type": "FIT Reservation"}
                        """);

        mockMvc.perform(signedPost(body, "nonce-v2-task-result-outlook-channel-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldAcceptV2TaskResultWhenLegacySourceChannelDoesNotMatchInboxChannel() throws Exception {
        String externalMessageId = "mail-v2-task-result-legacy-email-channel-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
        String body = minimalBodyWithHotelAndSourceMetadata(
                externalMessageId,
                "AGENTBUS",
                "EMAIL",
                "New Booking",
                "normal_task",
                "new_fit_reservation",
                """
                        "case_keys": {"confirmation_number": "CNF-V2-LEGACY-EMAIL-001"},
                        "extracted_fields": {"booking_object_type": "FIT Reservation"}
                        """);

        mockMvc.perform(signedPost(body, "nonce-v2-task-result-legacy-email-channel-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldRejectV2TaskResultAsAmbiguousEvenWhenLegacySourceChannelProvided() throws Exception {
        String externalMessageId = "mail-v2-task-result-ambiguous-legacy-channel-001";
        captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
        captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
        String body = minimalBodyWithHotelAndSourceMetadata(
                externalMessageId,
                "AGENTBUS",
                "EMAIL",
                "New Booking",
                "normal_task",
                "new_fit_reservation",
                """
                        "case_keys": {"confirmation_number": "CNF-V2-AMBIGUOUS-001"},
                        "extracted_fields": {"booking_object_type": "FIT Reservation"}
                        """);

        mockMvc.perform(signedPost(body, "nonce-v2-task-result-ambiguous-legacy-channel-001"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
    }

    @Test
    void shouldPreferNumericExternalSourceMessageIdBeforeLegacyInternalId() throws Exception {
        String externalMessageId = "1234567890123456789";
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
        String body = minimalBody(
                externalMessageId,
                "New Booking",
                "normal_task",
                "new_fit_reservation",
                """
                        "case_keys": {"confirmation_number": "CNF-V2-NUMERIC-001"},
                        "extracted_fields": {"booking_object_type": "FIT Reservation"}
                        """);

        mockMvc.perform(signedPost(body, "nonce-v2-task-result-numeric-external-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldAcceptSourceMessageOnlyTextResultWhenSourceMessageStoredAsOutlookChannel() throws Exception {
        String externalMessageId = "mail-s000-outlook-channel-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");

        mockMvc.perform(signedPlainPost("S000," + externalMessageId, "nonce-s000-outlook-channel-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'S000'
                  AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldCreateBusinessTaskFromV3BusinessRootWhenSourceMessageStoredAsOutlookChannel() throws Exception {
        String externalMessageId = """
                AAMkAGY5YWM5OTNlLTQ1OTktNDQ1Yi05NTk5LTgwNDIzNTAzY2Q4YgBGAAAAAAApDY1IkXlgT51ii6WOFxDvBwAuw1c78vBZRqpnmlmQMZnLAAAAAAEMAAAuw1c78vBZRqpnmlmQMZnLAAKAkhoCAAA=
                """.trim();
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
        String body = v3BusinessRootBody(externalMessageId, "CNF-V3-OUTLOOK-001");

        mockMvc.perform(signedPost(body, "nonce-v3-business-root-outlook-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
                  AND system_process_category = 'BUSINESS_TASK'
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
    }

    @Test
    void shouldRejectV3BusinessRootWhenExternalSourceMessageMatchesMultipleChannels() throws Exception {
        String externalMessageId = "mail-v3-business-root-ambiguous-channel-001";
        captureSourceMessage(externalMessageId, "HOTEL-TEST", "EMAIL");
        captureSourceMessage(externalMessageId, "HOTEL-TEST", "OUTLOOK");
        String body = v3BusinessRootBody(externalMessageId, "CNF-V3-AMBIGUOUS-001");

        mockMvc.perform(signedPost(body, "nonce-v3-business-root-ambiguous-channel-001"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_AMBIGUOUS"));
    }

    @Test
    void shouldConfirmV3BusinessTaskWithoutLegacyVisibleReason() throws Exception {
        captureSourceMessage("mail-v3-confirm-without-legacy-visible-reason-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-confirm-without-legacy-visible-reason-001",
                    "subject": "New booking without legacy visible reason",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "New Booking",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E1",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": null,
                        "confirmation_number": "CNF-V3-NO-VISIBLE-001",
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Please create a new FIT reservation without old root visible reason.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation",
                        "arrival_date": "2026-09-01",
                        "departure_date": "2026-09-03",
                        "room_quantity": 2,
                        "room_type": "Deluxe King",
                        "pms_room_type_code": "RM2"
                      },
                      "manual_review": null
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """;
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-confirm-without-visible-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {}
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_status").value("READY"))
                .andExpect(jsonPath("$.confirmed_payload.field_values['relevant_message_excerpt']")
                        .value("Please create a new FIT reservation without old root visible reason."))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
                        .value("RM2"));
    }

    @Test
    void shouldRejectEmptyV3BusinessRootWithoutPersistingBatch() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-empty-business-root-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-empty-business-root-001",
                    "subject": "Empty business root",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """;

        mockMvc.perform(signedPost(body, "nonce-v3-empty-business-root-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_RESULTS_EMPTY"));

        Long batchCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_batch
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(batchCount).isZero();
    }

    @Test
    void shouldPersistAdapterContractErrorWhenV3EventRouteCannotBeDerived() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-adapter-error-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-adapter-error-001",
                    "subject": "Trace without subtype",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "Trace",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E2",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": "GRP-V3-ERR-001",
                        "confirmation_number": null,
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Please add a trace note.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {},
                      "manual_review": null
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """;

        mockMvc.perform(signedPost(body, "nonce-v3-adapter-error-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].source_event_index").value(2))
                .andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR"))
                .andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_ROUTE_UNSUPPORTED"))
                .andExpect(jsonPath("$.items[0].system_task_type").value("ADAPTER_CONTRACT_ERROR"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND result_type = 'adapter_contract_error'
                  AND system_process_category = 'ADAPTER_CONTRACT_ERROR'
                  AND adapter_error_code = 'EVENT_ROUTE_UNSUPPORTED'
                """, Long.class, source.inboxId());
        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
        assertThat(taskCount).isZero();
    }

    @Test
    void shouldPersistAdapterContractErrorForExplicitV3ContractErrorsAndContinueSiblingEvents() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-contract-error-sibling-001");
        String overlongEventType = "Unsupported Event " + "X".repeat(120);
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-contract-error-sibling-001",
                    "subject": "Mixed events",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "%s",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E1",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": "%s",
                        "confirmation_number": null,
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Unsupported event with explicit contract error.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {},
                      "contract_errors": [
                        {
                          "path": "/extracted_fields",
                          "code": "unsupported_contract"
                        }
                      ],
                      "manual_review": null
                    },
                    {
                      "event_type": "New Booking",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E2",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": null,
                        "confirmation_number": "CNF-V3-SIBLING-001",
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Please create sibling FIT reservation.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation"
                      },
                      "manual_review": null
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """.formatted(overlongEventType, "GRP-" + "Y".repeat(180));

        mockMvc.perform(signedPost(body, "nonce-v3-contract-error-sibling-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(2))
                .andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR"))
                .andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_ERROR"))
                .andExpect(jsonPath("$.items[1].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
                .andExpect(jsonPath("$.items[1].system_task_type").value("NEW_BOOKING"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(2L);
        assertThat(taskCount).isEqualTo(1L);
    }

    @Test
    void shouldExposeV3RouteAndRelatedTransitionBlocksInFrontendQueries() throws Exception {
        captureSourceMessage("mail-v3-frontend-route-blocks-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-frontend-route-blocks-001",
                    "subject": "Mixed events",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "Trace",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E1",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": "GRP-V3-FRONTEND-BLOCK-001",
                        "confirmation_number": null,
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Trace event has explicit contract error.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {},
                      "contract_errors": [
                        {
                          "path": "/extracted_fields",
                          "code": "unsupported_contract"
                        }
                      ],
                      "manual_review": null
                    },
                    {
                      "event_type": "New Booking",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E2",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": null,
                        "confirmation_number": "CNF-V3-FRONTEND-BLOCK-001",
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Please create frontend-visible FIT reservation.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation"
                      },
                      "manual_review": null
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": [
                    {
                      "intent_type": "unsupported_active_request",
                      "reason_code": "no_task_card_available"
                    }
                  ]
                }
                """;

        MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-frontend-route-blocks-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andReturn();
        String orderId = com.jayway.jsonpath.JsonPath.read(
                result.getResponse().getContentAsString(),
                "$.items[1].order_id"
        );
        String taskId = com.jayway.jsonpath.JsonPath.read(
                result.getResponse().getContentAsString(),
                "$.items[1].task_id"
        );

        mockMvc.perform(get("/api/reservation/tasks")
                        .param("hotel_id", "HOTEL-TEST")
                        .param("keyword", "mail-v3-frontend-route-blocks-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items[0].task_id").value(taskId))
                .andExpect(jsonPath("$.items[0].result_type").value("normal_task"))
                .andExpect(jsonPath("$.items[0].ai_task_type").value("New Booking"))
                .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK"));

        mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId)
                        .param("hotel_id", "HOTEL-TEST")
                        .param("include_tasks", "true"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.tasks[0].task_id").value(taskId))
                .andExpect(jsonPath("$.tasks[0].result_type").value("normal_task"))
                .andExpect(jsonPath("$.tasks[0].ai_task_type").value("New Booking"))
                .andExpect(jsonPath("$.tasks[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
                .andExpect(jsonPath("$.tasks[0].system_process_category").value("BUSINESS_TASK"));

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result_type").value("normal_task"))
                .andExpect(jsonPath("$.ai_task_type").value("New Booking"))
                .andExpect(jsonPath("$.task_subtype").value("new_fit_reservation"))
                .andExpect(jsonPath("$.route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
                .andExpect(jsonPath("$.system_process_category").value("BUSINESS_TASK"))
                .andExpect(jsonPath("$.adapter_contract_errors.length()").value(1))
                .andExpect(jsonPath("$.adapter_contract_errors[0].adapter_error_code")
                        .value("EVENT_CONTRACT_ERROR"))
                .andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.contract_errors[0].code")
                        .value("unsupported_contract"))
                .andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.case_keys").doesNotExist())
                .andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.extracted_fields").doesNotExist())
                .andExpect(jsonPath("$.unhandled_intents.length()").value(1))
                .andExpect(jsonPath("$.unhandled_intents[0].route_code")
                        .value("R42_UNHANDLED_CURRENT_INTENT"))
                .andExpect(jsonPath("$.unhandled_intents[0].system_process_category")
                        .value("UNHANDLED_CURRENT_INTENT"))
                .andExpect(jsonPath("$.unhandled_intents[0].payload_fragment.intent_type")
                        .value("unsupported_active_request"));
    }

    @Test
    void shouldPersistAdapterContractErrorForIncompleteV3ManualReview() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-incomplete-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-manual-review-incomplete-001",
                    "subject": "Manual review incomplete",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "New Booking",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E1",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": null,
                        "confirmation_number": "CNF-V3-MR-001",
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Manual review payload is incomplete.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation"
                      },
                      "manual_review": {
                        "reason_code": "missing_room_type"
                      }
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """;

        mockMvc.perform(signedPost(body, "nonce-v3-manual-review-incomplete-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR"))
                .andExpect(jsonPath("$.items[0].adapter_error_code").value("MANUAL_REVIEW_CONTRACT_INCOMPLETE"));

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND adapter_error_code = 'MANUAL_REVIEW_CONTRACT_INCOMPLETE'
                """, Long.class, source.inboxId());
        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
        assertThat(taskCount).isZero();
    }

    @Test
    void shouldPersistUnhandledCurrentIntentWithDedicatedProcessCategory() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-unhandled-current-intent-001");
        String body = """
                {
                  "source_message": {
                    "source_message_id": "mail-v3-unhandled-current-intent-001",
                    "subject": "Unhandled current intent",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": [
                    {
                      "intent_type": "unsupported_active_request",
                      "reason_code": "no_task_card_available"
                    }
                  ]
                }
                """;

        mockMvc.perform(signedPost(body, "nonce-v3-unhandled-current-intent-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].route_code").value("R42_UNHANDLED_CURRENT_INTENT"))
                .andExpect(jsonPath("$.items[0].system_process_category").value("UNHANDLED_CURRENT_INTENT"))
                .andExpect(jsonPath("$.items[0].adapter_error_code").doesNotExist())
                .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
                .andExpect(jsonPath("$.items[0].task_id").doesNotExist());

        Long transitionCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'R42_UNHANDLED_CURRENT_INTENT'
                  AND system_process_category = 'UNHANDLED_CURRENT_INTENT'
                  AND adapter_error_code IS NULL
                """, Long.class, source.inboxId());
        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(transitionCount).isEqualTo(1L);
        assertThat(taskCount).isZero();
    }

    @Test
    void shouldRejectUnsupportedContentTypeForTaskResultCallback() throws Exception {
        String body = "S000,source-message-unsupported-content-type-001";

        mockMvc.perform(signedPostWithContentType(body, "nonce-unsupported-content-type-001", MediaType.APPLICATION_XML))
                .andExpect(status().isUnsupportedMediaType())
                .andExpect(jsonPath("$.error_code").value("REQUEST_CONTENT_TYPE_UNSUPPORTED"));
    }

    @Test
    void shouldMarkLaterTaskReadOnlyUntilPreviousQueueTaskIsCompletedOrFailed() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-queue-readonly-001");
        String body = twoTaskBody(source.inboxId().toString(), "GRP-M002-QUEUE-001");

        MvcResult result = mockMvc.perform(signedPost(body, "nonce-queue-readonly-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(2))
                .andExpect(jsonPath("$.items[0].execution_order").value(1))
                .andExpect(jsonPath("$.items[1].execution_order").value(2))
                .andReturn();
        String firstTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
        String secondTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[1].task_id");

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(secondTaskId))
                .andExpect(jsonPath("$.availability.blocked").value(true))
                .andExpect(jsonPath("$.availability.read_only").value(true))
                .andExpect(jsonPath("$.availability.editable").value(false))
                .andExpect(jsonPath("$.availability.confirmable").value(false))
                .andExpect(jsonPath("$.availability.executable").value(false))
                .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));

        jdbcTemplate.update("""
                UPDATE workflow_reservation_task
                SET task_status = 'READY'
                WHERE id = ?
                """, Long.valueOf(firstTaskId));

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.availability.blocked").value(true))
                .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));

        jdbcTemplate.update("""
                UPDATE workflow_reservation_task
                SET task_status = 'EXECUTING'
                WHERE id = ?
                """, Long.valueOf(firstTaskId));

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.availability.blocked").value(true))
                .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));

        jdbcTemplate.update("""
                UPDATE workflow_reservation_task
                SET task_status = 'FAILED'
                WHERE id = ?
                """, Long.valueOf(firstTaskId));

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.availability.blocked").value(false))
                .andExpect(jsonPath("$.availability.read_only").value(false))
                .andExpect(jsonPath("$.availability.editable").value(true))
                .andExpect(jsonPath("$.availability.confirmable").value(true));
    }

    @Test
    void shouldReturnTaskDetailFieldsFromTaskCardMatrix() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-detail-matrix-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {"confirmation_number": "CNF-MATRIX-001"},
                "extracted_fields": {"booking_object_type": "FIT Reservation"}
                """);

        MvcResult result = mockMvc.perform(signedPost(body, "nonce-detail-matrix-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.source_subject").value("M002 SuperAgent intake"))
                .andExpect(jsonPath("$.source_sender_summary").value("guest@example.test"))
                .andExpect(jsonPath("$.source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.external_conversation_id").value("thread-mail-detail-matrix-001"))
                .andExpect(jsonPath("$.conversation_message_count").value(1))
                .andExpect(jsonPath("$.field_contract_version").value("20260711-p0"))
                .andExpect(jsonPath("$.fields.length()").value(18))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].display_name")
                        .value(contains("Confirmation No.")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].result_type")
                        .value(contains("normal_task")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].task_type")
                        .value(contains("New Booking")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].task_subtype")
                        .value(contains("new_fit_reservation")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].default_value_source")
                        .value(contains("AI提取值")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].editable")
                        .value(contains("是")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].write_path")
                        .value(contains("confirmed_payload_json.case_keys.confirmation_number")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].value")
                        .value(contains("CNF-MATRIX-001")));
    }

    @Test
    void shouldSaveDraftPayloadForEditableTaskWithoutChangingAiSnapshot() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-draft-save-001");
        String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-DRAFT-001");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-draft-save-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {
                                    "extracted_fields.room_quantity": 3,
                                    "extracted_fields.pms_room_type_code": "RM3"
                                  }
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(taskId))
                .andExpect(jsonPath("$.task_status").value("PENDING_CONFIRM"))
                .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_items.0.room_quantity']").value(3))
                .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']").value("RM3"))
                .andExpect(jsonPath("$.draft_payload.legacy_field_values['extracted_fields.room_quantity']").value(3))
                .andExpect(jsonPath("$.draft_payload.legacy_field_values['extracted_fields.pms_room_type_code']").value("RM3"));

        String draftPayloadJson = jdbcTemplate.queryForObject("""
                SELECT draft_payload_json
                FROM workflow_reservation_task_card
                WHERE task_id = ?
                """, String.class, Long.valueOf(taskId));
        String aiPayloadJson = jdbcTemplate.queryForObject("""
                SELECT ai_payload_json
                FROM workflow_reservation_task_card
                WHERE task_id = ?
                """, String.class, Long.valueOf(taskId));
        assertThat(draftPayloadJson).contains("\"extracted_fields.room_items.0.room_quantity\":3");
        assertThat(draftPayloadJson).contains("\"extracted_fields.room_quantity\":3");
        assertThat(aiPayloadJson).contains("\"room_quantity\":2");
    }

    @Test
    void shouldConfirmTaskWithMergedPayloadAndMoveToReady() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-confirm-ready-001");
        String body = completeNewBookingBody(source.inboxId().toString(), null);
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-confirm-ready-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {
                                    "extracted_fields.room_quantity": 4,
                                    "extracted_fields.pms_room_type_code": "RM4"
                                  }
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(taskId))
                .andExpect(jsonPath("$.task_status").value("READY"))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.room_quantity']").value(4))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']").value("RM4"))
                .andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.room_quantity']").value(4))
                .andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.pms_room_type_code']").value("RM4"))
                .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].room_quantity").value(4))
                .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code").value("RM4"))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.arrival_date']").value("2026-08-01"))
                .andExpect(jsonPath("$.opera_operations.length()").value(2))
                .andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1))
                .andExpect(jsonPath("$.opera_operations[0].operation_status").value("PENDING"))
                .andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2))
                .andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING"));

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.opera_operations.length()").value(2))
                .andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1))
                .andExpect(jsonPath("$.opera_operations[0].operation_status").value("PENDING"))
                .andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2))
                .andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING"));

        String taskStatus = jdbcTemplate.queryForObject("""
                SELECT task_status
                FROM workflow_reservation_task
                WHERE id = ?
                """, String.class, Long.valueOf(taskId));
        Long confirmedAtCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE id = ?
                  AND confirmed_at IS NOT NULL
                """, Long.class, Long.valueOf(taskId));
        String confirmedPayloadJson = jdbcTemplate.queryForObject("""
                SELECT confirmed_payload_json
                FROM workflow_reservation_task_card
                WHERE task_id = ?
                """, String.class, Long.valueOf(taskId));
        Long auditCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_audit_log
                WHERE task_id = ?
                  AND action = 'TASK_CONFIRM'
                """, Long.class, Long.valueOf(taskId));
        assertThat(taskStatus).isEqualTo("READY");
        assertThat(confirmedAtCount).isEqualTo(1L);
        assertThat(confirmedPayloadJson).contains("\"extracted_fields.room_items.0.room_quantity\":4");
        assertThat(confirmedPayloadJson).contains("\"extracted_fields.room_quantity\":4");
        assertThat(auditCount).isEqualTo(1L);
    }

    @Test
    void shouldRejectConfirmWhenReadonlyRequiredEvidenceFieldMissing() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-readonly-required-missing-001");
        String body = """
                {
                  "source_message_id": "%s",
                  "ai_task_results": [
                    {
                      "source_event_index": 1,
                      "catalog_code": "S01",
                      "skill_id": "S01_new_booking_skill",
                      "result_type": "normal_task",
                      "task_type": "New Booking",
                      "task_subtype": "new_fit_reservation",
                      "current_or_history": "current",
                      "relevant_message_excerpt": "Please handle booking message.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "case_keys": {"confirmation_number": "CNF-CP5-READONLY-MISSING-001"},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation",
                        "arrival_date": "2026-08-01",
                        "departure_date": "2026-08-02",
                        "room_quantity": 2,
                        "room_type": "Deluxe King",
                        "pms_room_type_code": "RM2"
                      },
                      "additional_operations": [],
                      "idempotency_key": null
                    }
                  ],
                  "extraction_warnings": []
                }
                """.formatted(source.inboxId());
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-readonly-required-missing-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {}
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
                .andExpect(jsonPath("$.details[0]").value(containsString("visible_reason")));
    }

    @Test
    void shouldExecuteFirstOperaSimulationOperationAndRecordAttempt() throws Exception {
        String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
                "mail-cp5-opera-execute-001",
                "nonce-cp5-opera-execute-001",
                "CNF-CP5-OPERA-EXEC-001");
        String taskId = taskAndOperationIds[0];
        String firstOperationId = taskAndOperationIds[1];

        mockMvc.perform(post(
                        "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
                        taskId,
                        firstOperationId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "simulate_success": true
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.operation_id").value(firstOperationId))
                .andExpect(jsonPath("$.operation_status").value("SUCCEEDED"))
                .andExpect(jsonPath("$.attempt_count").value(1))
                .andExpect(jsonPath("$.attempts[0].attempt_number").value(1))
                .andExpect(jsonPath("$.attempts[0].attempt_status").value("SUCCEEDED"));

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.opera_operations[0].operation_id").value(firstOperationId))
                .andExpect(jsonPath("$.opera_operations[0].operation_status").value("SUCCEEDED"))
                .andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1))
                .andExpect(jsonPath("$.opera_operations[0].attempts[0].attempt_status").value("SUCCEEDED"));

        Long attemptCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_opera_operation_attempt
                WHERE operation_id = ?
                  AND attempt_status = 'SUCCEEDED'
                """, Long.class, Long.valueOf(firstOperationId));
        Long auditCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_audit_log
                WHERE task_id = ?
                  AND action = 'OPERA_OPERATION_EXECUTE'
                """, Long.class, Long.valueOf(taskId));
        assertThat(attemptCount).isEqualTo(1L);
        assertThat(auditCount).isEqualTo(1L);
    }

    @Test
    void shouldRetryFailedOperaSimulationOperationAndRecordSecondAttempt() throws Exception {
        String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
                "mail-cp5-opera-retry-001",
                "nonce-cp5-opera-retry-001",
                "CNF-CP5-OPERA-RETRY-001");
        String taskId = taskAndOperationIds[0];
        String firstOperationId = taskAndOperationIds[1];

        mockMvc.perform(post(
                        "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
                        taskId,
                        firstOperationId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "simulate_success": false,
                                  "failure_message": "模拟失败"
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.operation_status").value("FAILED"))
                .andExpect(jsonPath("$.attempt_count").value(1))
                .andExpect(jsonPath("$.attempts[0].attempt_status").value("FAILED"));

        mockMvc.perform(post(
                        "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry",
                        taskId,
                        firstOperationId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "simulate_success": true
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.operation_status").value("SUCCEEDED"))
                .andExpect(jsonPath("$.attempt_count").value(2))
                .andExpect(jsonPath("$.attempts[1].attempt_number").value(2))
                .andExpect(jsonPath("$.attempts[1].attempt_status").value("SUCCEEDED"));
    }

    @Test
    void shouldRejectExecutingSecondOperaOperationBeforePreviousSucceeded() throws Exception {
        String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
                "mail-cp5-opera-order-001",
                "nonce-cp5-opera-order-001",
                "CNF-CP5-OPERA-ORDER-001");
        String taskId = taskAndOperationIds[0];
        String secondOperationId = taskAndOperationIds[2];

        mockMvc.perform(post(
                        "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
                        taskId,
                        secondOperationId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "simulate_success": true
                                }
                                """))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("OPERA_PREVIOUS_OPERATION_NOT_SUCCEEDED"));
    }

    @Test
    void shouldListTaskAuditLogsForConfirmationAndOperaExecution() throws Exception {
        String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
                "mail-cp5-audit-list-001",
                "nonce-cp5-audit-list-001",
                "CNF-CP5-AUDIT-001");
        String taskId = taskAndOperationIds[0];
        String firstOperationId = taskAndOperationIds[1];

        mockMvc.perform(post(
                        "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
                        taskId,
                        firstOperationId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "simulate_success": true
                                }
                                """))
                .andExpect(status().isOk());

        mockMvc.perform(get("/api/reservation/tasks/{taskId}/audits", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(taskId))
                .andExpect(jsonPath("$.items[?(@.action=='TASK_CONFIRM')].action")
                        .value(org.hamcrest.Matchers.contains("TASK_CONFIRM")))
                .andExpect(jsonPath("$.items[?(@.action=='OPERA_OPERATION_EXECUTE')].action")
                        .value(org.hamcrest.Matchers.contains("OPERA_OPERATION_EXECUTE")));
    }

    @Test
    void shouldRejectConfirmWhenRequiredMatrixFieldMissing() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-required-missing-001");
        String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {},
                "extracted_fields": {
                  "booking_object_type": "FIT Reservation"
                }
                """);
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-required-missing-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {}
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
                .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.arrival_date")));
    }

    @Test
    void shouldRejectConfirmWhenEnumValueIsOutsideMatrixOptions() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-enum-invalid-001");
        String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-ENUM-001");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-enum-invalid-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {
                                    "extracted_fields.pms_room_type_code": "BAD_ROOM"
                                  }
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
                .andExpect(jsonPath("$.details[0]")
                        .value(containsString("extracted_fields.room_items.0.pms_room_type_code")));
    }

    @Test
    void shouldRejectConfirmWhenDateRuleIsInvalid() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-date-invalid-001");
        String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-DATE-001");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-date-invalid-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {
                                    "extracted_fields.arrival_date": "not-a-date"
                                  }
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
                .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.arrival_date")));
    }

    @Test
    void shouldRejectDraftSaveWhenPreviousQueueTaskBlocksCurrentTask() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-draft-blocked-001");
        String body = twoTaskBody(source.inboxId().toString(), "GRP-CP5-BLOCKED-001");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-draft-blocked-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String secondTaskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[1].task_id"
        );

        mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", secondTaskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {
                                    "extracted_fields.update_subtypes[]": ["update_stay_dates"]
                                  }
                                }
                                """))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error_code").value("TASK_READ_ONLY"));
    }

    @Test
    void shouldResolveTypeKnownManualReviewOnSameTaskAndMoveToReady() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-resolve-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-resolve-001",
                "CNF-V3-MR-RESOLVE-001",
                "2026-09-01",
                "2026-09-03");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-resolve-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_REVIEW"))
                .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"))
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");
        String orderId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].order_id");
        Long sourceTaskCountBefore = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());

        mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result_type").value("manual_review"))
                .andExpect(jsonPath("$.review_status").value("PENDING"))
                .andExpect(jsonPath("$.manual_review.reason_code").value("missing_room_type_code"))
                .andExpect(jsonPath("$.manual_review.missing_fields[0]").value("/extracted_fields/pms_room_type_code"));

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "reason": "确认 PMS 房型代码后解阻。",
                                  "field_overrides": [
                                    {
                                      "field_pointer": "/extracted_fields/pms_room_type_code",
                                      "value": "RM3"
                                    }
                                  ]
                                }
                                """.formatted(orderId)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(taskId))
                .andExpect(jsonPath("$.order_id").value(orderId))
                .andExpect(jsonPath("$.task_status").value("READY"))
                .andExpect(jsonPath("$.review_status").value("RESOLVED"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
                        .value("/extracted_fields/pms_room_type_code"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
                        .value("extracted_fields.room_items.0.pms_room_type_code"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].legacy_field_path")
                        .value("extracted_fields.pms_room_type_code"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].value").value("RM3"))
                .andExpect(jsonPath("$.review_resolution.resolved_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
                        .value("RM3"))
                .andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.pms_room_type_code']")
                        .value("RM3"))
                .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code")
                        .value("RM3"))
                .andExpect(jsonPath("$.opera_operations.length()").value(2));

        Long sourceTaskCountAfter = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        String taskStatus = jdbcTemplate.queryForObject("""
                SELECT task_status
                FROM workflow_reservation_task
                WHERE id = ?
                """, String.class, Long.valueOf(taskId));
        String reviewStatus = jdbcTemplate.queryForObject("""
                SELECT review_status
                FROM workflow_reservation_task_card
                WHERE task_id = ?
                """, String.class, Long.valueOf(taskId));
        String reviewResolutionJson = jdbcTemplate.queryForObject("""
                SELECT review_resolution_json
                FROM workflow_reservation_task_card
                WHERE task_id = ?
                """, String.class, Long.valueOf(taskId));
        String aiPayloadJson = jdbcTemplate.queryForObject("""
                SELECT ai_payload_json
                FROM workflow_reservation_task_card
                WHERE task_id = ?
                """, String.class, Long.valueOf(taskId));
        Long auditCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_audit_log
                WHERE task_id = ?
                  AND action = 'MANUAL_REVIEW_RESOLVE'
                """, Long.class, Long.valueOf(taskId));
        assertThat(sourceTaskCountAfter).isEqualTo(sourceTaskCountBefore);
        assertThat(taskStatus).isEqualTo("READY");
        assertThat(reviewStatus).isEqualTo("RESOLVED");
        assertThat(reviewResolutionJson).contains("\"field_pointer\":\"/extracted_fields/pms_room_type_code\"");
        assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"RM3\"");
        assertThat(auditCount).isEqualTo(1L);
    }

    @Test
    void shouldResolveTypeKnownManualReviewFromP0FieldPath() throws Exception {
        captureSourceMessage("mail-v3-manual-review-field-path-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-field-path-001",
                "CNF-V3-MR-FIELD-PATH-001",
                "2026-09-02",
                "2026-09-04");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-field-path-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");
        String orderId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].order_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "field_overrides": [
                                    {
                                      "field_path": "extracted_fields.room_items.0.pms_room_type_code",
                                      "value": "RM3"
                                    }
                                  ]
                                }
                                """.formatted(orderId)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_status").value("READY"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
                        .value("/extracted_fields/room_items/0/pms_room_type_code"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
                        .value("extracted_fields.room_items.0.pms_room_type_code"))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
                        .value("RM3"));
    }

    @Test
    void shouldResolveTypeKnownManualReviewFromLegacyFieldPath() throws Exception {
        captureSourceMessage("mail-v3-manual-review-legacy-field-path-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-legacy-field-path-001",
                "CNF-V3-MR-LEGACY-FIELD-PATH-001",
                "2026-09-02",
                "2026-09-04");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-legacy-field-path-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");
        String orderId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].order_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "field_overrides": [
                                    {
                                      "field_path": "extracted_fields.pms_room_type_code",
                                      "value": "RM3"
                                    }
                                  ]
                                }
                                """.formatted(orderId)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_status").value("READY"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
                        .value("/extracted_fields/room_items/0/pms_room_type_code"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
                        .value("extracted_fields.room_items.0.pms_room_type_code"))
                .andExpect(jsonPath("$.review_resolution.field_overrides[0].legacy_field_path")
                        .value("extracted_fields.pms_room_type_code"))
                .andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.pms_room_type_code']")
                        .value("RM3"));
    }

    @Test
    void shouldRejectGenericConfirmForTypeKnownManualReview() throws Exception {
        captureSourceMessage("mail-v3-manual-review-confirm-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-confirm-001",
                "CNF-V3-MR-CONFIRM-001",
                "2026-09-04",
                "2026-09-06");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-confirm-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {
                                    "extracted_fields.pms_room_type_code": "RM3"
                                  }
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_RESOLUTION_REQUIRED"));

        String taskStatus = jdbcTemplate.queryForObject("""
                SELECT task_status
                FROM workflow_reservation_task
                WHERE id = ?
                """, String.class, Long.valueOf(taskId));
        assertThat(taskStatus).isEqualTo("PENDING_CONFIRM");
    }

    @Test
    void shouldRejectManualReviewResolutionWhenPointerIsNotEditable() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-pointer-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-pointer-001",
                "CNF-V3-MR-POINTER-001",
                "2026-09-05",
                "2026-09-07");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-pointer-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");
        String orderId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].order_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "field_overrides": [
                                    {
                                      "field_pointer": "/manual_review/visible_reason",
                                      "value": "不允许覆盖复核说明"
                                    }
                                  ]
                                }
                                """.formatted(orderId)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"))
                .andExpect(jsonPath("$.details[0]").value(containsString("/manual_review/visible_reason")));

        Long sourceTaskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        assertThat(sourceTaskCount).isEqualTo(1L);
    }

    @Test
    void shouldRejectManualReviewResolutionWhenPointerIsDuplicated() throws Exception {
        captureSourceMessage("mail-v3-manual-review-duplicate-pointer-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-duplicate-pointer-001",
                "CNF-V3-MR-DUP-001",
                "2026-09-05",
                "2026-09-07");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-duplicate-pointer-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");
        String orderId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].order_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "field_overrides": [
                                    {
                                      "field_pointer": "/extracted_fields/pms_room_type_code",
                                      "value": "RM3"
                                    },
                                    {
                                      "field_pointer": "/extracted_fields/pms_room_type_code",
                                      "value": "RM4"
                                    }
                                  ]
                                }
                                """.formatted(orderId)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_DUPLICATE"))
                .andExpect(jsonPath("$.details[0]").value(containsString("/extracted_fields/pms_room_type_code")));

        String taskStatus = jdbcTemplate.queryForObject("""
                SELECT task_status
                FROM workflow_reservation_task
                WHERE id = ?
                """, String.class, Long.valueOf(taskId));
        assertThat(taskStatus).isEqualTo("PENDING_CONFIRM");
    }

    @Test
    void shouldRejectManualReviewResolutionWhenMissingFieldsAreNotResolved() throws Exception {
        captureSourceMessage("mail-v3-manual-review-missing-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-missing-001",
                "CNF-V3-MR-MISSING-001",
                "2026-09-06",
                "2026-09-08");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-missing-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");
        String orderId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].order_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "field_overrides": []
                                }
                                """.formatted(orderId)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
                .andExpect(content().string(containsString("/extracted_fields/pms_room_type_code")));
    }

    @Test
    void shouldRejectManualReviewResolutionWhenConfirmedOrderDoesNotMatchCurrentTask() throws Exception {
        SourceMessageCaptureResult firstSource = captureSourceMessage("mail-v3-manual-review-order-001");
        String body = typeKnownManualReviewBody(
                "mail-v3-manual-review-order-001",
                "CNF-V3-MR-ORDER-001",
                "2026-09-09",
                "2026-09-11");
        MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-order-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id");

        SourceMessageCaptureResult otherSource = captureSourceMessage("mail-v3-manual-review-other-order-001");
        String otherBody = completeNewBookingBody(otherSource.inboxId().toString(), "CNF-V3-MR-OTHER-001");
        MvcResult otherResult = mockMvc.perform(signedPost(otherBody, "nonce-v3-manual-review-other-order-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String otherOrderId = com.jayway.jsonpath.JsonPath.read(
                otherResult.getResponse().getContentAsString(),
                "$.items[0].order_id");

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "field_overrides": [
                                    {
                                      "field_pointer": "/extracted_fields/pms_room_type_code",
                                      "value": "RM3"
                                    }
                                  ]
                                }
                                """.formatted(otherOrderId)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("REVIEW_ORDER_ASSIGNMENT_MISMATCH"));
    }

    @Test
    void shouldNotMarkHistoricalPayloadRowsAsP0ContractInV18Migration() throws Exception {
        ClassPathResource migration = new ClassPathResource(
                "db/migration/V18__update_reservation_field_contract_version.sql");
        assertThat(migration.exists()).isTrue();

        String sql = migration.getContentAsString(StandardCharsets.UTF_8);
        assertThat(sql).contains("field_contract_version = 'code-v1'");
        assertThat(sql).contains("draft_payload_json IS NULL");
        assertThat(sql).contains("confirmed_payload_json IS NULL");
    }

    @Test
    void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception {
        SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001");
        String targetOrderBody = minimalBody(targetSource.inboxId().toString(), "New Booking", "normal_task", "new_group_block", """
                "case_keys": {"group_code": "GRP-FALLBACK-TARGET-001"},
                "extracted_fields": {"booking_object_type": "Group Block"}
                """);
        MvcResult targetOrderResult = mockMvc.perform(signedPost(targetOrderBody, "nonce-fallback-target-order-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String targetOrderId = com.jayway.jsonpath.JsonPath.read(
                targetOrderResult.getResponse().getContentAsString(),
                "$.items[0].order_id"
        );

        SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-001");
        String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
                "case_keys": {"group_code": "GRP-FALLBACK-CANDIDATE-001"},
                "manual_review": {"reason_code": "business_boundary_unclear", "visible_reason": "Need human routing."},
                "extracted_fields": {}
                """);
        MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
                fallbackResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );
        String temporaryOrderId = com.jayway.jsonpath.JsonPath.read(
                fallbackResult.getResponse().getContentAsString(),
                "$.items[0].order_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "target_task_type": "UPDATE_BOOKING",
                                  "target_order_id": "%s"
                                }
                                """.formatted(targetOrderId)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(fallbackTaskId))
                .andExpect(jsonPath("$.order_id").value(targetOrderId))
                .andExpect(jsonPath("$.system_task_type").value("UPDATE_BOOKING"))
                .andExpect(jsonPath("$.task_card_type").value("UPDATE_BOOKING"))
                .andExpect(jsonPath("$.original_order_logic_deleted").value(true));

        String tempOrderStatus = jdbcTemplate.queryForObject("""
                SELECT order_status
                FROM workflow_reservation_order
                WHERE id = ?
                """, String.class, Long.valueOf(temporaryOrderId));
        Long auditCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_audit_log
                WHERE task_id = ?
                  AND action = 'MANUAL_REVIEW_CONVERT'
                """, Long.class, Long.valueOf(fallbackTaskId));
        assertThat(tempOrderStatus).isEqualTo("LOGIC_DELETED");
        assertThat(auditCount).isEqualTo(1L);
    }

    @Test
    void shouldConvertFallbackToNewBookingAndKeepOriginalTemporaryOrder() throws Exception {
        SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-new-001");
        String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
                "case_keys": {"confirmation_number": "CNF-FALLBACK-NEW-001"},
                "manual_review": {"reason_code": "business_boundary_unclear", "visible_reason": "Need human routing."},
                "extracted_fields": {}
                """);
        MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-new-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
                fallbackResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );
        String temporaryOrderId = com.jayway.jsonpath.JsonPath.read(
                fallbackResult.getResponse().getContentAsString(),
                "$.items[0].order_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "target_task_type": "NEW_BOOKING"
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(fallbackTaskId))
                .andExpect(jsonPath("$.order_id").value(temporaryOrderId))
                .andExpect(jsonPath("$.system_task_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING"))
                .andExpect(jsonPath("$.original_order_logic_deleted").value(false));

        String tempOrderStatus = jdbcTemplate.queryForObject("""
                SELECT order_status
                FROM workflow_reservation_order
                WHERE id = ?
                """, String.class, Long.valueOf(temporaryOrderId));
        assertThat(tempOrderStatus).isEqualTo("ACTIVE");
        String orderBusinessKey = jdbcTemplate.queryForObject("""
                SELECT order_business_key
                FROM workflow_reservation_order
                WHERE id = ?
                """, String.class, Long.valueOf(temporaryOrderId));
        String activeBusinessKey = jdbcTemplate.queryForObject("""
                SELECT active_business_key
                FROM workflow_reservation_order
                WHERE id = ?
                """, String.class, Long.valueOf(temporaryOrderId));
        String displayName = jdbcTemplate.queryForObject("""
                SELECT display_name
                FROM workflow_reservation_order
                WHERE id = ?
                """, String.class, Long.valueOf(temporaryOrderId));
        assertThat(orderBusinessKey).isEqualTo("CNF-FALLBACK-NEW-001");
        assertThat(activeBusinessKey).isEqualTo("CNF-FALLBACK-NEW-001");
        assertThat(displayName).isEqualTo("CNF-FALLBACK-NEW-001");
    }

    @Test
    void shouldConvertFallbackToCancelBookingWhenTargetOrderProvided() throws Exception {
        SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-cancel-target-001");
        String targetOrderBody = minimalBody(targetSource.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": {"confirmation_number": "CNF-FALLBACK-CANCEL-001"},
                "extracted_fields": {"booking_object_type": "FIT Reservation"}
                """);
        MvcResult targetOrderResult = mockMvc.perform(signedPost(targetOrderBody, "nonce-fallback-cancel-target-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String targetOrderId = com.jayway.jsonpath.JsonPath.read(
                targetOrderResult.getResponse().getContentAsString(),
                "$.items[0].order_id"
        );

        SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-convert-cancel-001");
        String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
                "case_keys": {},
                "manual_review": {"reason_code": "route_conflict", "visible_reason": "Need human routing."},
                "extracted_fields": {}
                """);
        MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-convert-cancel-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
                fallbackResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "target_task_type": "CANCEL_BOOKING",
                                  "target_order_id": "%s"
                                }
                                """.formatted(targetOrderId)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_id").value(fallbackTaskId))
                .andExpect(jsonPath("$.order_id").value(targetOrderId))
                .andExpect(jsonPath("$.system_task_type").value("CANCEL_BOOKING"))
                .andExpect(jsonPath("$.task_card_type").value("CANCEL_BOOKING"))
                .andExpect(jsonPath("$.original_order_logic_deleted").value(true));
    }

    @Test
    void shouldRequireTargetOrderWhenConvertingFallbackToUpdateOrCancel() throws Exception {
        SourceMessageCaptureResult fallbackSource = captureSourceMessage("mail-fallback-target-required-001");
        String fallbackBody = minimalBody(fallbackSource.inboxId().toString(), "Fallback", "manual_review", "manual_review", """
                "case_keys": {},
                "manual_review": {"reason_code": "route_conflict", "visible_reason": "Need human routing."},
                "extracted_fields": {}
                """);
        MvcResult fallbackResult = mockMvc.perform(signedPost(fallbackBody, "nonce-fallback-target-required-001"))
                .andExpect(status().isCreated())
                .andReturn();
        String fallbackTaskId = com.jayway.jsonpath.JsonPath.read(
                fallbackResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-conversions", fallbackTaskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "target_task_type": "CANCEL_BOOKING"
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TARGET_ORDER_REQUIRED"));
    }

    private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
        return captureSourceMessage(externalMessageId, "HOTEL-TEST");
    }

    private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId) {
        return captureSourceMessage(externalMessageId, hotelId, "EMAIL");
    }

    private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId, String channel) {
        String providerFrameId = "frame-" + externalMessageId;
        if (providerFrameId.length() > 128) {
            providerFrameId = "frame-" + Integer.toHexString(externalMessageId.hashCode());
        }
        return captureService.capture(new CaptureSourceMessageCommand(
                hotelId,
                "AGENTBUS",
                channel,
                externalMessageId,
                "thread-" + externalMessageId,
                providerFrameId,
                "session-m002",
                Instant.parse("2026-07-07T08:00:00Z"),
                "guest@example.test",
                "M002 SuperAgent intake",
                "Please handle booking message.",
                "<html><body>Please handle booking message.</body></html>",
                "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
                "agentbus-outlook-v1",
                List.of()
        ));
    }

    private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPost(
            String body,
            String nonce) throws Exception {
        return signedPostWithClientId(body, nonce, CLIENT_ID);
    }

    private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPlainPost(
            String body,
            String nonce) throws Exception {
        return signedPostWithContentType(body, nonce, MediaType.TEXT_PLAIN);
    }

    private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithContentType(
            String body,
            String nonce,
            MediaType contentType) throws Exception {
        String timestamp = Instant.now().toString();
        return post(ENDPOINT)
                .contentType(contentType)
                .content(body)
                .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
                .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
                .header("X-TH-Hotel-SuperAgent-Nonce", nonce)
                .header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp, CLIENT_ID));
    }

    private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder signedPostWithClientId(
            String body,
            String nonce,
            String clientId) throws Exception {
        String timestamp = Instant.now().toString();
        return post(ENDPOINT)
                .contentType(MediaType.APPLICATION_JSON)
                .content(body)
                .header("X-TH-Hotel-SuperAgent-Client-Id", clientId)
                .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
                .header("X-TH-Hotel-SuperAgent-Nonce", nonce)
                .header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp, clientId));
    }

    private String minimalBody(
            String sourceMessageId,
            String taskType,
            String resultType,
            String taskSubtype,
            String itemFields) {
        return """
                {
                  "source_message_id": "%s",
                  "ai_task_results": [
                    {
                      "source_event_index": 1,
                      "catalog_code": "S01",
                      "skill_id": "S01_new_booking_skill",
                      "result_type": "%s",
                      "task_type": "%s",
                      "task_subtype": "%s",
                      "current_or_history": "current",
                      "visible_reason": "AI extracted a task result.",
                      "relevant_message_excerpt": "Please handle booking message.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      %s,
                      "additional_operations": [],
                      "idempotency_key": null
                    }
                  ],
                  "extraction_warnings": []
                }
                """.formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields);
    }

    private String minimalBodyWithHotel(
            String sourceMessageId,
            String taskType,
            String resultType,
            String taskSubtype,
            String itemFields) {
        return """
                {
                  "hotel_id": "HOTEL-TEST",
                  "source_message_id": "%s",
                  "ai_task_results": [
                    {
                      "source_event_index": 1,
                      "catalog_code": "S01",
                      "skill_id": "S01_new_booking_skill",
                      "result_type": "%s",
                      "task_type": "%s",
                      "task_subtype": "%s",
                      "current_or_history": "current",
                      "visible_reason": "AI extracted a task result.",
                      "relevant_message_excerpt": "Please handle booking message.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      %s,
                      "additional_operations": [],
                      "idempotency_key": null
                    }
                  ],
                  "extraction_warnings": []
                }
                """.formatted(sourceMessageId, resultType, taskType, taskSubtype, itemFields);
    }

    private String minimalBodyWithHotelAndSourceMetadata(
            String sourceMessageId,
            String sourceProvider,
            String sourceChannel,
            String taskType,
            String resultType,
            String taskSubtype,
            String itemFields) {
        return """
                {
                  "hotel_id": "HOTEL-TEST",
                  "source_provider": "%s",
                  "source_channel": "%s",
                  "source_message_id": "%s",
                  "ai_task_results": [
                    {
                      "source_event_index": 1,
                      "catalog_code": "S01",
                      "skill_id": "S01_new_booking_skill",
                      "result_type": "%s",
                      "task_type": "%s",
                      "task_subtype": "%s",
                      "current_or_history": "current",
                      "visible_reason": "AI extracted a task result.",
                      "relevant_message_excerpt": "Please handle booking message.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      %s,
                      "additional_operations": [],
                      "idempotency_key": null
                    }
                  ],
                  "extraction_warnings": []
                }
                """.formatted(sourceProvider, sourceChannel, sourceMessageId, resultType, taskType, taskSubtype, itemFields);
    }

    private String twoTaskBody(String sourceMessageId, String groupCode) {
        return """
                {
                  "source_message_id": "%s",
                  "ai_task_results": [
                    {
                      "source_event_index": 1,
                      "catalog_code": "S01",
                      "skill_id": "S01_new_booking_skill",
                      "result_type": "normal_task",
                      "task_type": "New Booking",
                      "task_subtype": "new_group_block",
                      "current_or_history": "current",
                      "visible_reason": "AI extracted a new booking.",
                      "relevant_message_excerpt": "Please create the group booking.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "case_keys": {"group_code": "%s"},
                      "extracted_fields": {"booking_object_type": "Group Block"},
                      "additional_operations": [],
                      "idempotency_key": null
                    },
                    {
                      "source_event_index": 2,
                      "catalog_code": "S02",
                      "skill_id": "S02_update_booking_amendment_skill",
                      "result_type": "normal_task",
                      "task_type": "Update Booking",
                      "task_subtype": "update_stay_dates",
                      "current_or_history": "current",
                      "visible_reason": "AI extracted an update.",
                      "relevant_message_excerpt": "Please update the group booking.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "case_keys": {"group_code": "%s"},
                      "extracted_fields": {"update_subtypes": ["update_stay_dates"]},
                      "additional_operations": [],
                      "idempotency_key": null
                    }
                  ],
                  "extraction_warnings": []
                }
                """.formatted(sourceMessageId, groupCode, groupCode);
    }

    private String completeNewBookingBody(String sourceMessageId, String confirmationNumber) {
        String caseKeys = confirmationNumber == null || confirmationNumber.isBlank()
                ? "{}"
                : "{\"confirmation_number\": \"" + confirmationNumber + "\"}";
        return minimalBody(sourceMessageId, "New Booking", "normal_task", "new_fit_reservation", """
                "case_keys": %s,
                "extracted_fields": {
                  "booking_object_type": "FIT Reservation",
                  "arrival_date": "2026-08-01",
                  "departure_date": "2026-08-02",
                  "room_quantity": 2,
                  "room_type": "Deluxe King",
                  "pms_room_type_code": "RM2"
                }
                """.formatted(caseKeys));
    }

    private String typeKnownManualReviewBody(
            String externalSourceMessageId,
            String confirmationNumber,
            String arrivalDate,
            String departureDate) {
        return """
                {
                  "source_message": {
                    "source_message_id": "%s",
                    "subject": "Manual review new booking",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "New Booking",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E1",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": null,
                        "confirmation_number": "%s",
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Please create a new FIT reservation with manual room type review.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation",
                        "arrival_date": "%s",
                        "departure_date": "%s",
                        "room_quantity": 2,
                        "room_type": "Deluxe King",
                        "pms_room_type_code": null
                      },
                      "manual_review": {
                        "review_record_type": "business_event_review",
                        "reason_code": "missing_room_type_code",
                        "visible_reason": "需要人工确认 PMS 房型代码。",
                        "known_fields": {
                          "/extracted_fields/booking_object_type": "FIT Reservation",
                          "/extracted_fields/room_type": "Deluxe King"
                        },
                        "missing_fields": [
                          "/extracted_fields/pms_room_type_code"
                        ],
                        "blocking_points": [
                          "缺少 PMS 房型代码，不能进入 OPERA 写入。"
                        ],
                        "conflicting_points": [],
                        "suggested_human_actions": [
                          "查看邮件和附件后补充 PMS 房型代码。"
                        ],
                        "evidence_to_check": [
                          "邮件正文房型描述"
                        ]
                      }
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """.formatted(externalSourceMessageId, confirmationNumber, arrivalDate, departureDate);
    }

    private String v3BusinessRootBody(String externalSourceMessageId, String confirmationNumber) {
        return """
                {
                  "source_message": {
                    "source_message_id": "%s",
                    "subject": "Outlook new booking",
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "message_events": [
                    {
                      "event_type": "New Booking",
                      "event_role": "travel_agent_request",
                      "source_event_index": "E1",
                      "current_or_history": "current",
                      "case_keys": {
                        "group_code": null,
                        "confirmation_number": "%s",
                        "reservation_number": null,
                        "block_code": null
                      },
                      "relevant_message_excerpt": "Please create a new FIT reservation.",
                      "attachments": [],
                      "file_references": [],
                      "context_used": {},
                      "extracted_fields": {
                        "booking_object_type": "FIT Reservation",
                        "arrival_date": "2026-09-01"
                      },
                      "manual_review": null
                    }
                  ],
                  "case_candidates": [],
                  "extraction_warnings": [],
                  "unhandled_current_intents": []
                }
                """.formatted(externalSourceMessageId, confirmationNumber);
    }

    private String structuredS10Body(String externalSourceMessageId) {
        return """
                {
                  "source_message": {
                    "source_message_id": "%s",
                    "subject": null,
                    "from": null,
                    "cc": [],
                    "received_at": null,
                    "source_channel": "Email"
                  },
                  "route_code": "S10",
                  "handler_type": "main_agent_outcome",
                  "result_type": "source_message_review_notification",
                  "current_or_history": "current",
                  "agent_assessment": {
                    "status": "no_booking_action_detected",
                    "reason_code": "no_booking_action_detected",
                    "automation_action": "none"
                  },
                  "notification": {
                    "required": true,
                    "notification_type": "source_message_review",
                    "show_source_message": true,
                    "requires_user_decision": true,
                    "visible_message": "未匹配到当前 Agent 支持的业务事件类型，请查看原邮件并决定是否需要回复或进行其他处理。"
                  },
                  "manual_review": null
                }
                """.formatted(externalSourceMessageId);
    }

    private String[] createReadyTaskWithTwoOperaOperations(
            String externalMessageId,
            String nonce,
            String confirmationNumber) throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage(externalMessageId);
        String body = completeNewBookingBody(source.inboxId().toString(), confirmationNumber);
        MvcResult createResult = mockMvc.perform(signedPost(body, nonce))
                .andExpect(status().isCreated())
                .andReturn();
        String taskId = com.jayway.jsonpath.JsonPath.read(
                createResult.getResponse().getContentAsString(),
                "$.items[0].task_id"
        );

        MvcResult confirmResult = mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {}
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.opera_operations.length()").value(2))
                .andReturn();
        String firstOperationId = com.jayway.jsonpath.JsonPath.read(
                confirmResult.getResponse().getContentAsString(),
                "$.opera_operations[0].operation_id"
        );
        String secondOperationId = com.jayway.jsonpath.JsonPath.read(
                confirmResult.getResponse().getContentAsString(),
                "$.opera_operations[1].operation_id"
        );
        return new String[] {taskId, firstOperationId, secondOperationId};
    }

    private String signature(String body, String nonce, String timestamp) throws Exception {
        return signature(body, nonce, timestamp, CLIENT_ID);
    }

    private String signature(String body, String nonce, String timestamp, String clientId) throws Exception {
        String bodySha256 = sha256(body);
        String canonical = "POST\n" + ENDPOINT + "\n" + timestamp + "\n" + nonce + "\n" + clientId + "\n" + bodySha256;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
    }

    private String sha256(String body) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        return HexFormat.of().formatHex(digest.digest(body.getBytes(StandardCharsets.UTF_8)));
    }
}
