"""Stale-code cron tick yield gate.

A long-lived process whose checkout was updated underneath it (hot git pull /
interrupted ``hermes update``) serves mixed ``sys.modules``; when such a
process races a fresh gateway for the cron tick lock and wins, every agent
job it dispatches can die on ImportErrors whose real cause is staleness.

The gate in ``cron.scheduler.tick`` (before lock acquisition) yields — raises
``CronTickYielded`` — only when ALL of:

1. this process is provably stale (boot fingerprint ≠ disk revision),
2. it does NOT own the gateway runtime lock, and
3. some other process currently holds that lock.

Any probe failure or missing fingerprint means "proceed" (fail-open). Yielded
ticks must surface as failed ticks (``record_ticker_error`` + heartbeat
``success=False``), never as healthy ones.
"""

from __future__ import annotations

import logging
import threading
import time
from unittest.mock import patch

import pytest

import cron.scheduler as scheduler_mod


SKEW = ("boot0123abcd", "disk4567efgh")


def _wait_until(predicate, timeout=10.0, interval=0.005):
    """Block until ``predicate()`` is truthy or ``timeout`` elapses."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        value = predicate()
        if value:
            return value
        time.sleep(interval)
    return predicate()


def _gate_mocks(monkeypatch, *, owns: bool, active: bool, skew=SKEW):
    """Point the yield gate's three probes at fixed answers."""
    from gateway import status as gateway_status

    monkeypatch.setattr(scheduler_mod, "_detect_gateway_code_skew", lambda: skew)
    monkeypatch.setattr(gateway_status, "owns_gateway_runtime_lock", lambda: owns)
    monkeypatch.setattr(
        gateway_status, "is_gateway_runtime_lock_active", lambda lock_path=None: active
    )


class TestTickYieldGate:
    def test_skew_plus_foreign_gateway_yields_before_lock(self, monkeypatch, tmp_path):
        """(a) skew + a fresh foreign gateway holds the runtime lock → the
        tick yields WITHOUT acquiring the tick lock or dispatching."""
        _gate_mocks(monkeypatch, owns=False, active=True)

        def _no_lock_allowed(*_a, **_kw):
            raise AssertionError("tick lock must not be acquired after a yield")

        monkeypatch.setattr(scheduler_mod, "_get_lock_paths", _no_lock_allowed)

        with pytest.raises(scheduler_mod.CronTickYielded) as excinfo:
            scheduler_mod.tick(verbose=False)
        # The exception carries the forensics for the ticker-error record.
        assert SKEW[0] in str(excinfo.value)
        assert SKEW[1] in str(excinfo.value)

    def test_skew_plus_self_owned_lock_proceeds(self, monkeypatch, tmp_path):
        """(b) skew + this process IS the gateway → proceed. The delivery
        path's stale-code hint (part 3) stays the surface for this case."""
        _gate_mocks(monkeypatch, owns=True, active=True)
        # Reaching the lock paths proves the gate let the tick proceed.
        called = []
        real = scheduler_mod._get_lock_paths

        def _spy():
            called.append(1)
            return real()

        monkeypatch.setattr(scheduler_mod, "_get_lock_paths", _spy)
        assert scheduler_mod.tick(verbose=False) == 0  # empty store: no jobs
        assert called, "tick must proceed when this process owns the gateway lock"

    def test_skew_plus_no_gateway_proceeds(self, monkeypatch, tmp_path):
        """(c) skew + no gateway alive (desktop-standalone) → proceed:
        yielding would silently kill the user's only ticker."""
        _gate_mocks(monkeypatch, owns=False, active=False)
        assert scheduler_mod.tick(verbose=False) == 0

    def test_no_fingerprint_proceeds(self, monkeypatch, tmp_path):
        """(d) skew is None (non-git install, no boot fingerprint, probe
        failure) → always proceed."""
        _gate_mocks(monkeypatch, owns=False, active=True, skew=None)
        assert scheduler_mod.tick(verbose=False) == 0

    def test_lock_probe_exception_proceeds(self, monkeypatch, tmp_path):
        """Fail-open: the lock probe raising must not yield or crash the
        tick."""
        from gateway import status as gateway_status

        monkeypatch.setattr(scheduler_mod, "_detect_gateway_code_skew", lambda: SKEW)

        def _boom(lock_path=None):
            raise OSError("lock probe failed")

        monkeypatch.setattr(gateway_status, "is_gateway_runtime_lock_active", _boom)
        assert scheduler_mod.tick(verbose=False) == 0

    def test_yield_logs_once_per_episode(self, monkeypatch, caplog):
        """The yield log is throttled: repeated yields with the same skew
        signature log once, not once per tick interval."""
        _gate_mocks(monkeypatch, owns=False, active=True)
        with caplog.at_level(logging.ERROR, logger="cron.scheduler"):
            scheduler_mod._log_tick_yield_once("boot=a disk=b")
            scheduler_mod._log_tick_yield_once("boot=a disk=b")
            scheduler_mod._log_tick_yield_once("boot=a disk=b")
        yield_logs = [
            r for r in caplog.records if "Cron tick yielded" in r.getMessage()
        ]
        assert len(yield_logs) == 1
        # A NEW skew signature (the checkout moved again) is a new episode.
        with caplog.at_level(logging.ERROR, logger="cron.scheduler"):
            scheduler_mod._log_tick_yield_once("boot=a disk=c")
        yield_logs = [
            r for r in caplog.records if "Cron tick yielded" in r.getMessage()
        ]
        assert len(yield_logs) == 2


