"""Unit tests for script/determine-jobs.py module."""

from collections.abc import Generator
import importlib.util
import json
import os
from pathlib import Path
import sys
from unittest.mock import Mock, call, patch

import pytest

# Add the script directory to Python path so we can import the module
script_dir = os.path.abspath(
    os.path.join(os.path.dirname(__file__), "..", "..", "script")
)
sys.path.insert(0, script_dir)

# Import helpers module for patching
import helpers  # noqa: E402

import script.helpers  # noqa: E402

spec = importlib.util.spec_from_file_location(
    "determine_jobs", os.path.join(script_dir, "determine-jobs.py")
)
determine_jobs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(determine_jobs)


@pytest.fixture
def mock_determine_integration_tests() -> Generator[Mock, None, None]:
    """Mock determine_integration_tests."""
    with patch.object(determine_jobs, "determine_integration_tests") as mock:
        yield mock


@pytest.fixture
def mock_should_run_clang_tidy() -> Generator[Mock, None, None]:
    """Mock should_run_clang_tidy from helpers."""
    with patch.object(determine_jobs, "should_run_clang_tidy") as mock:
        yield mock


@pytest.fixture
def mock_should_run_clang_format() -> Generator[Mock, None, None]:
    """Mock should_run_clang_format from helpers."""
    with patch.object(determine_jobs, "should_run_clang_format") as mock:
        yield mock


@pytest.fixture
def mock_should_run_python_linters() -> Generator[Mock, None, None]:
    """Mock should_run_python_linters from helpers."""
    with patch.object(determine_jobs, "should_run_python_linters") as mock:
        yield mock


@pytest.fixture
def mock_should_run_import_time() -> Generator[Mock, None, None]:
    """Mock should_run_import_time from determine_jobs."""
    with patch.object(determine_jobs, "should_run_import_time") as mock:
        yield mock


@pytest.fixture
def mock_should_run_device_builder() -> Generator[Mock, None, None]:
    """Mock should_run_device_builder from determine_jobs."""
    with patch.object(determine_jobs, "should_run_device_builder") as mock:
        yield mock


@pytest.fixture
def mock_native_idf_components_to_test() -> Generator[Mock, None, None]:
    """Mock native_idf_components_to_test from determine_jobs.

    main() drives both the ``native_idf`` boolean output and the
    ``native_idf_components`` CSV from this one function.
    """
    with patch.object(determine_jobs, "native_idf_components_to_test") as mock:
        yield mock


@pytest.fixture
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
    """Mock determine_cpp_unit_tests from helpers."""
    with patch.object(determine_jobs, "determine_cpp_unit_tests") as mock:
        yield mock


@pytest.fixture
def mock_changed_files() -> Generator[Mock, None, None]:
    """Mock changed_files for memory impact detection."""
    with patch.object(determine_jobs, "changed_files") as mock:
        # Default to empty list
        mock.return_value = []
        yield mock


@pytest.fixture
def mock_target_branch_dev() -> Generator[Mock, None, None]:
    """Mock get_target_branch to return 'dev' for memory impact tests."""
    with patch.object(determine_jobs, "get_target_branch", return_value="dev") as mock:
        yield mock


@pytest.fixture(autouse=True)
def clear_determine_jobs_caches() -> None:
    """Clear all cached functions before each test."""
    determine_jobs._is_clang_tidy_full_scan.cache_clear()
    determine_jobs._component_has_tests.cache_clear()


