from __future__ import annotations

import hashlib
import hmac
import json
import re
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from enum import StrEnum
from typing import Any

from sqlalchemy import and_, or_, 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.models.savings import SavingsOpportunity
from app.services.expense_claim_constants import STANDARD_ADJUSTMENT_RISK_SOURCE
from app.services.savings_discovery import SavingsDiscoveryService

DEFAULT_BATCH_SIZE = 100
MAX_BATCH_SIZE = 1000
_FINGERPRINT_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")


class StandardAdjustmentBackfillDisposition(StrEnum):
    ELIGIBLE = "eligible"
    CREATED = "created"
    REPLAYED = "replayed"
    MISSING_CASE_LINK = "missing_case_link"
    TENANT_CONFLICT = "tenant_conflict"
    INVALID_CASE_LINK = "invalid_case_link"
    MISSING_ITEM = "missing_item"
    AMBIGUOUS_ITEM_HISTORY = "ambiguous_item_history"
    DUPLICATE_FLAG = "duplicate_flag"
    MISSING_SERVER_POLICY = "missing_server_policy"
    MISSING_POLICY_VERSION = "missing_policy_version"
    INVALID_POLICY_SNAPSHOT = "invalid_policy_snapshot"
    INVALID_CALCULATION_FINGERPRINT = "invalid_calculation_fingerprint"
    CALCULATION_FINGERPRINT_MISMATCH = "calculation_fingerprint_mismatch"
    INVALID_ORIGINAL_AMOUNT = "invalid_original_amount"
    ORIGINAL_AMOUNT_MISMATCH = "original_amount_mismatch"
    INVALID_TARGET_AMOUNT = "invalid_target_amount"
    INVALID_SAVING_DIFFERENCE = "invalid_saving_difference"
    INVALID_CURRENCY = "invalid_currency"


@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillCursor:
    created_at: datetime
    claim_id: str


@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillItem:
    claim_id: str
    claim_no: str
    item_id: str
    disposition: StandardAdjustmentBackfillDisposition
    reason: str
    calculation_fingerprint: str = ""
    policy_version: str = ""
    currency: str = ""
    original_amount: Decimal | None = None
    target_amount: Decimal | None = None
    saving_amount: Decimal | None = None
    opportunity_id: str | None = None


@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillPage:
    tenant_id: str
    claims_inspected: int
    flags_inspected: int
    eligible: int
    replayed: int
    skipped: int
    reasons: dict[str, int]
    has_more: bool
    next_cursor: StandardAdjustmentBackfillCursor | None
    items: tuple[StandardAdjustmentBackfillItem, ...]


@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillResult:
    tenant_id: str
    run_id: str
    claims_inspected: int
    flags_inspected: int
    created: int
    replayed: int
    skipped: int
    reasons: dict[str, int]
    has_more: bool
    next_cursor: StandardAdjustmentBackfillCursor | None
    items: tuple[StandardAdjustmentBackfillItem, ...]


@dataclass(slots=True)
class _BatchContext:
    links_by_claim_id: dict[str, ExpenseCaseLink]
    cases_by_id: dict[str, ExpenseCase]
    opportunities_by_key: dict[str, SavingsOpportunity]


