#!/usr/bin/env python3
"""
winston_scheduler.py — Winston pipeline background scheduler (DVI-816).

Runs as a daemon thread registered by app.py at startup. Every minute it
checks all enabled Winston schedules and fires the pipeline for any that are
due, subject to idempotency key (schedule_id, scheduler_run_date).

Date logic by profile
---------------------
  prev_biz   Previous business day. On Mondays also covers Sat + Sun.
             Weekdays only (Sat/Sun produce no targets → schedule skips).
  today      Today's date. Weekdays only (Sat/Sun skipped).

Default seeds
-------------
On first startup with an absent or empty winston_schedules.json the scheduler
seeds three canonical profiles: 7am (prev_biz), 11am (today), 3pm (today).
"""

import json
import logging
import threading
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo

log = logging.getLogger("togen.winston_scheduler")

_BASE_DIR         = Path(__file__).resolve().parent
_SCHEDULES_FILE   = _BASE_DIR / "winston_schedules.json"
_SETTINGS_FILE    = _BASE_DIR / "winston_settings.json"

# Business timezone the schedule hours are expressed in. The server runs in UTC,
# but admins set schedule times in local business hours, so the scan must be
# evaluated in this zone (DVI-895). iCast operates in Indiana (Eastern); override
# via "schedule_timezone" in winston_settings.json. Uses a tz name (not a fixed
# offset) so DST is handled automatically.
_DEFAULT_TIMEZONE = "America/Indiana/Indianapolis"
_SCHED_LOG_FILE   = _BASE_DIR / "winston_scheduler_log.json"
_RUN_RECORDS_FILE = _BASE_DIR / "winston_run_records.json"
_MAX_LOG_ENTRIES  = 500
_MAX_RUN_RECORDS  = 100

_SCHED_LOG_LOCK = threading.Lock()
# Shared lock for winston_run_records.json — app.py imports this to coordinate.
run_records_lock = threading.Lock()


# ---------------------------------------------------------------------------
# Default seed schedules
# ---------------------------------------------------------------------------

_DEFAULT_SCHEDULES = [
    {
        "id": "default-7am",
        "name": "7am Run",
        "cadence": "weekdays",
        "custom_days": None,
        "hour": 7,
        "minute": 0,
        "profile": "prev_biz",
        "enabled": True,
    },
    {
        "id": "default-11am",
        "name": "11am Run",
        "cadence": "weekdays",
        "custom_days": None,
        "hour": 11,
        "minute": 0,
        "profile": "today",
        "enabled": True,
    },
    {
        "id": "default-3pm",
        "name": "3pm Run",
        "cadence": "weekdays",
        "custom_days": None,
        "hour": 15,
        "minute": 0,
        "profile": "today",
        "enabled": True,
    },
]


# ---------------------------------------------------------------------------
# Schedule I/O
# ---------------------------------------------------------------------------

def _schedule_timezone() -> str:
    """Business timezone the schedule hours are interpreted in."""
    if _SETTINGS_FILE.is_file():
        try:
            data = json.loads(_SETTINGS_FILE.read_text())
            if isinstance(data, dict):
                tz = data.get("schedule_timezone")
                if isinstance(tz, str) and tz.strip():
                    return tz.strip()
        except (json.JSONDecodeError, OSError):
            pass
    return _DEFAULT_TIMEZONE


def _now_local() -> datetime:
    """Current time in the business timezone (schedule hours are local)."""
    try:
        return datetime.now(ZoneInfo(_schedule_timezone()))
    except Exception:
        log.exception("Invalid schedule timezone %r — falling back to system time",
                      _schedule_timezone())
        return datetime.now()


def _global_dry_run() -> bool:
    """Global Dry Run master switch (DVI-908).

    When enabled in winston_settings.json every scheduled run executes in
    dry-run mode (no ClickUp/SharePoint/draft writes), regardless of the
    per-schedule flag.
    """
    if _SETTINGS_FILE.is_file():
        try:
            data = json.loads(_SETTINGS_FILE.read_text())
            if isinstance(data, dict):
                return bool(data.get("dry_run"))
        except (json.JSONDecodeError, OSError):
            pass
    return False


