"""Tests for Home-Assistant-style !include tags in the YAML config loader."""

from __future__ import annotations

import asyncio
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any

import pytest
import yaml
from pydantic import ValidationError
from typer.testing import CliRunner

from mindroom import file_watcher
from mindroom.cli.main import app
from mindroom.config.main import Config, load_config
from mindroom.config.yaml_includes import ConfigIncludeError, load_yaml_config_source, partial_source_files
from mindroom.constants import resolve_runtime_paths
from mindroom.orchestration.config_lifecycle import ConfigReloadLifecycle
from mindroom.response_admission import ResponseAdmissionGate

if TYPE_CHECKING:
    from collections.abc import Callable

runner = CliRunner()


@pytest.fixture(autouse=True)
def _clear_runtime_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("MINDROOM_CONFIG_PATH", raising=False)
    monkeypatch.delenv("MINDROOM_STORAGE_PATH", raising=False)


def _write(path: Path, content: str) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
    return path


MONOLITH_CONFIG: dict[str, Any] = {
    "models": {"default": {"provider": "ollama", "id": "test-model"}},
    "agents": {
        "code": {
            "display_name": "Code Agent",
            "role": "Writes code",
            "tools": ["calculator"],
            "instructions": ["Line one of a long prompt.\nLine two of a long prompt."],
            "rooms": ["dev"],
        },
        "research": {
            "display_name": "Research Agent",
            "role": "Researches topics",
            "tools": ["calculator"],
            "rooms": ["dev"],
        },
    },
    "defaults": {"markdown": True},
}


def _write_split_config(config_dir: Path) -> Path:
    """Write MONOLITH_CONFIG split across include files; return the top-level path."""
    config_path = _write(
        config_dir / "config.yaml",
        "agents: !include_dir_merge_named agents/\nmodels: !include models.yaml\ndefaults:\n  markdown: true\n",
    )
    _write(config_dir / "models.yaml", yaml.dump(MONOLITH_CONFIG["models"]))
    _write(
        config_dir / "agents" / "code.yaml",
        "code:\n"
        "  display_name: Code Agent\n"
        "  role: Writes code\n"
        "  tools: !include _shared/tools.yaml\n"
        "  instructions:\n"
        "    - !include_text ../prompts/code.md\n"
        "  rooms: [dev]\n",
    )
    _write(config_dir / "agents" / "_shared" / "tools.yaml", "- calculator\n")
    _write(config_dir / "prompts" / "code.md", "Line one of a long prompt.\nLine two of a long prompt.\n")
    _write(
        config_dir / "agents" / "research.yaml",
        "research:\n  display_name: Research Agent\n  role: Researches topics\n  tools: [calculator]\n  rooms: [dev]\n",
    )
    return config_path


