"""Unit tests for script/helpers.py module."""

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

import pytest
from pytest import MonkeyPatch

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

import helpers  # noqa: E402

changed_files = helpers.changed_files
filter_changed = helpers.filter_changed
get_changed_components = helpers.get_changed_components
_get_changed_files_from_command = helpers._get_changed_files_from_command
_get_pr_number_from_github_env = helpers._get_pr_number_from_github_env
_get_changed_files_github_actions = helpers._get_changed_files_github_actions
_filter_changed_ci = helpers._filter_changed_ci
_filter_changed_local = helpers._filter_changed_local
build_all_include = helpers.build_all_include
print_file_list = helpers.print_file_list
get_all_dependencies = helpers.get_all_dependencies


@pytest.fixture(autouse=True)
def clear_helpers_cache() -> None:
    """Clear cached functions before each test."""
    helpers._get_github_event_data.cache_clear()
    helpers._get_changed_files_github_actions.cache_clear()
    helpers.get_components_per_integration_fixture.cache_clear()


@pytest.mark.parametrize(
    ("github_ref", "expected_pr_number"),
    [
        ("refs/pull/1234/merge", "1234"),
        ("refs/pull/5678/head", "5678"),
        ("refs/pull/999/merge", "999"),
        ("refs/heads/main", None),
        ("", None),
    ],
)
def test_get_pr_number_from_github_env_ref(
    monkeypatch: MonkeyPatch, github_ref: str, expected_pr_number: str | None
) -> None:
    """Test extracting PR number from GITHUB_REF."""
    monkeypatch.setenv("GITHUB_REF", github_ref)
    # Make sure GITHUB_EVENT_PATH is not set
    monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)

    result = _get_pr_number_from_github_env()

    assert result == expected_pr_number


def test_get_pr_number_from_github_env_event_file(
    monkeypatch: MonkeyPatch, tmp_path: Path
) -> None:
    """Test extracting PR number from GitHub event file."""
    # No PR number in ref
    monkeypatch.setenv("GITHUB_REF", "refs/heads/feature-branch")

    event_file = tmp_path / "event.json"
    event_data = {"pull_request": {"number": 5678}}
    event_file.write_text(json.dumps(event_data))
    monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))

    result = _get_pr_number_from_github_env()

    assert result == "5678"


def test_get_pr_number_from_github_env_no_pr(
    monkeypatch: MonkeyPatch, tmp_path: Path
) -> None:
    """Test when no PR number is available."""
    monkeypatch.setenv("GITHUB_REF", "refs/heads/main")

    event_file = tmp_path / "event.json"
    event_data = {"push": {"head_commit": {"id": "abc123"}}}
    event_file.write_text(json.dumps(event_data))
    monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))

    result = _get_pr_number_from_github_env()

    assert result is None


@pytest.mark.parametrize(
    ("github_ref", "expected_pr_number"),
    [
        ("refs/pull/1234/merge", "1234"),
        ("refs/pull/5678/head", "5678"),
        ("refs/pull/999/merge", "999"),
    ],
)
def test_github_actions_pull_request_with_pr_number_in_ref(
    monkeypatch: MonkeyPatch, github_ref: str, expected_pr_number: str
) -> None:
    """Test PR detection via GITHUB_REF."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
    monkeypatch.setenv("GITHUB_REF", github_ref)

    expected_files = ["file1.py", "file2.cpp"]

    with patch("helpers._get_changed_files_from_command") as mock_get:
        mock_get.return_value = expected_files

        result = changed_files()

        mock_get.assert_called_once_with(
            ["gh", "pr", "diff", expected_pr_number, "--name-only"]
        )
        assert result == expected_files


def test_github_actions_pull_request_with_event_file(
    monkeypatch: MonkeyPatch, tmp_path: Path
) -> None:
    """Test PR detection via GitHub event file."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
    monkeypatch.setenv("GITHUB_REF", "refs/heads/feature-branch")

    event_file = tmp_path / "event.json"
    event_data = {"pull_request": {"number": 5678}}
    event_file.write_text(json.dumps(event_data))
    monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))

    expected_files = ["file1.py", "file2.cpp"]

    with patch("helpers._get_changed_files_from_command") as mock_get:
        mock_get.return_value = expected_files

        result = changed_files()

        mock_get.assert_called_once_with(["gh", "pr", "diff", "5678", "--name-only"])
        assert result == expected_files


def test_github_actions_push_event(monkeypatch: MonkeyPatch) -> None:
    """Test push event handling."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")
    monkeypatch.setenv("GITHUB_EVENT_NAME", "push")

    expected_files = ["file1.py", "file2.cpp"]

    with patch("helpers._get_changed_files_from_command") as mock_get:
        mock_get.return_value = expected_files

        result = changed_files()

        mock_get.assert_called_once_with(["git", "diff", "HEAD~1..HEAD", "--name-only"])
        assert result == expected_files


@pytest.fixture(autouse=True)
def clear_caches():
    """Clear function caches before each test."""
    # Clear the cache for _get_changed_files_github_actions
    _get_changed_files_github_actions.cache_clear()
    yield


def test_get_changed_files_github_actions_pull_request(
    monkeypatch: MonkeyPatch,
) -> None:
    """Test _get_changed_files_github_actions for pull request event."""
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")

    expected_files = ["file1.py", "file2.cpp"]

    with (
        patch("helpers._get_pr_number_from_github_env", return_value="1234"),
        patch("helpers._get_changed_files_from_command") as mock_get,
    ):
        mock_get.return_value = expected_files

        result = _get_changed_files_github_actions()

        mock_get.assert_called_once_with(["gh", "pr", "diff", "1234", "--name-only"])
        assert result == expected_files


def test_get_changed_files_github_actions_pull_request_large_pr(
    monkeypatch: MonkeyPatch,
) -> None:
    """Test _get_changed_files_github_actions fallback for PRs with >300 files."""
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")

    expected_files = ["file1.py", "file2.cpp"]

    with (
        patch("helpers._get_pr_number_from_github_env", return_value="10214"),
        patch("helpers._get_changed_files_from_command") as mock_get,
    ):
        # First call fails with too many files error, second succeeds with API method
        mock_get.side_effect = [
            Exception("Sorry, the diff exceeded the maximum number of files (300)"),
            expected_files,
        ]

        result = _get_changed_files_github_actions()

        assert mock_get.call_count == 2
        mock_get.assert_any_call(["gh", "pr", "diff", "10214", "--name-only"])
        mock_get.assert_any_call(
            [
                "gh",
                "api",
                "repos/esphome/esphome/pulls/10214/files",
                "--paginate",
                "--jq",
                ".[].filename",
            ]
        )
        assert result == expected_files


def test_get_changed_files_github_actions_pull_request_other_error(
    monkeypatch: MonkeyPatch,
) -> None:
    """Test _get_changed_files_github_actions re-raises non-file-limit errors."""
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")

    with (
        patch("helpers._get_pr_number_from_github_env", return_value="1234"),
        patch("helpers._get_changed_files_from_command") as mock_get,
    ):
        # Error that is not about file limit
        mock_get.side_effect = Exception("Command failed: authentication required")

        with pytest.raises(Exception, match="authentication required"):
            _get_changed_files_github_actions()

        # Should only be called once (no retry with API)
        mock_get.assert_called_once_with(["gh", "pr", "diff", "1234", "--name-only"])


def test_get_changed_files_github_actions_pull_request_no_pr_number(
    monkeypatch: MonkeyPatch,
) -> None:
    """Test _get_changed_files_github_actions when no PR number is found."""
    monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")

    with patch("helpers._get_pr_number_from_github_env", return_value=None):
        result = _get_changed_files_github_actions()

        assert result is None


def test_get_changed_files_github_actions_push(monkeypatch: MonkeyPatch) -> None:
    """Test _get_changed_files_github_actions for push event."""
    monkeypatch.setenv("GITHUB_EVENT_NAME", "push")

    expected_files = ["file1.py", "file2.cpp"]

    with patch("helpers._get_changed_files_from_command") as mock_get:
        mock_get.return_value = expected_files

        result = _get_changed_files_github_actions()

        mock_get.assert_called_once_with(["git", "diff", "HEAD~1..HEAD", "--name-only"])
        assert result == expected_files


def test_get_changed_files_github_actions_push_fallback(
    monkeypatch: MonkeyPatch,
) -> None:
    """Test _get_changed_files_github_actions fallback for push event."""
    monkeypatch.setenv("GITHUB_EVENT_NAME", "push")

    with patch("helpers._get_changed_files_from_command") as mock_get:
        mock_get.side_effect = Exception("Failed")

        result = _get_changed_files_github_actions()

        assert result is None


def test_get_changed_files_github_actions_other_event(monkeypatch: MonkeyPatch) -> None:
    """Test _get_changed_files_github_actions for other event types."""
    monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_dispatch")

    result = _get_changed_files_github_actions()

    assert result is None


def test_github_actions_push_event_fallback(monkeypatch: MonkeyPatch) -> None:
    """Test push event fallback to git merge-base."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")
    monkeypatch.setenv("GITHUB_EVENT_NAME", "push")

    expected_files = ["file1.py", "file2.cpp"]

    with (
        patch("helpers._get_changed_files_from_command") as mock_get,
        patch("helpers.get_output") as mock_output,
    ):
        # First call fails, triggering fallback
        mock_get.side_effect = [
            Exception("Failed"),
            expected_files,
        ]

        mock_output.side_effect = [
            "origin\nupstream\n",  # git remote
            "abc123\n",  # merge base
        ]

        result = changed_files()

        assert mock_get.call_count == 2
        assert result == expected_files


