"""Tests for the config.yaml parser, ensure_project_config, and resolve_review_strategies."""

from pathlib import Path

import pytest

import common
import project


# ═══════════════════════════════════════════════════════════════════════════
# _parse_config_yaml — low-level parser
# ═══════════════════════════════════════════════════════════════════════════


class TestParseConfigYaml:
    def test_scalar_values(self):
        raw = project._parse_config_yaml("no_push: true\nreview_polish_weight: 0.7\n")
        assert raw["no_push"] == "true"
        assert raw["review_polish_weight"] == "0.7"

    def test_comments_and_blanks_are_skipped(self):
        raw = project._parse_config_yaml(
            "# header comment\n\nno_push: false\n\n# another comment\n"
        )
        assert raw == {"no_push": "false"}

    def test_string_list(self):
        text = (
            "review_strategies:\n"
            "  - surface-sweep\n"
            "  - deep-dive\n"
            "  - error-handling\n"
        )
        raw = project._parse_config_yaml(text)
        assert raw["review_strategies"] == ["surface-sweep", "deep-dive", "error-handling"]

    def test_dict_block(self):
        text = (
            "strategy_max_tasks:\n"
            "  deep-dive: 2\n"
            "  surface-sweep: 3\n"
        )
        raw = project._parse_config_yaml(text)
        assert raw["strategy_max_tasks"] == {"deep-dive": "2", "surface-sweep": "3"}

    def test_dict_keys_preserve_hyphens(self):
        text = "strategy_max_tasks:\n  deep-dive: 2\n"
        raw = project._parse_config_yaml(text)
        assert "deep-dive" in raw["strategy_max_tasks"]

    def test_list_of_dicts(self):
        text = (
            "custom_review_strategies:\n"
            "  - name: security-audit\n"
            "    focus: Focus on SECURITY\n"
            "    max_tasks: 3\n"
            "  - name: api-contract\n"
            "    focus: Verify API endpoints\n"
        )
        raw = project._parse_config_yaml(text)
        items = raw["custom_review_strategies"]
        assert len(items) == 2
        assert items[0]["name"] == "security-audit"
        assert items[0]["focus"] == "Focus on SECURITY"
        assert items[0]["max_tasks"] == "3"
        assert items[1]["name"] == "api-contract"
        assert items[1]["focus"] == "Verify API endpoints"

    def test_top_level_key_hyphen_normalization(self):
        raw = project._parse_config_yaml("review-polish-weight: 0.5\n")
        assert "review_polish_weight" in raw

    def test_empty_input(self):
        assert project._parse_config_yaml("") == {}

    def test_comments_only(self):
        assert project._parse_config_yaml("# just a comment\n# another\n") == {}

    def test_mixed_scalars_and_lists(self):
        text = (
            "no_push: true\n"
            "review_strategies:\n"
            "  - deep-dive\n"
            "review_polish_weight: 0.5\n"
        )
        raw = project._parse_config_yaml(text)
        assert raw["no_push"] == "true"
        assert raw["review_strategies"] == ["deep-dive"]
        assert raw["review_polish_weight"] == "0.5"

    def test_quoted_values_stripped(self):
        text = (
            "custom_review_strategies:\n"
            '  - name: sec\n'
            '    focus: "Focus on security"\n'
        )
        raw = project._parse_config_yaml(text)
        assert raw["custom_review_strategies"][0]["focus"] == "Focus on security"

    def test_single_quoted_values_stripped(self):
        text = (
            "custom_review_strategies:\n"
            "  - name: sec\n"
            "    focus: 'Focus on security'\n"
        )
        raw = project._parse_config_yaml(text)
        assert raw["custom_review_strategies"][0]["focus"] == "Focus on security"

    def test_empty_list_block(self):
        text = "review_strategies:\nno_push: false\n"
        raw = project._parse_config_yaml(text)
        assert raw.get("no_push") == "false"

    def test_inline_comments_not_supported_but_no_crash(self):
        raw = project._parse_config_yaml("no_push: false  # comment\n")
        assert "no_push" in raw

    def test_list_with_comments_between_items(self):
        text = (
            "review_strategies:\n"
            "  - surface-sweep\n"
            "  # disabled: error-handling\n"
            "  - deep-dive\n"
        )
        raw = project._parse_config_yaml(text)
        assert raw["review_strategies"] == ["surface-sweep", "deep-dive"]


