"""Test Home Assistant add-on startup and logging."""

import functools
import importlib.util
import json
import os
import subprocess
import time
from pathlib import Path

import pytest
from testcontainers.core.container import DockerContainer
from testcontainers.core.wait_strategies import LogMessageWaitStrategy


@functools.cache
def _load_addon_start():
    """Import homeassistant-addon/start.py as a module."""
    spec = importlib.util.spec_from_file_location(
        "addon_start",
        Path(__file__).parents[2] / "homeassistant-addon" / "start.py",
    )
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


class TestSecretPathValidation:
    """Unit tests for secret path validation logic."""

    @pytest.fixture(autouse=True)
    def addon(self):
        self.addon = _load_addon_start()

    def test_valid_path_accepted(self, tmp_path):
        secret_file = tmp_path / "secret_path.txt"
        secret_file.write_text("/private_abc123")
        result = self.addon.get_or_create_secret_path(tmp_path)
        assert result == "/private_abc123"

    def test_url_in_file_triggers_regeneration(self, tmp_path):
        secret_file = tmp_path / "secret_path.txt"
        secret_file.write_text("https://192.168.1.18:9583/private_abc123")
        result = self.addon.get_or_create_secret_path(tmp_path)
        assert result.startswith("/private_")
        assert "://" not in result
        assert secret_file.read_text() == result

    def test_empty_file_triggers_regeneration(self, tmp_path):
        secret_file = tmp_path / "secret_path.txt"
        secret_file.write_text("")
        result = self.addon.get_or_create_secret_path(tmp_path)
        assert result.startswith("/private_")

    def test_url_custom_path_triggers_regeneration(self, tmp_path):
        result = self.addon.get_or_create_secret_path(
            tmp_path, custom_path="http://attacker.example.com/x"
        )
        assert result.startswith("/private_")
        assert "://" not in result

    def test_valid_custom_path_used(self, tmp_path):
        result = self.addon.get_or_create_secret_path(
            tmp_path, custom_path="my_custom_secret"
        )
        assert result == "/my_custom_secret"

    def test_no_secret_file_generates_new_path(self, tmp_path):
        result = self.addon.get_or_create_secret_path(tmp_path)
        assert result.startswith("/private_")
        assert (tmp_path / "secret_path.txt").read_text() == result

    def test_whitespace_custom_path_falls_through_to_stored(self, tmp_path):
        (tmp_path / "secret_path.txt").write_text("/private_stored")
        result = self.addon.get_or_create_secret_path(tmp_path, custom_path="   ")
        assert result == "/private_stored"

    def test_is_valid_secret_path(self):
        assert self.addon._is_valid_secret_path("/private_abc") is True
        assert self.addon._is_valid_secret_path("/mysecrt") is True  # exactly 8 chars
        assert (
            self.addon._is_valid_secret_path("/custom") is False
        )  # 7 chars — too short
        assert self.addon._is_valid_secret_path("/short") is False  # too short
        assert self.addon._is_valid_secret_path("https://example.com/x") is False
        assert self.addon._is_valid_secret_path("/https://evil.com") is False
        assert self.addon._is_valid_secret_path("no-leading-slash") is False
        assert self.addon._is_valid_secret_path("") is False


