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

"""
Tests for the four security pipeline gaps (P1-P4):

    P1 — SpiderFoot skill module (5 tools)
    P2 — OSINT investigation persistence (investigations.py)
    P3 — Netdata disk metrics (covered in test_network_skills; spot-checked here)
    P4 — Wazuh health checks + ServiceManager.start_ordered()
"""

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

import pytest

# ===========================================================================
# P4 — Wazuh health check probe helpers
# ===========================================================================


class TestProbeHelpers:
    def test_probe_tcp_port_refuses(self):
        from familiar.core.health import _probe_tcp_port

        assert _probe_tcp_port(1, timeout=1.0) is False

    def test_probe_tcp_port_open(self):
        import socket
        import threading

        from familiar.core.health import _probe_tcp_port

        srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        srv.bind(("127.0.0.1", 0))
        srv.listen(1)
        port = srv.getsockname()[1]

        def _accept():
            try:
                conn, _ = srv.accept()
                conn.close()
            except OSError:
                pass

        threading.Thread(target=_accept, daemon=True).start()
        try:
            result = _probe_tcp_port(port, timeout=2.0)
        finally:
            srv.close()
        assert result is True

    def test_probe_opensearch_failure(self):
        from familiar.core.health import _probe_opensearch

        assert _probe_opensearch(1, timeout=1.0) is False


class TestCreateWazuhHealthCheck:
    def test_manager_check_callable(self):
        from familiar.core.health import create_wazuh_health_check

        assert callable(create_wazuh_health_check("wazuh_manager"))

    def test_indexer_check_callable(self):
        from familiar.core.health import create_wazuh_health_check

        assert callable(create_wazuh_health_check("wazuh_indexer"))

    def test_dashboard_check_callable(self):
        from familiar.core.health import create_wazuh_health_check

        assert callable(create_wazuh_health_check("wazuh_dashboard"))

    def test_unknown_key_callable(self):
        from familiar.core.health import create_wazuh_health_check

        assert callable(create_wazuh_health_check("totally_unknown_key"))

    def test_manager_uses_tcp_probe(self):
        from familiar.core.health import create_wazuh_health_check

        with patch("familiar.core.health._probe_tcp_port", return_value=True) as mock_tcp:
            fn = create_wazuh_health_check("wazuh_manager")
            result = fn()
        mock_tcp.assert_called_once()
        assert result.healthy is True

    def test_manager_unhealthy_when_port_closed(self):
        from familiar.core.health import create_wazuh_health_check

        with patch("familiar.core.health._probe_tcp_port", return_value=False):
            result = create_wazuh_health_check("wazuh_manager")()
        assert result.healthy is False

    def test_indexer_uses_opensearch_probe(self):
        from familiar.core.health import create_wazuh_health_check

        with patch("familiar.core.health._probe_opensearch", return_value=True) as mock_os:
            fn = create_wazuh_health_check("wazuh_indexer")
            result = fn()
        mock_os.assert_called_once()
        assert result.healthy is True

    def test_indexer_unhealthy_when_opensearch_down(self):
        from familiar.core.health import create_wazuh_health_check

        with patch("familiar.core.health._probe_opensearch", return_value=False):
            result = create_wazuh_health_check("wazuh_indexer")()
        assert result.healthy is False


# ===========================================================================
# P4 — ServiceManager._topo_sort and start_ordered
# ===========================================================================


class TestTopoSort:
    def test_no_deps_returns_all_keys(self):
        from familiar.services.manager import ServiceManager

        keys = ["gitea", "mealie", "searxng"]
        assert set(ServiceManager._topo_sort(keys)) == set(keys)

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

        keys = ["wazuh_dashboard", "wazuh_indexer", "wazuh_manager"]
        ordered = ServiceManager._topo_sort(keys)
        assert ordered.index("wazuh_manager") < ordered.index("wazuh_indexer")
        assert ordered.index("wazuh_manager") < ordered.index("wazuh_dashboard")
        assert ordered.index("wazuh_indexer") < ordered.index("wazuh_dashboard")

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

        assert ServiceManager._topo_sort(["gitea"]) == ["gitea"]

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

        assert ServiceManager._topo_sort([]) == []

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

        keys = ["mealie", "nextcloud", "jellyfin"]
        assert sorted(ServiceManager._topo_sort(keys)) == sorted(keys)


