# 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

"""Tests for self-hosted service provisioning (specs, manager, compose)."""

import json
import os
import socket
import subprocess
import tempfile
from pathlib import Path
from unittest import mock

import pytest
import yaml

from familiar.services.manager import ContainerRuntime
from familiar.services.specs import SERVICE_SPECS, ServiceSpec, ServiceType

# =============================================================================
# ServiceSpec tests
# =============================================================================


class TestServiceSpec:
    """Tests for ServiceSpec dataclass."""

    def test_all_specs_registered(self):
        assert len(SERVICE_SPECS) == 21

    def test_spec_keys(self):
        expected = {
            "email_server",
            "gitea",
            "mealie",
            "joplin",
            "pihole",
            "nextcloud",
            "jellyfin",
            "searxng",
            "gotosocial",
            "bluesky_pds",
            "rendezvous",
            # Security & monitoring
            "cockpit",
            "netdata",
            "uptime_kuma",
            "wazuh_manager",
            "wazuh_indexer",
            "wazuh_dashboard",
            "spiderfoot",
            "crowdsec",
            # Infrastructure
            "caddy",
            # Password management
            "vaultwarden",
        }
        assert set(SERVICE_SPECS.keys()) == expected

    def test_email_is_container(self):
        spec = SERVICE_SPECS["email_server"]
        assert spec.service_type == ServiceType.CONTAINER
        assert "stalwart" in spec.image
        assert spec.health_check_path == "/healthz/live"
        assert spec.connect_key == "email"

    def test_container_specs_have_images(self):
        for key, spec in SERVICE_SPECS.items():
            if spec.service_type == ServiceType.CONTAINER:
                assert spec.image, f"{key} missing image"
                assert spec.ports, f"{key} missing ports"
                assert spec.default_container_name.startswith("familiar-"), (
                    f"{key} bad container name"
                )

    def test_all_specs_have_health_check(self):
        for key, spec in SERVICE_SPECS.items():
            if spec.service_type == ServiceType.CONTAINER:
                assert spec.health_check_path, f"{key} missing health_check_path"
                assert spec.health_check_port > 0, f"{key} missing health_check_port"

    def test_volume_base(self):
        spec = SERVICE_SPECS["gitea"]
        assert spec.volume_base == "~/.familiar/services/gitea"

    def test_get_resolved_volumes(self):
        spec = SERVICE_SPECS["gitea"]
        resolved = spec.get_resolved_volumes("/home/testuser")
        for host_path in resolved:
            assert host_path.startswith("/home/testuser")
            assert "~" not in host_path

    def test_to_dict(self):
        spec = SERVICE_SPECS["mealie"]
        d = spec.to_dict()
        assert d["key"] == "mealie"
        assert d["display_name"] == "Mealie"
        assert d["service_type"] == "container"
        assert isinstance(d["ports"], dict)
        assert isinstance(d["job_classes"], list)

    def test_default_container_name_auto_generated(self):
        spec = ServiceSpec(
            key="test_svc",
            display_name="Test",
            description="A test",
            icon="fa-cog",
            service_type=ServiceType.CONTAINER,
            image="test:latest",
        )
        assert spec.default_container_name == "familiar-test_svc"

    def test_native_no_auto_container_name(self):
        spec = ServiceSpec(
            key="native_test",
            display_name="Native Test",
            description="A native test",
            icon="fa-cog",
            service_type=ServiceType.NATIVE,
        )
        assert spec.default_container_name == ""

    def test_gitea_ports(self):
        spec = SERVICE_SPECS["gitea"]
        assert 3000 in spec.ports
        assert 2222 in spec.ports

    def test_pihole_ports(self):
        spec = SERVICE_SPECS["pihole"]
        assert 8053 in spec.ports
        assert 5353 in spec.ports

    def test_job_classes_are_lists(self):
        for key, spec in SERVICE_SPECS.items():
            assert isinstance(spec.job_classes, list), f"{key} job_classes not a list"

    def test_depends_on_defaults_empty(self):
        spec = SERVICE_SPECS["netdata"]
        assert spec.depends_on == []

    def test_wazuh_indexer_depends_on_manager(self):
        spec = SERVICE_SPECS["wazuh_indexer"]
        assert "wazuh_manager" in spec.depends_on

    def test_wazuh_dashboard_depends_on_both(self):
        spec = SERVICE_SPECS["wazuh_dashboard"]
        assert "wazuh_manager" in spec.depends_on
        assert "wazuh_indexer" in spec.depends_on

    def test_cockpit_is_native_with_systemctl_unit(self):
        spec = SERVICE_SPECS["cockpit"]
        assert spec.service_type == ServiceType.NATIVE
        assert spec.systemctl_unit == "cockpit.socket"
        assert spec.image == ""

    def test_native_specs_no_systemctl_unit_by_default(self):
        spec = SERVICE_SPECS["rendezvous"]
        assert spec.systemctl_unit is None

    def test_to_dict_includes_new_fields(self):
        spec = SERVICE_SPECS["wazuh_indexer"]
        d = spec.to_dict()
        assert "depends_on" in d
        assert "systemctl_unit" in d
        assert "wazuh_manager" in d["depends_on"]

    def test_spiderfoot_job_classes(self):
        spec = SERVICE_SPECS["spiderfoot"]
        assert "osint_researcher" in spec.job_classes
        assert "security_analyst" in spec.job_classes

    def test_wazuh_suite_all_in_security_analyst(self):
        for key in ("wazuh_manager", "wazuh_indexer", "wazuh_dashboard"):
            assert "security_analyst" in SERVICE_SPECS[key].job_classes, (
                f"{key} missing security_analyst job class"
            )

    def test_network_admin_services(self):
        for key in ("cockpit", "netdata", "uptime_kuma"):
            assert "network_admin" in SERVICE_SPECS[key].job_classes, (
                f"{key} missing network_admin job class"
            )

    def test_wazuh_manager_min_ram(self):
        spec = SERVICE_SPECS["wazuh_manager"]
        assert spec.min_ram_gb >= 2.0

    def test_uptime_kuma_health_check(self):
        spec = SERVICE_SPECS["uptime_kuma"]
        assert spec.health_check_port == 3001
        assert "heartbeat" in spec.health_check_path


# =============================================================================
# ServiceManager tests
# =============================================================================