class TestPersistAddonOptions:
    """Unit tests for persisting addon options to Supervisor (#941)."""

    @pytest.fixture(autouse=True)
    def addon(self):
        self.addon = _load_addon_start()

    def test_sends_full_options_dict_as_post(self, monkeypatch):
        """Sends POST /addons/self/options with body {"options": <full dict>}."""
        captured: dict = {}

        class FakeResp:
            def __enter__(self):
                return self

            def __exit__(self, *a):
                return False

            def read(self):
                return b""

        def fake_urlopen(req, timeout=None):
            captured["url"] = req.full_url
            captured["method"] = req.get_method()
            captured["headers"] = dict(req.header_items())
            captured["body"] = req.data
            return FakeResp()

        monkeypatch.setattr(self.addon.urllib.request, "urlopen", fake_urlopen)

        options = {
            "backup_hint": "normal",
            "secret_path": "/private_abc12345",
        }
        # Returns None on success — helper communicates failure via exceptions.
        assert self.addon.persist_addon_options(options, "test-token") is None
        assert captured["url"] == "http://supervisor/addons/self/options"
        assert captured["method"] == "POST"
        assert captured["headers"]["Authorization"] == "Bearer test-token"
        assert captured["headers"]["Content-type"] == "application/json"
        assert json.loads(captured["body"]) == {"options": options}

    def test_http_error_propagates(self, monkeypatch):
        """Validation failures from Supervisor propagate to the caller.

        Silent suppression here would hide the exact failure this PR exists
        to fix: the webhook proxy unable to discover the secret path. Main()
        must see the exception so it can log an actionable recovery message.
        """
        import io
        import urllib.error

        def fake_urlopen(req, timeout=None):
            raise urllib.error.HTTPError(
                req.full_url,
                400,
                "Bad Request",
                hdrs=None,  # type: ignore[arg-type]
                fp=io.BytesIO(b'{"result":"error","message":"invalid options"}'),
            )

        monkeypatch.setattr(self.addon.urllib.request, "urlopen", fake_urlopen)
        with pytest.raises(urllib.error.HTTPError):
            self.addon.persist_addon_options(
                {"secret_path": "/private_x"}, "test-token"
            )

    def test_connection_error_propagates(self, monkeypatch):
        """Network failures propagate to the caller — no silent swallowing."""
        import urllib.error

        def fake_urlopen(req, timeout=None):
            raise urllib.error.URLError("connection refused")

        monkeypatch.setattr(self.addon.urllib.request, "urlopen", fake_urlopen)
        with pytest.raises(urllib.error.URLError):
            self.addon.persist_addon_options(
                {"secret_path": "/private_x"}, "test-token"
            )


class TestMaybePersistSecretPath:
    """Tests for the gate-and-recover wrapper called from main() (#941)."""

    @pytest.fixture(autouse=True)
    def addon(self):
        self.addon = _load_addon_start()

    def test_skips_when_config_is_empty(self, monkeypatch):
        """If /data/options.json was missing/corrupt, don't try to persist.

        Sending a bare `{"secret_path": ...}` without required fields would
        trip schema validation on the Supervisor side and produce a second,
        misleading error line on top of the "Failed to read config" we
        already logged upstream.
        """
        calls: list = []
        monkeypatch.setattr(
            self.addon,
            "persist_addon_options",
            lambda *args, **kwargs: calls.append(args),
        )
        self.addon.maybe_persist_secret_path({}, "/private_new", "test-token")
        assert calls == []

    def test_skips_when_stored_path_matches(self, monkeypatch):
        """No-op when Supervisor already has the right value — avoids noise on every restart."""
        calls: list = []
        monkeypatch.setattr(
            self.addon,
            "persist_addon_options",
            lambda *args, **kwargs: calls.append(args),
        )
        config = {"backup_hint": "normal", "secret_path": "/private_same"}
        self.addon.maybe_persist_secret_path(config, "/private_same", "test-token")
        assert calls == []

    def test_persists_with_full_config_merged(self, monkeypatch):
        """When the path changes, POSTs `{**config, "secret_path": new}`."""
        captured: list = []

        def fake_persist(options, token):
            captured.append((options, token))

        monkeypatch.setattr(self.addon, "persist_addon_options", fake_persist)
        config = {
            "backup_hint": "normal",
            "secret_path": "/private_old",
        }
        self.addon.maybe_persist_secret_path(config, "/private_new", "test-token")
        assert captured == [
            (
                {
                    "backup_hint": "normal",
                    "secret_path": "/private_new",
                },
                "test-token",
            )
        ]

    def test_catches_http_error_and_logs_actionable_message(self, monkeypatch, capfd):
        """HTTPError from Supervisor must not escape — the addon must keep starting — but the log line must name the path so the user can recover manually."""
        import urllib.error

        def raising_persist(options, token):
            raise urllib.error.HTTPError(
                "http://supervisor/addons/self/options",
                400,
                "Bad Request",
                hdrs=None,  # type: ignore[arg-type]
                fp=None,
            )

        monkeypatch.setattr(self.addon, "persist_addon_options", raising_persist)
        # Should not raise.
        self.addon.maybe_persist_secret_path(
            {"backup_hint": "normal", "secret_path": "/private_old"},
            "/private_new",
            "test-token",
        )
        err = capfd.readouterr().err
        assert "Failed to persist secret_path" in err
        assert "HTTP 400" in err
        assert "/private_new" in err  # user-facing recovery value
        assert "Secret path override" in err  # points at the config field

    def test_catches_network_error_and_logs(self, monkeypatch, capfd):
        """URLError / timeout / OSError all caught the same way."""
        import urllib.error

        def raising_persist(options, token):
            raise urllib.error.URLError("supervisor unreachable")

        monkeypatch.setattr(self.addon, "persist_addon_options", raising_persist)
        self.addon.maybe_persist_secret_path(
            {"backup_hint": "normal", "secret_path": "/private_old"},
            "/private_new",
            "test-token",
        )
        err = capfd.readouterr().err
        assert "Failed to persist secret_path" in err
        assert "supervisor unreachable" in err
        assert "/private_new" in err