class TestStartOrdered:
    def test_calls_start_in_dependency_order(self):
        from familiar.services.manager import ServiceManager

        mgr = ServiceManager()
        call_order = []

        def mock_start(key):
            call_order.append(key)
            return {"success": True}

        mgr.start = mock_start
        mgr.start_ordered(["wazuh_dashboard", "wazuh_indexer", "wazuh_manager"])
        assert call_order.index("wazuh_manager") < call_order.index("wazuh_indexer")
        assert call_order.index("wazuh_manager") < call_order.index("wazuh_dashboard")

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

        mgr = ServiceManager()
        mgr.start = lambda key: {"success": True}
        results = mgr.start_ordered(["gitea", "mealie"])
        assert isinstance(results, list)
        assert len(results) == 2

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

        mgr = ServiceManager()
        mgr.start = lambda key: {"success": True}
        with patch.object(ServiceManager, "_topo_sort", side_effect=ValueError("cycle")):
            results = mgr.start_ordered(["gitea"])
        assert len(results) == 1


# ===========================================================================
# P1 — SpiderFoot skill
# ===========================================================================


class TestSpiderFootDegraded:
    def _patch_unavail(self):
        return patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=False)

    def test_start_scan(self):
        from familiar.skills.spiderfoot.skill import start_spiderfoot_scan

        with self._patch_unavail():
            assert start_spiderfoot_scan("x.com").get("format") == "degraded"

    def test_get_status(self):
        from familiar.skills.spiderfoot.skill import get_scan_status

        with self._patch_unavail():
            assert get_scan_status("abc").get("format") == "degraded"

    def test_get_findings(self):
        from familiar.skills.spiderfoot.skill import get_scan_findings

        with self._patch_unavail():
            assert get_scan_findings("abc").get("format") == "degraded"

    def test_list_scans(self):
        from familiar.skills.spiderfoot.skill import list_spiderfoot_scans

        with self._patch_unavail():
            assert list_spiderfoot_scans().get("scans") == []

    def test_export_report(self):
        from familiar.skills.spiderfoot.skill import export_scan_report

        with self._patch_unavail():
            assert export_scan_report("abc").get("format") == "degraded"


class TestSpiderFootStartScan:
    def test_passive_only_enforces_passive_preset(self):
        from familiar.skills.spiderfoot.skill import _MODULE_PRESETS, start_spiderfoot_scan

        posted = {}

        def mock_post(path, data):
            posted.update(data)
            return {"id": "scan-001"}

        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill._api_post", side_effect=mock_post):
                start_spiderfoot_scan("example.com", modules="full", passive_only=True)

        assert posted["modulelist"] == _MODULE_PRESETS["passive"]

    def test_returns_scan_id(self):
        def mock_post(path, data):
            return {"id": "scan-abc"}

        from familiar.skills.spiderfoot.skill import start_spiderfoot_scan

        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill._api_post", side_effect=mock_post):
                result = start_spiderfoot_scan("example.com")
        assert result["scan_id"] == "scan-abc"

    def test_default_scan_name(self):
        posted = {}

        def mock_post(path, data):
            posted.update(data)
            return {"id": "x"}

        from familiar.skills.spiderfoot.skill import start_spiderfoot_scan

        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill._api_post", side_effect=mock_post):
                start_spiderfoot_scan("target.com")
        assert posted["scanname"].startswith("scan-")

    def test_http_error_degraded(self):
        import httpx

        from familiar.skills.spiderfoot.skill import start_spiderfoot_scan

        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch(
                "familiar.skills.spiderfoot.skill._api_post",
                side_effect=Exception("connection refused"),
            ):
                result = start_spiderfoot_scan("example.com")
        assert result.get("format") == "degraded"


class TestSpiderFootFindings:
    def test_findings_parsed(self):
        from familiar.skills.spiderfoot.skill import get_scan_findings

        mock_data = [
            ["IP_ADDRESS", "1.2.3.4", "sfp_dns", "LOW"],
            ["EMAIL_ADDRESS", "a@b.com", "sfp_whois", "MEDIUM"],
        ]
        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill._api_get", return_value=mock_data):
                result = get_scan_findings("scan-001")

        assert result["total_count"] == 2
        assert result["risk_breakdown"]["low"] == 1
        assert result["risk_breakdown"]["medium"] == 1

    def test_limit_respected(self):
        from familiar.skills.spiderfoot.skill import get_scan_findings

        mock_data = [["IP_ADDRESS", f"1.2.3.{i}", "sfp", "LOW"] for i in range(200)]
        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill._api_get", return_value=mock_data):
                result = get_scan_findings("scan-001", limit=5)
        assert result["total_count"] == 5

    def test_empty_data(self):
        from familiar.skills.spiderfoot.skill import get_scan_findings

        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill._api_get", return_value=[]):
                result = get_scan_findings("scan-001")
        assert result["total_count"] == 0


