"""Regression tests for dashboard cron job profile routing."""

from concurrent.futures import ThreadPoolExecutor
import json
from queue import Empty, SimpleQueue
import threading

import pytest
from fastapi import HTTPException


@pytest.fixture()
def isolated_profiles(tmp_path, monkeypatch):
    """Give profile discovery an isolated default home with one named profile."""
    from hermes_cli import profiles

    default_home = tmp_path / ".hermes"
    profiles_root = default_home / "profiles"
    worker_home = profiles_root / "worker_alpha"

    for home in (default_home, worker_home):
        (home / "cron").mkdir(parents=True, exist_ok=True)
        (home / "config.yaml").write_text("model: test-model\n", encoding="utf-8")

    monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
    monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
    return {"default": default_home, "worker_alpha": worker_home}


def _drain_queue(q):
    values = []
    while True:
        try:
            values.append(q.get_nowait())
        except Empty:
            return values




def test_fire_cron_job_scopes_store_and_runtime_home_together(
    isolated_profiles,
    monkeypatch,
):
    """A profile fire must execute and persist under the same profile home."""
    from cron import jobs as cron_jobs
    from cron import scheduler
    from hermes_cli import web_server

    from hermes_constants import (
        reset_hermes_home_override,
        set_hermes_home_override,
    )

    default_home = isolated_profiles["default"]
    worker_home = isolated_profiles["worker_alpha"]
    monkeypatch.setattr(scheduler, "_hermes_home", None)
    captured = {}

    class RecordingProvider:
        def fire_due(self, job_id, *, adapters=None, loop=None):
            captured["job_id"] = job_id
            captured["runtime_home"] = scheduler._get_hermes_home()
            captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file
            return True

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: RecordingProvider(),
    )

    outer_token = set_hermes_home_override(default_home)
    try:
        assert web_server._fire_cron_job_for_profile("worker_alpha", "worker-job") is True
        assert captured == {
            "job_id": "worker-job",
            "runtime_home": worker_home,
            "jobs_file": worker_home / "cron" / "jobs.json",
        }
        assert scheduler._get_hermes_home() == default_home
    finally:
        reset_hermes_home_override(outer_token)


def test_create_registers_scheduler_inside_target_profile(
    isolated_profiles,
    monkeypatch,
):
    """Dashboard create must resolve and register under the selected profile."""
    from cron import jobs as cron_jobs
    from cron.scheduler_provider import CronScheduler
    from hermes_cli import web_server
    from hermes_constants import get_hermes_home

    worker_home = isolated_profiles["worker_alpha"]
    captured = {}

    class RecordingProvider(CronScheduler):
        @property
        def name(self):
            return "recording"

        def start(self, stop_event, **kw):
            pass

        def register_job(self, job):
            captured["job"] = job
            captured["runtime_home"] = get_hermes_home()
            captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: RecordingProvider(),
    )

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="named-profile-job",
    )

    assert captured["job"]["id"] == job["id"]
    assert captured["runtime_home"] == worker_home
    assert captured["jobs_file"] == worker_home / "cron" / "jobs.json"
    assert job["profile"] == "worker_alpha"


def test_dashboard_create_reports_saved_but_unregistered(
    isolated_profiles,
    monkeypatch,
):
    """Dashboard callers can distinguish persistence from remote registration."""
    from cron.scheduler import CronSchedulerRegistrationError
    from hermes_cli import web_server

    job = {"id": "saved-job", "name": "saved job"}
    failure = CronSchedulerRegistrationError(
        job,
        RuntimeError("private callback URL and token"),
    )

    def fail_create(*args, **kwargs):
        raise failure

    monkeypatch.setattr(web_server, "_call_cron_for_profile", fail_create)

    with pytest.raises(HTTPException) as exc_info:
        web_server._create_cron_job_sync(
            web_server.CronJobCreate(
                prompt="managed by named profile",
                schedule="every 1h",
                name="named-profile-job",
            ),
            profile="worker_alpha",
        )

    assert exc_info.value.status_code == 424
    assert exc_info.value.detail == {
        "error": str(failure),
        "job_id": "saved-job",
        "job_saved": True,
        "scheduler_registered": False,
        "retry_create": False,
    }
    assert "private callback URL and token" not in str(exc_info.value.detail)