class TestServiceManager:
    """Tests for ServiceManager with mocked subprocess calls."""

    @pytest.fixture()
    def manager(self, tmp_path):
        """Create a ServiceManager with temp dirs."""
        with (
            mock.patch("familiar.services.manager.SERVICES_DIR", tmp_path),
            mock.patch("familiar.services.manager.STATE_FILE", tmp_path / "state.json"),
        ):
            from familiar.services.manager import ServiceManager

            mgr = ServiceManager()
            yield mgr

    def test_detect_runtime_podman(self, manager):
        with (
            mock.patch("shutil.which", return_value="/usr/bin/podman"),
            mock.patch("subprocess.run") as mock_run,
        ):
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="", stderr=""
            )
            manager._runtime = None  # Reset
            rt = manager.detect_runtime()
            assert rt.value == "podman"

    def test_detect_runtime_docker_fallback(self, manager):
        def which_side_effect(name):
            if name == "podman":
                return None
            return "/usr/bin/docker"

        with (
            mock.patch("shutil.which", side_effect=which_side_effect),
            mock.patch("subprocess.run") as mock_run,
        ):
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="", stderr=""
            )
            manager._runtime = None
            rt = manager.detect_runtime()
            assert rt.value == "docker"

    def test_detect_runtime_none(self, manager):
        with mock.patch("shutil.which", return_value=None):
            manager._runtime = None
            rt = manager.detect_runtime()
            assert rt.value == "none"

    def test_get_runtime_info_none(self, manager):
        manager._runtime = None
        with mock.patch("shutil.which", return_value=None):
            info = manager.get_runtime_info()
            assert info["runtime"] == "none"

    def test_get_runtime_info_podman(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        def run_side_effect(cmd, **kwargs):
            if "version" in cmd:
                return subprocess.CompletedProcess(
                    args=cmd, returncode=0, stdout="5.3.1", stderr=""
                )
            if "info" in cmd:
                return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="true", stderr="")
            return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")

        with mock.patch("subprocess.run", side_effect=run_side_effect):
            info = manager.get_runtime_info()
            assert info["runtime"] == "podman"
            assert info["version"] == "5.3.1"
            assert info["rootless"] is True

    def test_check_port_available(self, manager):
        # Use a high ephemeral port that's very likely free
        assert manager._check_port_available(59123) is True

    def test_check_port_conflicts(self, manager):
        spec = SERVICE_SPECS["gitea"]
        # With all ports free, no conflicts
        with mock.patch.object(manager, "_check_port_available", return_value=True):
            conflicts = manager.check_port_conflicts(spec)
            assert conflicts == []

        with mock.patch.object(manager, "_check_port_available", return_value=False):
            conflicts = manager.check_port_conflicts(spec)
            assert 3000 in conflicts

    def test_pull_image_no_runtime(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.NONE
        result = manager.pull_image("gitea")
        assert result["success"] is False
        assert "No container runtime" in result["error"]

    def test_pull_image_native(self, manager):
        result = manager.pull_image("rendezvous")
        assert result["success"] is True

    def test_pull_image_success(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="pulled", stderr=""
            )
            result = manager.pull_image("gitea")
            assert result["success"] is True

    def test_pull_image_failure(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=1, stdout="", stderr="not found"
            )
            result = manager.pull_image("gitea")
            assert result["success"] is False

    def test_provision_unknown_service(self, manager):
        result = manager.provision("nonexistent")
        assert result["success"] is False
        assert "Unknown service" in result["error"]

    def test_provision_native_service(self, manager):
        result = manager.provision("rendezvous")
        assert result["success"] is True
        assert "native" in result["message"].lower()

    def test_provision_container_success(self, manager, tmp_path):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="abc123container", stderr=""
            )
            result = manager.provision("gitea")
            assert result["success"] is True
            assert result.get("container_id")

    def test_start_native(self, manager):
        result = manager.start("rendezvous")
        assert result["success"] is True
        state = manager._get_or_create_state("rendezvous")
        assert state.status == "running"

    def test_stop_native(self, manager):
        manager.start("rendezvous")
        result = manager.stop("rendezvous")
        assert result["success"] is True
        state = manager._get_or_create_state("rendezvous")
        assert state.status == "stopped"

    def test_start_container(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="", stderr=""
            )
            result = manager.start("gitea")
            assert result["success"] is True
            assert "url" in result

    def test_stop_container(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="", stderr=""
            )
            result = manager.stop("gitea")
            assert result["success"] is True

    def test_restart_container(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="", stderr=""
            )
            result = manager.restart("gitea")
            assert result["success"] is True

    def test_remove_container(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="", stderr=""
            )
            result = manager.remove("gitea")
            assert result["success"] is True

    def test_remove_native(self, manager):
        manager.start("rendezvous")
        result = manager.remove("rendezvous")
        assert result["success"] is True
        state = manager._get_or_create_state("rendezvous")
        assert state.status == "not_installed"

    def test_get_status_no_runtime(self, manager):
        manager._runtime = None
        with mock.patch("shutil.which", return_value=None):
            status = manager.get_status("gitea")
            assert status["key"] == "gitea"

    def test_get_status_running(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        inspect_data = [{"Id": "abc123", "State": {"Status": "running"}}]
        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout=json.dumps(inspect_data), stderr=""
            )
            status = manager.get_status("gitea")
            assert status["status"] == "running"
            assert status["url"]

    def test_get_status_stopped(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        inspect_data = [{"Id": "abc123", "State": {"Status": "exited"}}]
        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout=json.dumps(inspect_data), stderr=""
            )
            status = manager.get_status("gitea")
            assert status["status"] == "stopped"

    def test_get_all_statuses(self, manager):
        manager._runtime = None
        with mock.patch("shutil.which", return_value=None):
            statuses = manager.get_all_statuses()
            assert len(statuses) == 21
            assert all(isinstance(s, dict) for s in statuses)

    def test_get_logs_no_runtime(self, manager):
        manager._runtime = None
        with mock.patch("shutil.which", return_value=None):
            result = manager.get_logs("gitea")
            assert result["success"] is False

    def test_get_logs_native(self, manager):
        result = manager.get_logs("rendezvous")
        assert result["success"] is True
        assert "Native" in result["logs"] or "native" in result["logs"].lower()

    def test_get_logs_success(self, manager):
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        manager._runtime_path = "/usr/bin/podman"

        with mock.patch("subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=0, stdout="log line 1\nlog line 2", stderr=""
            )
            result = manager.get_logs("gitea", tail=50)
            assert result["success"] is True
            assert "log line" in result["logs"]

    def test_state_persistence(self, manager, tmp_path):
        with (
            mock.patch("familiar.services.manager.STATE_FILE", tmp_path / "state.json"),
            mock.patch("familiar.services.manager.SERVICES_DIR", tmp_path),
        ):
            manager.start_native("rendezvous")

            state_file = tmp_path / "state.json"
            assert state_file.exists()

            data = json.loads(state_file.read_text())
            assert "rendezvous" in data
            assert data["rendezvous"]["status"] == "running"

    def test_health_check_unknown(self, manager):
        result = manager.health_check("nonexistent")
        assert result["healthy"] is False

    def test_health_check_no_endpoint(self, manager):
        with mock.patch.dict(
            SERVICE_SPECS,
            {
                "test": ServiceSpec(
                    key="test",
                    display_name="T",
                    description="T",
                    icon="fa-cog",
                    service_type=ServiceType.CONTAINER,
                    image="t:1",
                )
            },
        ):
            result = manager.health_check("test")
            assert result["healthy"] is False


