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

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
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 com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
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.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.MvcResult;
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=20000"
        })
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SuperAgentTaskResultP0FixtureRegressionTest {

    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 Path FIXTURES_DIR = Path.of(
            "..",
            "docs",
            "import",
            "20260711",
            "开发交付_P0冻结基线_2026-07-11",
            "03_P0_Acceptance",
            "fixtures");

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private SourceMessageCaptureService captureService;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldAcceptLegalS10AndS99MainOutcomeFixtures() throws Exception {
        JsonNode cases = fixture("main_outcomes.json").path("cases");
        JsonNode s10 = caseValue(cases, "legal_s10_no_supported_match");
        JsonNode s99 = caseValue(cases, "legal_s99_material_unavailable");
        String s10ExternalId = "p0-main-outcome-s10-001";
        String s99ExternalId = "p0-main-outcome-s99-001";
        captureSourceMessage(s10ExternalId);
        captureSourceMessage(s99ExternalId);

        MvcResult s10Result = mockMvc.perform(signedPost(withSourceMessageId(s10, s10ExternalId), "nonce-p0-s10-001"))
                .andExpect(status().isCreated())
                .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"))
                .andReturn();
        MvcResult s99Result = mockMvc.perform(signedPost(withSourceMessageId(s99, s99ExternalId), "nonce-p0-s99-001"))
                .andExpect(status().isCreated())
                .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"))
                .andReturn();

        assertSourceMessageOnlyTaskCount(s10Result, "S10");
        assertSourceMessageOnlyTaskCount(s99Result, "S99");
    }

    @Test
    void shouldRejectIllegalMainOutcomeFixturesWithoutCreatingTasks() throws Exception {
        JsonNode cases = fixture("main_outcomes.json").path("cases");
        for (String caseId : List.of(
                "illegal_s10_with_review_object",
                "illegal_s99_with_null_review",
                "illegal_union_template_value")) {
            JsonNode value = caseValue(cases, caseId);
            String externalId = "p0-" + caseId.replace('_', '-');
            SourceMessageCaptureResult source = captureSourceMessage(externalId);

            mockMvc.perform(signedPost(withSourceMessageId(value, externalId), "nonce-" + externalId))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"))
                    .andExpect(content().string(notContainsSecret()));

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

    @Test
    void shouldReturnTypedInfrastructureInputErrorForMissingSourceIdentityFixtures() throws Exception {
        JsonNode cases = fixture("source_identity_errors.json").path("cases");
        for (JsonNode fixtureCase : cases) {
            if (fixtureCase.path("expected_error").isNull()) {
                continue;
            }
            String caseId = fixtureCase.path("case_id").asText();
            String body = objectMapper.writeValueAsString(fixtureCase.path("input"));

            mockMvc.perform(signedPost(body, "nonce-" + caseId))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.result_type").value("infrastructure_input_error"))
                    .andExpect(jsonPath("$.error_code").value("missing_source_message_id"))
                    .andExpect(jsonPath("$.retryable").value(true))
                    .andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id"))
                    .andExpect(jsonPath("$.route_code").doesNotExist())
                    .andExpect(jsonPath("$.message_events").doesNotExist());
        }
    }

    @Test
    void shouldNotRewriteOtherInfrastructureInputErrorsToMissingSourceMessageId() throws Exception {
        mockMvc.perform(signedPost("""
                        {
                          "result_type": "infrastructure_input_error",
                          "error_code": "object_storage_unavailable",
                          "retryable": true,
                          "missing_fields": []
                        }
                        """, "nonce-p0-infra-other-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("INFRASTRUCTURE_INPUT_ERROR"))
                .andExpect(jsonPath("$.result_type").doesNotExist());
    }

    @Test
    void shouldReturnTypedInfrastructureInputErrorWhenV3ShapeMissesSourceMessageObject() throws Exception {
        mockMvc.perform(signedPost("""
                        {
                          "body_current": "Please cancel group HD260710A",
                          "message_events": [],
                          "case_candidates": [],
                          "extraction_warnings": [],
                          "unhandled_current_intents": []
                        }
                        """, "nonce-p0-missing-source-object-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.result_type").value("infrastructure_input_error"))
                .andExpect(jsonPath("$.error_code").value("missing_source_message_id"))
                .andExpect(jsonPath("$.retryable").value(true))
                .andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id"));
    }

    @Test
    void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception {
        JsonNode validInput = caseItem(
                fixture("source_identity_errors.json").path("cases"),
                "nonblank_source_message_id_passes_guard").path("input");

        mockMvc.perform(signedPost(objectMapper.writeValueAsString(validInput), "nonce-p0-source-valid-guard-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_REQUIRED"))
                .andExpect(jsonPath("$.result_type").doesNotExist());
    }

    @Test
    void shouldKeepCandidateGateFixtureAsP0RegressionReference() throws Exception {
        JsonNode cases = fixture("candidate_gate.json").path("cases");

        assertThat(caseIdsByValidity(cases, true)).containsExactly(
                "valid_coarse_attachment_signal",
                "valid_single_supported_direction",
                "valid_main_no_active_signal_routes_s10",
                "valid_skill_no_supported_event_routes_s10");
        assertThat(caseIdsByValidity(cases, false)).containsExactly(
                "invalid_candidate_missing_target_hints",
                "invalid_candidate_wrong_status",
                "invalid_candidate_empty_possible_types",
                "invalid_review_outcome_used_as_candidate",
                "invalid_evidence_refs_string_array",
                "invalid_target_hints_object");

        JsonNode supportedSignal = caseItem(cases, "valid_coarse_attachment_signal")
                .path("candidate_events").get(0);
        assertThat(supportedSignal.path("classification_status").asText()).isEqualTo("coarse_supported_signal");
        assertThat(supportedSignal.path("possible_event_types")).hasSize(2);
        assertThat(supportedSignal.path("evidence_refs").get(0).isObject()).isTrue();
        assertThat(caseItem(cases, "invalid_review_outcome_used_as_candidate")
                .path("candidate_events").get(0).path("possible_event_types").get(0).asText())
                .isEqualTo("Need Manual Review");
    }

    @Test
    void shouldRejectS99WhenMainAgentReviewHasUnexpectedField() throws Exception {
        JsonNode s99 = caseValue(fixture("main_outcomes.json").path("cases"), "legal_s99_material_unavailable");
        ObjectNode copy = s99.deepCopy();
        ((ObjectNode) copy.path("manual_review")).put("unexpected_field", "not-allowed");
        String externalId = "p0-s99-unexpected-review-field-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(copy, externalId), "nonce-p0-s99-extra-field-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));

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

    @Test
    void shouldRejectV3BusinessRootWhenP0RootArraysAreMissing() throws Exception {
        ObjectNode root = objectMapper.createObjectNode();
        ObjectNode sourceMessage = root.putObject("source_message");
        String externalId = "p0-root-arrays-missing-001";
        sourceMessage.put("source_message_id", externalId);
        sourceMessage.put("subject", "P0 fixture");
        sourceMessage.put("from", "agent@example.test");
        sourceMessage.putArray("cc");
        sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00");
        sourceMessage.put("source_channel", "Email");
        root.putArray("message_events")
                .add(fixture("row_multiple_derived.json").path("message_events").get(0));
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(objectMapper.writeValueAsString(root), "nonce-p0-root-arrays-missing-001"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));

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

    @Test
    void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-OK", "CHILD-2608-OK-A", "CHILD-2608-OK-B");
        convertParentSplitRootToP01(root);
        String externalId = "p0-parent-split-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        MvcResult result = mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[0].execution_order").value(1))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].execution_order").value(1))
                .andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL"))
                .andReturn();

        String parentOrderId = com.jayway.jsonpath.JsonPath.read(
                result.getResponse().getContentAsString(),
                "$.items[2].order_id");
        Integer parentExecutionOrder = com.jayway.jsonpath.JsonPath.read(
                result.getResponse().getContentAsString(),
                "$.items[2].execution_order");
        assertThat(parentOrderId).isNotBlank();
        assertThat(parentExecutionOrder).isEqualTo(1);

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

    @Test
    void shouldNormalizeSingleParentBlockCodeAndCreateParentSplitTask() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-BLOCK", "CHILD-2608-BLOCK-A", "CHILD-2608-BLOCK-B");
        convertParentSplitRootToP01(root);
        ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
        ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys");
        parentCaseKeys.putNull("group_code");
        parentCaseKeys.put("block_code", "PARENT-2608-BLOCK");
        String externalId = "p0-parent-split-single-block-code-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-single-block-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL"));

        String parentCaseKeysJson = jdbcTemplate.queryForObject("""
                SELECT case_keys_json
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL'
                """, String.class, source.inboxId());
        JsonNode normalizedCaseKeys = objectMapper.readTree(parentCaseKeysJson);
        assertThat(normalizedCaseKeys.path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK");
        assertThat(normalizedCaseKeys.path("block_code").asText()).isEqualTo("PARENT-2608-BLOCK");

        String parentPayloadJson = jdbcTemplate.queryForObject("""
                SELECT ai_payload_json
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL'
                """, String.class, source.inboxId());
        JsonNode parentPayload = objectMapper.readTree(parentPayloadJson);
        assertThat(parentPayload.path("case_keys").path("group_code").asText()).isEqualTo("PARENT-2608-BLOCK");
        assertThat(parentPayload.path("v3_message_event").path("case_keys").path("group_code").isNull()).isTrue();
        assertThat(parentPayload.path("v3_message_event").path("case_keys").path("block_code").asText())
                .isEqualTo("PARENT-2608-BLOCK");
    }

    @Test
    void shouldCreateParentSplitManualReviewWhenParentKeysConflict() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-CONFLICT", "CHILD-2608-CONFLICT-A", "CHILD-2608-CONFLICT-B");
        convertParentSplitRootToP01(root);
        ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
        ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys");
        parentCaseKeys.putNull("group_code");
        parentCaseKeys.putNull("block_code");
        ObjectNode contextUsed = (ObjectNode) parentEvent.path("context_used");
        ArrayNode candidates = contextUsed.putArray("parent_identity_candidates");
        candidates.addObject()
                .put("field", "group_code")
                .put("value", "PARENT-2608-CONFLICT-A")
                .put("evidence_source", "subject");
        candidates.addObject()
                .put("field", "block_code")
                .put("value", "PARENT-2608-CONFLICT-B")
                .put("evidence_source", "attachment");
        ObjectNode manualReview = parentEvent.putObject("manual_review");
        manualReview.put("reason_code", "target_object_unclear");
        manualReview.put("visible_reason", "Parent Group 的 group_code 与 block_code 原始候选冲突，请人工确认目标。");
        manualReview.put("review_record_type", "business_event_review");
        manualReview.putArray("missing_fields")
                .add("/case_keys/group_code")
                .add("/case_keys/block_code");
        manualReview.putArray("blocking_points")
                .add("Parent Group identity cannot be safely normalized.");
        manualReview.putArray("conflicting_points")
                .add("PARENT-2608-CONFLICT-A")
                .add("PARENT-2608-CONFLICT-B");
        manualReview.putArray("suggested_human_actions")
                .add("confirm_parent_group_identity");
        manualReview.putArray("evidence_to_check")
                .add("parent_identity_candidates");
        manualReview.putObject("known_fields");
        String externalId = "p0-parent-split-key-conflict-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-key-conflict-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW"))
                .andExpect(jsonPath("$.items[2].adapter_error_code").doesNotExist());

        Long parentReviewTaskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task_card card
                JOIN workflow_reservation_task task
                  ON task.id = card.task_id
                JOIN workflow_reservation_ai_transition transition
                  ON transition.id = task.ai_transition_id
                WHERE task.source_message_id = ?
                  AND transition.route_code = 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW'
                  AND task.result_type = 'manual_review'
                """, Long.class, source.inboxId());
        assertThat(parentReviewTaskCount).isEqualTo(1L);
    }

    @Test
    void shouldFailClosedWhenCurrentProducerUsesLegacyParentCancelBooking() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-LEG", "CHILD-2608-LEG-A", "CHILD-2608-LEG-B");
        String externalId = "p0-parent-split-legacy-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-legacy-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].adapter_error_code")
                        .value("LINKED_PARENT_RELEASE_LEGACY_CANCEL_BOOKING_UNSUPPORTED"))
                .andExpect(jsonPath("$.items[2].task_id").doesNotExist());

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

    @Test
    void shouldFailClosedWhenParentSplitReferencesDoNotMatchChildEvents() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-MIS", "CHILD-2608-MIS-A", "CHILD-2608-MIS-B");
        convertParentSplitRootToP01(root);
        ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
        ((ArrayNode) parentEvent.path("related_source_event_indices"))
                .set(1, objectMapper.getNodeFactory().textNode("E_UNKNOWN_CHILD"));
        String externalId = "p0-parent-split-mismatch-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-mismatch-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].adapter_error_code")
                        .value("LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE"))
                .andExpect(jsonPath("$.items[2].task_id").doesNotExist());

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

    @Test
    void shouldFailClosedWhenParentSplitReferenceOrderDoesNotMatchChildCodes() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-ORD", "CHILD-2608-ORD-A", "CHILD-2608-ORD-B");
        convertParentSplitRootToP01(root);
        ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
        ArrayNode relatedIndices = (ArrayNode) parentEvent.path("related_source_event_indices");
        relatedIndices.removeAll();
        relatedIndices.add("E_CHILD_2");
        relatedIndices.add("E_CHILD_1");
        String externalId = "p0-parent-split-order-mismatch-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-order-mismatch-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].adapter_error_code")
                        .value("LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE"))
                .andExpect(jsonPath("$.items[2].task_id").doesNotExist());

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

    @Test
    void shouldFailClosedWhenParentSplitRequiredP01FieldsAreMissing() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-FIX", "CHILD-2608-FIX-A", "CHILD-2608-FIX-B");
        convertParentSplitRootToP01(root);
        ObjectNode parentExtractedFields = (ObjectNode) root.path("message_events").get(2).path("extracted_fields");
        parentExtractedFields.remove("parent_release_or_cancel_candidate");
        String externalId = "p0-parent-split-fixed-field-missing-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-fixed-field-missing-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].adapter_error_code")
                        .value("LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE"))
                .andExpect(jsonPath("$.items[2].task_id").doesNotExist());

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

    @Test
    void shouldFailClosedWhenParentSplitHasDuplicateParentCandidate() throws Exception {
        ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
        useParentSplitBusinessKeys(root, "PARENT-2608-DUP", "CHILD-2608-DUP-A", "CHILD-2608-DUP-B");
        convertParentSplitRootToP01(root);
        ArrayNode events = (ArrayNode) root.path("message_events");
        ObjectNode duplicateParent = events.get(2).deepCopy();
        duplicateParent.put("source_event_index", "E_PARENT_CANCEL_DUP");
        events.add(duplicateParent);
        String externalId = "p0-parent-split-duplicate-parent-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-duplicate-parent-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(4))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[3].adapter_error_code")
                        .value("LINKED_PARENT_RELEASE_DUPLICATE_PARENT"))
                .andExpect(jsonPath("$.items[3].task_id").doesNotExist());

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

    @Test
    void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception {
        ObjectNode root = fixture("row_multiple_derived.json").deepCopy();
        convertParentSplitRootToP01(root);
        String externalId = "p0-row-derived-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-row-derived-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(3))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[2].route_code").value("R13_EXTRA_BED_NORMAL"));

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

    @Test
    void shouldSaveDraftAndConfirmRoomItemsFromP0FieldPaths() throws Exception {
        ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy();
        useEventGroupCode(event, "CHILD-ROW-FIELD-001");
        ((ObjectNode) event.path("extracted_fields")).put("parent_group_code", "PARENT-ROW-FIELD-001");
        String externalId = "p0-room-items-field-migration-001";
        captureSourceMessage(externalId);

        MvcResult result = mockMvc.perform(signedPost(
                        businessRoot(externalId, event),
                        "nonce-p0-room-items-field-migration-create-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .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("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].field_pointer")
                        .value(contains("/extracted_fields/room_items/0/room_quantity")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].field_pointer")
                        .value(contains("/extracted_fields/room_items/0/room_type_raw")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer")
                        .value(contains("/extracted_fields/room_items/0/pms_room_type_code")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].value")
                        .value(contains("RM2")));

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

        mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "field_values": {
                                    "extracted_fields.room_items.0.room_quantity": 5,
                                    "extracted_fields.room_items.0.pms_room_type_code": "RM4"
                                  }
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_status").value("READY"))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.room_quantity']")
                        .value(5))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
                        .value("RM4"))
                .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].room_quantity")
                        .value(5))
                .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code")
                        .value("RM4"));
    }

    @Test
    void shouldFailClosedWhenV3BusinessEventMissesRequiredField() throws Exception {
        ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy();
        event.remove("attachments");
        String externalId = "p0-event-required-field-missing-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-event-required-missing-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_INCOMPLETE"))
                .andExpect(jsonPath("$.items[0].task_id").doesNotExist());

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

    @Test
    void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception {
        JsonNode fixture = fixture("allotment_scope.json");
        ObjectNode mixedOutput = fixture.path("mixed_partial_allotment").path("output").deepCopy();
        String externalId = "p0-allotment-mixed-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(withSourceMessageId(mixedOutput, externalId), "nonce-p0-allotment-mixed-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(2))
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
                .andExpect(jsonPath("$.items[1].route_code").value("R42_UNHANDLED_CURRENT_INTENT"))
                .andExpect(jsonPath("$.items[1].task_id").doesNotExist());

        Long unsupportedTaskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_ai_transition
                WHERE source_message_id = ?
                  AND ai_task_type IN (
                    'Allotment Maintenance',
                    'Update Booking / Amendment',
                    'Cancel Allotment'
                  )
                """, Long.class, source.inboxId());
        assertThat(unsupportedTaskCount).isZero();
    }

    @Test
    void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception {
        JsonNode manualReview = fixture("manual_review_resolution.json");
        ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
        useManualReviewGroupCode(event, "CHILD-SUITE-RESOLVE-001");
        String externalId = "p0-manual-review-same-card-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);
        String body = businessRoot(externalId, event);

        MvcResult result = mockMvc.perform(signedPost(body, "nonce-p0-manual-review-create-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_REVIEW"))
                .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
                .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("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer")
                        .value(contains("/extracted_fields/room_items/0/pms_room_type_code")))
                .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].value")
                        .value(contains("SUITE")));

        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/room_items/0/pms_room_type_code",
                                      "value": "SU1"
                                    }
                                  ]
                                }
                                """.formatted(orderId)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.task_status").value("READY"))
                .andExpect(jsonPath("$.review_status").value("RESOLVED"))
                .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.room_type_raw']")
                        .value("SUITE"))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.room_quantity']")
                        .value(2))
                .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
                        .value("SU1"))
                .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code")
                        .value("SU1"));

        Long sourceTaskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task
                WHERE source_message_id = ?
                """, Long.class, source.inboxId());
        String aiPayloadJson = jdbcTemplate.queryForObject("""
                SELECT ai_payload_json
                FROM workflow_reservation_task_card
                WHERE task_id = ?
                """, String.class, Long.valueOf(taskId));
        JsonNode aiPayload = objectMapper.readTree(aiPayloadJson);
        assertThat(sourceTaskCount).isEqualTo(1L);
        assertThat(aiPayload.path("extracted_fields").path("room_items").get(0)
                .path("pms_room_type_code").isNull()).isTrue();
        assertThat(aiPayload.path("extracted_fields").has("room_type")).isFalse();
        assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\"");
    }

    @Test
    void shouldFailClosedWhenManualReviewMissingFieldPointerDoesNotResolve() throws Exception {
        ObjectNode event = fixture("manual_review_resolution.json")
                .path("known_subtype_manual_review")
                .path("event")
                .deepCopy();
        ((ArrayNode) event.path("manual_review").path("missing_fields"))
                .set(0, objectMapper.getNodeFactory()
                        .textNode("/extracted_fields/room_items/99/pms_room_type_code"));
        String externalId = "p0-manual-review-invalid-pointer-001";
        SourceMessageCaptureResult source = captureSourceMessage(externalId);

        mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-manual-review-invalid-pointer-001"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accepted_count").value(1))
                .andExpect(jsonPath("$.items[0].adapter_error_code")
                        .value("MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID"))
                .andExpect(jsonPath("$.items[0].task_id").doesNotExist());

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

    @Test
    void shouldRejectOutOfRangeRoomItemPointerWhenResolvingManualReview() throws Exception {
        JsonNode manualReview = fixture("manual_review_resolution.json");
        ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
        useManualReviewGroupCode(event, "CHILD-SUITE-POINTER-001");
        String externalId = "p0-manual-review-resolution-pointer-001";
        captureSourceMessage(externalId);
        MvcResult result = mockMvc.perform(signedPost(
                        businessRoot(externalId, event),
                        "nonce-p0-manual-review-resolution-pointer-create-001"))
                .andExpect(status().isCreated())
                .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(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "confirmed_order_id": "%s",
                                  "field_overrides": [
                                    {
                                      "field_pointer": "/extracted_fields/room_items/99/pms_room_type_code",
                                      "value": "SU1"
                                    }
                                  ]
                                }
                                """.formatted(orderId)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"));
    }

    private JsonNode fixture(String fileName) throws Exception {
        return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile());
    }

    private JsonNode caseValue(JsonNode cases, String caseId) {
        return caseItem(cases, caseId).path("value");
    }

    private JsonNode caseItem(JsonNode cases, String caseId) {
        for (JsonNode item : cases) {
            if (caseId.equals(item.path("case_id").asText())) {
                return item;
            }
        }
        throw new IllegalArgumentException("fixture case not found: " + caseId);
    }

    private List<String> caseIdsByValidity(JsonNode cases, boolean expectValid) {
        List<String> ids = new java.util.ArrayList<>();
        for (JsonNode item : cases) {
            if (item.path("expect_valid").asBoolean(false) == expectValid) {
                ids.add(item.path("case_id").asText());
            }
        }
        return ids;
    }

    private String withSourceMessageId(JsonNode root, String externalId) throws Exception {
        ObjectNode copy = root.deepCopy();
        ((ObjectNode) copy.path("source_message")).put("source_message_id", externalId);
        return objectMapper.writeValueAsString(copy);
    }

    private String businessRoot(String externalId, JsonNode event) throws Exception {
        ObjectNode root = objectMapper.createObjectNode();
        ObjectNode sourceMessage = root.putObject("source_message");
        sourceMessage.put("source_message_id", externalId);
        sourceMessage.put("subject", "P0 fixture");
        sourceMessage.put("from", "agent@example.test");
        sourceMessage.putArray("cc");
        sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00");
        sourceMessage.put("source_channel", "Email");
        ArrayNode events = root.putArray("message_events");
        events.add(event);
        root.putArray("case_candidates");
        root.putArray("extraction_warnings");
        root.putArray("unhandled_current_intents");
        return objectMapper.writeValueAsString(root);
    }

    private void useParentSplitBusinessKeys(
            ObjectNode root,
            String parentGroupCode,
            String childGroupCodeOne,
            String childGroupCodeTwo) {
        ArrayNode events = (ArrayNode) root.path("message_events");
        useEventGroupCode((ObjectNode) events.get(0), childGroupCodeOne);
        useEventGroupCode((ObjectNode) events.get(1), childGroupCodeTwo);
        ((ObjectNode) events.get(0).path("extracted_fields")).put("parent_group_code", parentGroupCode);
        ((ObjectNode) events.get(1).path("extracted_fields")).put("parent_group_code", parentGroupCode);

        ObjectNode parentEvent = (ObjectNode) events.get(2);
        useEventGroupCode(parentEvent, parentGroupCode);
        ObjectNode parentExtractedFields = (ObjectNode) parentEvent.path("extracted_fields");
        parentExtractedFields.put("parent_group_code", parentGroupCode);
        ArrayNode childGroupCodes = (ArrayNode) parentExtractedFields.path("child_group_codes");
        childGroupCodes.removeAll();
        childGroupCodes.add(childGroupCodeOne);
        childGroupCodes.add(childGroupCodeTwo);
    }

    private void convertParentSplitRootToP01(ObjectNode root) {
        ArrayNode events = (ArrayNode) root.path("message_events");
        for (JsonNode item : events) {
            ObjectNode event = (ObjectNode) item;
            if (!"linked_parent_release_after_child_split".equals(event.path("relationship_type").asText(null))) {
                continue;
            }
            ObjectNode caseKeys = (ObjectNode) event.path("case_keys");
            String parentGroupCode = caseKeys.path("group_code").asText(null);
            event.put("event_type", "Cancel Allotment");
            caseKeys.put("block_code", parentGroupCode);

            ObjectNode extractedFields = (ObjectNode) event.path("extracted_fields");
            extractedFields.remove("cancel_object_type");
            extractedFields.put("cancel_scope", "entire_allotment_control_block");
            extractedFields.put("parent_release_or_cancel_candidate", true);
            extractedFields.put("release_reason", "parent_to_child_allocation_split");
            extractedFields.put("allocation_split_from_parent", true);
        }
    }

    private void useManualReviewGroupCode(ObjectNode event, String groupCode) {
        useEventGroupCode(event, groupCode);
        ((ObjectNode) event.path("manual_review").path("known_fields")).put("group_code", groupCode);
    }

    private void useEventGroupCode(ObjectNode event, String groupCode) {
        ((ObjectNode) event.path("case_keys")).put("group_code", groupCode);
    }

    private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
        return captureService.capture(new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                externalMessageId,
                "thread-" + externalMessageId,
                "frame-" + externalMessageId,
                "session-m002-p0",
                Instant.parse("2026-07-11T02:00:00Z"),
                "agent@example.test",
                "M002 P0 fixture",
                "P0 fixture source message.",
                "<html><body>P0 fixture source message.</body></html>",
                "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
                "agentbus-outlook-v1",
                List.of()
        ));
    }

    private void assertSourceMessageOnlyTaskCount(MvcResult result, String routeCode) throws Exception {
        String taskId = com.jayway.jsonpath.JsonPath.read(
                result.getResponse().getContentAsString(),
                "$.items[0].task_id");
        Long taskCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM workflow_reservation_task task
                JOIN workflow_reservation_ai_transition transition
                  ON transition.id = task.ai_transition_id
                WHERE task.id = ?
                  AND transition.route_code = ?
                  AND task.queue_participation = 0
                """, Long.class, Long.valueOf(taskId), routeCode);
        assertThat(taskCount).isEqualTo(1L);
    }

    private MockHttpServletRequestBuilder signedPost(String body, String nonce) throws Exception {
        String timestamp = Instant.now().toString();
        return 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)
                .header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp));
    }

    private String signature(String body, String nonce, String timestamp) throws Exception {
        String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
                .digest(body.getBytes(StandardCharsets.UTF_8)));
        String canonical = "POST\n" + ENDPOINT + "\n" + timestamp + "\n" + nonce + "\n" + CLIENT_ID + "\n" + bodyHash;
        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 org.hamcrest.Matcher<String> notContainsSecret() {
        return org.hamcrest.Matchers.not(containsString(SECRET));
    }
}
