package cn.nianxx.thhotel.workflows.reservation.service.impl;

import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem;
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageOriginalAccessResult;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryWarningResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationMessagesResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationMessageConversationTasksResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * Reservation AI 查询服务实现。只读取订单、任务和 AI 过渡层事实，不执行业务状态流转。
 */
@Service
public class ReservationAiQueryServiceImpl implements ReservationAiQueryService {

    private static final String OBJECT_ID_PREFIX_ORDER = "ORDER:";
    private static final String SOURCE_TABLE_ORDER = "workflow_reservation_order";
    private static final String WARNING_OPERA_PROJECTION_UNAVAILABLE = "OPERA_PROJECTION_UNAVAILABLE";
    private static final String DEFAULT_SOURCE_PROVIDER = "AGENTBUS";
    private static final String DEFAULT_SOURCE_CHANNEL = "EMAIL";
    private static final String SUPERAGENT_AI_QUERY_ACTOR = "system:superagent-ai-query";
    private static final String MESSAGE_CONVERSATION_ACCESS_SCENE = "superagent-message-conversation-messages";
    private static final Pattern HTML_URL_ATTRIBUTE_PATTERN = Pattern.compile(
            "(?i)\\s+(href|src|xlink:href|formaction|poster|background)\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
    private static final Set<String> OPEN_TASK_STATUSES = Set.of(
            ReservationTaskStatus.PENDING_CONFIRM.name(),
            ReservationTaskStatus.READY.name(),
            ReservationTaskStatus.EXECUTING.name());
    private static final Set<String> TERMINATED_TASK_STATUSES = Set.of(
            ReservationTaskStatus.FAILED.name(),
            ReservationTaskStatus.COMPLETED.name());

    private final ReservationAiWorkflowRepository repository;
    private final SourceMessageInboxRepository sourceMessageInboxRepository;
    private final SourceMessageHtmlSanitizerService htmlSanitizerService;
    private final HotelContextService hotelContextService;

    /**
     * 注入 Reservation 工作流持久化边界、SourceMessage 持久化边界和 HTML 清洗服务。
     */
    public ReservationAiQueryServiceImpl(
            ReservationAiWorkflowRepository repository,
            SourceMessageInboxRepository sourceMessageInboxRepository,
            SourceMessageHtmlSanitizerService htmlSanitizerService,
            HotelContextService hotelContextService) {
        this.repository = repository;
        this.sourceMessageInboxRepository = sourceMessageInboxRepository;
        this.htmlSanitizerService = htmlSanitizerService;
        this.hotelContextService = hotelContextService;
    }

    /**
     * 查询订单上下文。按业务 key 查订单，并合并同订单任务与同 key AI transition 任务。
     */
    @Override
    public ReservationAiCaseContextResult queryCaseContext(ReservationAiCaseContextQueryRequest request) {
        validateCaseContextRequest(request);
        String hotelId = resolveHotelId(request.hotelId());
        String groupCode = trimToNull(request.groupCode());
        String confirmationNumber = trimToNull(request.confirmationNumber());
        if (groupCode == null && confirmationNumber == null) {
            return unsupportedReservationNoOnlyResult();
        }

        List<ReservationAiQueryOrderSnapshot> orders = repository.findAiQueryOrdersByBusinessKeys(
                hotelId,
                groupCode,
                confirmationNumber);
        List<Long> orderIds = orders.stream().map(ReservationAiQueryOrderSnapshot::id).toList();
        List<ReservationAiQueryTaskSnapshot> tasks = mergeTasks(
                repository.findAiQueryTasksByOrderIds(hotelId, orderIds),
                repository.findAiQueryTasksByBusinessKeys(hotelId, groupCode, confirmationNumber));

        List<ReservationAiCaseContextResult.PendingOrOpenTask> openTasks = tasks.stream()
                .filter(task -> OPEN_TASK_STATUSES.contains(task.taskStatus()))
                .sorted(Comparator
                        .comparing(ReservationAiQueryTaskSnapshot::orderId, Comparator.nullsLast(Long::compareTo))
                        .thenComparing(ReservationAiQueryTaskSnapshot::executionOrder, Comparator.nullsLast(Integer::compareTo)))
                .map(this::toPendingOrOpenTask)
                .toList();
        List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords = terminatedRecords(orders, tasks);
        List<ReservationAiQueryOrderSnapshot> activeOrders = orders.stream()
                .filter(order -> ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus()))
                .toList();

        return new ReservationAiCaseContextResult(
                orders.stream().map(this::toMatchedOrderRecord).toList(),
                openTasks,
                List.of(),
                terminatedRecords,
                targetObjectValidation(activeOrders, openTasks, terminatedRecords),
                new ReservationAiCaseContextResult.KeyRelationships(null, ""));
    }

