"""Tests for per-job profile support in cron jobs.

Covers data-layer validation/storage, cronjob tool plumbing, scheduler runtime
HERMES_HOME scoping, and tick() serialization for profile jobs.
"""

from __future__ import annotations

import json
import os

import pytest


@pytest.fixture()
def isolated_cron_profile_home(tmp_path, monkeypatch):
    """Create an isolated Hermes root with a named profile and temp cron store."""
    root = tmp_path / "hermes-root"
    profile_home = root / "profiles" / "support"
    profile_home.mkdir(parents=True)
    (root / "cron").mkdir(parents=True)

    monkeypatch.setenv("HERMES_HOME", str(root))
    monkeypatch.setattr("cron.jobs.CRON_DIR", root / "cron")
    monkeypatch.setattr("cron.jobs.JOBS_FILE", root / "cron" / "jobs.json")
    monkeypatch.setattr("cron.jobs.OUTPUT_DIR", root / "cron" / "output")

    return root, profile_home


class TestNormalizeProfile:
    def test_none_and_empty_return_none(self, isolated_cron_profile_home):
        from cron.jobs import _normalize_profile

        assert _normalize_profile(None) is None
        assert _normalize_profile("") is None
        assert _normalize_profile("   ") is None

    def test_default_profile_is_valid_and_normalized(self, isolated_cron_profile_home):
        from cron.jobs import _normalize_profile

        assert _normalize_profile("Default") == "default"

    def test_named_profile_must_exist_and_is_normalized(self, isolated_cron_profile_home):
        from cron.jobs import _normalize_profile

        assert _normalize_profile("Support") == "support"

    def test_invalid_profile_name_is_rejected(self, isolated_cron_profile_home):
        from cron.jobs import _normalize_profile

        with pytest.raises(ValueError):
            _normalize_profile("invalid!")

    def test_missing_named_profile_is_rejected(self, isolated_cron_profile_home):
        from cron.jobs import _normalize_profile

        with pytest.raises(FileNotFoundError):
            _normalize_profile("missing")


class TestCreateAndUpdateJobProfile:
    def test_create_stores_profile_id(self, isolated_cron_profile_home):
        from cron.jobs import create_job, get_job

        job = create_job(prompt="hello", schedule="every 1h", profile="Support")
        stored = get_job(job["id"])

        assert stored is not None
        assert stored["profile"] == "support"

    def test_create_without_profile_preserves_old_behaviour(self, isolated_cron_profile_home):
        from cron.jobs import create_job, get_job

        job = create_job(prompt="hello", schedule="every 1h")
        stored = get_job(job["id"])

        assert stored is not None
        assert stored.get("profile") is None

    def test_create_accepts_explicit_default(self, isolated_cron_profile_home):
        from cron.jobs import create_job, get_job

        job = create_job(prompt="hello", schedule="every 1h", profile="default")
        stored = get_job(job["id"])

        assert stored is not None
        assert stored["profile"] == "default"

    def test_update_sets_and_clears_profile(self, isolated_cron_profile_home):
        from cron.jobs import create_job, get_job, update_job

        job = create_job(prompt="x", schedule="every 1h")
        update_job(job["id"], {"profile": "Support"})
        stored = get_job(job["id"])
        assert stored is not None
        assert stored["profile"] == "support"

        update_job(job["id"], {"profile": ""})
        stored = get_job(job["id"])
        assert stored is not None
        assert stored["profile"] is None

    def test_update_rejects_missing_profile(self, isolated_cron_profile_home):
        from cron.jobs import create_job, update_job

        job = create_job(prompt="x", schedule="every 1h")
        with pytest.raises(FileNotFoundError):
            update_job(job["id"], {"profile": "missing"})


class TestCronjobToolProfile:
    def test_create_and_list_with_profile(self, isolated_cron_profile_home):
        from tools.cronjob_tools import cronjob

        created = json.loads(
            cronjob(
                action="create",
                prompt="hi",
                schedule="every 1h",
                profile="Support",
            )
        )
        assert created["success"] is True
        assert created["job"]["profile"] == "support"

        listing = json.loads(cronjob(action="list"))
        assert listing["jobs"][0]["profile"] == "support"

    def test_update_clears_profile_with_empty_string(self, isolated_cron_profile_home):
        from tools.cronjob_tools import cronjob

        created = json.loads(
            cronjob(
                action="create",
                prompt="hi",
                schedule="every 1h",
                profile="Support",
            )
        )
        updated = json.loads(
            cronjob(action="update", job_id=created["job_id"], profile="")
        )

        assert updated["success"] is True
        assert "profile" not in updated["job"]

    def test_schema_advertises_profile(self):
        from tools.cronjob_tools import CRONJOB_SCHEMA

        assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"]
        desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"]
        desc_lower = desc.lower()
        assert "hermes profile" in desc_lower
        assert "context-local" in desc_lower
        assert "subprocess" in desc_lower
        assert "temporarily sets hermes_home" not in desc_lower