def test_notify_cron_provider_scopes_store_and_runtime_home_together(
    isolated_profiles,
    monkeypatch,
):
    """Provider reconciliation must observe the mutated profile, not default."""
    from cron import jobs as cron_jobs
    from cron import scheduler
    from hermes_cli import web_server

    from hermes_constants import (
        reset_hermes_home_override,
        set_hermes_home_override,
    )

    default_home = isolated_profiles["default"]
    worker_home = isolated_profiles["worker_alpha"]
    monkeypatch.setattr(scheduler, "_hermes_home", None)
    monkeypatch.setattr(
        web_server,
        "_cron_profile_dicts",
        lambda: [{"name": "worker_alpha"}],
    )
    captured = {}

    class RecordingProvider:
        def on_jobs_changed(self):
            captured["runtime_home"] = scheduler._get_hermes_home()
            captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: RecordingProvider(),
    )

    outer_token = set_hermes_home_override(default_home)
    try:
        web_server._notify_cron_provider_for_profile("worker_alpha")
        assert captured == {
            "runtime_home": worker_home,
            "jobs_file": worker_home / "cron" / "jobs.json",
        }
        assert scheduler._get_hermes_home() == default_home
    finally:
        reset_hermes_home_override(outer_token)


def test_notify_cron_provider_failure_is_best_effort(
    isolated_profiles,
    monkeypatch,
):
    from hermes_cli import web_server

    class FailNotifyProvider:
        @property
        def name(self):
            return "fail-notify"

        def register_job(self, job):
            return None

        def on_jobs_changed(self):
            raise RuntimeError("provider unavailable")

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: FailNotifyProvider(),
    )

    created = web_server._mutate_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="survives provider failure",
        schedule="every 1h",
        name="best-effort-notify",
    )

    assert created["profile"] == "worker_alpha"
    assert created["name"] == "best-effort-notify"


def test_external_provider_reconcile_fails_closed_with_multiple_profiles(
    isolated_profiles,
    monkeypatch,
):
    """Multi-profile dashboard + external provider: the unscoped reconcile
    must NOT run — its orphan cleanup would disarm the other profiles'
    armed one-shots in the shared NAS registry. The mutation itself still
    succeeds (fail-closed only skips the remote converge)."""
    from cron import scheduler
    from hermes_cli import web_server

    monkeypatch.setattr(scheduler, "_hermes_home", None)
    monkeypatch.setattr(
        web_server,
        "_cron_profile_dicts",
        lambda: [{"name": "default"}, {"name": "worker_alpha"}],
    )
    notified = []

    class ExternalProvider:
        @property
        def name(self):
            return "chronos"

        def register_job(self, job):
            return None

        def on_jobs_changed(self):
            notified.append(True)

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: ExternalProvider(),
    )

    created = web_server._mutate_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="must not disarm siblings",
        schedule="every 1h",
        name="multi-profile-guard",
    )

    assert created["profile"] == "worker_alpha"
    assert notified == [], (
        "external provider reconcile must stay fail-closed on a "
        "multi-profile dashboard"
    )


def test_builtin_provider_hook_still_fires_with_multiple_profiles(
    isolated_profiles,
    monkeypatch,
):
    """The built-in provider re-reads jobs.json per tick — its hook is a
    safe no-op and must NOT be blocked by the multi-profile guard."""
    from cron import scheduler
    from cron.scheduler_provider import InProcessCronScheduler
    from hermes_cli import web_server

    monkeypatch.setattr(scheduler, "_hermes_home", None)
    monkeypatch.setattr(
        web_server,
        "_cron_profile_dicts",
        lambda: [{"name": "default"}, {"name": "worker_alpha"}],
    )
    notified = []

    class BuiltinProbe(InProcessCronScheduler):
        def on_jobs_changed(self):
            notified.append(True)

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: BuiltinProbe(),
    )

    created = web_server._mutate_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="builtin notify",
        schedule="every 1h",
        name="builtin-notify",
    )

    assert created["profile"] == "worker_alpha"
    assert notified == [True]


