from __future__ import annotations

import re
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation

from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError

from app.api.deps import CurrentUserContext
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.schemas.user_agent import (
    UserAgentDraftPayload,
    UserAgentRequest,
    UserAgentResponse,
    UserAgentSuggestedAction,
)
from app.services.application_location_semantics import (
    strip_route_location_prefix_with_jieba,
    validate_application_location_text,
)
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
from app.services.document_numbering import (
    build_document_number,
    generate_unique_expense_claim_no,
)
from app.services.expense_application_draft_events import (
    ExpenseApplicationDraftEventService,
)
from app.services.expense_application_learning import ExpenseApplicationLearningService
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_historical_evidence import (
    build_user_agent_historical_evidence_notice,
)
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_application_dates import (
    expand_application_time_with_days,
    resolve_application_date_range,
    resolve_application_days_from_time_range,
)
from app.services.user_agent_application_locations import normalize_application_location
from app.services.user_agent_application_summary import (
    build_application_summary,
    build_application_summary_table,
    resolve_application_time_label,
)

APPLICATION_CONTEXT_VALUES = {
    "application",
    "documents_application",
    "expense_application",
    "pre_approval",
    "preapproval",
}
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
APPLICATION_TIME_LABELS = ("行程时间", "出发时间", "返回时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
APPLICATION_FIELD_LABELS = (
    "申请类型",
    "费用类型",
    "姓名",
    "申请人",
    "部门",
    "岗位",
    "职级",
    "直属领导",
    *APPLICATION_TIME_LABELS,
    "地点",
    "业务地点",
    "发生地点",
    "目的地",
    "事由",
    "申请事由",
    "出差事由",
    "原因",
    "用途",
    "天数",
    "出差天数",
    "申请天数",
    "出行方式",
    "交通方式",
    "交通工具",
    "出行工具",
    "用户预估费用",
    "预估费用",
    "预计总费用",
    "预计费用",
    "预计金额",
    "申请金额",
    "预算",
    "金额",
    "费用",
)
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
APPLICATION_TRANSPORT_KEYWORDS = {
    "飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
    "火车": ("火车", "高铁", "动车", "铁路", "列车"),
    "轮船": ("轮船", "船", "客轮", "邮轮", "坐船"),
}
APPLICATION_TYPE_DISPLAY_LABELS = {
    "travel": "差旅费用申请",
    "travel_application": "差旅费用申请",
    "expense_application": "费用申请",
    "application": "费用申请",
    "transport": "交通费用申请",
    "transportation": "交通费用申请",
    "traffic": "交通费用申请",
    "hotel": "住宿费用申请",
    "accommodation": "住宿费用申请",
    "meeting": "会务费用申请",
    "conference": "会务费用申请",
    "purchase": "采购费用申请",
    "procurement": "采购费用申请",
    "training": "培训费用申请",
    "business_entertainment": "业务招待申请",
    "entertainment": "业务招待申请",
    "office": "办公费用申请",
}
APPLICATION_REASON_VERBS = (
    "支撑",
    "支持",
    "部署",
    "上线",
    "实施",
    "驻场",
    "拜访",
    "验收",
    "会议",
    "采购",
    "培训",
    "协助",
    "处理",
    "办理",
    "参加",
    "进行",
)
APPLICATION_SUBMIT_KEYWORDS = (
    "确认提交",
    "确认申请",
    "提交审核",
    "确认无误提交",
    "直接提交",
)
APPLICATION_SAVE_DRAFT_KEYWORDS = (
    "保存草稿",
    "保存申请草稿",
    "存草稿",
    "先保存",
)
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"}
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"}
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
    "cancelled",
    "canceled",
    "void",
    "voided",
    "deleted",
    "已取消",
    "已作废",
    "作废",
    "已删除",
}