class TestYieldedTickIsAFailedTick:
    """(e) A yielded tick must not be recorded as a healthy tick."""

    def test_provider_records_error_and_unsuccessful_heartbeat(self):
        from cron.scheduler_provider import InProcessCronScheduler

        beats: list[bool] = []
        errors: list[str] = []
        stop = threading.Event()
        prov = InProcessCronScheduler()

        with patch(
            "cron.scheduler.tick",
            side_effect=scheduler_mod.CronTickYielded(SKEW[0], SKEW[1]),
        ), patch(
            "cron.jobs.record_ticker_heartbeat",
            side_effect=lambda success=False: beats.append(success),
        ), patch(
            "cron.jobs.record_ticker_error",
            side_effect=lambda msg: errors.append(msg),
        ), patch("cron.jobs.clear_ticker_error") as clear:
            t = threading.Thread(
                target=prov.start, args=(stop,), kwargs={"interval": 0}, daemon=True
            )
            t.start()
            assert _wait_until(lambda: len(beats) >= 3), "ticker did not keep beating"
            stop.set()
            t.join(timeout=5)

        assert not t.is_alive(), "ticker must keep yielding, not die"
        assert errors, "yield reason must be persisted for `hermes cron status`"
        assert "CronTickYielded" in errors[0] or "yielded" in errors[0]
        assert beats[-1] is False, "a yielded tick is not a successful tick"
        clear.assert_not_called()

    def test_ticker_takes_over_when_gateway_lock_releases(self):
        """Self-healing: once the fresh gateway dies (lock inactive), the
        yielding ticker's next tick proceeds normally."""
        from cron.scheduler_provider import InProcessCronScheduler

        beats: list[bool] = []
        state = {"foreign_gateway_alive": True}
        stop = threading.Event()
        prov = InProcessCronScheduler()

        def _tick(*args, **kwargs):
            # Gate logic is exercised for real; emulate the two probe arms.
            if state["foreign_gateway_alive"]:
                raise scheduler_mod.CronTickYielded(SKEW[0], SKEW[1])
            return 0

        with patch("cron.scheduler.tick", side_effect=_tick), patch(
            "cron.jobs.record_ticker_heartbeat",
            side_effect=lambda success=False: beats.append(success),
        ), patch("cron.jobs.record_ticker_error"), patch("cron.jobs.clear_ticker_error"):
            t = threading.Thread(
                target=prov.start, args=(stop,), kwargs={"interval": 0}, daemon=True
            )
            t.start()
            assert _wait_until(lambda: len(beats) >= 2)
            state["foreign_gateway_alive"] = False  # gateway lock released
            assert _wait_until(lambda: True in beats), "ticker never took over"
            stop.set()
            t.join(timeout=5)

        assert not t.is_alive()


    def test_multiplex_yield_in_one_profile_does_not_cancel_siblings(self, tmp_path):
        """Multiplex loop: a yield for profile A (its fresh gateway owns the
        runtime lock) must not cancel profile B's tick in the same cycle —
        B may have no other ticker. B's heartbeat stays healthy; A's records
        the yield."""
        from cron.scheduler_provider import InProcessCronScheduler

        # The multiplex loop skips profile homes that don't exist on disk
        # (_existing_profile_homes, #47368) — use real directories.
        home_a = str(tmp_path / "home-a")
        home_b = str(tmp_path / "home-b")
        (tmp_path / "home-a").mkdir()
        (tmp_path / "home-b").mkdir()
        ticked: list[str] = []
        per_home_beats: dict[str, list[bool]] = {home_a: [], home_b: []}
        per_home_errors: dict[str, list[str]] = {home_a: [], home_b: []}
        state = {"cycles": 0}
        stop = threading.Event()
        prov = InProcessCronScheduler()

        def _tick(*args, **kwargs):
            from hermes_constants import get_hermes_home

            home = str(get_hermes_home())
            ticked.append(home)
            if home == home_a:
                # Profile A: stale process, fresh foreign gateway → yield.
                raise scheduler_mod.CronTickYielded(SKEW[0], SKEW[1])
            # Profile B ticks fine.
            return 0

        def _beat(success=False):
            from hermes_constants import get_hermes_home

            per_home_beats[str(get_hermes_home())].append(success)

        def _err(msg):
            from hermes_constants import get_hermes_home

            per_home_errors[str(get_hermes_home())].append(msg)

        with patch("cron.scheduler.tick", side_effect=_tick), patch(
            "cron.jobs.record_ticker_heartbeat", side_effect=_beat
        ), patch("cron.jobs.record_ticker_error", side_effect=_err), patch(
            "cron.jobs.clear_ticker_error"
        ):
            t = threading.Thread(
                target=prov.start,
                args=(stop,),
                kwargs={
                    "interval": 0,
                    "profile_homes": [home_a, home_b],
                },
                daemon=True,
            )
            t.start()
            assert _wait_until(lambda: len(ticked) >= 6), "multiplex loop did not run"
            stop.set()
            t.join(timeout=5)

        assert not t.is_alive()
        # Every cycle ticked BOTH homes — B was never cancelled by A's yield.
        assert home_b in ticked and home_a in ticked
        # A recorded unsuccessful beats + its yield reason; B stayed healthy.
        # (The first beat per home is the pre-loop initial heartbeat, always
        # unsuccessful — skip it when judging cycle outcomes.)
        assert per_home_beats[home_a], "profile A must still beat (liveness)"
        assert all(b is False for b in per_home_beats[home_a][1:])
        assert per_home_errors[home_a], "profile A must record the yield reason"
        assert per_home_beats[home_b][1:] and all(
            b is True for b in per_home_beats[home_b][1:]
        ), "profile B must stay healthy"
        assert not per_home_errors[home_b], "profile B must not inherit A's yield"


