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

"""
Tests for the self_management skill.

Covers: show_self_status, list_config_checkpoints, propose_config_change,
rollback_config, apply_update, restart_agent, and TOOLS registry.
"""

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import yaml

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def tmp_config(tmp_path):
    """Redirect config and checkpoint paths to tmp_path."""
    config_file = tmp_path / "config.yaml"
    checkpoints_dir = tmp_path / "config_checkpoints"
    config_file.write_text(
        yaml.dump(
            {
                "agent": {
                    "name": "TestAgent",
                    "persona": "a test assistant",
                    "security_mode": "balanced",
                    "allow_shell_commands": False,
                    "scheduler_enabled": True,
                },
                "llm": {"provider": "ollama", "model": "llama3.2"},
            }
        )
    )
    with (
        patch("familiar.skills.self_management.skill.CONFIG_FILE", config_file),
        patch("familiar.skills.self_management.skill.CHECKPOINTS_DIR", checkpoints_dir),
        patch("familiar.skills.self_management.skill.DATA_DIR", tmp_path),
    ):
        yield config_file, checkpoints_dir


# ---------------------------------------------------------------------------
# show_self_status
# ---------------------------------------------------------------------------


class TestShowSelfStatus:
    def test_returns_version(self, tmp_config):
        from familiar.skills.self_management.skill import show_self_status

        result = show_self_status({})
        assert "version" in result

    def test_returns_platform_info(self, tmp_config):
        from familiar.skills.self_management.skill import show_self_status

        result = show_self_status({})
        assert "python_version" in result
        assert "hostname" in result
        assert "platform" in result

    def test_returns_config_summary(self, tmp_config):
        from familiar.skills.self_management.skill import show_self_status

        result = show_self_status({})
        assert "config_summary" in result
        summary = result["config_summary"]
        assert summary["agent_name"] == "TestAgent"
        assert summary["llm_model"] == "llama3.2"

    def test_returns_config_file_path(self, tmp_config):
        from familiar.skills.self_management.skill import show_self_status

        result = show_self_status({})
        assert "config_file" in result
        assert "config_exists" in result

    def test_loaded_skill_count_non_negative(self, tmp_config):
        from familiar.skills.self_management.skill import show_self_status

        result = show_self_status({})
        assert result.get("loaded_skill_count", 0) >= 0

    def test_checkpoint_count_zero_initially(self, tmp_config):
        from familiar.skills.self_management.skill import show_self_status

        result = show_self_status({})
        assert result["config_checkpoints"] == 0


# ---------------------------------------------------------------------------
# list_config_checkpoints
# ---------------------------------------------------------------------------


class TestListConfigCheckpoints:
    def test_empty_initially(self, tmp_config):
        from familiar.skills.self_management.skill import list_config_checkpoints

        result = list_config_checkpoints({})
        assert result["checkpoints"] == []
        assert result["count"] == 0

    def test_lists_saved_checkpoints(self, tmp_config):
        config_file, checkpoints_dir = tmp_config
        checkpoints_dir.mkdir(parents=True, exist_ok=True)

        # Manually write a checkpoint
        ck = {
            "id": "20260312_120000",
            "reason": "test checkpoint",
            "timestamp": "2026-03-12T12:00:00+00:00",
            "config": {"agent": {"name": "Old"}},
        }
        (checkpoints_dir / "20260312_120000.json").write_text(json.dumps(ck))

        from familiar.skills.self_management.skill import list_config_checkpoints

        result = list_config_checkpoints({})
        assert result["count"] == 1
        assert result["checkpoints"][0]["id"] == "20260312_120000"
        assert result["checkpoints"][0]["reason"] == "test checkpoint"

    def test_newest_first(self, tmp_config):
        config_file, checkpoints_dir = tmp_config
        checkpoints_dir.mkdir(parents=True, exist_ok=True)

        for ts in ("20260312_100000", "20260312_110000", "20260312_120000"):
            ck = {"id": ts, "reason": "", "timestamp": ts, "config": {}}
            (checkpoints_dir / f"{ts}.json").write_text(json.dumps(ck))

        from familiar.skills.self_management.skill import list_config_checkpoints

        result = list_config_checkpoints({})
        ids = [c["id"] for c in result["checkpoints"]]
        assert ids == sorted(ids, reverse=True)