def test_profile_call_cannot_retarget_ticker_store_mid_write(
    isolated_profiles,
    monkeypatch,
):
    """A dashboard profile call must not redirect a concurrent ticker save."""
    from cron import jobs as cron_jobs
    from hermes_cli import web_server

    default_cron = isolated_profiles["default"] / "cron"
    worker_cron = isolated_profiles["worker_alpha"] / "cron"
    default_file = default_cron / "jobs.json"
    worker_file = worker_cron / "jobs.json"
    default_job = {
        "id": "default-job",
        "name": "default job",
        "schedule": {"kind": "interval", "minutes": 60},
        "next_run_at": "2026-07-09T00:00:00+00:00",
    }
    worker_job = {
        "id": "worker-job",
        "name": "worker job",
        "schedule": {"kind": "interval", "minutes": 60},
        "next_run_at": "2026-07-09T00:00:00+00:00",
    }
    default_file.write_text(json.dumps({"jobs": [default_job]}), encoding="utf-8")
    worker_file.write_text(json.dumps({"jobs": [worker_job]}), encoding="utf-8")

    monkeypatch.setattr(cron_jobs, "CRON_DIR", default_cron)
    monkeypatch.setattr(cron_jobs, "JOBS_FILE", default_file)
    monkeypatch.setattr(cron_jobs, "OUTPUT_DIR", default_cron / "output")
    monkeypatch.setattr(
        cron_jobs,
        "compute_next_run",
        lambda _schedule, _last_run_at=None: "2026-07-10T00:00:00+00:00",
    )

    ticker_loaded = threading.Event()
    release_ticker = threading.Event()
    profile_entered = threading.Event()
    ticker_done = threading.Event()
    ticker_thread = threading.local()
    original_load_jobs = cron_jobs.load_jobs

    def blocking_load_jobs():
        loaded = original_load_jobs()
        if getattr(ticker_thread, "active", False):
            ticker_loaded.set()
            assert release_ticker.wait(5), "profile call did not enter in time"
        return loaded

    def hold_profile_call():
        profile_entered.set()
        assert ticker_done.wait(5), "ticker did not finish in time"
        return True

    def run_ticker_write():
        ticker_thread.active = True
        try:
            return cron_jobs.advance_next_run("default-job")
        finally:
            ticker_done.set()

    monkeypatch.setattr(cron_jobs, "load_jobs", blocking_load_jobs)
    monkeypatch.setattr(cron_jobs, "_hold_profile_call", hold_profile_call, raising=False)

    with ThreadPoolExecutor(max_workers=2) as pool:
        ticker_future = pool.submit(run_ticker_write)
        assert ticker_loaded.wait(5), "ticker did not load the default store"
        profile_future = pool.submit(
            web_server._call_cron_for_profile,
            "worker_alpha",
            "_hold_profile_call",
        )
        assert profile_entered.wait(5), "profile call did not retarget its store"
        release_ticker.set()
        assert ticker_future.result(timeout=5) is True
        assert profile_future.result(timeout=5) is True

    default_saved = json.loads(default_file.read_text(encoding="utf-8"))["jobs"]
    worker_saved = json.loads(worker_file.read_text(encoding="utf-8"))["jobs"]
    assert [job["id"] for job in worker_saved] == ["worker-job"]
    assert [job["id"] for job in default_saved] == ["default-job"]
    assert default_saved[0]["next_run_at"] == "2026-07-10T00:00:00+00:00"






@pytest.mark.asyncio
async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_profiles):
    from hermes_cli import web_server

    worker_job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="named-profile-job",
    )

    paused = await web_server.pause_cron_job(worker_job["id"])
    assert paused["profile"] == "worker_alpha"
    assert paused["enabled"] is False

    default_jobs = await web_server.list_cron_jobs(profile="default")
    worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")

    assert default_jobs == []
    assert len(worker_jobs) == 1
    assert worker_jobs[0]["id"] == worker_job["id"]
    assert worker_jobs[0]["enabled"] is False


@pytest.mark.asyncio
async def test_dashboard_cron_mutations_notify_selected_profile_provider(
    isolated_profiles,
    monkeypatch,
):
    from hermes_cli import web_server

    notified_profiles = []
    monkeypatch.setattr(
        web_server,
        "_notify_cron_provider_for_profile",
        notified_profiles.append,
    )

    created = await web_server.create_cron_job(
        web_server.CronJobCreate(
            prompt="managed by named profile",
            schedule="every 1h",
            name="provider-notify-job",
        ),
        profile="worker_alpha",
    )
    await web_server.update_cron_job(
        created["id"],
        web_server.CronJobUpdate(updates={"name": "provider-notify-job-updated"}),
        profile="worker_alpha",
    )
    await web_server.pause_cron_job(created["id"], profile="worker_alpha")
    await web_server.resume_cron_job(created["id"], profile="worker_alpha")
    await web_server.delete_cron_job(created["id"], profile="worker_alpha")

    assert notified_profiles == ["worker_alpha"] * 5