@pytest.mark.parametrize(
    ("branch", "merge_base"),
    [
        (None, "abc123"),  # Default branch (dev)
        ("release", "def456"),
        ("beta", "ghi789"),
    ],
)
def test_local_development_branches(
    monkeypatch: MonkeyPatch, branch: str | None, merge_base: str
) -> None:
    """Test local development with different branches."""
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    expected_files = ["file1.py", "file2.cpp"]

    with (
        patch("helpers.get_output") as mock_output,
        patch("helpers._get_changed_files_from_command") as mock_get,
    ):
        if branch is None:
            # For default branch, helpers.get_output is called twice (git remote and merge-base)
            mock_output.side_effect = [
                "origin\nupstream\n",  # git remote
                f"{merge_base}\n",  # merge base for upstream/dev
            ]
        else:
            # For custom branch, may need more calls if trying multiple remotes
            mock_output.side_effect = [
                "origin\nupstream\n",  # git remote
                Exception("not found"),  # upstream/{branch} may fail
                f"{merge_base}\n",  # merge base for origin/{branch}
            ]

        mock_get.return_value = expected_files

        result = changed_files(branch)

        mock_get.assert_called_once_with(["git", "diff", merge_base, "--name-only"])
        assert result == expected_files


def test_local_development_no_remotes_configured(monkeypatch: MonkeyPatch) -> None:
    """Test error when no git remotes are configured."""
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    with patch("helpers.get_output") as mock_output:
        # The function calls get_output multiple times:
        # 1. First to get list of remotes: git remote
        # 2. Then for each remote it tries: git merge-base
        # We simulate having some remotes but all merge-base attempts fail
        def side_effect_func(*args):
            if args == ("git", "remote"):
                return "origin\nupstream\n"
            # All merge-base attempts fail
            raise Exception("Command failed")

        mock_output.side_effect = side_effect_func

        with pytest.raises(ValueError, match="Git not configured"):
            changed_files()


@pytest.mark.parametrize(
    ("stdout", "expected"),
    [
        ("file1.py\nfile2.cpp\n\n", ["file1.py", "file2.cpp"]),
        ("\n\n", []),
        ("single.py\n", ["single.py"]),
        (
            "path/to/file.cpp\nanother/path.h\n",
            ["another/path.h", "path/to/file.cpp"],
        ),  # Sorted
    ],
)
def test_get_changed_files_from_command_successful(
    stdout: str, expected: list[str]
) -> None:
    """Test successful command execution with various outputs."""
    mock_result = Mock()
    mock_result.returncode = 0
    mock_result.stdout = stdout

    with patch("subprocess.run", return_value=mock_result):
        result = _get_changed_files_from_command(["git", "diff"])

        # Normalize paths to forward slashes for comparison
        # since os.path.relpath returns OS-specific separators
        normalized_result = [f.replace(os.sep, "/") for f in result]
        assert normalized_result == expected


@pytest.mark.parametrize(
    ("returncode", "stderr"),
    [
        (1, "Error: command failed"),
        (128, "fatal: not a git repository"),
        (2, "Unknown error"),
    ],
)
def test_get_changed_files_from_command_failed(returncode: int, stderr: str) -> None:
    """Test command failure handling."""
    mock_result = Mock()
    mock_result.returncode = returncode
    mock_result.stderr = stderr

    with patch("subprocess.run", return_value=mock_result):
        with pytest.raises(Exception) as exc_info:
            _get_changed_files_from_command(["git", "diff"])
        assert "Command failed" in str(exc_info.value)
        assert stderr in str(exc_info.value)


def test_get_changed_files_from_command_relative_paths() -> None:
    """Test that paths are made relative to current directory."""
    mock_result = Mock()
    mock_result.returncode = 0
    mock_result.stdout = "/some/project/file1.py\n/some/project/sub/file2.cpp\n"

    with (
        patch("subprocess.run", return_value=mock_result),
        patch(
            "os.path.relpath", side_effect=["file1.py", "sub/file2.cpp"]
        ) as mock_relpath,
        patch("os.getcwd", return_value="/some/project"),
    ):
        result = _get_changed_files_from_command(["git", "diff"])

        # Check relpath was called with correct arguments
        assert mock_relpath.call_count == 2
        assert result == ["file1.py", "sub/file2.cpp"]