# ---------------------------------------------------------------------------
# propose_config_change
# ---------------------------------------------------------------------------


class TestProposeConfigChange:
    def test_requires_key(self, tmp_config):
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change({"value": "X", "reason": "test"})
        assert "error" in result

    def test_requires_value(self, tmp_config):
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change({"key": "agent.name", "reason": "test"})
        assert "error" in result

    def test_requires_reason(self, tmp_config):
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change({"key": "agent.name", "value": "X"})
        assert "error" in result

    def test_blocks_non_editable_key(self, tmp_config):
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change(
            {"key": "agent.internal_secret", "value": "x", "reason": "testing"}
        )
        assert "error" in result
        assert "not in the list" in result["error"]

    def test_returns_confirmation_sentinel(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change(
            {"key": "agent.name", "value": "NewName", "reason": "renaming agent"}
        )
        assert isinstance(result, ConfirmationRequired)

    def test_confirmation_preview_shows_diff(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change(
            {"key": "agent.name", "value": "NewName", "reason": "renaming agent"}
        )
        assert isinstance(result, ConfirmationRequired)
        assert "TestAgent" in result.preview  # current value
        assert "NewName" in result.preview  # proposed value
        assert "renaming agent" in result.preview  # reason

    def test_applies_change_when_confirmed(self, tmp_config):
        config_file, checkpoints_dir = tmp_config
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change(
            {
                "key": "agent.name",
                "value": "Confirmed",
                "reason": "test apply",
                "_confirmed": True,
            }
        )
        assert result.get("applied") is True
        assert result["new_value"] == "Confirmed"
        assert result["old_value"] == "TestAgent"

        # Verify config file was actually written
        with open(config_file) as f:
            written = yaml.safe_load(f)
        assert written["agent"]["name"] == "Confirmed"

    def test_saves_checkpoint_before_applying(self, tmp_config):
        config_file, checkpoints_dir = tmp_config
        checkpoints_dir.mkdir(parents=True, exist_ok=True)

        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change(
            {
                "key": "agent.name",
                "value": "Updated",
                "reason": "checkpoint test",
                "_confirmed": True,
            }
        )
        assert "checkpoint_id" in result
        # Checkpoint file should exist
        ck_path = checkpoints_dir / f"{result['checkpoint_id']}.json"
        assert ck_path.exists()

        # Checkpoint should contain the OLD config
        with open(ck_path) as f:
            ck = json.load(f)
        assert ck["config"]["agent"]["name"] == "TestAgent"


# ---------------------------------------------------------------------------
# rollback_config
# ---------------------------------------------------------------------------


class TestRollbackConfig:
    def test_requires_checkpoint_id(self, tmp_config):
        from familiar.skills.self_management.skill import rollback_config

        result = rollback_config({})
        assert "error" in result

    def test_not_found_returns_error(self, tmp_config):
        from familiar.skills.self_management.skill import rollback_config

        result = rollback_config({"checkpoint_id": "nonexistent_123"})
        assert "error" in result

    def test_returns_confirmation_sentinel(self, tmp_config):
        config_file, checkpoints_dir = tmp_config
        checkpoints_dir.mkdir(parents=True, exist_ok=True)

        ck = {
            "id": "20260312_120000",
            "reason": "before change",
            "timestamp": "2026-03-12T12:00:00+00:00",
            "config": {"agent": {"name": "OldName"}},
        }
        (checkpoints_dir / "20260312_120000.json").write_text(json.dumps(ck))

        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import rollback_config

        result = rollback_config({"checkpoint_id": "20260312_120000"})
        assert isinstance(result, ConfirmationRequired)
        assert result.risk == "high"

    def test_restores_config_when_confirmed(self, tmp_config):
        config_file, checkpoints_dir = tmp_config
        checkpoints_dir.mkdir(parents=True, exist_ok=True)

        ck = {
            "id": "20260312_120000",
            "reason": "pre-change",
            "timestamp": "2026-03-12T12:00:00+00:00",
            "config": {"agent": {"name": "OldName", "security_mode": "paranoid"}},
        }
        (checkpoints_dir / "20260312_120000.json").write_text(json.dumps(ck))

        from familiar.skills.self_management.skill import rollback_config

        result = rollback_config({"checkpoint_id": "20260312_120000", "_confirmed": True})
        assert result.get("restored") is True

        with open(config_file) as f:
            written = yaml.safe_load(f)
        assert written["agent"]["name"] == "OldName"

    def test_saves_safety_checkpoint_before_restoring(self, tmp_config):
        config_file, checkpoints_dir = tmp_config
        checkpoints_dir.mkdir(parents=True, exist_ok=True)

        ck = {
            "id": "20260312_120000",
            "reason": "test",
            "timestamp": "2026-03-12T12:00:00+00:00",
            "config": {"agent": {"name": "OldName"}},
        }
        (checkpoints_dir / "20260312_120000.json").write_text(json.dumps(ck))

        from familiar.skills.self_management.skill import rollback_config

        result = rollback_config({"checkpoint_id": "20260312_120000", "_confirmed": True})
        assert "safety_checkpoint_id" in result
        safety_path = checkpoints_dir / f"{result['safety_checkpoint_id']}.json"
        assert safety_path.exists()


# ---------------------------------------------------------------------------
# apply_update
# ---------------------------------------------------------------------------


class TestApplyUpdate:
    def test_up_to_date_no_confirmation(self, tmp_config):
        from familiar.skills.self_management.skill import apply_update

        with patch(
            "familiar.skills.self_management.skill._pip_dry_run",
            return_value=(False, "Already up to date."),
        ):
            result = apply_update({"channel": "stable"})
        assert result.get("up_to_date") is True
        # No confirmation sentinel — already up to date
        assert "message" in result

    def test_returns_confirmation_when_update_available(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import apply_update

        with patch(
            "familiar.skills.self_management.skill._pip_dry_run",
            return_value=(True, "Would install familiar-agent-1.15.99"),
        ):
            result = apply_update({"channel": "stable"})
        assert isinstance(result, ConfirmationRequired)
        assert "familiar-agent-1.15.99" in result.preview

    def test_runs_pip_on_confirmation(self, tmp_config):
        from familiar.skills.self_management.skill import apply_update

        mock_run = MagicMock()
        mock_run.return_value.returncode = 0
        mock_run.return_value.stdout = "Successfully installed familiar-agent-1.15.99"
        mock_run.return_value.stderr = ""

        with patch("familiar.skills.self_management.skill.subprocess.run", mock_run):
            result = apply_update({"channel": "stable", "_confirmed": True})

        assert result.get("updated") is True
        call_args = mock_run.call_args[0][0]
        assert "install" in call_args
        assert "--upgrade" in call_args

    def test_channel_all_uses_full_extra(self, tmp_config):
        from familiar.skills.self_management.skill import apply_update

        mock_run = MagicMock()
        mock_run.return_value.returncode = 0
        mock_run.return_value.stdout = "ok"
        mock_run.return_value.stderr = ""

        with patch("familiar.skills.self_management.skill.subprocess.run", mock_run):
            apply_update({"channel": "all", "_confirmed": True})

        call_args = mock_run.call_args[0][0]
        assert any("full" in arg for arg in call_args)

    def test_pip_failure_returns_updated_false(self, tmp_config):
        from familiar.skills.self_management.skill import apply_update

        mock_run = MagicMock()
        mock_run.return_value.returncode = 1
        mock_run.return_value.stdout = ""
        mock_run.return_value.stderr = "ERROR: could not install"

        with patch("familiar.skills.self_management.skill.subprocess.run", mock_run):
            result = apply_update({"channel": "stable", "_confirmed": True})

        assert result.get("updated") is False


# ---------------------------------------------------------------------------
# restart_agent
# ---------------------------------------------------------------------------


class TestRestartAgent:
    def test_returns_confirmation_sentinel(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import restart_agent

        result = restart_agent({"reason": "applying config changes"})
        assert isinstance(result, ConfirmationRequired)

    def test_preview_shows_reason(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import restart_agent

        result = restart_agent({"reason": "new model loaded"})
        assert isinstance(result, ConfirmationRequired)
        assert "new model loaded" in result.preview

    def test_preview_shows_restart_method(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import restart_agent

        result = restart_agent({})
        assert isinstance(result, ConfirmationRequired)
        # Method should appear in preview
        assert any(m in result.preview for m in ("systemd", "supervisord", "direct"))

    def test_execv_called_when_confirmed_direct(self, tmp_config):
        from familiar.skills.self_management.skill import restart_agent

        with patch(
            "familiar.skills.self_management.skill._detect_restart_method",
            return_value="direct (os.execv)",
        ):
            with patch("familiar.skills.self_management.skill.os.execv") as mock_execv:
                restart_agent({"reason": "test", "_confirmed": True})
        mock_execv.assert_called_once()

    def test_systemctl_called_when_confirmed_systemd(self, tmp_config):
        from familiar.skills.self_management.skill import restart_agent

        with patch(
            "familiar.skills.self_management.skill._detect_restart_method",
            return_value="systemd",
        ):
            with patch("familiar.skills.self_management.skill.subprocess.Popen") as mock_popen:
                result = restart_agent({"reason": "test", "_confirmed": True})
        assert result.get("restarting") is True
        call_args = mock_popen.call_args[0][0]
        assert "systemctl" in call_args
        assert "restart" in call_args


# ---------------------------------------------------------------------------
# TOOLS registry
# ---------------------------------------------------------------------------


class TestSelfManagementToolsRegistry:
    def test_tools_list_exists(self):
        from familiar.skills.self_management.skill import TOOLS

        assert isinstance(TOOLS, list)
        assert len(TOOLS) == 6

    def test_all_tools_have_required_fields(self):
        from familiar.skills.self_management.skill import TOOLS

        for tool in TOOLS:
            assert "name" in tool
            assert "description" in tool
            assert "input_schema" in tool
            assert callable(tool["handler"])
            assert tool.get("category") == "self_management"

    def test_all_expected_tools_present(self):
        from familiar.skills.self_management.skill import TOOLS

        names = {t["name"] for t in TOOLS}
        expected = {
            "show_self_status",
            "list_config_checkpoints",
            "propose_config_change",
            "rollback_config",
            "apply_update",
            "restart_agent",
        }
        assert names == expected

    def test_read_only_tools_no_required_params(self):
        """show_self_status and list_config_checkpoints take no required params."""
        from familiar.skills.self_management.skill import TOOLS

        read_only = {
            t["name"]: t
            for t in TOOLS
            if t["name"] in ("show_self_status", "list_config_checkpoints")
        }
        for tool in read_only.values():
            assert tool["input_schema"]["required"] == []


# ---------------------------------------------------------------------------
# Editable sections guard
# ---------------------------------------------------------------------------


class TestEditableSectionsGuard:
    def test_agent_name_editable(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change({"key": "agent.name", "value": "X", "reason": "test"})
        assert isinstance(result, ConfirmationRequired)

    def test_llm_model_editable(self, tmp_config):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.self_management.skill import propose_config_change

        result = propose_config_change(
            {"key": "llm.model", "value": "qwen3", "reason": "switching model"}
        )
        assert isinstance(result, ConfirmationRequired)

    def test_internal_state_not_editable(self, tmp_config):
        from familiar.skills.self_management.skill import propose_config_change

        for blocked in ("agent._internal", "llm._api_secret", "channels.discord.token"):
            result = propose_config_change({"key": blocked, "value": "x", "reason": "test"})
            assert "error" in result, f"{blocked} should be blocked"


# ---------------------------------------------------------------------------
# Job class weights
# ---------------------------------------------------------------------------


class TestJobClassWeights:
    def test_helper_has_self_management_weights(self):
        from familiar.jobs.helper import HELPER

        weights = HELPER.skill_weights
        for tool in ("show_self_status", "propose_config_change", "apply_update", "restart_agent"):
            assert tool in weights, f"{tool} missing from Helper skill_weights"

    def test_network_admin_has_self_management_weights(self):
        from familiar.jobs.network_admin import NETWORK_ADMIN

        weights = NETWORK_ADMIN.skill_weights
        for tool in ("show_self_status", "propose_config_change", "restart_agent"):
            assert tool in weights, f"{tool} missing from Network Admin skill_weights"
