from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any

from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload

from app.api.deps import CurrentUserContext
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.schemas.receipt_folder import ReceiptFolderDetailRead
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_constants import EDITABLE_CLAIM_STATUSES
from app.services.expense_claims import ExpenseClaimService

CITY_NAMES = (
    "北京",
    "上海",
    "广州",
    "深圳",
    "武汉",
    "南京",
    "杭州",
    "成都",
    "重庆",
    "西安",
    "天津",
    "苏州",
    "长沙",
    "郑州",
    "青岛",
    "厦门",
    "宁波",
    "无锡",
    "合肥",
    "福州",
    "昆明",
    "大连",
    "沈阳",
    "济南",
    "哈尔滨",
    "长春",
    "南昌",
    "太原",
    "贵阳",
    "南宁",
    "石家庄",
    "兰州",
    "银川",
    "西宁",
    "海口",
    "拉萨",
)

HIGH_CONFIDENCE_SCORE = 7
MINIMUM_SCORE_LEAD = 2
MINIMUM_PER_RECEIPT_SIGNAL_SCORE = 4
APPROVED_APPLICATION_STATUSES = {"approved", "completed"}


@dataclass(slots=True)
class ExpenseReceiptMatchCandidate:
    target_type: str
    claim: ExpenseClaim | None
    application_claim: ExpenseClaim | None
    expense_case: ExpenseCase | None
    score: int
    reasons: list[str] = field(default_factory=list)

    @property
    def confidence(self) -> str:
        if self.score >= HIGH_CONFIDENCE_SCORE:
            return "high"
        if self.score >= 4:
            return "medium"
        return "low"

    @property
    def normalized_score(self) -> float:
        return round(min(1.0, max(0.0, self.score / 15)), 4)

    def to_payload(self) -> dict[str, Any]:
        return {
            "target_type": self.target_type,
            "expense_case_id": str(getattr(self.expense_case, "id", "") or ""),
            "application_claim_id": str(getattr(self.application_claim, "id", "") or ""),
            "application_claim_no": str(getattr(self.application_claim, "claim_no", "") or ""),
            "claim_id": str(getattr(self.claim, "id", "") or ""),
            "claim_no": str(getattr(self.claim, "claim_no", "") or ""),
            "confidence": self.confidence,
            "score": self.normalized_score,
            "match_reasons": list(self.reasons),
        }


@dataclass(slots=True)
class ExpenseReceiptMatchResult:
    resolution: str
    requires_confirmation: bool
    recommended: ExpenseReceiptMatchCandidate | None = None
    candidates: list[ExpenseReceiptMatchCandidate] = field(default_factory=list)
    exceptions: list[str] = field(default_factory=list)