class TestSpiderFootReport:
    def _setup_mocks(self, extra_findings=None):
        findings_list = extra_findings or [
            {
                "type": "IP_ADDRESS",
                "data": "1.2.3.4",
                "source_module": "sfp_dns",
                "risk_level": "low",
            },
            {
                "type": "EMAIL_ADDRESS",
                "data": "a@b.com",
                "source_module": "sfp_whois",
                "risk_level": "high",
            },
        ]
        status = {
            "status": "FINISHED",
            "target": "example.com",
            "elapsed_seconds": 60,
            "found_count": 2,
        }
        findings = {
            "findings": findings_list,
            "type_breakdown": {},
            "risk_breakdown": {"low": 1, "high": 1},
        }
        return status, findings

    def test_markdown_report(self):
        from familiar.skills.spiderfoot.skill import export_scan_report

        status, findings = self._setup_mocks()
        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill.get_scan_status", return_value=status):
                with patch(
                    "familiar.skills.spiderfoot.skill.get_scan_findings", return_value=findings
                ):
                    result = export_scan_report("s1", format="markdown")
        assert "example.com" in result["report"]
        assert result["finding_count"] == 2

    def test_json_report(self):
        from familiar.skills.spiderfoot.skill import export_scan_report

        status, findings = self._setup_mocks()
        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill.get_scan_status", return_value=status):
                with patch(
                    "familiar.skills.spiderfoot.skill.get_scan_findings", return_value=findings
                ):
                    result = export_scan_report("s1", format="json")
        assert "sections" in result
        assert isinstance(result["sections"]["high_risk"], list)

    def test_deduplication(self):
        from familiar.skills.spiderfoot.skill import export_scan_report

        # Same IP from two different modules = 1 unique finding
        dup_findings = [
            {
                "type": "IP_ADDRESS",
                "data": "1.2.3.4",
                "source_module": "sfp_dns",
                "risk_level": "low",
            },
            {
                "type": "IP_ADDRESS",
                "data": "1.2.3.4",
                "source_module": "sfp_passive",
                "risk_level": "low",
            },
        ]
        status = {"status": "FINISHED", "target": "t.com", "elapsed_seconds": 0, "found_count": 2}
        findings = {"findings": dup_findings, "type_breakdown": {}, "risk_breakdown": {"low": 2}}
        with patch("familiar.skills.spiderfoot.skill._spiderfoot_available", return_value=True):
            with patch("familiar.skills.spiderfoot.skill.get_scan_status", return_value=status):
                with patch(
                    "familiar.skills.spiderfoot.skill.get_scan_findings", return_value=findings
                ):
                    result = export_scan_report("s1", format="json")
        assert result["finding_count"] == 1  # deduplicated


class TestSpiderFootToolsRegistry:
    def test_tools_count(self):
        from familiar.skills.spiderfoot.skill import TOOLS

        assert len(TOOLS) == 5

    def test_all_required_fields(self):
        from familiar.skills.spiderfoot.skill import TOOLS

        for tool in TOOLS:
            assert "name" in tool
            assert "description" in tool
            assert "input_schema" in tool
            assert callable(tool["handler"])

    def test_all_tool_names_present(self):
        from familiar.skills.spiderfoot.skill import TOOLS

        names = {t["name"] for t in TOOLS}
        expected = {
            "start_spiderfoot_scan",
            "get_scan_status",
            "get_scan_findings",
            "list_spiderfoot_scans",
            "export_scan_report",
        }
        assert names == expected


# ===========================================================================
# P2 — OSINT investigation persistence
# ===========================================================================


@pytest.fixture
def inv_db(tmp_path):
    db_path = tmp_path / "investigations.db"
    with patch("familiar.skills.osint.investigations._get_db_path", return_value=db_path):
        yield db_path