class TestRunJobProfileContext:
    @staticmethod
    def _install_agent_stubs(monkeypatch, observed: dict):
        import sys
        import cron.scheduler as sched

        class FakeAgent:
            def __init__(self, **kwargs):
                from hermes_constants import get_hermes_home

                observed["env_home_during_init"] = os.environ.get("HERMES_HOME")
                observed["profile_env_only_during_init"] = os.environ.get(
                    "HERMES_PROFILE_TEST_ONLY"
                )
                observed["profile_env_shared_during_init"] = os.environ.get(
                    "HERMES_PROFILE_TEST_SHARED"
                )
                observed["hermes_home_during_init"] = str(get_hermes_home())
                observed["scheduler_home_during_init"] = str(sched._get_hermes_home())
                observed["skip_context_files"] = kwargs.get("skip_context_files")

            def run_conversation(self, *_a, **_kw):
                from hermes_constants import get_hermes_home

                observed["env_home_during_run"] = os.environ.get("HERMES_HOME")
                observed["profile_env_only_during_run"] = os.environ.get(
                    "HERMES_PROFILE_TEST_ONLY"
                )
                observed["profile_env_shared_during_run"] = os.environ.get(
                    "HERMES_PROFILE_TEST_SHARED"
                )
                observed["hermes_home_during_run"] = str(get_hermes_home())
                observed["scheduler_home_during_run"] = str(sched._get_hermes_home())
                return {"final_response": "done", "messages": []}

            def get_activity_summary(self):
                return {"seconds_since_activity": 0.0}

            def close(self):
                observed["closed"] = True

        fake_mod = type(sys)("run_agent")
        fake_mod.AIAgent = FakeAgent
        monkeypatch.setitem(sys.modules, "run_agent", fake_mod)

        from hermes_cli import runtime_provider as runtime_provider

        monkeypatch.setattr(
            runtime_provider,
            "resolve_runtime_provider",
            lambda **_kw: {
                "provider": "test",
                "api_key": "test-key",
                "base_url": "http://test.local",
                "api_mode": "chat_completions",
            },
        )

        monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi")
        monkeypatch.setattr(sched, "_resolve_origin", lambda job: None)
        monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None)
        monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None)
        monkeypatch.setattr(sched, "_hermes_home", None)
        monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")

        import dotenv

        def fake_load_dotenv(path, *_a, **_kw):
            observed.setdefault("dotenv_paths", []).append(str(path))
            return True

        monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)

    def test_run_job_sets_and_restores_profile_home(
        self, isolated_cron_profile_home, monkeypatch
    ):
        import cron.scheduler as sched

        root, profile_home = isolated_cron_profile_home
        observed: dict = {}
        self._install_agent_stubs(monkeypatch, observed)

        job = {
            "id": "abc",
            "name": "profile-job",
            "profile": "support",
            "schedule_display": "manual",
        }

        success, _output, response, error = sched.run_job(job)

        assert success is True, f"run_job failed: error={error!r} response={response!r}"
        assert observed["dotenv_paths"] == [str(profile_home / ".env")]
        assert observed["env_home_during_init"] == str(root)
        assert observed["env_home_during_run"] == str(root)
        assert observed["hermes_home_during_init"] == str(profile_home.resolve())
        assert observed["hermes_home_during_run"] == str(profile_home.resolve())
        assert observed["scheduler_home_during_init"] == str(profile_home.resolve())
        assert observed["scheduler_home_during_run"] == str(profile_home.resolve())
        assert observed["skip_context_files"] is True
        assert os.environ["HERMES_HOME"] == str(root)
        assert sched._get_hermes_home() == root

    def test_profile_dotenv_environment_is_restored(
        self, isolated_cron_profile_home, monkeypatch
    ):
        import dotenv
        import cron.scheduler as sched

        root, profile_home = isolated_cron_profile_home
        observed: dict = {}
        self._install_agent_stubs(monkeypatch, observed)
        monkeypatch.setenv("HERMES_PROFILE_TEST_SHARED", "outer")
        monkeypatch.delenv("HERMES_PROFILE_TEST_ONLY", raising=False)

        def fake_load_dotenv(path, *_a, **_kw):
            observed.setdefault("dotenv_paths", []).append(str(path))
            os.environ["HERMES_PROFILE_TEST_SHARED"] = "profile-value"
            os.environ["HERMES_PROFILE_TEST_ONLY"] = "profile-only"
            os.environ["HERMES_CRON_TIMEOUT"] = "123"
            return True

        monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)

        job = {
            "id": "env-profile",
            "name": "profile-env-job",
            "profile": "support",
            "schedule_display": "manual",
        }

        success, _output, _response, error = sched.run_job(job)

        assert success is True, error
        assert observed["dotenv_paths"] == [str(profile_home / ".env")]
        assert observed["profile_env_only_during_init"] == "profile-only"
        assert observed["profile_env_shared_during_init"] == "profile-value"
        assert observed["profile_env_only_during_run"] == "profile-only"
        assert observed["profile_env_shared_during_run"] == "profile-value"
        assert os.environ["HERMES_PROFILE_TEST_SHARED"] == "outer"
        assert "HERMES_PROFILE_TEST_ONLY" not in os.environ
        assert os.environ["HERMES_CRON_TIMEOUT"] == "0"
        assert os.environ["HERMES_HOME"] == str(root)
        assert sched._get_hermes_home() == root

    def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env(
        self, isolated_cron_profile_home, monkeypatch
    ):
        import cron.scheduler as sched

        root, profile_home = isolated_cron_profile_home
        scripts_dir = profile_home / "scripts"
        scripts_dir.mkdir(parents=True)
        (scripts_dir / "print_home.py").write_text(
            "import os\nprint(os.environ.get('HERMES_HOME', ''))\n",
            encoding="utf-8",
        )
        monkeypatch.setattr(sched, "_hermes_home", None)

        job = {
            "id": "script1",
            "name": "profile-script",
            "profile": "support",
            "script": "print_home.py",
            "no_agent": True,
        }

        success, _doc, response, error = sched.run_job(job)

        assert success is True, error
        assert response.strip() == str(profile_home.resolve())
        assert os.environ["HERMES_HOME"] == str(root)
        assert sched._get_hermes_home() == root

    def test_run_job_without_profile_leaves_hermes_home_untouched(
        self, isolated_cron_profile_home, monkeypatch
    ):
        import cron.scheduler as sched

        root, _profile_home = isolated_cron_profile_home
        observed: dict = {}
        self._install_agent_stubs(monkeypatch, observed)

        job = {
            "id": "noprof",
            "name": "no-profile-job",
            "profile": None,
            "schedule_display": "manual",
        }

        success, *_ = sched.run_job(job)

        assert success is True
        assert observed["hermes_home_during_init"] == str(root)
        assert os.environ["HERMES_HOME"] == str(root)

    def test_run_job_falls_back_on_missing_runtime_profile(
        self, isolated_cron_profile_home, monkeypatch
    ):
        import cron.scheduler as sched

        root, _profile_home = isolated_cron_profile_home
        observed: dict = {}
        self._install_agent_stubs(monkeypatch, observed)

        job = {
            "id": "missing-profile",
            "name": "missing-profile-job",
            "profile": "missing",
            "schedule_display": "manual",
        }

        # Should succeed with fallback, not raise
        success, _output, response, error = sched.run_job(job)

        assert success is True, f"run_job should fallback, not fail: error={error!r}"
        # Verify it used the default home, not the missing profile
        assert observed["hermes_home_during_init"] == str(root)
        assert os.environ["HERMES_HOME"] == str(root)


