"""Tests for esphome.components.nrf52.framework helpers."""

import hashlib
import os
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import patch

import pytest

from esphome.components.nrf52.framework import (
    _PLATFORMIO_PENV_REQUIREMENTS,
    _REQUIREMENTS,
    TOOLCHAIN_VERSION,
    _get_penv_site_packages,
    _get_platformio_penv_path,
    _get_toolchain_platform_info,
    check_and_install,
    get_build_env,
    get_sdk_nrf_tools_path,
    setup_platformio_python_env,
)
from esphome.config_validation import Version
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_python_env_executable_path


@pytest.fixture(autouse=True)
def _isolate_sdk_nrf_install_path(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Pin the sdk-nrf install root to a tmp dir for every test.

    The default location is the OS user cache dir, so without this any test
    that builds framework paths or pre-creates the install dir would touch
    the real ``~/.cache/esphome`` on the developer's machine. Tests that need
    to exercise the override or default-resolution logic clear/override the
    env themselves.
    """
    monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(tmp_path / "sdk_nrf_install"))


@pytest.mark.parametrize(
    ("system", "machine", "expected"),
    [
        # default — no branch hit
        ("Linux", "x86_64", ("linux", "x86_64", "tar.xz")),
        # arm64 → aarch64 rename
        ("Linux", "arm64", ("linux", "aarch64", "tar.xz")),
        # darwin → macos rename only
        ("Darwin", "x86_64", ("macos", "x86_64", "tar.xz")),
        # both renames apply
        ("Darwin", "arm64", ("macos", "aarch64", "tar.xz")),
        # windows forces x86_64 + 7z; arm64 rename is overwritten
        ("Windows", "arm64", ("windows", "x86_64", "7z")),
    ],
)
def test_get_toolchain_platform_info(
    system: str, machine: str, expected: tuple[str, str, str]
) -> None:
    with (
        patch("platform.system", return_value=system),
        patch("platform.machine", return_value=machine),
    ):
        assert _get_toolchain_platform_info() == expected


# ---------------------------------------------------------------------------
# Helpers and fixtures for check_and_install tests
# ---------------------------------------------------------------------------

_TEST_SDK_VERSION = "2.9.0"


@pytest.fixture
def nrf52_dirs(setup_core: Path) -> SimpleNamespace:
    """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds."""
    CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)}
    tools = get_sdk_nrf_tools_path()
    python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}"
    framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}"
    toolchain_dir = tools / "toolchains" / TOOLCHAIN_VERSION
    for d in (python_env, framework, toolchain_dir):
        d.mkdir(parents=True, exist_ok=True)
    zephyr_scripts = framework / "zephyr" / "scripts"
    zephyr_scripts.mkdir(parents=True, exist_ok=True)
    (zephyr_scripts / "requirements.txt").touch()
    return SimpleNamespace(
        python_env=python_env,
        framework=framework,
        toolchain=toolchain_dir,
    )


@pytest.fixture
def mock_nrf52_ops():
    """Patch all heavy I/O operations used by check_and_install."""
    with (
        patch("esphome.components.nrf52.framework.rmdir") as mock_rmdir,
        patch("esphome.components.nrf52.framework.create_venv") as mock_create_venv,
        patch(
            "esphome.components.nrf52.framework.run_command_ok", return_value=True
        ) as mock_run_cmd,
        patch(
            "esphome.components.nrf52.framework.download_from_mirrors",
            return_value="https://example.com/tc.tar.xz",
        ) as mock_download,
        patch("esphome.components.nrf52.framework.archive_extract_all") as mock_extract,
    ):
        yield SimpleNamespace(
            rmdir=mock_rmdir,
            create_venv=mock_create_venv,
            run_command_ok=mock_run_cmd,
            download_from_mirrors=mock_download,
            archive_extract_all=mock_extract,
        )


# ---------------------------------------------------------------------------
# check_and_install tests
# ---------------------------------------------------------------------------


def _mark_venv_ready(python_env: Path) -> None:
    """Write the venv sentinel with the current requirements hash."""
    requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
    (python_env / ".ready").write_text(requirements_hash, encoding="utf-8")


class TestCheckAndInstall:
    def test_all_installed_skips_all_steps(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """All three sentinels present → nothing downloaded or compiled."""
        _mark_venv_ready(nrf52_dirs.python_env)
        (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
        (nrf52_dirs.framework / ".ready").touch()
        (nrf52_dirs.toolchain / ".ready").touch()

        check_and_install()

        mock_nrf52_ops.create_venv.assert_not_called()
        mock_nrf52_ops.run_command_ok.assert_not_called()
        mock_nrf52_ops.download_from_mirrors.assert_not_called()
        mock_nrf52_ops.archive_extract_all.assert_not_called()

    def test_fresh_install_runs_all_steps(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """No sentinels → venv created, west installed, SDK init+update, toolchain downloaded."""
        check_and_install()

        mock_nrf52_ops.create_venv.assert_called_once()
        # pip install requirements, west init, west update, pip install zephyr reqs
        assert mock_nrf52_ops.run_command_ok.call_count == 4
        # minimal SDK + per-arch toolchain
        assert mock_nrf52_ops.download_from_mirrors.call_count == 2
        assert mock_nrf52_ops.archive_extract_all.call_count == 2
        assert (nrf52_dirs.python_env / ".ready").exists()
        assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists()
        assert (nrf52_dirs.framework / ".ready").exists()
        assert (nrf52_dirs.toolchain / ".ready").exists()

    def test_venv_exists_installs_framework_and_toolchain(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Venv ready but framework missing → skip venv creation, run SDK init+update."""
        _mark_venv_ready(nrf52_dirs.python_env)

        check_and_install()

        mock_nrf52_ops.create_venv.assert_not_called()
        # west init, west update, pip install zephyr reqs
        assert mock_nrf52_ops.run_command_ok.call_count == 3
        # minimal SDK + per-arch toolchain
        assert mock_nrf52_ops.download_from_mirrors.call_count == 2

    def test_toolchain_only_missing(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Venv and framework ready → only toolchain downloaded and extracted."""
        _mark_venv_ready(nrf52_dirs.python_env)
        (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
        (nrf52_dirs.framework / ".ready").touch()

        check_and_install()

        mock_nrf52_ops.create_venv.assert_not_called()
        mock_nrf52_ops.run_command_ok.assert_not_called()
        # minimal SDK + per-arch toolchain
        assert mock_nrf52_ops.download_from_mirrors.call_count == 2
        assert mock_nrf52_ops.archive_extract_all.call_count == 2

    def test_framework_clone_is_shallow(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Both the manifest repository and every project are fetched at depth 1."""
        _mark_venv_ready(nrf52_dirs.python_env)

        check_and_install()

        init_cmd, update_cmd = (
            call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2]
        )
        assert "init" in init_cmd
        assert "-o=--depth=1" in init_cmd
        assert "update" in update_cmd
        assert "--fetch-opt=--depth=1" in update_cmd

    def test_requirements_install_failure_raises(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Failing pip install -r requirements.txt raises EsphomeError."""
        mock_nrf52_ops.run_command_ok.return_value = False

        with pytest.raises(EsphomeError, match="Install requirements"):
            check_and_install()

    def test_framework_init_failure_raises(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Failing west init raises EsphomeError."""
        _mark_venv_ready(nrf52_dirs.python_env)
        mock_nrf52_ops.run_command_ok.return_value = False

        with pytest.raises(EsphomeError, match="Can't initialize"):
            check_and_install()

    def test_framework_update_failure_raises(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Failing west update raises EsphomeError."""
        _mark_venv_ready(nrf52_dirs.python_env)
        # init succeeds, update fails
        mock_nrf52_ops.run_command_ok.side_effect = [True, False]

        with pytest.raises(EsphomeError, match="Can't update"):
            check_and_install()

    def test_toolchain_download_passes_platform_substitutions(
        self,
        nrf52_dirs: SimpleNamespace,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info."""
        _mark_venv_ready(nrf52_dirs.python_env)
        (nrf52_dirs.framework / ".ready").touch()

        with patch(
            "esphome.components.nrf52.framework._get_toolchain_platform_info",
            return_value=("linux", "x86_64", "tar.xz"),
        ):
            check_and_install()

        args, _ = mock_nrf52_ops.download_from_mirrors.call_args
        substitutions = args[1]
        assert substitutions["VERSION"] == TOOLCHAIN_VERSION
        assert substitutions["sysname"] == "linux"
        assert substitutions["machine"] == "x86_64"
        assert substitutions["extension"] == "tar.xz"


# ---------------------------------------------------------------------------
# setup_platformio_python_env tests
# ---------------------------------------------------------------------------


def _platformio_requirements_hash() -> str:
    return hashlib.sha256(
        _REQUIREMENTS.read_bytes()
        + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
        + f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
    ).hexdigest()


@pytest.fixture
def platformio_penv_dir() -> Path:
    """Pre-create the PlatformIO penv dir so sentinel writes succeed.

    create_venv is mocked in these tests, so the directory it would have
    created must exist for ``sentinel.write_text`` to work.
    """
    penv_path = _get_platformio_penv_path()
    penv_path.mkdir(parents=True, exist_ok=True)
    return penv_path


class TestSetupPlatformioPythonEnv:
    def test_fresh_install_creates_venv_and_sets_env(
        self,
        platformio_penv_dir: Path,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """No sentinel → venv created, requirements installed, env exported."""
        with patch.dict(os.environ):
            os.environ.pop("PYTHONPATH", None)

            setup_platformio_python_env()

            mock_nrf52_ops.rmdir.assert_called_once()
            mock_nrf52_ops.create_venv.assert_called_once_with(
                platformio_penv_dir, msg="PlatformIO toolchain"
            )
            mock_nrf52_ops.run_command_ok.assert_called_once()
            cmd = mock_nrf52_ops.run_command_ok.call_args[0][0]
            assert cmd[1:4] == ["-m", "pip", "install"]
            assert "-r" in cmd
            assert str(_REQUIREMENTS) in cmd
            for requirement in _PLATFORMIO_PENV_REQUIREMENTS:
                assert requirement in cmd
            sentinel = platformio_penv_dir / ".ready"
            assert sentinel.read_text(encoding="utf-8") == (
                _platformio_requirements_hash()
            )

            assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)
            site_packages = str(_get_penv_site_packages(platformio_penv_dir))
            assert os.environ["PYTHONPATH"] == site_packages
            bin_dir = str(
                get_python_env_executable_path(platformio_penv_dir, "python").parent
            )
            assert os.environ["PATH"].split(os.pathsep)[0] == bin_dir

    def test_ready_sentinel_skips_install_but_sets_env(
        self,
        platformio_penv_dir: Path,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Current sentinel → no install work, env vars still exported."""
        (platformio_penv_dir / ".ready").write_text(
            _platformio_requirements_hash(), encoding="utf-8"
        )

        with patch.dict(os.environ):
            setup_platformio_python_env()

            mock_nrf52_ops.rmdir.assert_not_called()
            mock_nrf52_ops.create_venv.assert_not_called()
            mock_nrf52_ops.run_command_ok.assert_not_called()
            assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)

    def test_stale_sentinel_reinstalls(
        self,
        platformio_penv_dir: Path,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """A sentinel from different requirements → venv rebuilt from scratch."""
        sentinel = platformio_penv_dir / ".ready"
        sentinel.write_text("stale-hash", encoding="utf-8")

        with patch.dict(os.environ):
            setup_platformio_python_env()

        mock_nrf52_ops.rmdir.assert_called_once()
        mock_nrf52_ops.create_venv.assert_called_once()
        mock_nrf52_ops.run_command_ok.assert_called_once()
        assert sentinel.read_text(encoding="utf-8") == _platformio_requirements_hash()

    def test_install_failure_raises(
        self,
        platformio_penv_dir: Path,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Failing pip install raises EsphomeError and writes no sentinel."""
        mock_nrf52_ops.run_command_ok.return_value = False

        with (
            patch.dict(os.environ),
            pytest.raises(
                EsphomeError, match="Install requirements for PlatformIO toolchain"
            ),
        ):
            setup_platformio_python_env()

        assert not (platformio_penv_dir / ".ready").exists()

    def test_repeated_calls_do_not_duplicate_env_entries(
        self,
        platformio_penv_dir: Path,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """Compile then upload in one process must not grow PYTHONPATH/PATH."""
        (platformio_penv_dir / ".ready").write_text(
            _platformio_requirements_hash(), encoding="utf-8"
        )
        site_packages = str(_get_penv_site_packages(platformio_penv_dir))
        bin_dir = str(
            get_python_env_executable_path(platformio_penv_dir, "python").parent
        )

        with patch.dict(os.environ):
            setup_platformio_python_env()
            setup_platformio_python_env()

            assert os.environ["PYTHONPATH"].split(os.pathsep).count(site_packages) == 1
            assert os.environ["PATH"].split(os.pathsep).count(bin_dir) == 1

    def test_existing_pythonpath_preserved(
        self,
        platformio_penv_dir: Path,
        mock_nrf52_ops: SimpleNamespace,
    ) -> None:
        """A pre-existing PYTHONPATH keeps its entries after the venv entry."""
        (platformio_penv_dir / ".ready").write_text(
            _platformio_requirements_hash(), encoding="utf-8"
        )
        site_packages = str(_get_penv_site_packages(platformio_penv_dir))

        with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}):
            setup_platformio_python_env()

            assert os.environ["PYTHONPATH"] == os.pathsep.join(
                [site_packages, "/existing/path"]
            )


@pytest.mark.parametrize(
    ("os_name", "expected_parts"),
    [
        (
            "posix",
            (
                "lib",
                f"python{sys.version_info.major}.{sys.version_info.minor}",
                "site-packages",
            ),
        ),
        ("nt", ("Lib", "site-packages")),
    ],
)
def test_get_penv_site_packages(
    tmp_path: Path, os_name: str, expected_parts: tuple[str, ...]
) -> None:
    penv_path = tmp_path / "penv"
    with patch("os.name", os_name):
        assert _get_penv_site_packages(penv_path) == penv_path.joinpath(*expected_parts)


# ---------------------------------------------------------------------------
# get_build_env tests
# ---------------------------------------------------------------------------


def test_get_build_env(
    nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
    """get_build_env exposes ZEPHYR_SDK_INSTALL_DIR pointing at the toolchain root.

    ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr's FindZephyr-sdk.cmake
    explicitly consumes (from the environment) and uses as a find_package
    HINT. The old Zephyr-sdk_DIR environment hint proved unreliable in
    containerized non-root builds and was removed.
    """
    monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept")

    env = get_build_env()

    tools = get_sdk_nrf_tools_path()
    venv_bin_dir = get_python_env_executable_path(
        tools / "penvs" / f"v{_TEST_SDK_VERSION}", "python"
    ).parent
    assert env["PATH"].startswith(str(venv_bin_dir) + os.pathsep)
    assert env["ZEPHYR_BASE"] == str(
        tools / "frameworks" / f"v{_TEST_SDK_VERSION}" / "zephyr"
    )
    # Toolchain root, not the cmake/ subdir
    assert env["ZEPHYR_SDK_INSTALL_DIR"] == str(
        tools / "toolchains" / TOOLCHAIN_VERSION
    )
    assert "Zephyr-sdk_DIR" not in env
    # The rest of the process environment is inherited
    assert env["SOME_PREEXISTING_VAR"] == "kept"


# ---------------------------------------------------------------------------
# get_sdk_nrf_tools_path tests
# ---------------------------------------------------------------------------


def testget_tools_path_env_override(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    override = tmp_path / "custom" / "sdk-nrf"
    monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(override))
    assert get_sdk_nrf_tools_path() == override.resolve()


@pytest.mark.parametrize("value", ["", "   "])
def testget_tools_path_blank_env_falls_back_to_default(
    value: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A blank ESPHOME_SDK_NRF_PREFIX is treated as unset, not as CWD.

    Path("") would resolve to the working directory, which clean-all could
    then delete by accident.
    """
    import platformdirs

    monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value)
    expected = (
        Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
    ).resolve()
    assert get_sdk_nrf_tools_path() == expected


def testget_tools_path_default_is_global_cache(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    import platformdirs

    monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False)
    expected = (
        Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
    ).resolve()
    assert get_sdk_nrf_tools_path() == expected