class UserAgentApplicationSlotMixin:
    @staticmethod
    def _iter_application_user_messages(payload: UserAgentRequest) -> list[tuple[str, bool]]:
        messages: list[tuple[str, bool]] = []
        history = (payload.context_json or {}).get("conversation_history")
        if isinstance(history, list):
            for item in history:
                if not isinstance(item, dict):
                    continue
                if str(item.get("role") or "").strip() != "user":
                    continue
                content = str(item.get("content") or "").strip()
                if content:
                    messages.append((content, False))
        current_message = str(payload.message or "").strip()
        if current_message:
            messages.append((current_message, True))
        return messages

    @staticmethod
    def _resolve_application_missing_base_fields(facts: dict[str, str]) -> list[str]:
        return [field for field in APPLICATION_BASE_FIELDS if not str(facts.get(field) or "").strip()]

    @staticmethod
    def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]:
        return [
            field
            for field in ("transport_mode",)
            if not str(facts.get(field) or "").strip()
        ]

    @staticmethod
    def _resolve_application_validation_issues(facts: dict[str, str]) -> list[dict[str, str]]:
        issues: list[dict[str, str]] = []
        location_error = validate_application_location_text(facts.get("location", ""))
        if location_error:
            issues.append({
                "field": "location",
                "message": location_error,
            })
        return issues

    def _resolve_application_missing_fields(self, facts: dict[str, str]) -> list[str]:
        return [
            *self._resolve_application_missing_base_fields(facts),
            *self._resolve_application_missing_followup_fields(facts),
        ]

    @staticmethod
    def _resolve_application_time(payload: UserAgentRequest, *, message: str | None = None) -> str:
        if message and UserAgentApplicationMixin._resolve_application_time_from_text(message):
            return UserAgentApplicationMixin._resolve_application_time_from_text(message)

        context_time = UserAgentApplicationMixin._resolve_application_time_from_context(payload.context_json or {})
        if context_time:
            return context_time

        time_range = payload.ontology.time_range
        if time_range.start_date and time_range.end_date:
            return (
                time_range.start_date
                if time_range.start_date == time_range.end_date
                else f"{time_range.start_date} 至 {time_range.end_date}"
            )
        return str(time_range.raw or "").strip()

    @staticmethod
    def _resolve_application_time_from_text(message: str) -> str:
        departure_time = UserAgentApplicationMixin._resolve_application_labeled_value(
            message,
            ("出发时间", "出发日期"),
        )
        return_time = UserAgentApplicationMixin._resolve_application_labeled_value(
            message,
            ("返回时间", "返回日期"),
        )
        if departure_time and return_time:
            return departure_time if departure_time == return_time else f"{departure_time} 至 {return_time}"

        labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
            message,
            APPLICATION_TIME_LABELS,
        )
        if labeled:
            return labeled
        range_match = re.search(
            r"(?P<start>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)\s*(?:至|到|~|—|–|--)\s*(?P<end>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
            str(message or ""),
        )
        if range_match:
            return f"{range_match.group('start').rstrip('日')} 至 {range_match.group('end').rstrip('日')}"
        match = re.search(
            r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
            str(message or ""),
        )
        return match.group("date").rstrip("日") if match else ""

    @staticmethod
    def _resolve_application_time_from_context(context_json: dict[str, object]) -> str:
        business_time_context = context_json.get("business_time_context")
        if not isinstance(business_time_context, dict):
            return ""
        start_date = str(business_time_context.get("start_date") or "").strip()
        end_date = str(business_time_context.get("end_date") or start_date).strip()
        display_value = str(business_time_context.get("display_value") or "").strip()
        if start_date and end_date:
            return start_date if start_date == end_date else f"{start_date} 至 {end_date}"
        return display_value

    @staticmethod
    def _should_prefer_context_application_time(current_time: str, context_time: str) -> bool:
        current = str(current_time or "").strip()
        context = str(context_time or "").strip()
        if not context:
            return False
        if not current:
            return True
        if "至" not in context:
            return False
        current_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", current)
        context_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", context)
        return len(current_dates) <= 1 and len(context_dates) >= 2 and current_dates[:1] == context_dates[:1]

    @staticmethod
    def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
        label_pattern = "|".join(re.escape(label) for label in labels)
        table_match = re.search(
            rf"\|\s*(?:{label_pattern})\s*\|\s*(?P<value>[^|\n]+?)\s*\|",
            str(message or ""),
        )
        if table_match:
            return table_match.group("value").strip()

        next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
        match = re.search(
            rf"(?:{label_pattern})[:：]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[:：]|[\n，。；;]|$)",
            str(message or ""),
        )
        return match.group("value").strip() if match else ""

    def _resolve_application_location(
        self,
        payload: UserAgentRequest,
        *,
        message: str,
        use_entities: bool,
    ) -> str:
        labeled = self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点"))
        if labeled:
            return normalize_application_location(labeled)

        if use_entities:
            entity_value = next(
                (
                    str(item.normalized_value or item.value or "").strip()
                    for item in payload.ontology.entities
                    if item.type == "location"
                    and str(item.normalized_value or item.value or "").strip()
                ),
                "",
            )
            if entity_value:
                return normalize_application_location(entity_value)

        return self._resolve_application_location_from_text(message)

    @staticmethod
    def _resolve_application_location_from_text(message: str) -> str:
        compact = re.sub(r"\s+", "", str(message or ""))
        if not compact:
            return ""

        for pattern in (
            r"(?:出差|去|到|赴|前往)(?P<target>[\u4e00-\u9fa5]{1,24})",
            r"(?P<target>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)",
        ):
            match = re.search(pattern, compact)
            if not match:
                continue
            target = str(match.group("target") or "").strip()
            location = normalize_application_location(target)
            if location:
                return location
        return ""

    @staticmethod
    def _resolve_application_days(message: str) -> str:
        labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
            message,
            ("天数", "出差天数", "申请天数"),
        )
        if labeled:
            return labeled if labeled.endswith("天") else f"{labeled}天"
        match = re.search(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", str(message or ""))
        return f"{match.group('days')}天" if match else ""

    @staticmethod
    def _resolve_application_reason(message: str) -> str:
        labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
            message,
            ("事由", "申请事由", "出差事由", "原因", "用途"),
        )
        if labeled:
            return UserAgentApplicationMixin._cleanup_application_reason_candidate(labeled)

        text = str(message or "").strip()
        if not text:
            return ""

        candidates: list[str] = []
        for segment in re.split(r"[\n，。；;]+", text):
            candidate = UserAgentApplicationMixin._cleanup_application_reason_candidate(segment)
            if candidate:
                candidates.append(candidate)

        if not candidates:
            return ""
        business_candidate = next(
            (
                candidate
                for candidate in candidates
                if any(keyword in candidate for keyword in APPLICATION_REASON_VERBS)
            ),
            "",
        )
        return business_candidate or max(candidates, key=len)

    @staticmethod
    def _cleanup_application_reason_candidate(segment: str) -> str:
        text = str(segment or "").strip()
        if not text:
            return ""

        text = re.sub(
            r"^(?:行程时间|出发时间|返回时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[:：]\s*",
            "",
            text,
        )
        if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
            return ""
        if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
            return ""
        if re.fullmatch(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", text):
            return ""
        if re.fullmatch(r"(?:¥|￥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元)?", text):
            return ""
        if "时间" in text and re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", text):
            return ""
        if re.fullmatch(r"(?:去|到|前往)?[\u4e00-\u9fa5]{1,8}出差(?P<days>\d+|[一二两三四五六七八九十]{1,3})?天?", text):
            return ""

        tokenized = strip_route_location_prefix_with_jieba(text)
        if tokenized != text:
            text = tokenized
        else:
            text = re.sub(r"^.*?(?:出差|前往|去|到|赴)[\u4e00-\u9fa5]{1,8}(?:出差)?(?P<days>\d+|[一二两三四五六七八九十]{1,3})?天?[，,\s]*", "", text)
        text = re.sub(r"^(?:出差|申请|费用申请|业务|本次|去|到|前往)\s*", "", text)
        text = text.strip(" ：:，,。；;")
        if not text:
            return ""
        if re.fullmatch(r"[\u4e00-\u9fa5]{1,8}", text) and not any(keyword in text for keyword in APPLICATION_REASON_VERBS):
            return ""
        return text

    @staticmethod
    def _expand_application_time_with_days(
        time_text: str,
        days_text: str,
        context_json: dict[str, object] | None = None,
    ) -> str:
        return expand_application_time_with_days(
            time_text,
            days_text,
            context_json=context_json or {},
        )

    def _resolve_application_amount(
        self,
        payload: UserAgentRequest,
        *,
        message: str | None = None,
    ) -> str:
        entity_amount = next(
            (
                str(item.normalized_value or item.value or "").strip()
                for item in payload.ontology.entities
                if item.type == "amount"
                and str(item.normalized_value or item.value or "").strip()
            ),
            "",
        )
        if entity_amount:
            return entity_amount if entity_amount.endswith("元") else f"{entity_amount}元"
        return self._resolve_application_amount_from_text(message or payload.message)

    @staticmethod
    def _resolve_application_amount_from_text(message: str) -> str:
        labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
            message,
            ("用户预估费用", "预估费用", "预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
        )
        if labeled:
            return UserAgentApplicationMixin._normalize_application_amount(labeled)
        match = re.search(
            r"(?P<amount>\d+(?:\.\d+)?\s*万?\s*(?:元|块|人民币))",
            str(message or ""),
        )
        return UserAgentApplicationMixin._normalize_application_amount(match.group("amount")) if match else ""

    @staticmethod
    def _normalize_application_amount(value: str) -> str:
        normalized = str(value or "").strip()
        if not normalized:
            return ""
        normalized = re.sub(r"\s+", "", normalized)
        if normalized.endswith(("元", "块")) or "人民币" in normalized:
            return normalized.replace("块", "元").replace("人民币", "")
        return f"{normalized}元"

    @staticmethod
    def _resolve_application_transport_mode(message: str) -> str:
        compact_message = re.sub(r"\s+", "", str(message or ""))
        for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items():
            if any(keyword in compact_message for keyword in keywords):
                return transport
        labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
            message,
            ("出行方式", "交通方式", "交通工具", "出行工具"),
        )
        if labeled:
            for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items():
                if transport in labeled or any(keyword in labeled for keyword in keywords):
                    return transport
            return labeled
        return ""

    @staticmethod
    def _resolve_application_type_from_text(message: str) -> str:
        return UserAgentApplicationMixin._normalize_application_type_label(
            UserAgentApplicationMixin._resolve_application_labeled_value(
                message,
                ("申请类型", "费用类型"),
            )
        )

    @staticmethod
    def _normalize_application_type_label(value: object, fallback: str = "") -> str:
        raw_value = str(value or "").strip()
        if not raw_value:
            return str(fallback or "").strip()

        normalized_key = raw_value.lower()
        if normalized_key in APPLICATION_TYPE_DISPLAY_LABELS:
            return APPLICATION_TYPE_DISPLAY_LABELS[normalized_key]
        if re.fullmatch(r"(差旅费|差旅|出差)", raw_value):
            return "差旅费用申请"
        if re.fullmatch(r"(交通费|交通)", raw_value):
            return "交通费用申请"
        if re.fullmatch(r"(住宿费|住宿|酒店)", raw_value):
            return "住宿费用申请"
        if re.fullmatch(r"(会务|会议|会务费)", raw_value):
            return "会务费用申请"
        if re.fullmatch(r"(采购|采购费|办公用品)", raw_value):
            return "采购费用申请"
        if raw_value.endswith("费用申请") or raw_value.endswith("申请"):
            return raw_value
        if raw_value.endswith("费用"):
            return f"{raw_value}申请"
        if raw_value.endswith("费"):
            return f"{raw_value[:-1]}费用申请"
        return raw_value

    @staticmethod
    def _resolve_application_missing_slots(payload: UserAgentRequest) -> list[str]:
        return [
            str(item or "").strip()
            for item in payload.ontology.missing_slots
            if str(item or "").strip()
        ]

    @staticmethod
    def _display_application_slot_label(slot: str) -> str:
        return {
            "expense_type": "申请类型",
            "amount": "系统预估费用",
            "time_range": "申请时间",
            "time": "申请时间",
            "location": "地点",
            "reason": "申请事由",
            "days": "天数",
            "transport_mode": "出行方式",
            "attachments": "申请材料/附件",
            "customer_name": "业务对象",
            "participants": "参与人员",
        }.get(str(slot or "").strip(), str(slot or "").strip())

    def _build_expense_application_actions(
        self,
        step: str,
        facts: dict[str, str],
    ) -> list[UserAgentSuggestedAction]:
        if step in {"ask_missing", "ask_invalid"}:
            missing_fields = (
                self._resolve_application_missing_fields(facts)
                if step == "ask_missing"
                else [
                    issue.get("field", "")
                    for issue in self._resolve_application_validation_issues(facts)
                    if issue.get("field")
                ]
            )
            return [
                UserAgentSuggestedAction(
                    label="一次性补充申请信息",
                    action_type="prefill_composer",
                    description="在输入框预填所有待补充字段，填写后一次提交。",
                    payload={
                        "application_fields": missing_fields,
                        "prompt_prefill": self._build_application_prefill_template(missing_fields),
                        "missing_fields": missing_fields,
                    },
                )
            ]
        if step == "preview":
            return []
        if step == "submitted":
            return []
        return []

    @staticmethod
    def _resolve_application_prefill_config(field: str) -> tuple[str, str]:
        config = {
            "time": ("补充申请时间", "申请时间段："),
            "location": ("补充地点", "地点："),
            "reason": ("补充申请事由", "事由："),
            "days": ("补充天数", "天数："),
            "transport_mode": ("补充出行方式", "出行方式："),
            "amount": ("补充系统预估费用", "系统预估费用："),
        }
        return config.get(field, ("补充申请信息", ""))

    @classmethod
    def _build_application_prefill_template(cls, fields: list[str]) -> str:
        lines = [
            prefill
            for field in fields
            for _, prefill in [cls._resolve_application_prefill_config(field)]
            if prefill
        ]
        return "\n".join(lines)

    @classmethod
    def _build_application_prefill_action(cls, field: str) -> UserAgentSuggestedAction:
        label, prefill = cls._resolve_application_prefill_config(field)
        return UserAgentSuggestedAction(
            label=label,
            action_type="prefill_composer",
            description=f"在输入框预填“{prefill}”，用户补充后再提交。",
            payload={
                "application_field": field,
                "prompt_prefill": prefill,
                "missing_fields": [field],
            },
        )

    @staticmethod
    def _infer_application_type(facts: dict[str, str]) -> str:
        text = " ".join(str(facts.get(key) or "") for key in ("reason", "transport_mode", "days"))
        if "采购" in text:
            return "采购费用申请"
        if "会议" in text or "会务" in text:
            return "会务费用申请"
        return "差旅费用申请"