class ExpenseReceiptMatcher:
    """只读票据匹配器；评分阶段绝不修改 Claim、Case 或票据。"""

    def __init__(self, db: Session) -> None:
        self.db = db
        self.claim_service = ExpenseClaimService(db)
        self.case_service = ExpenseCaseService(db)

    def match(
        self,
        *,
        receipts: list[ReceiptFolderDetailRead],
        current_user: CurrentUserContext,
    ) -> ExpenseReceiptMatchResult:
        signals = collect_receipt_signals(receipts)
        accessible_claims = self._filter_claims_for_tenant(
            self._list_accessible_claims(current_user),
            tenant_id=current_user.tenant_id,
        )
        accessible_by_id = {str(claim.id): claim for claim in accessible_claims if claim.id}
        linked_claim_ids = {
            str(receipt.linked_claim_id or "").strip()
            for receipt in receipts
            if str(receipt.status or "").strip().lower() == "linked"
            and str(receipt.linked_claim_id or "").strip()
        }
        if len(linked_claim_ids) > 1:
            return ExpenseReceiptMatchResult(
                resolution="requires_confirmation",
                requires_confirmation=True,
                exceptions=["所选票据已经分属多个报销单，请先核对票据归属。"],
            )
        if linked_claim_ids:
            linked_claim = accessible_by_id.get(next(iter(linked_claim_ids)))
            if linked_claim is None or not self._is_editable_reimbursement(linked_claim):
                return ExpenseReceiptMatchResult(
                    resolution="requires_confirmation",
                    requires_confirmation=True,
                    exceptions=["票据已经关联到不可编辑或无权访问的报销单。"],
                )
            replay_candidate = self._score_claim(
                linked_claim,
                signals=signals,
                tenant_id=current_user.tenant_id,
                accessible_by_id=accessible_by_id,
            )
            replay_candidate.score = max(replay_candidate.score, 15)
            replay_candidate.reasons.append("票据已经归集到该草稿，本次按幂等重放处理")
            if not self._all_receipts_support_claim(receipts, linked_claim):
                return ExpenseReceiptMatchResult(
                    resolution="requires_confirmation",
                    requires_confirmation=True,
                    recommended=replay_candidate,
                    candidates=[replay_candidate],
                    exceptions=["所选票据并非都与已关联草稿具备独立匹配证据，请逐份确认。"],
                )
            return ExpenseReceiptMatchResult(
                resolution="auto_associated",
                requires_confirmation=False,
                recommended=replay_candidate,
                candidates=[replay_candidate],
            )
        draft_candidates = [
            self._score_claim(
                claim,
                signals=signals,
                tenant_id=current_user.tenant_id,
                accessible_by_id=accessible_by_id,
            )
            for claim in accessible_claims
            if self._is_editable_reimbursement(claim)
        ]
        ranked_drafts = self._rank(
            candidate for candidate in draft_candidates if candidate.score > 0
        )
        if ranked_drafts:
            recommended = ranked_drafts[0]
            runner_up = ranked_drafts[1] if len(ranked_drafts) > 1 else None
            has_required_score = recommended.score >= HIGH_CONFIDENCE_SCORE
            has_clear_lead = (
                runner_up is None or recommended.score - runner_up.score >= MINIMUM_SCORE_LEAD
            )
            has_consistent_receipts = self._all_receipts_support_claim(
                receipts,
                recommended.claim,
            )
            if has_required_score and has_clear_lead and has_consistent_receipts:
                return ExpenseReceiptMatchResult(
                    resolution="auto_associated",
                    requires_confirmation=False,
                    recommended=recommended,
                    candidates=ranked_drafts[:5],
                )
            exception = (
                "同批票据并非每一份都与候选草稿具备独立匹配证据，请逐份确认。"
                if not has_consistent_receipts
                else "票据与多个报销草稿的匹配证据不足，请确认目标单据。"
            )
            return ExpenseReceiptMatchResult(
                resolution="requires_confirmation",
                requires_confirmation=True,
                recommended=recommended,
                candidates=ranked_drafts[:5],
                exceptions=[exception],
            )

        application_candidates = [
            self._score_application(
                claim,
                signals=signals,
                tenant_id=current_user.tenant_id,
            )
            for claim in accessible_claims
            if self._is_approved_application(claim)
        ]
        ranked_applications = self._rank(
            candidate for candidate in application_candidates if candidate.score > 0
        )
        if ranked_applications:
            return ExpenseReceiptMatchResult(
                resolution="requires_confirmation",
                requires_confirmation=True,
                recommended=ranked_applications[0],
                candidates=ranked_applications[:5],
                exceptions=["找到可能的已审批申请，但没有可安全归集的系统报销草稿。"],
            )
        return ExpenseReceiptMatchResult(
            resolution="requires_confirmation",
            requires_confirmation=True,
            exceptions=["没有找到与当前票据匹配的报销草稿，请确认申请或补充行程信息。"],
        )

    def _list_accessible_claims(
        self,
        current_user: CurrentUserContext,
    ) -> list[ExpenseClaim]:
        """复用访问范围条件做纯查询，不触发旧工作流修复或提交。"""
        stmt = (
            select(ExpenseClaim)
            .options(selectinload(ExpenseClaim.items))
            .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
        )
        stmt = self.claim_service._access_policy.apply_claim_scope(stmt, current_user)
        return [
            claim
            for claim in list(self.db.scalars(stmt).all())
            if self.claim_service._access_policy.is_claim_owned_by_current_user(
                claim,
                current_user,
            )
        ]

    @staticmethod
    def _all_receipts_support_claim(
        receipts: list[ReceiptFolderDetailRead],
        claim: ExpenseClaim | None,
    ) -> bool:
        if claim is None:
            return False
        for receipt in receipts:
            if (
                str(receipt.status or "").strip().lower() == "linked"
                and str(receipt.linked_claim_id or "").strip() == str(claim.id or "").strip()
            ):
                continue
            signal_score, _reasons = score_claim_signals(
                claim,
                collect_receipt_signals([receipt]),
            )
            if signal_score < MINIMUM_PER_RECEIPT_SIGNAL_SCORE:
                return False
        return True

    def _filter_claims_for_tenant(
        self,
        claims: list[ExpenseClaim],
        *,
        tenant_id: str,
    ) -> list[ExpenseClaim]:
        """ExpenseClaim 尚无 tenant_id；非默认租户必须由同租户 Case Link 证明归属。"""
        normalized_tenant = self.case_service.normalize_tenant_id(tenant_id)
        claim_ids = [str(claim.id) for claim in claims if str(claim.id or "").strip()]
        if not claim_ids:
            return []
        links = list(
            self.db.scalars(
                select(ExpenseCaseLink).where(
                    ExpenseCaseLink.resource_type == "expense_claim",
                    ExpenseCaseLink.resource_id.in_(claim_ids),
                )
            ).all()
        )
        links_by_claim_id = {str(link.resource_id): link for link in links}
        case_ids = list({str(link.expense_case_id) for link in links})
        cases_by_id = {
            str(expense_case.id): expense_case
            for expense_case in list(
                self.db.scalars(select(ExpenseCase).where(ExpenseCase.id.in_(case_ids))).all()
            )
        }
        filtered: list[ExpenseClaim] = []
        for claim in claims:
            link = links_by_claim_id.get(str(claim.id))
            if link is None:
                if normalized_tenant == "default":
                    filtered.append(claim)
                continue
            expense_case = cases_by_id.get(str(link.expense_case_id))
            if (
                link.tenant_id == normalized_tenant
                and expense_case is not None
                and expense_case.tenant_id == normalized_tenant
            ):
                filtered.append(claim)
        return filtered

    @staticmethod
    def _rank(candidates: Any) -> list[ExpenseReceiptMatchCandidate]:
        return sorted(
            list(candidates),
            key=lambda item: (-item.score, str(getattr(item.claim, "claim_no", ""))),
        )

    def _score_claim(
        self,
        claim: ExpenseClaim,
        *,
        signals: dict[str, Any],
        tenant_id: str,
        accessible_by_id: dict[str, ExpenseClaim],
    ) -> ExpenseReceiptMatchCandidate:
        expense_case, application_claim = self._resolve_case_and_application(
            claim,
            tenant_id=tenant_id,
            accessible_by_id=accessible_by_id,
        )
        score, reasons = score_claim_signals(claim, signals)
        if str(claim.status or "").strip().lower() == "draft":
            score += 1
            reasons.append("当前单据仍是可归集草稿")
        if expense_case is not None:
            score += 1
            reasons.append("报销草稿已纳入同一费用事件")
        if application_claim is not None and self._is_approved_application(application_claim):
            score += 4
            reasons.append(f"关联申请 {application_claim.claim_no} 已审批通过")
        return ExpenseReceiptMatchCandidate(
            target_type="reimbursement_draft",
            claim=claim,
            application_claim=application_claim,
            expense_case=expense_case,
            score=score,
            reasons=reasons,
        )

    def _score_application(
        self,
        claim: ExpenseClaim,
        *,
        signals: dict[str, Any],
        tenant_id: str,
    ) -> ExpenseReceiptMatchCandidate:
        score, reasons = score_claim_signals(claim, signals)
        expense_case = self._resolve_case(claim, tenant_id=tenant_id)
        if expense_case is not None:
            score += 1
            reasons.append("申请已纳入费用事件")
        score += 2
        reasons.append("申请已审批通过")
        return ExpenseReceiptMatchCandidate(
            target_type="approved_application",
            claim=None,
            application_claim=claim,
            expense_case=expense_case,
            score=score,
            reasons=reasons,
        )

    def _resolve_case_and_application(
        self,
        claim: ExpenseClaim,
        *,
        tenant_id: str,
        accessible_by_id: dict[str, ExpenseClaim],
    ) -> tuple[ExpenseCase | None, ExpenseClaim | None]:
        expense_case = self._resolve_case(claim, tenant_id=tenant_id)
        if expense_case is None:
            return None, self._resolve_flag_application(claim, accessible_by_id)
        links = list(
            self.db.scalars(
                select(ExpenseCaseLink).where(
                    ExpenseCaseLink.tenant_id == self.case_service.normalize_tenant_id(tenant_id),
                    ExpenseCaseLink.expense_case_id == expense_case.id,
                    ExpenseCaseLink.resource_type == "expense_claim",
                )
            ).all()
        )
        for link in links:
            linked_claim = accessible_by_id.get(str(link.resource_id))
            if linked_claim is not None and self.claim_service._is_expense_application_claim(
                linked_claim
            ):
                return expense_case, linked_claim
        return expense_case, self._resolve_flag_application(claim, accessible_by_id)

    def _resolve_case(self, claim: ExpenseClaim, *, tenant_id: str) -> ExpenseCase | None:
        normalized_tenant = self.case_service.normalize_tenant_id(tenant_id)
        link = self.db.scalar(
            select(ExpenseCaseLink).where(
                ExpenseCaseLink.resource_type == "expense_claim",
                ExpenseCaseLink.resource_id == claim.id,
            )
        )
        if link is None or link.tenant_id != normalized_tenant:
            return None
        return self.db.scalar(
            select(ExpenseCase).where(
                ExpenseCase.id == link.expense_case_id,
                ExpenseCase.tenant_id == normalized_tenant,
            )
        )

    @staticmethod
    def _resolve_flag_application(
        claim: ExpenseClaim,
        accessible_by_id: dict[str, ExpenseClaim],
    ) -> ExpenseClaim | None:
        for flag in list(claim.risk_flags_json or []):
            if not isinstance(flag, dict):
                continue
            application_id = str(
                flag.get("application_claim_id") or flag.get("applicationClaimId") or ""
            ).strip()
            application = accessible_by_id.get(application_id)
            if application is not None:
                return application
        return None

    def _is_editable_reimbursement(self, claim: ExpenseClaim) -> bool:
        status = str(claim.status or "").strip().lower()
        return (
            status in EDITABLE_CLAIM_STATUSES
            and not self.claim_service._is_expense_application_claim(claim)
        )

    def _is_approved_application(self, claim: ExpenseClaim) -> bool:
        status = str(claim.status or "").strip().lower()
        return (
            status in APPROVED_APPLICATION_STATUSES
            and self.claim_service._is_expense_application_claim(claim)
        )