    /**
     * reservation_no 当前没有可靠表源时，不返回“可创建新任务”的误导性结论。
     */
    private ReservationAiCaseContextResult unsupportedReservationNoOnlyResult() {
        return new ReservationAiCaseContextResult(
                List.of(),
                List.of(),
                List.of(),
                List.of(),
                new ReservationAiCaseContextResult.TargetObjectValidation(
                        "conflict",
                        null,
                        null,
                        false,
                        false,
                        false,
                        false,
                        false,
                        "UNSUPPORTED_RESERVATION_NO_QUERY"),
                new ReservationAiCaseContextResult.KeyRelationships(null, ""));
    }

    /**
     * 查询对象详情。当前仅解析 ORDER:{orderId}，OPERA 投影字段返回 null 和 warning。
     */
    @Override
    public ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request) {
        validateObjectDetailRequest(request);
        String hotelId = resolveHotelId(request.hotelId());
        Long orderId = parseOrderObjectId(request.objectId());
        ReservationAiQueryOrderSnapshot order = repository.findAiQueryOrderById(hotelId, orderId)
                .orElseThrow(() -> new ReservationAiQueryException(
                        HttpStatus.NOT_FOUND,
                        "OBJECT_NOT_FOUND",
                        "查询对象不存在"));
        List<ReservationAiQueryTaskSnapshot> tasks = repository.findAiQueryTasksByOrderIds(hotelId, List.of(order.id()));
        boolean hasOpenTask = tasks.stream().anyMatch(task -> OPEN_TASK_STATUSES.contains(task.taskStatus()));
        boolean active = ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus());
        boolean canMutate = active && !hasOpenTask;

        return new ReservationAiObjectDetailResult(
                objectId(order.id()),
                objectType(order),
                idString(order.id()),
                order.orderKeyType(),
                groupCode(order),
                confirmationNumber(order),
                null,
                null,
                order.temporaryOrderCode(),
                order.displayName(),
                order.orderStatus(),
                idString(order.sourceMessageId()),
                idString(order.createdFromTaskId()),
                UtcTimeFormatter.toUtcOffsetDateTime(order.createdAt()),
                UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt()),
                null,
                null,
                null,
                null,
                List.of(),
                null,
                null,
                null,
                cancelStatus(order),
                canMutate,
                canMutate,
                List.of(new ReservationAiQueryWarningResult(
                        WARNING_OPERA_PROJECTION_UNAVAILABLE,
                        "当前系统尚未接入 OPERA 对象投影，日期、房型、房价等字段无法确认。")));
    }

    /**
     * 查询邮件会话下全部任务。排序先看邮件接收时间，再看任务创建时间，避免历史邮件任务乱序。
     */
    @Override
    public ReservationMessageConversationTasksResult queryMessageConversationTasks(
            ReservationMessageConversationQueryRequest request) {
        validateConversationRequest(request);
        String hotelId = resolveHotelId(request.hotelId());
        List<SourceMessageInboxSnapshot> messages = findConversationMessages(request, hotelId);
        Map<Long, SourceMessageInboxSnapshot> messageIndex = indexMessages(messages);
        List<ReservationMessageConversationTasksResult.TaskRecord> tasks = repository
                .findAiQueryTasksBySourceMessageIds(hotelId, messages.stream().map(SourceMessageInboxSnapshot::id).toList())
                .stream()
                .filter(task -> messageIndex.containsKey(task.sourceMessageId()))
                .sorted(Comparator
                        .comparing(
                                (ReservationAiQueryTaskSnapshot task) -> sourceReceivedAt(task, messageIndex),
                                Comparator.nullsLast(LocalDateTime::compareTo))
                        .thenComparing(
                                ReservationAiQueryTaskSnapshot::createdAt,
                                Comparator.nullsLast(LocalDateTime::compareTo))
                        .thenComparing(ReservationAiQueryTaskSnapshot::id, Comparator.nullsLast(Long::compareTo)))
                .map(task -> toConversationTask(task, messageIndex.get(task.sourceMessageId())))
                .toList();
        return new ReservationMessageConversationTasksResult(
                hotelId,
                conversationId(messages, request),
                tasks.size(),
                tasks);
    }

    /**
     * 查询邮件会话下全部受控正文。读取正文时写审计，只输出清洗后的 HTML 和纯文本正文。
     */
    @Override
    @Transactional
    public ReservationMessageConversationMessagesResult queryMessageConversationMessages(
            ReservationMessageConversationQueryRequest request) {
        validateConversationRequest(request);
        String hotelId = resolveHotelId(request.hotelId());
        List<SourceMessageInboxSnapshot> messages = findConversationMessages(request, hotelId);
        List<ReservationMessageConversationMessagesResult.MessageRecord> resultMessages = messages.stream()
                .map(this::toConversationMessage)
                .toList();
        return new ReservationMessageConversationMessagesResult(
                hotelId,
                conversationId(messages, request),
                resultMessages.size(),
                resultMessages);
    }

    /**
     * 根据会话 ID 或外部 SourceMessage ID 找到同一邮件链的全部消息。
     */
    private List<SourceMessageInboxSnapshot> findConversationMessages(
            ReservationMessageConversationQueryRequest request,
            String hotelId) {
        String externalConversationId = trimToNull(request.externalConversationId());
        if (externalConversationId != null) {
            List<SourceMessageInboxSnapshot> messages = sourceMessageInboxRepository.findByExternalConversationId(
                    hotelId,
                    sourceProvider(request),
                    sourceChannel(request),
                    externalConversationId);
            if (messages.isEmpty()) {
                throw notFound("MESSAGE_CONVERSATION_NOT_FOUND", "邮件会话不存在");
            }
            return messages;
        }

        SourceMessageInboxSnapshot anchor = sourceMessageInboxRepository.findByIdempotencyKey(
                        hotelId,
                        sourceProvider(request),
                        sourceChannel(request),
                        trimToNull(request.sourceMessageId()))
                .orElseThrow(() -> notFound("SOURCE_MESSAGE_NOT_FOUND", "外部来源消息不存在"));
        String anchorConversationId = trimToNull(anchor.externalConversationId());
        if (anchorConversationId == null) {
            return List.of(anchor);
        }
        List<SourceMessageInboxSnapshot> messages = sourceMessageInboxRepository.findByExternalConversationId(
                hotelId,
                anchor.provider(),
                anchor.channel(),
                anchorConversationId);
        return messages.isEmpty() ? List.of(anchor) : messages;
    }

    /**
     * 将邮件列表按内部 SourceMessage ID 建索引，只在服务层内部用于任务关联。
     */
    private Map<Long, SourceMessageInboxSnapshot> indexMessages(List<SourceMessageInboxSnapshot> messages) {
        Map<Long, SourceMessageInboxSnapshot> result = new LinkedHashMap<>();
        messages.forEach(message -> result.put(message.id(), message));
        return result;
    }

    /**
     * 将任务快照转换为 SuperAgent 会话任务响应，隐藏内部 SourceMessage ID。
     */
    private ReservationMessageConversationTasksResult.TaskRecord toConversationTask(
            ReservationAiQueryTaskSnapshot task,
            SourceMessageInboxSnapshot message) {
        return new ReservationMessageConversationTasksResult.TaskRecord(
                idString(task.id()),
                idString(task.orderId()),
                message.externalMessageId(),
                message.externalConversationId(),
                UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()),
                task.transitionSourceEventIndex(),
                task.catalogCode(),
                task.skillId(),
                task.resultType(),
                task.aiTaskType(),
                task.systemTaskType(),
                task.taskCardType(),
                task.taskSubtype(),
                task.taskStatus(),
                task.queueParticipation(),
                task.executionOrder(),
                idString(task.parentTaskId()),
                task.parentSourceEventIndex(),
                task.linkedTaskGroupId(),
                task.blockedUntilParentCompleted(),
                UtcTimeFormatter.toUtcOffsetDateTime(task.completedAt()),
                UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt()),
                UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt()));
    }

    /**
     * 将邮件原文转换为受控正文响应，不返回附件 URL 或原始未清洗 HTML。
     */
    private ReservationMessageConversationMessagesResult.MessageRecord toConversationMessage(
            SourceMessageInboxSnapshot message) {
        SourceMessageOriginalContent originalContent = readOriginalAndAudit(message);
        String htmlBody = originalContent.htmlBody();
        String sanitizedHtml = htmlSanitizerService.sanitizeHtml(removeMediaUrls(htmlBody, originalContent));
        return new ReservationMessageConversationMessagesResult.MessageRecord(
                message.externalMessageId(),
                message.externalConversationId(),
                message.senderSummary(),
                message.subject(),
                UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()),
                UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()),
                removeMediaUrls(originalContent.textBody(), originalContent),
                removeHtmlUrlAttributes(sanitizedHtml),
                true,
                htmlSanitizerService.htmlRenderMode(htmlBody));
    }

    /**
     * 移除正文中已知媒体外链，避免 SuperAgent 受控正文接口返回附件或内嵌图片 URL。
     */
    private String removeMediaUrls(String body, SourceMessageOriginalContent originalContent) {
        if (body == null || originalContent.mediaItems() == null || originalContent.mediaItems().isEmpty()) {
            return body;
        }
        String result = body;
        for (SourceMessageOriginalMediaItem mediaItem : originalContent.mediaItems()) {
            String externalUrl = trimToNull(mediaItem.externalUrl());
            if (externalUrl != null) {
                result = result.replace(externalUrl, "");
            }
        }
        return result;
    }

    /**
     * SuperAgent 正文查询不返回任何 HTML URL 属性，图片和附件由后续受控原文接口处理。
     */
    private String removeHtmlUrlAttributes(String htmlBody) {
        if (htmlBody == null) {
            return null;
        }
        return HTML_URL_ATTRIBUTE_PATTERN.matcher(htmlBody).replaceAll("");
    }

    /**
     * 受控读取邮件正文并写入审计，便于后续追踪 SuperAgent 原文访问。
     */
    private SourceMessageOriginalContent readOriginalAndAudit(SourceMessageInboxSnapshot message) {
        SourceMessageOriginalContent content = sourceMessageInboxRepository.findOriginalContent(message.id())
                .orElse(new SourceMessageOriginalContent(message.id(), null, null, List.of()));
        sourceMessageInboxRepository.insertOriginalAccessAudit(new SourceMessageOriginalAccessAuditDraft(
                message.id(),
                SUPERAGENT_AI_QUERY_ACTOR,
                MESSAGE_CONVERSATION_ACCESS_SCENE,
                SourceMessageOriginalAccessResult.GRANTED.code(),
                LocalDateTime.now(ZoneOffset.UTC)));
        return content;
    }

    /**
     * 读取任务来源邮件接收时间，供会话任务排序使用。
     */
    private LocalDateTime sourceReceivedAt(
            ReservationAiQueryTaskSnapshot task,
            Map<Long, SourceMessageInboxSnapshot> messageIndex) {
        SourceMessageInboxSnapshot message = messageIndex.get(task.sourceMessageId());
        return message == null ? null : message.receivedAt();
    }

    /**
     * 响应中的会话 ID 优先取实际消息快照，单封无会话 ID 时回显请求值。
     */
    private String conversationId(
            List<SourceMessageInboxSnapshot> messages,
            ReservationMessageConversationQueryRequest request) {
        return messages.stream()
                .map(SourceMessageInboxSnapshot::externalConversationId)
                .filter(value -> trimToNull(value) != null)
                .findFirst()
                .orElse(trimToNull(request.externalConversationId()));
    }

    /**
     * 查询外部 source_message_id 时使用来源提供方；缺省按当前 AgentBus 邮件入口处理。
     */
    private String sourceProvider(ReservationMessageConversationQueryRequest request) {
        return Optional.ofNullable(trimToNull(request.sourceProvider())).orElse(DEFAULT_SOURCE_PROVIDER);
    }

    /**
     * 查询外部 source_message_id 时使用来源渠道；缺省按当前 EMAIL 邮件入口处理。
     */
    private String sourceChannel(ReservationMessageConversationQueryRequest request) {
        return Optional.ofNullable(trimToNull(request.sourceChannel())).orElse(DEFAULT_SOURCE_CHANNEL);
    }

    private ReservationAiCaseContextResult.MatchedOrderRecord toMatchedOrderRecord(
            ReservationAiQueryOrderSnapshot order) {
        return new ReservationAiCaseContextResult.MatchedOrderRecord(
                objectId(order.id()),
                objectType(order),
                idString(order.id()),
                order.orderKeyType(),
                groupCode(order),
                confirmationNumber(order),
                order.temporaryOrderCode(),
                order.displayName(),
                order.orderStatus(),
                order.businessKeySource(),
                SOURCE_TABLE_ORDER,
                UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt()));
    }

    private ReservationAiCaseContextResult.PendingOrOpenTask toPendingOrOpenTask(
            ReservationAiQueryTaskSnapshot task) {
        return new ReservationAiCaseContextResult.PendingOrOpenTask(
                idString(task.id()),
                idString(task.orderId()),
                idString(task.sourceMessageId()),
                task.transitionSourceEventIndex(),
                task.catalogCode(),
                task.skillId(),
                task.resultType(),
                task.aiTaskType(),
                task.systemTaskType(),
                task.taskCardType(),
                task.taskSubtype(),
                task.taskStatus(),
                task.queueParticipation(),
                task.executionOrder(),
                idString(task.parentTaskId()),
                task.parentSourceEventIndex(),
                task.linkedTaskGroupId(),
                task.blockedUntilParentCompleted(),
                UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt()));
    }

    private List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords(
            List<ReservationAiQueryOrderSnapshot> orders,
            List<ReservationAiQueryTaskSnapshot> tasks) {
        List<ReservationAiCaseContextResult.TerminatedRecord> orderRecords = orders.stream()
                .filter(order -> ReservationOrderStatus.ENDED.name().equals(order.orderStatus())
                        || ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus()))
                .map(order -> new ReservationAiCaseContextResult.TerminatedRecord(
                        "order",
                        idString(order.id()),
                        order.orderStatus(),
                        order.logicDeletedReason(),
                        UtcTimeFormatter.toUtcOffsetDateTime(terminatedAt(order)),
                        UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt())))
                .toList();
        List<ReservationAiCaseContextResult.TerminatedRecord> taskRecords = tasks.stream()
                .filter(task -> TERMINATED_TASK_STATUSES.contains(task.taskStatus()))
                .map(task -> new ReservationAiCaseContextResult.TerminatedRecord(
                        "task",
                        idString(task.id()),
                        task.taskStatus(),
                        task.lastFailureReason(),
                        UtcTimeFormatter.toUtcOffsetDateTime(task.completedAt()),
                        UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt())))
                .toList();
        return Stream.concat(orderRecords.stream(), taskRecords.stream()).toList();
    }

    private ReservationAiCaseContextResult.TargetObjectValidation targetObjectValidation(
            List<ReservationAiQueryOrderSnapshot> activeOrders,
            List<ReservationAiCaseContextResult.PendingOrOpenTask> openTasks,
            List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords) {
        boolean hasOpenTask = !openTasks.isEmpty();
        boolean hasNewBookingOpenTask = openTasks.stream()
                .anyMatch(task -> ReservationSystemTaskType.NEW_BOOKING.name().equals(task.systemTaskType()));
        if (activeOrders.size() > 1) {
            return new ReservationAiCaseContextResult.TargetObjectValidation(
                    "multiple",
                    null,
                    null,
                    false,
                    false,
                    false,
                    false,
                    false,
                    "MULTIPLE_ACTIVE_OBJECTS");
        }
        if (activeOrders.size() == 1) {
            ReservationAiQueryOrderSnapshot order = activeOrders.get(0);
            boolean canOperate = !hasOpenTask;
            return new ReservationAiCaseContextResult.TargetObjectValidation(
                    hasOpenTask ? "conflict" : "single",
                    objectId(order.id()),
                    objectType(order),
                    false,
                    canOperate,
                    canOperate,
                    true,
                    true,
                    hasOpenTask ? "OPEN_TASK_EXISTS" : null);
        }
        return new ReservationAiCaseContextResult.TargetObjectValidation(
                terminatedRecords.isEmpty() ? "none" : "conflict",
                null,
                null,
                !hasOpenTask,
                false,
                false,
                hasNewBookingOpenTask,
                hasNewBookingOpenTask,
                terminatedRecords.isEmpty() ? null : "TERMINATED_RECORD_EXISTS");
    }

    private List<ReservationAiQueryTaskSnapshot> mergeTasks(
            List<ReservationAiQueryTaskSnapshot> first,
            List<ReservationAiQueryTaskSnapshot> second) {
        Map<Long, ReservationAiQueryTaskSnapshot> merged = new LinkedHashMap<>();
        first.forEach(task -> merged.put(task.id(), task));
        second.forEach(task -> merged.putIfAbsent(task.id(), task));
        return List.copyOf(merged.values());
    }

    private void validateCaseContextRequest(ReservationAiCaseContextQueryRequest request) {
        if (request == null) {
            throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
        }
        if (trimToNull(request.groupCode()) == null
                && trimToNull(request.confirmationNumber()) == null
                && trimToNull(request.reservationNo()) == null) {
            throw badRequest("QUERY_KEY_REQUIRED", "group_code、confirmation_number、reservation_no 至少需要一个");
        }
    }

    private void validateObjectDetailRequest(ReservationAiObjectDetailQueryRequest request) {
        if (request == null) {
            throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
        }
        requireText(request.objectId(), "OBJECT_ID_REQUIRED", "object_id 不能为空");
    }

    private void validateConversationRequest(ReservationMessageConversationQueryRequest request) {
        if (request == null) {
            throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
        }
        if (trimToNull(request.externalConversationId()) == null && trimToNull(request.sourceMessageId()) == null) {
            throw badRequest(
                    "MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED",
                    "external_conversation_id、source_message_id 至少需要一个");
        }
    }

    private ReservationAiQueryException badRequest(String code, String message) {
        return new ReservationAiQueryException(HttpStatus.BAD_REQUEST, code, message);
    }

    private ReservationAiQueryException notFound(String code, String message) {
        return new ReservationAiQueryException(HttpStatus.NOT_FOUND, code, message);
    }

    /**
     * 解析 AI 查询酒店上下文。SuperAgent 可不传 hotel_id；如果传入，单酒店阶段必须与系统酒店一致。
     */
    private String resolveHotelId(String requestedHotelId) {
        try {
            return hotelContextService.requireAccessibleHotel(requestedHotelId);
        } catch (HotelContextException exception) {
            throw new ReservationAiQueryException(
                    exception.getStatus(),
                    exception.getErrorCode(),
                    exception.getMessage());
        }
    }

    private void requireText(String value, String code, String message) {
        if (trimToNull(value) == null) {
            throw badRequest(code, message);
        }
    }

    private Long parseOrderObjectId(String objectId) {
        String text = trimToNull(objectId);
        if (text == null || !text.startsWith(OBJECT_ID_PREFIX_ORDER)) {
            throw badRequest("OBJECT_ID_INVALID", "object_id 第一版仅支持 ORDER:{orderId}");
        }
        return parseLong(text.substring(OBJECT_ID_PREFIX_ORDER.length()), "OBJECT_ID_INVALID", "object_id 中的订单 ID 不合法");
    }

    private Long parseLong(String value, String code, String message) {
        try {
            return Long.valueOf(Objects.requireNonNull(trimToNull(value)));
        } catch (RuntimeException ex) {
            throw badRequest(code, message);
        }
    }

    private String objectId(Long orderId) {
        return OBJECT_ID_PREFIX_ORDER + orderId;
    }

    private String objectType(ReservationAiQueryOrderSnapshot order) {
        if (ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
            return "group_block";
        }
        if (ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
            return "fit_reservation";
        }
        return "temporary_order";
    }

    private String groupCode(ReservationAiQueryOrderSnapshot order) {
        if (!ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
            return null;
        }
        return Optional.ofNullable(trimToNull(order.activeBusinessKey())).orElse(order.orderBusinessKey());
    }

    private String confirmationNumber(ReservationAiQueryOrderSnapshot order) {
        if (!ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
            return null;
        }
        return Optional.ofNullable(trimToNull(order.activeBusinessKey())).orElse(order.orderBusinessKey());
    }

    private String cancelStatus(ReservationAiQueryOrderSnapshot order) {
        if (ReservationOrderStatus.ENDED.name().equals(order.orderStatus())
                || ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus())) {
            return "cancelled";
        }
        return ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus()) ? "not_cancelled" : "unknown";
    }

    private LocalDateTime terminatedAt(ReservationAiQueryOrderSnapshot order) {
        if (order.logicDeletedAt() != null) {
            return order.logicDeletedAt();
        }
        return order.endedAt();
    }

    private String idString(Long id) {
        return id == null ? null : id.toString();
    }

    private String trimToNull(String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        return value.trim();
    }
}