@pytest.mark.asyncio
async def test_blueprint_instantiation_notifies_selected_profile_provider(
    isolated_profiles,
    monkeypatch,
):
    from hermes_cli import web_server

    notified_profiles = []
    monkeypatch.setattr(
        web_server,
        "_notify_cron_provider_for_profile",
        notified_profiles.append,
    )

    created = await web_server.instantiate_blueprint(
        web_server.AutomationBlueprintInstantiate(
            blueprint="morning-brief",
            values={"time": "07:30", "deliver": "local"},
        ),
        profile="worker_alpha",
    )

    assert created["profile"] == "worker_alpha"
    assert notified_profiles == ["worker_alpha"]


@pytest.mark.asyncio
async def test_trigger_cron_job_fires_only_selected_job_and_returns_refreshed_state(
    isolated_profiles,
    monkeypatch,
):
    from cron import jobs as cron_jobs
    from hermes_cli import web_server

    selected = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="run immediately",
        schedule="every 1h",
        name="selected-trigger-job",
    )
    sibling = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="leave scheduled",
        schedule="every 1h",
        name="sibling-job",
    )
    fired = []

    class RecordingProvider:
        def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
            fired.append(
                {
                    "job_id": job_id,
                    "jobs_file": cron_jobs._current_cron_store().jobs_file,
                    "force": force,
                }
            )
            cron_jobs.mark_job_run(job_id, success=True)
            return True

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: RecordingProvider(),
    )
    monkeypatch.setattr(
        cron_jobs,
        "trigger_job",
        lambda _job_id: (_ for _ in ()).throw(
            AssertionError("manual fire must not expose the job to the ticker first")
        ),
    )

    triggered = await web_server.trigger_cron_job(
        selected["id"],
        profile="worker_alpha",
    )

    assert fired == [
        {
            "job_id": selected["id"],
            "jobs_file": isolated_profiles["worker_alpha"] / "cron" / "jobs.json",
            "force": False,
        }
    ]
    assert triggered["last_status"] == "ok"
    assert triggered["last_run_at"] is not None
    untouched = web_server._call_cron_for_profile(
        "worker_alpha",
        "get_job",
        sibling["id"],
    )
    assert untouched["last_run_at"] is None


@pytest.mark.asyncio
async def test_trigger_cron_job_reports_lost_claim_as_conflict(
    isolated_profiles,
    monkeypatch,
):
    from hermes_cli import web_server

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="already running",
        schedule="every 1h",
        name="claimed-trigger-job",
    )

    class ClaimLostProvider:
        def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
            return False

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: ClaimLostProvider(),
    )

    with pytest.raises(HTTPException) as exc:
        await web_server.trigger_cron_job(job["id"], profile="worker_alpha")

    assert exc.value.status_code == 409
    assert "already running" in exc.value.detail


@pytest.mark.asyncio
async def test_trigger_cron_job_forces_paused_job_atomically(
    isolated_profiles,
    monkeypatch,
):
    from cron import jobs as cron_jobs
    from hermes_cli import web_server

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="resume me",
        schedule="every 1h",
        name="paused-trigger-job",
    )
    web_server._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
    observed = {}

    class ForceProvider:
        def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
            observed["force"] = force
            assert cron_jobs.claim_job_for_fire(job_id, force=force) is True
            cron_jobs.mark_job_run(job_id, success=True)
            return True

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: ForceProvider(),
    )

    triggered = await web_server.trigger_cron_job(
        job["id"],
        profile="worker_alpha",
    )

    assert observed["force"] is True
    assert triggered["enabled"] is True
    assert triggered["state"] == "scheduled"
    assert triggered["last_status"] == "ok"


@pytest.mark.asyncio
async def test_trigger_paused_job_rejects_legacy_provider_without_mutating_job(
    isolated_profiles,
    monkeypatch,
):
    from fastapi import HTTPException
    from hermes_cli import web_server

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="stay paused",
        schedule="every 1h",
        name="legacy-paused-trigger-job",
    )
    web_server._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
    calls = []

    class LegacyProvider:
        def fire_due(self, job_id, *, adapters=None, loop=None):
            calls.append(job_id)
            return True

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: LegacyProvider(),
    )

    with pytest.raises(HTTPException) as exc:
        await web_server.trigger_cron_job(job["id"], profile="worker_alpha")

    assert exc.value.status_code == 409
    assert "forced" in exc.value.detail.lower()
    assert calls == []
    persisted = web_server._call_cron_for_profile(
        "worker_alpha",
        "get_job",
        job["id"],
    )
    assert persisted["state"] == "paused"
    assert persisted["enabled"] is False