@pytest.mark.parametrize(
    "changed_files_list",
    [
        ["esphome/core/component.h", "esphome/components/wifi/wifi.cpp"],
        ["esphome/core/helpers.cpp"],
        ["esphome/core/application.h", "esphome/core/defines.h"],
    ],
)
def test_get_changed_components_core_cpp_files_trigger_full_scan(
    changed_files_list: list[str],
) -> None:
    """Test that core C++/header file changes trigger full scan without calling subprocess."""
    with patch("helpers.changed_files") as mock_changed:
        mock_changed.return_value = changed_files_list

        # Should return None without calling subprocess
        result = get_changed_components()
        assert result is None


def test_get_changed_components_core_python_files_no_full_scan() -> None:
    """Test that core Python file changes do NOT trigger full scan."""
    changed_files_list = [
        "esphome/core/__init__.py",
        "esphome/core/config.py",
        "esphome/components/wifi/wifi.cpp",
    ]

    with patch("helpers.changed_files") as mock_changed:
        mock_changed.return_value = changed_files_list

        mock_result = Mock()
        mock_result.stdout = "wifi\n"

        with patch("subprocess.run", return_value=mock_result):
            result = get_changed_components()
            # Should NOT return None - should call list-components.py
            assert result == ["wifi"]


def test_get_changed_components_mixed_core_files_with_cpp() -> None:
    """Test that mixed Python and C++ core files still trigger full scan due to C++ file."""
    changed_files_list = [
        "esphome/core/__init__.py",
        "esphome/core/config.py",
        "esphome/core/helpers.cpp",  # This C++ file should trigger full scan
        "esphome/components/wifi/wifi.cpp",
    ]

    with patch("helpers.changed_files") as mock_changed:
        mock_changed.return_value = changed_files_list

        # Should return None without calling subprocess due to helpers.cpp
        result = get_changed_components()
        assert result is None


@pytest.mark.parametrize(
    ("changed_files_list", "expected"),
    [
        # Only component files changed
        (
            ["esphome/components/wifi/wifi.cpp", "esphome/components/api/api.cpp"],
            ["wifi", "api"],
        ),
        # Non-component files only
        (["README.md", "script/clang-tidy"], []),
        # Single component
        (["esphome/components/mqtt/mqtt_client.cpp"], ["mqtt"]),
    ],
)
def test_get_changed_components_returns_component_list(
    changed_files_list: list[str], expected: list[str]
) -> None:
    """Test component detection returns correct component list."""
    with patch("helpers.changed_files") as mock_changed:
        mock_changed.return_value = changed_files_list

        mock_result = Mock()
        mock_result.stdout = "\n".join(expected) + "\n" if expected else "\n"

        with patch("subprocess.run", return_value=mock_result):
            result = get_changed_components()
            assert result == expected


def test_get_changed_components_script_failure() -> None:
    """Test fallback to full scan when script fails."""
    with patch("helpers.changed_files") as mock_changed:
        mock_changed.return_value = ["esphome/components/wifi/wifi_component.cpp"]

        with patch("subprocess.run") as mock_run:
            mock_run.side_effect = subprocess.CalledProcessError(1, "cmd")

            result = get_changed_components()

            assert result is None  # None means full scan


@pytest.mark.parametrize(
    ("components", "all_files", "expected_files"),
    [
        # Core C++/header files changed (full scan)
        (
            None,
            ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"],
            ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"],
        ),
        # Specific components
        (
            ["wifi", "api"],
            [
                "esphome/components/wifi/wifi.cpp",
                "esphome/components/wifi/wifi.h",
                "esphome/components/api/api.cpp",
                "esphome/components/mqtt/mqtt.cpp",
            ],
            [
                "esphome/components/wifi/wifi.cpp",
                "esphome/components/wifi/wifi.h",
                "esphome/components/api/api.cpp",
            ],
        ),
        # No components changed
        (
            [],
            ["esphome/components/wifi/wifi.cpp", "script/clang-tidy"],
            ["script/clang-tidy"],  # Only non-component changed files
        ),
    ],
)
def test_filter_changed_ci_mode(
    monkeypatch: MonkeyPatch,
    components: list[str] | None,
    all_files: list[str],
    expected_files: list[str],
) -> None:
    """Test filter_changed in CI mode with different component scenarios."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")

    with patch("helpers.get_changed_components") as mock_components:
        mock_components.return_value = components

        if components == []:
            # No components changed scenario needs changed_files mock
            with patch("helpers.changed_files") as mock_changed:
                mock_changed.return_value = ["script/clang-tidy", "README.md"]
                result = filter_changed(all_files)
        else:
            result = filter_changed(all_files)

        assert set(result) == set(expected_files)


def test_filter_changed_local_mode(monkeypatch: MonkeyPatch) -> None:
    """Test filter_changed in local mode filters files directly."""
    monkeypatch.delenv("GITHUB_ACTIONS", raising=False)

    all_files = [
        "esphome/components/wifi/wifi.cpp",
        "esphome/components/api/api.cpp",
        "esphome/core/helpers.cpp",
    ]

    with patch("helpers.changed_files") as mock_changed:
        mock_changed.return_value = [
            "esphome/components/wifi/wifi.cpp",
            "esphome/core/helpers.cpp",
        ]

        result = filter_changed(all_files)

        # Should only include files that actually changed
        expected = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"]
        assert set(result) == set(expected)


def test_filter_changed_component_path_parsing(monkeypatch: MonkeyPatch) -> None:
    """Test correct parsing of component paths."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")

    all_files = [
        "esphome/components/wifi/wifi_component.cpp",
        "esphome/components/wifi_info/wifi_info_text_sensor.cpp",  # Different component
        "esphome/components/api/api_server.cpp",
        "esphome/components/api/custom_api_device.h",
    ]

    with patch("helpers.get_changed_components") as mock_components:
        mock_components.return_value = ["wifi"]  # Only wifi, not wifi_info

        result = filter_changed(all_files)

        # Should only include files from wifi component, not wifi_info
        expected = ["esphome/components/wifi/wifi_component.cpp"]
        assert result == expected