def test_main_all_tests_should_run(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_should_run_import_time: Mock,
    mock_should_run_device_builder: Mock,
    mock_native_idf_components_to_test: Mock,
    mock_changed_files: Mock,
    mock_determine_cpp_unit_tests: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test when all tests should run."""
    # Ensure we're not in GITHUB_ACTIONS mode for this test
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (True, [])
    mock_should_run_clang_tidy.return_value = True
    mock_should_run_clang_format.return_value = True
    mock_should_run_python_linters.return_value = True
    mock_should_run_import_time.return_value = True
    mock_should_run_device_builder.return_value = True
    mock_native_idf_components_to_test.return_value = ["api", "esp32"]
    mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])

    # Mock changed_files to return non-component files (to avoid memory impact)
    # Memory impact only runs when component C++ files change
    mock_changed_files.return_value = [
        "esphome/config.py",
        "esphome/helpers.py",
    ]

    # Stable, deterministic stand-in for the tests/integration/ glob so the
    # bucket assertions don't drift with the real test count.
    fake_test_files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]

    # Run main function with mocked argv
    with (
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
        patch.object(
            determine_jobs,
            "_all_integration_test_files",
            return_value=fake_test_files,
        ),
        patch.object(
            determine_jobs,
            "get_changed_components",
            return_value=["wifi", "api", "sensor"],
        ),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("esphome/components/"),
        ),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            side_effect=lambda files, deps: (
                ["wifi", "api"] if not deps else ["wifi", "api", "sensor"]
            ),
        ),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
        patch.object(
            determine_jobs,
            "create_intelligent_batches",
            return_value=([["wifi", "api", "sensor"]], {}),
        ),
    ):
        determine_jobs.main()

    # Check output
    captured = capsys.readouterr()
    output = json.loads(captured.out)

    assert output["integration_tests"] is True
    # run_all=True expands to the full glob and pre-buckets into 3 parts.
    # Each bucket's `tests` is a JSON list of file paths.
    assert isinstance(output["integration_test_buckets"], list)
    assert len(output["integration_test_buckets"]) == 3
    assert [b["name"] for b in output["integration_test_buckets"]] == [
        "1/3",
        "2/3",
        "3/3",
    ]
    for bucket in output["integration_test_buckets"]:
        assert isinstance(bucket["tests"], list)
        for path in bucket["tests"]:
            assert isinstance(path, str)
    bucket_files = [f for b in output["integration_test_buckets"] for f in b["tests"]]
    assert bucket_files == fake_test_files
    # Bucket sizes are balanced (max-min difference at most 1).
    sizes = [len(b["tests"]) for b in output["integration_test_buckets"]]
    assert max(sizes) - min(sizes) <= 1
    assert output["clang_tidy"] is True
    assert output["clang_tidy_mode"] in ["nosplit", "split"]
    assert output["clang_format"] is True
    assert output["python_linters"] is True
    assert output["import_time"] is True
    assert output["device_builder"] is True
    assert output["native_idf"] is True
    assert output["native_idf_components"] == "api,esp32"
    assert output["changed_components"] == ["wifi", "api", "sensor"]
    # changed_components_with_tests will only include components that actually have test files
    assert "changed_components_with_tests" in output
    assert isinstance(output["changed_components_with_tests"], list)
    # component_test_count matches number of components with tests
    assert output["component_test_count"] == len(
        output["changed_components_with_tests"]
    )
    # changed_cpp_file_count should be present
    assert "changed_cpp_file_count" in output
    assert isinstance(output["changed_cpp_file_count"], int)
    # memory_impact should be false (no component C++ files changed)
    assert "memory_impact" in output
    assert output["memory_impact"]["should_run"] == "false"
    assert output["cpp_unit_tests_run_all"] is False
    assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"]
    # component_test_batches should be present and be a list of space-separated strings
    assert "component_test_batches" in output
    assert isinstance(output["component_test_batches"], list)
    # Each batch should be a space-separated string of component names
    for batch in output["component_test_batches"]:
        assert isinstance(batch, str)
        # Should contain at least one component (no empty batches)
        assert len(batch) > 0


def test_main_no_tests_should_run(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_should_run_import_time: Mock,
    mock_should_run_device_builder: Mock,
    mock_native_idf_components_to_test: Mock,
    mock_changed_files: Mock,
    mock_determine_cpp_unit_tests: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test when no tests should run."""
    # Ensure we're not in GITHUB_ACTIONS mode for this test
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = False
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False
    mock_should_run_import_time.return_value = False
    mock_should_run_device_builder.return_value = False
    mock_native_idf_components_to_test.return_value = []
    mock_determine_cpp_unit_tests.return_value = (False, [])

    # Mock changed_files to return no component files
    mock_changed_files.return_value = []

    # Run main function with mocked argv
    with (
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(determine_jobs, "get_changed_components", return_value=[]),
        patch.object(
            determine_jobs, "filter_component_and_test_files", return_value=False
        ),
        patch.object(
            determine_jobs, "get_components_with_dependencies", return_value=[]
        ),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
        patch.object(
            determine_jobs,
            "create_intelligent_batches",
            return_value=([], {}),
        ),
    ):
        determine_jobs.main()

    # Check output
    captured = capsys.readouterr()
    output = json.loads(captured.out)

    assert output["integration_tests"] is False
    assert output["integration_test_buckets"] == []
    assert output["clang_tidy"] is False
    assert output["clang_tidy_mode"] == "disabled"
    assert output["clang_format"] is False
    assert output["python_linters"] is False
    assert output["import_time"] is False
    assert output["device_builder"] is False
    assert output["native_idf"] is False
    assert output["native_idf_components"] == ""
    assert output["changed_components"] == []
    assert output["changed_components_with_tests"] == []
    assert output["component_test_count"] == 0
    # changed_cpp_file_count should be 0
    assert output["changed_cpp_file_count"] == 0
    # memory_impact should be present
    assert "memory_impact" in output
    assert output["memory_impact"]["should_run"] == "false"
    assert output["cpp_unit_tests_run_all"] is False
    assert output["cpp_unit_tests_components"] == []
    # component_test_batches should be empty list
    assert "component_test_batches" in output
    assert output["component_test_batches"] == []


def test_main_with_branch_argument(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_should_run_import_time: Mock,
    mock_should_run_device_builder: Mock,
    mock_native_idf_components_to_test: Mock,
    mock_changed_files: Mock,
    mock_determine_cpp_unit_tests: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test with branch argument."""
    # Ensure we're not in GITHUB_ACTIONS mode for this test
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = True
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = True
    mock_should_run_import_time.return_value = True
    mock_should_run_device_builder.return_value = True
    mock_native_idf_components_to_test.return_value = ["esp32"]
    mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])

    # Mock changed_files to return non-component files (to avoid memory impact)
    # Memory impact only runs when component C++ files change
    mock_changed_files.return_value = ["esphome/config.py"]

    with (
        patch("sys.argv", ["script.py", "-b", "main"]),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
        patch.object(determine_jobs, "get_changed_components", return_value=["mqtt"]),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("esphome/components/"),
        ),
        patch.object(
            determine_jobs, "get_components_with_dependencies", return_value=["mqtt"]
        ),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
        patch.object(
            determine_jobs,
            "create_intelligent_batches",
            return_value=([["mqtt"]], {}),
        ),
    ):
        determine_jobs.main()

    # Check that functions were called with branch
    mock_determine_integration_tests.assert_called_once_with("main")
    mock_should_run_clang_tidy.assert_called_once_with("main")
    mock_should_run_clang_format.assert_called_once_with("main")
    mock_should_run_python_linters.assert_called_once_with("main")
    mock_should_run_import_time.assert_called_once_with("main")
    mock_should_run_device_builder.assert_called_once_with("main")
    mock_native_idf_components_to_test.assert_called_once_with("main")

    # Check output
    captured = capsys.readouterr()
    output = json.loads(captured.out)

    assert output["integration_tests"] is False
    assert output["integration_test_buckets"] == []
    assert output["clang_tidy"] is True
    assert output["clang_tidy_mode"] in ["nosplit", "split"]
    assert output["clang_format"] is False
    assert output["python_linters"] is True
    assert output["import_time"] is True
    assert output["device_builder"] is True
    assert output["native_idf"] is True
    assert output["native_idf_components"] == "esp32"
    assert output["changed_components"] == ["mqtt"]
    # changed_components_with_tests will only include components that actually have test files
    assert "changed_components_with_tests" in output
    assert isinstance(output["changed_components_with_tests"], list)
    # component_test_count matches number of components with tests
    assert output["component_test_count"] == len(
        output["changed_components_with_tests"]
    )
    # changed_cpp_file_count should be present
    assert "changed_cpp_file_count" in output
    assert isinstance(output["changed_cpp_file_count"], int)
    # memory_impact should be false (no component C++ files changed)
    assert "memory_impact" in output
    assert output["memory_impact"]["should_run"] == "false"
    assert output["cpp_unit_tests_run_all"] is False
    assert output["cpp_unit_tests_components"] == ["mqtt"]


def test_compute_integration_test_buckets_empty() -> None:
    """No integration tests scheduled => (False, [])."""
    run, buckets = determine_jobs._compute_integration_test_buckets(False, [])
    assert run is False
    assert buckets == []


def test_compute_integration_test_buckets_below_threshold() -> None:
    """A small explicit list (<= threshold) => single 1/1 bucket with that list."""
    files = [f"tests/integration/test_{name}.py" for name in ("c", "a", "b")]
    run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
    assert run is True
    assert buckets == [{"name": "1/1", "tests": sorted(files)}]


def test_compute_integration_test_buckets_at_threshold_stays_single() -> None:
    """Exactly INTEGRATION_TESTS_SPLIT_THRESHOLD files => still one bucket
    (the split kicks in only when count is strictly greater than threshold)."""
    files = [
        f"tests/integration/test_{i:02d}.py"
        for i in range(determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD)
    ]
    run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
    assert run is True
    assert len(buckets) == 1
    assert buckets[0]["name"] == "1/1"
    assert buckets[0]["tests"] == sorted(files)


def test_compute_integration_test_buckets_just_over_threshold_splits() -> None:
    """One file over the threshold triggers the 3-bucket fan-out, balanced."""
    n = determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD + 1
    files = [f"tests/integration/test_{i:02d}.py" for i in range(n)]
    run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
    assert run is True
    assert [b["name"] for b in buckets] == ["1/3", "2/3", "3/3"]
    union = [path for b in buckets for path in b["tests"]]
    assert union == sorted(files)
    sizes = [len(b["tests"]) for b in buckets]
    assert max(sizes) - min(sizes) <= 1


def test_compute_integration_test_buckets_run_all_with_empty_glob_disables_run() -> (
    None
):
    """run_all=True but glob returns no files => run suppressed (otherwise
    pytest would collect tests outside tests/integration/)."""
    with patch.object(determine_jobs, "_all_integration_test_files", return_value=[]):
        run, buckets = determine_jobs._compute_integration_test_buckets(True, [])
    assert run is False
    assert buckets == []


def test_determine_integration_tests(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test determine_integration_tests function."""
    # Core C++ files trigger run_all
    with patch.object(
        determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"]
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is True
        assert test_files == []

    # Core Python files trigger run_all
    with patch.object(
        determine_jobs, "changed_files", return_value=["esphome/core/config.py"]
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is True
        assert test_files == []

    # Python files directly in esphome/ do NOT trigger tests
    with patch.object(
        determine_jobs, "changed_files", return_value=["esphome/config.py"]
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is False
        assert test_files == []

    # Python files in subdirectories (not core) do NOT trigger tests
    with patch.object(
        determine_jobs,
        "changed_files",
        return_value=["esphome/dashboard/web_server.py"],
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is False
        assert test_files == []


def test_determine_integration_tests_with_branch() -> None:
    """Test determine_integration_tests with branch argument."""
    with patch.object(determine_jobs, "changed_files") as mock_changed:
        mock_changed.return_value = []
        run_all, test_files = determine_jobs.determine_integration_tests("release")
        mock_changed.assert_called_once_with("release")
        assert run_all is False
        assert test_files == []


def test_determine_integration_tests_component_dependency() -> None:
    """Test that integration tests return specific test files when components used in fixtures change."""
    with (
        patch.object(
            determine_jobs,
            "changed_files",
            return_value=["esphome/components/api/api.cpp"],
        ),
        patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
        patch.object(
            determine_jobs, "get_integration_test_files_for_components"
        ) as mock_test_files,
    ):
        mock_fixture_map.return_value = {}
        mock_test_files.return_value = [
            "tests/integration/test_api.py",
            "tests/integration/test_sensor.py",
        ]
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is False
        assert test_files == [
            "tests/integration/test_api.py",
            "tests/integration/test_sensor.py",
        ]


def test_determine_integration_tests_component_only_affected_tests() -> None:
    """Test that only tests using the changed component are returned."""
    with (
        patch.object(
            determine_jobs,
            "changed_files",
            return_value=["esphome/components/modbus/modbus.cpp"],
        ),
        patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
        patch.object(
            determine_jobs, "get_integration_test_files_for_components"
        ) as mock_test_files,
    ):
        mock_test_files.return_value = [
            "tests/integration/test_uart_mock_modbus.py",
        ]
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is False
        assert test_files == ["tests/integration/test_uart_mock_modbus.py"]
        # Verify it was called with the right component
        mock_test_files.assert_called_once_with({"modbus"})


def test_determine_integration_tests_infra_file_runs_all() -> None:
    """Test that changing infrastructure files (conftest.py, etc.) runs all tests."""
    with patch.object(
        determine_jobs,
        "changed_files",
        return_value=["tests/integration/conftest.py"],
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is True
        assert test_files == []


def test_determine_integration_tests_readme_does_not_run_all() -> None:
    """Test that changing README.md does not trigger integration tests."""
    with patch.object(
        determine_jobs,
        "changed_files",
        return_value=["tests/integration/README.md"],
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is False
        assert test_files == []


def test_determine_integration_tests_changed_test_file() -> None:
    """Test that changing a specific test file only runs that test."""
    with (
        patch.object(
            determine_jobs,
            "changed_files",
            return_value=["tests/integration/test_syslog.py"],
        ),
        patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}),
        patch.object(
            determine_jobs,
            "get_integration_test_files_for_components",
            return_value=[],
        ),
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is False
        assert test_files == ["tests/integration/test_syslog.py"]


def test_determine_integration_tests_changed_fixture_yaml() -> None:
    """Test that changing a fixture YAML runs the corresponding test file."""
    with (
        patch.object(
            determine_jobs,
            "changed_files",
            return_value=["tests/integration/fixtures/uart_mock_modbus.yaml"],
        ),
        patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map,
        patch.object(
            determine_jobs,
            "get_integration_test_files_for_components",
            return_value=[],
        ),
    ):
        mock_fixture_map.return_value = {
            "uart_mock_modbus": frozenset(
                {"tests/integration/test_uart_mock_modbus.py"}
            ),
        }
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is False
        assert test_files == ["tests/integration/test_uart_mock_modbus.py"]


def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None:
    """Test that non-YAML changes under fixtures/ (e.g., external_components) run all tests."""
    with patch.object(
        determine_jobs,
        "changed_files",
        return_value=[
            "tests/integration/fixtures/external_components/test_component/__init__.py"
        ],
    ):
        run_all, test_files = determine_jobs.determine_integration_tests()
        assert run_all is True
        assert test_files == []


@pytest.mark.parametrize(
    ("check_returncode", "changed_files", "expected_result"),
    [
        (0, [], True),  # Hash changed - need full scan
        (1, ["esphome/core.cpp"], True),  # C++ file changed
        (1, ["README.md"], False),  # No C++ files changed
        (1, [".clang-tidy.hash"], True),  # Hash file itself changed
        (1, ["platformio.ini", ".clang-tidy.hash"], True),  # Config + hash changed
    ],
)
def test_should_run_clang_tidy(
    check_returncode: int,
    changed_files: list[str],
    expected_result: bool,
) -> None:
    """Test should_run_clang_tidy function."""
    with (
        patch.object(determine_jobs, "changed_files", return_value=changed_files),
        patch("subprocess.run") as mock_run,
    ):
        # Test with hash check returning specific code
        mock_run.return_value = Mock(returncode=check_returncode)
        result = determine_jobs.should_run_clang_tidy()
        assert result == expected_result


def test_should_run_clang_tidy_hash_check_exception() -> None:
    """Test should_run_clang_tidy when hash check fails with exception."""
    # When hash check fails, clang-tidy should run as a safety measure
    with (
        patch.object(determine_jobs, "changed_files", return_value=["README.md"]),
        patch("subprocess.run", side_effect=Exception("Hash check failed")),
    ):
        result = determine_jobs.should_run_clang_tidy()
        assert result is True  # Fail safe - run clang-tidy


def test_should_run_clang_tidy_with_branch() -> None:
    """Test should_run_clang_tidy with branch argument."""
    with patch.object(determine_jobs, "changed_files") as mock_changed:
        mock_changed.return_value = []
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = Mock(returncode=1)  # Hash unchanged
            determine_jobs.should_run_clang_tidy("release")
            # Changed files is called twice now - once for hash check, once for .clang-tidy.hash check
            assert mock_changed.call_count == 2
            mock_changed.assert_has_calls([call("release"), call("release")])


@pytest.mark.parametrize(
    ("changed_files", "expected_result"),
    [
        (["esphome/core.py"], True),
        (["script/test.py"], True),
        (["esphome/test.pyi"], True),  # .pyi files should trigger
        (["README.md"], False),
        ([], False),
    ],
)
def test_should_run_python_linters(
    changed_files: list[str], expected_result: bool
) -> None:
    """Test should_run_python_linters function."""
    with patch.object(determine_jobs, "changed_files", return_value=changed_files):
        result = determine_jobs.should_run_python_linters()
        assert result == expected_result


def test_should_run_python_linters_with_branch() -> None:
    """Test should_run_python_linters with branch argument."""
    with patch.object(determine_jobs, "changed_files") as mock_changed:
        mock_changed.return_value = []
        determine_jobs.should_run_python_linters("release")
        mock_changed.assert_called_once_with("release")


@pytest.mark.parametrize(
    ("changed_files", "expected_result"),
    [
        # esphome Python files trigger the check
        (["esphome/__main__.py"], True),
        (["esphome/components/wifi/__init__.py"], True),
        (["esphome/core/config.py"], True),
        (["esphome/types.pyi"], True),
        # Dependency declarations and the check's own files trigger
        (["requirements.txt"], True),
        (["requirements_dev.txt"], True),
        (["requirements_test.txt"], True),
        (["pyproject.toml"], True),
        (["script/check_import_time.py"], True),
        (["script/import_time_budget.json"], True),
        # Mixed: any triggering file is enough
        (["docs/README.md", "esphome/config.py"], True),
        # Python files outside esphome/ don't trigger
        (["script/some_other_script.py"], False),
        (["tests/script/test_determine_jobs.py"], False),
        # Non-Python changes don't trigger
        (["esphome/core/component.cpp"], False),
        (["tests/components/wifi/test.esp32-idf.yaml"], False),
        (["README.md"], False),
        ([], False),
    ],
)
def test_should_run_import_time(
    changed_files: list[str], expected_result: bool
) -> None:
    """Test should_run_import_time function."""
    with patch.object(determine_jobs, "changed_files", return_value=changed_files):
        result = determine_jobs.should_run_import_time()
        assert result == expected_result


def test_should_run_import_time_with_branch() -> None:
    """Test should_run_import_time with branch argument."""
    with patch.object(determine_jobs, "changed_files") as mock_changed:
        mock_changed.return_value = []
        determine_jobs.should_run_import_time("release")
        mock_changed.assert_called_once_with("release")


@pytest.mark.parametrize(
    ("changed_files", "expected_result"),
    [
        # esphome Python files trigger downstream device-builder tests
        (["esphome/__main__.py"], True),
        (["esphome/components/wifi/__init__.py"], True),
        (["esphome/core/config.py"], True),
        (["esphome/types.pyi"], True),
        # Runtime dependency changes trigger
        (["requirements.txt"], True),
        (["pyproject.toml"], True),
        # Non-C++ files packaged with esphome trigger -- device-builder
        # picks them up because esphome's pyproject sets
        # include-package-data = true.
        (["esphome/idf_component.yml"], True),
        (["esphome/dashboard/templates/index.html"], True),
        (["esphome/components/api/api_pb2_service.json"], True),
        # Mixed: any triggering file is enough
        (["docs/README.md", "esphome/config.py"], True),
        # Dev/test-only dependency changes don't trigger device-builder
        # (they don't affect the importable surface device-builder uses)
        (["requirements_dev.txt"], False),
        (["requirements_test.txt"], False),
        # Files outside esphome/ don't trigger
        (["script/some_other_script.py"], False),
        (["tests/script/test_determine_jobs.py"], False),
        # C++ files under esphome/ don't trigger -- they only affect
        # compiled firmware, not the Python install device-builder pulls in.
        (["esphome/core/component.cpp"], False),
        (["esphome/core/component.h"], False),
        (["esphome/components/wifi/wifi_component.cpp"], False),
        # Files outside esphome/ entirely
        (["tests/components/wifi/test.esp32-idf.yaml"], False),
        (["README.md"], False),
        ([], False),
    ],
)
def test_should_run_device_builder(
    changed_files: list[str], expected_result: bool
) -> None:
    """Test should_run_device_builder function (non-beta/release target)."""
    with (
        patch.object(determine_jobs, "changed_files", return_value=changed_files),
        # Mock target branch to "dev" so the beta/release skip is bypassed
        # for these per-file behavior checks.
        patch.object(determine_jobs, "get_target_branch", return_value="dev"),
    ):
        result = determine_jobs.should_run_device_builder()
        assert result == expected_result


def test_should_run_device_builder_with_branch() -> None:
    """Test should_run_device_builder with branch argument."""
    with (
        patch.object(determine_jobs, "changed_files") as mock_changed,
        patch.object(determine_jobs, "get_target_branch", return_value="dev"),
    ):
        mock_changed.return_value = []
        determine_jobs.should_run_device_builder("release")
        mock_changed.assert_called_once_with("release")


@pytest.mark.parametrize("target_branch", ["beta", "release", "release-2026.5"])
def test_should_run_device_builder_skips_beta_release(target_branch: str) -> None:
    """Beta/release target branches skip device-builder (lag behind device-builder@main)."""
    with (
        patch.object(determine_jobs, "get_target_branch", return_value=target_branch),
        patch.object(determine_jobs, "changed_files") as mock_changed,
    ):
        # Even with a triggering file present, the target-branch guard wins.
        mock_changed.return_value = ["esphome/__main__.py"]
        assert determine_jobs.should_run_device_builder() is False
        # changed_files shouldn't even be consulted -- the guard short-circuits.
        mock_changed.assert_not_called()


_NATIVE_IDF_FULL_LIST_FILES = [
    # Core C++/Python changes -- caught by core_changed()
    ["esphome/core/component.cpp"],
    ["esphome/core/config.py"],
    # Native IDF infrastructure paths
    ["esphome/espidf/framework.py"],
    ["esphome/espidf/component.py"],
    ["esphome/espidf/api.py"],
    ["esphome/build_gen/espidf.py"],
    # Workflow / harness files
    ["script/test_build_components.py"],
    [".github/workflows/ci.yml"],
]


@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES)
def test_native_idf_components_to_test_returns_full_list_on_infrastructure(
    changed_files: list[str],
) -> None:
    """Infrastructure / core / harness changes fall back to the full component list."""
    with (
        patch.object(determine_jobs, "changed_files", return_value=changed_files),
        # The dep-closure path shouldn't be consulted at all -- if it is,
        # the obviously-wrong "wifi" sneaks in and the assertion catches it.
        patch.object(
            determine_jobs, "get_components_with_dependencies", return_value=["wifi"]
        ),
    ):
        result = determine_jobs.native_idf_components_to_test()
        assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS)


@pytest.mark.parametrize(
    ("changed_files", "dependency_closure", "expected"),
    [
        # Single tested component changed -- narrow to just that component.
        (
            ["esphome/components/esp32/__init__.py"],
            ["esp32"],
            ["esp32"],
        ),
        # Dependency closure: multiple BLE components in the changed set
        # are all intersected with the test list and returned sorted.
        (
            ["esphome/components/esp32_ble/ble.cpp"],
            ["esp32_ble", "esp32_ble_tracker", "ble_scanner"],
            ["ble_scanner", "esp32_ble", "esp32_ble_tracker"],
        ),
        # api in the test set -- narrow to [api] even though the closure
        # has other (unrelated to native-IDF coverage) entries.
        (
            ["esphome/components/api/api_connection.cpp"],
            ["api", "logger"],
            ["api"],
        ),
        # Components outside the test set return an empty list (job skipped).
        (
            ["esphome/components/wifi/wifi_component.cpp"],
            ["wifi", "network"],
            [],
        ),
        # Pure Python-only change outside trigger paths -> empty.
        (["esphome/yaml_util.py"], [], []),
        # Non-IDF files in esphome/build_gen/ do NOT trigger the full
        # list -- only esphome/build_gen/espidf.py is a trigger.
        (["esphome/build_gen/platformio.py"], [], []),
        # Docs / unrelated files -> empty.
        (["README.md"], [], []),
        ([], [], []),
    ],
)
def test_native_idf_components_to_test_narrowing(
    changed_files: list[str],
    dependency_closure: list[str],
    expected: list[str],
) -> None:
    """Component changes narrow the test list to the intersection."""
    with (
        patch.object(determine_jobs, "changed_files", return_value=changed_files),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            return_value=dependency_closure,
        ),
    ):
        result = determine_jobs.native_idf_components_to_test()
        assert result == expected


def test_native_idf_components_to_test_with_branch() -> None:
    """native_idf_components_to_test passes branch argument through.

    Regression test: an earlier version called ``get_changed_components()``,
    which silently ignored the branch argument because that helper re-runs
    ``changed_files()`` with its own default. The current implementation
    derives the closure from ``files = changed_files(branch)`` directly,
    so a branch arg has to flow through ``changed_files``.
    """
    with (
        patch.object(determine_jobs, "changed_files") as mock_changed,
        patch.object(
            determine_jobs, "get_components_with_dependencies", return_value=[]
        ),
    ):
        mock_changed.return_value = []
        determine_jobs.native_idf_components_to_test("release")
        mock_changed.assert_called_once_with("release")


@pytest.mark.parametrize(
    ("components_to_test", "expected"),
    [
        ([], False),
        (["esp32"], True),
        (["esp32", "api"], True),
    ],
)
def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None:
    """should_run_native_idf is a thin wrapper around the component list."""
    with patch.object(
        determine_jobs,
        "native_idf_components_to_test",
        return_value=components_to_test,
    ):
        assert determine_jobs.should_run_native_idf() is expected


def test_should_run_native_idf_with_branch() -> None:
    """Test should_run_native_idf passes branch argument through."""
    with patch.object(
        determine_jobs, "native_idf_components_to_test", return_value=[]
    ) as mock_inner:
        determine_jobs.should_run_native_idf("release")
        mock_inner.assert_called_once_with("release")


@pytest.mark.parametrize(
    ("changed_files", "expected_result"),
    [
        (["esphome/core.cpp"], True),
        (["esphome/core.h"], True),
        (["test.hpp"], True),
        (["test.cc"], True),
        (["test.cxx"], True),
        (["test.c"], True),
        (["test.tcc"], True),
        (["README.md"], False),
        ([], False),
    ],
)
def test_should_run_clang_format(
    changed_files: list[str], expected_result: bool
) -> None:
    """Test should_run_clang_format function."""
    with patch.object(determine_jobs, "changed_files", return_value=changed_files):
        result = determine_jobs.should_run_clang_format()
        assert result == expected_result


def test_should_run_clang_format_with_branch() -> None:
    """Test should_run_clang_format with branch argument."""
    with patch.object(determine_jobs, "changed_files") as mock_changed:
        mock_changed.return_value = []
        determine_jobs.should_run_clang_format("release")
        mock_changed.assert_called_once_with("release")


@pytest.mark.parametrize(
    ("changed_files", "expected_count"),
    [
        (["esphome/core.cpp"], 1),
        (["esphome/core.h"], 1),
        (["test.hpp"], 1),
        (["test.cc"], 1),
        (["test.cxx"], 1),
        (["test.c"], 1),
        (["test.tcc"], 1),
        (["esphome/core.cpp", "esphome/core.h"], 2),
        (["esphome/core.cpp", "esphome/core.h", "test.cc"], 3),
        (["README.md"], 0),
        (["esphome/config.py"], 0),
        (["README.md", "esphome/config.py"], 0),
        (["esphome/core.cpp", "README.md", "esphome/config.py"], 1),
        ([], 0),
    ],
)
def test_count_changed_cpp_files(changed_files: list[str], expected_count: int) -> None:
    """Test count_changed_cpp_files function."""
    with patch.object(determine_jobs, "changed_files", return_value=changed_files):
        result = determine_jobs.count_changed_cpp_files()
        assert result == expected_count


def test_count_changed_cpp_files_with_branch() -> None:
    """Test count_changed_cpp_files with branch argument."""
    with patch.object(determine_jobs, "changed_files") as mock_changed:
        mock_changed.return_value = []
        determine_jobs.count_changed_cpp_files("release")
        mock_changed.assert_called_once_with("release")


def test_main_filters_components_without_tests(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_changed_files: Mock,
    capsys: pytest.CaptureFixture[str],
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test that components without test files are filtered out."""
    # Ensure we're not in GITHUB_ACTIONS mode for this test
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = False
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False

    # Mock changed_files to return component files
    mock_changed_files.return_value = [
        "esphome/components/wifi/wifi.cpp",
        "esphome/components/sensor/sensor.h",
    ]

    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # wifi has tests
    wifi_dir = tests_dir / "wifi"
    wifi_dir.mkdir(parents=True)
    (wifi_dir / "test.esp32.yaml").write_text("test: config")

    # sensor has tests
    sensor_dir = tests_dir / "sensor"
    sensor_dir.mkdir(parents=True)
    (sensor_dir / "test.esp8266.yaml").write_text("test: config")

    # airthings_ble exists but has no test files
    airthings_dir = tests_dir / "airthings_ble"
    airthings_dir.mkdir(parents=True)

    # Mock root_path to use tmp_path (need to patch both determine_jobs and helpers)
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(helpers, "create_components_graph", return_value={}),
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(
            determine_jobs,
            "get_changed_components",
            return_value=["wifi", "sensor", "airthings_ble"],
        ),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("esphome/components/"),
        ),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            side_effect=lambda files, deps: (
                ["wifi", "sensor"] if not deps else ["wifi", "sensor", "airthings_ble"]
            ),
        ),
        patch.object(determine_jobs, "changed_files", return_value=[]),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
    ):
        # Clear the cache since we're mocking root_path
        determine_jobs.main()

    # Check output
    captured = capsys.readouterr()
    output = json.loads(captured.out)

    # changed_components should have all components
    assert set(output["changed_components"]) == {"wifi", "sensor", "airthings_ble"}
    # changed_components_with_tests should only have components with test files
    assert set(output["changed_components_with_tests"]) == {"wifi", "sensor"}
    # component_test_count should be based on components with tests
    assert output["component_test_count"] == 2
    # changed_cpp_file_count should be present
    assert "changed_cpp_file_count" in output
    assert isinstance(output["changed_cpp_file_count"], int)
    # memory_impact should be present
    assert "memory_impact" in output
    assert output["memory_impact"]["should_run"] == "false"


def test_main_detects_components_with_variant_tests(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_changed_files: Mock,
    capsys: pytest.CaptureFixture[str],
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test that components with only variant test files (test-*.yaml) are detected.

    This test verifies the fix for components like improv_serial, ethernet, mdns,
    improv_base, and safe_mode which only have variant test files (test-*.yaml)
    instead of base test files (test.*.yaml).
    """
    # Ensure we're not in GITHUB_ACTIONS mode for this test
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = False
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False

    # Mock changed_files to return component files
    mock_changed_files.return_value = [
        "esphome/components/improv_serial/improv_serial.cpp",
        "esphome/components/ethernet/ethernet.cpp",
        "esphome/components/no_tests/component.cpp",
    ]

    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # improv_serial has only variant tests (like the real component)
    improv_serial_dir = tests_dir / "improv_serial"
    improv_serial_dir.mkdir(parents=True)
    (improv_serial_dir / "test-uart0.esp32-idf.yaml").write_text("test: config")
    (improv_serial_dir / "test-uart0.esp8266-ard.yaml").write_text("test: config")
    (improv_serial_dir / "test-usb_cdc.esp32-s2-idf.yaml").write_text("test: config")

    # ethernet also has only variant tests
    ethernet_dir = tests_dir / "ethernet"
    ethernet_dir.mkdir(parents=True)
    (ethernet_dir / "test-manual_ip.esp32-idf.yaml").write_text("test: config")
    (ethernet_dir / "test-dhcp.esp32-idf.yaml").write_text("test: config")

    # no_tests component has no test files at all
    no_tests_dir = tests_dir / "no_tests"
    no_tests_dir.mkdir(parents=True)

    # Mock root_path to use tmp_path (need to patch both determine_jobs and helpers)
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(helpers, "create_components_graph", return_value={}),
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(
            determine_jobs,
            "get_changed_components",
            return_value=["improv_serial", "ethernet", "no_tests"],
        ),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("esphome/components/"),
        ),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            side_effect=lambda files, deps: (
                ["improv_serial", "ethernet"]
                if not deps
                else ["improv_serial", "ethernet", "no_tests"]
            ),
        ),
        patch.object(determine_jobs, "changed_files", return_value=[]),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
    ):
        # Clear the cache since we're mocking root_path
        determine_jobs.main()

    # Check output
    captured = capsys.readouterr()
    output = json.loads(captured.out)

    # changed_components should have all components
    assert set(output["changed_components"]) == {
        "improv_serial",
        "ethernet",
        "no_tests",
    }
    # changed_components_with_tests should include components with variant tests
    assert set(output["changed_components_with_tests"]) == {"improv_serial", "ethernet"}
    # component_test_count should be 2 (improv_serial and ethernet)
    assert output["component_test_count"] == 2
    # no_tests should be excluded since it has no test files
    assert "no_tests" not in output["changed_components_with_tests"]


# Tests for detect_memory_impact_config function


@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> None:
    """Test memory impact detection when components share a common platform."""
    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # wifi component with esp32-idf test
    wifi_dir = tests_dir / "wifi"
    wifi_dir.mkdir(parents=True)
    (wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")

    # api component with esp32-idf test
    api_dir = tests_dir / "api"
    api_dir.mkdir(parents=True)
    (api_dir / "test.esp32-idf.yaml").write_text("test: api")

    # Mock changed_files to return wifi and api component changes
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "esphome/components/wifi/wifi.cpp",
            "esphome/components/api/api.cpp",
        ]

        result = determine_jobs.detect_memory_impact_config()

    assert result["should_run"] == "true"
    assert set(result["components"]) == {"wifi", "api"}
    assert result["platform"] == "esp32-idf"  # Common platform
    assert result["use_merged_config"] == "true"


@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
    """Test memory impact detection with core C++ changes (no component changes)."""
    # Create test directory structure with fallback component
    tests_dir = tmp_path / "tests" / "components"

    # api component (fallback component) with esp32-idf test
    api_dir = tests_dir / "api"
    api_dir.mkdir(parents=True)
    (api_dir / "test.esp32-idf.yaml").write_text("test: api")

    # Mock changed_files to return only core C++ files (no component files)
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "esphome/core/application.cpp",
            "esphome/core/component.h",
        ]

        result = determine_jobs.detect_memory_impact_config()

    assert result["should_run"] == "true"
    assert result["components"] == ["api"]  # Fallback component
    assert result["platform"] == "esp32-idf"  # Fallback platform
    assert result["use_merged_config"] == "true"


def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> None:
    """Test that Python-only core changes don't trigger memory impact analysis."""
    # Create test directory structure with fallback component
    tests_dir = tmp_path / "tests" / "components"

    # api component (fallback component) with esp32-idf test
    api_dir = tests_dir / "api"
    api_dir.mkdir(parents=True)
    (api_dir / "test.esp32-idf.yaml").write_text("test: api")

    # Mock changed_files to return only core Python files (no C++ files)
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "esphome/__main__.py",
            "esphome/config.py",
            "esphome/core/config.py",
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Python-only changes should NOT trigger memory impact analysis
    assert result["should_run"] == "false"


@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
    """Test memory impact detection when components have no common platform."""
    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # wifi component only has esp32-idf test
    wifi_dir = tests_dir / "wifi"
    wifi_dir.mkdir(parents=True)
    (wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")

    # logger component only has esp8266-ard test
    logger_dir = tests_dir / "logger"
    logger_dir.mkdir(parents=True)
    (logger_dir / "test.esp8266-ard.yaml").write_text("test: logger")

    # Mock changed_files to return both components
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "esphome/components/wifi/wifi.cpp",
            "esphome/components/logger/logger.cpp",
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Should pick the most frequently supported platform
    assert result["should_run"] == "true"
    assert set(result["components"]) == {"wifi", "logger"}
    # When no common platform, picks most commonly supported
    # esp8266-ard is preferred over esp32-idf in the preference list
    assert result["platform"] in ["esp32-idf", "esp8266-ard"]
    assert result["use_merged_config"] == "true"


def test_detect_memory_impact_config_no_changes(tmp_path: Path) -> None:
    """Test memory impact detection when no files changed."""
    # Mock changed_files to return empty list
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = []

        result = determine_jobs.detect_memory_impact_config()

    assert result["should_run"] == "false"


def test_detect_memory_impact_config_no_components_with_tests(tmp_path: Path) -> None:
    """Test memory impact detection when changed components have no tests."""
    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # Create component directory but no test files
    custom_component_dir = tests_dir / "my_custom_component"
    custom_component_dir.mkdir(parents=True)

    # Mock changed_files to return component without tests
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "esphome/components/my_custom_component/component.cpp",
        ]

        result = determine_jobs.detect_memory_impact_config()

    assert result["should_run"] == "false"


@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_includes_base_bus_components(
    tmp_path: Path,
) -> None:
    """Test that base bus components (i2c, spi, uart) are included when directly changed.

    Base bus components should be analyzed for memory impact when they are directly
    changed, even though they are often used as dependencies. This ensures that
    optimizations to base components (like using move semantics or initializer_list)
    are properly measured.
    """
    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # uart component (base bus component that should be included)
    uart_dir = tests_dir / "uart"
    uart_dir.mkdir(parents=True)
    (uart_dir / "test.esp32-idf.yaml").write_text("test: uart")

    # wifi component (regular component)
    wifi_dir = tests_dir / "wifi"
    wifi_dir.mkdir(parents=True)
    (wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")

    # Mock changed_files to return both uart and wifi
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "esphome/components/uart/automation.h",  # Header file with inline code
            "esphome/components/wifi/wifi.cpp",
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Should include both uart and wifi
    assert result["should_run"] == "true"
    assert set(result["components"]) == {"uart", "wifi"}
    assert result["platform"] == "esp32-idf"  # Common platform


@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
    """Test memory impact detection for components with only variant test files.

    This verifies that memory impact analysis works correctly for components like
    improv_serial, ethernet, mdns, etc. which only have variant test files
    (test-*.yaml) instead of base test files (test.*.yaml).
    """
    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # improv_serial with only variant tests
    improv_serial_dir = tests_dir / "improv_serial"
    improv_serial_dir.mkdir(parents=True)
    (improv_serial_dir / "test-uart0.esp32-idf.yaml").write_text("test: improv")
    (improv_serial_dir / "test-uart0.esp8266-ard.yaml").write_text("test: improv")
    (improv_serial_dir / "test-usb_cdc.esp32-s2-idf.yaml").write_text("test: improv")

    # ethernet with only variant tests
    ethernet_dir = tests_dir / "ethernet"
    ethernet_dir.mkdir(parents=True)
    (ethernet_dir / "test-manual_ip.esp32-idf.yaml").write_text("test: ethernet")
    (ethernet_dir / "test-dhcp.esp32-c3-idf.yaml").write_text("test: ethernet")

    # Mock changed_files to return both components
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "esphome/components/improv_serial/improv_serial.cpp",
            "esphome/components/ethernet/ethernet.cpp",
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Should detect both components even though they only have variant tests
    assert result["should_run"] == "true"
    assert set(result["components"]) == {"improv_serial", "ethernet"}
    # Both components support esp32-idf
    assert result["platform"] == "esp32-idf"
    assert result["use_merged_config"] == "true"


# Tests for clang-tidy split mode logic


def test_clang_tidy_mode_full_scan(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_changed_files: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test that full scan (hash changed) always uses split mode."""
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = True
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False

    # Mock changed_files to return no component files
    mock_changed_files.return_value = []

    # Mock full scan (hash changed)
    with (
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True),
        patch.object(determine_jobs, "get_changed_components", return_value=[]),
        patch.object(
            determine_jobs, "filter_component_and_test_files", return_value=False
        ),
        patch.object(
            determine_jobs, "get_components_with_dependencies", return_value=[]
        ),
    ):
        determine_jobs.main()

    captured = capsys.readouterr()
    output = json.loads(captured.out)

    # Full scan should always use split mode
    assert output["clang_tidy_mode"] == "split"


@pytest.mark.parametrize(
    ("component_count", "files_per_component", "expected_mode"),
    [
        # Small PR: 5 files in 1 component -> nosplit
        (1, 5, "nosplit"),
        # Medium PR: 30 files in 2 components -> nosplit
        (2, 15, "nosplit"),
        # Medium PR: 64 files total -> nosplit (just under threshold)
        (2, 32, "nosplit"),
        # Large PR: 65 files total -> split (at threshold)
        (2, 33, "split"),  # 2 * 33 = 66 files
        # Large PR: 100 files in 10 components -> split
        (10, 10, "split"),
    ],
    ids=[
        "1_comp_5_files_nosplit",
        "2_comp_30_files_nosplit",
        "2_comp_64_files_nosplit_under_threshold",
        "2_comp_66_files_split_at_threshold",
        "10_comp_100_files_split",
    ],
)
def test_clang_tidy_mode_targeted_scan(
    component_count: int,
    files_per_component: int,
    expected_mode: str,
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_changed_files: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test clang-tidy mode selection based on files_to_check count."""
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = True
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False

    # Create component names
    components = [f"comp{i}" for i in range(component_count)]

    # Mock changed_files to return component files
    mock_changed_files.return_value = [
        f"esphome/components/{comp}/file.cpp" for comp in components
    ]

    # Mock git_ls_files to return files for each component
    cpp_files = {
        f"esphome/components/{comp}/file{i}.cpp": 0
        for comp in components
        for i in range(files_per_component)
    }

    # Create a mock that returns the cpp_files dict for any call
    def mock_git_ls_files(patterns=None):
        return cpp_files

    with (
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
        patch.object(determine_jobs, "git_ls_files", side_effect=mock_git_ls_files),
        patch.object(determine_jobs, "get_changed_components", return_value=components),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("esphome/components/"),
        ),
        patch.object(
            determine_jobs, "get_components_with_dependencies", return_value=components
        ),
    ):
        determine_jobs.main()

    captured = capsys.readouterr()
    output = json.loads(captured.out)

    assert output["clang_tidy_mode"] == expected_mode


def test_main_core_files_changed_still_detects_components(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_changed_files: Mock,
    mock_determine_cpp_unit_tests: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Test that component changes are detected even when core files change."""
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (True, [])
    mock_should_run_clang_tidy.return_value = True
    mock_should_run_clang_format.return_value = True
    mock_should_run_python_linters.return_value = True
    mock_determine_cpp_unit_tests.return_value = (True, [])

    mock_changed_files.return_value = [
        "esphome/core/helpers.h",
        "esphome/components/select/select_traits.h",
        "esphome/components/select/select_traits.cpp",
        "esphome/components/api/api.proto",
    ]

    with (
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
        patch.object(determine_jobs, "get_changed_components", return_value=None),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("esphome/components/"),
        ),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            side_effect=lambda files, deps: (
                ["select", "api"]
                if not deps
                else ["select", "api", "bluetooth_proxy", "logger"]
            ),
        ),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
        patch.object(
            determine_jobs,
            "create_intelligent_batches",
            return_value=([["select", "api", "bluetooth_proxy", "logger"]], {}),
        ),
    ):
        determine_jobs.main()

    captured = capsys.readouterr()
    output = json.loads(captured.out)

    assert output["clang_tidy"] is True
    assert output["clang_tidy_mode"] == "split"
    assert "select" in output["changed_components"]
    assert "api" in output["changed_components"]
    assert len(output["changed_components"]) > 0


@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_filters_incompatible_esp32_on_esp8266(
    tmp_path: Path,
) -> None:
    """Test that ESP32 components are filtered out when ESP8266 platform is selected.

    This test verifies the fix for the issue where ESP32 components were being included
    when ESP8266 was selected as the platform, causing build failures in PR 10387.
    """
    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # esp32 component only has esp32-idf tests (NOT compatible with esp8266)
    esp32_dir = tests_dir / "esp32"
    esp32_dir.mkdir(parents=True)
    (esp32_dir / "test.esp32-idf.yaml").write_text("test: esp32")
    (esp32_dir / "test.esp32-s3-idf.yaml").write_text("test: esp32")

    # esp8266 component only has esp8266-ard test (NOT compatible with esp32)
    esp8266_dir = tests_dir / "esp8266"
    esp8266_dir.mkdir(parents=True)
    (esp8266_dir / "test.esp8266-ard.yaml").write_text("test: esp8266")

    # Mock changed_files to return both esp32 and esp8266 component changes
    # Include esp8266-specific filename to trigger esp8266 platform hint
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "tests/components/esp32/common.yaml",
            "tests/components/esp8266/test.esp8266-ard.yaml",
            "esphome/core/helpers_esp8266.h",  # ESP8266-specific file to hint platform
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Memory impact should run
    assert result["should_run"] == "true"

    # Platform should be esp8266-ard (due to ESP8266 filename hint)
    assert result["platform"] == "esp8266-ard"

    # CRITICAL: Only esp8266 component should be included, not esp32
    # This prevents trying to build ESP32 components on ESP8266 platform
    assert result["components"] == ["esp8266"], (
        "When esp8266-ard platform is selected, only esp8266 component should be included, "
        "not esp32. This prevents trying to build ESP32 components on ESP8266 platform."
    )

    assert result["use_merged_config"] == "true"


@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_filters_incompatible_esp8266_on_esp32(
    tmp_path: Path,
) -> None:
    """Test that ESP8266 components are filtered out when ESP32 platform is selected.

    This is the inverse of the ESP8266 test - ensures filtering works both ways.
    """
    # Create test directory structure
    tests_dir = tmp_path / "tests" / "components"

    # esp32 component only has esp32-idf tests (NOT compatible with esp8266)
    esp32_dir = tests_dir / "esp32"
    esp32_dir.mkdir(parents=True)
    (esp32_dir / "test.esp32-idf.yaml").write_text("test: esp32")
    (esp32_dir / "test.esp32-s3-idf.yaml").write_text("test: esp32")

    # esp8266 component only has esp8266-ard test (NOT compatible with esp32)
    esp8266_dir = tests_dir / "esp8266"
    esp8266_dir.mkdir(parents=True)
    (esp8266_dir / "test.esp8266-ard.yaml").write_text("test: esp8266")

    # Mock changed_files to return both esp32 and esp8266 component changes
    # Include MORE esp32-specific filenames to ensure esp32-idf wins the hint count
    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
    ):
        mock_changed_files.return_value = [
            "tests/components/esp32/common.yaml",
            "tests/components/esp8266/test.esp8266-ard.yaml",
            "esphome/components/wifi/wifi_component_esp_idf.cpp",  # ESP-IDF hint
            "esphome/components/ethernet/ethernet_esp32.cpp",  # ESP32 hint
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Memory impact should run
    assert result["should_run"] == "true"

    # Platform should be esp32-idf (due to more ESP32-IDF hints)
    assert result["platform"] == "esp32-idf"

    # CRITICAL: Only esp32 component should be included, not esp8266
    # This prevents trying to build ESP8266 components on ESP32 platform
    assert result["components"] == ["esp32"], (
        "When esp32-idf platform is selected, only esp32 component should be included, "
        "not esp8266. This prevents trying to build ESP8266 components on ESP32 platform."
    )

    assert result["use_merged_config"] == "true"


def test_detect_memory_impact_config_skips_release_branch(tmp_path: Path) -> None:
    """Test that memory impact analysis is skipped for release* branches."""
    # Create test directory structure with components that have tests
    tests_dir = tmp_path / "tests" / "components"
    wifi_dir = tests_dir / "wifi"
    wifi_dir.mkdir(parents=True)
    (wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")

    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
        patch.object(determine_jobs, "get_target_branch", return_value="release"),
    ):
        mock_changed_files.return_value = ["esphome/components/wifi/wifi.cpp"]

        result = determine_jobs.detect_memory_impact_config()

    # Memory impact should be skipped for release branch
    assert result["should_run"] == "false"


def test_detect_memory_impact_config_skips_beta_branch(tmp_path: Path) -> None:
    """Test that memory impact analysis is skipped for beta* branches."""
    # Create test directory structure with components that have tests
    tests_dir = tmp_path / "tests" / "components"
    wifi_dir = tests_dir / "wifi"
    wifi_dir.mkdir(parents=True)
    (wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")

    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
        patch.object(determine_jobs, "get_target_branch", return_value="beta"),
    ):
        mock_changed_files.return_value = ["esphome/components/wifi/wifi.cpp"]

        result = determine_jobs.detect_memory_impact_config()

    # Memory impact should be skipped for beta branch
    assert result["should_run"] == "false"


def test_detect_memory_impact_config_runs_for_dev_branch(tmp_path: Path) -> None:
    """Test that memory impact analysis runs for dev branch."""
    # Create test directory structure with components that have tests
    tests_dir = tmp_path / "tests" / "components"
    wifi_dir = tests_dir / "wifi"
    wifi_dir.mkdir(parents=True)
    (wifi_dir / "test.esp32-idf.yaml").write_text("test: wifi")

    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
        patch.object(determine_jobs, "get_target_branch", return_value="dev"),
    ):
        mock_changed_files.return_value = ["esphome/components/wifi/wifi.cpp"]

        result = determine_jobs.detect_memory_impact_config()

    # Memory impact should run for dev branch
    assert result["should_run"] == "true"
    assert result["components"] == ["wifi"]


def test_detect_memory_impact_config_skips_too_many_components(
    tmp_path: Path,
) -> None:
    """Test that memory impact analysis is skipped when more than 40 components changed."""
    # Create test directory structure with 41 components
    tests_dir = tmp_path / "tests" / "components"
    component_names = [f"component_{i}" for i in range(41)]

    for component_name in component_names:
        comp_dir = tests_dir / component_name
        comp_dir.mkdir(parents=True)
        (comp_dir / "test.esp32-idf.yaml").write_text(f"test: {component_name}")

    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
        patch.object(determine_jobs, "get_target_branch", return_value="dev"),
    ):
        mock_changed_files.return_value = [
            f"esphome/components/{name}/{name}.cpp" for name in component_names
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Memory impact should be skipped for too many components (41 > 40)
    assert result["should_run"] == "false"


def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> None:
    """Test that memory impact analysis runs with exactly 40 components (at limit)."""
    # Create test directory structure with exactly 40 components
    tests_dir = tmp_path / "tests" / "components"
    component_names = [f"component_{i}" for i in range(40)]

    for component_name in component_names:
        comp_dir = tests_dir / component_name
        comp_dir.mkdir(parents=True)
        (comp_dir / "test.esp32-idf.yaml").write_text(f"test: {component_name}")

    with (
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "changed_files") as mock_changed_files,
        patch.object(determine_jobs, "get_target_branch", return_value="dev"),
    ):
        mock_changed_files.return_value = [
            f"esphome/components/{name}/{name}.cpp" for name in component_names
        ]

        result = determine_jobs.detect_memory_impact_config()

    # Memory impact should run at exactly 40 components (at limit but not over)
    assert result["should_run"] == "true"
    assert len(result["components"]) == 40


# Tests for _detect_platform_hint_from_filename function


@pytest.mark.parametrize(
    ("filename", "expected_platform"),
    [
        # ESP-IDF platform detection
        ("esphome/components/wifi/wifi_esp_idf.cpp", determine_jobs.Platform.ESP32_IDF),
        (
            "esphome/components/wifi/wifi_component_esp_idf.cpp",
            determine_jobs.Platform.ESP32_IDF,
        ),
        (
            "esphome/components/ethernet/ethernet_idf.cpp",
            determine_jobs.Platform.ESP32_IDF,
        ),
        # ESP32 variant detection with IDF suffix
        (
            "esphome/components/ble/esp32c3_idf.cpp",
            determine_jobs.Platform.ESP32_C3_IDF,
        ),
        (
            "esphome/components/ble/esp32c6_idf.cpp",
            determine_jobs.Platform.ESP32_C6_IDF,
        ),
        (
            "esphome/components/ble/esp32s2_idf.cpp",
            determine_jobs.Platform.ESP32_S2_IDF,
        ),
        (
            "esphome/components/ble/esp32s3_idf.cpp",
            determine_jobs.Platform.ESP32_S3_IDF,
        ),
        # ESP8266 detection
        (
            "esphome/components/wifi/wifi_esp8266.cpp",
            determine_jobs.Platform.ESP8266_ARD,
        ),
        ("esphome/core/helpers_esp8266.h", determine_jobs.Platform.ESP8266_ARD),
        # Generic ESP32 detection (without IDF suffix)
        ("esphome/components/wifi/wifi_esp32.cpp", determine_jobs.Platform.ESP32_IDF),
        (
            "esphome/components/ethernet/ethernet_esp32.cpp",
            determine_jobs.Platform.ESP32_IDF,
        ),
        # LibreTiny / BK72xx detection
        (
            "esphome/components/wifi/wifi_libretiny.cpp",
            determine_jobs.Platform.BK72XX_ARD,
        ),
        ("esphome/components/ble/ble_bk72xx.cpp", determine_jobs.Platform.BK72XX_ARD),
        # RTL87xx (LibreTiny Realtek) detection
        (
            "tests/components/logger/test.rtl87xx-ard.yaml",
            determine_jobs.Platform.RTL87XX_ARD,
        ),
        (
            "esphome/components/libretiny/wifi_rtl87xx.cpp",
            determine_jobs.Platform.RTL87XX_ARD,
        ),
        # LN882x (LibreTiny Lightning) detection
        (
            "tests/components/logger/test.ln882x-ard.yaml",
            determine_jobs.Platform.LN882X_ARD,
        ),
        (
            "esphome/components/libretiny/wifi_ln882x.cpp",
            determine_jobs.Platform.LN882X_ARD,
        ),
        # RP2040 / Raspberry Pi Pico detection
        ("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
        ("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
        ("esphome/components/i2c/i2c_pico.cpp", determine_jobs.Platform.RP2040_ARD),
        ("esphome/components/spi/spi_pico.cpp", determine_jobs.Platform.RP2040_ARD),
        (
            "tests/components/rp2040/test.rp2040-ard.yaml",
            determine_jobs.Platform.RP2040_ARD,
        ),
        # nRF52 / Zephyr detection
        (
            "tests/components/logger/test.nrf52-adafruit.yaml",
            determine_jobs.Platform.NRF52_ZEPHYR,
        ),
        (
            "esphome/components/nrf52/gpio.cpp",
            determine_jobs.Platform.NRF52_ZEPHYR,
        ),
        (
            "esphome/components/zephyr/core.cpp",
            determine_jobs.Platform.NRF52_ZEPHYR,
        ),
        (
            "esphome/components/zephyr_ble_server/ble_server.cpp",
            determine_jobs.Platform.NRF52_ZEPHYR,
        ),
        # No platform hint (generic files)
        ("esphome/components/wifi/wifi.cpp", None),
        ("esphome/components/sensor/sensor.h", None),
        ("esphome/core/helpers.h", None),
        ("README.md", None),
    ],
    ids=[
        "esp_idf_suffix",
        "esp_idf_component_suffix",
        "idf_suffix",
        "esp32c3_idf",
        "esp32c6_idf",
        "esp32s2_idf",
        "esp32s3_idf",
        "esp8266_suffix",
        "esp8266_core_header",
        "generic_esp32",
        "esp32_in_name",
        "libretiny",
        "bk72xx",
        "rtl87xx_test_yaml",
        "rtl87xx_wifi",
        "ln882x_test_yaml",
        "ln882x_wifi",
        "rp2040_gpio",
        "rp2040_wifi",
        "pico_i2c",
        "pico_spi",
        "rp2040_test_yaml",
        "nrf52_test_yaml",
        "nrf52_gpio",
        "zephyr_core",
        "zephyr_ble_server",
        "generic_wifi_no_hint",
        "generic_sensor_no_hint",
        "core_helpers_no_hint",
        "readme_no_hint",
    ],
)
def test_detect_platform_hint_from_filename(
    filename: str, expected_platform: determine_jobs.Platform | None
) -> None:
    """Test _detect_platform_hint_from_filename correctly detects platform hints."""
    result = determine_jobs._detect_platform_hint_from_filename(filename)
    assert result == expected_platform


@pytest.mark.parametrize(
    ("filename", "expected_platform"),
    [
        # RP2040/Pico with different cases
        ("file_RP2040.cpp", determine_jobs.Platform.RP2040_ARD),
        ("file_Rp2040.cpp", determine_jobs.Platform.RP2040_ARD),
        ("file_PICO.cpp", determine_jobs.Platform.RP2040_ARD),
        ("file_Pico.cpp", determine_jobs.Platform.RP2040_ARD),
        # ESP8266 with different cases
        ("file_ESP8266.cpp", determine_jobs.Platform.ESP8266_ARD),
        # ESP32 with different cases
        ("file_ESP32.cpp", determine_jobs.Platform.ESP32_IDF),
        # nRF52/Zephyr with different cases
        ("file_NRF52.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
        ("file_Nrf52.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
        ("file_ZEPHYR.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
        ("file_Zephyr.cpp", determine_jobs.Platform.NRF52_ZEPHYR),
    ],
    ids=[
        "rp2040_uppercase",
        "rp2040_mixedcase",
        "pico_uppercase",
        "pico_titlecase",
        "esp8266_uppercase",
        "esp32_uppercase",
        "nrf52_uppercase",
        "nrf52_mixedcase",
        "zephyr_uppercase",
        "zephyr_titlecase",
    ],
)
def test_detect_platform_hint_from_filename_case_insensitive(
    filename: str, expected_platform: determine_jobs.Platform
) -> None:
    """Test that platform detection is case-insensitive."""
    result = determine_jobs._detect_platform_hint_from_filename(filename)
    assert result == expected_platform


def test_component_batching_beta_branch_40_per_batch(
    tmp_path: Path,
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_changed_files: Mock,
    mock_determine_cpp_unit_tests: Mock,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """Test that beta/release branches create batches with 40 actual components each.

    For beta/release branches, all components should be groupable (not isolated),
    and each batch should contain 40 actual components with weight 1 each.
    This matches the original behavior before consolidation.
    """
    # Create 120 test components with test files
    component_names = [f"comp_{i:03d}" for i in range(120)]
    tests_dir = tmp_path / "tests" / "components"

    for comp in component_names:
        comp_dir = tests_dir / comp
        comp_dir.mkdir(parents=True)
        (comp_dir / "test.esp32-idf.yaml").write_text(f"# Test for {comp}")

    # Setup mocks
    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = False
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False
    mock_determine_cpp_unit_tests.return_value = (False, [])

    # Mock changed_files to return all component files
    changed_files = [
        f"esphome/components/{comp}/{comp}.cpp" for comp in component_names
    ]
    mock_changed_files.return_value = changed_files

    # Run main function with beta branch
    # Don't mock create_intelligent_batches - that's what we're testing!
    with (
        patch("sys.argv", ["determine-jobs.py", "--branch", "beta"]),
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(helpers, "root_path", str(tmp_path)),
        patch.object(script.helpers, "root_path", str(tmp_path)),
        patch.object(determine_jobs, "get_target_branch", return_value="beta"),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
        patch.object(
            determine_jobs,
            "get_changed_components",
            return_value=component_names,
        ),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("esphome/components/"),
        ),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            side_effect=lambda files, deps: component_names,
        ),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
    ):
        determine_jobs.main()

    # Check output
    captured = capsys.readouterr()
    output = json.loads(captured.out)

    # Verify batches are present and properly sized
    assert "component_test_batches" in output
    batches = output["component_test_batches"]

    # Should have 3 batches (120 components / 40 per batch = 3)
    assert len(batches) == 3, f"Expected 3 batches, got {len(batches)}"

    # Each batch should have approximately 40 components (all weight=1, groupable)
    for i, batch_str in enumerate(batches):
        batch_components = batch_str.split()
        assert len(batch_components) == 40, (
            f"Batch {i} should have 40 components, got {len(batch_components)}"
        )

    # Verify all 120 components are in batches
    all_components = []
    for batch_str in batches:
        all_components.extend(batch_str.split())
    assert len(all_components) == 120
    assert set(all_components) == set(component_names)


# --- should_run_benchmarks tests ---


def test_should_run_benchmarks_core_change() -> None:
    """Test benchmarks trigger on core C++ file changes."""
    with patch.object(
        determine_jobs, "changed_files", return_value=["esphome/core/scheduler.cpp"]
    ):
        assert determine_jobs.should_run_benchmarks() is True


def test_should_run_benchmarks_core_header_change() -> None:
    """Test benchmarks trigger on core header changes."""
    with patch.object(
        determine_jobs, "changed_files", return_value=["esphome/core/helpers.h"]
    ):
        assert determine_jobs.should_run_benchmarks() is True


def test_should_run_benchmarks_host_platform_change() -> None:
    """Test benchmarks trigger on host platform changes.

    Benchmarks build and run on the host platform, so changes to its
    millis()/micros()/etc. implementations affect every benchmark.
    """
    for host_file in [
        "esphome/components/host/core.cpp",
        "esphome/components/host/__init__.py",
    ]:
        with patch.object(determine_jobs, "changed_files", return_value=[host_file]):
            assert determine_jobs.should_run_benchmarks() is True, (
                f"Expected benchmarks to run for {host_file}"
            )


def test_should_run_benchmarks_benchmark_infra_change() -> None:
    """Test benchmarks trigger on benchmark infrastructure changes."""
    for infra_file in [
        "script/cpp_benchmark.py",
        "script/build_helpers.py",
        "script/setup_codspeed_lib.py",
    ]:
        with patch.object(determine_jobs, "changed_files", return_value=[infra_file]):
            assert determine_jobs.should_run_benchmarks() is True, (
                f"Expected benchmarks to run for {infra_file}"
            )


def test_should_run_benchmarks_benchmark_file_change() -> None:
    """Test benchmarks trigger on benchmark file changes."""
    with patch.object(
        determine_jobs,
        "changed_files",
        return_value=["tests/benchmarks/components/api/bench_proto_encode.cpp"],
    ):
        assert determine_jobs.should_run_benchmarks() is True


def test_should_run_benchmarks_core_benchmark_file_change() -> None:
    """Test benchmarks trigger on core benchmark file changes."""
    with patch.object(
        determine_jobs,
        "changed_files",
        return_value=["tests/benchmarks/core/bench_scheduler.cpp"],
    ):
        assert determine_jobs.should_run_benchmarks() is True


def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None:
    """Test benchmarks trigger when a benchmarked component changes."""
    # Create a fake benchmarks directory with an 'api' component
    benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
    benchmarks_dir.mkdir(parents=True)
    (benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")

    with (
        patch.object(
            determine_jobs,
            "changed_files",
            return_value=["esphome/components/api/proto.h"],
        ),
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(
            determine_jobs,
            "BENCHMARKS_COMPONENTS_PATH",
            "tests/benchmarks/components",
        ),
    ):
        assert determine_jobs.should_run_benchmarks() is True


def test_should_run_benchmarks_non_benchmarked_component_change(
    tmp_path: Path,
) -> None:
    """Test benchmarks do NOT trigger for non-benchmarked component changes."""
    # Create a fake benchmarks directory with only 'api'
    benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
    benchmarks_dir.mkdir(parents=True)
    (benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")

    with (
        patch.object(
            determine_jobs,
            "changed_files",
            return_value=["esphome/components/sensor/__init__.py"],
        ),
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(
            determine_jobs,
            "BENCHMARKS_COMPONENTS_PATH",
            "tests/benchmarks/components",
        ),
    ):
        assert determine_jobs.should_run_benchmarks() is False


def test_should_run_benchmarks_no_dependency_expansion(tmp_path: Path) -> None:
    """Test benchmarks do NOT expand to dependent components.

    Changing 'sensor' should not trigger 'api' benchmarks even if api
    depends on sensor. This is intentional — benchmark runs should be
    targeted to directly changed components only.
    """
    benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
    benchmarks_dir.mkdir(parents=True)
    (benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")

    with (
        patch.object(
            determine_jobs,
            "changed_files",
            # sensor is a dependency of api, but benchmarks don't expand
            return_value=["esphome/components/sensor/sensor.cpp"],
        ),
        patch.object(determine_jobs, "root_path", str(tmp_path)),
        patch.object(
            determine_jobs,
            "BENCHMARKS_COMPONENTS_PATH",
            "tests/benchmarks/components",
        ),
    ):
        assert determine_jobs.should_run_benchmarks() is False


def test_should_run_benchmarks_unrelated_change() -> None:
    """Test benchmarks do NOT trigger for unrelated changes."""
    with patch.object(determine_jobs, "changed_files", return_value=["README.md"]):
        assert determine_jobs.should_run_benchmarks() is False


def test_should_run_benchmarks_no_changes() -> None:
    """Test benchmarks do NOT trigger with no changes."""
    with patch.object(determine_jobs, "changed_files", return_value=[]):
        assert determine_jobs.should_run_benchmarks() is False


def test_should_run_benchmarks_with_branch() -> None:
    """Test should_run_benchmarks passes branch to changed_files."""
    with patch.object(determine_jobs, "changed_files") as mock_changed:
        mock_changed.return_value = []
        determine_jobs.should_run_benchmarks("release")
        mock_changed.assert_called_with("release")


# ---------------------------------------------------------------------------
# _component_change_is_validate_only
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("component", "changed", "expected"),
    [
        # Only a base validate file changed.
        (
            "foo",
            ["tests/components/foo/validate.esp32-idf.yaml"],
            True,
        ),
        # Only a validate variant changed.
        (
            "foo",
            ["tests/components/foo/validate-legacy.esp32-idf.yaml"],
            True,
        ),
        # Multiple validate files (all validate).
        (
            "foo",
            [
                "tests/components/foo/validate.esp32-idf.yaml",
                "tests/components/foo/validate-legacy.esp32-idf.yaml",
            ],
            True,
        ),
        # Mixed: validate + regular test must NOT be classified as validate-only.
        (
            "foo",
            [
                "tests/components/foo/validate.esp32-idf.yaml",
                "tests/components/foo/test.esp32-idf.yaml",
            ],
            False,
        ),
        # Regular test only.
        (
            "foo",
            ["tests/components/foo/test.esp32-idf.yaml"],
            False,
        ),
        # Source change disqualifies even if a validate file is also touched.
        (
            "foo",
            [
                "esphome/components/foo/foo.cpp",
                "tests/components/foo/validate.esp32-idf.yaml",
            ],
            False,
        ),
        # No matching files at all.
        ("foo", ["esphome/core/helpers.cpp"], False),
        # Filenames merely starting with "validate" but not following the
        # grammar must not match (defensive against accidental classification).
        (
            "foo",
            ["tests/components/foo/validatesomething.yaml"],
            False,
        ),
        # An unrelated component's validate change doesn't affect this one.
        (
            "foo",
            ["tests/components/bar/validate.esp32-idf.yaml"],
            False,
        ),
        # common.yaml change in the component dir disqualifies.
        (
            "foo",
            [
                "tests/components/foo/common.yaml",
                "tests/components/foo/validate.esp32-idf.yaml",
            ],
            False,
        ),
    ],
)
def test_component_change_is_validate_only(
    component: str, changed: list[str], expected: bool
) -> None:
    """The validate-only classifier rejects anything beyond validate.* edits."""
    assert (
        determine_jobs._component_change_is_validate_only(component, changed)
        is expected
    )


def test_main_emits_validate_only_components(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_should_run_import_time: Mock,
    mock_should_run_device_builder: Mock,
    mock_changed_files: Mock,
    mock_determine_cpp_unit_tests: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Directly-changed components whose only edits are validate.*.yaml are
    listed in `validate_only_components` so CI can skip their compile stage.
    """
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = False
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False
    mock_should_run_import_time.return_value = False
    mock_should_run_device_builder.return_value = False
    mock_determine_cpp_unit_tests.return_value = (False, [])

    # foo: only validate file changed (qualifies)
    # bar: test file changed (does not qualify)
    mock_changed_files.return_value = [
        "tests/components/foo/validate.esp32-idf.yaml",
        "tests/components/bar/test.esp32-idf.yaml",
    ]

    with (
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
        patch.object(
            determine_jobs,
            "get_changed_components",
            return_value=["foo", "bar"],
        ),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("tests/components/"),
        ),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            side_effect=lambda files, deps: ["foo", "bar"],
        ),
        patch.object(determine_jobs, "_component_has_tests", return_value=True),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
        patch.object(
            determine_jobs,
            "create_intelligent_batches",
            return_value=([["foo", "bar"]], {}),
        ),
    ):
        determine_jobs.main()

    output = json.loads(capsys.readouterr().out)
    assert output["validate_only_components"] == ["foo"]


def test_main_validate_only_excludes_transitive_components(
    mock_determine_integration_tests: Mock,
    mock_should_run_clang_tidy: Mock,
    mock_should_run_clang_format: Mock,
    mock_should_run_python_linters: Mock,
    mock_should_run_import_time: Mock,
    mock_should_run_device_builder: Mock,
    mock_changed_files: Mock,
    mock_determine_cpp_unit_tests: Mock,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A component pulled in only as a dependency must NOT be considered
    validate-only, even if it has no source changes -- its dependency moved,
    so the compile is still required.
    """
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    mock_determine_integration_tests.return_value = (False, [])
    mock_should_run_clang_tidy.return_value = False
    mock_should_run_clang_format.return_value = False
    mock_should_run_python_linters.return_value = False
    mock_should_run_import_time.return_value = False
    mock_should_run_device_builder.return_value = False
    mock_determine_cpp_unit_tests.return_value = (False, [])

    # Only foo's validate file changed directly. bar is a transitive dep.
    mock_changed_files.return_value = [
        "tests/components/foo/validate.esp32-idf.yaml",
    ]

    with (
        patch("sys.argv", ["determine-jobs.py"]),
        patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
        patch.object(
            determine_jobs,
            "get_changed_components",
            return_value=["foo", "bar"],  # bar pulled in via dependencies
        ),
        patch.object(
            determine_jobs,
            "filter_component_and_test_files",
            side_effect=lambda f: f.startswith("tests/components/"),
        ),
        patch.object(
            determine_jobs,
            "get_components_with_dependencies",
            # deps=False -> directly_changed = [foo]; deps=True -> [foo, bar]
            side_effect=lambda files, deps: ["foo", "bar"] if deps else ["foo"],
        ),
        patch.object(determine_jobs, "_component_has_tests", return_value=True),
        patch.object(
            determine_jobs,
            "detect_memory_impact_config",
            return_value={"should_run": "false"},
        ),
        patch.object(
            determine_jobs,
            "create_intelligent_batches",
            return_value=([["foo", "bar"]], {}),
        ),
    ):
        determine_jobs.main()

    output = json.loads(capsys.readouterr().out)
    # Only foo (directly changed, validate-only). bar is a transitive dep
    # and still needs compile despite no source change of its own.
    assert output["validate_only_components"] == ["foo"]