# =============================================================================
# Bootstrap runtime tests
# =============================================================================


class TestBootstrapRuntime:
    """Tests for ServiceManager.bootstrap_runtime()."""

    @pytest.fixture()
    def manager(self, tmp_path):
        with (
            mock.patch("familiar.services.manager.SERVICES_DIR", tmp_path),
            mock.patch("familiar.services.manager.STATE_FILE", tmp_path / "state.json"),
        ):
            from familiar.services.manager import ServiceManager

            mgr = ServiceManager()
            yield mgr

    def test_already_installed(self, manager):
        """If Podman/Docker exists, return success without installing."""
        from familiar.services.manager import ContainerRuntime

        manager._runtime = ContainerRuntime.PODMAN
        result = manager.bootstrap_runtime()
        assert result["success"] is True
        assert result["already_installed"] is True

    def test_no_package_manager_linux(self, manager):
        """If no package manager found on Linux, return error."""
        manager._runtime = None
        with (
            mock.patch.object(type(manager), "_detect_platform", return_value="linux"),
            mock.patch("shutil.which", return_value=None),
        ):
            result = manager.bootstrap_runtime()
            assert result["success"] is False
            assert "No supported package manager" in result["error"]

    def test_install_podman_dnf(self, manager):
        """Install podman via dnf when no runtime exists."""
        manager._runtime = None
        call_count = [0]

        def which_side(name):
            if name in ("podman", "docker"):
                if name == "podman" and call_count[0] > 0:
                    return "/usr/bin/podman"
                return None
            if name == "dnf":
                return "/usr/bin/dnf"
            return None

        def run_side(cmd, **kwargs):
            if "install" in cmd:
                call_count[0] += 1
                return subprocess.CompletedProcess(
                    args=cmd, returncode=0, stdout="Installed!", stderr=""
                )
            if "info" in cmd:
                return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
            return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="")

        with (
            mock.patch.object(type(manager), "_detect_platform", return_value="linux"),
            mock.patch("shutil.which", side_effect=which_side),
            mock.patch("subprocess.run", side_effect=run_side),
        ):
            result = manager.bootstrap_runtime()
            assert result["success"] is True
            assert result["already_installed"] is False
            assert "podman" in result["message"].lower()

    def test_install_fails(self, manager):
        """Install command returns non-zero."""
        manager._runtime = None

        def which_side(name):
            if name in ("podman", "docker"):
                return None
            if name == "dnf":
                return "/usr/bin/dnf"
            return None

        with (
            mock.patch.object(type(manager), "_detect_platform", return_value="linux"),
            mock.patch("shutil.which", side_effect=which_side),
            mock.patch(
                "subprocess.run",
                return_value=subprocess.CompletedProcess(
                    args=[], returncode=1, stdout="", stderr="Permission denied"
                ),
            ),
        ):
            result = manager.bootstrap_runtime()
            assert result["success"] is False
            assert "failed" in result["error"].lower()

    def test_macos_brew_install(self, manager):
        """macOS: install via brew, then init+start machine."""
        manager._runtime = None
        call_count = [0]

        def which_side(name):
            if name in ("podman", "docker"):
                if name == "podman" and call_count[0] > 0:
                    return "/usr/local/bin/podman"
                return None
            if name == "brew":
                return "/usr/local/bin/brew"
            return None

        def run_side(cmd, **kwargs):
            if "install" in cmd:
                call_count[0] += 1
            return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="OK", stderr="")

        with (
            mock.patch.object(type(manager), "_detect_platform", return_value="darwin"),
            mock.patch("shutil.which", side_effect=which_side),
            mock.patch("subprocess.run", side_effect=run_side),
        ):
            result = manager.bootstrap_runtime()
            assert result["success"] is True
            assert result.get("platform") == "macos"

    def test_macos_no_brew(self, manager):
        """macOS without Homebrew returns helpful error."""
        manager._runtime = None
        with (
            mock.patch.object(type(manager), "_detect_platform", return_value="darwin"),
            mock.patch("shutil.which", return_value=None),
        ):
            result = manager.bootstrap_runtime()
            assert result["success"] is False
            assert "brew" in result["error"].lower() or "Homebrew" in result["error"]
            assert result.get("platform") == "macos"

    def test_windows_winget_install(self, manager):
        """Windows: install via winget."""
        manager._runtime = None
        call_count = [0]

        def which_side(name):
            if name in ("podman", "docker"):
                if name == "podman" and call_count[0] > 0:
                    return "C:\\Program Files\\Podman\\podman.exe"
                return None
            if name == "winget":
                return "C:\\Windows\\winget.exe"
            return None

        def run_side(cmd, **kwargs):
            if "install" in cmd or "winget" in cmd:
                call_count[0] += 1
            return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="OK", stderr="")

        with (
            mock.patch.object(type(manager), "_detect_platform", return_value="windows"),
            mock.patch("shutil.which", side_effect=which_side),
            mock.patch("subprocess.run", side_effect=run_side),
        ):
            result = manager.bootstrap_runtime()
            assert result["success"] is True
            assert result.get("platform") == "windows"

    def test_windows_no_pkg_manager(self, manager):
        """Windows without winget or choco returns download instructions."""
        manager._runtime = None
        with (
            mock.patch.object(type(manager), "_detect_platform", return_value="windows"),
            mock.patch("shutil.which", return_value=None),
        ):
            result = manager.bootstrap_runtime()
            assert result["success"] is False
            assert (
                "podman-desktop" in result["error"].lower() or "docker" in result["error"].lower()
            )
            assert result.get("platform") == "windows"


