from __future__ import annotations

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

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.api.deps import CurrentUserContext
from app.models.budget import BudgetAllocation, BudgetTransaction
from app.schemas.savings_insights import (
    SavingsAnalysisQualityIssue,
    SavingsInsightCandidateRead,
    SavingsInsightEvidenceRead,
)
from app.services.finance_dashboard_scope import finance_dashboard_includes_legacy_budget
from app.services.savings_access_policy import SavingsAccessPolicy
from app.services.savings_fact_scope import stable_digest


class SavingsBudgetForecastAnalyzer:
    """只把预算偏差作为暴露信号，不把预测超支伪装成节省。"""

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

    def analyze(
        self,
        *,
        window_start: datetime,
        window_end: datetime,
        as_of: datetime,
        current_user: CurrentUserContext,
    ) -> tuple[list[SavingsInsightCandidateRead], list[SavingsAnalysisQualityIssue]]:
        if not finance_dashboard_includes_legacy_budget(current_user.tenant_id):
            return [], [
                SavingsAnalysisQualityIssue(
                    code="tenant_budget_scope_unavailable",
                    message="旧预算表没有 tenant_id，非 default 租户禁止读取，未生成预算预测洞察。",
                    severity="error",
                )
            ]

        analysis_cutoff = min(as_of, window_end)
        statement = select(BudgetAllocation).where(
            BudgetAllocation.status == "active",
            BudgetAllocation.created_at <= analysis_cutoff,
            BudgetAllocation.updated_at <= analysis_cutoff,
        )
        statement = self._apply_scope(statement, current_user)
        allocations = list(self.db.scalars(statement).all())
        candidates: list[SavingsInsightCandidateRead] = []
        issues: list[SavingsAnalysisQualityIssue] = []
        unsupported_periods = 0
        insufficient_samples = 0
        for allocation in allocations:
            bounds = self._period_bounds(allocation)
            if bounds is None:
                unsupported_periods += 1
                continue
            period_start, period_end = bounds
            if period_end < window_start or period_start > window_end:
                continue
            rows = list(
                self.db.scalars(
                    select(BudgetTransaction)
                    .where(
                        BudgetTransaction.allocation_id == allocation.id,
                        BudgetTransaction.created_at >= period_start,
                        BudgetTransaction.created_at
                        <= min(analysis_cutoff, period_end),
                        BudgetTransaction.transaction_type.in_(("consume", "rollback")),
                    )
                    .order_by(BudgetTransaction.created_at.asc(), BudgetTransaction.id.asc())
                ).all()
            )
            distinct_sources = {
                (str(row.source_type or ""), str(row.source_id or "")) for row in rows
            }
            if len(distinct_sources) < 2:
                insufficient_samples += 1
                continue
            consumed = sum(
                (
                    -Decimal(row.amount)
                    if str(row.transaction_type).strip().lower() == "rollback"
                    else Decimal(row.amount)
                    for row in rows
                ),
                Decimal("0.0000"),
            )
            consumed = max(consumed, Decimal("0.0000"))
            total_budget = (
                Decimal(allocation.original_amount or 0)
                + Decimal(allocation.adjusted_amount or 0)
            ).quantize(Decimal("0.0001"))
            if total_budget <= 0:
                continue
            elapsed_ratio = self._elapsed_ratio(
                period_start,
                period_end,
                min(analysis_cutoff, period_end),
            )
            if elapsed_ratio < Decimal("0.2000"):
                insufficient_samples += 1
                continue
            projected = (consumed / elapsed_ratio).quantize(Decimal("0.0001"))
            if projected <= total_budget * Decimal("1.0500"):
                continue
            exposure = (projected - total_budget).quantize(Decimal("0.0001"))
            candidates.append(
                self._candidate(
                    allocation=allocation,
                    rows=rows,
                    consumed=consumed,
                    projected=projected,
                    total_budget=total_budget,
                    exposure=exposure,
                    elapsed_ratio=elapsed_ratio,
                    as_of=analysis_cutoff,
                )
            )

        if unsupported_periods:
            issues.append(
                SavingsAnalysisQualityIssue(
                    code="budget_period_format_unsupported",
                    message="部分预算期间无法解析，已从预测中排除。",
                    metadata={"allocation_count": unsupported_periods},
                )
            )
        if allocations and insufficient_samples and not candidates:
            issues.append(
                SavingsAnalysisQualityIssue(
                    code="budget_forecast_sample_insufficient",
                    message="预算核销来源或期间进度不足，未生成预测偏差信号。",
                    metadata={"allocation_count": insufficient_samples},
                )
            )
        if not allocations:
            issues.append(
                SavingsAnalysisQualityIssue(
                    code="budget_allocation_unavailable",
                    message="当前数据范围没有活动预算额度，未生成预算预测洞察。",
                )
            )
        return candidates, issues

    @staticmethod
    def _apply_scope(statement, current_user: CurrentUserContext):
        if SavingsAccessPolicy.can_read_tenant_value(current_user):
            return statement
        department_id = str(current_user.department_id or "").strip()
        if department_id:
            return statement.where(BudgetAllocation.department_id == department_id)
        department_name = str(current_user.department_name or "").strip()
        if department_name:
            return statement.where(BudgetAllocation.department_name == department_name)
        cost_center = str(current_user.cost_center or "").strip()
        if cost_center:
            return statement.where(BudgetAllocation.cost_center == cost_center)
        return statement.where(False)

    @staticmethod
    def _period_bounds(
        allocation: BudgetAllocation,
    ) -> tuple[datetime, datetime] | None:
        key = str(allocation.period_key or "").strip().upper()
        quarter = re.fullmatch(r"(\d{4})Q([1-4])", key)
        if quarter:
            year, number = int(quarter.group(1)), int(quarter.group(2))
            start_month = (number - 1) * 3 + 1
            end_month = start_month + 2
            return (
                datetime(year, start_month, 1, tzinfo=UTC),
                datetime(
                    year,
                    end_month,
                    calendar.monthrange(year, end_month)[1],
                    23,
                    59,
                    59,
                    999999,
                    tzinfo=UTC,
                ),
            )
        month = re.fullmatch(r"(\d{4})[-M](\d{1,2})", key)
        if month:
            year, number = int(month.group(1)), int(month.group(2))
            if not 1 <= number <= 12:
                return None
            return (
                datetime(year, number, 1, tzinfo=UTC),
                datetime(
                    year,
                    number,
                    calendar.monthrange(year, number)[1],
                    23,
                    59,
                    59,
                    999999,
                    tzinfo=UTC,
                ),
            )
        if re.fullmatch(r"\d{4}", key):
            year = int(key)
            return (
                datetime(year, 1, 1, tzinfo=UTC),
                datetime(year, 12, 31, 23, 59, 59, 999999, tzinfo=UTC),
            )
        return None

    @staticmethod
    def _elapsed_ratio(start: datetime, end: datetime, as_of: datetime) -> Decimal:
        total_seconds = Decimal(str((end - start).total_seconds()))
        elapsed_seconds = Decimal(str((min(as_of, end) - start).total_seconds()))
        if total_seconds <= 0:
            return Decimal("1.0000")
        return max(
            Decimal("0.0000"),
            min(Decimal("1.0000"), elapsed_seconds / total_seconds),
        ).quantize(Decimal("0.0001"))

    @staticmethod
    def _candidate(
        *,
        allocation: BudgetAllocation,
        rows: list[BudgetTransaction],
        consumed: Decimal,
        projected: Decimal,
        total_budget: Decimal,
        exposure: Decimal,
        elapsed_ratio: Decimal,
        as_of: datetime,
    ) -> SavingsInsightCandidateRead:
        material = {
            "allocation_id": allocation.id,
            "as_of": as_of,
            "transaction_ids": [row.id for row in rows],
            "projected": str(projected),
        }
        candidate_key = f"budget-forecast:{stable_digest(material)}"
        evidence = [
            SavingsInsightEvidenceRead(
                evidence_role="budget_allocation",
                resource_type="budget_allocation",
                resource_id=allocation.id,
                content_hash=stable_digest(
                    {
                        "original_amount": allocation.original_amount,
                        "adjusted_amount": allocation.adjusted_amount,
                        "period_key": allocation.period_key,
                    }
                ),
                occurred_at=allocation.updated_at or allocation.created_at or as_of,
                metadata={"budget_no": allocation.budget_no},
            )
        ]
        evidence.extend(
            SavingsInsightEvidenceRead(
                evidence_role="budget_consumption",
                resource_type="budget_transaction",
                resource_id=row.id,
                content_hash=stable_digest(
                    {
                        "type": row.transaction_type,
                        "amount": row.amount,
                        "source_type": row.source_type,
                        "source_id": row.source_id,
                    }
                ),
                occurred_at=row.created_at or as_of,
                metadata={"source_id": row.source_id, "source_no": row.source_no},
            )
            for row in rows[:100]
        )
        return SavingsInsightCandidateRead(
            candidate_key=candidate_key,
            insight_type="budget_forecast_variance",
            title=f"{allocation.department_name} {allocation.subject_name} 预算预测偏差",
            description=(
                f"按当前期间进度和已核销金额推算，期末支出约 {projected}，"
                f"预算额度为 {total_budget}。该差额仅表示超支暴露，不代表可节省金额。"
            ),
            dimension_json={
                "department_id": allocation.department_id or "",
                "department_name": allocation.department_name,
                "cost_center": allocation.cost_center or "",
                "project_code": allocation.project_code or "",
                "subject_code": allocation.subject_code,
                "period_key": allocation.period_key,
                "consumed_amount": str(consumed),
                "projected_amount": str(projected),
                "total_budget": str(total_budget),
                "elapsed_ratio": str(elapsed_ratio),
            },
            evidence=evidence,
            evidence_sufficient_for_signal=True,
            data_quality_status="partial",
            quality_issues=[
                SavingsAnalysisQualityIssue(
                    code="forecast_exposure_not_savings",
                    message="没有可执行的费用削减反事实，预测超支暴露不得计入节省台账。",
                    metadata={"transaction_count": len(rows)},
                ),
                SavingsAnalysisQualityIssue(
                    code="budget_currency_unavailable",
                    message="旧预算事实没有币种字段，暴露金额不标注或推断币种。",
                ),
            ],
            exposure_amount=exposure,
            exposure_meaning="projected_spend_above_budget",
            currency=None,
            estimated_savings=None,
            monetization_status="withheld_no_counterfactual",
        )