IMAGE_TAG = "ha-mcp-addon-test"
DOCKERFILE = "homeassistant-addon/Dockerfile"


def _build_addon_image():
    """Build the addon test image via docker CLI (supports BuildKit)."""
    result = subprocess.run(
        [
            "docker",
            "build",
            "-t",
            IMAGE_TAG,
            "-f",
            DOCKERFILE,
            "--build-arg",
            "BUILD_VERSION=1.0.0-test",
            "--build-arg",
            "BUILD_ARCH=amd64",
            ".",
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        pytest.fail(f"Failed to build {IMAGE_TAG}:\n{result.stderr}")


class TestResolveBoolOption:
    """Unit tests for the resolve_bool_option helper used for verify_ssl."""

    @pytest.fixture(autouse=True)
    def addon(self):
        self.addon = _load_addon_start()

    def test_missing_key_returns_default(self):
        assert self.addon.resolve_bool_option({}, "verify_ssl", True) is True
        assert self.addon.resolve_bool_option({}, "verify_ssl", False) is False

    def test_explicit_false_returns_false(self):
        assert (
            self.addon.resolve_bool_option({"verify_ssl": False}, "verify_ssl", True)
            is False
        )

    def test_explicit_true_returns_true(self):
        assert (
            self.addon.resolve_bool_option({"verify_ssl": True}, "verify_ssl", False)
            is True
        )

    def test_string_value_falls_back_to_default(self):
        # HA Supervisor coerces YAML scalars to the schema type, so a string
        # here means user-edited options.json with a bad type. The secure
        # default must win — never accept "false" as a string.
        assert (
            self.addon.resolve_bool_option({"verify_ssl": "false"}, "verify_ssl", True)
            is True
        )

    def test_int_value_falls_back_to_default(self):
        assert (
            self.addon.resolve_bool_option({"verify_ssl": 0}, "verify_ssl", True)
            is True
        )

    def test_none_value_falls_back_to_default(self):
        assert (
            self.addon.resolve_bool_option({"verify_ssl": None}, "verify_ssl", True)
            is True
        )


class TestResolveEffectiveLogLevel:
    """Unit tests for resolve_effective_log_level (#1721).

    The addon hardcoded ``logging.basicConfig(level=logging.INFO)``, so the
    web Settings UI's "Log level" advanced setting could never reach the
    addon container log — reporters asked to "enable debug logging" saw
    INFO-only output. The helper reads the effective settings (including
    the feature_flags.json override) and falls back to INFO on any failure
    so logging config can never block addon startup.
    """

    @pytest.fixture(autouse=True)
    def addon(self):
        self.addon = _load_addon_start()

    def _patch_level(self, monkeypatch, level_name):
        class _FakeSettings:
            log_level = level_name

        monkeypatch.setattr("ha_mcp.config.get_global_settings", _FakeSettings)

    def test_debug_setting_yields_debug_level(self, monkeypatch):
        import logging

        self._patch_level(monkeypatch, "DEBUG")
        assert self.addon.resolve_effective_log_level() == logging.DEBUG

    def test_default_info(self, monkeypatch):
        import logging

        self._patch_level(monkeypatch, "INFO")
        assert self.addon.resolve_effective_log_level() == logging.INFO

    def test_unknown_level_falls_back_to_info(self, monkeypatch):
        import logging

        self._patch_level(monkeypatch, "VERBOSE")
        assert self.addon.resolve_effective_log_level() == logging.INFO

    def test_settings_failure_falls_back_to_info(self, monkeypatch, capsys):
        import logging

        def _boom():
            raise RuntimeError("settings unavailable")

        monkeypatch.setattr("ha_mcp.config.get_global_settings", _boom)
        assert self.addon.resolve_effective_log_level() == logging.INFO
        # The fallback must be LOUD — a silent INFO fallback recreates
        # the "user set DEBUG, nothing happened, no clue why" bug class.
        captured = capsys.readouterr()
        assert "web-UI Log level not applied" in captured.err
        assert "settings unavailable" in captured.err

    def test_advanced_debug_logging_toggle_removed(self):
        """The confusing addon-config toggle is gone everywhere.

        Kill-signal diagnostics now arm when the effective log level is
        DEBUG; no separate toggle. Guards against reintroduction in any
        addon flavor (functional config is hand-mirrored, not synced).
        """
        repo_root = Path(__file__).parents[2]
        for rel in (
            "homeassistant-addon/start.py",
            "homeassistant-addon/config.yaml",
            "homeassistant-addon/translations/en.yaml",
            "homeassistant-addon-dev/config.yaml",
            "homeassistant-addon-dev/translations/en.yaml",
            "tests/haos_image_build/build_image.py",
        ):
            content = (repo_root / rel).read_text(encoding="utf-8")
            assert "advanced_debug_logging" not in content, (
                f"{rel} still references the removed advanced_debug_logging toggle"
            )

    def test_start_py_arms_kill_diagnostics_on_debug(self):
        """start.py still installs kill-signal diagnostics — gated on the
        effective DEBUG level instead of the removed toggle."""
        repo_root = Path(__file__).parents[2]
        content = (repo_root / "homeassistant-addon/start.py").read_text(
            encoding="utf-8"
        )
        assert "schedule_install_after_uvicorn" in content
        assert "resolve_effective_log_level" in content


class TestCleanupStaleMigrationMarker:
    """Unit tests for cleanup_stale_migration_marker.

    Best-effort cleanup of the one-time enable_skills_as_tools migration
    marker (removed in #1133). Runs on every boot, so silent failure on
    permission-restricted /data mounts is the production-only failure
    mode worth locking down.
    """

    @pytest.fixture(autouse=True)
    def addon(self):
        self.addon = _load_addon_start()

    def test_removes_marker_when_present(self, tmp_path):
        marker = tmp_path / ".skills_as_tools_default_migration_v1"
        marker.write_text("")
        assert marker.exists()

        self.addon.cleanup_stale_migration_marker(tmp_path)

        assert not marker.exists()

    def test_noop_when_marker_absent(self, tmp_path):
        # No marker present — must not raise (missing_ok=True).
        self.addon.cleanup_stale_migration_marker(tmp_path)

    def test_swallows_oserror_and_logs(self, tmp_path, monkeypatch, capsys):
        """Permission-restricted /data mounts must not crash boot.

        Simulates the real-world failure mode where unlink raises despite
        ``missing_ok=True`` (e.g. EACCES on a read-only bind mount).
        """
        marker = tmp_path / ".skills_as_tools_default_migration_v1"
        marker.write_text("")

        def boom(self, missing_ok=False):
            raise PermissionError("read-only filesystem")

        monkeypatch.setattr(Path, "unlink", boom)

        self.addon.cleanup_stale_migration_marker(tmp_path)

        captured = capsys.readouterr()
        assert "Failed to remove stale migration marker" in captured.err
        assert "Safe to ignore" in captured.err


@pytest.mark.slow
class TestAddonStartup:
    """Test add-on container startup behavior."""

    @pytest.fixture(autouse=True, scope="class")
    def build_image(self):
        """Build the addon image once before all tests in this class."""
        _build_addon_image()

    @pytest.fixture
    def addon_config(self, tmp_path):
        """Create a test add-on configuration file."""
        config = {
            "backup_hint": "normal",
            "secret_path": "",  # Auto-generate
        }
        config_file = tmp_path / "options.json"
        with open(config_file, "w") as f:
            json.dump(config, f)
        return config_file

    @pytest.fixture
    def container(self, addon_config):
        """Create the add-on container for testing (image built by build_image fixture)."""
        return (
            DockerContainer(image=IMAGE_TAG)
            .with_bind_ports(9583, 9583)
            .with_env("SUPERVISOR_TOKEN", "test-supervisor-token")
            .with_env("HOMEASSISTANT_URL", "http://supervisor/core")
            .with_volume_mapping(str(addon_config.parent), "/data", mode="rw")
        )

    def test_addon_startup_logs(self, container):
        """Test that add-on produces expected startup logs."""
        # Configure wait strategy for server actually starting
        container.waiting_for(
            LogMessageWaitStrategy("Uvicorn running on").with_startup_timeout(30)
        )

        # Start container
        container.start()

        try:
            # Get logs (both stdout and stderr)
            stdout, stderr = container.get_logs()
            logs = stdout.decode("utf-8") + "\n" + stderr.decode("utf-8")

            # Verify expected log messages
            assert "[INFO] Starting Home Assistant MCP Server..." in logs
            assert "[INFO] Backup hint mode: normal" in logs
            assert "[INFO] Generated new secret path with 128-bit entropy" in logs
            assert "[INFO] Home Assistant URL: http://supervisor/core" in logs
            assert "🔐 MCP Server URL: http://<home-assistant-ip>:9583/private_" in logs
            assert "Secret Path: /private_" in logs
            assert (
                "⚠️  IMPORTANT: Copy this exact URL - the secret path is required!"
                in logs
            )

            # Verify debug messages
            assert "[INFO] Importing ha_mcp module..." in logs
            assert "[INFO] Starting MCP server..." in logs

            # Verify FastMCP started successfully
            assert "Starting MCP server 'ha-mcp'" in logs
            assert "Uvicorn running on http://0.0.0.0:9583" in logs

            # Should not have errors
            assert "[ERROR] Failed to start MCP server:" not in logs

            # Default config = INFO level: the DEBUG canary must be
            # absent and kill-signal diagnostics must NOT arm. Guards
            # the arming gate against regressing to always-on (the
            # SA_SIGINFO handler must stay opt-in via DEBUG).
            assert "Debug logging active (log_level applied from settings)" not in logs
            assert "arming kill-signal diagnostics" not in logs
            assert "kill-signal diagnostics installed for" not in logs

        finally:
            container.stop()

    def test_addon_startup_custom_secret_path(self, tmp_path):
        """Test that add-on uses custom secret path when configured."""
        # Create config with custom secret path
        config = {
            "backup_hint": "strong",
            "secret_path": "/my_custom_secret",
        }
        config_file = tmp_path / "options.json"
        with open(config_file, "w") as f:
            json.dump(config, f)

        container = (
            DockerContainer(image=IMAGE_TAG)
            .with_bind_ports(9583, 9583)
            .with_env("SUPERVISOR_TOKEN", "test-supervisor-token")
            .with_env("HOMEASSISTANT_URL", "http://supervisor/core")
            .with_volume_mapping(str(config_file.parent), "/data", mode="rw")
        )

        # Configure wait strategy
        container.waiting_for(
            LogMessageWaitStrategy("MCP Server URL:").with_startup_timeout(30)
        )

        container.start()

        try:
            # Get logs
            logs = container.get_logs()[0].decode("utf-8")

            # Verify custom config is used
            assert "[INFO] Backup hint mode: strong" in logs
            assert "[INFO] Using custom secret path from configuration" in logs
            assert (
                "🔐 MCP Server URL: http://<home-assistant-ip>:9583/my_custom_secret"
                in logs
            )
            assert "Secret Path: /my_custom_secret" in logs

        finally:
            container.stop()

    def test_web_ui_debug_log_level_reaches_addon_logs(self, tmp_path):
        """Regression for the #1721 diagnosis: the web Settings UI
        "Log level" advanced setting (persisted to
        ``/data/feature_flags.json``) must actually change addon log
        verbosity. start.py hardcoded ``basicConfig(level=INFO)``, so
        reporters asked to "enable debug logging" could never produce
        DEBUG output. Also covers the DEBUG-armed kill-signal
        diagnostics (which replaced the advanced_debug_logging toggle).
        """
        config_file = tmp_path / "options.json"
        with open(config_file, "w") as f:
            json.dump({"backup_hint": "normal", "secret_path": ""}, f)
        with open(tmp_path / "feature_flags.json", "w") as f:
            json.dump({"log_level": "DEBUG"}, f)

        container = (
            DockerContainer(image=IMAGE_TAG)
            .with_env("SUPERVISOR_TOKEN", "test-supervisor-token")
            .with_env("HOMEASSISTANT_URL", "http://supervisor/core")
            .with_volume_mapping(str(tmp_path), "/data", mode="rw")
        )
        container.waiting_for(
            LogMessageWaitStrategy("Uvicorn running on").with_startup_timeout(30)
        )
        container.start()

        try:
            # The install-confirmation line is emitted by the deferred
            # install thread slightly AFTER uvicorn logs "running", so
            # poll briefly instead of single-shot reading the logs.
            deadline = time.monotonic() + 15.0
            logs = ""
            while time.monotonic() < deadline:
                stdout, stderr = container.get_logs()
                logs = stdout.decode("utf-8") + "\n" + stderr.decode("utf-8")
                if "kill-signal diagnostics installed for" in logs:
                    break
                time.sleep(0.5)

            # The canary is emitted at DEBUG through the logging system,
            # so its presence proves the level override was applied.
            assert "Debug logging active (log_level applied from settings)" in logs
            # DEBUG also arms the kill-signal diagnostics.
            assert "arming kill-signal diagnostics" in logs
            assert "kill-signal diagnostics installed for" in logs
        finally:
            container.stop()

    def test_addon_startup_missing_supervisor_token(self, addon_config):
        """Test that add-on exits with error when SUPERVISOR_TOKEN is missing."""
        container = (
            DockerContainer(image=IMAGE_TAG)
            .with_bind_ports(9583, 9583)
            .with_volume_mapping(str(addon_config.parent), "/data", mode="ro")
        )

        container.start()

        try:
            # Wait a bit for container to start and error
            time.sleep(3)

            # Get logs (both stdout and stderr)
            stdout, stderr = container.get_logs()
            logs = stdout.decode("utf-8") + "\n" + stderr.decode("utf-8")

            # Verify error message
            assert "[ERROR] SUPERVISOR_TOKEN not found! Cannot authenticate." in logs

        finally:
            container.stop()


class TestBetaMasterAutoEnableInDevAddon:
    """Dev addon keeps the beta sub-flag keys in its Supervisor options
    schema (unlike stable). start.py auto-writes
    ``ENABLE_BETA_FEATURES=true`` whenever any of those sub-flag keys
    are present in ``/data/options.json``, so the runtime master gate
    in ``_apply_feature_flag_overrides`` becomes a no-op for dev addon
    users — Supervisor options remain authoritative for the beta sub-flags."""

    _DEV_BETA_KEYS = (
        "enable_yaml_config_editing",
        "enable_yaml_packages_automation",
        "enable_yaml_packages_script",
        "enable_yaml_packages_scene",
        "enable_filesystem_tools",
        "enable_custom_component_integration",
        "enable_code_mode",
        "enable_lite_docstrings",
    )

    @pytest.fixture(autouse=True)
    def addon(self):
        self.addon = _load_addon_start()

    def test_auto_enable_writes_true_when_any_beta_key_truthy(self, monkeypatch):
        """Dev-addon options with ANY beta sub-flag set to True →
        ENABLE_BETA_FEATURES=true written to env."""
        monkeypatch.delenv("ENABLE_BETA_FEATURES", raising=False)
        self.addon.maybe_auto_enable_beta_master(
            {"backup_hint": "normal", "enable_yaml_config_editing": True}
        )
        assert os.environ.get("ENABLE_BETA_FEATURES") == "true"

    def test_auto_enable_skips_when_all_beta_keys_false(self, monkeypatch):
        """HA Supervisor persists every schema-declared option to
        options.json with its default value on first start, so all 5
        beta keys land there with ``false`` immediately on a fresh
        dev-addon install. The auto-enable must NOT fire in that case
        — the master should follow the user's actual sub-flag choices,
        not the bare presence of the schema keys. Previously this test
        asserted the opposite (presence alone fires) and the user hit
        the resulting "locked-on master, no sub-flags enabled" bug
        """
        monkeypatch.delenv("ENABLE_BETA_FEATURES", raising=False)
        self.addon.maybe_auto_enable_beta_master(
            dict.fromkeys(self._DEV_BETA_KEYS, False)
        )
        assert "ENABLE_BETA_FEATURES" not in os.environ

    def test_auto_enable_fires_when_one_of_many_truthy(self, monkeypatch):
        """4 sub-flags False, 1 sub-flag True → still fires. Locks the
        ``any(...)`` semantic so a future change to ``all(...)`` would
        regress the single-feature-enabled case."""
        monkeypatch.delenv("ENABLE_BETA_FEATURES", raising=False)
        cfg: dict[str, object] = dict.fromkeys(self._DEV_BETA_KEYS, False)
        cfg["enable_code_mode"] = True
        self.addon.maybe_auto_enable_beta_master(cfg)
        assert os.environ.get("ENABLE_BETA_FEATURES") == "true"

    def test_auto_enable_skips_non_bool_truthy_values(self, monkeypatch):
        """Defensive: only Python ``True`` triggers, not a truthy
        non-bool. Supervisor's JSON parsing returns proper bools, but
        if a future malformed options.json or test fixture passes a
        truthy string, we want the gate to stay shut rather than
        silently flip the master on."""
        monkeypatch.delenv("ENABLE_BETA_FEATURES", raising=False)
        self.addon.maybe_auto_enable_beta_master({"enable_yaml_config_editing": "true"})
        assert "ENABLE_BETA_FEATURES" not in os.environ

    def test_auto_enable_does_not_set_var_when_no_beta_key(self, monkeypatch):
        """Stable-addon options never carry any beta key → no auto-
        enable. Master stays at the pydantic default (False) so the
        UI master toggle remains the gate."""
        monkeypatch.delenv("ENABLE_BETA_FEATURES", raising=False)
        self.addon.maybe_auto_enable_beta_master(
            {"backup_hint": "normal", "enable_tool_search": True}
        )
        assert "ENABLE_BETA_FEATURES" not in os.environ

    def test_auto_enable_does_not_set_var_for_empty_config(self, monkeypatch):
        """Missing/corrupt options.json yields an empty config → no
        auto-enable. Defensive: silent feature regression is the
        worst possible outcome of a bad options.json."""
        monkeypatch.delenv("ENABLE_BETA_FEATURES", raising=False)
        self.addon.maybe_auto_enable_beta_master({})
        assert "ENABLE_BETA_FEATURES" not in os.environ

    def test_auto_enable_keys_match_BETA_FEATURE_FIELDS_registry(self):
        """The keys checked by start.py must exactly match the
        runtime registry — drift would silently break the auto-enable
        signal for dev-addon users."""
        # Import lazily because ha_mcp isn't importable until the
        # repo's package is on the path. Acceptable here because
        # this test only asserts a structural invariant.
        import sys
        from pathlib import Path

        sys.path.insert(
            0,
            str(Path(__file__).parents[2] / "src"),
        )
        from ha_mcp.config import BETA_FEATURE_FIELDS

        assert set(self.addon._DEV_ADDON_BETA_KEYS) == set(BETA_FEATURE_FIELDS), (
            "start.py's _DEV_ADDON_BETA_KEYS drifted from config.BETA_FEATURE_FIELDS"
        )

    def test_stable_addon_still_does_not_carry_beta_keys(self):
        """Stable addon's ``config.yaml`` must NOT list any of the
        beta sub-flag keys (their Supervisor-options absence is what
        makes the master gate the sole gate for stable users)."""
        import yaml

        stable_yaml = yaml.safe_load(
            (
                Path(__file__).parents[2] / "homeassistant-addon" / "config.yaml"
            ).read_text()
        )
        for key in self._DEV_BETA_KEYS:
            assert key not in stable_yaml.get("options", {}), (
                f"{key} must not be in stable addon options"
            )
            assert key not in stable_yaml.get("schema", {}), (
                f"{key} must not be in stable addon schema"
            )

    def test_stable_addon_does_not_declare_enable_beta_features(self):
        """Stable's ``config.yaml`` must NOT declare the master toggle
        either — the master is web-UI-only on stable.
        Schema-declaring it on stable would auto-fill options.json with
        the default on first start, locking the master to env-mode and
        the standalone web UI master path would no longer be the gate.
        """
        import yaml

        stable_yaml = yaml.safe_load(
            (
                Path(__file__).parents[2] / "homeassistant-addon" / "config.yaml"
            ).read_text()
        )
        assert "enable_beta_features" not in stable_yaml.get("options", {}), (
            "enable_beta_features must not be in stable addon options"
        )
        assert "enable_beta_features" not in stable_yaml.get("schema", {}), (
            "enable_beta_features must not be in stable addon schema"
        )

    def test_dev_addon_declares_enable_beta_features_master_in_schema(self):
        """Dev addon's ``config.yaml`` must declare ``enable_beta_features``
        in both ``options:`` (default true) and ``schema:`` (bool?), so
        start.py reads it and writes the env var on every boot. The web
        UI then surfaces the master row as origin='addon' (editable)
        and saves route through Supervisor.
        """
        import yaml

        dev_yaml = yaml.safe_load(
            (
                Path(__file__).parents[2] / "homeassistant-addon-dev" / "config.yaml"
            ).read_text()
        )
        assert dev_yaml.get("options", {}).get("enable_beta_features") is True, (
            "dev addon options must default enable_beta_features=true"
        )
        assert "enable_beta_features" in dev_yaml.get("schema", {}), (
            "dev addon schema must declare enable_beta_features"
        )

    def test_dev_addon_defaults_every_beta_subflag_to_false(self):
        """Beta sub-tools default OFF on a fresh dev install — only the
        master defaults on. Shipping them on would risk damaging fresh
        installs out of the box.
        """
        import yaml

        dev_yaml = yaml.safe_load(
            (
                Path(__file__).parents[2] / "homeassistant-addon-dev" / "config.yaml"
            ).read_text()
        )
        opts = dev_yaml.get("options", {})
        for key in self._DEV_BETA_KEYS:
            assert key in opts, f"dev addon must declare {key} in options"
            assert opts[key] is False, (
                f"dev addon must default {key} to False (got {opts[key]!r})"
            )
