"""Stale-schedule guard on the cron fire path (#93049).

A direct ``jobs.json`` edit that changes ``schedule.expr`` leaves the stored
``next_run_at`` computed under the *old* expression — e.g. editing a daily
``0 7 * * *`` job down to ``0 7 * * 1-5`` keeps a Saturday 07:00
``next_run_at``. The old due check only compared ``next_run_at <= now``, so
the job fired on the day the new expression excludes. The guard re-anchors
``next_run_at`` from the current expression and skips the fire.

These exercise the real store against a temp ``HERMES_HOME`` (no mocks) per
the E2EE-over-mocks discipline for file-touching code.
"""

from __future__ import annotations

from datetime import datetime, timedelta

import pytest


@pytest.fixture
def temp_home(tmp_path, monkeypatch):
    """Isolated HERMES_HOME so jobs.json doesn't touch the real store."""
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    yield tmp_path


def _write_cron_job(schedule_expr: str, next_run_at: datetime) -> str:
    """Persist a cron job with a pinned next_run_at (the stale-edit shape)."""
    from cron.jobs import create_job, save_jobs, load_jobs

    job = create_job(prompt="x", schedule="every 5m", name="t")
    jobs = load_jobs()
    for j in jobs:
        if j["id"] == job["id"]:
            j["schedule"] = {"kind": "cron", "expr": schedule_expr}
            j["next_run_at"] = next_run_at.isoformat()
    save_jobs(jobs)
    return job["id"]


# 2026-08-22 is a Saturday; 2026-08-24 is the following Monday.
_SATURDAY_0700 = datetime.fromisoformat("2026-08-22T07:00:00+00:00")
_MONDAY_0700 = datetime.fromisoformat("2026-08-24T07:00:00+00:00")


def test_stale_next_run_on_excluded_dow_does_not_fire(temp_home, monkeypatch):
    """next_run_at computed under the old daily expr must not fire the new
    Mon-Fri schedule on a Saturday — the job re-anchors to Monday instead."""
    from cron.jobs import get_due_jobs, get_job

    monkeypatch.setattr(
        "cron.jobs._hermes_now", lambda: _SATURDAY_0700 + timedelta(seconds=30)
    )
    jid = _write_cron_job("0 7 * * 1-5", _SATURDAY_0700)

    due = get_due_jobs()

    assert [j["id"] for j in due if j["id"] == jid] == []
    stored = get_job(jid)
    assert stored["next_run_at"].startswith(_MONDAY_0700.isoformat())


def test_matching_next_run_still_fires(temp_home, monkeypatch):
    """Control: the same stored instant under an expression it matches (daily)
    still fires — the guard only blocks schedule-drifted instants."""
    from cron.jobs import get_due_jobs

    monkeypatch.setattr(
        "cron.jobs._hermes_now", lambda: _SATURDAY_0700 + timedelta(seconds=30)
    )
    jid = _write_cron_job("0 7 * * *", _SATURDAY_0700)

    due = get_due_jobs()

    assert jid in [j["id"] for j in due]


def test_manual_trigger_bypasses_stale_schedule_guard(temp_home, monkeypatch):
    """An explicit run-now instant need not occur in the cron expression."""
    from cron.jobs import create_job, get_due_jobs, get_job, mark_job_run, trigger_job

    now = _SATURDAY_0700 + timedelta(seconds=30)
    monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
    job = create_job(prompt="x", schedule="0 7 * * 1-5", name="manual")

    triggered = trigger_job(job["id"])
    due = get_due_jobs()

    assert triggered is not None
    assert job["id"] in [candidate["id"] for candidate in due]
    assert triggered["manual_run_at"] == triggered["next_run_at"]

    mark_job_run(job["id"], success=True)

    assert "manual_run_at" not in get_job(job["id"])


