# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Unit tests for the OnboardingEngine.

Tests cover each validation step and the activation flow with mocked I/O.
"""

import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))

from familiar.onboard_engine import (
    CHANNELS,
    PERSONA_LABELS,
    PERSONAS,
    PROVIDERS,
    OnboardingEngine,
)

# ── Fixtures ──────────────────────────────────────────────────────────


@pytest.fixture
def engine(tmp_path):
    """Fresh engine with a temp project directory."""
    return OnboardingEngine(project_dir=tmp_path)


# ── Provider validation ──────────────────────────────────────────────


class TestValidateProvider:
    def test_valid_anthropic_key(self, engine):
        result = engine.validate_provider({"provider": "anthropic", "api_key": "sk-ant-test-1234"})
        assert result.success
        assert result.data["provider"] == "anthropic"
        assert result.data["api_key"] == "sk-ant-test-1234"

    def test_valid_openai_key(self, engine):
        result = engine.validate_provider({"provider": "openai", "api_key": "sk-test-abc"})
        assert result.success
        assert result.data["provider"] == "openai"

    def test_valid_gemini_key(self, engine):
        result = engine.validate_provider({"provider": "gemini", "api_key": "AIza-test-key"})
        assert result.success
        assert result.data["provider"] == "gemini"

    def test_ollama_no_key_needed(self, engine):
        result = engine.validate_provider({"provider": "ollama"})
        assert result.success
        assert result.data["provider"] == "ollama"

    def test_missing_anthropic_key(self, engine):
        # Ensure env var is not set
        with patch.dict(os.environ, {}, clear=True):
            result = engine.validate_provider({"provider": "anthropic"})
            assert not result.success
            assert any("API key required" in e for e in result.errors)

    def test_anthropic_key_from_env(self, engine):
        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-env-key"}):
            result = engine.validate_provider({"provider": "anthropic"})
            assert result.success
            assert result.data["api_key"] == "sk-env-key"

    def test_unknown_provider(self, engine):
        result = engine.validate_provider({"provider": "cohere"})
        assert not result.success
        assert any("Unknown provider" in e for e in result.errors)

    def test_ollama_model_passthrough(self, engine):
        result = engine.validate_provider({"provider": "ollama", "ollama_model": "qwen2.5:7b"})
        assert result.success
        assert result.data["ollama_model"] == "qwen2.5:7b"


# ── Channel validation ───────────────────────────────────────────────


class TestValidateChannels:
    def test_valid_telegram(self, engine):
        result = engine.validate_channels(
            {"channels": ["telegram"], "telegram_token": "123456:ABCdef"}
        )
        assert result.success
        assert result.data["channel"] == "telegram"
        assert result.data["telegram_token"] == "123456:ABCdef"

    def test_valid_cli(self, engine):
        result = engine.validate_channels({"channels": ["cli"]})
        assert result.success

    def test_empty_channels(self, engine):
        result = engine.validate_channels({"channels": []})
        assert not result.success
        assert any("At least one" in e for e in result.errors)

    def test_telegram_missing_token(self, engine):
        result = engine.validate_channels({"channels": ["telegram"]})
        assert not result.success
        assert any("token is required" in e for e in result.errors)

    def test_telegram_suspicious_token_warning(self, engine):
        result = engine.validate_channels(
            {"channels": ["telegram"], "telegram_token": "no-colon-here"}
        )
        assert result.success  # warning, not error
        assert len(result.warnings) > 0

    def test_discord_missing_token(self, engine):
        result = engine.validate_channels({"channels": ["discord"]})
        assert not result.success

    def test_multi_channel(self, engine):
        result = engine.validate_channels(
            {
                "channels": ["telegram", "discord", "cli"],
                "telegram_token": "123:ABC",
                "discord_token": "MTk.xyz",
            }
        )
        assert result.success
        assert result.data["channels"] == ["telegram", "discord", "cli"]
        assert result.data["channel"] == "telegram"  # primary = first

    def test_unknown_channel(self, engine):
        result = engine.validate_channels({"channels": ["smoke_signal"]})
        assert not result.success
        assert any("Unknown channel" in e for e in result.errors)

    def test_matrix_requires_homeserver(self, engine):
        result = engine.validate_channels(
            {
                "channels": ["matrix"],
                "matrix_user": "@bot:matrix.org",
                "matrix_password": "secret",
            }
        )
        assert not result.success
        assert any("homeserver" in e.lower() for e in result.errors)

    def test_matrix_valid(self, engine):
        result = engine.validate_channels(
            {
                "channels": ["matrix"],
                "matrix_homeserver": "https://matrix.org",
                "matrix_user": "@bot:matrix.org",
                "matrix_password": "secret",
            }
        )
        assert result.success

    def test_signal_phone_required(self, engine):
        result = engine.validate_channels({"channels": ["signal"]})
        assert not result.success
        assert any("Signal phone" in e for e in result.errors)

    def test_signal_phone_format_warning(self, engine):
        result = engine.validate_channels({"channels": ["signal"], "signal_phone": "14155551234"})
        assert result.success
        assert any("country code" in w for w in result.warnings)

    def test_signal_valid(self, engine):
        result = engine.validate_channels({"channels": ["signal"], "signal_phone": "+14155551234"})
        assert result.success
        assert result.data["signal_phone"] == "+14155551234"
        assert "signal" in result.data["channels"]

    def test_whatsapp_valid(self, engine):
        result = engine.validate_channels({"channels": ["whatsapp"], "whatsapp_port": "3001"})
        assert result.success
        assert "whatsapp" in result.data["channels"]

    def test_whatsapp_invalid_port(self, engine):
        result = engine.validate_channels({"channels": ["whatsapp"], "whatsapp_port": "abc"})
        assert not result.success
        assert any("port must be a number" in e for e in result.errors)

    def test_whatsapp_default_port(self, engine):
        result = engine.validate_channels({"channels": ["whatsapp"]})
        assert result.success
        assert "whatsapp" in result.data["channels"]

    @patch("familiar.onboard_engine.subprocess.run", side_effect=FileNotFoundError)
    def test_whatsapp_nodejs_warning(self, mock_run, engine):
        """WhatsApp channel warns when Node.js is not installed."""
        result = engine.validate_channels({"channels": ["whatsapp"], "whatsapp_port": "3001"})
        assert result.success  # warning, not error
        assert any("Node.js" in w for w in result.warnings)

    @patch("familiar.onboard_engine.subprocess.run")
    def test_whatsapp_nodejs_ok(self, mock_run, engine):
        """WhatsApp channel produces no warning when Node.js v20+ is present."""
        mock_run.return_value = MagicMock(returncode=0, stdout="v20.11.0\n")
        result = engine.validate_channels({"channels": ["whatsapp"], "whatsapp_port": "3001"})
        assert result.success
        assert not any("Node.js" in w for w in result.warnings)

    @patch("familiar.onboard_engine.sys")
    def test_imessage_macos_only(self, mock_sys, engine):
        """iMessage channel fails on non-macOS platforms."""
        mock_sys.platform = "linux"
        result = engine.validate_channels({"channels": ["imessage"]})
        assert not result.success
        assert any("macOS" in e for e in result.errors)

    @patch("familiar.onboard_engine.sys")
    def test_imessage_valid_on_macos(self, mock_sys, engine):
        """iMessage channel succeeds on macOS."""
        mock_sys.platform = "darwin"
        result = engine.validate_channels({"channels": ["imessage"]})
        assert result.success
        assert "imessage" in result.data["channels"]

    def test_teams_missing_app_id(self, engine):
        """Teams channel requires app_id."""
        result = engine.validate_channels({"channels": ["teams"], "teams_app_password": "secret"})
        assert not result.success
        assert any("App ID" in e for e in result.errors)

    def test_teams_missing_app_password(self, engine):
        """Teams channel requires app_password."""
        result = engine.validate_channels({"channels": ["teams"], "teams_app_id": "abc-123"})
        assert not result.success
        assert any("App Password" in e for e in result.errors)

    def test_teams_valid(self, engine):
        """Teams channel succeeds with app_id + app_password."""
        result = engine.validate_channels(
            {
                "channels": ["teams"],
                "teams_app_id": "abc-123",
                "teams_app_password": "secret",
            }
        )
        assert result.success
        assert "teams" in result.data["channels"]
        assert result.data["teams_app_id"] == "abc-123"


# ── PIN validation ────────────────────────────────────────────────────


class TestValidateOwnerPin:
    def test_valid_4_digit(self, engine):
        result = engine.validate_owner_pin({"pin": "1234"})
        assert result.success
        assert ":" in result.data["hash"]
        assert result.data["length"] == 4

    def test_valid_8_digit(self, engine):
        result = engine.validate_owner_pin({"pin": "12345678"})
        assert result.success
        assert result.data["length"] == 8

    def test_too_short(self, engine):
        result = engine.validate_owner_pin({"pin": "12"})
        assert not result.success
        assert any("4-8 digits" in e for e in result.errors)

    def test_too_long(self, engine):
        result = engine.validate_owner_pin({"pin": "123456789"})
        assert not result.success

    def test_non_digits(self, engine):
        result = engine.validate_owner_pin({"pin": "12ab"})
        assert not result.success
        assert any("digits" in e for e in result.errors)

    def test_empty(self, engine):
        result = engine.validate_owner_pin({"pin": ""})
        assert not result.success

    def test_hash_format(self, engine):
        result = engine.validate_owner_pin({"pin": "5678"})
        assert result.success
        salt, hash_hex = result.data["hash"].split(":")
        assert len(salt) == 32  # 16 bytes hex
        assert len(hash_hex) == 64  # sha256 hex

    def test_default_pin_generation(self):
        pin = OnboardingEngine.generate_default_pin()
        assert pin.isdigit()
        assert len(pin) == 4


# ── Identity validation ──────────────────────────────────────────────


class TestValidateIdentity:
    def test_valid(self, engine):
        result = engine.validate_identity({"name": "Jarvis", "persona": "efficient"})
        assert result.success
        assert result.data["name"] == "Jarvis"
        assert "concise" in result.data["persona"]

    def test_default_name(self, engine):
        result = engine.validate_identity({"name": "", "persona": "hospitality"})
        assert result.success
        assert result.data["name"] == "Familiar"

    def test_unknown_persona(self, engine):
        result = engine.validate_identity({"name": "Bot", "persona": "pirate"})
        assert not result.success

    def test_all_personas(self, engine):
        for key in PERSONAS:
            result = engine.validate_identity({"name": "Test", "persona": key})
            assert result.success, f"Persona {key} should be valid"


# ── Briefing validation ──────────────────────────────────────────────


class TestValidateBriefing:
    def test_enabled_valid_time(self, engine):
        result = engine.validate_briefing({"enabled": True, "time": "08:30", "heartbeat": True})
        assert result.success
        assert result.data["briefing_time"] == "08:30"
        assert result.data["heartbeat_enabled"] is True

    def test_disabled(self, engine):
        result = engine.validate_briefing({"enabled": False})
        assert result.success
        assert result.data["briefing_enabled"] is False

    def test_invalid_time(self, engine):
        result = engine.validate_briefing({"enabled": True, "time": "25:00"})
        assert not result.success
        assert any("valid time" in e for e in result.errors)

    def test_invalid_time_format(self, engine):
        result = engine.validate_briefing({"enabled": True, "time": "8am"})
        assert not result.success

    def test_midnight(self, engine):
        result = engine.validate_briefing({"enabled": True, "time": "00:00"})
        assert result.success

    def test_2359(self, engine):
        result = engine.validate_briefing({"enabled": True, "time": "23:59"})
        assert result.success


# ── Encryption validation ────────────────────────────────────────────


class TestValidateEncryption:
    def test_enabled_with_passphrase(self, engine):
        result = engine.validate_encryption({"enabled": True, "passphrase": "my-secret-passphrase"})
        assert result.success
        assert result.data["passphrase"] == "my-secret-passphrase"

    def test_enabled_auto_passphrase(self, engine):
        result = engine.validate_encryption({"enabled": True})
        assert result.success
        assert len(result.data["passphrase"]) > 10

    def test_disabled(self, engine):
        result = engine.validate_encryption({"enabled": False})
        assert result.success
        assert result.data["encrypt_at_rest"] is False


# ── Summary ───────────────────────────────────────────────────────────


class TestGetSummary:
    def test_full_summary(self, engine):
        engine.validate_provider({"provider": "ollama", "ollama_model": "llama3.2"})
        engine.validate_channels({"channels": ["cli"]})
        engine.validate_owner_pin({"pin": "4321"})
        engine.validate_identity({"name": "Hal", "persona": "formal"})
        engine.validate_briefing({"enabled": True, "time": "07:00", "heartbeat": True})
        engine.validate_encryption({"enabled": True, "passphrase": "secret"})

        summary = engine.get_summary()
        assert summary["provider"] == "ollama"
        assert summary["name"] == "Hal"
        assert summary["channels"] == ["cli"]
        assert summary["briefing_enabled"] is True
        assert summary["briefing_time"] == "07:00"
        assert summary["encrypt_at_rest"] is True
        assert summary["pin_length"] == 4

    def test_empty_summary(self, engine):
        summary = engine.get_summary()
        assert summary["provider"] == ""
        assert summary["channels"] == []


# ── Activation ────────────────────────────────────────────────────────


class TestActivate:
    def _setup_engine(self, engine):
        """Complete all steps so activation can proceed."""
        engine.validate_provider({"provider": "ollama", "ollama_model": "llama3.2"})
        engine.validate_channels({"channels": ["cli"]})
        engine.validate_owner_pin({"pin": "9999"})
        engine.validate_identity({"name": "Tester", "persona": "efficient"})
        engine.validate_briefing({"enabled": False})
        engine.validate_encryption({"enabled": False})

    def test_missing_steps_fail(self, engine):
        result = engine.activate()
        assert not result.success
        assert any("provider" in e for e in result.errors)

    @patch("familiar.onboard_engine.subprocess.run")
    def test_activation_writes_config(self, mock_run, engine, tmp_path):
        mock_run.return_value = MagicMock(returncode=0, stderr=b"")
        self._setup_engine(engine)

        # Mock out the mesh/config imports that won't exist in test
        with patch.dict(
            "sys.modules",
            {
                "familiar.core.config": MagicMock(),
                "familiar.core.mesh": MagicMock(),
            },
        ):
            notifications = []
            result = engine.activate(notify=notifications.append)

        assert result.success
        assert (tmp_path / "config.yaml").exists()
        assert (tmp_path / ".env").exists()

        # Check config content
        config_text = (tmp_path / "config.yaml").read_text()
        assert "Tester" in config_text
        assert "ollama" in config_text
        assert "cli_enabled: true" in config_text

        # Check .env content
        env_text = (tmp_path / ".env").read_text()
        assert "DEFAULT_PROVIDER=ollama" in env_text
        assert "ENABLED_CHANNELS=cli" in env_text

        # Check notifications were sent
        assert len(notifications) > 0
        assert any("complete" in n.lower() for n in notifications)

    @patch("familiar.onboard_engine.subprocess.run")
    def test_activation_with_encryption(self, mock_run, engine, tmp_path):
        mock_run.return_value = MagicMock(returncode=0, stderr=b"")
        self._setup_engine(engine)
        engine.validate_encryption({"enabled": True, "passphrase": "my-pass"})

        with patch.dict(
            "sys.modules",
            {
                "familiar.core.config": MagicMock(),
                "familiar.core.mesh": MagicMock(),
            },
        ):
            result = engine.activate()

        assert result.success
        env_text = (tmp_path / ".env").read_text()
        assert "ENCRYPTION_ENABLED=true" in env_text
        assert "ENCRYPTION_PASSPHRASE=my-pass" in env_text

    @patch("familiar.onboard_engine.subprocess.run")
    def test_activation_with_api_keys(self, mock_run, engine, tmp_path):
        mock_run.return_value = MagicMock(returncode=0, stderr=b"")
        engine.validate_provider({"provider": "anthropic", "api_key": "sk-ant-xyz"})
        engine.validate_channels({"channels": ["telegram"], "telegram_token": "123:ABC"})
        engine.validate_identity({"name": "Claude", "persona": "hospitality"})

        with patch.dict(
            "sys.modules",
            {
                "familiar.core.config": MagicMock(),
                "familiar.core.mesh": MagicMock(),
            },
        ):
            result = engine.activate()

        assert result.success
        env_text = (tmp_path / ".env").read_text()
        assert "ANTHROPIC_API_KEY=sk-ant-xyz" in env_text
        assert "TELEGRAM_BOT_TOKEN=123:ABC" in env_text

        config_text = (tmp_path / "config.yaml").read_text()
        assert "anthropic" in config_text
        assert "telegram_enabled: true" in config_text


# ── Detect providers ──────────────────────────────────────────────────


class TestDetectProviders:
    def test_no_providers(self, engine):
        with patch.dict(os.environ, {}, clear=True):
            with patch("familiar.onboard_engine.subprocess.run", side_effect=FileNotFoundError):
                result = engine.detect_providers()
        assert result.success
        assert len(result.data["providers"]) == 0

    def test_anthropic_detected(self, engine):
        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}, clear=True):
            with patch("familiar.onboard_engine.subprocess.run", side_effect=FileNotFoundError):
                result = engine.detect_providers()
        assert "anthropic" in result.data["providers"]

    def test_gemini_detected(self, engine):
        with patch.dict(os.environ, {"GEMINI_API_KEY": "AIza-test"}, clear=True):
            with patch("familiar.onboard_engine.subprocess.run", side_effect=FileNotFoundError):
                result = engine.detect_providers()
        assert "gemini" in result.data["providers"]

    def test_ram_detected(self, engine):
        with patch.dict(os.environ, {}, clear=True):
            with patch("familiar.onboard_engine.subprocess.run", side_effect=FileNotFoundError):
                result = engine.detect_providers()
        assert "ram_gb" in result.data
        assert result.data["ram_gb"] > 0


# ── Constants ─────────────────────────────────────────────────────────


class TestConstants:
    def test_all_providers_have_required_fields(self):
        for p in PROVIDERS:
            assert "key" in p
            assert "label" in p
            assert "default_model" in p
            assert "model_field" in p

    def test_all_channels_have_required_fields(self):
        for c in CHANNELS:
            assert "key" in c
            assert "label" in c
            assert "needs_token" in c

    def test_all_persona_keys_match(self):
        assert set(PERSONAS.keys()) == set(PERSONA_LABELS.keys())


# ── Merge env cleanup ────────────────────────────────────────────


class TestMergeEnvCleanup:
    def test_cleans_matrix_secrets(self, engine, tmp_path):
        """Deselecting matrix removes MATRIX_* vars from .env only when
        matrix was previously in ENABLED_CHANNELS."""
        env_path = tmp_path / ".env"
        env_path.write_text(
            "ENABLED_CHANNELS=telegram,matrix\n"
            "MATRIX_HOMESERVER=https://matrix.org\n"
            "MATRIX_USER=@bot:matrix.org\n"
            "MATRIX_PASSWORD=secret\n"
            "MATRIX_ACCESS_TOKEN=tok\n"
            "OWNER_MATRIX_ID=@me:matrix.org\n"
            "DEFAULT_PROVIDER=ollama\n"
        )
        channels = {"channels": ["telegram"]}
        new_vars = {"TELEGRAM_BOT_TOKEN": "123:ABC"}
        OnboardingEngine._merge_env(env_path, new_vars, channels)
        text = env_path.read_text()
        assert "MATRIX_HOMESERVER" not in text
        assert "MATRIX_USER" not in text
        assert "MATRIX_PASSWORD" not in text
        assert "MATRIX_ACCESS_TOKEN" not in text
        assert "OWNER_MATRIX_ID" not in text
        assert "TELEGRAM_BOT_TOKEN=123:ABC" in text

    def test_preserves_tokens_for_unknown_channels(self, engine, tmp_path):
        """Tokens for channels NOT in prev ENABLED_CHANNELS are preserved."""
        env_path = tmp_path / ".env"
        env_path.write_text(
            "ENABLED_CHANNELS=cli\n"
            "TELEGRAM_BOT_TOKEN=real:TOKEN\n"
            "OWNER_TELEGRAM_ID=12345\n"
            "DEFAULT_PROVIDER=ollama\n"
        )
        channels = {"channels": ["cli"]}
        new_vars = {"DEFAULT_PROVIDER": "ollama"}
        OnboardingEngine._merge_env(env_path, new_vars, channels)
        text = env_path.read_text()
        assert "TELEGRAM_BOT_TOKEN=real:TOKEN" in text
        assert "OWNER_TELEGRAM_ID=12345" in text

    def test_cleans_encryption_when_disabled(self, engine, tmp_path):
        """Encryption vars removed only when explicitly set to false."""
        env_path = tmp_path / ".env"
        env_path.write_text(
            "ENCRYPTION_ENABLED=true\nENCRYPTION_PASSPHRASE=hunter2\nDEFAULT_PROVIDER=ollama\n"
        )
        channels = {"channels": ["cli"]}
        new_vars = {"DEFAULT_PROVIDER": "ollama", "ENCRYPTION_ENABLED": "false"}
        OnboardingEngine._merge_env(env_path, new_vars, channels)
        text = env_path.read_text()
        assert "ENCRYPTION_PASSPHRASE" not in text

    def test_preserves_encryption_when_absent(self, engine, tmp_path):
        """Encryption vars preserved when not mentioned in new_vars."""
        env_path = tmp_path / ".env"
        env_path.write_text(
            "ENCRYPTION_ENABLED=true\nENCRYPTION_PASSPHRASE=hunter2\nDEFAULT_PROVIDER=ollama\n"
        )
        channels = {"channels": ["cli"]}
        new_vars = {"DEFAULT_PROVIDER": "ollama"}
        OnboardingEngine._merge_env(env_path, new_vars, channels)
        text = env_path.read_text()
        assert "ENCRYPTION_ENABLED=true" in text
        assert "ENCRYPTION_PASSPHRASE=hunter2" in text

    def test_preserves_lightweight_model(self, engine, tmp_path):
        """Lightweight model var is preserved across wizard runs."""
        env_path = tmp_path / ".env"
        env_path.write_text("FAMILIAR_LIGHTWEIGHT_MODEL=qwen2.5:0.5b\n")
        channels = {"channels": ["cli"]}
        new_vars = {"DEFAULT_PROVIDER": "ollama"}
        OnboardingEngine._merge_env(env_path, new_vars, channels)
        text = env_path.read_text()
        assert "FAMILIAR_LIGHTWEIGHT_MODEL=qwen2.5:0.5b" in text

    def test_cleans_legacy_provider_var(self, engine, tmp_path):
        """LLM_DEFAULT_PROVIDER (legacy) is always removed."""
        env_path = tmp_path / ".env"
        env_path.write_text("LLM_DEFAULT_PROVIDER=ollama\nDEFAULT_PROVIDER=ollama\n")
        channels = {"channels": ["cli"]}
        new_vars = {"DEFAULT_PROVIDER": "ollama"}
        OnboardingEngine._merge_env(env_path, new_vars, channels)
        text = env_path.read_text()
        assert "LLM_DEFAULT_PROVIDER" not in text
        assert "DEFAULT_PROVIDER=ollama" in text


# ── Config completeness ──────────────────────────────────────────


class TestConfigCompleteness:
    def test_missing_provider(self, tmp_path):
        """Empty provider triggers warning."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("llm:\n  default_provider:\nchannels:\n  cli_enabled: true\n")
        env = tmp_path / ".env"
        env.write_text("")
        result = OnboardingEngine.validate_config_completeness(cfg, env)
        assert not result.success
        assert any("provider" in w.lower() for w in result.warnings)

    def test_no_channels(self, tmp_path):
        """No channels enabled triggers warning."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("llm:\n  default_provider: ollama\nchannels:\n  telegram_enabled: false\n")
        env = tmp_path / ".env"
        env.write_text("")
        result = OnboardingEngine.validate_config_completeness(cfg, env)
        assert not result.success
        assert any("channel" in w.lower() for w in result.warnings)

    def test_valid_config(self, tmp_path):
        """Valid config passes completeness check."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("llm:\n  default_provider: ollama\nchannels:\n  cli_enabled: true\n")
        env = tmp_path / ".env"
        env.write_text("DEFAULT_PROVIDER=ollama\n")
        result = OnboardingEngine.validate_config_completeness(cfg, env)
        assert result.success