def normalize_text(value: Any) -> str:
    return re.sub(r"\s+", "", str(value or "").strip())


def unique(values: list[str] | tuple[str, ...]) -> list[str]:
    return list(
        dict.fromkeys(str(item or "").strip() for item in values if str(item or "").strip())
    )


def extract_date_tokens(text: Any) -> list[str]:
    source = str(text or "")
    matches = [
        *re.finditer(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", source),
        *re.finditer(r"\d{1,2}月\d{1,2}", source),
    ]
    return unique([normalize_date_token(match.group(0)) for match in matches])


def normalize_date_token(value: Any) -> str:
    if isinstance(value, (date, datetime)):
        return value.isoformat()[:10]
    text = str(value or "").strip()
    full_match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
    if full_match:
        year, month, day = full_match.groups()
        return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
    short_match = re.search(r"(\d{1,2})月(\d{1,2})", text)
    if short_match:
        month, day = short_match.groups()
        return f"{month.zfill(2)}-{day.zfill(2)}"
    return ""


def extract_city_tokens(text: Any) -> list[str]:
    compact = normalize_text(text)
    return [city for city in CITY_NAMES if city in compact] if compact else []


def dates_overlap(left: list[str], right: list[str]) -> bool:
    return any(
        left_date
        and right_date
        and (
            left_date == right_date
            or left_date.endswith(right_date)
            or right_date.endswith(left_date)
        )
        for left_date in left
        for right_date in right
    )


def collect_receipt_signals(receipts: list[ReceiptFolderDetailRead]) -> dict[str, Any]:
    text = "\n".join(build_receipt_text(receipt) for receipt in receipts)
    return {
        "text": text,
        "dates": unique(
            [
                *extract_date_tokens(text),
                *[str(receipt.document_date or "").strip() for receipt in receipts],
            ]
        ),
        "cities": unique(extract_city_tokens(text)),
        "scenes": unique([str(receipt.scene_code or "").strip().lower() for receipt in receipts]),
    }


def build_receipt_text(receipt: ReceiptFolderDetailRead) -> str:
    fields_text = "\n".join(
        f"{field.label} {field.value}"
        for field in list(receipt.fields or [])
        if str(field.label or field.value or "").strip()
    )
    return "\n".join(
        value
        for value in (
            receipt.file_name,
            receipt.summary,
            receipt.ocr_text,
            receipt.document_date,
            receipt.merchant_name,
            fields_text,
        )
        if str(value or "").strip()
    )


def build_claim_text(claim: ExpenseClaim) -> str:
    item_text = "\n".join(
        " ".join(
            str(value or "").strip()
            for value in (
                item.item_date.isoformat() if item.item_date else "",
                item.item_type,
                item.item_reason,
                item.item_location,
                item.item_note,
            )
            if str(value or "").strip()
        )
        for item in list(claim.items or [])
    )
    occurred_at = claim.occurred_at.isoformat()[:10] if claim.occurred_at else ""
    return "\n".join(
        value
        for value in (
            claim.claim_no,
            claim.expense_type,
            claim.status,
            claim.reason,
            claim.location,
            occurred_at,
            item_text,
        )
        if str(value or "").strip()
    )


def score_claim_signals(claim: ExpenseClaim, signals: dict[str, Any]) -> tuple[int, list[str]]:
    claim_text = build_claim_text(claim)
    compact_claim_text = normalize_text(claim_text)
    claim_dates = extract_date_tokens(claim_text)
    claim_cities = unique([*extract_city_tokens(claim_text), *extract_city_tokens(claim.location)])
    reasons: list[str] = []
    score = 0
    if dates_overlap(list(signals.get("dates") or []), claim_dates):
        score += 4
        reasons.append("票据日期与单据日期一致")
    matched_cities = [
        city for city in list(signals.get("cities") or []) if city in compact_claim_text
    ]
    if matched_cities:
        score += min(4, len(matched_cities) * 2)
        reasons.append(f"地点或行程包含 {'、'.join(matched_cities)}")
    if len(claim_cities) >= 2 and len(matched_cities) >= 2:
        score += 2
        reasons.append("票据往返城市与单据事由吻合")
    claim_scene = str(claim.expense_type or "").strip().lower().removesuffix("_application")
    receipt_scenes = set(signals.get("scenes") or [])
    if claim_scene and claim_scene in receipt_scenes:
        score += 2
        reasons.append("票据费用场景与单据类型一致")
    return score, reasons