class TestCreateInvestigation:
    def test_creates_with_open_status(self, inv_db):
        from familiar.skills.osint.investigations import create_investigation

        result = create_investigation("Case", "example.com", "Testing")
        assert result["id"] is not None
        assert result["status"] == "open"

    def test_requires_name(self, inv_db):
        from familiar.skills.osint.investigations import create_investigation

        assert "error" in create_investigation("", "example.com")

    def test_requires_target(self, inv_db):
        from familiar.skills.osint.investigations import create_investigation

        assert "error" in create_investigation("Name", "")


class TestUpdateStatus:
    def test_valid_transition(self, inv_db):
        from familiar.skills.osint.investigations import (
            create_investigation,
            update_investigation_status,
        )

        inv = create_investigation("Case", "t.com")
        result = update_investigation_status(inv["id"], "active")
        assert result["status"] == "active"

    def test_invalid_status(self, inv_db):
        from familiar.skills.osint.investigations import update_investigation_status

        assert "error" in update_investigation_status(1, "flying")

    def test_not_found(self, inv_db):
        from familiar.skills.osint.investigations import update_investigation_status

        assert "error" in update_investigation_status(9999, "active")

    def test_all_statuses(self, inv_db):
        from familiar.skills.osint.investigations import (
            VALID_STATUSES,
            create_investigation,
            update_investigation_status,
        )

        inv = create_investigation("Case", "t.com")
        for s in VALID_STATUSES:
            assert update_investigation_status(inv["id"], s)["status"] == s


class TestAddFinding:
    def test_adds_finding(self, inv_db):
        from familiar.skills.osint.investigations import add_finding, create_investigation

        inv = create_investigation("Case", "t.com")
        result = add_finding(inv["id"], "email", "a@b.com", "manual", "high")
        assert result["finding_type"] == "email"
        assert result["risk_level"] == "high"

    def test_invalid_type_becomes_other(self, inv_db):
        from familiar.skills.osint.investigations import add_finding, create_investigation

        inv = create_investigation("Case", "t.com")
        result = add_finding(inv["id"], "fake_type", "val", "manual")
        assert result["finding_type"] == "other"

    def test_invalid_risk_becomes_info(self, inv_db):
        from familiar.skills.osint.investigations import add_finding, create_investigation

        inv = create_investigation("Case", "t.com")
        result = add_finding(inv["id"], "ip", "1.2.3.4", "nmap", risk_level="super_high")
        assert result["risk_level"] == "info"

    def test_raw_data_stored(self, inv_db):
        import sqlite3

        from familiar.skills.osint.investigations import add_finding, create_investigation

        inv = create_investigation("Case", "t.com")
        raw = {"port": 22, "banner": "OpenSSH"}
        add_finding(inv["id"], "ip", "1.2.3.4", "nmap", raw_data=raw)
        conn = sqlite3.connect(str(inv_db))
        row = conn.execute("SELECT raw_data FROM findings WHERE value=?", ("1.2.3.4",)).fetchone()
        conn.close()
        assert json.loads(row[0]) == raw


class TestListInvestigations:
    def test_lists_all(self, inv_db):
        from familiar.skills.osint.investigations import create_investigation, list_investigations

        create_investigation("A", "a.com")
        create_investigation("B", "b.com")
        assert len(list_investigations()) >= 2

    def test_status_filter(self, inv_db):
        from familiar.skills.osint.investigations import (
            create_investigation,
            list_investigations,
            update_investigation_status,
        )

        inv = create_investigation("Active", "a.com")
        update_investigation_status(inv["id"], "active")
        create_investigation("Open", "b.com")
        active = list_investigations(status_filter="active")
        assert all(i["status"] == "active" for i in active)

    def test_finding_count(self, inv_db):
        from familiar.skills.osint.investigations import (
            add_finding,
            create_investigation,
            list_investigations,
        )

        inv = create_investigation("Case", "t.com")
        add_finding(inv["id"], "ip", "1.2.3.4", "nmap")
        add_finding(inv["id"], "email", "a@b.com", "harvester")
        results = list_investigations()
        case = next(r for r in results if r["id"] == inv["id"])
        assert case["finding_count"] == 2


