# 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.specs import SERVICE_SPECS, ServiceSpec, ServiceType


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


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

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

    def test_spec_keys(self):
        expected = {
            "email_server", "gitea", "mealie", "joplin",
            "pihole", "nextcloud", "jellyfin", "searxng",
            "gotosocial", "bluesky_pds",
        }
        assert set(SERVICE_SPECS.keys()) == expected

    def test_email_is_native(self):
        spec = SERVICE_SPECS["email_server"]
        assert spec.service_type == ServiceType.NATIVE
        assert spec.native_module == "familiar.skills.email.server"
        assert spec.image == ""

    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 53 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"


# =============================================================================
# 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("email_server")
        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("email_server")
        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("email_server")
        assert result["success"] is True
        state = manager._get_or_create_state("email_server")
        assert state.status == "running"

    def test_stop_native(self, manager):
        manager.start("email_server")
        result = manager.stop("email_server")
        assert result["success"] is True
        state = manager._get_or_create_state("email_server")
        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("email_server")
        result = manager.remove("email_server")
        assert result["success"] is True
        state = manager._get_or_create_state("email_server")
        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) == 10
            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("email_server")
        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("email_server")

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

            data = json.loads(state_file.read_text())
            assert "email_server" in data
            assert data["email_server"]["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


# =============================================================================
# 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
        assert "email_server:" not 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=["email_server", "gitea"])
        parsed = yaml.safe_load(output.split("\n", 3)[-1])
        assert "email_server" 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:latest"
        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:latest"
        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.manager import ServiceManager

        env = {"PDS_HOSTNAME": "localhost"}
        result = ServiceManager._generate_provision_secrets("bluesky_pds", env)
        assert "PDS_JWT_SECRET" in result
        assert "PDS_ADMIN_PASSWORD" in result
        assert "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX" in result
        assert result["PDS_HOSTNAME"] == "localhost"
        # Secrets should be non-empty
        assert len(result["PDS_JWT_SECRET"]) >= 16
        assert len(result["PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX"]) >= 32

    def test_no_secrets_for_other_services(self):
        from familiar.services.manager import ServiceManager

        env = {"GTS_HOST": "localhost:8081"}
        result = ServiceManager._generate_provision_secrets("gotosocial", env)
        assert result == env
