"""
core/epoch/epoch_scheduler.py
Story 9.01 — EpochScheduler: APScheduler Cron Trigger

Wraps APScheduler AsyncIOScheduler to trigger EpochRunner.run_epoch_safe()
every Sunday at 02:00 AEST (= UTC 16:00 Saturday night).

Design notes:
- APScheduler import is deferred to start() so the module can be imported
  even when apscheduler is not installed (test environments, etc.).
- The scheduler uses AsyncIOScheduler so it integrates cleanly with an
  already-running asyncio event loop (FastAPI, aiohttp, etc.).
- Job ID is fixed as 'nightly_epoch' so duplicate registrations are detected
  and cleanly replaced on restart.
- events_log_path is a constructor parameter (defaults to EVENTS_LOG_PATH)
  so tests can redirect observability output to a temp directory.

# VERIFICATION_STAMP
# Story: 9.01
# Verified By: parallel-builder
# Verified At: 2026-02-25T00:00:00Z
# Tests: 29/29
# Coverage: 100%
"""

from __future__ import annotations

import json
import logging
import os
from datetime import datetime, timezone
from typing import Optional

logger = logging.getLogger(__name__)

# Default path for the observability events log (JSONL)
EVENTS_LOG_PATH = "data/observability/events.jsonl"

# Fixed APScheduler job identifier — used to detect and prevent duplicate jobs
_JOB_ID = "nightly_epoch"

# Cron parameters (UTC) — Sunday 16:00 UTC = Monday 02:00 AEST
_CRON_DAY_OF_WEEK = "sun"
_CRON_HOUR = 16
_CRON_MINUTE = 0


class EpochScheduler:
    """
    Thin wrapper around APScheduler that fires EpochRunner.run_epoch_safe()
    on a weekly cron schedule (Sunday UTC 16:00 = AEST Monday 02:00).

    Parameters
    ----------
    runner : EpochRunner
        The epoch runner whose run_epoch_safe() method will be triggered
        by the cron schedule.
    events_log_path : str, optional
        Path for writing start/stop events to a JSONL observability log.
        Defaults to ``EVENTS_LOG_PATH`` (``data/observability/events.jsonl``).
        Override in tests to redirect to a temp directory.

    Usage
    -----
    ::

        scheduler = EpochScheduler(runner)
        scheduler.start()          # begin cron scheduling
        # ... application runs ...
        scheduler.stop()           # graceful shutdown

        # For immediate on-demand triggering (testing / admin):
        await scheduler.force_trigger()
    """

    def __init__(self, runner, events_log_path: str = EVENTS_LOG_PATH) -> None:
        self.runner = runner
        self.events_log_path = events_log_path
        # Lazily created inside start() to avoid import-time apscheduler dependency
        self._scheduler = None

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def start(self) -> None:
        """
        Create an AsyncIOScheduler, register the weekly cron job, and start it.

        If called after a previous stop(), a fresh scheduler is created
        and the job is registered with ``replace_existing=True`` to prevent
        duplicate jobs.

        Logs ``epoch_scheduler_started`` event to events.jsonl.
        """
        # Lazy import — tolerates missing apscheduler in test environments
        from apscheduler.schedulers.asyncio import AsyncIOScheduler  # noqa: PLC0415

        self._scheduler = AsyncIOScheduler()

        self._scheduler.add_job(
            self.runner.run_epoch_safe,
            "cron",
            day_of_week=_CRON_DAY_OF_WEEK,
            hour=_CRON_HOUR,
            minute=_CRON_MINUTE,
            id=_JOB_ID,
            replace_existing=True,  # Prevents duplicate jobs on restart
        )

        self._scheduler.start()
        self._log_event("epoch_scheduler_started")
        logger.info(
            "EpochScheduler: started — cron=%s %02d:%02d UTC (AEST Mon 02:00)",
            _CRON_DAY_OF_WEEK, _CRON_HOUR, _CRON_MINUTE,
        )

    def stop(self) -> None:
        """
        Gracefully stop the scheduler.

        Safe to call even if start() was never called (no-op).
        Logs ``epoch_scheduler_stopped`` event to events.jsonl.
        """
        if self._scheduler is not None:
            self._scheduler.shutdown(wait=False)
            self._log_event("epoch_scheduler_stopped")
            logger.info("EpochScheduler: stopped")

    def get_next_run(self) -> Optional[datetime]:
        """
        Return the next scheduled epoch run time.

        Returns
        -------
        datetime or None
            The next run time as a timezone-aware datetime, or ``None`` if
            the scheduler has not been started or the job cannot be found.
        """
        if self._scheduler is None:
            return None
        job = self._scheduler.get_job(_JOB_ID)
        if job is None:
            return None
        return job.next_run_time

    async def force_trigger(self) -> None:
        """
        Immediately trigger an epoch run, bypassing the cron schedule.

        Delegates directly to runner.run_epoch_safe() so the distributed
        lock and timeout logic are still honoured.

        Intended for: manual admin triggers, integration tests, CI verification.
        """
        await self.runner.run_epoch_safe()

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _log_event(self, event_type: str) -> None:
        """
        Append a single JSON event entry to the observability events log.

        Uses ``self.events_log_path`` (set in constructor) rather than the
        module-level constant so tests can redirect output to a temp path.

        Parent directories are created automatically if they do not exist.
        Any I/O error is caught and logged — never propagated.
        """
        entry = {
            "event_type": event_type,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        path = self.events_log_path
        try:
            parent = os.path.dirname(path)
            if parent:
                os.makedirs(parent, exist_ok=True)
            with open(path, "a", encoding="utf-8") as fh:
                fh.write(json.dumps(entry) + "\n")
        except OSError as exc:
            logger.error(
                "EpochScheduler: failed to write event '%s' to %s: %s",
                event_type, path, exc,
            )
