from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from typing import Literal

FreshnessStatus = Literal["fresh", "stale", "partial", "unavailable", "unknown"]
FreshnessSourceKind = Literal[
    "generic",
    "market",
    "crypto_24_7",
    "bank_balance",
    "managed_portfolio",
]

DEFAULT_FRESHNESS_MAX_AGE = timedelta(days=2)
BANK_BALANCE_MAX_AGE = timedelta(days=7)
MANAGED_PORTFOLIO_MAX_AGE = timedelta(days=31)
CRYPTO_MAX_AGE = timedelta(days=2)
MARKET_MAX_SESSION_LAG = 1


@dataclass(frozen=True)
class FreshnessAssessment:
    status: FreshnessStatus
    reason_code: str
    expected_as_of: str | None
    source_kind: FreshnessSourceKind


def parse_source_time(value: str | None) -> datetime | None:
    """Parse a source date/time as UTC without substituting a receive time."""
    if not value:
        return None
    raw = value.strip()
    try:
        if len(raw) == 10:
            return datetime.combine(date.fromisoformat(raw), datetime.min.time(), tzinfo=UTC)
        parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError:
        return None
    return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)


def _easter_sunday(year: int) -> date:
    """Anonymous Gregorian algorithm, used only for the SIX holiday calendar."""
    a = year % 19
    b = year // 100
    c = year % 100
    d = b // 4
    e = b % 4
    f = (b + 8) // 25
    g = (b - f + 1) // 3
    h = (19 * a + b - d - g + 15) % 30
    i = c // 4
    k = c % 4
    length = (32 + 2 * e + 2 * i - h - k) % 7
    m = (a + 11 * h + 22 * length) // 451
    month = (h + length - 7 * m + 114) // 31
    day = (h + length - 7 * m + 114) % 31 + 1
    return date(year, month, day)


def six_market_holidays(year: int) -> frozenset[date]:
    """Deterministic Swiss Stock Exchange full-day closures.

    The policy mirrors the recurring SIX holiday calendar. Weekend dates are
    harmless because market-day evaluation excludes weekends independently.
    """
    easter = _easter_sunday(year)
    return frozenset(
        {
            date(year, 1, 1),
            date(year, 1, 2),
            easter - timedelta(days=2),
            easter + timedelta(days=1),
            date(year, 5, 1),
            easter + timedelta(days=39),
            easter + timedelta(days=50),
            date(year, 8, 1),
            date(year, 12, 24),
            date(year, 12, 25),
            date(year, 12, 26),
            date(year, 12, 31),
        }
    )


def _is_market_day(day: date) -> bool:
    return day.weekday() < 5 and day not in six_market_holidays(day.year)


def _latest_market_day(reference: date) -> date:
    candidate = reference
    while not _is_market_day(candidate):
        candidate -= timedelta(days=1)
    return candidate


def _market_sessions_after(source: date, expected: date) -> int:
    if source >= expected:
        return 0
    sessions = 0
    cursor = source + timedelta(days=1)
    while cursor <= expected:
        if _is_market_day(cursor):
            sessions += 1
        cursor += timedelta(days=1)
    return sessions


def assess_freshness(
    *,
    available: bool,
    as_of: str | None,
    now: datetime | None = None,
    source_kind: FreshnessSourceKind = "generic",
    max_age: timedelta | None = None,
) -> FreshnessAssessment:
    if not available:
        return FreshnessAssessment("unavailable", "value_unavailable", None, source_kind)
    source_time = parse_source_time(as_of)
    if source_time is None:
        return FreshnessAssessment("unknown", "business_date_missing", None, source_kind)
    reference = now or datetime.now(UTC)
    reference = (
        reference.replace(tzinfo=UTC)
        if reference.tzinfo is None
        else reference.astimezone(UTC)
    )
    if source_time > reference:
        return FreshnessAssessment(
            "unknown", "source_date_in_future", reference.date().isoformat(), source_kind
        )

    if source_kind == "market":
        expected = _latest_market_day(reference.date())
        lag = _market_sessions_after(source_time.date(), expected)
        return FreshnessAssessment(
            "stale" if lag > MARKET_MAX_SESSION_LAG else "fresh",
            "market_session_overdue" if lag > MARKET_MAX_SESSION_LAG else "within_market_session_lag",
            expected.isoformat(),
            source_kind,
        )

    policy_age, fresh_reason, stale_reason = {
        "crypto_24_7": (CRYPTO_MAX_AGE, "within_crypto_update_rhythm", "crypto_update_overdue"),
        "bank_balance": (BANK_BALANCE_MAX_AGE, "within_bank_update_rhythm", "bank_update_overdue"),
        "managed_portfolio": (
            MANAGED_PORTFOLIO_MAX_AGE,
            "within_managed_update_rhythm",
            "managed_update_overdue",
        ),
        "generic": (
            max_age or DEFAULT_FRESHNESS_MAX_AGE,
            "within_generic_age_limit",
            "generic_update_overdue",
        ),
    }[source_kind]
    stale = reference - source_time > policy_age
    return FreshnessAssessment(
        "stale" if stale else "fresh",
        stale_reason if stale else fresh_reason,
        reference.date().isoformat(),
        source_kind,
    )


def freshness_status(
    *,
    available: bool,
    as_of: str | None,
    now: datetime | None = None,
    max_age: timedelta = DEFAULT_FRESHNESS_MAX_AGE,
    source_kind: FreshnessSourceKind = "generic",
) -> FreshnessStatus:
    return assess_freshness(
        available=available,
        as_of=as_of,
        now=now,
        source_kind=source_kind,
        max_age=max_age,
    ).status


def combined_freshness(statuses: list[FreshnessStatus]) -> FreshnessStatus:
    """Collapse read-model sources without claiming availability that is absent."""
    present = [status for status in statuses if status != "unavailable"]
    if not present:
        return "unavailable"
    if len(present) != len(statuses) or "partial" in present:
        return "partial"
    if "stale" in present:
        return "stale"
    if "unknown" in present:
        return "unknown"
    return "fresh"