@pytest.mark.asyncio
async def test_trigger_cron_job_returns_refreshed_execution_failure(
    isolated_profiles,
    monkeypatch,
):
    from cron import jobs as cron_jobs
    from hermes_cli import web_server

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="fail visibly",
        schedule="every 1h",
        name="failed-trigger-job",
    )

    class FailedProvider:
        def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
            cron_jobs.mark_job_run(job_id, success=False, error="expected failure")
            return False

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: FailedProvider(),
    )

    triggered = await web_server.trigger_cron_job(
        job["id"],
        profile="worker_alpha",
    )

    assert triggered["last_status"] == "error"
    assert triggered["last_error"] == "expected failure"


@pytest.mark.asyncio
async def test_trigger_cron_job_returns_completed_snapshot_for_retained_oneshot(
    isolated_profiles,
    monkeypatch,
):
    from cron import jobs as cron_jobs
    from hermes_cli import web_server

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="run once",
        schedule="30m",
        name="completed-trigger-job",
    )

    class SuccessfulProvider:
        def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
            cron_jobs.mark_job_run(job_id, success=True)
            return True

    monkeypatch.setattr(
        "cron.scheduler_provider.resolve_cron_scheduler",
        lambda: SuccessfulProvider(),
    )

    triggered = await web_server.trigger_cron_job(
        job["id"],
        profile="worker_alpha",
    )

    assert triggered["state"] == "completed"
    assert triggered["enabled"] is False
    # Completed one-shots are retained for the retention window (#80624) with
    # their terminal status inspectable — the trigger response is the real
    # record, not a synthetic pre-removal snapshot.
    assert triggered["last_status"] == "ok"
    assert triggered["last_run_at"] is not None
    retained = web_server._call_cron_for_profile(
        "worker_alpha",
        "get_job",
        job["id"],
    )
    assert retained is not None
    assert retained["state"] == "completed"


@pytest.mark.asyncio
async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypatch):
    from hermes_cli import web_server

    worker_job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="thread-offload-job",
    )

    event_loop_thread = threading.get_ident()
    profile_scan_threads = SimpleQueue()
    worker_threads = SimpleQueue()
    original_profile_dicts = web_server._cron_profile_dicts
    original_find = web_server._find_cron_job_profile

    def tracking_profile_dicts():
        profile_scan_threads.put(threading.get_ident())
        return original_profile_dicts()

    def tracking_find(job_id):
        worker_threads.put(threading.get_ident())
        return original_find(job_id)

    monkeypatch.setattr(web_server, "_cron_profile_dicts", tracking_profile_dicts)
    monkeypatch.setattr(web_server, "_find_cron_job_profile", tracking_find)

    jobs = await web_server.list_cron_jobs(profile="all")
    paused = await web_server.pause_cron_job(worker_job["id"])

    assert any(job["id"] == worker_job["id"] for job in jobs)
    assert paused["profile"] == "worker_alpha"
    profile_scan_thread_ids = _drain_queue(profile_scan_threads)
    worker_thread_ids = _drain_queue(worker_threads)
    assert profile_scan_thread_ids
    assert worker_thread_ids
    assert all(thread_id != event_loop_thread for thread_id in profile_scan_thread_ids)
    assert all(thread_id != event_loop_thread for thread_id in worker_thread_ids)


@pytest.mark.asyncio
async def test_cron_dashboard_io_rejects_async_callables():
    from hermes_cli import web_server

    async def async_callable():
        return "nope"

    with pytest.raises(TypeError, match="only accepts sync callables"):
        await web_server._run_cron_dashboard_io(async_callable)



@pytest.mark.asyncio
async def test_update_cron_job_normalizes_dashboard_core_fields(isolated_profiles, tmp_path):
    from hermes_cli import web_server

    scripts_dir = isolated_profiles["worker_alpha"] / "scripts"
    scripts_dir.mkdir()
    (scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")
    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="normalizes-dashboard-fields",
    )

    updated = await web_server.update_cron_job(
        job["id"],
        web_server.CronJobUpdate(
            updates={
                "base_url": "https://example.invalid/v1/",
                "script": str(scripts_dir / "collect.py"),
                "context_from": "",
                "no_agent": True,
            }
        ),
        profile="worker_alpha",
    )

    assert updated["base_url"] == "https://example.invalid/v1"
    assert updated["script"] == "collect.py"
    assert updated["context_from"] is None
    assert updated["no_agent"] is True


