package cn.nianxx.thhotel.platform.message.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
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.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.domain.SourceMessageInboxEntity;
import cn.nianxx.thhotel.platform.message.domain.SourceMessagePayloadEntity;
import cn.nianxx.thhotel.platform.message.mapper.SourceMessageInboxMapper;
import cn.nianxx.thhotel.platform.message.mapper.SourceMessagePayloadMapper;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageCaptureServiceImpl;
import cn.nianxx.thhotel.platform.message.service.impl.SourceMessageSafetySanitizer;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest(classes = ThHotelApplication.class)
@ActiveProfiles("test")
class SourceMessageCaptureServiceImplTest {

    @Autowired
    private SourceMessageCaptureService captureService;

    @Autowired
    private SourceMessageQueryService queryService;

    @Autowired
    private SourceMessageInboxMapper inboxMapper;

    @Autowired
    private SourceMessagePayloadMapper payloadMapper;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldCaptureReceivedEmailAndExposeOnlySafeSummaryForQueries() {
        CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-m001-001",
                "conversation-m001",
                "frame-m001-001",
                "session-m001",
                Instant.parse("2026-07-06T09:00:00Z"),
                "guest@example.test",
                "Booking change request",
                "Please change the arrival date. Private phone 13800138000.",
                "<html><body>Please change <img src=\"https://media.example.test/inline.png?token=secret\"></body></html>",
                "{\"source\":{\"external_message_id\":\"mail-m001-001\"}}",
                "agentbus-outlook-v1",
                List.of(
                        new CaptureSourceMessageMedia(
                                "INLINE_IMAGE",
                                "inline.png",
                                "image/png",
                                12345L,
                                "https://media.example.test/inline.png?token=secret",
                                "inline-media-001"),
                        new CaptureSourceMessageMedia(
                                "ATTACHMENT",
                                "booking.pdf",
                                "application/pdf",
                                45678L,
                                "https://media.example.test/booking.pdf?token=secret",
                                "attachment-001")
                )
        );

        SourceMessageCaptureResult result = captureService.capture(command);

        assertThat(result.created()).isTrue();
        assertThat(result.captureStatus()).isEqualTo("RECEIVED");
        assertThat(result.inboxId()).isNotNull();

        SourceMessagePageResult<SourceMessageSummaryResponse> page = queryService.query(
                new SourceMessageQueryRequest("HOTEL-TEST", null, "conversation-m001", null, 1, 20)
        );

