from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any

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

from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.expense_claim_risk_flags import (
    claim_risk_flag_observation_key,
    claim_risk_flag_severity,
    is_open_claim_risk_flag,
)
from app.services.expense_claim_risk_stage import (
    risk_business_stage_for_claim,
    risk_flag_business_stage,
)
from app.services.expense_claim_status_registry import (
    is_application_claim_reference,
    normalize_expense_claim_state,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
    BUDGET_MANAGER_APPROVAL_STAGE,
    DIRECT_MANAGER_APPROVAL_STAGE,
    FINANCE_APPROVAL_STAGE,
)

DEFAULT_APPROVAL_SLA_HOURS = 24
SUPPORTED_APPROVAL_STAGES = frozenset(
    {
        DIRECT_MANAGER_APPROVAL_STAGE,
        BUDGET_MANAGER_APPROVAL_STAGE,
        FINANCE_APPROVAL_STAGE,
    }
)

_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
_RISK_WEIGHTS = {"low": 0, "medium": 14, "high": 30, "critical": 38}
_CLOSED_OBSERVATION_STATES = {
    "false_positive",
    "ignored",
    "resolved",
}
_BATCH_AMOUNT_LIMIT = Decimal("10000")
_BATCH_BUDGET_USAGE_LIMIT = Decimal("90")
_COMPLETENESS_QUANTUM = Decimal("0.0001")


@dataclass(frozen=True, slots=True)
class ApprovalTaskProjection:
    """审批队列的可重算快照；它不是审批放行的权威依据。"""

    priority_score: int
    priority_tier: str
    priority_reasons_json: tuple[dict[str, Any], ...]
    risk_level: str
    open_risk_count: int
    evidence_completeness: Decimal
    batch_eligible: bool
    batch_block_reasons_json: tuple[str, ...]
    due_at: datetime
    next_escalation_at: datetime
    projection_updated_at: datetime