@pytest.mark.asyncio
async def test_create_cron_job_rejects_script_outside_profile_scripts(
    isolated_profiles, tmp_path
):
    from hermes_cli import web_server

    outside = tmp_path / "outside.py"
    outside.write_text("print('nope')\n", encoding="utf-8")

    with pytest.raises(HTTPException) as exc:
        await web_server.create_cron_job(
            web_server.CronJobCreate(
                schedule="every 1h",
                script=str(outside),
                no_agent=True,
            ),
            profile="worker_alpha",
        )

    assert exc.value.status_code == 400
    assert "inside" in exc.value.detail


@pytest.mark.asyncio
async def test_create_cron_job_rejects_empty_agent_job(isolated_profiles):
    from hermes_cli import web_server

    with pytest.raises(HTTPException) as exc:
        await web_server.create_cron_job(
            web_server.CronJobCreate(schedule="every 1h"),
            profile="worker_alpha",
        )

    assert exc.value.status_code == 400
    assert "prompt, skill, or script" in exc.value.detail


@pytest.mark.asyncio
async def test_update_cron_job_no_agent_reuses_existing_script(isolated_profiles):
    from hermes_cli import web_server

    scripts_dir = isolated_profiles["worker_alpha"] / "scripts"
    scripts_dir.mkdir()
    (scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")

    job = await web_server.create_cron_job(
        web_server.CronJobCreate(
            schedule="every 1h",
            script=str(scripts_dir / "collect.py"),
        ),
        profile="worker_alpha",
    )

    updated = await web_server.update_cron_job(
        job["id"],
        web_server.CronJobUpdate(updates={"no_agent": True}),
        profile="worker_alpha",
    )

    assert updated["no_agent"] is True
    assert updated["script"] == "collect.py"


@pytest.mark.asyncio
async def test_dashboard_cron_rejects_missing_context_from(isolated_profiles):
    from hermes_cli import web_server

    with pytest.raises(HTTPException) as create_exc:
        await web_server.create_cron_job(
            web_server.CronJobCreate(
                prompt="process missing upstream",
                schedule="every 1h",
                context_from=["missing-job-id"],
            ),
            profile="worker_alpha",
        )

    assert create_exc.value.status_code == 400
    assert "missing-job-id" in create_exc.value.detail

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="context-update-target",
    )

    with pytest.raises(HTTPException) as update_exc:
        await web_server.update_cron_job(
            job["id"],
            web_server.CronJobUpdate(
                updates={
                    "context_from": ["missing-job-id"],
                }
            ),
            profile="worker_alpha",
        )

    assert update_exc.value.status_code == 400
    assert "missing-job-id" in update_exc.value.detail






@pytest.mark.asyncio
async def test_dashboard_cron_noop_inference_fields_keep_existing_snapshots(
    isolated_profiles,
    monkeypatch,
):
    from hermes_cli import runtime_provider, web_server

    current_provider = {"name": "initial-provider"}
    monkeypatch.setattr(
        runtime_provider,
        "resolve_runtime_provider",
        lambda **kwargs: {"provider": current_provider["name"]},
    )

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="dashboard-edit-job",
    )

    assert job["provider_snapshot"] == "initial-provider"
    assert job["model_snapshot"] == "test-model"

    current_provider["name"] = "changed-provider"
    (isolated_profiles["worker_alpha"] / "config.yaml").write_text(
        "model: changed-model\n",
        encoding="utf-8",
    )

    updated = await web_server.update_cron_job(
        job["id"],
        web_server.CronJobUpdate(
            updates={
                "name": "dashboard-edit-job-renamed",
                "provider": None,
                "model": None,
                "base_url": None,
                "no_agent": False,
            }
        ),
        profile="worker_alpha",
    )

    assert updated["name"] == "dashboard-edit-job-renamed"
    assert updated["provider_snapshot"] == "initial-provider"
    assert updated["model_snapshot"] == "test-model"