class UserAgentApplicationPersistenceMixin:
    def _record_expense_application_learning(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
        claim: ExpenseClaim,
        trusted_current_user: CurrentUserContext | None,
        *,
        action_type: str,
        business_event: BusinessEvent,
        preview_decision: AIApplicationPreviewDecision | None = None,
        action_request_id: str = "",
    ) -> None:
        if trusted_current_user is None:
            return
        ExpenseApplicationLearningService(self.db).record_action(
            payload,
            facts,
            claim,
            trusted_current_user,
            action_type=action_type,
            business_event=business_event,
            preview_decision=preview_decision,
            action_request_id=action_request_id,
        )

    @staticmethod
    def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
        if not isinstance(context_json, dict):
            return ""
        is_edit_mode = bool(context_json.get("application_edit_mode") or context_json.get("applicationEditMode"))
        claim_id = str(
            context_json.get("application_edit_claim_id")
            or context_json.get("applicationEditClaimId")
            or ""
        ).strip()
        return claim_id if is_edit_mode and claim_id else ""

    @staticmethod
    def _is_expense_application_claim_like(claim: ExpenseClaim) -> bool:
        expense_type = str(claim.expense_type or "").strip().lower()
        claim_no = str(claim.claim_no or "").strip().upper()
        flags = claim.risk_flags_json
        if isinstance(flags, dict):
            flags = [flags]
        if not isinstance(flags, list):
            flags = []
        has_application_detail = any(
            isinstance(flag, dict)
            and (
                str(flag.get("business_stage") or "").strip() == "expense_application"
                or isinstance(flag.get("application_detail"), dict)
            )
            for flag in flags
        )
        return (
            expense_type in {"application", "expense_application"}
            or expense_type.endswith("_application")
            or claim_no.startswith("AP-")
            or claim_no.startswith("APP-")
            or has_application_detail
        )

    def _find_editable_expense_application_record(
        self,
        payload: UserAgentRequest,
    ) -> ExpenseClaim | None:
        claim_id = self._resolve_application_edit_claim_id(payload.context_json or {})
        if not claim_id:
            return None

        claim = self.db.get(ExpenseClaim, claim_id)
        if claim is None:
            raise ValueError("未找到要修改的申请单。")
        if not self._is_expense_application_claim_like(claim):
            raise ValueError("只能修改申请单。")

        current_user = self._build_application_current_user(payload)
        access_policy = ExpenseClaimAccessPolicy(self.db)
        if not (current_user.is_admin or access_policy.is_claim_owned_by_current_user(claim, current_user)):
            raise ValueError("只能修改本人被退回的申请单。")

        status = str(claim.status or "").strip().lower()
        if status not in {"returned", "draft", "supplement"}:
            raise ValueError("当前申请单状态不支持修改。")
        return claim

    def _update_expense_application_record(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
        claim: ExpenseClaim,
        *,
        submit: bool,
        learning_current_user: CurrentUserContext | None = None,
        learning_preview_decision: AIApplicationPreviewDecision | None = None,
        learning_action_request_id: str = "",
    ) -> ExpenseClaim:
        current_user = self._build_application_current_user(payload)
        previous_status = str(claim.status or "").strip()
        previous_approval_stage = str(claim.approval_stage or "").strip()
        flags = claim.risk_flags_json
        if isinstance(flags, dict):
            flags = [flags]
        if not isinstance(flags, list):
            flags = []
        preserved_flags = [
            flag
            for flag in flags
            if not (
                isinstance(flag, dict)
                and str(flag.get("source") or "").strip() == "application_detail"
            )
        ]
        claim.expense_type = self._resolve_application_expense_type_code(facts)
        claim.reason = str(facts.get("reason") or "费用申请").strip() or "费用申请"
        claim.location = str(facts.get("location") or "待补充").strip() or "待补充"
        claim.amount = self._parse_application_amount_to_decimal(facts.get("amount", ""))
        claim.occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
        claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)]

        if not submit:
            claim.status = "draft"
            claim.approval_stage = "待提交"
            claim.submitted_at = None
            try:
                _, draft_event = ExpenseApplicationDraftEventService(self.db).record(
                    payload,
                    claim,
                    current_user,
                    event_type="claim_draft_updated",
                    previous_status=previous_status,
                    previous_approval_stage=previous_approval_stage,
                )
                self._record_expense_application_learning(
                    payload,
                    facts,
                    claim,
                    learning_current_user,
                    action_type="save_draft",
                    business_event=draft_event,
                    preview_decision=learning_preview_decision,
                    action_request_id=learning_action_request_id,
                )
                self.db.commit()
                self.db.refresh(claim)
                return claim
            except Exception:
                self.db.rollback()
                raise

        from app.services.expense_claims import ExpenseClaimService

        try:
            submitted = ExpenseClaimService(self.db).submit_claim(
                claim.id,
                current_user,
                correlation_id=payload.run_id,
                before_commit=(
                    lambda event: self._record_expense_application_learning(
                        payload,
                        facts,
                        claim,
                        learning_current_user,
                        action_type="submit",
                        business_event=event,
                        preview_decision=learning_preview_decision,
                        action_request_id=learning_action_request_id,
                    )
                ),
            )
            if submitted is None:
                raise ValueError("未找到可修改的申请单。")
            return submitted
        except Exception:
            self.db.rollback()
            raise

    def _create_expense_application_record(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
        *,
        submit: bool,
        learning_current_user: CurrentUserContext | None = None,
        learning_preview_decision: AIApplicationPreviewDecision | None = None,
        learning_action_request_id: str = "",
    ) -> ExpenseClaim:
        current_user = self._build_application_current_user(payload)
        access_policy = ExpenseClaimAccessPolicy(self.db)
        employee = access_policy.resolve_current_employee(current_user)
        department_name = str(current_user.department_name or "").strip() or "待补充"
        department_id = None
        employee_id = None
        employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip()

        if employee is not None:
            employee_id = employee.id
            employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip()
            department_id = employee.organization_unit_id
            if employee.organization_unit is not None and employee.organization_unit.name:
                department_name = str(employee.organization_unit.name).strip()

        claim = ExpenseClaim(
            tenant_id=current_user.tenant_id,
            claim_no=self._build_application_claim_no(payload, facts),
            employee_id=employee_id,
            employee_name=employee_name,
            department_id=department_id,
            department_name=department_name,
            project_code=None,
            expense_type=self._resolve_application_expense_type_code(facts),
            reason=str(facts.get("reason") or "费用申请").strip() or "费用申请",
            location=str(facts.get("location") or "待补充").strip() or "待补充",
            amount=self._parse_application_amount_to_decimal(facts.get("amount", "")),
            currency="CNY",
            invoice_count=0,
            occurred_at=self._parse_application_occurred_at(facts.get("time", "")),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[self._build_application_detail_flag(facts)],
        )
        draft_event_service = ExpenseApplicationDraftEventService(self.db)
        draft_idempotency_key = ""
        if not submit:
            draft_idempotency_key, existing = draft_event_service.prepare_created_draft(
                payload,
                claim,
                current_user,
            )
            if existing is not None:
                return existing

        self.db.add(claim)
        try:
            try:
                self.db.flush()
            except IntegrityError:
                self.db.rollback()
                if draft_idempotency_key:
                    existing = draft_event_service.find_created_draft(
                        current_user,
                        idempotency_key=draft_idempotency_key,
                    )
                    if existing is not None:
                        return existing
                raise
            # 非默认租户的费用单必须先建立 tenant-scoped Case Link，后续
            # submit_claim() 的访问策略才能在同一事务内重新查询到刚创建的记录。
            ExpenseCaseService(self.db).ensure_case_for_claim(
                claim,
                tenant_id=current_user.tenant_id,
            )
            if not submit:
                _, draft_event = draft_event_service.record(
                    payload,
                    claim,
                    current_user,
                    event_type="claim_draft_created",
                )
                self._record_expense_application_learning(
                    payload,
                    facts,
                    claim,
                    learning_current_user,
                    action_type="save_draft",
                    business_event=draft_event,
                    preview_decision=learning_preview_decision,
                    action_request_id=learning_action_request_id,
                )
                self.db.commit()
                self.db.refresh(claim)
                return claim

            from app.services.expense_claims import ExpenseClaimService

            submitted = ExpenseClaimService(self.db).submit_claim(
                claim.id,
                current_user,
                correlation_id=payload.run_id,
                before_commit=(
                    lambda event: self._record_expense_application_learning(
                        payload,
                        facts,
                        claim,
                        learning_current_user,
                        action_type="submit",
                        business_event=event,
                        preview_decision=learning_preview_decision,
                        action_request_id=learning_action_request_id,
                    )
                ),
            )
            if submitted is None:
                raise ValueError("未找到可提交的申请单。")
            return submitted
        except Exception:
            # 外层编排会记录失败工具调用并提交事务，必须先回滚本次已 flush 的草稿。
            self.db.rollback()
            raise

    def _find_duplicate_expense_application_record(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
    ) -> ExpenseClaim | None:
        current_user = self._build_application_current_user(payload)
        access_policy = ExpenseClaimAccessPolicy(self.db)
        employee = access_policy.resolve_current_employee(current_user)
        employee_id = employee.id if employee is not None else None
        employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip()
        if employee is not None:
            employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip()

        employee_filter = ExpenseClaim.employee_name == employee_name
        if employee_id is not None:
            employee_filter = or_(ExpenseClaim.employee_id == employee_id, employee_filter)

        stmt = (
            select(ExpenseClaim)
            .where(
                ExpenseClaim.expense_type == self._resolve_application_expense_type_code(facts),
                employee_filter,
            )
            .order_by(ExpenseClaim.id.desc())
            .limit(100)
        )
        occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
        for claim in self.db.scalars(stmt).all():
            if self._is_ignored_application_duplicate_status(claim.status):
                continue
            if self._matches_application_business_time(claim, facts, occurred_at):
                return claim
        return None

    @staticmethod
    def _is_ignored_application_duplicate_status(status: str | None) -> bool:
        return str(status or "").strip().lower() in APPLICATION_DUPLICATE_IGNORED_STATUSES

    @classmethod
    def _matches_application_business_time(
        cls,
        claim: ExpenseClaim,
        facts: dict[str, str],
        occurred_at: datetime,
    ) -> bool:
        current_range = resolve_application_date_range(facts.get("time", ""))
        current_time = cls._normalize_application_time_identity(facts.get("time"))
        existing_detail = cls._extract_application_detail_from_claim(claim)
        existing_range = resolve_application_date_range(existing_detail.get("time"))
        if existing_range is None and claim.occurred_at is not None:
            existing_day = claim.occurred_at.date()
            existing_range = (existing_day, existing_day)
        if current_range is None and occurred_at is not None:
            current_day = occurred_at.date()
            current_range = (current_day, current_day)
        if current_range is not None and existing_range is not None:
            return current_range[0] <= existing_range[1] and existing_range[0] <= current_range[1]

        existing_time = cls._normalize_application_time_identity(existing_detail.get("time"))
        if current_time and existing_time:
            return current_time == existing_time
        if claim.occurred_at is None:
            return False
        return claim.occurred_at.date() == occurred_at.date()

    @staticmethod
    def _normalize_application_time_identity(value: object) -> str:
        normalized = str(value or "").strip()
        if not normalized:
            return ""
        normalized = (
            normalized.replace("到", "至")
            .replace("~", "至")
            .replace("—", "至")
            .replace("–", "至")
            .replace("－", "至")
            .replace("/", "-")
        )
        return re.sub(r"\s+", "", normalized)

    @staticmethod
    def _extract_application_detail_from_claim(claim: ExpenseClaim) -> dict[str, object]:
        flags = claim.risk_flags_json
        if isinstance(flags, dict):
            flags = [flags]
        if not isinstance(flags, list):
            return {}
        for item in flags:
            if not isinstance(item, dict):
                continue
            detail = item.get("application_detail")
            if isinstance(detail, dict):
                return detail
        return {}

    @staticmethod
    def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
        return with_risk_business_stage(
            {
                "source": "application_detail",
                "severity": "info",
                "label": "申请详情",
                "application_detail": {
                    "application_type": str(facts.get("application_type") or "").strip(),
                    "time": str(facts.get("time") or "").strip(),
                    "location": str(facts.get("location") or "").strip(),
                    "reason": str(facts.get("reason") or "").strip(),
                    "days": str(facts.get("days") or "").strip(),
                    "transport_mode": str(facts.get("transport_mode") or "").strip(),
                    "amount": str(facts.get("amount") or "").strip(),
                    "grade": str(facts.get("grade") or "").strip(),
                    "lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
                    "subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
                    "transport_policy": str(facts.get("transport_policy") or "").strip(),
                    "policy_estimate": str(facts.get("policy_estimate") or "").strip(),
                    "matched_city": str(facts.get("matched_city") or "").strip(),
                    "rule_name": str(facts.get("rule_name") or "").strip(),
                    "rule_version": str(facts.get("rule_version") or "").strip(),
                    "hotel_amount": str(facts.get("hotel_amount") or "").strip(),
                    "allowance_amount": str(facts.get("allowance_amount") or "").strip(),
                    "transport_estimated_amount": str(facts.get("transport_estimated_amount") or "").strip(),
                    "transport_estimate_source": str(facts.get("transport_estimate_source") or "").strip(),
                    "transport_estimate_confidence": str(facts.get("transport_estimate_confidence") or "").strip(),
                    "policy_total_amount": str(facts.get("policy_total_amount") or "").strip(),
                },
            },
            "expense_application",
        )

    def _resolve_application_manager_name(
        self,
        payload: UserAgentRequest,
        claim: ExpenseClaim | None = None,
    ) -> str:
        if claim is not None:
            manager_name = ExpenseClaimAccessPolicy.resolve_claim_manager_name(claim)
            if manager_name and not ExpenseClaimAccessPolicy.is_missing_value(manager_name):
                return manager_name

        context_json = payload.context_json or {}
        for key in ("manager_name", "managerName", "direct_manager_name", "directManagerName"):
            value = str(context_json.get(key) or "").strip()
            if value and not ExpenseClaimAccessPolicy.is_missing_value(value):
                return value
        return ""

    @staticmethod
    def _build_application_current_user(payload: UserAgentRequest) -> CurrentUserContext:
        context_json = payload.context_json or {}
        raw_role_codes = context_json.get("role_codes")
        if isinstance(raw_role_codes, list):
            role_codes = [str(item).strip() for item in raw_role_codes if str(item).strip()]
        else:
            role_codes = [item.strip() for item in str(raw_role_codes or "").split(",") if item.strip()]
        username = str(
            payload.user_id
            or context_json.get("username")
            or context_json.get("user_id")
            or context_json.get("employee_no")
            or context_json.get("name")
            or "anonymous"
        ).strip()
        name = str(context_json.get("name") or context_json.get("user_name") or username).strip()
        return CurrentUserContext(
            username=username or name or "anonymous",
            name=name or username or "anonymous",
            role_codes=role_codes,
            is_admin=bool(context_json.get("is_admin")),
            tenant_id=str(
                context_json.get("tenant_id")
                or context_json.get("tenantId")
                or "default"
            ).strip()
            or "default",
            department_name=str(
                context_json.get("department_name")
                or context_json.get("department")
                or context_json.get("departmentName")
                or ""
            ).strip(),
            cost_center=str(context_json.get("cost_center") or context_json.get("costCenter") or "").strip(),
            position=str(
                context_json.get("position")
                or context_json.get("employee_position")
                or context_json.get("employeePosition")
                or ""
            ).strip(),
            grade=str(
                context_json.get("grade")
                or context_json.get("employee_grade")
                or context_json.get("employeeGrade")
                or ""
            ).strip(),
            employee_no=str(
                context_json.get("employee_no")
                or context_json.get("employeeNo")
                or ""
            ).strip(),
            manager_name=str(
                context_json.get("manager_name")
                or context_json.get("managerName")
                or context_json.get("direct_manager_name")
                or context_json.get("directManagerName")
                or ""
            ).strip(),
        )

    @staticmethod
    def _resolve_application_expense_type_code(facts: dict[str, str]) -> str:
        application_type = str(facts.get("application_type") or "").strip()
        if "差旅" in application_type:
            return "travel_application"
        if "采购" in application_type:
            return "purchase_application"
        if "会务" in application_type or "会议" in application_type:
            return "meeting_application"
        return "expense_application"

    @staticmethod
    def _parse_application_amount_to_decimal(amount_text: str) -> Decimal:
        normalized = str(amount_text or "").replace(",", "").replace("，", "").strip()
        match = re.search(r"\d+(?:\.\d+)?", normalized)
        if not match:
            return Decimal("0.00")
        try:
            return Decimal(match.group(0)).quantize(Decimal("0.01"))
        except (InvalidOperation, ValueError):
            return Decimal("0.00")

    @staticmethod
    def _parse_application_occurred_at(time_text: str) -> datetime:
        normalized = str(time_text or "")
        match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", normalized)
        if match:
            year, month, day = (int(part) for part in match.groups())
            return datetime(year, month, day, tzinfo=UTC)
        return datetime.now(UTC)

    def _build_persisted_application_payload(
        self,
        claim: ExpenseClaim | None,
        facts: dict[str, str],
    ) -> UserAgentDraftPayload | None:
        if claim is None:
            return None
        return UserAgentDraftPayload(
            draft_type="expense_application",
            title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
            body=build_application_summary(facts),
            confirmation_required=False,
            claim_id=claim.id,
            claim_no=claim.claim_no,
            status=claim.status,
            approval_stage=claim.approval_stage,
        )

    @staticmethod
    def _is_application_save_draft_action(payload: UserAgentRequest) -> bool:
        context_json = payload.context_json or {}
        action = str(
            context_json.get("application_action")
            or context_json.get("applicationAction")
            or ""
        ).strip().lower()
        if action in {"save_draft", "application_save_draft", "draft"}:
            return True
        if bool(context_json.get("application_save_mode") or context_json.get("applicationSaveMode")):
            return True
        compact_message = re.sub(r"\s+", "", str(payload.message or ""))
        return any(keyword in compact_message for keyword in APPLICATION_SAVE_DRAFT_KEYWORDS)

    def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool:
        compact_message = re.sub(r"\s+", "", str(payload.message or ""))
        if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS):
            return True
        if compact_message not in APPLICATION_SHORT_CONFIRMATIONS:
            return False
        history = (payload.context_json or {}).get("conversation_history")
        if not isinstance(history, list):
            return False
        return any(
            isinstance(item, dict)
            and str(item.get("role") or "").strip() == "assistant"
            and (
                "是否确认提交" in str(item.get("content") or "")
                or "当前状态：待确认提交" in str(item.get("content") or "")
                or "#application-submit" in str(item.get("content") or "")
                or "确认无误后" in str(item.get("content") or "")
            )
            for item in history[-4:]
        )

    def _build_simulated_application_no(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
    ) -> str:
        return self._build_simulated_application_no_from_facts(
            facts,
            fallback_seed=str(payload.run_id or ""),
        )

    @staticmethod
    def _build_simulated_application_no_from_facts(
        facts: dict[str, str],
        *,
        fallback_seed: str = "",
    ) -> str:
        return build_document_number("application", timestamp=datetime.now(UTC))

    def _build_application_claim_no(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
    ) -> str:
        return generate_unique_expense_claim_no(
            self.db,
            "application",
            timestamp=datetime.now(UTC),
        )