class TestGatewayLockOwnershipProbe:
    """The self-ownership accessor backing the gate, against the real lock."""

    def test_ownership_follows_acquire_and_release(self, tmp_path, monkeypatch):
        from gateway import status as gateway_status

        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
        assert gateway_status.owns_gateway_runtime_lock() is False
        assert gateway_status.acquire_gateway_runtime_lock() is True
        try:
            assert gateway_status.owns_gateway_runtime_lock() is True
            # Self-ownership is distinguishable even though the shared
            # liveness probe also reports active for the owner.
            assert gateway_status.is_gateway_runtime_lock_active() is True
        finally:
            gateway_status.release_gateway_runtime_lock()
        assert gateway_status.owns_gateway_runtime_lock() is False

    def test_ownership_probe_is_profile_scoped_by_process_home(
        self, tmp_path, monkeypatch
    ):
        """The gateway lock is a process-level identity file (#56986): under
        a multiplex profile-home override the probe must still resolve the
        launch home's lock, not the overridden profile's."""
        from gateway import status as gateway_status
        from hermes_constants import (
            reset_hermes_home_override,
            set_hermes_home_override,
        )

        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
        other_profile = tmp_path / "profiles" / "other"
        other_profile.mkdir(parents=True)
        token = set_hermes_home_override(str(other_profile))
        try:
            assert gateway_status.acquire_gateway_runtime_lock() is True
            try:
                assert gateway_status.owns_gateway_runtime_lock() is True
                # The lock file lives in the launch home, not the override.
                assert (tmp_path / "gateway.lock").exists()
                assert not (other_profile / "gateway.lock").exists()
            finally:
                gateway_status.release_gateway_runtime_lock()
        finally:
            reset_hermes_home_override(token)
