"""When should expensive AI jobs fire?

Markets matter. The scheduler wakes every hour, but there's no point spending
OpenRouter tokens at 03:00 UTC on a Sunday when nothing has moved. This module
encodes a single policy: weekday active hours (LSE open through NYSE close,
roughly 07:00-21:00 UTC) get the full hourly cadence; off-hours and weekends
get throttled.

Used by ai_log_job and indicator_summary_job to decide whether to run NOW or
skip until enough time has passed since the last successful run. Market /
news / portfolio ingestion jobs keep running hourly — they're cheap.
"""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone


@dataclass(frozen=True)
class CadencePolicy:
    # Active trading windows in UTC. A timestamp is "active" if its hour
    # falls in ANY listed window. Add or remove tuples to change coverage.
    #
    # LSE opens 07:00 BST → 07:00 UTC summer / 08:00 UTC winter.
    # NYSE closes 16:00 ET → 21:00 UTC summer / 21:00 UTC winter.
    # Tokyo trades 09:00-15:00 JST → 00:00-06:00 UTC.
    # HK/Shanghai trade 09:30-16:00 local → 01:30-08:00 UTC.
    active_windows: tuple[tuple[int, int], ...] = (
        (7, 21),    # EU/US (LSE open through NYSE close)
        # (0, 8),   # Asia (Tokyo + HK/Shanghai) — uncomment to add
    )
    # Minimum gap between successful runs DURING the active window. The
    # cron may fire more frequently than this — we just skip until enough
    # time has passed since the last success. Default 0 means "run on
    # every cron fire" (the original AI-job behaviour).
    active_gap_h: float = 0.0
    # Minimum gap between successful runs outside the active window.
    off_hours_gap_h: float = 4.0
    weekend_gap_h: float = 12.0

    def is_active_window(self, now: datetime | None = None) -> bool:
        now = now or datetime.now(timezone.utc)
        if now.weekday() >= 5:  # Saturday / Sunday
            return False
        return any(start <= now.hour < end for start, end in self.active_windows)

    def min_gap_hours(self, now: datetime | None = None) -> float:
        now = now or datetime.now(timezone.utc)
        if now.weekday() >= 5:
            return self.weekend_gap_h
        if self.is_active_window(now):
            return self.active_gap_h
        return self.off_hours_gap_h

    def should_run(
        self,
        last_success_at: datetime | None,
        now: datetime | None = None,
    ) -> tuple[bool, str]:
        """Returns (should_run, reason). The reason is human-readable for logs
        and the job_runs.error column when a run is skipped."""
        now = now or datetime.now(timezone.utc)
        min_gap = self.min_gap_hours(now)
        if last_success_at is None:
            return True, "no prior successful run"
        # Normalise tz; DB returns naive but we treat it as UTC.
        if last_success_at.tzinfo is None:
            last_success_at = last_success_at.replace(tzinfo=timezone.utc)
        age_h = (now - last_success_at).total_seconds() / 3600.0
        if min_gap <= 0 and self.is_active_window(now):
            return True, "active window"
        if age_h >= min_gap:
            band = "active" if self.is_active_window(now) else (
                "weekend" if now.weekday() >= 5 else "off-hours"
            )
            return True, f"{band}: last run {age_h:.2f}h ago (≥ {min_gap:.2f}h)"
        band = "active" if self.is_active_window(now) else (
            "weekend" if now.weekday() >= 5 else "off-hours"
        )
        return False, f"{band} throttled — last run {age_h:.2f}h ago (< {min_gap:.2f}h)"


# AI jobs: run hot during the active window, throttle off-hours.
DEFAULT_POLICY = CadencePolicy()

# News + tagging: 3 runs/hour during the active window (20-min gap),
# every 3h off-hours, every 2h on weekends. Cron fires every 20 min;
# the policy gates whether each fire actually does work.
NEWS_POLICY = CadencePolicy(
    active_gap_h=1.0 / 3.0,    # 20 minutes
    off_hours_gap_h=3.0,
    weekend_gap_h=2.0,
)