class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicationPersistenceMixin):
    @staticmethod
    def _is_expense_application_request(payload: UserAgentRequest) -> bool:
        context_json = payload.context_json or {}
        context_values = {
            str(context_json.get("session_type") or "").strip(),
            str(context_json.get("entry_source") or "").strip(),
            str(context_json.get("document_type") or "").strip(),
            str(context_json.get("application_stage") or "").strip(),
        }
        conversation_state = context_json.get("conversation_state")
        if isinstance(conversation_state, dict):
            context_values.update(
                {
                    str(conversation_state.get("session_type") or "").strip(),
                    str(conversation_state.get("entry_source") or "").strip(),
                    str(conversation_state.get("document_type") or "").strip(),
                    str(conversation_state.get("application_stage") or "").strip(),
                }
            )
        if context_values & APPLICATION_CONTEXT_VALUES:
            return True

        history = context_json.get("conversation_history")
        if not isinstance(history, list):
            return False
        compact_message = re.sub(r"\s+", "", str(payload.message or ""))
        looks_like_submit = (
            any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
            or compact_message in APPLICATION_SHORT_CONFIRMATIONS
        )
        if not looks_like_submit:
            return False
        return any(
            isinstance(item, dict)
            and str(item.get("role") or "").strip() == "assistant"
            and (
                "#application-submit" in str(item.get("content") or "")
                or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or ""))
            )
            for item in history[-6:]
        )

    def _build_expense_application_response(
        self,
        payload: UserAgentRequest,
        *,
        risk_flags: list[str],
        learning_current_user: CurrentUserContext | None = None,
        learning_preview_decision: AIApplicationPreviewDecision | None = None,
        learning_action_request_id: str = "",
    ) -> UserAgentResponse:
        facts = self._resolve_expense_application_facts(payload)
        step = self._resolve_expense_application_step(payload, facts)
        application_claim = None
        if step in {"draft", "submitted"}:
            editable_claim = self._find_editable_expense_application_record(payload)
            if editable_claim is not None:
                application_claim = self._update_expense_application_record(
                    payload,
                    facts,
                    editable_claim,
                    submit=step == "submitted",
                    learning_current_user=learning_current_user,
                    learning_preview_decision=learning_preview_decision,
                    learning_action_request_id=learning_action_request_id,
                )
                facts["application_edit_mode"] = "true"
            elif step == "submitted":
                application_claim = self._find_duplicate_expense_application_record(payload, facts)
                if application_claim is not None:
                    step = "duplicate"
                    facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
                else:
                    application_claim = self._create_expense_application_record(
                        payload,
                        facts,
                        submit=True,
                        learning_current_user=learning_current_user,
                        learning_preview_decision=learning_preview_decision,
                        learning_action_request_id=learning_action_request_id,
                    )
            else:
                application_claim = self._create_expense_application_record(
                    payload,
                    facts,
                    submit=False,
                    learning_current_user=learning_current_user,
                    learning_preview_decision=learning_preview_decision,
                    learning_action_request_id=learning_action_request_id,
                )
            if application_claim is not None:
                facts["application_no"] = application_claim.claim_no
                facts["application_claim_id"] = application_claim.id
                facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
                facts["historical_case_evidence_notice"] = (
                    build_user_agent_historical_evidence_notice(application_claim)
                )
        return UserAgentResponse(
            answer=self._build_expense_application_answer(payload, facts=facts, step=step),
            citations=[],
            suggested_actions=self._build_expense_application_actions(step, facts),
            query_payload=None,
            draft_payload=(
                self._build_persisted_application_payload(application_claim, facts)
                if step in {"draft", "submitted"}
                else None
            ),
            review_payload=None,
            risk_flags=risk_flags,
            requires_confirmation=step == "preview",
        )

    def _build_expense_application_answer(
        self,
        payload: UserAgentRequest,
        *,
        facts: dict[str, str],
        step: str,
    ) -> str:
        recognized_table = build_application_summary_table(facts, include_empty=False)

        if step == "ask_missing":
            missing_fields = self._resolve_application_missing_fields(facts)
            missing_text = "、".join(
                self._display_application_slot_label(item)
                for item in missing_fields
            )
            return "\n\n".join(
                [
                    "我已按「费用申请 / 事前审批」来处理这条内容。",
                    "已识别信息：\n" + recognized_table,
                    f"当前还需要补充：{missing_text}。",
                    "请一次性补齐上述字段，我会继续生成申请核对结果，并请您确认是否提交。",
                ]
            )

        if step == "ask_invalid":
            issue_messages = [
                item["message"]
                for item in self._resolve_application_validation_issues(facts)
                if str(item.get("message") or "").strip()
            ]
            return "\n\n".join(
                [
                    "我已识别到申请信息里有需要先修正的字段。",
                    "已识别信息：\n" + recognized_table,
                    *issue_messages,
                    "请把地点改为真实出差地点，业务事项放在事由中；修正后我再帮您提交申请。",
                ]
            )

        if step == "draft":
            application_no = str(facts.get("application_no") or "").strip()
            return "\n\n".join(
                [
                    "申请草稿已保存。",
                    f"草稿单号：{application_no}" if application_no else "草稿单号：待生成",
                    "当前节点：待提交。",
                    "后续可进入单据详情继续核对、补充或提交审批。",
                ]
            )

        if step == "submitted":
            application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
            manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
            historical_notice = str(
                facts.get("historical_case_evidence_notice") or ""
            ).strip()
            submitted_title = (
                "申请单据已修改并重新提交，已进入审批流程。"
                if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
                else "申请单据已生成，并已进入审批流程。"
            )
            return "\n\n".join(
                [
                    submitted_title,
                    f"系统已推送给 {manager_name} 审核，当前节点：{manager_name}审核中。",
                    f"申请单号：{application_no}",
                    *([historical_notice] if historical_notice else []),
                    "下方是简要单据信息。需要查看完整详情时，请点击快捷方式进入单据详情。",
                ]
            )

        if step == "duplicate":
            application_no = str(facts.get("application_no") or "").strip()
            stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
            time_label = resolve_application_time_label(facts)
            return "\n\n".join(
                [
                    f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单，系统没有重复创建。",
                    f"已有申请单号：{application_no}",
                    f"当前节点：{stage}",
                    "如需继续处理，请在单据中心查看该申请；如果本次业务时间不同，请先调整时间后再提交。",
                ]
            )

        return "\n\n".join(
            [
                "这是费用申请核对结果，请核对：",
                build_application_summary_table(facts),
                "请核对上述信息无误，确认无误后 [确认](#application-submit) 提交至审批流程。",
            ]
        )

    def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]:
        facts = {
            "time": "",
            "location": "",
            "reason": "",
            "days": "",
            "transport_mode": "",
            "amount": "",
            "application_type": "",
            "applicant": "",
            "grade": "",
            "department": "",
            "position": "",
            "manager_name": "",
            "lodging_daily_cap": "",
            "subsidy_daily_cap": "",
            "transport_policy": "",
            "policy_estimate": "",
            "matched_city": "",
            "rule_name": "",
            "rule_version": "",
            "hotel_amount": "",
            "allowance_amount": "",
            "transport_estimated_amount": "",
            "transport_estimate_source": "",
            "transport_estimate_confidence": "",
            "policy_total_amount": "",
        }
        for message, is_current in self._iter_application_user_messages(payload):
            partial = {
                "time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message),
                "location": self._resolve_application_location(payload, message=message, use_entities=is_current),
                "reason": self._resolve_application_reason(message),
                "days": self._resolve_application_days(message),
                "transport_mode": self._resolve_application_transport_mode(message),
                "amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message),
                "application_type": self._resolve_application_type_from_text(message),
            }
            for key, value in partial.items():
                if value:
                    facts[key] = value

        for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
            if value:
                facts[key] = value

        facts["application_type"] = self._normalize_application_type_label(facts.get("application_type", ""))
        context_json = payload.context_json or {}
        context_time = self._resolve_application_time_from_context(context_json)
        if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
            facts["time"] = context_time
        current_user = self._build_application_current_user(payload)
        employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
        if not facts["applicant"]:
            facts["applicant"] = str(
                context_json.get("name")
                or context_json.get("user_name")
                or context_json.get("applicant")
                or (employee.name if employee is not None else "")
                or current_user.name
                or ""
            ).strip()
        if not facts["grade"]:
            facts["grade"] = str(
                context_json.get("grade")
                or context_json.get("employee_grade")
                or context_json.get("employeeGrade")
                or current_user.grade
                or (employee.grade if employee is not None else "")
                or ""
            ).strip()
        if not facts["department"]:
            facts["department"] = str(
                context_json.get("department")
                or context_json.get("department_name")
                or context_json.get("departmentName")
                or current_user.department_name
                or (
                    employee.organization_unit.name
                    if employee is not None and employee.organization_unit is not None
                    else ""
                )
                or ""
            ).strip()
        if not facts["position"]:
            facts["position"] = str(
                context_json.get("position")
                or context_json.get("employee_position")
                or context_json.get("employeePosition")
                or current_user.position
                or (employee.position if employee is not None else "")
                or ""
            ).strip()
        if not facts["manager_name"]:
            facts["manager_name"] = str(
                context_json.get("manager_name")
                or context_json.get("managerName")
                or context_json.get("direct_manager_name")
                or context_json.get("directManagerName")
                or current_user.manager_name
                or (
                    employee.manager.name
                    if employee is not None and employee.manager is not None
                    else ""
                )
                or (
                    employee.organization_unit.manager_name
                    if employee is not None and employee.organization_unit is not None
                    else ""
                )
                or ""
            ).strip()

        if not facts["application_type"]:
            facts["application_type"] = self._infer_application_type(facts)
        facts["time"] = self._expand_application_time_with_days(
            facts.get("time", ""),
            facts.get("days", ""),
            payload.context_json or {},
        )
        if self._is_application_missing_value(facts.get("days", "")):
            range_days = resolve_application_days_from_time_range(facts.get("time", ""))
            if range_days:
                facts["days"] = f"{range_days}天"
        self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
        apply_application_system_estimate_to_facts(facts)
        return facts

    def _apply_rule_center_travel_policy_to_application_facts(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
    ) -> None:
        if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
            return

        location = str(facts.get("location") or "").strip()
        grade = str(facts.get("grade") or "").strip()
        if not location or not grade:
            return

        days = self._parse_application_days_count(facts.get("days", "")) or 1
        try:
            result = TravelReimbursementCalculatorService(self.db).calculate(
                TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
                self._build_application_current_user(payload),
            )
        except ValueError:
            return

        hotel_rate = self._format_application_policy_money(result.hotel_rate)
        hotel_amount = self._format_application_policy_money(result.hotel_amount)
        allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
        allowance_amount = self._format_application_policy_money(result.allowance_amount)
        if hotel_rate:
            facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
        if hotel_amount:
            facts["hotel_amount"] = f"{hotel_amount}元"
        if allowance_rate:
            facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
        if allowance_amount:
            facts["allowance_amount"] = f"{allowance_amount}元"
        if str(result.matched_city or "").strip():
            facts["matched_city"] = str(result.matched_city).strip()
        if str(result.rule_name or "").strip():
            facts["rule_name"] = str(result.rule_name).strip()
        if str(result.rule_version or "").strip():
            facts["rule_version"] = str(result.rule_version).strip()

    @staticmethod
    def _format_application_policy_money(value: object) -> str:
        try:
            amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
        except (InvalidOperation, ValueError):
            return ""
        if amount == amount.to_integral():
            return f"{int(amount):,}"
        return f"{amount:,.2f}".rstrip("0").rstrip(".")

    @staticmethod
    def _parse_application_days_count(value: object) -> int:
        match = re.search(r"\d+", str(value or ""))
        if not match:
            return 0
        try:
            return max(0, int(match.group(0)))
        except ValueError:
            return 0

    @staticmethod
    def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
        preview = context_json.get("application_preview")
        if not isinstance(preview, dict):
            return {}
        fields = preview.get("fields")
        if not isinstance(fields, dict):
            return {}

        def pick(*keys: str) -> str:
            for key in keys:
                value = str(fields.get(key) or "").strip()
                if value:
                    return value
            return ""

        reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
        return {
            "application_type": UserAgentApplicationMixin._normalize_application_type_label(
                pick("applicationType", "application_type")
            ),
            "time": pick("time", "timeRange", "time_range"),
            "location": normalize_application_location(pick("location")),
            "reason": reason,
            "days": pick("days"),
            "transport_mode": pick("transportMode", "transport_mode"),
            "amount": pick("amount"),
            "applicant": pick("applicant", "name", "userName", "user_name"),
            "grade": pick("grade"),
            "department": pick("department", "departmentName", "department_name"),
            "position": pick("position", "employeePosition", "employee_position"),
            "manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
            "lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
            "subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
            "transport_policy": pick("transportPolicy", "transport_policy"),
            "policy_estimate": pick("policyEstimate", "policy_estimate"),
            "matched_city": pick("matchedCity", "matched_city"),
            "rule_name": pick("ruleName", "rule_name"),
            "rule_version": pick("ruleVersion", "rule_version"),
            "hotel_amount": pick("hotelAmount", "hotel_amount"),
            "allowance_amount": pick("allowanceAmount", "allowance_amount"),
            "transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
            "transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
            "transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
            "policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
        }

    @staticmethod
    def _is_application_missing_value(value: object) -> bool:
        return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES

    def _resolve_expense_application_step(
        self,
        payload: UserAgentRequest,
        facts: dict[str, str],
    ) -> str:
        if self._resolve_application_validation_issues(facts):
            return "ask_invalid"
        if self._is_application_save_draft_action(payload):
            return "draft"
        if self._resolve_application_missing_base_fields(facts):
            return "ask_missing"
        if self._resolve_application_missing_followup_fields(facts):
            return "ask_missing"
        if self._is_application_submit_confirmation(payload):
            return "submitted"
        return "preview"