# ═══════════════════════════════════════════════════════════════════════════
# load_project_config — typed coercion
# ═══════════════════════════════════════════════════════════════════════════


class TestLoadProjectConfigTyped:
    def test_all_defaults_when_no_file(self, project_dir: Path):
        cfg = project.load_project_config(project_dir)
        assert cfg["no_push"] is False
        assert cfg["review_polish_weight"] == 0.9
        assert cfg["review_max_tasks_polish"] == 5
        assert cfg["review_max_tasks_feature"] == 3
        assert cfg["review_strategies"] is None
        assert cfg["strategy_max_tasks"] == {}
        assert cfg["custom_review_strategies"] == []
        assert cfg["plan_batch_size"] is None
        assert cfg["execute_batch_size"] is None

    def test_batch_sizes_explicit_int(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "plan_batch_size: 3\nexecute_batch_size: 2\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["plan_batch_size"] == 3
        assert cfg["execute_batch_size"] == 2

    def test_batch_sizes_null_means_legacy_batched(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "plan_batch_size: null\nexecute_batch_size: null\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["plan_batch_size"] is None
        assert cfg["execute_batch_size"] is None

    def test_batch_sizes_zero_or_negative_clamps_to_null(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "plan_batch_size: 0\nexecute_batch_size: -3\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["plan_batch_size"] is None
        assert cfg["execute_batch_size"] is None

    def test_polish_weight_as_float(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "review_polish_weight: 0.42\n"
        )
        cfg = project.load_project_config(project_dir)
        assert isinstance(cfg["review_polish_weight"], float)
        assert cfg["review_polish_weight"] == 0.42

    def test_max_tasks_as_int(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "review_max_tasks_polish: 8\nreview_max_tasks_feature: 2\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["review_max_tasks_polish"] == 8
        assert isinstance(cfg["review_max_tasks_polish"], int)
        assert cfg["review_max_tasks_feature"] == 2

    def test_strategy_list_filtering(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "review_strategies:\n  - deep-dive\n  - surface-sweep\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["review_strategies"] == ["deep-dive", "surface-sweep"]

    def test_strategy_max_tasks_as_int_dict(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "strategy_max_tasks:\n  deep-dive: 2\n  surface-sweep: 3\n"
        )
        cfg = project.load_project_config(project_dir)
        limits = cfg["strategy_max_tasks"]
        assert limits["deep-dive"] == 2
        assert isinstance(limits["deep-dive"], int)

    def test_custom_strategies_parsed(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "custom_review_strategies:\n"
            "  - name: security-audit\n"
            "    focus: Focus on SECURITY\n"
            "    max_tasks: 4\n"
        )
        cfg = project.load_project_config(project_dir)
        custom = cfg["custom_review_strategies"]
        assert len(custom) == 1
        assert custom[0]["name"] == "security-audit"
        assert custom[0]["focus"] == "Focus on SECURITY"
        assert custom[0]["max_tasks"] == 4
        assert isinstance(custom[0]["max_tasks"], int)

    def test_custom_strategies_without_max_tasks(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "custom_review_strategies:\n"
            "  - name: accessibility\n"
            "    focus: Check a11y\n"
        )
        cfg = project.load_project_config(project_dir)
        assert "max_tasks" not in cfg["custom_review_strategies"][0]

    def test_invalid_float_keeps_default(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "review_polish_weight: not_a_number\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["review_polish_weight"] == 0.9

    def test_invalid_int_keeps_default(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "review_max_tasks_polish: abc\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["review_max_tasks_polish"] == 5

    def test_no_push_bool_coercion(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text("no_push: true\n")
        cfg = project.load_project_config(project_dir)
        assert cfg["no_push"] is True

    def test_incomplete_custom_strategy_ignored(self, project_dir: Path):
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "custom_review_strategies:\n"
            "  - name: no-focus-field\n"
        )
        cfg = project.load_project_config(project_dir)
        assert cfg["custom_review_strategies"] == []

    def test_full_config_roundtrip(self, project_dir: Path):
        text = (
            "no_push: true\n"
            "review_polish_weight: 0.7\n"
            "review_max_tasks_polish: 3\n"
            "review_max_tasks_feature: 2\n"
            "review_strategies:\n"
            "  - surface-sweep\n"
            "  - deep-dive\n"
            "strategy_max_tasks:\n"
            "  deep-dive: 1\n"
            "custom_review_strategies:\n"
            "  - name: sec\n"
            "    focus: security\n"
            "    max_tasks: 2\n"
        )
        (project_dir / ".autobuilder" / "config.yaml").write_text(text)
        cfg = project.load_project_config(project_dir)
        assert cfg["no_push"] is True
        assert cfg["review_polish_weight"] == 0.7
        assert cfg["review_max_tasks_polish"] == 3
        assert cfg["review_max_tasks_feature"] == 2
        assert cfg["review_strategies"] == ["surface-sweep", "deep-dive"]
        assert cfg["strategy_max_tasks"]["deep-dive"] == 1
        assert cfg["custom_review_strategies"][0]["name"] == "sec"
        assert cfg["custom_review_strategies"][0]["max_tasks"] == 2


# ═══════════════════════════════════════════════════════════════════════════
# ensure_project_config — auto-generation
# ═══════════════════════════════════════════════════════════════════════════


class TestEnsureProjectConfig:
    def test_creates_config_when_absent(self, project_dir: Path):
        path = project.ensure_project_config(project_dir)
        assert path.exists()
        assert path.name == "config.yaml"
        assert path.parent.name == ".autobuilder"

    def test_generated_config_is_parseable(self, project_dir: Path):
        project.ensure_project_config(project_dir)
        cfg = project.load_project_config(project_dir)
        assert cfg["review_polish_weight"] == 0.9
        assert cfg["review_max_tasks_polish"] == 5
        assert cfg["review_max_tasks_feature"] == 3

    def test_generated_config_has_all_strategies(self, project_dir: Path):
        project.ensure_project_config(project_dir)
        cfg = project.load_project_config(project_dir)
        assert len(cfg["review_strategies"]) == len(common.REVIEW_STRATEGIES)
        for s in common.REVIEW_STRATEGIES:
            assert s["name"] in cfg["review_strategies"]

    def test_idempotent_does_not_overwrite(self, project_dir: Path):
        project.ensure_project_config(project_dir)
        config_path = project_dir / ".autobuilder" / "config.yaml"
        original = config_path.read_text()

        # Modify the file
        config_path.write_text("no_push: true\n")
        project.ensure_project_config(project_dir)

        # Should not have been overwritten
        assert config_path.read_text() == "no_push: true\n"

    def test_creates_parent_dirs(self, tmp_path: Path):
        proj = tmp_path / "new-project"
        # Don't create .autobuilder — ensure_project_config should do it
        path = project.ensure_project_config(proj)
        assert path.exists()

    def test_returns_existing_path(self, project_dir: Path):
        config_path = project_dir / ".autobuilder" / "config.yaml"
        config_path.write_text("no_push: false\n")
        result = project.ensure_project_config(project_dir)
        assert result == config_path


# ═══════════════════════════════════════════════════════════════════════════
# resolve_review_strategies — strategy pool building
# ═══════════════════════════════════════════════════════════════════════════


class TestResolveReviewStrategies:
    def test_all_builtin_when_no_filter(self):
        cfg = {"review_strategies": None, "custom_review_strategies": []}
        strategies = project.resolve_review_strategies(cfg)
        assert len(strategies) == len(common.REVIEW_STRATEGIES)

    def test_filters_to_enabled_only(self):
        cfg = {
            "review_strategies": ["surface-sweep", "deep-dive"],
            "custom_review_strategies": [],
        }
        strategies = project.resolve_review_strategies(cfg)
        names = [s["name"] for s in strategies]
        assert names == ["surface-sweep", "deep-dive"]

    def test_preserves_builtin_focus_text(self):
        cfg = {
            "review_strategies": ["surface-sweep"],
            "custom_review_strategies": [],
        }
        strategies = project.resolve_review_strategies(cfg)
        assert "focus" in strategies[0]
        assert len(strategies[0]["focus"]) > 10

    def test_merges_custom_strategies(self):
        cfg = {
            "review_strategies": ["surface-sweep"],
            "custom_review_strategies": [
                {"name": "security", "focus": "Check security"},
            ],
        }
        strategies = project.resolve_review_strategies(cfg)
        names = [s["name"] for s in strategies]
        assert "surface-sweep" in names
        assert "security" in names
        assert len(strategies) == 2

    def test_custom_strategy_with_max_tasks(self):
        cfg = {
            "review_strategies": None,
            "custom_review_strategies": [
                {"name": "sec", "focus": "Security", "max_tasks": 3},
            ],
        }
        strategies = project.resolve_review_strategies(cfg)
        sec = [s for s in strategies if s["name"] == "sec"][0]
        assert sec["max_tasks"] == 3

    def test_empty_filter_falls_back_to_all(self):
        cfg = {"review_strategies": [], "custom_review_strategies": []}
        strategies = project.resolve_review_strategies(cfg)
        assert len(strategies) == len(common.REVIEW_STRATEGIES)

    def test_invalid_names_produce_empty_then_fallback(self):
        cfg = {
            "review_strategies": ["nonexistent-strategy"],
            "custom_review_strategies": [],
        }
        strategies = project.resolve_review_strategies(cfg)
        assert len(strategies) == len(common.REVIEW_STRATEGIES)

    def test_does_not_mutate_builtin_strategies(self):
        original_count = len(common.REVIEW_STRATEGIES)
        original_first = dict(common.REVIEW_STRATEGIES[0])
        cfg = {
            "review_strategies": ["surface-sweep"],
            "custom_review_strategies": [
                {"name": "custom", "focus": "test"},
            ],
        }
        project.resolve_review_strategies(cfg)
        assert len(common.REVIEW_STRATEGIES) == original_count
        assert common.REVIEW_STRATEGIES[0] == original_first

    def test_custom_only_no_builtin(self):
        cfg = {
            "review_strategies": ["nonexistent"],
            "custom_review_strategies": [
                {"name": "my-only-strategy", "focus": "Do everything"},
            ],
        }
        strategies = project.resolve_review_strategies(cfg)
        # Falls back to all built-in + custom because filter produced empty
        assert any(s["name"] == "my-only-strategy" for s in strategies)


# ═══════════════════════════════════════════════════════════════════════════
# pick_review_strategy with custom pool
# ═══════════════════════════════════════════════════════════════════════════


class TestPickReviewStrategyWithPool:
    def test_round_robin_with_custom_pool(self):
        import prompt
        pool = [
            {"name": "alpha", "focus": "a"},
            {"name": "beta", "focus": "b"},
        ]
        s1 = prompt.pick_review_strategy(1, strategies=pool)
        s2 = prompt.pick_review_strategy(2, strategies=pool)
        s3 = prompt.pick_review_strategy(3, strategies=pool)
        assert s1["name"] == "alpha"
        assert s2["name"] == "beta"
        assert s3["name"] == "alpha"

    def test_none_strategies_uses_builtin(self):
        import prompt
        s = prompt.pick_review_strategy(1, strategies=None)
        assert s["name"] == common.REVIEW_STRATEGIES[0]["name"]

    def test_journal_weighted_with_custom_pool(self, project_dir: Path):
        import prompt
        import status

        pool = [
            {"name": "alpha", "focus": "a"},
            {"name": "beta", "focus": "b"},
        ]
        entries = [
            {"iter": i, "phases": ["review"], "strategy": "alpha", "outcome": "productive"}
            for i in range(1, 6)
        ]
        for e in entries:
            status.append_journal_entry(project_dir, e)

        from unittest.mock import patch
        counts = {"alpha": 0, "beta": 0}
        with patch("prompt._random.random") as mock_rand:
            for val in [x / 50 for x in range(50)]:
                mock_rand.return_value = val
                s = prompt.pick_review_strategy(6, project_dir=project_dir, strategies=pool)
                counts[s["name"]] += 1

        assert counts["alpha"] > 0
        assert counts["beta"] > 0


# ═══════════════════════════════════════════════════════════════════════════
# validate_executor_config — startup validation of executor + model
# ═══════════════════════════════════════════════════════════════════════════


class TestValidateExecutorConfig:
    """Tests stub binary presence + the CLI model-list query."""

    def _patch_which(self, monkeypatch, present: set):
        """Make ``shutil.which`` return a fake path only for binaries in *present*."""
        def fake_which(cmd, *_a, **_kw):
            return f"/fake/bin/{cmd}" if cmd in present else None
        monkeypatch.setattr(project.shutil, "which", fake_which)

    def _patch_cursor_models(self, monkeypatch, models):
        """Stub the cursor model query so tests don't shell out."""
        # Reset cache so the stub is observed fresh each test, and clear
        # the env-var short-circuit so the stub is actually consulted.
        project._MODELS_CACHE.clear()
        monkeypatch.delenv("AUTOBUILDER_SKIP_MODEL_QUERY", raising=False)
        monkeypatch.setattr(
            project, "_query_cursor_models",
            lambda _bin: list(models) if models is not None else None,
        )

    def _patch_opencode_models(self, monkeypatch, models):
        """Stub the opencode model query so tests don't shell out."""
        project._MODELS_CACHE.clear()
        monkeypatch.delenv("AUTOBUILDER_SKIP_MODEL_QUERY", raising=False)
        monkeypatch.setattr(
            project, "_query_opencode_models",
            lambda _bin: list(models) if models is not None else None,
        )

    def test_valid_default_passes(self, monkeypatch):
        self._patch_which(monkeypatch, {"cursor"})
        self._patch_cursor_models(monkeypatch, ["claude-opus-4-7-thinking-high"])
        project.validate_executor_config(
            "cursor", "claude-opus-4-7-thinking-high", {},
        )

    def test_unknown_executor_raises(self, monkeypatch):
        self._patch_which(monkeypatch, {"cursor", "codex", "claude"})
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("gpt-shell", None, {})
        assert "unknown executor" in str(ei.value)
        assert "'gpt-shell'" in str(ei.value)

    def test_missing_binary_raises(self, monkeypatch):
        self._patch_which(monkeypatch, set())  # nothing on PATH
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("cursor", None, {})
        msg = str(ei.value)
        assert "requires 'cursor' on $PATH" in msg
        assert "$CURSOR_CMD" in msg

    def test_env_override_overrides_binary_name(self, monkeypatch):
        monkeypatch.setenv("CURSOR_CMD", "/opt/custom/cursor-agent")
        self._patch_which(monkeypatch, {"/opt/custom/cursor-agent"})
        self._patch_cursor_models(monkeypatch, ["sonnet"])
        project.validate_executor_config("cursor", None, {})

    def test_empty_model_raises(self, monkeypatch):
        self._patch_which(monkeypatch, {"cursor"})
        self._patch_cursor_models(monkeypatch, ["any"])
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("cursor", "   ", {})
        assert "must not be empty" in str(ei.value)

    def test_cursor_model_must_be_in_cli_list(self, monkeypatch):
        self._patch_which(monkeypatch, {"cursor"})
        # >6 models so the "run --list-models for the rest" hint renders.
        self._patch_cursor_models(monkeypatch, [
            f"model-{i}" for i in range(10)
        ] + ["claude-opus-4-7-thinking-high"])
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config(
                "cursor", "claude-opus-99-imaginary", {},
            )
        msg = str(ei.value)
        assert "is not available for cursor" in msg
        # Should mention how to see the full list when >6 models exist.
        assert "--list-models" in msg
        assert "Known models:" in msg

    def test_cursor_short_list_omits_more_hint(self, monkeypatch):
        # With ≤6 models, the inline "Known models:" line is the full
        # list, so the "(… N more)" hint is omitted.
        self._patch_which(monkeypatch, {"cursor"})
        self._patch_cursor_models(monkeypatch, ["a", "b", "c"])
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("cursor", "missing", {})
        msg = str(ei.value)
        assert "Known models: a, b, c" in msg
        assert "more" not in msg.split("Known models:", 1)[1]

    def test_cursor_suggests_near_matches(self, monkeypatch):
        self._patch_which(monkeypatch, {"cursor"})
        self._patch_cursor_models(monkeypatch, [
            "claude-opus-4-7-thinking-high",
            "claude-opus-4-7-thinking-low",
            "gpt-5.4-high",
        ])
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config(
                "cursor", "claude-opus-4-7-thinking", {},
            )
        msg = str(ei.value)
        assert "Did you mean" in msg
        assert "claude-opus-4-7-thinking-high" in msg

    def test_cursor_query_failed_skips_model_check(self, monkeypatch):
        # If the CLI list query fails, validation skips model-name check
        # and trusts the executor to error at first invocation.
        self._patch_which(monkeypatch, {"cursor"})
        self._patch_cursor_models(monkeypatch, None)
        # Should NOT raise even though "anything" isn't a real model.
        project.validate_executor_config("cursor", "anything", {})

    def test_claude_model_is_not_validated(self, monkeypatch):
        # claude has no --list-models; the validator accepts any non-empty
        # model and lets the executor reject it at first invocation.
        self._patch_which(monkeypatch, {"claude"})
        # Even a typo should pass through.
        project.validate_executor_config("claude", "sonet", {})
        project.validate_executor_config("claude", "sonnet", {})
        project.validate_executor_config(
            "claude", "claude-sonnet-4-5-20250929", {},
        )

    def test_codex_model_is_not_validated(self, monkeypatch):
        self._patch_which(monkeypatch, {"codex"})
        # Any non-empty model passes — codex CLI has no list command.
        project.validate_executor_config("codex", "gpt-5.5", {})
        project.validate_executor_config("codex", "made-up-model", {})

    # -- opencode --------------------------------------------------------

    def test_opencode_valid_model_passes(self, monkeypatch):
        self._patch_which(monkeypatch, {"opencode"})
        self._patch_opencode_models(monkeypatch, [
            "anthropic/claude-sonnet-4-5-20250929",
            "anthropic/claude-opus-4-7",
            "openai/gpt-5",
        ])
        project.validate_executor_config(
            "opencode", "anthropic/claude-sonnet-4-5-20250929", {},
        )

    def test_opencode_unknown_model_raises_with_provider_hint(
        self, monkeypatch,
    ):
        self._patch_which(monkeypatch, {"opencode"})
        # Use >6 models so the "see the full list" hint renders.
        self._patch_opencode_models(monkeypatch, [
            f"anthropic/model-{i}" for i in range(8)
        ] + ["openai/gpt-5"])
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config(
                "opencode", "anthropic/imaginary-model", {},
            )
        msg = str(ei.value)
        assert "is not available for opencode" in msg
        # Hint must point at `opencode models` (not cursor's flag).
        assert "opencode models" in msg
        assert "agent --list-models" not in msg

    def test_opencode_suggests_near_matches(self, monkeypatch):
        self._patch_which(monkeypatch, {"opencode"})
        self._patch_opencode_models(monkeypatch, [
            "anthropic/claude-sonnet-4-5-20250929",
            "anthropic/claude-opus-4-7",
        ])
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config(
                "opencode", "anthropic/claude-sonnet", {},
            )
        msg = str(ei.value)
        assert "Did you mean" in msg
        assert "anthropic/claude-sonnet-4-5-20250929" in msg

    def test_opencode_query_failed_skips_model_check(self, monkeypatch):
        self._patch_which(monkeypatch, {"opencode"})
        self._patch_opencode_models(monkeypatch, None)
        # Should NOT raise — fall back to "trust the executor".
        project.validate_executor_config(
            "opencode", "anything/at-all", {},
        )

    def test_opencode_missing_binary(self, monkeypatch):
        self._patch_which(monkeypatch, set())
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config(
                "opencode", "anthropic/claude-sonnet-4-5", {},
            )
        msg = str(ei.value)
        assert "requires 'opencode' on $PATH" in msg
        assert "$OPENCODE_CMD" in msg
        assert "opencode.ai" in msg  # install hint URL

    def test_skip_query_env_var(self, monkeypatch):
        # Setting AUTOBUILDER_SKIP_MODEL_QUERY makes cursor validation
        # skip the CLI call entirely (useful for tests / offline runs).
        monkeypatch.setenv("AUTOBUILDER_SKIP_MODEL_QUERY", "1")
        self._patch_which(monkeypatch, {"cursor"})
        # Without the env var the next line would shell out; with it,
        # validation accepts any non-empty model.
        project.validate_executor_config("cursor", "literally-anything", {})

    def test_per_phase_override_validated(self, monkeypatch):
        self._patch_which(monkeypatch, {"cursor", "codex"})  # claude missing
        self._patch_cursor_models(monkeypatch, ["sonnet"])
        cfg = {"phase_executors": {"review": {"executor": "claude"}}}
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("cursor", None, cfg)
        msg = str(ei.value)
        assert "phase_executors[review]" in msg
        assert "'claude'" in msg

    def test_per_phase_inherits_default_executor(self, monkeypatch):
        self._patch_which(monkeypatch, set())
        cfg = {"phase_executors": {"plan": {"model": "sonnet"}}}
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("cursor", None, cfg)
        msg = str(ei.value)
        assert "daemon defaults" in msg
        assert "phase_executors[plan]" in msg

    def test_multiple_errors_collected(self, monkeypatch):
        self._patch_which(monkeypatch, set())
        cfg = {"phase_executors": {
            "review": {"executor": "wrong"},
            "execute": {"executor": "claude"},
        }}
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("codex", None, cfg)
        msg = str(ei.value)
        assert "daemon defaults" in msg
        assert "phase_executors[review]" in msg
        assert "unknown executor" in msg
        assert "phase_executors[execute]" in msg

    def test_invalid_config_via_load_then_validate(
        self, project_dir: Path, monkeypatch,
    ):
        self._patch_which(monkeypatch, {"cursor"})
        self._patch_cursor_models(monkeypatch, ["sonnet"])
        (project_dir / ".autobuilder" / "config.yaml").write_text(
            "phase_executors:\n"
            "  - phase: review\n"
            "    executor: bogus-executor\n"
            "    model: sonnet\n"
        )
        cfg = project.load_project_config(project_dir)
        with pytest.raises(project.ExecutorConfigError) as ei:
            project.validate_executor_config("cursor", None, cfg)
        assert "'bogus-executor'" in str(ei.value)


# ═══════════════════════════════════════════════════════════════════════════
# query_available_models / _query_cursor_models
# ═══════════════════════════════════════════════════════════════════════════


class TestQueryAvailableModels:
    """Verify the CLI-output parser handles ANSI codes, blanks, headers."""

    def test_returns_none_for_claude(self, monkeypatch):
        project._MODELS_CACHE.clear()
        assert project.query_available_models("claude") is None

    def test_returns_none_for_codex(self, monkeypatch):
        project._MODELS_CACHE.clear()
        assert project.query_available_models("codex") is None

    def test_skip_env_short_circuits(self, monkeypatch):
        project._MODELS_CACHE.clear()
        monkeypatch.setenv("AUTOBUILDER_SKIP_MODEL_QUERY", "1")
        assert project.query_available_models("cursor") is None

    @pytest.fixture(autouse=True)
    def _hermetic_query(self, monkeypatch):
        """Reset the cache + clear the skip env so subprocess stubs run."""
        project._MODELS_CACHE.clear()
        monkeypatch.delenv("AUTOBUILDER_SKIP_MODEL_QUERY", raising=False)

    def test_parses_real_cli_output(self, monkeypatch):
        # Simulate `cursor agent --list-models` output verbatim, including
        # ANSI escape codes and the "(default)" / "(current)" markers.
        sample = (
            "\x1b[2K\x1b[GLoading models…\n"
            "\x1b[2K\x1b[1A\x1b[2K\x1b[GAvailable models\n"
            "\n"
            "auto - Auto\n"
            "composer-2-fast - Composer 2 Fast  (default)\n"
            "claude-opus-4-7-thinking-high - Opus 4.7 1M High Thinking  (current)\n"
            "gpt-5.4-medium - GPT-5.4 1M\n"
        )

        class FakeResult:
            returncode = 0
            stdout = sample

        def fake_run(cmd, **kwargs):
            assert cmd[1:] == ["agent", "--list-models"]
            return FakeResult()

        monkeypatch.setattr(project.subprocess, "run", fake_run)
        models = project.query_available_models("cursor")
        assert models is not None
        assert "auto" in models
        assert "composer-2-fast" in models
        assert "claude-opus-4-7-thinking-high" in models
        assert "gpt-5.4-medium" in models
        # No header noise leaked through.
        assert not any(m.startswith(("Loading", "Available")) for m in models)

    def test_failed_cli_call_returns_none(self, monkeypatch):
        class FakeResult:
            returncode = 1
            stdout = ""

        monkeypatch.setattr(
            project.subprocess, "run", lambda *_a, **_kw: FakeResult(),
        )
        assert project.query_available_models("cursor") is None

    # -- opencode --------------------------------------------------------

    def test_opencode_parses_provider_slash_model(self, monkeypatch):
        sample = (
            "anthropic/claude-sonnet-4-5-20250929\n"
            "anthropic/claude-opus-4-7\n"
            "openai/gpt-5\n"
            "google/gemini-2.5-pro\n"
        )

        class FakeResult:
            returncode = 0
            stdout = sample

        def fake_run(cmd, **kwargs):
            assert cmd[1] == "models"
            return FakeResult()

        monkeypatch.setattr(project.subprocess, "run", fake_run)
        models = project.query_available_models("opencode")
        assert models is not None
        assert "anthropic/claude-sonnet-4-5-20250929" in models
        assert "openai/gpt-5" in models
        assert len(models) == 4

    def test_opencode_skips_lines_without_slash(self, monkeypatch):
        # Header / status / blank lines must not show up as fake models.
        sample = (
            "Loading providers…\n"
            "\n"
            "Available models\n"
            "anthropic/claude-sonnet-4-5\n"
            "openai/gpt-5\n"
        )

        class FakeResult:
            returncode = 0
            stdout = sample

        monkeypatch.setattr(
            project.subprocess, "run", lambda *_a, **_kw: FakeResult(),
        )
        models = project.query_available_models("opencode")
        assert models == ["anthropic/claude-sonnet-4-5", "openai/gpt-5"]

    def test_opencode_strips_ansi_codes(self, monkeypatch):
        sample = (
            "\x1b[2K\x1b[Ganthropic/claude-sonnet-4-5\n"
            "\x1b[1mopenai/gpt-5\x1b[0m\n"
        )

        class FakeResult:
            returncode = 0
            stdout = sample

        monkeypatch.setattr(
            project.subprocess, "run", lambda *_a, **_kw: FakeResult(),
        )
        models = project.query_available_models("opencode")
        assert models == ["anthropic/claude-sonnet-4-5", "openai/gpt-5"]

    def test_cursor_and_opencode_caches_are_separate(self, monkeypatch):
        # Each executor caches under its own key so a failed query for
        # one doesn't poison the other.
        monkeypatch.setattr(
            project, "_query_cursor_models",
            lambda _bin: ["claude-opus-4-7-thinking-high"],
        )
        monkeypatch.setattr(
            project, "_query_opencode_models",
            lambda _bin: ["anthropic/claude-sonnet-4-5"],
        )
        cursor_models = project.query_available_models("cursor")
        oc_models = project.query_available_models("opencode")
        assert cursor_models == ["claude-opus-4-7-thinking-high"]
        assert oc_models == ["anthropic/claude-sonnet-4-5"]

    def test_timeout_returns_none(self, monkeypatch):
        def raise_timeout(*_a, **_kw):
            raise project.subprocess.TimeoutExpired(cmd="x", timeout=20)

        monkeypatch.setattr(project.subprocess, "run", raise_timeout)
        assert project.query_available_models("cursor") is None

    def test_result_is_cached(self, monkeypatch):
        class FakeResult:
            returncode = 0
            stdout = "auto - Auto\n"

        calls = {"n": 0}

        def fake_run(*_a, **_kw):
            calls["n"] += 1
            return FakeResult()

        monkeypatch.setattr(project.subprocess, "run", fake_run)
        a = project.query_available_models("cursor")
        b = project.query_available_models("cursor")
        assert a == b
        assert calls["n"] == 1  # second call hit the cache