class TestTickProfilePartition:
    def test_profile_and_workdir_combined(self, isolated_cron_profile_home, monkeypatch):
        """Both profile and workdir set — verify both are applied and restored."""
        import cron.scheduler as sched

        root, profile_home = isolated_cron_profile_home
        observed: dict = {}
        TestRunJobProfileContext._install_agent_stubs(monkeypatch, observed)
        fake_workdir = str(root / "myproject")
        (root / "myproject").mkdir()

        job = {
            "id": "combo",
            "name": "combo-job",
            "profile": "support",
            "workdir": fake_workdir,
            "schedule_display": "manual",
        }

        success, _output, _response, error = sched.run_job(job)

        assert success is True, error
        assert observed["hermes_home_during_init"] == str(profile_home.resolve())
        assert os.environ.get("TERMINAL_CWD", "") != fake_workdir, \
            "TERMINAL_CWD should be restored after job"
        assert os.environ["HERMES_HOME"] == str(root)
        assert sched._get_hermes_home() == root

    def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch):
        import threading
        import cron.scheduler as sched

        # Two profile jobs (both sequential) + one parallel job.
        profile_a = {"id": "a", "name": "A", "profile": "default"}
        profile_b = {"id": "b", "name": "B", "profile": "default"}
        parallel_job = {"id": "c", "name": "C", "profile": None}

        monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_a, profile_b, parallel_job])
        monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None)

        calls: list[tuple[str, str]] = []
        order_lock = threading.Lock()

        def fake_run_job(job):
            with order_lock:
                calls.append((job["id"], threading.current_thread().name))
            return True, "output", "response", None

        monkeypatch.setattr(sched, "run_job", fake_run_job)
        monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None)
        monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None)
        monkeypatch.setattr(sched, "_deliver_result", lambda *_a, **_kw: None)

        n = sched.tick(verbose=False)

        assert n == 3
        ids = [job_id for job_id, _thread_name in calls]
        # Sequential profile jobs preserve submission order relative to each
        # other (single-thread pool).
        assert ids.index("a") < ids.index("b")
        # Sequential (profile) jobs run on the persistent single-thread
        # cron-seq pool — NOT the main thread — so a long profile job never
        # blocks the ticker.  Parallel jobs run on the cron-parallel pool.
        for jid in ("a", "b"):
            seq_thread = next(t for job_id, t in calls if job_id == jid)
            assert seq_thread != threading.current_thread().name
            assert seq_thread.startswith("cron-seq"), seq_thread
        par_thread = next(t for job_id, t in calls if job_id == "c")
        assert par_thread.startswith("cron-parallel"), par_thread