def _load_schedules() -> list:
    if _SCHEDULES_FILE.is_file():
        try:
            data = json.loads(_SCHEDULES_FILE.read_text())
            if isinstance(data, list):
                return [s for s in data if isinstance(s, dict)]
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _save_schedules(schedules: list) -> None:
    _SCHEDULES_FILE.write_text(json.dumps(schedules, indent=2))


def seed_schedules_if_empty() -> None:
    """Write the three default schedules when none exist yet."""
    if _load_schedules():
        return
    now = datetime.utcnow().isoformat()
    seeded = []
    for s in _DEFAULT_SCHEDULES:
        entry = dict(s)
        entry["created_at"] = now
        entry["updated_at"] = now
        seeded.append(entry)
    _save_schedules(seeded)
    log.info("Seeded %d default Winston schedules.", len(seeded))


# ---------------------------------------------------------------------------
# Idempotency log
# ---------------------------------------------------------------------------

def _load_sched_log() -> list:
    if _SCHED_LOG_FILE.is_file():
        try:
            data = json.loads(_SCHED_LOG_FILE.read_text())
            return data if isinstance(data, list) else []
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _already_ran(schedule_id: str, scheduler_run_date: str) -> bool:
    return any(
        e.get("schedule_id") == schedule_id and
        e.get("scheduler_run_date") == scheduler_run_date
        for e in _load_sched_log()
    )


def _record_ran(schedule_id: str, scheduler_run_date: str, run_ids: list) -> None:
    with _SCHED_LOG_LOCK:
        entries = _load_sched_log()
        entries.append({
            "schedule_id": schedule_id,
            "scheduler_run_date": scheduler_run_date,
            "run_ids": run_ids,
            "fired_at": datetime.utcnow().isoformat(),
        })
        entries = entries[-_MAX_LOG_ENTRIES:]
        try:
            _SCHED_LOG_FILE.write_text(json.dumps(entries, indent=2))
        except OSError:
            log.exception("Failed to persist %s", _SCHED_LOG_FILE)


# ---------------------------------------------------------------------------
# Date logic
# ---------------------------------------------------------------------------

def target_dates(profile: str, today: date) -> list:
    """Return pipeline target date strings for this profile + today.

    Returns [] when the weekday guard rules out firing (e.g. profile=prev_biz
    on a Saturday means no prior business day was missed — the 7am run never
    fires on weekends).
    """
    wd = today.weekday()  # 0=Mon … 6=Sun
    if profile == "prev_biz":
        if wd == 0:    # Monday: cover Sat + Sun
            return [
                (today - timedelta(days=2)).isoformat(),  # Saturday
                (today - timedelta(days=1)).isoformat(),  # Sunday
            ]
        elif 1 <= wd <= 4:  # Tue–Fri: yesterday
            return [(today - timedelta(days=1)).isoformat()]
        # Sat/Sun: no target
        return []
    # "today" profile (11am, 3pm, or any custom)
    if wd >= 5:  # Sat/Sun: skip
        return []
    return [today.isoformat()]


def _is_schedule_day(schedule: dict, today: date) -> bool:
    cadence = schedule.get("cadence", "daily")
    wd = today.weekday()
    if cadence == "daily":
        return True
    if cadence == "weekdays":
        return wd <= 4
    if cadence == "custom":
        days = schedule.get("custom_days") or []
        return wd in days
    return False


# ---------------------------------------------------------------------------
# Run record helpers — READ-ONLY migration shim (DVI-825).
# New runs persist to bot_diagnostics.db via DiagnosticRun in winston_pipeline.
# TODO(next-release): retire _RUN_RECORDS_FILE and these helpers entirely.
# ---------------------------------------------------------------------------

def _load_run_records() -> list:
    """Read legacy winston_run_records.json (read-only after DVI-825)."""
    if _RUN_RECORDS_FILE.is_file():
        try:
            data = json.loads(_RUN_RECORDS_FILE.read_text())
            if isinstance(data, list):
                return [r for r in data if isinstance(r, dict)]
        except (json.JSONDecodeError, OSError):
            pass
    return []