# =============================================================================
# Full server setup tests
# =============================================================================


class TestFullServerSetup:
    """Tests for ServiceManager.full_server_setup()."""

    @pytest.fixture()
    def manager(self, tmp_path):
        with (
            mock.patch("familiar.services.manager.SERVICES_DIR", tmp_path),
            mock.patch("familiar.services.manager.STATE_FILE", tmp_path / "state.json"),
        ):
            from familiar.services.manager import ContainerRuntime, ServiceManager

            mgr = ServiceManager()
            # Simulate Podman already installed
            mgr._runtime = ContainerRuntime.PODMAN
            mgr._runtime_path = "/usr/bin/podman"
            yield mgr

    def test_full_setup_with_runtime(self, manager):
        """Full setup when runtime already exists — provisions and starts services."""

        def run_side(cmd, **kwargs):
            return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="abc123", stderr="")

        with mock.patch("subprocess.run", side_effect=run_side):
            result = manager.full_server_setup(["mealie", "joplin"])

        assert result["bootstrap"]["success"] is True
        assert result["bootstrap"]["already_installed"] is True
        assert result["summary"]["total"] == 2
        # Both should attempt to provision+start
        assert len(result["services"]) == 2

    def test_full_setup_skips_native(self, manager):
        """Native services are skipped with a hint."""
        result = manager.full_server_setup(["rendezvous"])
        assert result["summary"]["skipped"] == 1
        assert result["services"][0]["status"] == "native_skipped"
        assert result["services"][0]["ok"] is True

    def test_full_setup_unknown_service(self, manager):
        """Unknown service key returns error."""
        result = manager.full_server_setup(["nonexistent_thing"])
        assert result["summary"]["failed"] == 1
        assert result["services"][0]["status"] == "unknown_spec"

    def test_full_setup_no_runtime_bootstraps(self, tmp_path):
        """When no runtime, full_server_setup bootstraps it first."""
        with (
            mock.patch("familiar.services.manager.SERVICES_DIR", tmp_path),
            mock.patch("familiar.services.manager.STATE_FILE", tmp_path / "state.json"),
        ):
            from familiar.services.manager import ServiceManager

            mgr = ServiceManager()
            mgr._runtime = None

            call_count = [0]

            def which_side(name):
                if name in ("podman", "docker"):
                    if call_count[0] > 0:
                        return "/usr/bin/podman"
                    return None
                if name == "dnf":
                    return "/usr/bin/dnf"
                return None

            def run_side(cmd, **kwargs):
                if "install" in cmd:
                    call_count[0] += 1
                return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="ok", stderr="")

            with (
                mock.patch("shutil.which", side_effect=which_side),
                mock.patch("subprocess.run", side_effect=run_side),
            ):
                result = mgr.full_server_setup(["email_server"])

            # Bootstrap should have run
            assert result["bootstrap"]["success"] is True
            assert result["bootstrap"]["already_installed"] is False

    def test_full_setup_bootstrap_fails(self, tmp_path):
        """If bootstrap fails, all services fail."""
        with (
            mock.patch("familiar.services.manager.SERVICES_DIR", tmp_path),
            mock.patch("familiar.services.manager.STATE_FILE", tmp_path / "state.json"),
        ):
            from familiar.services.manager import ServiceManager

            mgr = ServiceManager()
            mgr._runtime = None

            with mock.patch("shutil.which", return_value=None):
                result = mgr.full_server_setup(["mealie", "joplin"])

            assert result["bootstrap"]["success"] is False
            assert result["summary"]["failed"] == 2


# =============================================================================
# Compose generator tests
# =============================================================================