        assertThat(page.items()).hasSize(1);
        SourceMessageSummaryResponse summary = page.items().get(0);
        assertThat(summary.id()).isEqualTo(result.inboxId().toString());
        assertThat(summary.externalMessageId()).isEqualTo("mail-m001-001");
        assertThat(summary.externalConversationId()).isEqualTo("conversation-m001");
        assertThat(summary.captureStatus()).isEqualTo("RECEIVED");
        assertThat(summary.senderSummary()).isEqualTo("guest@example.test");
        assertThat(summary.subject()).isEqualTo("Booking change request");
        assertThat(summary.safeSnippet()).contains("Please change the arrival date");
        assertThat(summary.safeSnippet()).doesNotContain("13800138000");
        assertThat(summary.toString()).doesNotContain("html");
        assertThat(summary.toString()).doesNotContain("media.example.test");
        assertThat(summary.toString()).doesNotContain("payload");
        assertThat(summary.toString()).doesNotContain("token=secret");
    }

    @Test
    void shouldKeepIdempotentInboxAndOriginalPayloadWhenDuplicatePayloadChanges() {
        CaptureSourceMessageCommand firstCommand = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-duplicate-001",
                "conversation-duplicate",
                "frame-duplicate-001",
                "session-duplicate",
                Instant.parse("2026-07-06T09:10:00Z"),
                "guest@example.test",
                "Duplicate delivery",
                "Original body",
                "<html>Original body</html>",
                "{\"source\":{\"external_message_id\":\"mail-duplicate-001\"},\"version\":1}",
                "agentbus-outlook-v1",
                List.of()
        );
        CaptureSourceMessageCommand changedCommand = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-duplicate-001",
                "conversation-duplicate",
                "frame-duplicate-002",
                "session-duplicate",
                Instant.parse("2026-07-06T09:11:00Z"),
                "guest@example.test",
                "Duplicate delivery changed",
                "Changed body",
                "<html>Changed body</html>",
                "{\"source\":{\"external_message_id\":\"mail-duplicate-001\"},\"version\":2}",
                "agentbus-outlook-v1",
                List.of()
        );

        SourceMessageCaptureResult firstResult = captureService.capture(firstCommand);
        SourceMessageCaptureResult duplicateResult = captureService.capture(changedCommand);

        assertThat(duplicateResult.created()).isFalse();
        assertThat(duplicateResult.inboxId()).isEqualTo(firstResult.inboxId());
        assertThat(duplicateResult.duplicatePayloadChanged()).isTrue();

        Long payloadCount = payloadMapper.selectCount(Wrappers.<SourceMessagePayloadEntity>lambdaQuery()
                .eq(SourceMessagePayloadEntity::getInboxId, firstResult.inboxId()));
        assertThat(payloadCount).isEqualTo(1L);

        SourceMessagePayloadEntity payload = payloadMapper.selectOne(Wrappers.<SourceMessagePayloadEntity>lambdaQuery()
                .eq(SourceMessagePayloadEntity::getInboxId, firstResult.inboxId()));
        assertThat(payload.getPayloadJson()).contains("\"version\":1");
        assertThat(payload.getPayloadJson()).doesNotContain("\"version\":2");

        Long duplicatePayloadCount = jdbcTemplate.queryForObject("""
                SELECT COUNT(*)
                FROM platform_source_message_payload_duplicate
                WHERE inbox_id = ?
                  AND original_payload_sha256 = ?
                  AND duplicate_payload_sha256 <> ?
                  AND payload_json LIKE '%"version":2%'
                  AND provider_frame_id = 'frame-duplicate-002'
                  AND provider_session_id = 'session-duplicate'
                  AND schema_version = 'agentbus-outlook-v1'
                """, Long.class, firstResult.inboxId(), payload.getPayloadSha256(), payload.getPayloadSha256());
        assertThat(duplicatePayloadCount).isEqualTo(1L);

        SourceMessageInboxEntity inbox = inboxMapper.selectById(firstResult.inboxId());
        assertThat(inbox.getDuplicatePayloadChanged()).isTrue();
        assertThat(inbox.getSafeErrorSummary()).contains("重复投递 payload");
    }

    @Test
    void shouldPersistSourceReceivedAtAndExposeFullSenderIdentifier() {
        CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-source-received-time-001",
                "conversation-source-received-time",
                "frame-source-received-time-001",
                "session-source-received-time",
                Instant.parse("2026-07-09T05:56:56Z"),
                Instant.parse("2026-07-09T05:55:00Z"),
                "guest@example.test",
                "Source received time",
                "Please check received time.",
                "<html>Please check received time.</html>",
                "{\"received_at\":\"2026-07-09T05:56:56Z\",\"source\":{\"external_message_id\":\"mail-source-received-time-001\"}}",
                "agentbus-outlook-v1",
                List.of()
        );

        SourceMessageCaptureResult result = captureService.capture(command);

        SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow();
        assertThat(summary.receivedAt().toInstant()).isEqualTo(Instant.parse("2026-07-09T05:56:56Z"));
        assertThat(summary.sourceSentAt().toInstant()).isEqualTo(Instant.parse("2026-07-09T05:55:00Z"));
        assertThat(summary.senderSummary()).isEqualTo("guest@example.test");

        SourceMessageInboxEntity inbox = inboxMapper.selectById(result.inboxId());
        assertThat(inbox.getReceivedAt()).isEqualTo(LocalDateTime.parse("2026-07-09T05:56:56"));
        assertThat(inbox.getCreatedAt()).isNotEqualTo(inbox.getReceivedAt());
        assertThat(inbox.getUpdatedAt()).isEqualTo(inbox.getCreatedAt());
    }

    @Test
    void shouldCaptureCaseVariantExternalMessageIdsAsDifferentMessages() {
        CaptureSourceMessageCommand upperCaseCommand = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-case-sensitive-X",
                "conversation-case-sensitive",
                "frame-case-sensitive-001",
                "session-case-sensitive",
                Instant.parse("2026-07-06T09:20:00Z"),
                "guest@example.test",
                "Case sensitive delivery",
                "Upper case external id",
                "<html>Upper case external id</html>",
                "{\"source\":{\"external_message_id\":\"mail-case-sensitive-X\"},\"version\":1}",
                "agentbus-outlook-v1",
                List.of()
        );
        CaptureSourceMessageCommand lowerCaseCommand = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-case-sensitive-x",
                "conversation-case-sensitive",
                "frame-case-sensitive-002",
                "session-case-sensitive",
                Instant.parse("2026-07-06T09:21:00Z"),
                "guest@example.test",
                "Case sensitive delivery",
                "Lower case external id",
                "<html>Lower case external id</html>",
                "{\"source\":{\"external_message_id\":\"mail-case-sensitive-x\"},\"version\":1}",
                "agentbus-outlook-v1",
                List.of()
        );

        SourceMessageCaptureResult upperCaseResult = captureService.capture(upperCaseCommand);
        SourceMessageCaptureResult lowerCaseResult = captureService.capture(lowerCaseCommand);

        assertThat(upperCaseResult.created()).isTrue();
        assertThat(lowerCaseResult.created()).isTrue();
        assertThat(lowerCaseResult.inboxId()).isNotEqualTo(upperCaseResult.inboxId());

        List<String> externalMessageIds = inboxMapper.selectList(Wrappers.<SourceMessageInboxEntity>lambdaQuery()
                        .eq(SourceMessageInboxEntity::getHotelId, "HOTEL-TEST")
                        .eq(SourceMessageInboxEntity::getProvider, "AGENTBUS")
                        .eq(SourceMessageInboxEntity::getChannel, "EMAIL")
                        .eq(SourceMessageInboxEntity::getExternalConversationId, "conversation-case-sensitive")
                        .orderByAsc(SourceMessageInboxEntity::getExternalMessageId))
                .stream()
                .map(SourceMessageInboxEntity::getExternalMessageId)
                .toList();
        assertThat(externalMessageIds)
                .containsExactlyInAnyOrder("mail-case-sensitive-X", "mail-case-sensitive-x");
    }

    @Test
    void shouldProvideMysqlMigrationForCaseSensitiveStringCollation() throws IOException {
        ClassPathResource migration = new ClassPathResource(
                "db/migration/V13__make_existing_string_columns_case_sensitive.sql");

        assertThat(migration.exists()).isTrue();

        String sql = migration.getContentAsString(StandardCharsets.UTF_8);
        assertThat(sql).contains("DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin");
        assertThat(sql).contains("ALTER TABLE platform_source_message_inbox");
        assertThat(sql).contains(
                "MODIFY COLUMN external_message_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin");
        assertThat(sql).contains("ALTER TABLE platform_source_message_payload_duplicate");
    }

    @Test
    void shouldPersistFailedInboxWhenExternalMessageIdIsMissing() {
        CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                null,
                "conversation-invalid",
                "frame-invalid-001",
                "session-invalid",
                Instant.parse("2026-07-06T09:30:00Z"),
                "guest@example.test",
                "Invalid source payload",
                "This text contains private phone 13800138000 and must not appear in the error summary.",
                "<html><body>private html</body></html>",
                "{\"source\":{\"external_conversation_id\":\"conversation-invalid\"},\"token\":\"secret\"}",
                "agentbus-outlook-v1",
                List.of()
        );

        SourceMessageCaptureResult result = captureService.capture(command);

        assertThat(result.created()).isTrue();
        assertThat(result.captureStatus()).isEqualTo("FAILED");

        SourceMessagePageResult<SourceMessageSummaryResponse> page = queryService.query(
                new SourceMessageQueryRequest("HOTEL-TEST", null, "conversation-invalid", "FAILED", 1, 20)
        );

        assertThat(page.items()).hasSize(1);
        SourceMessageSummaryResponse summary = page.items().get(0);
        assertThat(summary.id()).isEqualTo(result.inboxId().toString());
        assertThat(summary.captureStatus()).isEqualTo("FAILED");
        assertThat(summary.externalMessageId()).isNull();
        assertThat(summary.safeSnippet()).contains("payload missing external message id");
        assertThat(summary.toString()).doesNotContain("13800138000");
        assertThat(summary.toString()).doesNotContain("private html");
        assertThat(summary.toString()).doesNotContain("token");
        assertThat(summary.toString()).doesNotContain("secret");
    }

    @Test
    void shouldSanitizeSensitiveSubjectForSafeQueries() {
        CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-sensitive-subject-001",
                "conversation-sensitive-subject",
                "frame-sensitive-subject-001",
                "session-sensitive-subject",
                Instant.parse("2026-07-06T09:40:00Z"),
                "guest@example.test",
                "Urgent: guest@example.test phone 13800138000 https://media.example.test/file.pdf?token=secret",
                "Please check the booking.",
                "<html><body>Please check the booking.</body></html>",
                "{\"source\":{\"external_message_id\":\"mail-sensitive-subject-001\"}}",
                "agentbus-outlook-v1",
                List.of()
        );

        SourceMessageCaptureResult result = captureService.capture(command);

        SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow();
        assertThat(summary.subject()).doesNotContain("guest@example.test");
        assertThat(summary.subject()).doesNotContain("13800138000");
        assertThat(summary.subject()).doesNotContain("media.example.test");
        assertThat(summary.subject()).doesNotContain("token=secret");
        assertThat(summary.subject()).contains("[email]");
        assertThat(summary.subject()).contains("[number]");
        assertThat(summary.subject()).contains("[url]");
    }

    @Test
    void shouldPersistFailedInboxWhenMediaUrlIsMissing() {
        CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-invalid-media-001",
                "conversation-invalid-media",
                "frame-invalid-media-001",
                "session-invalid-media",
                Instant.parse("2026-07-06T09:45:00Z"),
                "guest@example.test",
                "Invalid media source payload",
                "This text must not appear as an exception.",
                "<html><body>private html</body></html>",
                "{\"source\":{\"external_message_id\":\"mail-invalid-media-001\"},\"token\":\"secret\"}",
                "agentbus-outlook-v1",
                List.of(new CaptureSourceMessageMedia(
                        "ATTACHMENT",
                        "private.pdf",
                        "application/pdf",
                        123L,
                        null,
                        "attachment-invalid-media-001"))
        );

        SourceMessageCaptureResult result = captureService.capture(command);

        assertThat(result.created()).isTrue();
        assertThat(result.captureStatus()).isEqualTo("FAILED");

        SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow();
        assertThat(summary.captureStatus()).isEqualTo("FAILED");
        assertThat(summary.externalMessageId()).isEqualTo("mail-invalid-media-001");
        assertThat(summary.safeSnippet()).contains("payload media item missing external url");
        assertThat(summary.toString()).doesNotContain("private html");
        assertThat(summary.toString()).doesNotContain("token");
        assertThat(summary.toString()).doesNotContain("secret");
    }

    @Test
    void shouldReturnExistingInboxWhenConcurrentInsertHitsUniqueConstraint() {
        SourceMessageInboxRepository localRepository = mock(SourceMessageInboxRepository.class);
        SourceMessageCaptureService localService = new SourceMessageCaptureServiceImpl(
                localRepository,
                new SourceMessageSafetySanitizer()
        );
        SourceMessageInboxSnapshot existing = new SourceMessageInboxSnapshot(
                99001L,
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-race-001",
                "conversation-race",
                "different-payload-hash",
                "RECEIVED",
                false,
                LocalDateTime.parse("2026-07-06T09:50:00"),
                LocalDateTime.parse("2026-07-06T09:50:00"),
                "guest@example.test",
                "Race delivery",
                "Race body"
        );
        when(localRepository.findByIdempotencyKey("HOTEL-TEST", "AGENTBUS", "EMAIL", "mail-race-001"))
                .thenReturn(Optional.empty())
                .thenReturn(Optional.of(existing));
        when(localRepository.insert(any(SourceMessageInboxDraft.class)))
                .thenThrow(new DuplicateKeyException("duplicate source message"));
        CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-race-001",
                "conversation-race",
                "frame-race-001",
                "session-race",
                Instant.parse("2026-07-06T09:50:00Z"),
                "guest@example.test",
                "Race delivery",
                "Race body",
                "<html>Race body</html>",
                "{\"source\":{\"external_message_id\":\"mail-race-001\"},\"version\":2}",
                "agentbus-outlook-v1",
                List.of()
        );

        SourceMessageCaptureResult result = localService.capture(command);

        assertThat(result.created()).isFalse();
        assertThat(result.inboxId()).isEqualTo(99001L);
        assertThat(result.duplicatePayloadChanged()).isTrue();
        verify(localRepository).markDuplicatePayloadChanged(eq(99001L), any(String.class), any(LocalDateTime.class));
    }

    @Test
    void shouldKeepIdempotentResultWhenDuplicatePayloadDiagnosticWriteFails() {
        SourceMessageInboxRepository localRepository = mock(SourceMessageInboxRepository.class);
        SourceMessageCaptureService localService = new SourceMessageCaptureServiceImpl(
                localRepository,
                new SourceMessageSafetySanitizer()
        );
        SourceMessageInboxSnapshot existing = new SourceMessageInboxSnapshot(
                99002L,
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-diagnostic-fail-001",
                "conversation-diagnostic-fail",
                "different-payload-hash",
                "RECEIVED",
                true,
                LocalDateTime.parse("2026-07-06T10:00:00"),
                LocalDateTime.parse("2026-07-06T10:00:00"),
                "guest@example.test",
                "Duplicate diagnostic fail",
                "Original body"
        );
        when(localRepository.findByIdempotencyKey("HOTEL-TEST", "AGENTBUS", "EMAIL", "mail-diagnostic-fail-001"))
                .thenReturn(Optional.of(existing));
        doThrow(new DuplicateKeyException("duplicate diagnostic payload"))
                .when(localRepository).insertDuplicatePayload(any());
        CaptureSourceMessageCommand command = new CaptureSourceMessageCommand(
                "HOTEL-TEST",
                "AGENTBUS",
                "EMAIL",
                "mail-diagnostic-fail-001",
                "conversation-diagnostic-fail",
                "frame-diagnostic-fail-001",
                "session-diagnostic-fail",
                Instant.parse("2026-07-06T10:00:00Z"),
                "guest@example.test",
                "Duplicate diagnostic fail",
                "Changed body",
                "<html>Changed body</html>",
                "{\"source\":{\"external_message_id\":\"mail-diagnostic-fail-001\"},\"version\":2}",
                "agentbus-outlook-v1",
                List.of()
        );

        SourceMessageCaptureResult result = localService.capture(command);

        assertThat(result.created()).isFalse();
        assertThat(result.inboxId()).isEqualTo(99002L);
        assertThat(result.duplicatePayloadChanged()).isTrue();
        assertThat(result.captureStatus()).isEqualTo("RECEIVED");
    }
}