class StandardAdjustmentSavingsBackfillService:
    """严格按历史服务端证据回填节省机会；从不自行提交事务。"""

    def __init__(
        self,
        db: Session,
        *,
        tenant_id: str,
        created_before: datetime | None = None,
    ) -> None:
        self.db = db
        self.tenant_id = self._required_text(tenant_id, field_name="tenant_id", max_length=64)
        self.created_before = (
            self._aware(created_before, field_name="created_before")
            if created_before is not None
            else None
        )

    def preview(
        self,
        *,
        batch_size: int = DEFAULT_BATCH_SIZE,
        after: StandardAdjustmentBackfillCursor | None = None,
    ) -> StandardAdjustmentBackfillPage:
        """只读预览；不 add、不 flush、不 commit。"""

        with self.db.no_autoflush:
            claims, has_more = self._load_claims(
                batch_size=self._batch_size(batch_size),
                after=after,
                lock_rows=False,
            )
            context = self._load_context(claims)
            items = self._classify(claims, context=context)
        return StandardAdjustmentBackfillPage(
            tenant_id=self.tenant_id,
            claims_inspected=len(claims),
            flags_inspected=len(items),
            eligible=self._count(items, StandardAdjustmentBackfillDisposition.ELIGIBLE),
            replayed=self._count(items, StandardAdjustmentBackfillDisposition.REPLAYED),
            skipped=self._skipped(items),
            reasons=self._reason_counts(items),
            has_more=has_more,
            next_cursor=self._next_cursor(claims),
            items=items,
        )

    def apply_batch(
        self,
        *,
        run_id: str,
        batch_size: int = DEFAULT_BATCH_SIZE,
        after: StandardAdjustmentBackfillCursor | None = None,
    ) -> StandardAdjustmentBackfillResult:
        """锁定并应用一批；调用方负责按批 commit/rollback。"""

        normalized_run_id = self._required_text(run_id, field_name="run_id", max_length=64)
        claims, has_more = self._load_claims(
            batch_size=self._batch_size(batch_size),
            after=after,
            lock_rows=True,
        )
        context = self._load_context(claims)
        classified = self._classify(claims, context=context)
        claims_by_id = {str(claim.id): claim for claim in claims}
        items_by_claim = {
            str(claim.id): {str(item.id): item for item in claim.items} for claim in claims
        }
        actor = self._system_actor(run_id=normalized_run_id)
        result_items: list[StandardAdjustmentBackfillItem] = []

        for item in classified:
            if item.disposition is not StandardAdjustmentBackfillDisposition.ELIGIBLE:
                result_items.append(item)
                continue
            claim = claims_by_id[item.claim_id]
            claim_items = items_by_claim[item.claim_id]
            source_flag = self._find_flag(claim, item=item)
            discovered = SavingsDiscoveryService(self.db).discover_standard_adjustments(
                claim=claim,
                items_by_id=claim_items,
                adjustment_flags=[source_flag],
                current_user=actor,
                request_id=f"savings-backfill:{normalized_run_id}",
            )
            if len(discovered) != 1:
                raise RuntimeError("历史标准调整回填未返回唯一节省机会，当前批次必须回滚。")
            opportunity = discovered[0]
            context.opportunities_by_key[opportunity.opportunity_key] = opportunity
            result_items.append(
                replace(
                    item,
                    disposition=StandardAdjustmentBackfillDisposition.CREATED,
                    reason="证据完整，已通过 SavingsDiscoveryService 创建机会。",
                    opportunity_id=opportunity.id,
                )
            )

        result = tuple(result_items)
        return StandardAdjustmentBackfillResult(
            tenant_id=self.tenant_id,
            run_id=normalized_run_id,
            claims_inspected=len(claims),
            flags_inspected=len(result),
            created=self._count(result, StandardAdjustmentBackfillDisposition.CREATED),
            replayed=self._count(result, StandardAdjustmentBackfillDisposition.REPLAYED),
            skipped=self._skipped(result),
            reasons=self._reason_counts(result),
            has_more=has_more,
            next_cursor=self._next_cursor(claims),
            items=result,
        )

    def _load_claims(
        self,
        *,
        batch_size: int,
        after: StandardAdjustmentBackfillCursor | None,
        lock_rows: bool,
    ) -> tuple[list[ExpenseClaim], bool]:
        stmt = select(ExpenseClaim).options(selectinload(ExpenseClaim.items))
        if self.created_before is not None:
            stmt = stmt.where(ExpenseClaim.created_at < self.created_before)
        if after is not None:
            cursor_time = self._aware(after.created_at, field_name="after.created_at")
            cursor_id = self._required_text(
                after.claim_id,
                field_name="after.claim_id",
                max_length=36,
            )
            stmt = stmt.where(
                or_(
                    ExpenseClaim.created_at > cursor_time,
                    and_(ExpenseClaim.created_at == cursor_time, ExpenseClaim.id > cursor_id),
                )
            )
        stmt = stmt.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.id.asc()).limit(
            batch_size + 1
        )
        if lock_rows:
            stmt = stmt.with_for_update()
        # JSON 文本过滤在不同数据库方言上语义不完全一致，因此在 Python 中确认来源；
        # 这里按 Claim 分页，空标志页仍可通过 cursor 继续推进。
        rows = list(self.db.scalars(stmt).unique().all())
        return rows[:batch_size], len(rows) > batch_size

    def _load_context(self, claims: Sequence[ExpenseClaim]) -> _BatchContext:
        claim_ids = [str(claim.id) for claim in claims]
        if not claim_ids:
            return _BatchContext({}, {}, {})
        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 = {str(link.expense_case_id) for link in links}
        cases_by_id = (
            {
                str(expense_case.id): expense_case
                for expense_case in self.db.scalars(
                    select(ExpenseCase).where(ExpenseCase.id.in_(case_ids))
                ).all()
            }
            if case_ids
            else {}
        )
        opportunities = list(
            self.db.scalars(
                select(SavingsOpportunity).where(
                    SavingsOpportunity.tenant_id == self.tenant_id,
                    SavingsOpportunity.claim_id.in_(claim_ids),
                    SavingsOpportunity.source_type == "standard_adjustment",
                )
            ).all()
        )
        return _BatchContext(
            links_by_claim_id=links_by_claim_id,
            cases_by_id=cases_by_id,
            opportunities_by_key={item.opportunity_key: item for item in opportunities},
        )

    def _classify(
        self,
        claims: Sequence[ExpenseClaim],
        *,
        context: _BatchContext,
    ) -> tuple[StandardAdjustmentBackfillItem, ...]:
        results: list[StandardAdjustmentBackfillItem] = []
        for claim in claims:
            flags = self._standard_adjustment_flags(claim)
            if not flags:
                continue
            grouped: dict[str, list[dict[str, Any]]] = {}
            for flag in flags:
                grouped.setdefault(str(flag.get("item_id") or "").strip(), []).append(flag)
            for _item_id, item_flags in grouped.items():
                fingerprints = {
                    str(flag.get("calculation_fingerprint") or "").strip()
                    for flag in item_flags
                }
                if len(fingerprints) > 1:
                    results.extend(
                        self._item(
                            claim,
                            flag,
                            StandardAdjustmentBackfillDisposition.AMBIGUOUS_ITEM_HISTORY,
                            "同一明细存在多个不同政策计算快照，无法安全判断应货币化哪一版。",
                        )
                        for flag in item_flags
                    )
                    continue
                first, *duplicates = item_flags
                results.append(self._classify_flag(claim, first, context=context))
                results.extend(
                    self._item(
                        claim,
                        flag,
                        StandardAdjustmentBackfillDisposition.DUPLICATE_FLAG,
                        "同一明细存在重复标准调整标志；仅首条进入证据校验。",
                    )
                    for flag in duplicates
                )
        return tuple(results)

    def _classify_flag(
        self,
        claim: ExpenseClaim,
        flag: dict[str, Any],
        *,
        context: _BatchContext,
    ) -> StandardAdjustmentBackfillItem:
        item_id = str(flag.get("item_id") or "").strip()
        link = context.links_by_claim_id.get(str(claim.id))
        if link is None:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.MISSING_CASE_LINK,
                "Claim 没有 ExpenseCaseLink，租户归属无法证明。",
            )
        if link.tenant_id != self.tenant_id:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.TENANT_CONFLICT,
                "Claim 的 ExpenseCaseLink 属于其他租户。",
            )
        expense_case = context.cases_by_id.get(str(link.expense_case_id))
        if (
            expense_case is None
            or expense_case.tenant_id != self.tenant_id
            or not str(link.relation_type or "").strip()
        ):
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.INVALID_CASE_LINK,
                "ExpenseCaseLink 关联目标缺失、租户不一致或关系类型为空。",
            )
        item = next((entry for entry in claim.items if str(entry.id) == item_id), None)
        if item is None:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.MISSING_ITEM,
                "标准调整引用的费用明细不存在。",
            )
        if str(flag.get("calculation_source") or "").strip() != "server_policy":
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.MISSING_SERVER_POLICY,
                "缺少 calculation_source=server_policy，不能证明由服务端政策计算。",
            )
        policy_version = str(flag.get("policy_rule_version") or "").strip()
        if not policy_version:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.MISSING_POLICY_VERSION,
                "缺少服务端政策版本。",
            )
        calculation_fingerprint = str(flag.get("calculation_fingerprint") or "").strip()
        if not _FINGERPRINT_PATTERN.fullmatch(calculation_fingerprint):
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.INVALID_CALCULATION_FINGERPRINT,
                "政策计算指纹不是合法 sha256 快照。",
            )
        original = self._money(flag.get("original_amount"))
        if original is None or original <= Decimal("0"):
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.INVALID_ORIGINAL_AMOUNT,
                "原金额缺失、格式错误或不为正数。",
            )
        item_amount = self._money(item.item_amount)
        if item_amount is None or item_amount != original:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.ORIGINAL_AMOUNT_MISMATCH,
                "历史原金额与当前 ExpenseClaimItem.item_amount 不一致。",
                original=original,
            )
        target = self._money(flag.get("reimbursable_amount"))
        if target is None or target < Decimal("0"):
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.INVALID_TARGET_AMOUNT,
                "目标报销金额缺失、格式错误或为负数。",
                original=original,
            )
        saving = (original - target).quantize(Decimal("0.01"))
        absorbed = self._money(flag.get("employee_absorbed_amount"))
        if saving <= Decimal("0") or absorbed is None or absorbed != saving:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.INVALID_SAVING_DIFFERENCE,
                "差额不为正数，或员工承担金额与原金额减目标金额不一致。",
                original=original,
                target=target,
                saving=saving,
            )
        currency = str(claim.currency or "").strip().upper()
        if len(currency) != 3 or not currency.isalpha():
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.INVALID_CURRENCY,
                "Claim 缺少合法三位币种，禁止默认猜测币种。",
                original=original,
                target=target,
                saving=saving,
            )
        expected_fingerprint = self._calculation_fingerprint(flag, original, target)
        if expected_fingerprint is None:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.INVALID_POLICY_SNAPSHOT,
                "政策天数、地点、职级、酒店标准或规则名称快照不完整。",
                original=original,
                target=target,
                saving=saving,
            )
        if not hmac.compare_digest(expected_fingerprint, calculation_fingerprint):
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.CALCULATION_FINGERPRINT_MISMATCH,
                "按历史政策快照重算的 SHA-256 与存储指纹不一致。",
                original=original,
                target=target,
                saving=saving,
            )
        opportunity_key = self._opportunity_key(
            claim_id=str(claim.id),
            item_id=item_id,
            policy_version=policy_version,
            calculation_fingerprint=calculation_fingerprint,
        )
        existing = context.opportunities_by_key.get(opportunity_key)
        if existing is not None:
            return self._item(
                claim,
                flag,
                StandardAdjustmentBackfillDisposition.REPLAYED,
                "相同稳定键的节省机会已存在。",
                original=original,
                target=target,
                saving=saving,
                opportunity_id=existing.id,
            )
        return self._item(
            claim,
            flag,
            StandardAdjustmentBackfillDisposition.ELIGIBLE,
            "Case、租户、金额及服务端政策指纹均验证通过。",
            original=original,
            target=target,
            saving=saving,
        )

    @staticmethod
    def _standard_adjustment_flags(claim: ExpenseClaim) -> list[dict[str, Any]]:
        return [
            flag
            for flag in list(claim.risk_flags_json or [])
            if isinstance(flag, dict)
            and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
        ]

    @classmethod
    def _find_flag(
        cls,
        claim: ExpenseClaim,
        *,
        item: StandardAdjustmentBackfillItem,
    ) -> dict[str, Any]:
        matches = [
            flag
            for flag in cls._standard_adjustment_flags(claim)
            if str(flag.get("item_id") or "").strip() == item.item_id
            and str(flag.get("calculation_fingerprint") or "").strip()
            == item.calculation_fingerprint
        ]
        if len(matches) != 1:
            raise RuntimeError("历史标准调整证据在锁定后发生歧义，当前批次必须回滚。")
        return matches[0]

    @classmethod
    def _calculation_fingerprint(
        cls,
        flag: dict[str, Any],
        original: Decimal,
        target: Decimal,
    ) -> str | None:
        try:
            days = int(flag["policy_days"])
            location = cls._snapshot_text(flag, "policy_location")
            matched_city = cls._snapshot_text(flag, "policy_matched_city")
            grade = cls._snapshot_text(flag, "policy_grade")
            grade_band = cls._snapshot_text(flag, "policy_grade_band")
            hotel_rate = cls._snapshot_money(flag, "policy_hotel_rate")
            hotel_amount = cls._snapshot_money(flag, "policy_hotel_amount")
            rule_name = cls._snapshot_text(flag, "policy_rule_name")
            rule_version = cls._snapshot_text(flag, "policy_rule_version")
        except (KeyError, TypeError, ValueError, InvalidOperation):
            return None
        if days <= 0 or hotel_rate < Decimal("0") or hotel_amount < Decimal("0"):
            return None
        material = {
            "item_id": str(flag.get("item_id") or "").strip(),
            "original_amount": f"{original:.2f}",
            "reimbursable_amount": f"{target:.2f}",
            "policy_days": days,
            "policy_location": location,
            "policy_matched_city": matched_city,
            "policy_grade": grade,
            "policy_grade_band": grade_band,
            "policy_hotel_rate": f"{hotel_rate:.2f}",
            "policy_hotel_amount": f"{hotel_amount:.2f}",
            "policy_rule_name": rule_name,
            "policy_rule_version": rule_version,
        }
        canonical = json.dumps(
            material,
            ensure_ascii=False,
            sort_keys=True,
            separators=(",", ":"),
        )
        return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"

    def _opportunity_key(
        self,
        *,
        claim_id: str,
        item_id: str,
        policy_version: str,
        calculation_fingerprint: str,
    ) -> str:
        material = {
            "tenant_id": self.tenant_id,
            "claim_id": claim_id,
            "item_id": item_id,
            "policy_version": policy_version,
            "calculation_fingerprint": calculation_fingerprint,
        }
        canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
        return f"standard-adjustment:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"

    def _item(
        self,
        claim: ExpenseClaim,
        flag: dict[str, Any],
        disposition: StandardAdjustmentBackfillDisposition,
        reason: str,
        *,
        original: Decimal | None = None,
        target: Decimal | None = None,
        saving: Decimal | None = None,
        opportunity_id: str | None = None,
    ) -> StandardAdjustmentBackfillItem:
        return StandardAdjustmentBackfillItem(
            claim_id=str(claim.id),
            claim_no=str(claim.claim_no or claim.id),
            item_id=str(flag.get("item_id") or "").strip(),
            disposition=disposition,
            reason=reason,
            calculation_fingerprint=str(flag.get("calculation_fingerprint") or "").strip(),
            policy_version=str(flag.get("policy_rule_version") or "").strip(),
            currency=str(claim.currency or "").strip().upper(),
            original_amount=original,
            target_amount=target,
            saving_amount=saving,
            opportunity_id=opportunity_id,
        )

    def _system_actor(self, *, run_id: str) -> CurrentUserContext:
        return CurrentUserContext(
            username=f"savings-backfill:{run_id}"[:120],
            name="Savings 历史回填",
            role_codes=["finance"],
            is_admin=False,
            tenant_id=self.tenant_id,
        )

    @staticmethod
    def _snapshot_text(flag: dict[str, Any], key: str) -> str:
        value = str(flag[key] or "").strip()
        if not value:
            raise ValueError(f"{key} is blank")
        return value

    @classmethod
    def _snapshot_money(cls, flag: dict[str, Any], key: str) -> Decimal:
        value = cls._money(flag[key])
        if value is None:
            raise ValueError(f"{key} is not money")
        return value

    @staticmethod
    def _money(value: Any) -> Decimal | None:
        if isinstance(value, bool) or value is None or str(value).strip() == "":
            return None
        try:
            parsed = Decimal(str(value))
        except (InvalidOperation, ValueError):
            return None
        if not parsed.is_finite():
            return None
        return parsed.quantize(Decimal("0.01"))

    @staticmethod
    def _required_text(value: str, *, field_name: str, max_length: int) -> str:
        normalized = str(value or "").strip()
        if not normalized:
            raise ValueError(f"{field_name} must not be empty")
        if len(normalized) > max_length:
            raise ValueError(f"{field_name} must be at most {max_length} characters")
        return normalized

    @staticmethod
    def _aware(value: datetime, *, field_name: str) -> datetime:
        if value.tzinfo is None or value.utcoffset() is None:
            raise ValueError(f"{field_name} must include timezone")
        return value.astimezone(UTC)

    @staticmethod
    def _batch_size(value: int) -> int:
        normalized = int(value)
        if normalized < 1 or normalized > MAX_BATCH_SIZE:
            raise ValueError(f"batch_size must be between 1 and {MAX_BATCH_SIZE}")
        return normalized

    @staticmethod
    def _next_cursor(claims: Sequence[ExpenseClaim]) -> StandardAdjustmentBackfillCursor | None:
        if not claims:
            return None
        claim = claims[-1]
        return StandardAdjustmentBackfillCursor(created_at=claim.created_at, claim_id=str(claim.id))

    @staticmethod
    def _count(
        items: Sequence[StandardAdjustmentBackfillItem],
        disposition: StandardAdjustmentBackfillDisposition,
    ) -> int:
        return sum(item.disposition is disposition for item in items)

    @staticmethod
    def _reason_counts(items: Sequence[StandardAdjustmentBackfillItem]) -> dict[str, int]:
        counts = Counter(item.disposition.value for item in items)
        return dict(sorted(counts.items()))

    @staticmethod
    def _skipped(items: Sequence[StandardAdjustmentBackfillItem]) -> int:
        accepted = {
            StandardAdjustmentBackfillDisposition.ELIGIBLE,
            StandardAdjustmentBackfillDisposition.CREATED,
            StandardAdjustmentBackfillDisposition.REPLAYED,
        }
        return sum(item.disposition not in accepted for item in items)