def test_filter_changed_prints_output(
    monkeypatch: MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    """Test that appropriate messages are printed."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")

    all_files = ["esphome/components/wifi/wifi_component.cpp"]

    with patch("helpers.get_changed_components") as mock_components:
        mock_components.return_value = ["wifi"]

        filter_changed(all_files)

        # Check that output was produced (not checking exact messages)
        captured = capsys.readouterr()
        assert len(captured.out) > 0


@pytest.mark.parametrize(
    ("files", "expected_empty"),
    [
        ([], True),
        (["file.cpp"], False),
    ],
    ids=["empty_files", "non_empty_files"],
)
def test_filter_changed_empty_file_handling(
    monkeypatch: MonkeyPatch, files: list[str], expected_empty: bool
) -> None:
    """Test handling of empty file lists."""
    monkeypatch.setenv("GITHUB_ACTIONS", "true")

    with patch("helpers.get_changed_components") as mock_components:
        mock_components.return_value = ["wifi"]

        result = filter_changed(files)

        # Both cases should be empty:
        # - Empty files list -> empty result
        # - file.cpp doesn't match esphome/components/wifi/* pattern -> filtered out
        assert len(result) == 0


def test_filter_changed_ci_full_scan() -> None:
    """Test _filter_changed_ci when core C++/header files changed (full scan)."""
    all_files = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"]

    with patch("helpers.get_changed_components", return_value=None):
        result = _filter_changed_ci(all_files)

    # Should return all files for full scan
    assert result == all_files


def test_filter_changed_ci_no_components_changed() -> None:
    """Test _filter_changed_ci when no components changed."""
    all_files = ["esphome/components/wifi/wifi.cpp", "script/clang-tidy", "README.md"]

    with (
        patch("helpers.get_changed_components", return_value=[]),
        patch("helpers.changed_files", return_value=["script/clang-tidy", "README.md"]),
    ):
        result = _filter_changed_ci(all_files)

    # Should only include non-component files that changed
    assert set(result) == {"script/clang-tidy", "README.md"}


def test_filter_changed_ci_specific_components() -> None:
    """Test _filter_changed_ci with specific components changed."""
    all_files = [
        "esphome/components/wifi/wifi.cpp",
        "esphome/components/wifi/wifi.h",
        "esphome/components/api/api.cpp",
        "esphome/components/mqtt/mqtt.cpp",
    ]

    with patch("helpers.get_changed_components", return_value=["wifi", "api"]):
        result = _filter_changed_ci(all_files)

    # Should include all files from wifi and api components
    expected = [
        "esphome/components/wifi/wifi.cpp",
        "esphome/components/wifi/wifi.h",
        "esphome/components/api/api.cpp",
    ]
    assert set(result) == set(expected)


def test_filter_changed_local() -> None:
    """Test _filter_changed_local filters based on git changes."""
    all_files = [
        "esphome/components/wifi/wifi.cpp",
        "esphome/components/api/api.cpp",
        "esphome/core/helpers.cpp",
    ]

    with patch("helpers.changed_files") as mock_changed:
        mock_changed.return_value = [
            "esphome/components/wifi/wifi.cpp",
            "esphome/core/helpers.cpp",
        ]

        result = _filter_changed_local(all_files)

    # Should only include files that actually changed
    expected = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"]
    assert set(result) == set(expected)


def test_build_all_include_with_git(tmp_path: Path) -> None:
    """Test build_all_include using git ls-files."""
    # Mock git output
    git_output = "esphome/core/component.h\nesphome/components/wifi/wifi.h\nesphome/components/api/api.h\n"

    mock_proc = Mock()
    mock_proc.returncode = 0
    mock_proc.stdout = git_output

    with (
        patch("subprocess.run", return_value=mock_proc),
        patch("helpers.temp_header_file", str(tmp_path / "all-include.cpp")),
    ):
        build_all_include()

    # Check the generated file
    include_file = tmp_path / "all-include.cpp"
    assert include_file.exists()

    content = include_file.read_text()
    expected_lines = [
        '#include "esphome/components/api/api.h"',
        '#include "esphome/components/wifi/wifi.h"',
        '#include "esphome/core/component.h"',
        "",  # Empty line at end
    ]
    assert content == "\n".join(expected_lines)


def test_build_all_include_empty_output(tmp_path: Path) -> None:
    """Test build_all_include with empty git output."""
    # Mock git returning empty output
    mock_proc = Mock()
    mock_proc.returncode = 0
    mock_proc.stdout = ""

    with (
        patch("subprocess.run", return_value=mock_proc),
        patch("helpers.temp_header_file", str(tmp_path / "all-include.cpp")),
    ):
        build_all_include()

    # Check the generated file
    include_file = tmp_path / "all-include.cpp"
    assert include_file.exists()

    content = include_file.read_text()
    # When git output is empty, the list comprehension filters out empty strings,
    # then we append "" to get [""], which joins to just ""
    assert content == ""


def test_build_all_include_creates_directory(tmp_path: Path) -> None:
    """Test that build_all_include creates the temp directory if needed."""
    # Use a subdirectory that doesn't exist
    temp_file = tmp_path / "subdir" / "all-include.cpp"

    mock_proc = Mock()
    mock_proc.returncode = 0
    mock_proc.stdout = "esphome/core/test.h\n"

    with (
        patch("subprocess.run", return_value=mock_proc),
        patch("helpers.temp_header_file", str(temp_file)),
    ):
        build_all_include()

    # Check that directory was created
    assert temp_file.parent.exists()
    assert temp_file.exists()


def test_print_file_list_empty(capsys: pytest.CaptureFixture[str]) -> None:
    """Test printing an empty file list."""
    print_file_list([], "Test Files:")
    captured = capsys.readouterr()

    assert "Test Files:" in captured.out
    assert "No files to check!" in captured.out


def test_print_file_list_small(capsys: pytest.CaptureFixture[str]) -> None:
    """Test printing a small list of files (less than max_files)."""
    files = ["file1.cpp", "file2.cpp", "file3.cpp"]
    print_file_list(files, "Test Files:", max_files=20)
    captured = capsys.readouterr()

    assert "Test Files:" in captured.out
    assert "    file1.cpp" in captured.out
    assert "    file2.cpp" in captured.out
    assert "    file3.cpp" in captured.out
    assert "... and" not in captured.out


def test_print_file_list_exact_max_files(capsys: pytest.CaptureFixture[str]) -> None:
    """Test printing exactly max_files number of files."""
    files = [f"file{i}.cpp" for i in range(20)]
    print_file_list(files, "Test Files:", max_files=20)
    captured = capsys.readouterr()

    # All files should be shown
    for i in range(20):
        assert f"    file{i}.cpp" in captured.out
    assert "... and" not in captured.out


def test_print_file_list_large(capsys: pytest.CaptureFixture[str]) -> None:
    """Test printing a large list of files (more than max_files)."""
    files = [f"file{i:03d}.cpp" for i in range(50)]
    print_file_list(files, "Test Files:", max_files=20)
    captured = capsys.readouterr()

    assert "Test Files:" in captured.out
    # First 10 files should be shown (sorted)
    for i in range(10):
        assert f"    file{i:03d}.cpp" in captured.out
    # Files 10-49 should not be shown
    assert "    file010.cpp" not in captured.out
    assert "    file049.cpp" not in captured.out
    # Should show count of remaining files
    assert "... and 40 more files" in captured.out


def test_print_file_list_unsorted(capsys: pytest.CaptureFixture[str]) -> None:
    """Test that files are sorted before printing."""
    files = ["z_file.cpp", "a_file.cpp", "m_file.cpp"]
    print_file_list(files, "Test Files:", max_files=20)
    captured = capsys.readouterr()

    lines = captured.out.strip().split("\n")
    # Check order in output
    assert lines[1] == "    a_file.cpp"
    assert lines[2] == "    m_file.cpp"
    assert lines[3] == "    z_file.cpp"


def test_print_file_list_custom_max_files(capsys: pytest.CaptureFixture[str]) -> None:
    """Test with custom max_files parameter."""
    files = [f"file{i}.cpp" for i in range(15)]
    print_file_list(files, "Test Files:", max_files=10)
    captured = capsys.readouterr()

    # Should truncate after 10 files
    assert "... and 5 more files" in captured.out


def test_print_file_list_default_title(capsys: pytest.CaptureFixture[str]) -> None:
    """Test with default title."""
    print_file_list(["test.cpp"])
    captured = capsys.readouterr()

    assert "Files:" in captured.out
    assert "    test.cpp" in captured.out


@pytest.mark.parametrize(
    ("component_configs", "initial_components", "expected_components"),
    [
        # No dependencies
        (
            {"sensor": ([], [])},  # (dependencies, auto_load)
            {"sensor"},
            {"sensor"},
        ),
        # Simple dependencies
        (
            {
                "sensor": (["esp32"], []),
                "esp32": ([], []),
            },
            {"sensor"},
            {"sensor", "esp32"},
        ),
        # Auto-load components
        (
            {
                "light": ([], ["output", "power_supply"]),
                "output": ([], []),
                "power_supply": ([], []),
            },
            {"light"},
            {"light", "output", "power_supply"},
        ),
        # Transitive dependencies
        (
            {
                "comp_a": (["comp_b"], []),
                "comp_b": (["comp_c"], []),
                "comp_c": ([], []),
            },
            {"comp_a"},
            {"comp_a", "comp_b", "comp_c"},
        ),
        # Dependencies with dots (sensor.base)
        (
            {
                "my_comp": (["sensor.base", "binary_sensor.base"], []),
                "sensor": ([], []),
                "binary_sensor": ([], []),
            },
            {"my_comp"},
            {"my_comp", "sensor", "binary_sensor"},
        ),
        # Circular dependencies (should not cause infinite loop)
        (
            {
                "comp_a": (["comp_b"], []),
                "comp_b": (["comp_a"], []),
            },
            {"comp_a"},
            {"comp_a", "comp_b"},
        ),
    ],
)
def test_get_all_dependencies(
    component_configs: dict[str, tuple[list[str], list[str]]],
    initial_components: set[str],
    expected_components: set[str],
) -> None:
    """Test dependency resolution for components."""
    with patch("esphome.loader.get_component") as mock_get_component:

        def get_component_side_effect(name: str):
            if name in component_configs:
                deps, auto_load = component_configs[name]
                comp = Mock()
                comp.dependencies = deps
                comp.auto_load = auto_load
                return comp
            return None

        mock_get_component.side_effect = get_component_side_effect

        result = helpers.get_all_dependencies(initial_components)

        assert result == expected_components


def test_get_all_dependencies_handles_missing_components() -> None:
    """Test handling of components that can't be loaded."""
    with patch("esphome.loader.get_component") as mock_get_component:
        # First component exists, its dependency doesn't
        comp = Mock()
        comp.dependencies = ["missing_comp"]
        comp.auto_load = []

        mock_get_component.side_effect = lambda name: (
            comp if name == "existing" else None
        )

        result = helpers.get_all_dependencies({"existing", "nonexistent"})

        # Should still include all components, even if some can't be loaded
        assert result == {"existing", "nonexistent", "missing_comp"}


def test_get_all_dependencies_empty_set() -> None:
    """Test with empty initial component set."""
    result = helpers.get_all_dependencies(set())
    assert result == set()


def test_get_all_dependencies_platform_component() -> None:
    """Platform components (domain.component) are looked up via get_platform,
    not get_component."""
    platform_comp = Mock()
    platform_comp.dependencies = []
    platform_comp.auto_load = []

    with (
        patch("esphome.loader.get_component") as mock_get_component,
        patch("esphome.loader.get_platform") as mock_get_platform,
    ):
        mock_get_platform.return_value = platform_comp
        mock_get_component.return_value = None

        result = helpers.get_all_dependencies({"sensor.bthome"})

        mock_get_platform.assert_called_once_with("sensor", "bthome")
        mock_get_component.assert_not_called()
        assert result == {"sensor.bthome"}


def test_get_all_dependencies_platform_component_with_dependencies() -> None:
    """Dependencies of a platform component are resolved transitively."""
    platform_comp = Mock()
    platform_comp.dependencies = ["sensor"]
    platform_comp.auto_load = []

    sensor_comp = Mock()
    sensor_comp.dependencies = []
    sensor_comp.auto_load = []

    with (
        patch("esphome.loader.get_component") as mock_get_component,
        patch("esphome.loader.get_platform") as mock_get_platform,
    ):
        mock_get_platform.return_value = platform_comp
        mock_get_component.side_effect = lambda name: (
            sensor_comp if name == "sensor" else None
        )

        result = helpers.get_all_dependencies({"sensor.bthome"})

        assert result == {"sensor.bthome", "sensor"}


def test_get_components_from_integration_fixtures() -> None:
    """Test extraction of components from fixture YAML files."""
    yaml_content = {
        "sensor": [{"platform": "template", "name": "test"}],
        "binary_sensor": [{"platform": "gpio", "pin": 5}],
        "esphome": {"name": "test"},
        "api": {},
    }
    expected_components = {
        "sensor",
        "binary_sensor",
        "esphome",
        "api",
        "template",
        "gpio",
    }

    mock_yaml_file = Mock(stem="test_fixture")

    with (
        patch("pathlib.Path.glob") as mock_glob,
        patch("esphome.yaml_util.load_yaml", return_value=yaml_content),
    ):
        mock_glob.return_value = [mock_yaml_file]

        components = helpers.get_components_from_integration_fixtures()

        assert components == expected_components


def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None:
    """Test that YAML anchor keys (starting with '.') are excluded."""
    yaml_content = {
        "sensor": [{"platform": "template", "name": "test"}],
        "esphome": {"name": "test"},
        ".sensor_filters": {"filters": [{"timeout": "50ms"}]},
        ".binary_filters": {"filters": [{"settle": "50ms"}]},
    }

    mock_yaml_file = Mock(stem="test_fixture")

    with (
        patch("pathlib.Path.glob") as mock_glob,
        patch("esphome.yaml_util.load_yaml", return_value=yaml_content),
    ):
        mock_glob.return_value = [mock_yaml_file]

        components = helpers.get_components_from_integration_fixtures()

        assert ".sensor_filters" not in components
        assert ".binary_filters" not in components
        assert components == {"sensor", "esphome", "template"}


def test_get_integration_test_files_for_components_real_fixtures() -> None:
    """Test that component changes map to the correct real integration test files.

    This test uses real fixtures to verify the mapping stays correct
    as new tests are added.
    """
    # modbus should include at least the modbus test
    modbus_tests = helpers.get_integration_test_files_for_components({"modbus"})
    assert "tests/integration/test_uart_mock_modbus.py" in modbus_tests

    # ld2410 should include at least the ld2410 test
    ld2410_tests = helpers.get_integration_test_files_for_components({"ld2410"})
    assert "tests/integration/test_uart_mock_ld2410.py" in ld2410_tests

    # syslog should include at least the syslog test
    syslog_tests = helpers.get_integration_test_files_for_components({"syslog"})
    assert "tests/integration/test_syslog.py" in syslog_tests

    # A component not used by any fixture should return nothing
    fake_tests = helpers.get_integration_test_files_for_components(
        {"nonexistent_component_xyz"}
    )
    assert fake_tests == []


@pytest.mark.parametrize(
    "output,expected",
    [
        ("wifi\napi\nsensor\n", ["wifi", "api", "sensor"]),
        ("wifi\n", ["wifi"]),
        ("", []),
        ("  \n  \n", []),
        ("\n\n", []),
        ("  wifi  \n  api  \n", ["wifi", "api"]),
        ("wifi\n\napi\n\nsensor", ["wifi", "api", "sensor"]),
    ],
)
def test_parse_list_components_output(output: str, expected: list[str]) -> None:
    """Test parse_list_components_output function."""
    result = helpers.parse_list_components_output(output)
    assert result == expected


@pytest.mark.parametrize(
    ("file_path", "expected_component"),
    [
        # Component files
        ("esphome/components/wifi/wifi.cpp", "wifi"),
        ("esphome/components/uart/uart.h", "uart"),
        ("esphome/components/api/api_server.cpp", "api"),
        ("esphome/components/sensor/sensor.cpp", "sensor"),
        # Test files
        ("tests/components/uart/test.esp32-idf.yaml", "uart"),
        ("tests/components/wifi/test.esp8266-ard.yaml", "wifi"),
        ("tests/components/sensor/test.esp32-idf.yaml", "sensor"),
        ("tests/components/api/test_api.cpp", "api"),
        ("tests/components/uart/common.h", "uart"),
        # Non-component files
        ("esphome/core/component.cpp", None),
        ("esphome/core/helpers.h", None),
        ("tests/integration/test_api.py", None),
        ("tests/unit_tests/test_helpers.py", None),
        ("README.md", None),
        ("script/helpers.py", None),
        # Edge cases
        ("esphome/components/", None),  # No component name
        ("tests/components/", None),  # No component name
        ("esphome/components", None),  # No trailing slash
        ("tests/components", None),  # No trailing slash
        # Files in component directories that are not components
        ("tests/components/.gitignore", None),  # Hidden file
        ("tests/components/README.md", None),  # Documentation file
        ("esphome/components/__init__.py", None),  # Python init file
        ("tests/components/main.cpp", None),  # File with extension
    ],
)
def test_get_component_from_path(
    file_path: str, expected_component: str | None
) -> None:
    """Test extraction of component names from file paths."""
    result = helpers.get_component_from_path(file_path)
    assert result == expected_component


# Components graph cache tests


@pytest.fixture
def mock_git_output() -> str:
    """Fixture for mock git ls-files output with realistic component files.

    Includes examples of AUTO_LOAD in sensor.py and binary_sensor.py files,
    which is why we need to hash all .py files, not just __init__.py.
    """
    return (
        "100644 abc123... 0 esphome/components/wifi/__init__.py\n"
        "100644 def456... 0 esphome/components/api/__init__.py\n"
        "100644 ghi789... 0 esphome/components/xiaomi_lywsd03mmc/__init__.py\n"
        "100644 jkl012... 0 esphome/components/xiaomi_lywsd03mmc/sensor.py\n"
        "100644 mno345... 0 esphome/components/xiaomi_cgpr1/__init__.py\n"
        "100644 pqr678... 0 esphome/components/xiaomi_cgpr1/binary_sensor.py\n"
    )


@pytest.fixture
def mock_cache_file(tmp_path: Path) -> Path:
    """Fixture for a temporary cache file path."""
    return tmp_path / "components_graph.json"


@pytest.fixture(autouse=True)
def clear_cache_key_cache() -> None:
    """Clear the components graph cache key cache before each test."""
    helpers.get_components_graph_cache_key.cache_clear()


@pytest.fixture
def mock_subprocess_run() -> Generator[Mock, None, None]:
    """Fixture to mock subprocess.run for git commands."""
    with patch("subprocess.run") as mock_run:
        yield mock_run


def test_cache_key_generation(mock_git_output: str, mock_subprocess_run: Mock) -> None:
    """Test that cache key is generated based on git file hashes."""
    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    key = helpers.get_components_graph_cache_key()

    # Should be a 64-character hex string (SHA256)
    assert len(key) == 64
    assert all(c in "0123456789abcdef" for c in key)


def test_cache_key_consistent_for_same_files(
    mock_git_output: str, mock_subprocess_run: Mock
) -> None:
    """Test that same git output produces same cache key."""
    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    key1 = helpers.get_components_graph_cache_key()
    key2 = helpers.get_components_graph_cache_key()

    assert key1 == key2


def test_cache_key_different_for_changed_files(mock_subprocess_run: Mock) -> None:
    """Test that different git output produces different cache key.

    This test demonstrates that changes to any .py file (not just __init__.py)
    will invalidate the cache, which is important because AUTO_LOAD can be
    defined in sensor.py, binary_sensor.py, etc.
    """
    mock_result1 = Mock()
    mock_result1.stdout = (
        "100644 abc123... 0 esphome/components/xiaomi_lywsd03mmc/sensor.py\n"
    )

    mock_result2 = Mock()
    # Same file, different hash - simulates a change to AUTO_LOAD
    mock_result2.stdout = (
        "100644 xyz789... 0 esphome/components/xiaomi_lywsd03mmc/sensor.py\n"
    )

    mock_subprocess_run.return_value = mock_result1
    key1 = helpers.get_components_graph_cache_key()

    helpers.get_components_graph_cache_key.cache_clear()
    mock_subprocess_run.return_value = mock_result2
    key2 = helpers.get_components_graph_cache_key()

    assert key1 != key2


def test_cache_key_uses_git_ls_files(
    mock_git_output: str, mock_subprocess_run: Mock
) -> None:
    """Test that git ls-files command is called correctly."""
    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    helpers.get_components_graph_cache_key()

    # Verify git ls-files was called with correct arguments
    mock_subprocess_run.assert_called_once()
    call_args = mock_subprocess_run.call_args
    assert call_args[0][0] == [
        "git",
        "ls-files",
        "-s",
        "esphome/components/**/*.py",
    ]
    assert call_args[1]["capture_output"] is True
    assert call_args[1]["text"] is True
    assert call_args[1]["check"] is True
    assert call_args[1]["close_fds"] is False


def test_cache_hit_returns_cached_graph(
    tmp_path: Path, mock_git_output: str, mock_subprocess_run: Mock
) -> None:
    """Test that cache hit returns cached data without rebuilding."""
    mock_graph = {"wifi": ["network"], "api": ["socket"]}
    cache_key = "a" * 64
    cache_data = {
        "_version": helpers.COMPONENTS_GRAPH_CACHE_VERSION,
        "_cache_key": cache_key,
        "graph": mock_graph,
    }

    # Write cache file
    cache_file = tmp_path / "components_graph.json"
    cache_file.write_text(json.dumps(cache_data))

    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    with (
        patch("helpers.get_components_graph_cache_key", return_value=cache_key),
        patch("helpers.temp_folder", str(tmp_path)),
    ):
        result = helpers.create_components_graph()
        assert result == mock_graph


def test_cache_miss_no_cache_file(
    tmp_path: Path, mock_git_output: str, mock_subprocess_run: Mock
) -> None:
    """Test that cache miss rebuilds graph when no cache file exists."""
    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    # Create minimal components directory structure
    components_dir = tmp_path / "esphome" / "components"
    components_dir.mkdir(parents=True)

    with (
        patch("helpers.root_path", str(tmp_path)),
        patch("helpers.temp_folder", str(tmp_path / ".temp")),
        patch("helpers.get_components_graph_cache_key", return_value="test_key"),
    ):
        result = helpers.create_components_graph()
        # Should return empty graph for empty components directory
        assert result == {}


def test_cache_miss_version_mismatch(
    tmp_path: Path, mock_git_output: str, mock_subprocess_run: Mock
) -> None:
    """Test that cache miss rebuilds graph when version doesn't match."""
    cache_data = {
        "_version": 999,  # Wrong version
        "_cache_key": "test_key",
        "graph": {"old": ["data"]},
    }

    cache_file = tmp_path / ".temp" / "components_graph.json"
    cache_file.parent.mkdir(parents=True)
    cache_file.write_text(json.dumps(cache_data))

    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    # Create minimal components directory structure
    components_dir = tmp_path / "esphome" / "components"
    components_dir.mkdir(parents=True)

    with (
        patch("helpers.root_path", str(tmp_path)),
        patch("helpers.temp_folder", str(tmp_path / ".temp")),
        patch("helpers.get_components_graph_cache_key", return_value="test_key"),
    ):
        result = helpers.create_components_graph()
        # Should rebuild and return empty graph, not use cached data
        assert result == {}


def test_cache_miss_key_mismatch(
    tmp_path: Path, mock_git_output: str, mock_subprocess_run: Mock
) -> None:
    """Test that cache miss rebuilds graph when cache key doesn't match."""
    cache_data = {
        "_version": helpers.COMPONENTS_GRAPH_CACHE_VERSION,
        "_cache_key": "old_key",
        "graph": {"old": ["data"]},
    }

    cache_file = tmp_path / ".temp" / "components_graph.json"
    cache_file.parent.mkdir(parents=True)
    cache_file.write_text(json.dumps(cache_data))

    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    # Create minimal components directory structure
    components_dir = tmp_path / "esphome" / "components"
    components_dir.mkdir(parents=True)

    with (
        patch("helpers.root_path", str(tmp_path)),
        patch("helpers.temp_folder", str(tmp_path / ".temp")),
        patch("helpers.get_components_graph_cache_key", return_value="new_key"),
    ):
        result = helpers.create_components_graph()
        # Should rebuild and return empty graph, not use cached data with old key
        assert result == {}


def test_cache_miss_corrupted_json(
    tmp_path: Path, mock_git_output: str, mock_subprocess_run: Mock
) -> None:
    """Test that cache miss rebuilds graph when cache file has invalid JSON."""
    cache_file = tmp_path / ".temp" / "components_graph.json"
    cache_file.parent.mkdir(parents=True)
    cache_file.write_text("{invalid json")

    mock_result = Mock()
    mock_result.stdout = mock_git_output
    mock_subprocess_run.return_value = mock_result

    # Create minimal components directory structure
    components_dir = tmp_path / "esphome" / "components"
    components_dir.mkdir(parents=True)

    with (
        patch("helpers.root_path", str(tmp_path)),
        patch("helpers.temp_folder", str(tmp_path / ".temp")),
        patch("helpers.get_components_graph_cache_key", return_value="test_key"),
    ):
        result = helpers.create_components_graph()
        # Should handle corruption gracefully and rebuild
        assert result == {}


# ---------------------------------------------------------------------------
# parse_component_metadata / split_conflicting_groups
# ---------------------------------------------------------------------------


@pytest.fixture
def fake_components(tmp_path: Path) -> Path:
    """Create a fake esphome/components/ tree and return the repo root.

    Component layout (tested against split_conflicting_groups):

        alpha          -- CONFLICTS_WITH=["beta"]
        beta           -- CONFLICTS_WITH=["alpha"]
        beta_variant   -- AUTO_LOAD=["beta"]
        gamma          -- (no metadata)
        one_sided      -- CONFLICTS_WITH=["plain"]  (plain does not reject back)
        plain          -- no CONFLICTS_WITH
        callable_auto  -- AUTO_LOAD is a function (not a list literal) -> ignored
        broken         -- __init__.py has a SyntaxError
    """
    components = tmp_path / "esphome" / "components"
    components.mkdir(parents=True)

    def write(name: str, body: str) -> None:
        (components / name).mkdir()
        (components / name / "__init__.py").write_text(body)

    write("alpha", 'CONFLICTS_WITH = ["beta"]\n')
    write("beta", 'CONFLICTS_WITH = ["alpha"]\n')
    write("beta_variant", 'AUTO_LOAD = ["beta"]\n')
    write("gamma", "")
    write("one_sided", 'CONFLICTS_WITH = ["plain"]\n')
    write("plain", "")
    write("callable_auto", "def AUTO_LOAD():\n    return ['beta']\n")
    write("broken", "this is not valid python !!!")
    helpers.parse_component_metadata.cache_clear()
    return tmp_path


def test_parse_component_metadata_list_literals(
    fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
    monkeypatch.setattr(helpers, "root_path", str(fake_components))
    helpers.parse_component_metadata.cache_clear()

    meta = helpers.parse_component_metadata("alpha")
    assert meta.conflicts_with == frozenset({"beta"})
    assert meta.auto_load == frozenset()

    variant = helpers.parse_component_metadata("beta_variant")
    assert variant.auto_load == frozenset({"beta"})
    assert variant.conflicts_with == frozenset()


def test_parse_component_metadata_missing_empty_and_callable(
    fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
    monkeypatch.setattr(helpers, "root_path", str(fake_components))
    helpers.parse_component_metadata.cache_clear()

    # Unknown component -> empty metadata, not an error.
    unknown = helpers.parse_component_metadata("does_not_exist")
    assert unknown == helpers.ComponentMetadata()

    # Empty __init__.py -> empty metadata.
    assert helpers.parse_component_metadata("gamma") == helpers.ComponentMetadata()

    # Callable AUTO_LOAD cannot be statically evaluated -> empty.
    callable_meta = helpers.parse_component_metadata("callable_auto")
    assert callable_meta.auto_load == frozenset()

    # SyntaxError in __init__.py must not raise.
    assert helpers.parse_component_metadata("broken") == helpers.ComponentMetadata()


def test_split_conflicting_groups_splits_direct_conflict(
    fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
    monkeypatch.setattr(helpers, "root_path", str(fake_components))
    helpers.parse_component_metadata.cache_clear()

    result = helpers.split_conflicting_groups(
        {("esp32", "i2c"): ["alpha", "beta", "gamma"]}
    )
    # alpha and beta must end up in different buckets; gamma has no conflicts.
    buckets = list(result.values())
    assert any("alpha" in b for b in buckets)
    assert any("beta" in b for b in buckets)
    for bucket in buckets:
        assert not ({"alpha", "beta"} <= set(bucket))
    # Gamma sticks with whichever bucket it landed in first (alpha's).
    all_members = {c for b in buckets for c in b}
    assert all_members == {"alpha", "beta", "gamma"}


def test_split_conflicting_groups_propagates_through_auto_load(
    fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
    """A component that AUTO_LOADs a conflicting one must also be split out."""
    monkeypatch.setattr(helpers, "root_path", str(fake_components))
    helpers.parse_component_metadata.cache_clear()

    result = helpers.split_conflicting_groups(
        {("esp32", "i2c"): ["alpha", "beta_variant"]}
    )
    buckets = list(result.values())
    for bucket in buckets:
        assert not ({"alpha", "beta_variant"} <= set(bucket))
    assert sum(len(b) for b in buckets) == 2


def test_split_conflicting_groups_symmetric_one_sided_declaration(
    fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
    """If only one side declares CONFLICTS_WITH, the pair must still be split."""
    monkeypatch.setattr(helpers, "root_path", str(fake_components))
    helpers.parse_component_metadata.cache_clear()

    result = helpers.split_conflicting_groups(
        {("esp32", "i2c"): ["one_sided", "plain"]}
    )
    buckets = list(result.values())
    for bucket in buckets:
        assert not ({"one_sided", "plain"} <= set(bucket))


def test_split_conflicting_groups_preserves_non_conflicting_group(
    fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
    monkeypatch.setattr(helpers, "root_path", str(fake_components))
    helpers.parse_component_metadata.cache_clear()

    original = {("esp32", "i2c"): ["alpha", "gamma", "plain"]}
    result = helpers.split_conflicting_groups(original)
    # All three are mutually compatible -- the group must not be split.
    assert result == original


def test_split_conflicting_groups_preserves_original_signature_for_first_bucket(
    fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
    """When a group is split, the first bucket keeps the original signature key."""
    monkeypatch.setattr(helpers, "root_path", str(fake_components))
    helpers.parse_component_metadata.cache_clear()

    result = helpers.split_conflicting_groups({("esp32", "i2c"): ["alpha", "beta"]})
    keys = set(result.keys())
    assert ("esp32", "i2c") in keys
    # One additional bucket with a disambiguated signature.
    extra = keys - {("esp32", "i2c")}
    assert len(extra) == 1
    platform, signature = next(iter(extra))
    assert platform == "esp32"
    assert signature.startswith("i2c__conflict")


# ---------------------------------------------------------------------------
# get_component_test_files / is_validate_only_file
# ---------------------------------------------------------------------------


@pytest.fixture
def fake_component_tests(tmp_path: Path) -> Path:
    """Create a fake tests/components/ tree and return the repo root.

    Layout for component "demo":
        test.esp32-idf.yaml
        test.esp8266-ard.yaml
        test-variant.esp32-idf.yaml
        validate.esp32-idf.yaml
        validate-legacy.esp32-idf.yaml

    Layout for component "validate_only":
        validate.esp32-idf.yaml      (only validate files)

    Layout for component "no_tests":
        common.yaml                  (no test/validate files at all)
    """
    tests_dir = tmp_path / "tests" / "components"

    demo = tests_dir / "demo"
    demo.mkdir(parents=True)
    (demo / "test.esp32-idf.yaml").write_text("")
    (demo / "test.esp8266-ard.yaml").write_text("")
    (demo / "test-variant.esp32-idf.yaml").write_text("")
    (demo / "validate.esp32-idf.yaml").write_text("")
    (demo / "validate-legacy.esp32-idf.yaml").write_text("")

    validate_only = tests_dir / "validate_only"
    validate_only.mkdir(parents=True)
    (validate_only / "validate.esp32-idf.yaml").write_text("")

    no_tests = tests_dir / "no_tests"
    no_tests.mkdir(parents=True)
    (no_tests / "common.yaml").write_text("")

    return tmp_path


def _names(paths: list[Path]) -> set[str]:
    return {p.name for p in paths}


def test_get_component_test_files_default_excludes_validate(
    fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
    """Default behaviour: only base test.*.yaml; no variants, no validate."""
    monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))

    files = helpers.get_component_test_files("demo")

    assert _names(files) == {"test.esp32-idf.yaml", "test.esp8266-ard.yaml"}


def test_get_component_test_files_all_variants_excludes_validate(
    fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
    """all_variants=True picks up test variants but still skips validate."""
    monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))

    files = helpers.get_component_test_files("demo", all_variants=True)

    assert _names(files) == {
        "test.esp32-idf.yaml",
        "test.esp8266-ard.yaml",
        "test-variant.esp32-idf.yaml",
    }


def test_get_component_test_files_include_validate_base_only(
    fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
    """include_validate=True with base-only adds validate.*.yaml only."""
    monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))

    files = helpers.get_component_test_files("demo", include_validate=True)

    assert _names(files) == {
        "test.esp32-idf.yaml",
        "test.esp8266-ard.yaml",
        "validate.esp32-idf.yaml",
    }


def test_get_component_test_files_include_validate_all_variants(
    fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
    """include_validate=True with all_variants adds validate variants too."""
    monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))

    files = helpers.get_component_test_files(
        "demo", all_variants=True, include_validate=True
    )

    assert _names(files) == {
        "test.esp32-idf.yaml",
        "test.esp8266-ard.yaml",
        "test-variant.esp32-idf.yaml",
        "validate.esp32-idf.yaml",
        "validate-legacy.esp32-idf.yaml",
    }


def test_get_component_test_files_validate_only_component(
    fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
    """A component with only validate files is invisible without the flag."""
    monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))

    assert helpers.get_component_test_files("validate_only") == []
    assert helpers.get_component_test_files("validate_only", all_variants=True) == []

    files = helpers.get_component_test_files(
        "validate_only", all_variants=True, include_validate=True
    )
    assert _names(files) == {"validate.esp32-idf.yaml"}


def test_get_component_test_files_missing_component(
    fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
    """Unknown components return an empty list, regardless of flags."""
    monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))

    assert (
        helpers.get_component_test_files(
            "does_not_exist", all_variants=True, include_validate=True
        )
        == []
    )


def test_get_component_test_files_component_without_tests(
    fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
    """A component with only common.yaml and no test/validate files returns []."""
    monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))

    assert (
        helpers.get_component_test_files(
            "no_tests", all_variants=True, include_validate=True
        )
        == []
    )


@pytest.mark.parametrize(
    ("filename", "expected"),
    [
        ("validate.esp32-idf.yaml", True),
        ("validate-legacy.esp32-idf.yaml", True),
        ("validate.host.yaml", True),
        ("test.esp32-idf.yaml", False),
        ("test-variant.esp32-idf.yaml", False),
        ("common.yaml", False),
        # Defensive: a hypothetical name starting with "validate" but not
        # following the grammar must not be classified as a validate file.
        ("validatesomething.yaml", False),
    ],
)
def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None:
    assert helpers.is_validate_only_file(tmp_path / filename) is expected
