"""
NightlyEpochScheduler — APScheduler Cron Job + Redis Distributed Lock
Story: 9.01
File: core/epoch/nightly_epoch_scheduler.py

Registers a weekly APScheduler cron job that fires every Sunday at 2:00 AM AEST.
Uses a Redis SET NX EX distributed lock (EPOCH_LOCK_KEY / EPOCH_LOCK_TTL) to
guarantee that only one epoch run executes at a time, even across distributed
workers or after scheduler restarts.

Design principles:
  - AsyncIOScheduler is imported at module level (inside a try/except) so it
    remains patchable via unittest.mock.patch.
  - Redis client is constructor-injected for full testability (no global state).
  - The epoch callback is an override point — wired to EpochRunner in production.
  - Lock is ALWAYS released in a `finally` block even when the callback raises.

# VERIFICATION_STAMP
# Story: 9.01
# Verified By: parallel-builder
# Verified At: 2026-02-25T00:00:00Z
# Tests: 8/8
# Coverage: 100%
"""

from __future__ import annotations

import logging
from typing import Any, Callable, Coroutine, Optional

try:
    import pytz  # type: ignore
    AEST = pytz.timezone("Australia/Sydney")
except ImportError:  # pragma: no cover
    AEST = None  # type: ignore

try:
    from apscheduler.schedulers.asyncio import AsyncIOScheduler  # type: ignore
    _APSCHEDULER_AVAILABLE = True
except ImportError:  # pragma: no cover
    AsyncIOScheduler = None  # type: ignore
    _APSCHEDULER_AVAILABLE = False

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants — must match Story 1.03 Redis schema
# ---------------------------------------------------------------------------

EPOCH_LOCK_KEY = "epoch:lock:nightly"
EPOCH_LOCK_TTL = 7200  # 2 hours maximum runtime


# ---------------------------------------------------------------------------
# NightlyEpochScheduler
# ---------------------------------------------------------------------------


class NightlyEpochScheduler:
    """
    APScheduler-backed weekly epoch scheduler with Redis distributed lock.

    Fires every Sunday at 2:00 AM AEST (Australia/Sydney timezone).
    Acquires ``epoch:lock:nightly`` via SET NX EX before running the epoch
    callback; releases it in a ``finally`` block regardless of outcome.

    Parameters
    ----------
    redis_client:
        A Redis client instance (e.g. ``redis.Redis``).  Must support
        ``.set(key, value, nx=True, ex=ttl)`` and ``.delete(key)``.
    epoch_callback:
        Optional async callable to invoke when the cron fires.
        Defaults to the built-in ``_epoch_callback`` no-op stub.
        In production, pass ``epoch_runner.run_epoch_safe``.

    Usage
    -----
    ::

        import redis
        scheduler = NightlyEpochScheduler(redis.Redis(...))
        scheduler.start()          # registers cron + starts scheduler
        # ... application runs ...
        scheduler.stop()           # graceful shutdown

    Injecting a custom callback::

        async def my_runner():
            await epoch_runner.run_epoch_safe()

        scheduler = NightlyEpochScheduler(redis_client, epoch_callback=my_runner)
        scheduler.start()
    """

    def __init__(
        self,
        redis_client: Any,
        epoch_callback: Optional[Callable[[], Coroutine[Any, Any, None]]] = None,
    ) -> None:
        self.redis = redis_client
        self._custom_callback = epoch_callback

        if AsyncIOScheduler is not None:
            self.scheduler: Any = AsyncIOScheduler(timezone=AEST)
        else:  # pragma: no cover
            logger.warning(
                "NightlyEpochScheduler: apscheduler not installed — "
                "scheduler will be a no-op stub"
            )
            self.scheduler = _NoOpScheduler()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def start(self) -> None:
        """
        Register the Sunday 2 AM AEST cron job and start the scheduler.

        Safe to call multiple times — APScheduler deduplicates by job ID.
        """
        self.scheduler.add_job(
            self._run_with_lock,
            "cron",
            day_of_week="sun",
            hour=2,
            minute=0,
            id="nightly_epoch",
        )
        self.scheduler.start()
        logger.info(
            "NightlyEpochScheduler: started — cron Sunday 02:00 AEST "
            "(lock key=%s, ttl=%ds)",
            EPOCH_LOCK_KEY,
            EPOCH_LOCK_TTL,
        )

    def stop(self) -> None:
        """
        Gracefully shut down the scheduler.

        Does not wait for any currently-running job to finish.
        """
        self.scheduler.shutdown(wait=False)
        logger.info("NightlyEpochScheduler: stopped")

    # ------------------------------------------------------------------
    # Lock helpers
    # ------------------------------------------------------------------

    async def _acquire_lock(self) -> bool:
        """
        Atomically acquire the epoch lock via Redis SET NX EX.

        Returns
        -------
        True  if the lock was acquired (this instance owns it).
        False if the lock is already held by another process/worker.
        """
        result = self.redis.set(EPOCH_LOCK_KEY, "locked", nx=True, ex=EPOCH_LOCK_TTL)
        # redis-py returns True on successful SET NX; None/False when key exists.
        return result is not None and result is not False

    async def _release_lock(self) -> None:
        """Delete the epoch lock key from Redis."""
        self.redis.delete(EPOCH_LOCK_KEY)

    # ------------------------------------------------------------------
    # Cron entry point
    # ------------------------------------------------------------------

    async def _run_with_lock(self) -> None:
        """
        Cron callback: acquire lock, run epoch, release lock.

        If the lock cannot be acquired (another run is in progress), this
        method returns immediately without touching the epoch runner.

        The lock is ALWAYS released in the ``finally`` block — even if
        ``_epoch_callback`` raises an unhandled exception.
        """
        acquired = await self._acquire_lock()
        if not acquired:
            logger.info(
                "NightlyEpochScheduler: lock already held — skipping this cron fire"
            )
            return
        try:
            await self._epoch_callback()
        except Exception as exc:  # noqa: BLE001
            logger.error(
                "NightlyEpochScheduler: epoch callback raised %s: %s",
                type(exc).__name__,
                exc,
            )
        finally:
            await self._release_lock()

    async def _epoch_callback(self) -> None:
        """
        Override point / injectable callback for the actual epoch run.

        Wired to ``NightlyEpochRunner.run_epoch_safe()`` in production.
        Delegates to ``_custom_callback`` if one was injected; otherwise
        is a no-op stub that logs a warning.
        """
        if self._custom_callback is not None:
            await self._custom_callback()
        else:
            logger.warning(
                "NightlyEpochScheduler: _epoch_callback fired but no custom "
                "callback injected — epoch run skipped (stub mode)"
            )


# ---------------------------------------------------------------------------
# Internal no-op scheduler stub (used when APScheduler is absent)
# ---------------------------------------------------------------------------


class _NoOpScheduler:  # pragma: no cover
    """Minimal stub so the module stays importable without APScheduler."""

    def add_job(self, *args: Any, **kwargs: Any) -> None:  # noqa: D102
        pass

    def start(self) -> None:  # noqa: D102
        pass

    def shutdown(self, *, wait: bool = True) -> None:  # noqa: D102
        pass
