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

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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.request.CaptureSourceMessageMedia;
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.time.LocalDateTime;
import java.time.ZoneOffset;
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.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

@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"
        })
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationAiQueryControllerTest {

    private static final String HOTEL_ID = "HOTEL-TEST";
    private static final String CASE_CONTEXT_ENDPOINT = "/api/ai-query/v1/case-context";
    private static final String OBJECT_DETAIL_ENDPOINT = "/api/ai-query/v1/object-detail";
    private static final String CONVERSATION_TASKS_ENDPOINT = "/api/ai-query/v1/message-conversation/tasks";
    private static final String CONVERSATION_MESSAGES_ENDPOINT = "/api/ai-query/v1/message-conversation/messages";
    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 shouldReturnCaseContextWithMatchedOrderAndPendingTask() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-case-001");
        insertActiveGroupOrder(920000000000000101L, source.inboxId(), "GRP-AIQUERY-001");
        insertTransition(920000000000000201L, source.inboxId(), 1, "GRP-AIQUERY-001");
        insertTask(920000000000000301L, 920000000000000101L, source.inboxId(), 920000000000000201L, "PENDING_CONFIRM");

        String body = """
                                {
                                  "hotel_id": "HOTEL-TEST",
                                  "source_message_id": "%s",
                                  "source_event_index": 1,
                                  "group_code": "GRP-AIQUERY-001",
                                  "target_key_source": "body_current",
                                  "body_thread_used_only_as_evidence": false
                                }
                                """.formatted("mail-ai-query-case-001");

        mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-case-001", "req-ai-query-case-001")
                        .header("X-AI-Trace-Id", "trace-ai-query-case-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.request_id").value("req-ai-query-case-001"))
                .andExpect(jsonPath("$.trace_id").value("trace-ai-query-case-001"))
                .andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000101"))
                .andExpect(jsonPath("$.data.matched_order_records[0].object_type").value("group_block"))
                .andExpect(jsonPath("$.data.matched_order_records[0].order_id").value("920000000000000101"))
                .andExpect(jsonPath("$.data.matched_order_records[0].group_code").value("GRP-AIQUERY-001"))
                .andExpect(jsonPath("$.data.matched_order_records[0].last_updated_at")
                        .value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_id").value("920000000000000301"))
                .andExpect(jsonPath("$.data.pending_or_open_tasks[0].source_event_index").value(1))
                .andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_status").value("PENDING_CONFIRM"))
                .andExpect(jsonPath("$.data.pending_or_open_tasks[0].last_updated_at")
                        .value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.data.active_workflows.length()").value(0))
                .andExpect(jsonPath("$.data.terminated_records.length()").value(0))
                .andExpect(jsonPath("$.data.target_object_validation.status").value("conflict"))
                .andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(false))
                .andExpect(jsonPath("$.data.target_object_validation.can_create_update_task").value(false))
                .andExpect(jsonPath("$.data.target_object_validation.needs_manual_review_reason").value("OPEN_TASK_EXISTS"))
                .andExpect(jsonPath("$.data.key_relationships.group_code_and_confirmation_same_object").value(nullValue()))
                .andExpect(content().string(not(containsString("Please handle booking message"))));
    }

    @Test
    void shouldReturnSuccessfulEmptyCaseContextWhenNoObjectMatches() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-empty-001");

        String body = """
                                {
                                  "source_message_id": "%s",
                                  "source_event_index": 1,
                                  "group_code": "GRP-AIQUERY-NOT-FOUND"
                                }
                                """.formatted("mail-ai-query-empty-001");

        mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-empty-001", "req-ai-query-empty-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
                .andExpect(jsonPath("$.data.pending_or_open_tasks.length()").value(0))
                .andExpect(jsonPath("$.data.active_workflows.length()").value(0))
                .andExpect(jsonPath("$.data.terminated_records.length()").value(0))
                .andExpect(jsonPath("$.data.target_object_validation.status").value("none"))
                .andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(true));
    }

    @Test
    void shouldNotAllowCreationWhenOnlyUnsupportedReservationNoProvided() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-reservation-no-001");

        String body = """
                                {
                                  "hotel_id": "HOTEL-TEST",
                                  "source_message_id": "%s",
                                  "source_event_index": 1,
                                  "reservation_no": "RESV-AIQUERY-001"
                                }
                                """.formatted("mail-ai-query-reservation-no-001");

        mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-reservation-no-001",
                        "req-ai-query-reservation-no-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
                .andExpect(jsonPath("$.data.pending_or_open_tasks.length()").value(0))
                .andExpect(jsonPath("$.data.target_object_validation.status").value("conflict"))
                .andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(false))
                .andExpect(jsonPath("$.data.target_object_validation.needs_manual_review_reason")
                        .value("UNSUPPORTED_RESERVATION_NO_QUERY"));
    }

    @Test
    void shouldAllowGlobalCaseContextQueryWithoutSourceMessageAndEventIndex() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-global-001");
        insertActiveGroupOrder(920000000000000501L, source.inboxId(), "GRP-AIQUERY-GLOBAL-001");
        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "group_code": "GRP-AIQUERY-GLOBAL-001"
                }
                """;

        mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-global-001",
                        "req-ai-query-global-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000501"))
                .andExpect(jsonPath("$.data.target_object_validation.status").value("single"));
    }

    @Test
    void shouldIgnoreExternalSourceMessageIdInGlobalCaseContextQuery() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-external-001");
        insertActiveGroupOrder(920000000000000601L, source.inboxId(), "GRP-AIQUERY-EXTERNAL-001");
        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "source_message_id": "mail-ai-query-external-001",
                  "source_event_index": -1,
                  "group_code": "GRP-AIQUERY-EXTERNAL-001"
                }
                """;

        mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-external-001",
                        "req-ai-query-external-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000601"))
                .andExpect(jsonPath("$.data.target_object_validation.status").value("single"));
    }

    @Test
    void shouldRejectCaseContextWhenHmacSignatureInvalid() throws Exception {
        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "source_event_index": 1,
                  "group_code": "GRP-AIQUERY-HMAC-INVALID"
                }
                """;

        mockMvc.perform(post(CASE_CONTEXT_ENDPOINT)
                        .contentType(MediaType.APPLICATION_JSON)
                        .header("X-TH-Hotel-Request-Id", "req-ai-query-hmac-invalid")
                        .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
                        .header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
                        .header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-hmac-invalid")
                        .header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
                        .content(body))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
                .andExpect(content().string(not(containsString(SECRET))));
    }

    @Test
    void shouldReturnUnifiedErrorWhenCaseContextJsonInvalid() throws Exception {
        String body = "{\"hotel_id\":";

        mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-invalid-json",
                        "req-ai-query-invalid-json"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.request_id").value("req-ai-query-invalid-json"))
                .andExpect(jsonPath("$.error.code").value("REQUEST_BODY_INVALID"))
                .andExpect(content().string(not(containsString(SECRET))));
    }

    @Test
    void shouldReturnUnifiedErrorWhenCaseContextContentTypeUnsupported() throws Exception {
        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "group_code": "GRP-AIQUERY-CONTENT-TYPE"
                }
                """;
        String timestamp = Instant.now().toString();

        mockMvc.perform(post(CASE_CONTEXT_ENDPOINT)
                        .contentType(MediaType.TEXT_PLAIN)
                        .header("X-TH-Hotel-Request-Id", "req-ai-query-content-type")
                        .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
                        .header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
                        .header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-content-type")
                        .header("X-TH-Hotel-SuperAgent-Signature",
                                signature(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-content-type", timestamp))
                        .content(body))
                .andExpect(status().isUnsupportedMediaType())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.request_id").value("req-ai-query-content-type"))
                .andExpect(jsonPath("$.error.code").value("REQUEST_CONTENT_TYPE_UNSUPPORTED"))
                .andExpect(content().string(not(containsString(SECRET))));
    }

    @Test
    void shouldReturnObjectDetailWithNullableOperaProjectionWarning() throws Exception {
        SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-detail-001");
        insertActiveGroupOrder(920000000000000401L, source.inboxId(), "GRP-AIQUERY-DETAIL-001");

        String body = """
                                {
                                  "hotel_id": "HOTEL-TEST",
                                  "object_id": "ORDER:920000000000000401",
                                  "object_type": "group_block"
                                }
                                """;

        mockMvc.perform(signedPost(OBJECT_DETAIL_ENDPOINT, body, "nonce-ai-query-detail-001",
                        "req-ai-query-detail-001")
                        .header("X-AI-Trace-Id", "trace-ai-query-detail-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.object_id").value("ORDER:920000000000000401"))
                .andExpect(jsonPath("$.data.object_type").value("group_block"))
                .andExpect(jsonPath("$.data.order_id").value("920000000000000401"))
                .andExpect(jsonPath("$.data.group_code").value("GRP-AIQUERY-DETAIL-001"))
                .andExpect(jsonPath("$.data.reservation_no").value(nullValue()))
                .andExpect(jsonPath("$.data.block_id").value(nullValue()))
                .andExpect(jsonPath("$.data.room_items.length()").value(0))
                .andExpect(jsonPath("$.data.rate_code_price").value(nullValue()))
                .andExpect(jsonPath("$.data.created_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.data.last_updated_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.data.can_update").value(true))
                .andExpect(jsonPath("$.data.can_cancel").value(true))
                .andExpect(jsonPath("$.data.hard_validation_warnings[0].code").value("OPERA_PROJECTION_UNAVAILABLE"));
    }

    @Test
    void shouldRejectObjectDetailWhenHmacSignatureInvalid() throws Exception {
        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "object_id": "ORDER:920000000000000401",
                  "object_type": "group_block"
                }
                """;

        mockMvc.perform(post(OBJECT_DETAIL_ENDPOINT)
                        .contentType(MediaType.APPLICATION_JSON)
                        .header("X-TH-Hotel-Request-Id", "req-ai-query-detail-hmac-invalid")
                        .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
                        .header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
                        .header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-detail-hmac-invalid")
                        .header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
                        .content(body))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.request_id").value("req-ai-query-detail-hmac-invalid"))
                .andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
                .andExpect(content().string(not(containsString(SECRET))));
    }

    @Test
    void shouldQueryConversationMessagesByExternalConversationIdWithoutRawHtmlOrMediaUrls() throws Exception {
        String conversationId = "thread-ai-query-conversation-messages-001";
        captureSourceMessage(
                "mail-ai-query-conversation-messages-late-001",
                conversationId,
                Instant.parse("2026-07-07T09:10:00Z"),
                "Late message controlled text.",
                "<html><body><p>Late HTML body</p></body></html>",
                List.of());
        captureSourceMessage(
                "mail-ai-query-conversation-messages-early-001",
                conversationId,
                Instant.parse("2026-07-07T09:00:00Z"),
                "Early message controlled text.",
                "<html><body onclick=\"alert(1)\"><p>Early HTML body</p>"
                        + "<img src=\"https://media.example.test/inline.png?token=secret\" />"
                        + "<a href=\"https://media.example.test/private.pdf?token=secret\">private file</a>"
                        + "<a href=\"javascript:alert(2)\">unsafe link</a>"
                        + "<script>alert(3)</script></body></html>",
                List.of(new CaptureSourceMessageMedia(
                        "ATTACHMENT",
                        "private.pdf",
                        "application/pdf",
                        1000L,
                        "https://media.example.test/private.pdf?token=secret",
                        "attachment-ai-query-001")));

        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "source_provider": "AGENTBUS",
                  "source_channel": "EMAIL",
                  "external_conversation_id": "%s"
                }
                """.formatted(conversationId);

        mockMvc.perform(signedPost(CONVERSATION_MESSAGES_ENDPOINT, body,
                        "nonce-ai-query-conversation-messages-001",
                        "req-ai-query-conversation-messages-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.request_id").value("req-ai-query-conversation-messages-001"))
                .andExpect(jsonPath("$.data.hotel_id").value(HOTEL_ID))
                .andExpect(jsonPath("$.data.external_conversation_id").value(conversationId))
                .andExpect(jsonPath("$.data.message_count").value(2))
                .andExpect(jsonPath("$.data.messages[0].external_source_message_id")
                        .value("mail-ai-query-conversation-messages-early-001"))
                .andExpect(jsonPath("$.data.messages[0].received_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.data.messages[0].text_body").value(containsString("Early message")))
                .andExpect(jsonPath("$.data.messages[0].html_body_sanitized").value(containsString("Early HTML body")))
                .andExpect(jsonPath("$.data.messages[0].html_render_mode").value("SANITIZED_HTML"))
                .andExpect(jsonPath("$.data.messages[1].external_source_message_id")
                        .value("mail-ai-query-conversation-messages-late-001"))
                .andExpect(content().string(not(containsString("\"html_body\":"))))
                .andExpect(content().string(not(containsString("\"attachments\""))))
                .andExpect(content().string(not(containsString("media.example.test"))))
                .andExpect(content().string(not(containsString("token=secret"))))
                .andExpect(content().string(not(containsString("<script"))))
                .andExpect(content().string(not(containsString("onclick"))))
                .andExpect(content().string(not(containsString("javascript:"))));
    }

    @Test
    void shouldFilterConversationMessagesByProviderAndChannelWhenConversationIdOverlaps() throws Exception {
        String conversationId = "thread-ai-query-provider-overlap-001";
        captureSourceMessage(
                "AGENTBUS",
                "EMAIL",
                "mail-ai-query-provider-target-001",
                conversationId,
                Instant.parse("2026-07-07T09:00:00Z"),
                "Target provider text.",
                "<html><body>Target provider HTML.</body></html>",
                List.of());
        captureSourceMessage(
                "OTHER_PROVIDER",
                "EMAIL",
                "mail-ai-query-provider-other-001",
                conversationId,
                Instant.parse("2026-07-07T09:01:00Z"),
                "Other provider text must not leak.",
                "<html><body>Other provider HTML must not leak.</body></html>",
                List.of());

        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "source_provider": "AGENTBUS",
                  "source_channel": "EMAIL",
                  "external_conversation_id": "%s"
                }
                """.formatted(conversationId);

        mockMvc.perform(signedPost(CONVERSATION_MESSAGES_ENDPOINT, body,
                        "nonce-ai-query-provider-overlap-001",
                        "req-ai-query-provider-overlap-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.message_count").value(1))
                .andExpect(jsonPath("$.data.messages[0].external_source_message_id")
                        .value("mail-ai-query-provider-target-001"))
                .andExpect(content().string(containsString("Target provider text")))
                .andExpect(content().string(not(containsString("Other provider text must not leak"))))
                .andExpect(content().string(not(containsString("mail-ai-query-provider-other-001"))));
    }

    @Test
    void shouldQueryConversationTasksByExternalSourceMessageIdAnchorInRequiredOrder() throws Exception {
        String conversationId = "thread-ai-query-conversation-tasks-001";
        SourceMessageCaptureResult lateSource = captureSourceMessage(
                "mail-ai-query-conversation-tasks-late-001",
                conversationId,
                Instant.parse("2026-07-07T09:10:00Z"),
                "Late task source.",
                "<html><body>Late task source.</body></html>",
                List.of());
        SourceMessageCaptureResult earlySource = captureSourceMessage(
                "mail-ai-query-conversation-tasks-early-001",
                conversationId,
                Instant.parse("2026-07-07T09:00:00Z"),
                "Early task source.",
                "<html><body>Early task source.</body></html>",
                List.of());
        insertActiveGroupOrder(920000000000001101L, earlySource.inboxId(), "GRP-AIQUERY-CONV-EARLY-001");
        insertActiveGroupOrder(920000000000001102L, lateSource.inboxId(), "GRP-AIQUERY-CONV-LATE-001");
        insertTransition(920000000000001201L, earlySource.inboxId(), 1, "GRP-AIQUERY-CONV-EARLY-001");
        insertTransition(920000000000001202L, earlySource.inboxId(), 2, "GRP-AIQUERY-CONV-EARLY-001");
        insertTransition(920000000000001203L, lateSource.inboxId(), 1, "GRP-AIQUERY-CONV-LATE-001");
        insertTask(
                920000000000001301L,
                920000000000001101L,
                earlySource.inboxId(),
                920000000000001201L,
                "READY",
                1,
                LocalDateTime.parse("2026-07-07T09:06:00"));
        insertTask(
                920000000000001302L,
                920000000000001101L,
                earlySource.inboxId(),
                920000000000001202L,
                "PENDING_CONFIRM",
                2,
                LocalDateTime.parse("2026-07-07T09:05:00"));
        insertTask(
                920000000000001303L,
                920000000000001102L,
                lateSource.inboxId(),
                920000000000001203L,
                "READY",
                1,
                LocalDateTime.parse("2026-07-07T08:00:00"));

        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "source_provider": "AGENTBUS",
                  "source_channel": "EMAIL",
                  "source_message_id": "mail-ai-query-conversation-tasks-early-001"
                }
                """;

        mockMvc.perform(signedPost(CONVERSATION_TASKS_ENDPOINT, body,
                        "nonce-ai-query-conversation-tasks-001",
                        "req-ai-query-conversation-tasks-001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.hotel_id").value(HOTEL_ID))
                .andExpect(jsonPath("$.data.external_conversation_id").value(conversationId))
                .andExpect(jsonPath("$.data.task_count").value(3))
                .andExpect(jsonPath("$.data.tasks[0].task_id").value("920000000000001302"))
                .andExpect(jsonPath("$.data.tasks[0].external_source_message_id")
                        .value("mail-ai-query-conversation-tasks-early-001"))
                .andExpect(jsonPath("$.data.tasks[0].task_created_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
                .andExpect(jsonPath("$.data.tasks[1].task_id").value("920000000000001301"))
                .andExpect(jsonPath("$.data.tasks[1].external_source_message_id")
                        .value("mail-ai-query-conversation-tasks-early-001"))
                .andExpect(jsonPath("$.data.tasks[2].task_id").value("920000000000001303"))
                .andExpect(jsonPath("$.data.tasks[2].external_source_message_id")
                        .value("mail-ai-query-conversation-tasks-late-001"));
    }

    @Test
    void shouldRejectConversationMessagesWhenHmacSignatureInvalid() throws Exception {
        String body = """
                {
                  "hotel_id": "HOTEL-TEST",
                  "external_conversation_id": "thread-ai-query-hmac-invalid"
                }
                """;

        mockMvc.perform(post(CONVERSATION_MESSAGES_ENDPOINT)
                        .contentType(MediaType.APPLICATION_JSON)
                        .header("X-TH-Hotel-Request-Id", "req-ai-query-conversation-hmac-invalid")
                        .header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
                        .header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
                        .header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-conversation-hmac-invalid")
                        .header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
                        .content(body))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.request_id").value("req-ai-query-conversation-hmac-invalid"))
                .andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
                .andExpect(content().string(not(containsString(SECRET))));
    }

    private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
        return captureSourceMessage(
                externalMessageId,
                "thread-" + externalMessageId,
                Instant.parse("2026-07-07T08:00:00Z"),
                "Please handle booking message.",
                "<html><body>Please handle booking message.</body></html>",
                List.of());
    }

    private SourceMessageCaptureResult captureSourceMessage(
            String externalMessageId,
            String externalConversationId,
            Instant receivedAt,
            String textBody,
            String htmlBody,
            List<CaptureSourceMessageMedia> mediaItems) {
        return captureSourceMessage(
                "AGENTBUS",
                "EMAIL",
                externalMessageId,
                externalConversationId,
                receivedAt,
                textBody,
                htmlBody,
                mediaItems);
    }

    private SourceMessageCaptureResult captureSourceMessage(
            String provider,
            String channel,
            String externalMessageId,
            String externalConversationId,
            Instant receivedAt,
            String textBody,
            String htmlBody,
            List<CaptureSourceMessageMedia> mediaItems) {
        SourceMessageCaptureResult result = captureService.capture(new CaptureSourceMessageCommand(
                HOTEL_ID,
                provider,
                channel,
                externalMessageId,
                externalConversationId,
                "frame-" + externalMessageId,
                "session-ai-query",
                receivedAt,
                "guest@example.test",
                "M002 AI Query",
                textBody,
                htmlBody,
                "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
                "agentbus-outlook-v1",
                mediaItems
        ));
        jdbcTemplate.update("""
                UPDATE platform_source_message_inbox
                SET received_at = ?
                WHERE id = ?
                """, LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), result.inboxId());
        return result;
    }

    private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
        jdbcTemplate.update("""
                INSERT INTO workflow_reservation_order (
                    id, hotel_id, order_key_type, order_business_key, active_business_key,
                    temporary_order_code, order_status, business_key_source, display_name,
                    source_message_id, version, created_at, updated_at
                )
                VALUES (?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'AI_CANDIDATE', ?, ?, 0,
                    CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
                """, orderId, HOTEL_ID, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId);
    }

    private void insertTransition(Long transitionId, Long sourceMessageId, Integer sourceEventIndex, String groupCode) {
        jdbcTemplate.update("""
                INSERT INTO workflow_reservation_ai_transition (
                    id, hotel_id, batch_id, source_message_id, source_event_index, array_index,
                    execution_order, catalog_code, skill_id, result_type, ai_task_type,
                    system_task_type, task_card_type, task_subtype, current_or_history,
                    group_code, item_payload_sha256, item_idempotency_key, blocked_until_parent_completed,
                    ai_payload_json, case_keys_json, extracted_fields_json, created_at, updated_at
                )
                VALUES (?, ?, ?, ?, ?, 1, 1, 'S02', 'update_booking_amendment_skill',
                    'normal_task', 'Update Booking', 'UPDATE_BOOKING', 'UPDATE_BOOKING',
                    'update_stay_dates', 'current', ?, ?, ?, 0, '{}', '{}', '{}',
                    CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
                """, transitionId, HOTEL_ID, transitionId - 1, sourceMessageId, sourceEventIndex, groupCode,
                fixedHash(transitionId), fixedHash(transitionId + 1));
    }

    private void insertTask(Long taskId, Long orderId, Long sourceMessageId, Long transitionId, String taskStatus) {
        insertTask(taskId, orderId, sourceMessageId, transitionId, taskStatus, 1, null);
    }

    private void insertTask(
            Long taskId,
            Long orderId,
            Long sourceMessageId,
            Long transitionId,
            String taskStatus,
            int executionOrder,
            LocalDateTime createdAt) {
        LocalDateTime createdAtValue = createdAt == null ? LocalDateTime.now(ZoneOffset.UTC) : createdAt;
        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_subtype,
                    task_status, queue_participation, execution_order, blocked_until_parent_completed,
                    version, created_at, updated_at
                )
                VALUES (?, ?, ?, ?, ?, 'normal_task', 'Update Booking', 'UPDATE_BOOKING',
                    'UPDATE_BOOKING', 'update_stay_dates', ?, 1, ?, 0, 0,
                    ?, ?)
                """, taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus,
                executionOrder, createdAtValue, createdAtValue);
    }

    private String fixedHash(Long value) {
        return String.format("%064d", value);
    }

    private MockHttpServletRequestBuilder signedPost(String endpoint, String body, String nonce, String requestId) throws Exception {
        String timestamp = Instant.now().toString();
        return post(endpoint)
                .contentType(MediaType.APPLICATION_JSON)
                .header("X-TH-Hotel-Request-Id", requestId)
                .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(endpoint, body, nonce, timestamp))
                .content(body);
    }

    private String signature(String endpoint, String body, String nonce, String timestamp) throws Exception {
        String canonical = "POST\n" + endpoint + "\n" + timestamp + "\n" + nonce + "\n" + CLIENT_ID + "\n" + sha256(body);
        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)));
    }
}
