# 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 host_setup skill.

Covers: detect_system_info, check_installed, list_installed,
install_package, download_file, setup_binary, remove_package,
run_setup_script, and TOOLS registry.
"""

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

import pytest

# ---------------------------------------------------------------------------
# detect_system_info
# ---------------------------------------------------------------------------


class TestDetectSystemInfo:
    def test_returns_os_fields(self):
        from familiar.skills.host_setup.skill import detect_system_info

        result = detect_system_info({})
        assert "os" in result
        assert "arch" in result
        assert "python_version" in result

    def test_returns_package_manager(self):
        from familiar.skills.host_setup.skill import detect_system_info

        result = detect_system_info({})
        # package_manager may be None if nothing detected, but field must exist
        assert "package_manager" in result
        assert "package_manager_name" in result

    def test_returns_install_methods_list(self):
        from familiar.skills.host_setup.skill import detect_system_info

        result = detect_system_info({})
        assert isinstance(result.get("install_methods"), list)
        # binary is always available
        assert "binary" in result["install_methods"]

    def test_returns_disk_info(self):
        from familiar.skills.host_setup.skill import detect_system_info

        result = detect_system_info({})
        assert "disk_free_gb" in result
        assert result["disk_free_gb"] >= 0

    def test_returns_python_executable(self):
        import sys

        from familiar.skills.host_setup.skill import detect_system_info

        result = detect_system_info({})
        assert result["python"] == sys.executable


# ---------------------------------------------------------------------------
# check_installed
# ---------------------------------------------------------------------------


class TestCheckInstalled:
    def test_requires_name(self):
        from familiar.skills.host_setup.skill import check_installed

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

    def test_finds_python(self):
        from familiar.skills.host_setup.skill import check_installed

        result = check_installed({"name": "python3"})
        assert result["installed"] is True
        assert result["path"] is not None

    def test_not_installed(self):
        from familiar.skills.host_setup.skill import check_installed

        result = check_installed({"name": "this_tool_does_not_exist_xyz"})
        assert result["installed"] is False
        assert result["path"] is None
        assert result["version"] is None

    def test_returns_version_string(self):
        from familiar.skills.host_setup.skill import check_installed

        result = check_installed({"name": "python3"})
        if result["installed"]:
            assert result["version"] is not None
            assert len(result["version"]) > 0


# ---------------------------------------------------------------------------
# list_installed
# ---------------------------------------------------------------------------


class TestListInstalled:
    def test_no_pm_returns_error(self):
        from familiar.skills.host_setup.skill import list_installed

        with patch("familiar.skills.host_setup.skill._detect_package_manager", return_value=None):
            result = list_installed({})
        assert "error" in result

    def test_returns_list_shape(self):
        from familiar.skills.host_setup.skill import list_installed

        mock_rc = (0, "pkg1 1.0\npkg2 2.0\n", "")
        with patch("familiar.skills.host_setup.skill._run", return_value=mock_rc):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = list_installed({"filter": "python"})
        assert isinstance(result["packages"], list)
        assert "count" in result


# ---------------------------------------------------------------------------
# install_package
# ---------------------------------------------------------------------------


class TestInstallPackage:
    def test_requires_packages(self):
        from familiar.skills.host_setup.skill import install_package

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

    def test_returns_confirmation_sentinel(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import install_package

        dry_output = (0, "Would install: nmap-7.92", "")
        with patch("familiar.skills.host_setup.skill._run", return_value=dry_output):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = install_package({"packages": "nmap", "reason": "network scanning"})
        assert isinstance(result, ConfirmationRequired)

    def test_preview_shows_package_name(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import install_package

        dry_output = (0, "Would install: nmap", "")
        with patch("familiar.skills.host_setup.skill._run", return_value=dry_output):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = install_package({"packages": "nmap", "reason": "test"})
        assert isinstance(result, ConfirmationRequired)
        assert "nmap" in result.preview

    def test_executes_on_confirmation(self):
        from familiar.skills.host_setup.skill import install_package

        mock_run = MagicMock(return_value=(0, "Installed: nmap", ""))
        with patch("familiar.skills.host_setup.skill._run", mock_run):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = install_package({"packages": "nmap", "reason": "test", "_confirmed": True})
        assert result.get("installed") is True
        assert "nmap" in result["packages"]

    def test_pip_manager(self):
        from familiar.skills.host_setup.skill import install_package

        mock_run = MagicMock(return_value=(0, "Successfully installed requests", ""))
        with patch("familiar.skills.host_setup.skill._run", mock_run):
            result = install_package(
                {"packages": "requests", "manager": "pip", "reason": "test", "_confirmed": True}
            )
        assert result.get("installed") is True
        call_cmd = mock_run.call_args[0][0]
        assert "pip" in " ".join(call_cmd)

    def test_list_of_packages(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import install_package

        dry_output = (0, "", "")
        with patch("familiar.skills.host_setup.skill._run", return_value=dry_output):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = install_package(
                    {"packages": ["nmap", "iperf3"], "reason": "network tools"}
                )
        assert isinstance(result, ConfirmationRequired)
        assert "nmap" in result.preview
        assert "iperf3" in result.preview

    def test_install_failure_returns_installed_false(self):
        from familiar.skills.host_setup.skill import install_package

        mock_run = MagicMock(return_value=(1, "", "Error: package not found"))
        with patch("familiar.skills.host_setup.skill._run", mock_run):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = install_package(
                    {"packages": "nonexistent_pkg", "reason": "test", "_confirmed": True}
                )
        assert result.get("installed") is False


# ---------------------------------------------------------------------------
# download_file
# ---------------------------------------------------------------------------


class TestDownloadFile:
    def test_requires_url(self):
        from familiar.skills.host_setup.skill import download_file

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

    def test_returns_confirmation_sentinel(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import download_file

        result = download_file({"url": "https://example.com/file.tar.gz", "reason": "test"})
        assert isinstance(result, ConfirmationRequired)

    def test_preview_shows_url_and_dest(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import download_file

        result = download_file({"url": "https://example.com/file.tar.gz", "reason": "test"})
        assert isinstance(result, ConfirmationRequired)
        assert "example.com" in result.preview

    def test_checksum_note_in_preview(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import download_file

        result = download_file(
            {
                "url": "https://example.com/file.bin",
                "checksum": "a" * 64,
                "reason": "test",
            }
        )
        assert isinstance(result, ConfirmationRequired)
        assert "SHA-256" in result.preview

    def test_download_writes_file_when_confirmed(self, tmp_path):
        from familiar.skills.host_setup.skill import download_file

        content = b"fake binary content"
        dest = tmp_path / "testfile.bin"

        mock_response = MagicMock()
        mock_response.headers = {"content-length": str(len(content))}
        mock_response.raise_for_status = MagicMock()
        mock_response.iter_bytes = MagicMock(return_value=[content])
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)

        mock_client = MagicMock()
        mock_client.stream = MagicMock(return_value=mock_response)
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)

        with patch("httpx.Client", return_value=mock_client):
            result = download_file(
                {
                    "url": "https://example.com/file.bin",
                    "destination": str(dest),
                    "reason": "test",
                    "_confirmed": True,
                }
            )

        assert result.get("downloaded") is True
        assert "sha256" in result

    def test_checksum_mismatch_deletes_file(self, tmp_path):
        from familiar.skills.host_setup.skill import download_file

        content = b"corrupted content"
        dest = tmp_path / "bad.bin"

        mock_response = MagicMock()
        mock_response.headers = {}
        mock_response.raise_for_status = MagicMock()
        mock_response.iter_bytes = MagicMock(return_value=[content])
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)

        mock_client = MagicMock()
        mock_client.stream = MagicMock(return_value=mock_response)
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)

        with patch("httpx.Client", return_value=mock_client):
            result = download_file(
                {
                    "url": "https://example.com/file.bin",
                    "destination": str(dest),
                    "checksum": "0" * 64,  # wrong checksum
                    "reason": "test",
                    "_confirmed": True,
                }
            )

        assert result.get("downloaded") is False
        assert "mismatch" in result["error"].lower()
        assert not dest.exists()  # file cleaned up


# ---------------------------------------------------------------------------
# setup_binary
# ---------------------------------------------------------------------------


class TestSetupBinary:
    def test_requires_url(self):
        from familiar.skills.host_setup.skill import setup_binary

        result = setup_binary({"name": "mytool"})
        assert "error" in result

    def test_returns_confirmation_sentinel(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import setup_binary

        result = setup_binary(
            {"url": "https://example.com/mytool", "name": "mytool", "reason": "test"}
        )
        assert isinstance(result, ConfirmationRequired)

    def test_preview_shows_install_path(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import setup_binary

        result = setup_binary(
            {"url": "https://example.com/mytool", "name": "mytool", "reason": "test"}
        )
        assert isinstance(result, ConfirmationRequired)
        assert "mytool" in result.preview
        assert ".local/bin" in result.preview

    def test_installs_and_makes_executable(self, tmp_path):
        from familiar.skills.host_setup.skill import setup_binary

        content = b"#!/bin/bash\necho hello"
        install_dir = tmp_path / "bin"

        mock_response = MagicMock()
        mock_response.headers = {}
        mock_response.raise_for_status = MagicMock()
        mock_response.iter_bytes = MagicMock(return_value=[content])
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)

        mock_client = MagicMock()
        mock_client.stream = MagicMock(return_value=mock_response)
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)

        with patch("httpx.Client", return_value=mock_client):
            result = setup_binary(
                {
                    "url": "https://example.com/mytool",
                    "name": "mytool",
                    "destination": str(install_dir),
                    "reason": "test",
                    "_confirmed": True,
                }
            )

        assert result.get("installed") is True
        binary = install_dir / "mytool"
        assert binary.exists()
        assert binary.stat().st_mode & 0o111  # executable bits set

    def test_checksum_verified_on_install(self, tmp_path):
        from familiar.skills.host_setup.skill import setup_binary

        content = b"binary content"
        expected = hashlib.sha256(content).hexdigest()
        install_dir = tmp_path / "bin"

        mock_response = MagicMock()
        mock_response.headers = {}
        mock_response.raise_for_status = MagicMock()
        mock_response.iter_bytes = MagicMock(return_value=[content])
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)

        mock_client = MagicMock()
        mock_client.stream = MagicMock(return_value=mock_response)
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)

        with patch("httpx.Client", return_value=mock_client):
            result = setup_binary(
                {
                    "url": "https://example.com/mytool",
                    "name": "mytool",
                    "destination": str(install_dir),
                    "checksum": expected,
                    "reason": "test",
                    "_confirmed": True,
                }
            )

        assert result.get("installed") is True
        assert result["checksum_verified"] is True


# ---------------------------------------------------------------------------
# remove_package
# ---------------------------------------------------------------------------


class TestRemovePackage:
    def test_requires_packages(self):
        from familiar.skills.host_setup.skill import remove_package

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

    def test_returns_high_risk_confirmation(self):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import remove_package

        with patch("familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"):
            result = remove_package({"packages": "nmap", "reason": "cleanup"})
        assert isinstance(result, ConfirmationRequired)
        assert result.risk == "high"

    def test_executes_on_confirmation(self):
        from familiar.skills.host_setup.skill import remove_package

        mock_run = MagicMock(return_value=(0, "Removed: nmap", ""))
        with patch("familiar.skills.host_setup.skill._run", mock_run):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = remove_package(
                    {"packages": "nmap", "reason": "cleanup", "_confirmed": True}
                )
        assert result.get("removed") is True


# ---------------------------------------------------------------------------
# run_setup_script
# ---------------------------------------------------------------------------


class TestRunSetupScript:
    def test_requires_script_path(self):
        from familiar.skills.host_setup.skill import run_setup_script

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

    def test_not_found_returns_error(self):
        from familiar.skills.host_setup.skill import run_setup_script

        result = run_setup_script({"script_path": "/nonexistent/setup.sh"})
        assert "error" in result

    def test_returns_high_risk_confirmation(self, tmp_path):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import run_setup_script

        script = tmp_path / "install.sh"
        script.write_text("#!/bin/bash\necho hello\n")

        result = run_setup_script({"script_path": str(script), "reason": "test"})
        assert isinstance(result, ConfirmationRequired)
        assert result.risk == "high"

    def test_preview_shows_script_contents(self, tmp_path):
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import run_setup_script

        script = tmp_path / "install.sh"
        script.write_text("#!/bin/bash\necho 'Setting up tool'\n")

        result = run_setup_script({"script_path": str(script), "reason": "test"})
        assert isinstance(result, ConfirmationRequired)
        assert "Setting up tool" in result.preview

    def test_executes_script_when_confirmed(self, tmp_path):
        from familiar.skills.host_setup.skill import run_setup_script

        script = tmp_path / "install.sh"
        script.write_text("#!/bin/bash\necho 'done'\n")

        mock_run = MagicMock(return_value=(0, "done", ""))
        with patch("familiar.skills.host_setup.skill._run", mock_run):
            result = run_setup_script(
                {"script_path": str(script), "reason": "test", "_confirmed": True}
            )
        assert result.get("success") is True

    def test_invalid_interpreter_rejected(self, tmp_path):
        from familiar.skills.host_setup.skill import run_setup_script

        script = tmp_path / "install.sh"
        script.write_text("echo hello")

        result = run_setup_script(
            {"script_path": str(script), "interpreter": "zsh", "reason": "test"}
        )
        assert "error" in result


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


class TestHostSetupToolsRegistry:
    def test_tools_list_exists(self):
        from familiar.skills.host_setup.skill import TOOLS

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

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

        for tool in TOOLS:
            assert "name" in tool, f"missing name in {tool}"
            assert "description" in tool
            assert "input_schema" in tool
            assert callable(tool["handler"])
            assert tool.get("category") == "host_setup"

    def test_read_only_tools_no_required_params(self):
        from familiar.skills.host_setup.skill import TOOLS

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

    def test_all_expected_tool_names_present(self):
        from familiar.skills.host_setup.skill import TOOLS

        names = {t["name"] for t in TOOLS}
        expected = {
            "detect_system_info",
            "check_installed",
            "list_installed",
            "install_package",
            "download_file",
            "setup_binary",
            "remove_package",
            "run_setup_script",
        }
        assert names == expected


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


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

        weights = HELPER.skill_weights
        for tool in (
            "detect_system_info",
            "check_installed",
            "install_package",
            "download_file",
            "setup_binary",
        ):
            assert tool in weights, f"{tool} missing from Helper"

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

        weights = NETWORK_ADMIN.skill_weights
        for tool in ("check_installed", "install_package", "download_file"):
            assert tool in weights, f"{tool} missing from Network Admin"

    def test_security_analyst_has_host_setup_weights(self):
        from familiar.jobs.security_analyst import SECURITY_ANALYST

        weights = SECURITY_ANALYST.skill_weights
        assert "check_installed" in weights
        assert "install_package" in weights


# ---------------------------------------------------------------------------
# Integration: detect then install flow (no real subprocess)
# ---------------------------------------------------------------------------


class TestDetectThenInstallFlow:
    """Simulate the 'what do I have, now install what I need' workflow."""

    def test_check_then_install_skips_if_present(self):
        """If check_installed finds the tool, the agent can skip install_package."""
        from familiar.skills.host_setup.skill import check_installed, install_package

        # check_installed returns True for python3
        check = check_installed({"name": "python3"})
        assert check["installed"] is True

        # Since it's installed, we would not call install_package
        # This validates the workflow: check first, act only if missing

    def test_package_string_normalised_from_comma(self):
        """'nmap, iperf3' should be parsed as two packages."""
        from familiar.core.confirmations import ConfirmationRequired
        from familiar.skills.host_setup.skill import install_package

        dry_output = (0, "", "")
        with patch("familiar.skills.host_setup.skill._run", return_value=dry_output):
            with patch(
                "familiar.skills.host_setup.skill._detect_package_manager", return_value="dnf"
            ):
                result = install_package({"packages": "nmap, iperf3", "reason": "network tools"})
        assert isinstance(result, ConfirmationRequired)
        assert "nmap" in result.preview
        assert "iperf3" in result.preview