# ── State persistence ────────────────────────────────────────────


class TestStatePersistence:
    def test_save_load_cycle(self, engine, tmp_path):
        """Save state, load it, verify match."""
        engine._STATE_FILE = tmp_path / ".onboard_state.json"
        engine._state = {"provider": {"provider": "ollama"}}
        engine.save_state(current_step=3)
        loaded = engine.load_saved_state()
        assert loaded is not None
        assert loaded["step"] == 3
        assert loaded["state"]["provider"]["provider"] == "ollama"

    def test_expires_after_24h(self, engine, tmp_path):
        """State older than 24h returns None."""
        state_file = tmp_path / ".onboard_state.json"
        engine._STATE_FILE = state_file
        old_time = datetime.now() - timedelta(hours=25)
        payload = {
            "timestamp": old_time.isoformat(),
            "step": 2,
            "state": {"provider": {"provider": "ollama"}},
        }
        state_file.write_text(json.dumps(payload))
        loaded = engine.load_saved_state()
        assert loaded is None
        assert not state_file.exists()

    def test_clear_saved_state(self, engine, tmp_path):
        """clear_saved_state removes the file."""
        state_file = tmp_path / ".onboard_state.json"
        engine._STATE_FILE = state_file
        state_file.write_text("{}")
        engine.clear_saved_state()
        assert not state_file.exists()