class TestCompose:
    """Tests for Compose YAML generation."""

    def test_generate_compose_all(self):
        from familiar.services.compose import generate_compose

        output = generate_compose()
        assert "services:" in output
        assert "gitea:" in output
        assert "mealie:" in output
        # Native services should be excluded (rendezvous is native)
        assert "rendezvous:" not in output
        # email_server is now a container, should be included
        assert "email_server:" in output

    def test_generate_compose_subset(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["gitea", "mealie"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])  # skip header comments
        assert "gitea" in parsed["services"]
        assert "mealie" in parsed["services"]
        assert len(parsed["services"]) == 2

    def test_compose_has_ports(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["gitea"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        gitea = parsed["services"]["gitea"]
        assert "ports" in gitea
        assert any("3000" in str(p) for p in gitea["ports"])

    def test_compose_has_volumes(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["gitea"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        gitea = parsed["services"]["gitea"]
        assert "volumes" in gitea

    def test_compose_has_env(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["mealie"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        mealie = parsed["services"]["mealie"]
        assert "environment" in mealie
        assert mealie["environment"]["ALLOW_SIGNUP"] == "true"

    def test_compose_has_restart_policy(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["gitea"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        gitea = parsed["services"]["gitea"]
        assert gitea["restart"] == "unless-stopped"

    def test_compose_container_names(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["gitea"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        assert parsed["services"]["gitea"]["container_name"] == "familiar-gitea"

    def test_write_compose_file(self, tmp_path):
        from familiar.services.compose import write_compose_file

        out = write_compose_file(
            output_path=str(tmp_path / "test-compose.yml"),
            service_keys=["gitea"],
        )
        assert os.path.exists(out)
        content = Path(out).read_text()
        assert "gitea:" in content

    def test_compose_native_excluded(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["rendezvous", "gitea"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        assert "rendezvous" not in parsed["services"]
        assert "gitea" in parsed["services"]

    def test_compose_runtime_header(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(runtime="docker")
        assert (
            "docker" in output.splitlines()[0].lower() or "docker" in output.splitlines()[2].lower()
        )


# =============================================================================
# Health integration tests
# =============================================================================


class TestHealthIntegration:
    """Tests for create_service_health_check()."""

    def test_create_service_health_check_returns_callable(self):
        from familiar.core.health import create_service_health_check

        check = create_service_health_check("gitea")
        assert callable(check)

    def test_service_health_check_not_running(self):
        from familiar.core.health import create_service_health_check

        check = create_service_health_check("gitea")
        with mock.patch("familiar.services.manager.get_service_manager") as mock_mgr:
            mgr_instance = mock.MagicMock()
            mgr_instance.get_status.return_value = {"status": "stopped"}
            mock_mgr.return_value = mgr_instance

            result = check()
            assert result.healthy is False
            assert "stopped" in result.message

    def test_service_health_check_running(self):
        from familiar.core.health import create_service_health_check

        check = create_service_health_check("gitea")
        with mock.patch("familiar.services.manager.get_service_manager") as mock_mgr:
            mgr_instance = mock.MagicMock()
            mgr_instance.get_status.return_value = {"status": "running"}
            mgr_instance.health_check.return_value = {
                "healthy": True,
                "url": "http://localhost:3000",
            }
            mock_mgr.return_value = mgr_instance

            result = check()
            assert result.healthy is True
            assert "Running" in result.message


# =============================================================================
# Onboarding integration tests
# =============================================================================


class TestOnboardingRecommendations:
    """Tests for get_server_recommendations()."""

    def _make_creature(self, job_class):
        """Create a mock creature with the given job_class."""
        creature = mock.MagicMock()
        creature.job_class = job_class
        return creature

    def test_helper_gets_searxng(self):
        from familiar.onboard_engine import OnboardingEngine

        engine = OnboardingEngine()
        with mock.patch(
            "familiar.creature.state.load_creature",
            return_value=self._make_creature("helper"),
        ):
            result = engine.get_server_recommendations()
            assert result.success is True
            keys = [r["key"] for r in result.data["recommendations"]]
            assert "searxng" in keys
            assert "email_server" in keys

    def test_chef_gets_mealie(self):
        from familiar.onboard_engine import OnboardingEngine

        engine = OnboardingEngine()
        with mock.patch(
            "familiar.creature.state.load_creature",
            return_value=self._make_creature("chef"),
        ):
            result = engine.get_server_recommendations()
            assert result.success is True
            keys = [r["key"] for r in result.data["recommendations"]]
            assert "mealie" in keys
            assert "email_server" in keys

    def test_artist_gets_jellyfin(self):
        from familiar.onboard_engine import OnboardingEngine

        engine = OnboardingEngine()
        with mock.patch(
            "familiar.creature.state.load_creature",
            return_value=self._make_creature("artist"),
        ):
            result = engine.get_server_recommendations()
            assert result.success is True
            keys = [r["key"] for r in result.data["recommendations"]]
            assert "jellyfin" in keys
            assert "gitea" in keys

    def test_fallback_on_import_error(self):
        from familiar.onboard_engine import OnboardingEngine

        engine = OnboardingEngine()
        with mock.patch(
            "familiar.creature.state.load_creature",
            side_effect=ImportError("no creature"),
        ):
            result = engine.get_server_recommendations()
            assert result.success is True
            # Falls back to helper
            assert result.data["job_class"] == "helper"

    def test_artist_gets_gotosocial(self):
        from familiar.onboard_engine import OnboardingEngine

        engine = OnboardingEngine()
        with mock.patch(
            "familiar.creature.state.load_creature",
            return_value=self._make_creature("artist"),
        ):
            result = engine.get_server_recommendations()
            keys = [r["key"] for r in result.data["recommendations"]]
            assert "gotosocial" in keys


# =============================================================================
# Social service spec tests
# =============================================================================


class TestSocialServiceSpecs:
    """Tests for GoToSocial and Bluesky PDS service specs."""

    def test_gotosocial_spec_fields(self):
        spec = SERVICE_SPECS["gotosocial"]
        assert spec.display_name == "GoToSocial"
        assert spec.service_type == ServiceType.CONTAINER
        assert spec.image == "superseriousbusiness/gotosocial:0.17"
        assert spec.connect_key == "mastodon"
        assert spec.min_ram_gb == 0.25

    def test_gotosocial_ports(self):
        spec = SERVICE_SPECS["gotosocial"]
        assert 8081 in spec.ports
        assert spec.ports[8081] == 8080
        assert spec.health_check_port == 8081

    def test_gotosocial_health_path(self):
        spec = SERVICE_SPECS["gotosocial"]
        assert spec.health_check_path == "/api/v1/instance"

    def test_bluesky_pds_spec_fields(self):
        spec = SERVICE_SPECS["bluesky_pds"]
        assert spec.display_name == "Bluesky PDS"
        assert spec.service_type == ServiceType.CONTAINER
        assert spec.image == "ghcr.io/bluesky-social/pds:0.4"
        assert spec.connect_key == "bluesky"
        assert spec.min_ram_gb == 0.5

    def test_bluesky_pds_ports(self):
        spec = SERVICE_SPECS["bluesky_pds"]
        assert 2583 in spec.ports
        assert spec.ports[2583] == 3000
        assert spec.health_check_port == 2583

    def test_bluesky_pds_health_path(self):
        spec = SERVICE_SPECS["bluesky_pds"]
        assert spec.health_check_path == "/xrpc/_health"

    def test_social_job_classes(self):
        gts = SERVICE_SPECS["gotosocial"]
        assert "artist" in gts.job_classes
        assert "social_worker" in gts.job_classes
        assert "nonprofit_director" in gts.job_classes

        pds = SERVICE_SPECS["bluesky_pds"]
        assert "artist" in pds.job_classes
        assert "social_worker" in pds.job_classes

    def test_bluesky_secret_generation(self):
        from familiar.services.credentials import get_service_credentials

        spec = SERVICE_SPECS["bluesky_pds"]
        # Bluesky PDS declares credential_keys for generated secrets
        assert "PDS_JWT_SECRET" in spec.credential_keys
        assert "PDS_ADMIN_PASSWORD" in spec.credential_keys
        # Credential generation produces unique values
        creds = get_service_credentials("_test_bluesky", spec.credential_keys)
        assert len(creds["PDS_JWT_SECRET"]) >= 16
        assert len(creds["PDS_ADMIN_PASSWORD"]) >= 16
        # Cleanup test creds
        from familiar.services.credentials import delete_credentials

        delete_credentials("_test_bluesky")

    def test_no_secrets_for_services_without_credential_keys(self):
        # Services without credential_keys should have no generated secrets
        spec = SERVICE_SPECS["gotosocial"]
        assert len(spec.credential_keys) == 0


# =============================================================================
# Sprint 1-4: Security hardening tests
# =============================================================================


class TestSecurityProfiles:
    """Verify security fields are set correctly on all service specs."""

    def test_all_container_specs_have_security_fields(self):
        """Every container spec must have security profile fields."""
        for key, spec in SERVICE_SPECS.items():
            if spec.service_type != ServiceType.CONTAINER:
                continue
            # These fields must exist (they have defaults)
            assert isinstance(spec.capabilities, list), f"{key} missing capabilities"
            assert isinstance(spec.read_only_root, bool), f"{key} missing read_only_root"
            assert isinstance(spec.tmpfs_mounts, list), f"{key} missing tmpfs_mounts"
            assert isinstance(spec.memory_limit, str), f"{key} missing memory_limit"
            assert isinstance(spec.cpu_limit, (int, float)), f"{key} missing cpu_limit"
            assert isinstance(spec.no_new_privileges, bool), f"{key} missing no_new_privileges"
            assert isinstance(spec.localhost_only, bool), f"{key} missing localhost_only"

    def test_all_specs_default_to_localhost_only(self):
        """All services except Caddy should bind to localhost by default."""
        for key, spec in SERVICE_SPECS.items():
            if spec.service_type != ServiceType.CONTAINER:
                continue
            if key == "caddy":
                # Caddy is the reverse proxy — it must be LAN-reachable
                assert spec.localhost_only is False, "Caddy must be LAN-accessible"
                continue
            assert spec.localhost_only is True, f"{key} not localhost_only"

    def test_all_specs_default_no_new_privileges(self):
        """All services should prevent privilege escalation (with documented exceptions)."""
        # Services whose entrypoints use su-exec/setpriv to drop from root to
        # an unprivileged user MUST have no_new_privileges=False.
        needs_priv_transition = {"gitea", "wazuh_dashboard"}
        for key, spec in SERVICE_SPECS.items():
            if spec.service_type != ServiceType.CONTAINER:
                continue
            if key in needs_priv_transition:
                assert spec.no_new_privileges is False, (
                    f"{key} needs no_new_privileges=False for su-exec"
                )
            else:
                assert spec.no_new_privileges is True, f"{key} allows new privileges"

    def test_all_specs_have_memory_limits(self):
        """All container services must have memory limits set."""
        for key, spec in SERVICE_SPECS.items():
            if spec.service_type != ServiceType.CONTAINER:
                continue
            assert spec.memory_limit, f"{key} has no memory limit"
            # Verify it's a valid format (e.g. "512m", "1g", "2g")
            assert spec.memory_limit[-1] in ("m", "g"), (
                f"{key} memory_limit bad format: {spec.memory_limit}"
            )

    def test_no_latest_or_stable_image_tags(self):
        """No container image should use :latest or :stable tags (supply chain risk).

        Exceptions: images whose registries don't publish stable semver tags
        (e.g. SearXNG uses date+hash tags, SpiderFoot requires auth for tags).
        These should be re-pinned once valid tags are confirmed.
        """
        _TAG_EXCEPTIONS = {"searxng", "joplin", "crowdsec"}
        for key, spec in SERVICE_SPECS.items():
            if spec.service_type != ServiceType.CONTAINER:
                continue
            if key in _TAG_EXCEPTIONS:
                continue
            tag = spec.image.split(":")[-1] if ":" in spec.image else ""
            assert tag not in ("latest", "stable", ""), (
                f"{key} uses unpinned image tag '{tag}' — pin to a specific version"
            )

    def test_most_specs_need_zero_capabilities(self):
        """Most services should need zero Linux capabilities."""
        with_caps = {
            k
            for k, s in SERVICE_SPECS.items()
            if s.service_type == ServiceType.CONTAINER and s.capabilities
        }
        # Services needing capabilities for their entrypoints:
        # Pi-hole (CHOWN/SETUID/SETGID/NET_BIND_SERVICE),
        # Netdata (SYS_PTRACE), CrowdSec (NET_*),
        # Caddy (NET_BIND_SERVICE), Vaultwarden (NET_BIND_SERVICE),
        # Mealie/Nextcloud/Gitea/Uptime Kuma (CHOWN/SETUID/etc.)
        expected = {
            "pihole",
            "netdata",
            "crowdsec",
            "caddy",
            "mealie",
            "nextcloud",
            "gitea",
            "uptime_kuma",
            "vaultwarden",
            "wazuh_dashboard",
        }
        assert with_caps == expected, (
            f"Unexpected services with capabilities: {with_caps - expected}"
        )

    def test_no_hardcoded_passwords_in_env_vars(self):
        """No service spec should contain hardcoded passwords."""
        password_keywords = {"password", "secret", "token"}
        for key, spec in SERVICE_SPECS.items():
            for env_key, env_val in spec.env_vars.items():
                # If the env key looks like a credential, it should be empty or non-secret
                if any(kw in env_key.lower() for kw in password_keywords):
                    # It should be in credential_keys for generation, not hardcoded
                    assert env_key in spec.credential_keys or env_val == "", (
                        f"{key} has hardcoded credential {env_key}={env_val!r}. "
                        f"Move to credential_keys for secure generation."
                    )

    def test_credential_keys_declared_for_password_services(self):
        """Services that need passwords must declare credential_keys."""
        # These services are known to need generated credentials
        expected_cred_services = {
            "pihole",
            "nextcloud",
            "wazuh_manager",
            "wazuh_dashboard",
            "bluesky_pds",
            "vaultwarden",
        }
        actual = {k for k, s in SERVICE_SPECS.items() if s.credential_keys}
        assert actual == expected_cred_services


class TestCredentialManager:
    """Test the credential generation and storage system."""

    def test_generate_unique_credentials(self):
        from familiar.services.credentials import generate_credential

        c1 = generate_credential()
        c2 = generate_credential()
        assert c1 != c2
        assert len(c1) >= 24

    def test_get_service_credentials_idempotent(self):
        from familiar.services.credentials import (
            delete_credentials,
            get_service_credentials,
        )

        creds1 = get_service_credentials("_test_svc", ["PASSWORD", "API_KEY"])
        creds2 = get_service_credentials("_test_svc", ["PASSWORD", "API_KEY"])
        # Same values on second call
        assert creds1 == creds2
        delete_credentials("_test_svc")

    def test_get_credentials_adds_missing_keys(self):
        from familiar.services.credentials import (
            delete_credentials,
            get_service_credentials,
        )

        creds1 = get_service_credentials("_test_svc2", ["KEY_A"])
        creds2 = get_service_credentials("_test_svc2", ["KEY_A", "KEY_B"])
        # KEY_A preserved, KEY_B added
        assert creds2["KEY_A"] == creds1["KEY_A"]
        assert "KEY_B" in creds2
        delete_credentials("_test_svc2")

    def test_delete_credentials(self):
        from familiar.services.credentials import (
            delete_credentials,
            get_service_credentials,
            load_credentials,
        )

        get_service_credentials("_test_del", ["X"])
        assert load_credentials("_test_del")
        delete_credentials("_test_del")
        assert load_credentials("_test_del") == {}

    def test_empty_credential_keys_returns_empty(self):
        from familiar.services.credentials import get_service_credentials

        assert get_service_credentials("_test_empty", []) == {}


class TestHardenedProvisioning:
    """Test that provision() generates hardened container commands."""

    def _get_provision_cmd(self, key, mock_run):
        """Extract the 'create' command from mock subprocess calls."""
        for call in mock_run.call_args_list:
            args = call[0][0] if call[0] else call.kwargs.get("args", [])
            if isinstance(args, list) and "create" in args:
                return args
        return None

    @mock.patch("subprocess.run")
    @mock.patch("shutil.which", return_value="/usr/bin/podman")
    def test_provision_drops_all_caps(self, mock_which, mock_run):
        mock_run.return_value = mock.Mock(
            returncode=0,
            stdout="container123",
            stderr="",
        )
        from familiar.services.manager import get_service_manager

        mgr = get_service_manager()
        mgr._runtime = ContainerRuntime.PODMAN
        mgr._runtime_path = "/usr/bin/podman"
        mgr.provision("mealie")
        cmd = self._get_provision_cmd("mealie", mock_run)
        assert cmd is not None, "No create command found"
        assert "--cap-drop" in cmd
        cap_idx = cmd.index("--cap-drop")
        assert cmd[cap_idx + 1] == "ALL"

    @mock.patch("subprocess.run")
    @mock.patch("shutil.which", return_value="/usr/bin/podman")
    def test_provision_adds_no_new_privileges(self, mock_which, mock_run):
        mock_run.return_value = mock.Mock(
            returncode=0,
            stdout="container123",
            stderr="",
        )
        from familiar.services.manager import get_service_manager

        mgr = get_service_manager()
        mgr._runtime = ContainerRuntime.PODMAN
        mgr._runtime_path = "/usr/bin/podman"
        mgr.provision("searxng")
        cmd = self._get_provision_cmd("searxng", mock_run)
        assert "--security-opt" in cmd
        sec_idx = cmd.index("--security-opt")
        assert cmd[sec_idx + 1] == "no-new-privileges:true"

    @mock.patch("subprocess.run")
    @mock.patch("shutil.which", return_value="/usr/bin/podman")
    def test_provision_localhost_binding(self, mock_which, mock_run):
        mock_run.return_value = mock.Mock(
            returncode=0,
            stdout="container123",
            stderr="",
        )
        from familiar.services.manager import get_service_manager

        mgr = get_service_manager()
        mgr._runtime = ContainerRuntime.PODMAN
        mgr._runtime_path = "/usr/bin/podman"
        mgr.provision("mealie")
        cmd = self._get_provision_cmd("mealie", mock_run)
        # All ports should be prefixed with 127.0.0.1
        port_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-p"]
        for pa in port_args:
            assert pa.startswith("127.0.0.1:"), f"Port not localhost-only: {pa}"

    @mock.patch("subprocess.run")
    @mock.patch("shutil.which", return_value="/usr/bin/podman")
    def test_provision_memory_limits(self, mock_which, mock_run):
        mock_run.return_value = mock.Mock(
            returncode=0,
            stdout="container123",
            stderr="",
        )
        from familiar.services.manager import get_service_manager

        mgr = get_service_manager()
        mgr._runtime = ContainerRuntime.PODMAN
        mgr._runtime_path = "/usr/bin/podman"
        mgr.provision("mealie")
        cmd = self._get_provision_cmd("mealie", mock_run)
        assert "--memory" in cmd
        mem_idx = cmd.index("--memory")
        assert cmd[mem_idx + 1] == "512m"

    @mock.patch("subprocess.run")
    @mock.patch("shutil.which", return_value="/usr/bin/podman")
    def test_provision_read_only_root(self, mock_which, mock_run):
        mock_run.return_value = mock.Mock(
            returncode=0,
            stdout="container123",
            stderr="",
        )
        from familiar.services.manager import get_service_manager

        mgr = get_service_manager()
        mgr._runtime = ContainerRuntime.PODMAN
        mgr._runtime_path = "/usr/bin/podman"
        # Mealie has read_only_root=False (needs writable for entrypoint chown)
        mgr.provision("mealie")
        cmd = self._get_provision_cmd("mealie", mock_run)
        assert "--read-only" not in cmd

    @mock.patch("subprocess.run")
    @mock.patch("shutil.which", return_value="/usr/bin/podman")
    def test_provision_pihole_has_capabilities(self, mock_which, mock_run):
        """Pi-hole needs CHOWN/SETUID/SETGID/NET_BIND_SERVICE."""
        mock_run.return_value = mock.Mock(
            returncode=0,
            stdout="container123",
            stderr="",
        )
        from familiar.services.manager import get_service_manager

        mgr = get_service_manager()
        mgr._runtime = ContainerRuntime.PODMAN
        mgr._runtime_path = "/usr/bin/podman"
        mgr.provision("pihole")
        cmd = self._get_provision_cmd("pihole", mock_run)
        assert "--cap-add" in cmd
        cap_idx = [i for i, x in enumerate(cmd) if x == "--cap-add"]
        added_caps = {cmd[i + 1] for i in cap_idx}
        assert "CHOWN" in added_caps
        assert "NET_BIND_SERVICE" in added_caps

    @mock.patch("subprocess.run")
    @mock.patch("shutil.which", return_value="/usr/bin/podman")
    def test_provision_generates_credentials(self, mock_which, mock_run):
        mock_run.return_value = mock.Mock(
            returncode=0,
            stdout="container123",
            stderr="",
        )
        from familiar.services.credentials import delete_credentials
        from familiar.services.manager import get_service_manager

        mgr = get_service_manager()
        mgr._runtime = ContainerRuntime.PODMAN
        mgr._runtime_path = "/usr/bin/podman"
        mgr.provision("nextcloud")
        cmd = self._get_provision_cmd("nextcloud", mock_run)
        # Should have NEXTCLOUD_ADMIN_PASSWORD as an env var
        env_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-e"]
        pw_args = [a for a in env_args if a.startswith("NEXTCLOUD_ADMIN_PASSWORD=")]
        assert pw_args, "No NEXTCLOUD_ADMIN_PASSWORD env var in command"
        # Value should NOT be "familiar" (the old hardcoded value)
        pw_val = pw_args[0].split("=", 1)[1]
        assert pw_val != "familiar", "Password is still hardcoded!"
        assert len(pw_val) >= 16, "Generated password too short"
        delete_credentials("nextcloud")


# =============================================================================
# Sprint 8: Caddy reverse proxy + compose security tests
# =============================================================================


class TestCaddyProxy:
    """Tests for Caddy reverse proxy configuration and service spec."""

    def test_caddy_spec_registered(self):
        spec = SERVICE_SPECS["caddy"]
        assert spec.display_name == "Caddy"
        assert spec.service_type == ServiceType.CONTAINER
        assert spec.image == "caddy:2.9"

    def test_caddy_ports(self):
        spec = SERVICE_SPECS["caddy"]
        assert 8880 in spec.ports
        assert 8443 in spec.ports

    def test_caddy_not_localhost_only(self):
        """Caddy must be LAN-reachable for TLS to work."""
        spec = SERVICE_SPECS["caddy"]
        assert spec.localhost_only is False

    def test_caddy_has_net_bind_service(self):
        spec = SERVICE_SPECS["caddy"]
        assert "NET_BIND_SERVICE" in spec.capabilities

    def test_caddy_volumes(self):
        spec = SERVICE_SPECS["caddy"]
        container_paths = list(spec.volumes.values())
        assert "/data" in container_paths
        assert "/config" in container_paths
        assert "/etc/caddy/Caddyfile" in container_paths

    def test_generate_caddyfile_default(self):
        from familiar.services.proxy import generate_caddyfile

        content = generate_caddyfile()
        assert "local_certs" in content
        assert "dashboard.familiar.local" in content
        assert "tls internal" in content
        # Should have entries for container services with health check ports
        assert "mealie.familiar.local" in content
        assert "gitea.familiar.local" in content

    def test_generate_caddyfile_custom_domain(self):
        from familiar.services.proxy import generate_caddyfile

        content = generate_caddyfile(domain="myhouse.lan")
        assert "dashboard.myhouse.lan" in content
        assert "mealie.myhouse.lan" in content

    def test_generate_caddyfile_subset(self):
        from familiar.services.proxy import generate_caddyfile

        content = generate_caddyfile(services=["mealie", "gitea"])
        assert "mealie.familiar.local" in content
        assert "gitea.familiar.local" in content
        # Jellyfin not requested — should not appear
        assert "jellyfin.familiar.local" not in content

    def test_caddyfile_excludes_caddy_itself(self):
        """Caddy should not create a reverse proxy entry for itself."""
        from familiar.services.proxy import generate_caddyfile

        content = generate_caddyfile()
        assert "caddy.familiar.local" not in content

    def test_caddyfile_dns_safe_subdomains(self):
        """Underscores in service keys should become hyphens in subdomains."""
        from familiar.services.proxy import generate_caddyfile

        content = generate_caddyfile(services=["uptime_kuma"])
        assert "uptime-kuma.familiar.local" in content
        assert "uptime_kuma.familiar.local" not in content

    def test_get_service_urls(self):
        from familiar.services.proxy import get_service_urls

        urls = get_service_urls()
        assert urls["dashboard"] == "https://dashboard.familiar.local"
        assert urls["mealie"] == "https://mealie.familiar.local"
        # Caddy should not have its own URL
        assert "caddy" not in urls

    def test_write_caddyfile(self, tmp_path):
        from familiar.services.proxy import generate_caddyfile

        path = tmp_path / "Caddyfile"
        content = generate_caddyfile(services=["mealie"])
        path.write_text(content, encoding="utf-8")
        assert path.exists()
        assert "mealie.familiar.local" in path.read_text()

    def test_caddyfile_reverse_proxy_ports(self):
        """Each service should proxy to its health_check_port."""
        from familiar.services.proxy import generate_caddyfile

        content = generate_caddyfile(services=["mealie", "gitea"])
        assert "reverse_proxy 127.0.0.1:9925" in content  # mealie
        assert "reverse_proxy 127.0.0.1:3000" in content  # gitea


class TestComposeSecurityFlags:
    """Tests that compose output includes security hardening flags."""

    def test_compose_has_cap_drop(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["mealie"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        mealie = parsed["services"]["mealie"]
        assert mealie["cap_drop"] == ["ALL"]

    def test_compose_pihole_has_cap_add(self):
        """Pi-hole needs CHOWN/SETUID/SETGID/NET_BIND_SERVICE."""
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["pihole"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        pihole = parsed["services"]["pihole"]
        assert "CHOWN" in pihole["cap_add"]
        assert "NET_BIND_SERVICE" in pihole["cap_add"]

    def test_compose_has_security_opt(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["mealie"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        mealie = parsed["services"]["mealie"]
        assert "no-new-privileges:true" in mealie["security_opt"]

    def test_compose_has_read_only(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["searxng"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        searxng = parsed["services"]["searxng"]
        assert searxng["read_only"] is True

    def test_compose_no_read_only_for_gitea(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["gitea"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        gitea = parsed["services"]["gitea"]
        assert "read_only" not in gitea

    def test_compose_has_tmpfs(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["mealie"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        mealie = parsed["services"]["mealie"]
        assert any("/tmp" in t for t in mealie["tmpfs"])

    def test_compose_has_memory_limit(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["wazuh_manager"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        wm = parsed["services"]["wazuh_manager"]
        assert wm["deploy"]["resources"]["limits"]["memory"] == "2g"

    def test_compose_localhost_binding(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["mealie"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        mealie = parsed["services"]["mealie"]
        for port in mealie["ports"]:
            assert port.startswith("127.0.0.1:"), f"Port not localhost-only: {port}"

    def test_compose_caddy_not_localhost(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["caddy"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        caddy = parsed["services"]["caddy"]
        for port in caddy["ports"]:
            assert not port.startswith("127.0.0.1:"), f"Caddy should be LAN-accessible: {port}"

    def test_compose_depends_on(self):
        from familiar.services.compose import generate_compose

        output = generate_compose(service_keys=["wazuh_manager", "wazuh_dashboard"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        wd = parsed["services"]["wazuh_dashboard"]
        assert "wazuh_manager" in wd["depends_on"]