class TestGetFindings:
    def test_grouped_results(self, inv_db):
        from familiar.skills.osint.investigations import (
            add_finding,
            create_investigation,
            get_investigation_findings,
        )

        inv = create_investigation("Case", "t.com")
        add_finding(inv["id"], "ip", "1.2.3.4", "nmap")
        add_finding(inv["id"], "email", "a@b.com", "harvester")
        result = get_investigation_findings(inv["id"])
        assert result["total_count"] == 2
        assert "ip" in result["type_groups"]

    def test_type_filter(self, inv_db):
        from familiar.skills.osint.investigations import (
            add_finding,
            create_investigation,
            get_investigation_findings,
        )

        inv = create_investigation("Case", "t.com")
        add_finding(inv["id"], "ip", "1.2.3.4", "nmap")
        add_finding(inv["id"], "email", "a@b.com", "harvester")
        result = get_investigation_findings(inv["id"], type_filter="email")
        assert result["total_count"] == 1
        assert result["findings"][0]["finding_type"] == "email"


class TestSummarize:
    def test_summary_by_risk(self, inv_db):
        from familiar.skills.osint.investigations import (
            add_finding,
            create_investigation,
            summarize_investigation,
        )

        inv = create_investigation("Case", "t.com")
        add_finding(inv["id"], "ip", "1.2.3.4", "nmap", "high")
        add_finding(inv["id"], "email", "a@b.com", "harvester", "critical")
        result = summarize_investigation(inv["id"])
        assert result["by_risk"]["high"] == 1
        assert result["by_risk"]["critical"] == 1
        assert len(result["notable_findings"]) == 2

    def test_not_found(self, inv_db):
        from familiar.skills.osint.investigations import summarize_investigation

        assert "error" in summarize_investigation(9999)


class TestCrossCaseDedup:
    def test_cross_case_count_detected(self, inv_db):
        from familiar.skills.osint.investigations import (
            add_finding,
            create_investigation,
            get_recent_findings,
        )

        inv1 = create_investigation("Case 1", "a.com")
        inv2 = create_investigation("Case 2", "b.com")
        add_finding(inv1["id"], "email", "shared@corp.com", "manual")
        add_finding(inv2["id"], "email", "shared@corp.com", "spiderfoot")

        findings = get_recent_findings(limit=50)
        shared = [f for f in findings if f.get("value") == "shared@corp.com"]
        assert any(f["cross_case_count"] > 1 for f in shared)

    def test_unique_finding_count_is_1(self, inv_db):
        from familiar.skills.osint.investigations import (
            add_finding,
            create_investigation,
            get_recent_findings,
        )

        inv = create_investigation("Solo", "a.com")
        add_finding(inv["id"], "ip", "192.168.1.1", "nmap")
        findings = get_recent_findings(limit=50)
        solo = [f for f in findings if f.get("value") == "192.168.1.1"]
        assert all(f["cross_case_count"] == 1 for f in solo)


# ===========================================================================
# Gate and job class completeness
# ===========================================================================


class TestOSINTGateIntegrity:
    def test_start_spiderfoot_scan_gated(self):
        from familiar.core.tool_executor import OSINT_GATED_TOOLS

        assert "start_spiderfoot_scan" in OSINT_GATED_TOOLS

    def test_investigation_tools_not_gated(self):
        from familiar.core.tool_executor import OSINT_GATED_TOOLS

        for tool in ("create_investigation", "add_finding", "summarize_investigation"):
            assert tool not in OSINT_GATED_TOOLS


class TestOSINTResearcherCompleteness:
    def test_spiderfoot_tools_in_weights(self):
        from familiar.jobs.osint_researcher import OSINT_RESEARCHER

        weights = OSINT_RESEARCHER.skill_weights
        for tool in (
            "start_spiderfoot_scan",
            "get_scan_status",
            "get_scan_findings",
            "list_spiderfoot_scans",
            "export_scan_report",
        ):
            assert tool in weights, f"{tool} missing from skill_weights"

    def test_investigation_tools_in_weights(self):
        from familiar.jobs.osint_researcher import OSINT_RESEARCHER

        weights = OSINT_RESEARCHER.skill_weights
        for tool in (
            "create_investigation",
            "add_finding",
            "list_investigations",
            "summarize_investigation",
        ):
            assert tool in weights, f"{tool} missing from skill_weights"

    def test_spiderfoot_source_referenced(self):
        from familiar.jobs.osint_researcher import OSINT_RESEARCHER

        assert "spiderfoot" in str(OSINT_RESEARCHER.workspace)