# ── Test message engine ──────────────────────────────────────────


class TestSendTestMessage:
    def test_cli_channel(self, engine):
        """CLI channel returns success with notify."""
        engine.validate_provider({"provider": "ollama"})
        engine.validate_channels({"channels": ["cli"]})
        engine.validate_identity({"name": "Test", "persona": "efficient"})
        messages = []
        result = engine.send_test_message(notify=messages.append)
        assert result.success
        assert any("Test" in m for m in messages)

    def test_no_channels(self, engine):
        """No channels returns error."""
        result = engine.send_test_message()
        assert not result.success

    @patch("time.sleep", return_value=None)
    @patch("urllib.request.urlopen")
    def test_telegram_no_start(self, mock_urlopen, mock_sleep, engine):
        """Telegram test returns warning when no /start received."""
        engine._state["channels"] = {
            "channels": ["telegram"],
            "channel": "telegram",
            "telegram_token": "123:ABC",
        }
        engine._state["identity"] = {"name": "Bot", "persona": "test"}
        engine._state["briefing"] = {"briefing_enabled": False}

        # Return empty updates (no /start received)
        mock_resp = MagicMock()
        mock_resp.read.return_value = b'{"ok":true,"result":[]}'
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        result = engine.send_test_message()
        assert not result.success
        assert len(result.warnings) > 0

    @patch("urllib.request.urlopen")
    def test_telegram_success(self, mock_urlopen, engine):
        """Telegram test sends message when /start received."""
        engine._state["channels"] = {
            "channels": ["telegram"],
            "channel": "telegram",
            "telegram_token": "123:ABC",
        }
        engine._state["identity"] = {"name": "Bot", "persona": "test"}
        engine._state["briefing"] = {"briefing_enabled": False}

        # First call: getUpdates returns a /start message
        # Second call: sendMessage returns ok
        update_resp = MagicMock()
        update_resp.read.return_value = json.dumps(
            {
                "ok": True,
                "result": [
                    {
                        "update_id": 1,
                        "message": {"text": "/start", "chat": {"id": 42}},
                    }
                ],
            }
        ).encode()
        update_resp.__enter__ = lambda s: s
        update_resp.__exit__ = MagicMock(return_value=False)

        send_resp = MagicMock()
        send_resp.read.return_value = b'{"ok":true}'
        send_resp.__enter__ = lambda s: s
        send_resp.__exit__ = MagicMock(return_value=False)

        mock_urlopen.side_effect = [update_resp, send_resp]

        result = engine.send_test_message()
        assert result.success
        assert mock_urlopen.call_count == 2

    def test_unsupported_channel(self, engine):
        """Unsupported channel returns success with deferred warning."""
        engine._state["channels"] = {
            "channels": ["whatsapp"],
            "channel": "whatsapp",
        }
        engine._state["identity"] = {"name": "Bot", "persona": "test"}
        engine._state["briefing"] = {"briefing_enabled": False}
        result = engine.send_test_message()
        assert result.success
        assert any("deferred" in w.lower() for w in result.warnings)

    def test_telegram_no_token(self, engine):
        """Telegram with missing token falls through to deferred."""
        engine._state["channels"] = {
            "channels": ["telegram"],
            "channel": "telegram",
            # no telegram_token — guard fails, falls to deferred path
        }
        engine._state["identity"] = {"name": "Bot", "persona": "test"}
        engine._state["briefing"] = {"briefing_enabled": False}
        result = engine.send_test_message()
        assert result.success
        assert any("deferred" in w.lower() for w in result.warnings)


# ── Ollama bundles ───────────────────────────────────────────────


class TestOllamaBundles:
    def test_pi_bundle(self, engine):
        """Low RAM returns Pi Bundle."""
        bundles = engine.get_ollama_bundles(ram_gb=2.0)
        assert bundles["recommended"]["label"] == "Pi Bundle"
        assert "qwen2.5:0.5b" in bundles["recommended"]["models"]

    def test_standard_bundle(self, engine):
        """Mid RAM returns Standard Bundle."""
        bundles = engine.get_ollama_bundles(ram_gb=6.0)
        assert bundles["recommended"]["label"] == "Standard Bundle"

    def test_full_bundle(self, engine):
        """High RAM returns Full Bundle."""
        bundles = engine.get_ollama_bundles(ram_gb=16.0)
        assert bundles["recommended"]["label"] == "Full Bundle"
        assert "qwen2.5:7b" in bundles["recommended"]["models"]

    def test_available_models_populated(self, engine):
        """Available models list is non-empty."""
        bundles = engine.get_ollama_bundles(ram_gb=8.0)
        assert len(bundles["available_models"]) >= 9