@pytest.mark.asyncio
async def test_update_cron_job_clears_snapshots_for_no_agent(
    isolated_profiles,
    monkeypatch,
):
    from hermes_cli import runtime_provider, web_server

    monkeypatch.setattr(
        runtime_provider,
        "resolve_runtime_provider",
        lambda **kwargs: {"provider": "worker-provider"},
    )
    scripts_dir = isolated_profiles["worker_alpha"] / "scripts"
    scripts_dir.mkdir()
    (scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")

    job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="agent-to-script-job",
    )

    assert job["provider_snapshot"] == "worker-provider"
    assert job["model_snapshot"] == "test-model"

    updated = await web_server.update_cron_job(
        job["id"],
        web_server.CronJobUpdate(
            updates={
                "script": str(scripts_dir / "collect.py"),
                "no_agent": True,
            }
        ),
        profile="worker_alpha",
    )

    assert updated["provider_snapshot"] is None
    assert updated["model_snapshot"] is None


@pytest.mark.asyncio
async def test_update_cron_job_rejects_id_mutation(isolated_profiles, monkeypatch):
    """Dashboard surfaces a 400 (not a 500 or silent rename) when an
    id-mutation attempt is rejected by cron/jobs.update_job."""
    from hermes_cli import web_server

    notified_profiles = []
    monkeypatch.setattr(
        web_server,
        "_notify_cron_provider_for_profile",
        notified_profiles.append,
    )
    worker_job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="managed by named profile",
        schedule="every 1h",
        name="immutable-id-job",
    )

    with pytest.raises(HTTPException) as exc:
        await web_server.update_cron_job(
            worker_job["id"],
            web_server.CronJobUpdate(updates={"id": "../escape"}),
            profile="worker_alpha",
        )

    assert exc.value.status_code == 400
    assert "id" in exc.value.detail
    assert notified_profiles == []
    worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
    assert [job["id"] for job in worker_jobs] == [worker_job["id"]]


@pytest.mark.asyncio
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
    from hermes_cli import web_server

    default_job = web_server._call_cron_for_profile(
        "default",
        "create_job",
        prompt="same-ish default",
        schedule="every 1h",
        name="shared-name",
    )
    worker_job = web_server._call_cron_for_profile(
        "worker_alpha",
        "create_job",
        prompt="same-ish worker",
        schedule="every 1h",
        name="shared-name-worker",
    )

    deleted = await web_server.delete_cron_job(worker_job["id"], profile="worker_alpha")
    assert deleted == {"ok": True}

    remaining_default = await web_server.list_cron_jobs(profile="default")
    remaining_worker = await web_server.list_cron_jobs(profile="worker_alpha")
    assert [job["id"] for job in remaining_default] == [default_job["id"]]
    assert remaining_worker == []


@pytest.mark.asyncio
async def test_cron_profile_validation_errors(isolated_profiles):
    from hermes_cli import web_server

    with pytest.raises(HTTPException) as bad_name:
        await web_server.list_cron_jobs(profile="../bad")
    assert bad_name.value.status_code == 400

    with pytest.raises(HTTPException) as missing:
        await web_server.list_cron_jobs(profile="missing_profile")
    assert missing.value.status_code == 404


@pytest.mark.asyncio
async def test_create_cron_job_without_profile_uses_backend_own_profile(
    isolated_profiles, monkeypatch
):
    """A pool backend scoped to a named profile must not default creates to
    ``~/.hermes`` when the request carries no explicit ``profile`` (the
    Desktop app's pre-profileScoped clients sent none)."""
    from hermes_cli import web_server

    monkeypatch.setenv(
        "HERMES_HOME", str(isolated_profiles["worker_alpha"])
    )

    job = await web_server.create_cron_job(
        web_server.CronJobCreate(
            prompt="runs in my own profile",
            schedule="every 1h",
            name="own-profile-job",
        ),
        profile=None,
    )

    assert job["profile"] == "worker_alpha"
    assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
    assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()


@pytest.mark.asyncio
async def test_create_cron_job_without_profile_defaults_when_unscoped(
    isolated_profiles, monkeypatch
):
    """HERMES_HOME at the default home (or unrecognized) keeps the legacy
    ``default`` fallback."""
    from hermes_cli import web_server

    monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"]))

    job = await web_server.create_cron_job(
        web_server.CronJobCreate(
            prompt="runs in default",
            schedule="every 1h",
            name="default-job",
        ),
        profile=None,
    )

    assert job["profile"] == "default"
    assert (isolated_profiles["default"] / "cron" / "jobs.json").exists()