class ApprovalTaskProjectionService:
    """计算审批任务的排序、风险、材料和安全批处理投影。

    投影仅用于列表排序与操作提示。真正审批时仍必须重新读取 Claim、风险处置、
    预算和任务版本，不能用这里的 ``batch_eligible`` 替代权威风控校验。
    """

    def __init__(self, db: Session) -> None:
        self.db = db

    def build(
        self,
        claim: ExpenseClaim,
        *,
        tenant_id: str,
        entered_at: datetime,
        now: datetime | None = None,
        sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS,
        observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None,
    ) -> ApprovalTaskProjection:
        normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
        normalized_entered_at = self._aware(entered_at, field_name="entered_at")
        generated_at = self._aware(now or datetime.now(UTC), field_name="now")
        normalized_sla_hours = int(sla_hours)
        if normalized_sla_hours <= 0:
            raise ValueError("sla_hours must be greater than zero")

        rows = (
            list(observation_rows)
            if observation_rows is not None
            else self._load_observation_rows(
                tenant_id=normalized_tenant,
                claim_id=str(claim.id or "").strip(),
            )
        )
        risk_level, open_risk_count = self._combined_risk_summary(
            claim,
            rows,
            now=generated_at,
        )
        evidence_completeness, missing_evidence = self._evidence_summary(claim)
        budget_usage_rate = self._budget_usage_rate(claim.risk_flags_json)
        amount = self._decimal(claim.amount)
        due_at = normalized_entered_at + timedelta(hours=normalized_sla_hours)
        waiting_hours = max(
            Decimal("0"),
            Decimal(str((generated_at - normalized_entered_at).total_seconds())) / Decimal("3600"),
        )

        reasons = self._priority_reasons(
            risk_level=risk_level,
            open_risk_count=open_risk_count,
            waiting_hours=waiting_hours,
            sla_hours=normalized_sla_hours,
            budget_usage_rate=budget_usage_rate,
            amount=amount,
            missing_evidence=missing_evidence,
        )
        score = min(100, sum(int(item.get("weight") or 0) for item in reasons))
        priority_tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal"

        state = normalize_expense_claim_state(
            claim.status,
            claim.approval_stage,
            claim_no=claim.claim_no,
            expense_type=claim.expense_type,
        )
        batch_block_reasons = self._batch_block_reasons(
            approval_stage=state.approval_stage,
            risk_level=risk_level,
            open_risk_count=open_risk_count,
            evidence_completeness=evidence_completeness,
            amount=amount,
            budget_usage_rate=budget_usage_rate,
            overdue=generated_at >= due_at,
        )
        return ApprovalTaskProjection(
            priority_score=score,
            priority_tier=priority_tier,
            priority_reasons_json=tuple(reasons),
            risk_level=risk_level,
            open_risk_count=open_risk_count,
            evidence_completeness=evidence_completeness,
            batch_eligible=not batch_block_reasons,
            batch_block_reasons_json=tuple(batch_block_reasons),
            due_at=due_at,
            next_escalation_at=due_at,
            projection_updated_at=generated_at,
        )

    @staticmethod
    def apply(
        task: ApprovalTask,
        projection: ApprovalTaskProjection | None = None,
        *,
        claim: ExpenseClaim | None = None,
        now: datetime | None = None,
        observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None,
    ) -> ApprovalTask:
        """计算（可选）并应用投影，不 flush、不 commit。

        ``claim=`` 便捷形式会从持久化 Claim 取得 Session，因此即使 Task 尚未 add，
        仍能租户隔离地合并结构化风险；也保留显式 ``projection`` 形式供批处理复用。
        """

        if projection is None:
            if claim is None:
                raise ValueError("claim is required when projection is not provided")
            db = object_session(claim) or object_session(task)
            if db is None and observation_rows is None:
                raise ValueError(
                    "claim must be attached to a Session when observation_rows is omitted"
                )
            projection = ApprovalTaskProjectionService(db).build(  # type: ignore[arg-type]
                claim,
                tenant_id=str(task.tenant_id or "").strip(),
                entered_at=task.entered_at,
                now=now,
                sla_hours=task.sla_hours_snapshot,
                observation_rows=observation_rows,
            )

        task.priority_score = projection.priority_score
        task.priority_tier = projection.priority_tier
        task.priority_reasons_json = [dict(item) for item in projection.priority_reasons_json]
        task.risk_level = projection.risk_level
        task.open_risk_count = projection.open_risk_count
        task.evidence_completeness = projection.evidence_completeness
        task.batch_eligible = projection.batch_eligible
        task.batch_block_reasons_json = list(projection.batch_block_reasons_json)
        task.due_at = projection.due_at
        task.next_escalation_at = projection.next_escalation_at
        task.projection_updated_at = projection.projection_updated_at
        return task

    def _load_observation_rows(
        self,
        *,
        tenant_id: str,
        claim_id: str,
    ) -> list[tuple[RiskObservation, RiskDisposition | None]]:
        if not claim_id:
            return []
        return list(
            self.db.execute(
                select(RiskObservation, RiskDisposition)
                .outerjoin(
                    RiskDisposition,
                    (RiskDisposition.tenant_id == RiskObservation.tenant_id)
                    & (RiskDisposition.observation_id == RiskObservation.id),
                )
                .where(
                    RiskObservation.tenant_id == tenant_id,
                    RiskObservation.claim_id == claim_id,
                )
            ).all()
        )

    @classmethod
    def _combined_risk_summary(
        cls,
        claim: ExpenseClaim,
        rows: Sequence[tuple[RiskObservation, RiskDisposition | None]],
        *,
        now: datetime,
    ) -> tuple[str, int]:
        claim_business_stage = risk_business_stage_for_claim(
            is_application_claim=is_application_claim_reference(
                claim_no=claim.claim_no,
                expense_type=claim.expense_type,
            )
        )
        persisted_level, persisted_count = cls._persisted_risk_summary(
            rows,
            claim_business_stage=claim_business_stage,
            now=now,
        )
        materialized_keys = {
            str(observation.observation_key or "").strip()
            for observation, _disposition in rows
            if str(observation.observation_key or "").strip()
        }
        raw_level, raw_count = cls._raw_risk_summary(
            claim.risk_flags_json,
            claim_id=str(claim.id or "").strip(),
            materialized_keys=materialized_keys,
            claim_business_stage=claim_business_stage,
        )
        return (
            max((persisted_level, raw_level), key=lambda value: _RISK_ORDER[value]),
            persisted_count + raw_count,
        )

    @staticmethod
    def _persisted_risk_summary(
        rows: Sequence[tuple[RiskObservation, RiskDisposition | None]],
        *,
        claim_business_stage: str,
        now: datetime,
    ) -> tuple[str, int]:
        level = "low"
        count = 0
        for observation, disposition in rows:
            observation_stage = risk_flag_business_stage(
                {"control_stage": observation.control_stage},
                default="",
            )
            if observation_stage and observation_stage != claim_business_stage:
                continue
            adjudication = (
                str(
                    disposition.adjudication
                    if disposition is not None
                    else observation.feedback_status or ""
                )
                .strip()
                .lower()
            )
            lifecycle = (
                str(disposition.lifecycle_status if disposition is not None else "open")
                .strip()
                .lower()
            )
            observation_status = str(observation.status or "").strip().lower()
            active_waiver = False
            if disposition is not None and lifecycle == "waived":
                expires_at = disposition.waiver_expires_at
                normalized_expiry = (
                    expires_at.replace(tzinfo=UTC)
                    if expires_at is not None and expires_at.tzinfo is None
                    else expires_at.astimezone(UTC)
                    if expires_at is not None
                    else None
                )
                active_waiver = (
                    str(disposition.waiver_decision or "").strip().lower() == "approved"
                    and normalized_expiry is not None
                    and normalized_expiry > now
                )
            if (
                adjudication == "false_positive"
                or lifecycle == "resolved"
                or active_waiver
                or observation_status in _CLOSED_OBSERVATION_STATES
            ):
                continue

            candidate = str(observation.risk_level or "medium").strip().lower()
            if candidate == "danger":
                candidate = "high"
            if candidate not in _RISK_ORDER:
                candidate = "medium"
            count += 1
            if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
                level = candidate
        return level, count

    @staticmethod
    def _raw_risk_summary(
        raw_flags: Any,
        *,
        claim_id: str,
        materialized_keys: set[str],
        claim_business_stage: str,
    ) -> tuple[str, int]:
        flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
        level = "low"
        count = 0
        for flag in flags:
            if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag):
                continue
            flag_stage = risk_flag_business_stage(flag, default="")
            if flag_stage and flag_stage != claim_business_stage:
                continue
            observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id)
            if observation_key and observation_key in materialized_keys:
                continue
            candidate = claim_risk_flag_severity(flag) or "medium"
            if candidate not in _RISK_ORDER:
                candidate = "medium"
            count += 1
            if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
                level = candidate
        return level, count

    @classmethod
    def _evidence_summary(cls, claim: ExpenseClaim) -> tuple[Decimal, tuple[str, ...]]:
        checks = [
            ("事由", bool(str(claim.reason or "").strip())),
            ("地点", bool(str(claim.location or "").strip())),
            ("费用明细", bool(list(claim.items or []))),
        ]
        if not is_application_claim_reference(
            claim_no=claim.claim_no,
            expense_type=claim.expense_type,
        ):
            checks.append(
                (
                    "票据",
                    int(claim.invoice_count or 0) > 0
                    or any(str(item.invoice_id or "").strip() for item in list(claim.items or [])),
                )
            )
        missing = tuple(label for label, present in checks if not present)
        if not checks:
            return Decimal("1.0000"), missing
        present_count = len(checks) - len(missing)
        completeness = (Decimal(present_count) / Decimal(len(checks))).quantize(
            _COMPLETENESS_QUANTUM, rounding=ROUND_HALF_UP
        )
        return completeness, missing

    @classmethod
    def _priority_reasons(
        cls,
        *,
        risk_level: str,
        open_risk_count: int,
        waiting_hours: Decimal,
        sla_hours: int,
        budget_usage_rate: Decimal | None,
        amount: Decimal,
        missing_evidence: tuple[str, ...],
    ) -> list[dict[str, Any]]:
        reasons: list[dict[str, Any]] = []
        risk_weight = _RISK_WEIGHTS[risk_level]
        if risk_weight:
            reasons.append(
                {
                    "code": "open_risk",
                    "label": f"{risk_level.upper()} 风险 {open_risk_count} 项待复核",
                    "weight": risk_weight,
                    "tone": "danger" if risk_level in {"high", "critical"} else "warning",
                }
            )

        sla = Decimal(sla_hours)
        if waiting_hours >= sla:
            reasons.append(
                {
                    "code": "sla_overdue",
                    "label": f"已超过 {sla_hours} 小时审批 SLA",
                    "weight": 26,
                    "tone": "danger",
                }
            )
        elif waiting_hours >= sla * Decimal("0.6667"):
            reasons.append(
                {
                    "code": "sla_near_due",
                    "label": "审批 SLA 即将到期",
                    "weight": 16,
                    "tone": "warning",
                }
            )
        elif waiting_hours >= sla * Decimal("0.3333"):
            reasons.append(
                {
                    "code": "waiting",
                    "label": "审批等待时间较长",
                    "weight": 8,
                    "tone": "warning",
                }
            )

        if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT:
            reasons.append(
                {
                    "code": "budget_pressure",
                    "label": f"审批后预算占用约 {budget_usage_rate.quantize(Decimal('1'))}%",
                    "weight": 18,
                    "tone": "danger" if budget_usage_rate >= 100 else "warning",
                }
            )
        if amount >= Decimal("50000"):
            reasons.append(
                {
                    "code": "large_amount",
                    "label": "大额费用需重点核对",
                    "weight": 15,
                    "tone": "warning",
                }
            )
        elif amount >= _BATCH_AMOUNT_LIMIT:
            reasons.append(
                {
                    "code": "amount_attention",
                    "label": "金额达到 1 万元复核线",
                    "weight": 8,
                    "tone": "warning",
                }
            )
        if missing_evidence:
            reasons.append(
                {
                    "code": "evidence_gap",
                    "label": "材料仍有缺口：" + "、".join(missing_evidence),
                    "weight": 12,
                    "tone": "warning",
                }
            )
        if not reasons:
            reasons.append(
                {
                    "code": "routine",
                    "label": "常规低风险待办",
                    "weight": 0,
                    "tone": "neutral",
                }
            )
        return reasons

    @staticmethod
    def _batch_block_reasons(
        *,
        approval_stage: str,
        risk_level: str,
        open_risk_count: int,
        evidence_completeness: Decimal,
        amount: Decimal,
        budget_usage_rate: Decimal | None,
        overdue: bool,
    ) -> list[str]:
        reasons: list[str] = []
        if approval_stage not in SUPPORTED_APPROVAL_STAGES:
            reasons.append("unsupported_approval_stage")
        if open_risk_count > 0 or risk_level != "low":
            reasons.append("open_risk")
        if evidence_completeness < Decimal("1"):
            reasons.append("evidence_incomplete")
        if amount >= _BATCH_AMOUNT_LIMIT:
            reasons.append("amount_requires_individual_review")
        if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT:
            reasons.append("budget_pressure")
        if overdue:
            reasons.append("sla_overdue")
        return reasons

    @classmethod
    def _budget_usage_rate(cls, raw_flags: Any) -> Decimal | None:
        values: list[Decimal] = []
        flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
        for flag in flags:
            if not isinstance(flag, dict):
                continue
            candidates = [
                flag,
                flag.get("metrics"),
                flag.get("budget_result"),
                (flag.get("route_decision") or {}).get("budget_result")
                if isinstance(flag.get("route_decision"), dict)
                else None,
            ]
            containers = [item for item in candidates if isinstance(item, dict)]
            containers.extend(
                item["metrics"]
                for item in list(containers)
                if isinstance(item.get("metrics"), dict)
            )
            for container in containers:
                for key in (
                    "after_usage_rate",
                    "budget_usage_rate",
                    "usage_rate",
                    "utilization_rate",
                ):
                    value = cls._optional_decimal(container.get(key))
                    if value is None or value < 0:
                        continue
                    values.append(value * 100 if Decimal("0") < value <= 1 else value)
        return max(values).quantize(Decimal("0.01")) if values else None

    @staticmethod
    def _decimal(value: Any) -> Decimal:
        try:
            return Decimal(str(value or "0"))
        except (InvalidOperation, TypeError, ValueError):
            return Decimal("0")

    @staticmethod
    def _optional_decimal(value: Any) -> Decimal | None:
        if value is None or str(value).strip() == "":
            return None
        try:
            return Decimal(str(value))
        except (InvalidOperation, TypeError, ValueError):
            return None

    @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 a timezone")
        return value.astimezone(UTC)