def test_delayed_manual_trigger_is_not_counted_as_catch_up(temp_home, monkeypatch):
    """Run-now intent remains explicit even when the next scan is hours later."""
    from cron.jobs import (
        create_job,
        get_catch_up_occurrence_count,
        get_due_jobs,
        trigger_job,
    )

    trigger_time = _SATURDAY_0700 + timedelta(seconds=30)
    monkeypatch.setattr("cron.jobs._hermes_now", lambda: trigger_time)
    job = create_job(prompt="x", schedule="0 7 * * 1-5", name="delayed")
    trigger_job(job["id"])
    monkeypatch.setattr(
        "cron.jobs._hermes_now", lambda: trigger_time + timedelta(hours=5)
    )

    due = get_due_jobs()

    assert job["id"] in [candidate["id"] for candidate in due]
    assert get_catch_up_occurrence_count() == 0


def test_manual_trigger_survives_timezone_change_before_tick(temp_home, monkeypatch):
    """TZ migration repair must not replace an explicit run-now instant."""
    from cron.jobs import create_job, get_due_jobs, trigger_job

    trigger_time = datetime.fromisoformat("2026-08-22T21:00:00+10:00")
    scan_time = datetime.fromisoformat("2026-08-22T13:00:00+02:00")
    monkeypatch.setattr("cron.jobs._hermes_now", lambda: trigger_time)
    job = create_job(prompt="x", schedule="0 7 * * 1-5", name="tz-manual")
    trigger_job(job["id"])
    monkeypatch.setattr("cron.jobs._hermes_now", lambda: scan_time)

    due = get_due_jobs()

    assert job["id"] in [candidate["id"] for candidate in due]


def test_stale_next_run_skips_even_inside_catchup_window(temp_home, monkeypatch):
    """The catch-up 'run once now' policy must not resurrect a stale instant:
    hours after the stored time, the drifted job re-anchors without firing."""
    from cron.jobs import get_due_jobs, get_job

    monkeypatch.setattr(
        "cron.jobs._hermes_now", lambda: _SATURDAY_0700 + timedelta(hours=5)
    )
    jid = _write_cron_job("0 7 * * 1-5", _SATURDAY_0700)

    due = get_due_jobs()

    assert [j["id"] for j in due if j["id"] == jid] == []
    assert get_job(jid)["next_run_at"].startswith(_MONDAY_0700.isoformat())


def test_helper_reports_match_for_non_cron_and_unvalidatable(temp_home):
    """Best-effort validation: non-cron kinds, missing expr, and croniter
    unavailable all report a match so the fire path is unchanged there."""
    from cron.jobs import _cron_next_run_matches_expr

    assert _cron_next_run_matches_expr({"kind": "interval"}, _SATURDAY_0700) is True
    assert _cron_next_run_matches_expr({"kind": "cron"}, _SATURDAY_0700) is True
    # A croniter-matching instant reports a match; an excluded one does not.
    assert (
        _cron_next_run_matches_expr(
            {"kind": "cron", "expr": "0 7 * * *"}, _SATURDAY_0700
        )
        is True
    )
    assert (
        _cron_next_run_matches_expr(
            {"kind": "cron", "expr": "0 7 * * 1-5"}, _SATURDAY_0700
        )
        is False
    )


def test_stale_edit_without_manual_marker_still_reanchors(temp_home, monkeypatch):
    """#93049 protection intact after the #94010 fix: a hand-edited stale
    next_run_at WITHOUT the manual_run_at marker still re-anchors without
    firing (carried from #94034's suite)."""
    from cron.jobs import get_due_jobs, get_job

    monkeypatch.setattr(
        "cron.jobs._hermes_now", lambda: _SATURDAY_0700 + timedelta(minutes=5)
    )
    jid = _write_cron_job("0 7 * * 1-5", _SATURDAY_0700)

    stored_before = get_job(jid)
    assert "manual_run_at" not in stored_before

    due = get_due_jobs()

    assert [j["id"] for j in due if j["id"] == jid] == []
    after = get_job(jid)
    assert after["next_run_at"] != _SATURDAY_0700.isoformat()
    assert "manual_run_at" not in after