# ---------------------------------------------------------------------------
# Pipeline execution (runs in a background thread per target date)
# ---------------------------------------------------------------------------

def _pipeline_thread(schedule_name: str, target_date: str, run_id: str, trigger: str,
                     pipeline_id: str = "", dry_run: bool = False) -> None:
    from togen import winston_pipeline as _wp
    log.info("Winston scheduler: pipeline start date=%s run_id=%s schedule=%r dry_run=%s",
             target_date, run_id, schedule_name, dry_run)
    try:
        summary = _wp.run(target_date, trigger=trigger, run_id=run_id,
                          pipeline_id=pipeline_id or None, dry_run=dry_run)
        status = "error" if (summary.get("error") or summary.get("config_errors")) else "done"
        log.info("Winston scheduler: pipeline done date=%s run_id=%s status=%s",
                 target_date, run_id, status)
    except Exception:
        log.exception("Winston scheduler: pipeline error date=%s run_id=%s", target_date, run_id)
    finally:
        _wp._clear_cancel(run_id)


def _fire_schedule(schedule: dict, today: date) -> list:
    """Start pipeline background threads for all target dates. Returns list of run_ids."""
    import uuid

    profile = schedule.get("profile", "today")
    try:
        from togen import winston_date_logic as _wdl
        base_rule = _wdl.resolve_base_rule(profile)
    except Exception:
        base_rule = profile  # fallback: treat stored value as the rule
    dates = target_dates(base_rule, today)
    if not dates:
        log.info("Winston scheduler: schedule %r — no target dates (profile=%s today=%s)",
                 schedule.get("name"), profile, today)
        return []

    # Effective dry-run: the global master switch forces every run to dry-run;
    # otherwise honor this schedule's own dry_run flag (DVI-908).
    dry_run = _global_dry_run() or bool(schedule.get("dry_run"))

    run_ids = []
    for target_date in dates:
        run_id = str(uuid.uuid4())
        trigger = f"scheduler:{schedule.get('id', '')}"
        run_ids.append(run_id)

        t = threading.Thread(
            target=_pipeline_thread,
            args=(schedule.get("name", ""), target_date, run_id, trigger, profile, dry_run),
            daemon=True,
        )
        t.start()

    return run_ids


# ---------------------------------------------------------------------------
# Scan (called every minute)
# ---------------------------------------------------------------------------

def scan(now: datetime | None = None) -> None:
    """Fire any enabled schedules whose time-of-day matches now.

    ``now`` is evaluated in the business timezone (schedule hours are local
    business hours, not the server's UTC), so a 07:00 schedule fires at 7am
    local rather than 7am UTC (DVI-895).
    """
    now = now or _now_local()
    today = now.date()
    today_str = today.isoformat()

    for schedule in _load_schedules():
        if not schedule.get("enabled"):
            continue
        sid = schedule.get("id")
        if not sid:
            continue
        if not _is_schedule_day(schedule, today):
            continue
        if schedule.get("hour") != now.hour or schedule.get("minute") != now.minute:
            continue
        if _already_ran(sid, today_str):
            log.debug("Winston scheduler: schedule %r already ran %s — skip.",
                      schedule.get("name"), today_str)
            continue

        log.info("Winston scheduler: schedule %r due at %02d:%02d on %s — firing.",
                 schedule.get("name"), now.hour, now.minute, today_str)
        run_ids = _fire_schedule(schedule, today)
        _record_ran(sid, today_str, run_ids)


# ---------------------------------------------------------------------------
# Background loop entry point (called from app.py at startup)
# ---------------------------------------------------------------------------

def start_loop() -> threading.Thread:
    """Start the scheduler daemon thread and return it."""

    def _loop():
        seed_schedules_if_empty()
        while True:
            time.sleep(60)
            try:
                scan()
            except Exception:
                log.exception("Winston scheduler scan error")

    t = threading.Thread(target=_loop, daemon=True, name="winston-scheduler")
    t.start()
    log.info("Winston scheduler loop started.")
    return t
