from __future__ import annotations

import re
from calendar import monthrange
from datetime import date, datetime, timedelta
from typing import Any

from app.models.financial_record import ExpenseClaim
from app.services.risk_rule_execution_trace import build_risk_rule_execution_trace
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
from app.services.risk_rule_value_compare import compare_numbers, duplicate_text_values, parse_number_value

CITY_CONSISTENCY_SEMANTIC_TYPES = {
    "travel_city_consistency",
    "travel_route_city_consistency",
}
ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->|－|-|—|~|～|/|、|，|,|;|；)\s*")


class RiskRuleTemplateConditionMixin:
    def _evaluate_required_fields(
        self,
        params: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> dict[str, Any] | None:
        required_fields = self._read_string_list(
            params.get("required_fields") or params.get("field_keys")
        )
        missing = [
            field_key
            for field_key in required_fields
            if not self._resolve_values(field_key, claim=claim, contexts=contexts)
        ]
        if not missing:
            return None
        return {
            "message": self._resolve_message(
                params,
                fallback=f"规则要求的字段未完整提供：{'、'.join(missing[:4])}。",
            ),
            "evidence": {
                "missing_fields": missing,
                "condition_summary": params.get("condition_summary"),
            },
        }

    def _evaluate_compare_conditions(
        self,
        params: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> dict[str, Any] | None:
        conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
        failures: list[dict[str, Any]] = []
        for index, condition in enumerate(conditions, start=1):
            if not isinstance(condition, dict):
                continue
            condition_id = str(condition.get("id") or f"condition_{index}").strip()
            left_key = str(condition.get("left") or "").strip()
            right_key = str(condition.get("right") or "").strip()
            operator = str(condition.get("operator") or "not_overlap").strip()
            left_values = self._resolve_values(left_key, claim=claim, contexts=contexts)
            right_values = self._resolve_values(right_key, claim=claim, contexts=contexts)
            if self._condition_passes(operator, left_values, right_values):
                continue
            failures.append(
                {
                    "left": left_key,
                    "id": condition_id,
                    "operator": operator,
                    "right": right_key,
                    "left_values": left_values[:5],
                    "right_values": right_values[:5],
                }
            )

        if not failures:
            return None
        return {
            "message": self._resolve_message(
                params,
                fallback=(
                    "规则字段对比未通过："
                    f"{params.get('condition_summary') or '字段关系不符合要求'}。"
                ),
            ),
            "evidence": {
                "failed_conditions": failures[:5],
                "condition_summary": params.get("condition_summary"),
            },
        }

    def _evaluate_keyword_match(
        self,
        params: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> dict[str, Any] | None:
        if self._looks_like_city_consistency_rule(params):
            return self._evaluate_city_consistency_rule(
                params,
                claim=claim,
                contexts=contexts,
            )

        keywords = self._read_string_list(params.get("keywords"))
        search_fields = self._read_string_list(
            params.get("search_fields") or params.get("field_keys")
        )
        if not keywords:
            return None

        corpus_parts: list[str] = []
        for field_key in search_fields:
            corpus_parts.extend(self._resolve_values(field_key, claim=claim, contexts=contexts))
        if not corpus_parts:
            corpus_parts.extend(
                [
                    str(claim.reason or ""),
                    str(claim.location or ""),
                    *[str(item.item_reason or "") for item in list(claim.items or [])],
                    *[str(context.get("ocr_text") or "") for context in contexts],
                ]
            )
        corpus = "\n".join(corpus_parts)
        hits = [keyword for keyword in keywords if keyword and keyword in corpus]
        if not hits:
            return None
        return {
            "message": self._resolve_message(
                params,
                fallback=f"识别到风险关键词：{'、'.join(hits[:5])}。",
            ),
            "evidence": {
                "keyword_hits": hits[:8],
                "search_fields": search_fields,
                "condition_summary": params.get("condition_summary"),
            },
        }

    def _evaluate_city_consistency_rule(
        self,
        params: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> dict[str, Any] | None:
        field_keys = self._read_string_list(params.get("search_fields") or params.get("field_keys"))
        reference_keys = self._read_string_list(params.get("reference_city_fields")) or [
            key for key in field_keys if key in {"claim.location", "item.item_location"}
        ] or ["claim.location", "item.item_location"]
        attachment_keys = self._read_string_list(params.get("attachment_city_fields")) or [
            key
            for key in field_keys
            if key in {"attachment.route_cities", "attachment.hotel_city"}
        ] or ["attachment.route_cities", "attachment.hotel_city"]
        reference_values: list[str] = []
        application_reference_values: list[str] = []
        attachment_values: list[str] = []
        route_values: list[str] = []
        for key in reference_keys:
            reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
        application_reference_values.extend(self._iter_application_location_values(claim))
        reference_values.extend(application_reference_values)
        for key in attachment_keys:
            resolved = self._resolve_values(key, claim=claim, contexts=contexts)
            attachment_values.extend(resolved)
            if key == "attachment.route_cities":
                route_values.extend(resolved)
        route_sequence_values = list(route_values)
        reference_values = self._dedupe_values(reference_values)
        application_reference_values = self._dedupe_values(application_reference_values)
        attachment_values = self._dedupe_values(attachment_values)
        route_values = self._dedupe_values(route_values)
        if not reference_values or not attachment_values:
            return None

        explanation_keywords = self._read_string_list(
            params.get("exception_keywords") or params.get("keywords")
        )
        exception_fields = self._read_string_list(params.get("exception_fields")) or [
            "claim.reason",
            "item.item_reason",
        ]
        explanation_corpus = "\n".join(
            value
            for key in exception_fields
            for value in self._resolve_values(key, claim=claim, contexts=contexts)
        )
        keyword_hits = [
            keyword
            for keyword in explanation_keywords
            if keyword and keyword in explanation_corpus
        ]
        unexpected_route_cities = self._resolve_unexpected_route_cities(
            route_sequence_values,
            reference_values=reference_values,
        )
        has_destination_overlap = self._condition_passes(
            "overlap",
            attachment_values,
            reference_values,
        )
        if not unexpected_route_cities and (has_destination_overlap or keyword_hits):
            return None

        reason = (
            "票据路线包含无法由申请单、报销单或附件起终点解释的额外城市。"
            if unexpected_route_cities
            else "票据城市与申报目的地或明细地点不一致，且未说明绕行、跨城或改签原因。"
        )
        return {
            "message": self._resolve_message(
                params,
                fallback=reason,
            ),
            "evidence": {
                "failed_conditions": [
                    {
                        "left": "attachment.city",
                        "operator": "overlap",
                        "right": "claim.location",
                        "left_values": attachment_values[:5],
                        "right_values": reference_values[:5],
                    }
                ],
                "condition_summary": params.get("condition_summary"),
                "formula": params.get("formula"),
                "condition_results": {
                    "city_evidence_present": bool(attachment_values and reference_values),
                    "destination_overlap": has_destination_overlap,
                    "unexpected_route_city": bool(unexpected_route_cities),
                    "reasonable_exception": bool(keyword_hits),
                },
                "city_consistency": {
                    "application_reference_values": application_reference_values[:8],
                    "claim_reference_values": self._dedupe_values(
                        [
                            *self._resolve_values("claim.location", claim=claim, contexts=contexts),
                            *self._resolve_values("item.item_location", claim=claim, contexts=contexts),
                        ]
                    )[:8],
                    "attachment_values": attachment_values[:8],
                    "reference_values": reference_values[:8],
                    "route_values": route_values[:8],
                    "unexpected_route_cities": unexpected_route_cities[:8],
                    "explanation_keywords": explanation_keywords[:8],
                    "explanation_hits": keyword_hits[:8],
                },
            },
        }

    def _evaluate_composite_rule(
        self,
        params: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> dict[str, Any] | None:
        conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
        condition_evidence: list[dict[str, Any]] = []
        condition_results: dict[str, bool] = {}
        for index, condition in enumerate(conditions):
            if not isinstance(condition, dict):
                continue
            condition_id = str(condition.get("id") or f"condition_{index + 1}").strip()
            passed, evidence = self._evaluate_composite_condition(
                condition,
                claim=claim,
                contexts=contexts,
            )
            condition_results[condition_id] = passed
            condition_evidence.append({"id": condition_id, **evidence, "passed": passed})

        hit_logic = params.get("hit_logic")
        hit = (
            self._evaluate_logic_node(hit_logic, condition_results)
            if isinstance(hit_logic, (dict, list, str))
            else bool(condition_results) and all(condition_results.values())
        )
        if not hit:
            return None

        return {
            "message": self._resolve_message(
                params,
                fallback=str(params.get("condition_summary") or "复合规则条件命中，进入人工复核。"),
            ),
            "evidence": {
                "condition_summary": params.get("condition_summary"),
                "formula": params.get("formula"),
                "semantic_type": params.get("semantic_type"),
                "conditions": condition_evidence,
                "condition_results": condition_results,
                "hit_logic": hit_logic,
                "rule_ir": params.get("rule_ir") if isinstance(params.get("rule_ir"), dict) else {},
            },
        }

    def _evaluate_composite_condition(
        self,
        condition: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> tuple[bool, dict[str, Any]]:
        operator = str(condition.get("operator") or "").strip()
        fields = self._read_string_list(condition.get("fields"))
        left_fields = self._read_string_list(condition.get("left_fields"))
        right_fields = self._read_string_list(condition.get("right_fields"))
        if operator == "exists_any":
            values = self._resolve_group_values(fields, claim=claim, contexts=contexts)
            return bool(values), {"operator": operator, "fields": fields, "values": values[:8]}
        if operator in {"exists_all", "all_present"}:
            missing = [
                key for key in fields if not self._resolve_values(key, claim=claim, contexts=contexts)
            ]
            return not missing, {"operator": operator, "fields": fields, "missing_fields": missing}
        if operator in {"not_in_scope", "not_in_set", "not_overlap"}:
            left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts)
            right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts)
            matched = self._values_overlap(left_values, right_values)
            return bool(left_values and right_values and not matched), {
                "operator": operator,
                "left_fields": left_fields,
                "right_fields": right_fields,
                "left_values": left_values[:8],
                "right_values": right_values[:8],
            }
        if operator in {"in_scope", "overlap"}:
            left_values = self._resolve_group_values(left_fields, claim=claim, contexts=contexts)
            right_values = self._resolve_group_values(right_fields, claim=claim, contexts=contexts)
            return self._values_overlap(left_values, right_values), {
                "operator": operator,
                "left_fields": left_fields,
                "right_fields": right_fields,
                "left_values": left_values[:8],
                "right_values": right_values[:8],
            }
        if operator == "date_outside_range":
            return self._evaluate_date_outside_range(condition, claim=claim, contexts=contexts)
        if operator == "numeric_compare":
            return self._evaluate_numeric_compare(condition, claim=claim, contexts=contexts)
        if operator == "duplicate_value":
            values = [
                value
                for key in fields
                for value in self._resolve_values(key, claim=claim, contexts=contexts)
            ]
            duplicates = duplicate_text_values(values)
            evidence = {"operator": operator, "fields": fields, "values": values[:8], "duplicates": duplicates[:8]}
            return bool(duplicates), evidence
        if operator in {"not_contains_any", "contains_any"}:
            keywords = self._read_string_list(condition.get("keywords"))
            values = self._resolve_group_values(fields, claim=claim, contexts=contexts)
            corpus = "\n".join(values)
            hits = [keyword for keyword in keywords if keyword and keyword in corpus]
            passed = not hits if operator == "not_contains_any" else bool(hits)
            return passed, {
                "operator": operator,
                "fields": fields,
                "keyword_hits": hits[:8],
                "values": values[:8],
            }
        left = str(condition.get("left") or "").strip()
        right = str(condition.get("right") or "").strip()
        if left:
            left_values = self._resolve_values(left, claim=claim, contexts=contexts)
            right_values = self._resolve_values(right, claim=claim, contexts=contexts) if right else []
            passed = self._condition_passes(operator or "overlap", left_values, right_values)
            return passed, {
                "operator": operator or "overlap",
                "left": left,
                "right": right,
                "left_values": left_values[:8],
                "right_values": right_values[:8],
            }
        return False, {"operator": operator or "unknown"}

    def _evaluate_date_outside_range(
        self,
        condition: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> tuple[bool, dict[str, Any]]:
        date_fields = self._read_string_list(condition.get("date_fields"))
        start_fields = self._read_string_list(condition.get("range_start_fields"))
        end_fields = self._read_string_list(condition.get("range_end_fields"))
        tolerance_days = int(condition.get("tolerance_days") or 0)
        dates = self._resolve_group_dates(date_fields, claim=claim, contexts=contexts)
        starts = self._resolve_group_dates(start_fields, claim=claim, contexts=contexts)
        ends = self._resolve_group_dates(end_fields, claim=claim, contexts=contexts)
        if not dates or not (starts or ends):
            return False, {
                "operator": "date_outside_range",
                "date_fields": date_fields,
                "range_start_fields": start_fields,
                "range_end_fields": end_fields,
                "dates": [item.isoformat() for item in dates],
                "range_start": None,
                "range_end": None,
            }
        start = min(starts or ends) - timedelta(days=tolerance_days)
        end = max(ends or starts) + timedelta(days=tolerance_days)
        outside = [item for item in dates if item < start or item > end]
        return bool(outside), {
            "operator": "date_outside_range",
            "date_fields": date_fields,
            "range_start_fields": start_fields,
            "range_end_fields": end_fields,
            "dates": [item.isoformat() for item in dates],
            "range_start": start.isoformat(),
            "range_end": end.isoformat(),
            "outside_dates": [item.isoformat() for item in outside],
        }

    def _evaluate_numeric_compare(
        self,
        condition: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> tuple[bool, dict[str, Any]]:
        left_fields = self._read_string_list(condition.get("left_fields") or condition.get("fields"))
        right_fields = self._read_string_list(condition.get("right_fields"))
        left_numbers = self._resolve_group_numbers(left_fields, claim=claim, contexts=contexts)
        right_numbers = self._resolve_group_numbers(right_fields, claim=claim, contexts=contexts)
        threshold = parse_number_value(condition.get("threshold") or condition.get("value"))
        if threshold is not None:
            right_numbers.append(threshold)
        compare = str(condition.get("compare") or condition.get("comparator") or "gt").strip().lower()
        passed = any(
            compare_numbers(left, right, compare)
            for left in left_numbers
            for right in right_numbers
        )
        return passed, {
            "operator": "numeric_compare",
            "compare": compare,
            "left_fields": left_fields,
            "right_fields": right_fields,
            "left_values": left_numbers[:8],
            "right_values": right_numbers[:8],
        }


class RiskRuleTemplateValueResolverMixin:
    def _resolve_group_values(
        self,
        field_keys: list[str],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> list[str]:
        values: list[str] = []
        for key in field_keys:
            values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
        return self._dedupe_values(values)

    def _resolve_group_dates(
        self,
        field_keys: list[str],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> list[date]:
        values: list[date] = []
        for key in field_keys:
            for value in self._resolve_values(key, claim=claim, contexts=contexts):
                parsed = self._parse_date_value(value)
                if parsed and parsed not in values:
                    values.append(parsed)
        return values

    def _resolve_group_numbers(
        self,
        field_keys: list[str],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> list[float]:
        values: list[float] = []
        for key in field_keys:
            for value in self._resolve_values(key, claim=claim, contexts=contexts):
                parsed = parse_number_value(value)
                if parsed is not None and parsed not in values:
                    values.append(parsed)
        return values

    @staticmethod
    def _evaluate_logic_node(node: Any, condition_results: dict[str, bool]) -> bool:
        if isinstance(node, str):
            return bool(condition_results.get(node))
        if isinstance(node, list):
            return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in node)
        if not isinstance(node, dict):
            return bool(node)
        if "all" in node:
            values = node.get("all") if isinstance(node.get("all"), list) else []
            return all(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values)
        if "any" in node:
            values = node.get("any") if isinstance(node.get("any"), list) else []
            return any(RiskRuleTemplateExecutor._evaluate_logic_node(item, condition_results) for item in values)
        if "not" in node:
            return not RiskRuleTemplateExecutor._evaluate_logic_node(node.get("not"), condition_results)
        return False

    @staticmethod
    def _looks_like_city_consistency_rule(params: dict[str, Any]) -> bool:
        field_keys = RiskRuleTemplateExecutor._read_string_list(
            params.get("search_fields") or params.get("field_keys")
        )
        if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
            return True
        has_reference = any(key in {"claim.location", "item.item_location"} for key in field_keys)
        has_attachment_city = any(
            key in {"attachment.route_cities", "attachment.hotel_city"} for key in field_keys
        )
        if not (has_reference and has_attachment_city):
            return False
        text = "\n".join(
            str(params.get(key) or "")
            for key in ("natural_language", "condition_summary", "message_template")
        )
        consistency_terms = ("一致", "不一致", "匹配", "不符", "对应", "出现在")
        city_terms = ("城市", "地点", "目的地", "行程", "票据", "发票")
        return any(term in text for term in consistency_terms) and any(
            term in text for term in city_terms
        )

    def _resolve_values(
        self,
        field_key: str,
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> list[str]:
        normalized = str(field_key or "").strip()
        if not normalized:
            return []
        if normalized == "claim.trip_start_date":
            explicit = getattr(claim, "trip_start_date", None)
            return self._normalize_values([explicit or self._claim_trip_date(claim, start=True)])
        if normalized == "claim.trip_end_date":
            explicit = getattr(claim, "trip_end_date", None)
            return self._normalize_values([explicit or self._claim_trip_date(claim, start=False)])
        if normalized.startswith("claim."):
            return self._normalize_values([getattr(claim, normalized.removeprefix("claim."), "")])
        if normalized.startswith("item."):
            attr = normalized.removeprefix("item.")
            return self._normalize_values(
                [getattr(item, attr, "") for item in list(claim.items or [])]
            )
        if normalized.startswith("employee."):
            employee = getattr(claim, "employee", None)
            if employee is None:
                return []
            return self._normalize_values(
                [getattr(employee, normalized.removeprefix("employee."), "")]
            )
        if normalized.startswith("attachment."):
            return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
        if normalized.startswith("application."):
            return self._resolve_application_values(normalized.removeprefix("application."), claim)
        if normalized.startswith("budget."):
            return self._resolve_budget_values(normalized.removeprefix("budget."), contexts)
        return []

    @staticmethod
    def _resolve_unexpected_route_cities(
        route_values: list[str],
        *,
        reference_values: list[str],
    ) -> list[str]:
        if len(route_values) < 2:
            return []
        allowed_values = [value for value in reference_values if value]
        if not allowed_values:
            return []
        allowed_values.extend(
            RiskRuleTemplateExecutor._resolve_inferred_route_endpoint_values(
                route_values,
                reference_values=reference_values,
            )
        )
        candidates = route_values
        unexpected: list[str] = []
        for city in candidates:
            if RiskRuleTemplateExecutor._values_overlap([city], allowed_values):
                continue
            if city not in unexpected:
                unexpected.append(city)
        return unexpected

    @staticmethod
    def _resolve_inferred_route_endpoint_values(
        route_values: list[str],
        *,
        reference_values: list[str],
    ) -> list[str]:
        if len(route_values) < 2 or not reference_values:
            return []
        has_declared_destination = any(
            RiskRuleTemplateExecutor._values_overlap([city], reference_values)
            for city in route_values
        )
        if not has_declared_destination:
            return []

        inferred: list[str] = []
        first_city = str(route_values[0] or "").strip()
        last_city = str(route_values[-1] or "").strip()
        if first_city:
            inferred.append(first_city)
        if (
            last_city
            and (
                len(route_values) == 2
                or RiskRuleTemplateExecutor._values_overlap([last_city], [first_city])
            )
            and last_city not in inferred
        ):
            inferred.append(last_city)
        return inferred

    @staticmethod
    def _expand_route_city_values(values: list[Any]) -> list[Any]:
        expanded: list[Any] = []
        for value in values:
            if isinstance(value, (list, tuple, set)):
                expanded.extend(RiskRuleTemplateExecutor._expand_route_city_values(list(value)))
                continue
            text = str(value or "").strip()
            if not text:
                continue
            parts = [part.strip() for part in ROUTE_CITY_SPLIT_PATTERN.split(text) if part.strip()]
            expanded.extend(parts if len(parts) >= 2 else [text])
        return expanded

    def _resolve_attachment_values(
        self, field_key: str, contexts: list[dict[str, Any]]
    ) -> list[str]:
        values: list[Any] = []
        for context in contexts:
            document_info = context.get("document_info") if isinstance(context, dict) else {}
            if not isinstance(document_info, dict):
                document_info = {}
            if field_key == "ocr_text":
                values.extend([context.get("ocr_text"), context.get("ocr_summary")])
            if field_key == "hotel_city":
                specific_values = self._scan_document_values(document_info, field_key)
                values.extend(
                    specific_values
                    if specific_values
                    else self._scan_document_values(document_info, "city")
                )
            elif field_key == "route_cities":
                values.extend(self._expand_route_city_values(self._scan_document_values(document_info, field_key)))
            else:
                values.extend(self._scan_document_values(document_info, field_key))
        return self._normalize_values(values)

    def _resolve_budget_values(self, field_key: str, contexts: list[dict[str, Any]]) -> list[str]:
        values: list[Any] = []
        for context in contexts:
            if not isinstance(context, dict):
                continue
            budget_context = context.get("budget_context")
            if not isinstance(budget_context, dict):
                continue
            for key in {field_key, field_key.replace("_", ""), field_key.replace("-", "_")}:
                if key in budget_context:
                    values.append(budget_context.get(key))
        return self._normalize_values(values)

    def _resolve_application_values(self, field_key: str, claim: ExpenseClaim) -> list[str]:
        values: list[Any] = []
        normalized_key = str(field_key or "").strip()
        alias_map = {
            "id": (
                "application_claim_id",
                "applicationClaimId",
                "application_id",
                "applicationId",
                "claim_id",
                "claimId",
                "id",
            ),
            "claim_no": (
                "application_claim_no",
                "applicationClaimNo",
                "application_no",
                "applicationNo",
                "claim_no",
                "claimNo",
                "no",
            ),
            "status": ("application_status", "applicationStatus", "status"),
            "approved_amount": (
                "application_approved_amount",
                "applicationApprovedAmount",
                "approved_amount",
                "approvedAmount",
                "application_amount",
                "applicationAmount",
                "amount",
            ),
            "amount": (
                "application_amount",
                "applicationAmount",
                "approved_amount",
                "approvedAmount",
                "amount",
            ),
            "expense_type": (
                "application_expense_type",
                "applicationExpenseType",
                "expense_type",
                "expenseType",
            ),
            "department_name": (
                "application_department_name",
                "applicationDepartmentName",
                "department_name",
                "departmentName",
            ),
            "reason": ("application_reason", "applicationReason", "reason"),
        }
        lookup_keys = alias_map.get(
            normalized_key,
            (normalized_key, normalized_key.replace("_", ""), normalized_key.replace("_", "-")),
        )
        for source in self._iter_application_contexts(claim):
            for key in lookup_keys:
                if key in source and source.get(key) not in (None, ""):
                    values.append(source.get(key))
        return self._normalize_values(values)

    @staticmethod
    def _iter_application_contexts(claim: ExpenseClaim) -> list[dict[str, Any]]:
        contexts: list[dict[str, Any]] = []
        application_sources = {"application_detail", "application_handoff", "application_link"}
        nested_keys = (
            "application_detail",
            "applicationDetail",
            "review_form_values",
            "reviewFormValues",
            "expense_scene_selection",
            "expenseSceneSelection",
        )
        for flag in list(getattr(claim, "risk_flags_json", None) or []):
            if not isinstance(flag, dict):
                continue
            source = str(flag.get("source") or "").strip()
            has_application_anchor = (
                source in application_sources
                or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
                or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
            )
            if not has_application_anchor:
                continue
            contexts.append(flag)
            for key in nested_keys:
                nested = flag.get(key)
                if isinstance(nested, dict):
                    contexts.append(nested)
        return contexts

    def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]:
        values: list[Any] = []
        for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}:
            if key in document_info:
                values.append(document_info.get(key))
        for field in list(document_info.get("fields") or []):
            if not isinstance(field, dict):
                continue
            key = str(field.get("key") or "").strip().lower()
            label = str(field.get("label") or "").strip()
            if self._field_matches(key, label, field_key):
                values.append(field.get("value"))
        return values

    @staticmethod
    def _field_matches(key: str, label: str, field_key: str) -> bool:
        compact_key = key.replace("_", "")
        compact_target = field_key.replace("_", "")
        if compact_target in compact_key:
            return True
        label_map = {
            "invoice_no": ("发票号", "发票号码", "票号"),
            "buyer_name": ("购买方", "抬头", "买方"),
            "goods_name": ("品名", "商品", "服务名称"),
            "issue_date": ("日期", "开票日期", "发票日期"),
            "stay_start_date": ("入住日期", "住宿开始", "入住时间", "开始日期"),
            "stay_end_date": ("离店日期", "退房日期", "住宿结束", "结束日期"),
            "hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
            "route_cities": ("行程", "路线", "目的地", "出差城市"),
            "city": ("城市", "地点"),
        }
        return any(item in label for item in label_map.get(field_key, ()))

    @staticmethod
    def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
        application_date = RiskRuleTemplateExecutor._claim_application_trip_date(claim, start=start)
        if application_date is not None:
            return application_date
        item_dates = [
            item.item_date
            for item in list(claim.items or [])
            if getattr(item, "item_date", None) is not None
        ]
        if item_dates:
            return min(item_dates) if start else max(item_dates)
        return getattr(claim, "occurred_at", None)

    @staticmethod
    def _claim_application_trip_date(claim: ExpenseClaim, *, start: bool) -> date | None:
        windows: list[tuple[date, date]] = []
        reference_year = RiskRuleTemplateExecutor._claim_reference_year(claim)
        for raw_value in RiskRuleTemplateExecutor._iter_application_time_values(claim):
            windows.extend(
                RiskRuleTemplateExecutor._parse_date_windows(
                    raw_value,
                    reference_year=reference_year,
                )
            )
        if not windows:
            return None
        values = [window[0] if start else window[1] for window in windows]
        return min(values) if start else max(values)

    @staticmethod
    def _claim_reference_year(claim: ExpenseClaim) -> int | None:
        for value in [getattr(claim, "occurred_at", None)]:
            parsed = RiskRuleTemplateExecutor._parse_date_value(value)
            if parsed is not None:
                return parsed.year
        for item in list(claim.items or []):
            parsed = RiskRuleTemplateExecutor._parse_date_value(getattr(item, "item_date", None))
            if parsed is not None:
                return parsed.year
        return None

    @staticmethod
    def _iter_application_location_values(claim: ExpenseClaim) -> list[Any]:
        values: list[Any] = []
        application_sources = {"application_detail", "application_handoff", "application_link"}
        location_keys = (
            "application_location",
            "applicationLocation",
            "business_location",
            "businessLocation",
            "location",
            "destination",
            "destination_city",
            "destinationCity",
            "matched_city",
            "matchedCity",
        )
        nested_keys = (
            "application_detail",
            "applicationDetail",
            "review_form_values",
            "reviewFormValues",
            "expense_scene_selection",
            "expenseSceneSelection",
        )
        for flag in list(getattr(claim, "risk_flags_json", None) or []):
            if not isinstance(flag, dict):
                continue
            source = str(flag.get("source") or "").strip()
            has_application_anchor = (
                source in application_sources
                or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
                or any(
                    isinstance(flag.get(key), dict)
                    for key in ("application_detail", "applicationDetail")
                )
            )
            if not has_application_anchor:
                continue
            sources: list[dict[str, Any]] = [flag]
            for key in nested_keys:
                nested = flag.get(key)
                if isinstance(nested, dict):
                    sources.append(nested)
            for source_dict in sources:
                for key in location_keys:
                    value = source_dict.get(key)
                    if value not in (None, ""):
                        values.append(value)
        return RiskRuleTemplateExecutor._normalize_values(values)

    @staticmethod
    def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]:
        values: list[Any] = []
        application_sources = {"application_detail", "application_handoff", "application_link"}
        time_keys = (
            "application_time",
            "applicationTime",
            "application_business_time",
            "applicationBusinessTime",
            "application_date",
            "applicationDate",
            "business_time",
            "businessTime",
            "time_range",
            "timeRange",
            "time",
            "date",
        )
        nested_keys = (
            "application_detail",
            "applicationDetail",
            "review_form_values",
            "reviewFormValues",
            "expense_scene_selection",
            "expenseSceneSelection",
        )
        for flag in list(getattr(claim, "risk_flags_json", None) or []):
            if not isinstance(flag, dict):
                continue
            source = str(flag.get("source") or "").strip()
            has_application_anchor = (
                source in application_sources
                or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
                or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
            )
            if not has_application_anchor:
                continue
            sources: list[dict[str, Any]] = [flag]
            for key in nested_keys:
                nested = flag.get(key)
                if isinstance(nested, dict):
                    sources.append(nested)
            for source_dict in sources:
                business_time_context = source_dict.get("business_time_context")
                if isinstance(business_time_context, dict):
                    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 display_value:
                        values.append(display_value)
                    elif start_date:
                        values.append(f"{start_date} 至 {end_date or start_date}")
                for key in time_keys:
                    value = source_dict.get(key)
                    if value not in (None, ""):
                        values.append(value)
        return values

    @staticmethod
    def _parse_date_windows(
        value: Any,
        *,
        reference_year: int | None = None,
    ) -> list[tuple[date, date]]:
        if isinstance(value, datetime):
            item = value.date()
            return [(item, item)]
        if isinstance(value, date):
            return [(value, value)]

        text = str(value or "").strip()
        if not text:
            return []

        exact_dates = RiskRuleTemplateExecutor._parse_exact_dates(
            text,
            reference_year=reference_year,
        )
        if exact_dates:
            return [(min(exact_dates), max(exact_dates))]

        month_windows = RiskRuleTemplateExecutor._parse_month_windows(
            text,
            reference_year=reference_year,
        )
        if month_windows:
            return month_windows
        return []

    @staticmethod
    def _parse_exact_dates(text: str, *, reference_year: int | None = None) -> list[date]:
        values: list[date] = []

        def append_date(year: int, month: int, day: int) -> None:
            try:
                parsed = date(year, month, day)
            except ValueError:
                return
            if parsed not in values:
                values.append(parsed)

        for pattern in (
            r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})",
            r"(\d{4})年(\d{1,2})月(\d{1,2})日?",
        ):
            for match in re.finditer(pattern, text):
                year, month, day = (int(part) for part in match.groups())
                append_date(year, month, day)

        if reference_year is not None:
            for match in re.finditer(r"(?<!\d)(\d{1,2})月(\d{1,2})日?", text):
                month, day = (int(part) for part in match.groups())
                append_date(reference_year, month, day)

        return values

    @staticmethod
    def _parse_month_windows(
        text: str,
        *,
        reference_year: int | None = None,
    ) -> list[tuple[date, date]]:
        windows: list[tuple[date, date]] = []

        def append_month(year: int, month: int) -> None:
            if month < 1 or month > 12:
                return
            last_day = monthrange(year, month)[1]
            window = (date(year, month, 1), date(year, month, last_day))
            if window not in windows:
                windows.append(window)

        for match in re.finditer(r"(\d{4})[-/.](\d{1,2})(?![-/.]\d)", text):
            year, month = (int(part) for part in match.groups())
            append_month(year, month)
        for match in re.finditer(r"(\d{4})年(\d{1,2})月(?!\d)", text):
            year, month = (int(part) for part in match.groups())
            append_month(year, month)
        if reference_year is not None:
            for match in re.finditer(r"(?<!\d)(\d{1,2})月(?!\d|日)", text):
                append_month(reference_year, int(match.group(1)))
        return windows

    @staticmethod
    def _condition_passes(operator: str, left_values: list[str], right_values: list[str]) -> bool:
        if operator == "is_empty":
            return not left_values
        if not left_values or not right_values:
            return False

        left_set = {value.lower() for value in left_values}
        right_set = {value.lower() for value in right_values}
        if operator in {"equals", "in", "overlap"}:
            return RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
        if operator in {"not_equals", "not_in", "not_overlap"}:
            return not RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
        if operator == "contains_any":
            return any(any(right in left for right in right_set) for left in left_set)
        return bool(left_set & right_set)

    @staticmethod
    def _values_overlap(left_values: list[str], right_values: list[str]) -> bool:
        left_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in left_values]
        right_set = [RiskRuleTemplateExecutor._normalize_match_value(value) for value in right_values]
        for left in left_set:
            for right in right_set:
                if left and right and (left == right or left in right or right in left):
                    return True
        return False

    @staticmethod
    def _normalize_match_value(value: str) -> str:
        return re.sub(r"[省市区县\s]+$", "", str(value or "").strip().lower())

    @staticmethod
    def _parse_date_value(value: Any) -> date | None:
        if isinstance(value, datetime):
            return value.date()
        if isinstance(value, date):
            return value
        text = str(value or "").strip()
        if not text:
            return None
        iso_match = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", text)
        cn_match = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日", text)
        match = iso_match or cn_match
        if match:
            year, month, day = (int(part) for part in match.groups())
            try:
                return date(year, month, day)
            except ValueError:
                return None
        try:
            return date.fromisoformat(text[:10])
        except ValueError:
            return None

    @staticmethod
    def _normalize_values(values: list[Any]) -> list[str]:
        normalized: list[str] = []
        for value in values:
            if isinstance(value, (list, tuple, set)):
                normalized.extend(RiskRuleTemplateExecutor._normalize_values(list(value)))
                continue
            text = re.sub(r"\s+", " ", str(value or "")).strip()
            if text:
                normalized.append(text)
        return normalized

    @staticmethod
    def _dedupe_values(values: list[str]) -> list[str]:
        deduped: list[str] = []
        for value in values:
            text = str(value or "").strip()
            if text and text not in deduped:
                deduped.append(text)
        return deduped

    @staticmethod
    def _read_string_list(value: Any) -> list[str]:
        if not isinstance(value, list):
            return []
        return [str(item or "").strip() for item in value if str(item or "").strip()]

    @staticmethod
    def _resolve_message(params: dict[str, Any], *, fallback: str) -> str:
        template = str(params.get("message_template") or "").strip()
        return template or fallback


class RiskRuleTemplateExecutor(RiskRuleTemplateConditionMixin, RiskRuleTemplateValueResolverMixin):
    def evaluate_with_trace(
        self,
        manifest: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> dict[str, Any]:
        result = self.evaluate(manifest, claim=claim, contexts=contexts)
        return {
            "hit": result is not None,
            "result": result,
            "trace": build_risk_rule_execution_trace(manifest, result=result),
        }

    def evaluate(
        self,
        manifest: dict[str, Any],
        *,
        claim: ExpenseClaim,
        contexts: list[dict[str, Any]],
    ) -> dict[str, Any] | None:
        params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
        template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()

        if template_key == "field_required_v1":
            return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
        if template_key == "field_compare_v1":
            if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
                return self._evaluate_city_consistency_rule(
                    params,
                    claim=claim,
                    contexts=contexts,
                )
            return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
        if template_key == "keyword_match_v1":
            return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
        if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
            return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
        return None