class TestIncludeTags:
    """Happy-path semantics of each include tag."""

    def test_include_resolves_nested_files_relative_to_including_file(self, tmp_path: Path) -> None:
        """!include nests recursively and resolves relative to the including file."""
        _write(tmp_path / "config.yaml", "outer: !include sub/inner.yaml\n")
        _write(tmp_path / "sub" / "inner.yaml", "deep: !include deeper.yaml\n")
        _write(tmp_path / "sub" / "deeper.yaml", "value: 42\n")

        data, files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"outer": {"deep": {"value": 42}}}
        assert files == frozenset(
            {
                (tmp_path / "config.yaml").resolve(),
                (tmp_path / "sub" / "inner.yaml").resolve(),
                (tmp_path / "sub" / "deeper.yaml").resolve(),
            },
        )

    def test_include_text_strips_exactly_one_trailing_newline(self, tmp_path: Path) -> None:
        """!include_text returns raw text with one trailing newline removed."""
        _write(tmp_path / "config.yaml", "prompt: !include_text prompt.md\n")
        _write(tmp_path / "prompt.md", "first line\nsecond line\n\n")

        data, files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"prompt": "first line\nsecond line\n"}
        assert (tmp_path / "prompt.md").resolve() in files

    def test_include_dir_list_orders_by_relative_path_and_recurses(self, tmp_path: Path) -> None:
        """!include_dir_list yields one item per file in lexicographic relative-path order."""
        _write(tmp_path / "config.yaml", "items: !include_dir_list items/\n")
        _write(tmp_path / "items" / "10.yaml", "ten\n")
        _write(tmp_path / "items" / "2.yml", "two\n")
        _write(tmp_path / "items" / "nested" / "b.yaml", "nested-b\n")
        _write(tmp_path / "items" / "notes.txt", "ignored\n")

        data, _files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"items": ["ten", "two", "nested-b"]}

    def test_dir_includes_skip_dot_and_underscore_names(self, tmp_path: Path) -> None:
        """Directory includes skip files and directories starting with '.' or '_'."""
        _write(tmp_path / "config.yaml", "named: !include_dir_named entries/\n")
        _write(tmp_path / "entries" / "kept.yaml", "1\n")
        _write(tmp_path / "entries" / ".hidden.yaml", "2\n")
        _write(tmp_path / "entries" / "_shared" / "snippet.yaml", "3\n")
        _write(tmp_path / "entries" / "_draft.yaml", "4\n")

        data, files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"named": {"kept": 1}}
        assert (tmp_path / "entries" / "_shared" / "snippet.yaml").resolve() not in files

    def test_include_dir_named_maps_filename_stems(self, tmp_path: Path) -> None:
        """!include_dir_named maps filename-without-extension to parsed content."""
        _write(tmp_path / "config.yaml", "named: !include_dir_named entries/\n")
        _write(tmp_path / "entries" / "alpha.yaml", "value: 1\n")
        _write(tmp_path / "entries" / "beta.yml", "value: 2\n")

        data, _files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"named": {"alpha": {"value": 1}, "beta": {"value": 2}}}

    def test_include_dir_merge_list_concatenates_lists(self, tmp_path: Path) -> None:
        """!include_dir_merge_list concatenates the lists from each file in order."""
        _write(tmp_path / "config.yaml", "merged: !include_dir_merge_list lists/\n")
        _write(tmp_path / "lists" / "a.yaml", "- 1\n- 2\n")
        _write(tmp_path / "lists" / "b.yaml", "- 3\n")

        data, _files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"merged": [1, 2, 3]}

    def test_include_dir_merge_named_merges_mappings(self, tmp_path: Path) -> None:
        """!include_dir_merge_named merges the mappings from each file."""
        _write(tmp_path / "config.yaml", "merged: !include_dir_merge_named maps/\n")
        _write(tmp_path / "maps" / "a.yaml", "one: 1\n")
        _write(tmp_path / "maps" / "b.yaml", "two: 2\n")

        data, _files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"merged": {"one": 1, "two": 2}}

    def test_empty_included_files_are_tolerated(self, tmp_path: Path) -> None:
        """Empty files resolve to None under !include and contribute nothing to dir tags."""
        _write(tmp_path / "config.yaml", "scalar: !include empty.yaml\nmerged: !include_dir_merge_named maps/\n")
        _write(tmp_path / "empty.yaml", "")
        _write(tmp_path / "maps" / "empty.yaml", "")
        _write(tmp_path / "maps" / "full.yaml", "key: value\n")

        data, files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"scalar": None, "merged": {"key": "value"}}
        assert (tmp_path / "empty.yaml").resolve() in files

    def test_empty_top_level_config_resolves_to_empty_dict(self, tmp_path: Path) -> None:
        """An empty top-level config parses to an empty dict, matching yaml.safe_load or {}."""
        _write(tmp_path / "config.yaml", "")

        data, files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {}
        assert files == frozenset({(tmp_path / "config.yaml").resolve()})

    def test_diamond_include_reads_the_shared_file_once(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A file reachable via two include paths is read once so digest and content stay coherent."""
        _write(tmp_path / "config.yaml", "a: !include a.yaml\nb: !include b.yaml\n")
        _write(tmp_path / "a.yaml", "tools: !include shared/tools.yaml\n")
        _write(tmp_path / "b.yaml", "tools: !include shared/tools.yaml\n")
        _write(tmp_path / "shared" / "tools.yaml", "- calculator\n")

        read_names: list[str] = []
        original_read_bytes = Path.read_bytes

        def _counting_read_bytes(self: Path) -> bytes:
            read_names.append(self.name)
            return original_read_bytes(self)

        monkeypatch.setattr(Path, "read_bytes", _counting_read_bytes)

        data, files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"a": {"tools": ["calculator"]}, "b": {"tools": ["calculator"]}}
        assert (tmp_path / "shared" / "tools.yaml").resolve() in files
        assert read_names.count("tools.yaml") == 1


class TestIncludeErrors:
    """Error reporting for broken include trees."""

    def test_missing_file_error_names_including_file_and_line(self, tmp_path: Path) -> None:
        """A missing include target reports the including file and tag line."""
        _write(tmp_path / "config.yaml", "top: value\nbroken: !include missing.yaml\n")

        with pytest.raises(ConfigIncludeError, match=r"missing\.yaml' does not exist \(in .*config\.yaml, line 2\)"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_missing_directory_error_names_including_file(self, tmp_path: Path) -> None:
        """A missing include directory reports the including file and tag line."""
        _write(tmp_path / "config.yaml", "broken: !include_dir_list nowhere/\n")

        with pytest.raises(ConfigIncludeError, match=r"'nowhere' does not exist \(in .*config\.yaml, line 1\)"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_absolute_path_is_rejected(self, tmp_path: Path) -> None:
        """Absolute include paths are rejected outright."""
        _write(tmp_path / "config.yaml", "broken: !include /etc/passwd\n")

        with pytest.raises(ConfigIncludeError, match="does not allow absolute paths"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_parent_escape_is_rejected(self, tmp_path: Path) -> None:
        """Includes resolving outside the config directory are rejected."""
        config_dir = tmp_path / "conf"
        _write(config_dir / "config.yaml", "broken: !include ../outside.yaml\n")
        _write(tmp_path / "outside.yaml", "value: 1\n")

        with pytest.raises(ConfigIncludeError, match="resolves outside the configuration directory"):
            load_yaml_config_source(config_dir / "config.yaml")

    def test_symlink_escape_is_rejected(self, tmp_path: Path) -> None:
        """A symlink pointing outside the config directory is rejected after resolution."""
        config_dir = tmp_path / "conf"
        _write(config_dir / "config.yaml", "broken: !include link.yaml\n")
        _write(tmp_path / "outside.yaml", "value: 1\n")
        (config_dir / "link.yaml").symlink_to(tmp_path / "outside.yaml")

        with pytest.raises(ConfigIncludeError, match="resolves outside the configuration directory"):
            load_yaml_config_source(config_dir / "config.yaml")

    def test_symlinked_directory_escape_is_rejected(self, tmp_path: Path) -> None:
        """A symlinked directory pointing outside the config directory is rejected before traversal."""
        config_dir = tmp_path / "conf"
        _write(config_dir / "config.yaml", "agents: !include_dir_merge_named agents/\n")
        (config_dir / "agents").mkdir()
        _write(tmp_path / "outside" / "external.yaml", "key: value\n")
        (config_dir / "agents" / "ext").symlink_to(tmp_path / "outside", target_is_directory=True)

        with pytest.raises(ConfigIncludeError, match="'ext' resolves outside the configuration directory"):
            load_yaml_config_source(config_dir / "config.yaml")

    def test_cycle_error_shows_the_include_chain(self, tmp_path: Path) -> None:
        """An include cycle reports the full chain of files."""
        _write(tmp_path / "config.yaml", "a: !include agents/b.yaml\n")
        _write(tmp_path / "agents" / "b.yaml", "b: !include ../config.yaml\n")

        with pytest.raises(ConfigIncludeError, match=r"config\.yaml -> agents/b\.yaml -> config\.yaml"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_duplicate_merge_named_key_names_both_files(self, tmp_path: Path) -> None:
        """Duplicate keys across merge_named files raise, naming the key and both files."""
        _write(tmp_path / "config.yaml", "merged: !include_dir_merge_named maps/\n")
        _write(tmp_path / "maps" / "a.yaml", "shared: 1\n")
        _write(tmp_path / "maps" / "b.yaml", "shared: 2\n")

        with pytest.raises(ConfigIncludeError, match=r"duplicate key 'shared'.*maps/a\.yaml.*maps/b\.yaml"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_duplicate_dir_named_stem_names_both_files(self, tmp_path: Path) -> None:
        """Duplicate filename stems across dir_named subdirectories raise instead of overwriting."""
        _write(tmp_path / "config.yaml", "named: !include_dir_named entries/\n")
        _write(tmp_path / "entries" / "one" / "same.yaml", "1\n")
        _write(tmp_path / "entries" / "two" / "same.yaml", "2\n")

        with pytest.raises(ConfigIncludeError, match=r"duplicate name 'same'.*one/same\.yaml.*two/same\.yaml"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_merge_list_rejects_non_list_content(self, tmp_path: Path) -> None:
        """merge_list files containing non-list YAML raise a typed error naming the file."""
        _write(tmp_path / "config.yaml", "merged: !include_dir_merge_list lists/\n")
        _write(tmp_path / "lists" / "bad.yaml", "key: value\n")

        with pytest.raises(ConfigIncludeError, match=r"'lists/bad\.yaml' must contain a YAML list, got dict"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_merge_named_rejects_non_mapping_content(self, tmp_path: Path) -> None:
        """merge_named files containing non-mapping YAML raise a typed error naming the file."""
        _write(tmp_path / "config.yaml", "merged: !include_dir_merge_named maps/\n")
        _write(tmp_path / "maps" / "bad.yaml", "- 1\n")

        with pytest.raises(ConfigIncludeError, match=r"'maps/bad\.yaml' must contain a YAML mapping, got list"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_parse_error_inside_included_file_names_that_file(self, tmp_path: Path) -> None:
        """A YAML syntax error inside an included file reports the included file's mark."""
        _write(tmp_path / "config.yaml", "broken: !include bad.yaml\n")
        _write(tmp_path / "bad.yaml", "key: [unclosed\n")

        with pytest.raises(yaml.YAMLError, match=r"bad\.yaml"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_hidden_file_component_is_rejected(self, tmp_path: Path) -> None:
        """!include_text of a dotfile is rejected with the including file and line."""
        _write(tmp_path / "config.yaml", "top: value\nsecrets: !include_text .env\n")
        _write(tmp_path / ".env", "SECRET=1\n")

        with pytest.raises(
            ConfigIncludeError,
            match=r"hidden path components: '\.env' \(in .*config\.yaml, line 2\)",
        ):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_hidden_directory_component_is_rejected(self, tmp_path: Path) -> None:
        """!include through a hidden directory is rejected outright."""
        _write(tmp_path / "config.yaml", "broken: !include .storage/x.yaml\n")
        _write(tmp_path / ".storage" / "x.yaml", "value: 1\n")

        with pytest.raises(ConfigIncludeError, match=r"hidden path components: '\.storage/x\.yaml'"):
            load_yaml_config_source(tmp_path / "config.yaml")

    def test_dot_navigation_components_stay_legal(self, tmp_path: Path) -> None:
        """`./` prefixes and in-config-dir `..` segments are navigation, not hidden names."""
        _write(tmp_path / "config.yaml", "sub: !include ./sub/x.yaml\n")
        _write(tmp_path / "sub" / "x.yaml", "shared: !include ../_shared/y.yaml\nvalue: 1\n")
        _write(tmp_path / "_shared" / "y.yaml", "2\n")

        data, _files = load_yaml_config_source(tmp_path / "config.yaml")

        assert data == {"sub": {"shared": 2, "value": 1}}


class TestPartialSourceFiles:
    """Failed loads expose the files read before the failure."""

    def test_parse_time_failure_carries_files_read_so_far(self, tmp_path: Path) -> None:
        """A syntax error mid-tree records every file read up to and including the broken one."""
        config_path = _write(tmp_path / "config.yaml", "a: !include good.yaml\nb: !include bad.yaml\n")
        good = _write(tmp_path / "good.yaml", "1\n")
        bad = _write(tmp_path / "bad.yaml", "key: [unclosed\n")

        with pytest.raises(yaml.YAMLError) as exc_info:
            load_yaml_config_source(config_path)

        assert partial_source_files(exc_info.value) == frozenset(
            {config_path.resolve(), good.resolve(), bad.resolve()},
        )

    def test_validation_time_failure_carries_the_full_file_set(self, tmp_path: Path) -> None:
        """A parsed-but-invalid config exposes the complete source set on the exception."""
        split_path = _write_split_config(tmp_path)
        _write(tmp_path / "agents" / "research.yaml", "research: [not, a, mapping]\n")
        runtime_paths = resolve_runtime_paths(config_path=split_path)

        with pytest.raises(ValidationError) as exc_info:
            load_config(runtime_paths)

        files = partial_source_files(exc_info.value)
        assert files is not None
        assert split_path.resolve() in files
        assert len(files) == 6


class TestRoundTripEquivalence:
    """A split config must load identically to the monolith it came from."""

    def test_split_config_loads_to_same_dict_as_monolith(self, tmp_path: Path) -> None:
        """The include-based fixture resolves to exactly the monolith dict."""
        monolith_path = _write(tmp_path / "monolith" / "config.yaml", yaml.dump(MONOLITH_CONFIG))
        split_path = _write_split_config(tmp_path / "split")

        monolith_data, monolith_files = load_yaml_config_source(monolith_path)
        split_data, split_files = load_yaml_config_source(split_path)

        assert split_data == monolith_data
        assert monolith_files == frozenset({monolith_path.resolve()})
        assert len(split_files) == 6

    def test_load_config_validates_split_config_and_reports_source_files(self, tmp_path: Path) -> None:
        """load_config resolves includes into a validated Config carrying the file set."""
        split_path = _write_split_config(tmp_path)
        runtime_paths = resolve_runtime_paths(config_path=split_path)

        config = load_config(runtime_paths)

        assert set(config.agents) == {"code", "research"}
        assert config.agents["code"].instructions == ["Line one of a long prompt.\nLine two of a long prompt."]
        assert split_path.resolve() in config.source_files
        assert len(config.source_files) == 6

    def test_load_config_logs_the_source_file_count(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """The loaded_agent_configuration log line reports how many files resolved."""
        split_path = _write_split_config(tmp_path)
        events: list[tuple[str, dict[str, object]]] = []

        class _RecordingLogger:
            def info(self, event: str, **kwargs: object) -> None:
                events.append((event, kwargs))

        monkeypatch.setattr("mindroom.config.main.logger", _RecordingLogger())
        load_config(resolve_runtime_paths(config_path=split_path))

        counts = [kwargs["source_file_count"] for event, kwargs in events if event == "loaded_agent_configuration"]
        assert counts == [6]

    def test_cli_validate_supports_include_configs(self, tmp_path: Path) -> None:
        """`mindroom config validate --path` accepts an include-based config."""
        split_path = _write_split_config(tmp_path)

        result = runner.invoke(app, ["config", "validate", "--path", str(split_path)])

        assert result.exit_code == 0, result.output
        assert "Configuration is valid" in result.output

    def test_cli_resolve_prints_merged_yaml_matching_monolith(self, tmp_path: Path) -> None:
        """`mindroom config resolve` output parses back to the monolith dict."""
        split_path = _write_split_config(tmp_path)

        result = runner.invoke(app, ["config", "resolve", "--path", str(split_path)])

        assert result.exit_code == 0, result.output
        assert yaml.safe_load(result.stdout) == MONOLITH_CONFIG

    def test_cli_resolve_reports_include_errors(self, tmp_path: Path) -> None:
        """`mindroom config resolve` surfaces include errors as validation output."""
        config_path = _write(tmp_path / "config.yaml", "broken: !include missing.yaml\n")

        result = runner.invoke(app, ["config", "resolve", "--path", str(config_path)])

        assert result.exit_code == 1
        assert "missing.yaml" in result.output


class TestWatchPaths:
    """The dynamic multi-file watcher backing include-aware hot reload."""

    @pytest.mark.asyncio
    async def test_scans_run_off_the_event_loop(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Stat scans must cross the thread offload boundary, not block the loop."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.001)
        path = _write(tmp_path / "config.yaml", "a: 1\n")
        offloaded: list[str] = []
        stop_event = asyncio.Event()

        async def fake_to_thread(function: Callable[..., object], *args: object, **kwargs: object) -> object:
            offloaded.append(getattr(function, "__name__", repr(function)))
            return function(*args, **kwargs)

        async def on_change() -> None:
            return

        monkeypatch.setattr(file_watcher.asyncio, "to_thread", fake_to_thread)
        watch_task = asyncio.create_task(file_watcher.watch_paths(lambda: (path,), on_change, stop_event))
        await asyncio.sleep(0.05)
        stop_event.set()
        await asyncio.wait_for(watch_task, timeout=2)

        assert offloaded
        assert set(offloaded) == {"paths_mtime_snapshot"}

    @pytest.mark.asyncio
    async def test_change_to_any_watched_file_triggers_callback(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Editing an included file fires the watch callback."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.01)
        top = _write(tmp_path / "config.yaml", "a: 1\n")
        included = _write(tmp_path / "agents.yaml", "b: 2\n")
        changed = asyncio.Event()
        stop_event = asyncio.Event()

        async def on_change() -> None:
            changed.set()

        watch_task = asyncio.create_task(
            file_watcher.watch_paths(lambda: (top, included), on_change, stop_event),
        )
        await asyncio.sleep(0.05)
        future_time = time.time() + 5
        included.write_text("b: 3\n", encoding="utf-8")
        os.utime(included, (future_time, future_time))

        await asyncio.wait_for(changed.wait(), timeout=2)
        stop_event.set()
        await watch_task

    @pytest.mark.asyncio
    async def test_burst_across_files_fires_exactly_once_after_quiescence(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Several files changing in one update burst produce one callback once writes settle."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.01)
        files = [_write(tmp_path / name, "a: 1\n") for name in ("config.yaml", "b.yaml", "c.yaml")]
        callbacks: list[bool] = []
        stop_event = asyncio.Event()

        async def on_change() -> None:
            callbacks.append(True)

        watch_task = asyncio.create_task(file_watcher.watch_paths(lambda: tuple(files), on_change, stop_event))
        await asyncio.sleep(0.05)
        future_time = time.time() + 5
        for path in files:
            path.write_text("a: 2\n", encoding="utf-8")
            os.utime(path, (future_time, future_time))
        await asyncio.sleep(0.2)

        stop_event.set()
        await watch_task
        assert callbacks == [True]

    @pytest.mark.asyncio
    async def test_continuous_churn_keeps_deferring_until_a_quiet_scan(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Changes arriving on consecutive scans defer the callback; the first quiet scan fires once."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.001)
        path = (tmp_path / "config.yaml").resolve()
        # Baseline, then four scans that each see a new mtime, then stable scans.
        scan_mtimes = [1, 2, 3, 4, 5]
        scans = {"count": 0}
        stop_event = asyncio.Event()
        fired_at_scan: list[int] = []

        def scripted_snapshot(paths: object) -> dict[Path, int]:
            del paths
            index = min(scans["count"], len(scan_mtimes) - 1)
            scans["count"] += 1
            return {path: scan_mtimes[index]}

        async def on_change() -> None:
            fired_at_scan.append(scans["count"])
            stop_event.set()

        monkeypatch.setattr(file_watcher, "paths_mtime_snapshot", scripted_snapshot)
        await asyncio.wait_for(file_watcher.watch_paths(lambda: (path,), on_change, stop_event), timeout=2)

        # Baseline consumed scan 1; scans 2-5 each saw a change; scan 6 was the
        # first quiet scan and fired the single callback.
        assert fired_at_scan == [6]

    @pytest.mark.asyncio
    async def test_a_newly_vanished_file_defers_the_pending_callback(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A file vanishing mid-burst keeps deferring; the fire happens once the set is stable."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.001)
        top = (tmp_path / "config.yaml").resolve()
        included = (tmp_path / "agents.yaml").resolve()
        scan_snapshots = [
            {top: 1, included: 1},  # baseline
            {top: 2, included: 1},  # change -> dirty
            {top: 2, included: 0},  # included vanished mid-burst -> keep deferring
            {top: 2, included: 0},  # still missing but no new transition -> quiet, fire
        ]
        scans = {"count": 0}
        fired_at_scan: list[int] = []
        stop_event = asyncio.Event()

        def scripted_snapshot(paths: object) -> dict[Path, int]:
            del paths
            index = min(scans["count"], len(scan_snapshots) - 1)
            scans["count"] += 1
            return dict(scan_snapshots[index])

        async def on_change() -> None:
            fired_at_scan.append(scans["count"])
            stop_event.set()

        monkeypatch.setattr(file_watcher, "paths_mtime_snapshot", scripted_snapshot)
        await asyncio.wait_for(file_watcher.watch_paths(lambda: (top, included), on_change, stop_event), timeout=2)

        assert fired_at_scan == [4]

    @pytest.mark.asyncio
    async def test_missing_file_stays_silent_until_it_reappears(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Deleting a watched file never fires; its reappearance fires after quiescence."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.01)
        top = _write(tmp_path / "config.yaml", "a: 1\n")
        included = _write(tmp_path / "agents.yaml", "b: 2\n")
        callbacks: list[bool] = []
        stop_event = asyncio.Event()

        async def on_change() -> None:
            callbacks.append(True)

        watch_task = asyncio.create_task(file_watcher.watch_paths(lambda: (top, included), on_change, stop_event))
        await asyncio.sleep(0.05)
        included.unlink()
        await asyncio.sleep(0.1)
        assert callbacks == []

        future_time = time.time() + 5
        _write(included, "b: 3\n")
        os.utime(included, (future_time, future_time))
        await asyncio.sleep(0.1)

        stop_event.set()
        await watch_task
        assert callbacks == [True]

    @pytest.mark.asyncio
    async def test_paths_entering_the_set_are_baselined_silently(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Growing the watched set (a reload re-derived includes) does not fire a change."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.01)
        top = _write(tmp_path / "config.yaml", "a: 1\n")
        extra = _write(tmp_path / "extra.yaml", "b: 2\n")
        watched: list[Path] = [top]
        callbacks: list[bool] = []
        stop_event = asyncio.Event()

        async def on_change() -> None:
            callbacks.append(True)

        watch_task = asyncio.create_task(file_watcher.watch_paths(lambda: tuple(watched), on_change, stop_event))
        await asyncio.sleep(0.05)
        watched.append(extra)
        await asyncio.sleep(0.1)

        stop_event.set()
        await watch_task
        assert callbacks == []


def _reload_lifecycle(config_path: Path) -> ConfigReloadLifecycle:
    """Build one reload lifecycle that loads fresh configs and never applies plans."""

    async def _load_initial(config: Config) -> bool:
        del config
        return True

    async def _apply_plan(config: Config, plan: object, plugin_changes: tuple[str, ...]) -> bool:
        del config, plan, plugin_changes
        raise AssertionError

    return ConfigReloadLifecycle(
        runtime_paths=resolve_runtime_paths(config_path=config_path),
        is_running=lambda: True,
        current_config=lambda: None,
        agent_bots=dict,
        load_initial_config=_load_initial,
        apply_update_plan=_apply_plan,
        response_admission_gate=ResponseAdmissionGate(),
    )


class TestFailedReloadWatchSet:
    """A failed orchestrator reload keeps its own include files reachable by the watcher."""

    @pytest.mark.asyncio
    async def test_failed_validation_reload_records_its_source_files(self, tmp_path: Path) -> None:
        """A parsed-but-invalid reload records its source set; a later success clears it."""
        config_path = _write(
            tmp_path / "config.yaml",
            "agents: !include agents.yaml\nmodels:\n  default:\n    provider: ollama\n    id: test-model\n",
        )
        agents_path = _write(tmp_path / "agents.yaml", "broken: [not, a, mapping]\n")
        lifecycle = _reload_lifecycle(config_path)

        await lifecycle._apply_queued_config_reload()

        assert lifecycle.failed_reload_source_files == frozenset(
            {config_path.resolve(), agents_path.resolve()},
        )

        _write(tmp_path / "agents.yaml", "code:\n  display_name: Code\n  role: Writes code\n")
        await lifecycle._apply_queued_config_reload()

        assert lifecycle.failed_reload_source_files is None

    @pytest.mark.asyncio
    async def test_broken_new_include_file_stays_watched_until_fixed(self, tmp_path: Path) -> None:
        """A syntax error in a newly referenced include file is watched; fixing only it recovers."""
        config_path = _write(
            tmp_path / "config.yaml",
            "models:\n  default:\n    provider: ollama\n    id: test-model\n",
        )
        lifecycle = _reload_lifecycle(config_path)
        await lifecycle._apply_queued_config_reload()
        assert lifecycle.failed_reload_source_files is None

        # Reference a NEW include file whose content is a syntax error, so the
        # reload fails while parsing and the last good config never saw the file.
        _write(
            config_path,
            "agents: !include agents/new.yaml\nmodels:\n  default:\n    provider: ollama\n    id: test-model\n",
        )
        new_file = _write(tmp_path / "agents" / "new.yaml", "code: [unclosed\n")
        await lifecycle._apply_queued_config_reload()

        assert lifecycle.failed_reload_source_files is not None
        assert new_file.resolve() in lifecycle.failed_reload_source_files

        # Fixing ONLY the new include file makes the retry reload succeed.
        _write(new_file, "code:\n  display_name: Code\n  role: Writes code\n")
        await lifecycle._apply_queued_config_reload()

        assert lifecycle.failed_reload_source_files is None

    @pytest.mark.asyncio
    async def test_watch_paths_picks_up_a_fix_to_a_failed_reloads_new_include(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Through the dynamic watcher, fixing only the broken new include triggers the retry."""
        monkeypatch.setattr(file_watcher, "_WATCH_SCAN_INTERVAL_SECONDS", 0.01)
        config_path = _write(
            tmp_path / "config.yaml",
            "models:\n  default:\n    provider: ollama\n    id: test-model\n",
        )
        lifecycle = _reload_lifecycle(config_path)
        await lifecycle._apply_queued_config_reload()
        reloads = asyncio.Event()
        stop_event = asyncio.Event()

        def provider() -> set[Path]:
            # Mirrors orchestrator._watch_config_task: the top-level file plus
            # whatever a failed reload attempt read.
            watched = {config_path}
            if lifecycle.failed_reload_source_files:
                watched.update(lifecycle.failed_reload_source_files)
            return watched

        async def on_change() -> None:
            await lifecycle._apply_queued_config_reload()
            reloads.set()

        watch_task = asyncio.create_task(file_watcher.watch_paths(provider, on_change, stop_event))
        await asyncio.sleep(0.05)

        future_time = time.time() + 5
        new_file = _write(tmp_path / "agents" / "new.yaml", "code: [unclosed\n")
        _write(
            config_path,
            "agents: !include agents/new.yaml\nmodels:\n  default:\n    provider: ollama\n    id: test-model\n",
        )
        os.utime(new_file, (future_time, future_time))
        os.utime(config_path, (future_time, future_time))
        await asyncio.wait_for(reloads.wait(), timeout=2)
        assert lifecycle.failed_reload_source_files is not None
        assert new_file.resolve() in lifecycle.failed_reload_source_files

        # Let the watcher baseline the newly watched file, then fix ONLY it.
        await asyncio.sleep(0.05)
        reloads.clear()
        future_time += 5
        _write(new_file, "code:\n  display_name: Code\n  role: Writes code\n")
        os.utime(new_file, (future_time, future_time))
        await asyncio.wait_for(reloads.wait(), timeout=2)

        stop_event.set()
        await watch_task
        assert lifecycle.failed_reload_source_files is None
