# 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 security skills: Wazuh, CrowdSec, and Sysaudit."""

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

import pytest

# ---------------------------------------------------------------------------
# Wazuh tests
# ---------------------------------------------------------------------------


class TestWazuhAvailability:
    def test_wazuh_available_false_when_no_password(self):
        from familiar.skills.wazuh.skill import _wazuh_available

        with patch.dict(
            os.environ, {"WAZUH_PASSWORD": "", "WAZUH_HOST": "https://localhost:55000"}
        ):
            assert _wazuh_available() is False

    def test_wazuh_available_true_when_configured(self):
        from familiar.skills.wazuh.skill import _wazuh_available

        with patch.dict(
            os.environ, {"WAZUH_PASSWORD": "secret", "WAZUH_HOST": "https://localhost:55000"}
        ):
            assert _wazuh_available() is True


class TestGetWazuhAlerts:
    def test_get_wazuh_alerts_degrades_when_not_configured(self):
        from familiar.skills.wazuh.skill import get_wazuh_alerts

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=False):
            result = get_wazuh_alerts({})
            assert isinstance(result, str)
            assert "not configured" in result.lower() or "wazuh" in result.lower()

    def test_get_wazuh_alerts_returns_alerts(self):
        from familiar.skills.wazuh.skill import get_wazuh_alerts

        mock_search_result = {
            "aggregations": {
                "by_level": {
                    "buckets": [
                        {"key": "15", "doc_count": 3},
                        {"key": "12", "doc_count": 5},
                    ]
                }
            },
            "hits": {
                "hits": [
                    {
                        "_source": {
                            "rule": {"description": "Test alert", "level": 12},
                            "agent": {"name": "agent1"},
                            "@timestamp": "2026-01-01T00:00:00Z",
                            "timestamp": "2026-01-01T00:00:00Z",
                        }
                    }
                ]
            },
        }

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            with patch(
                "familiar.skills.wazuh.skill._indexer_search", return_value=mock_search_result
            ):
                result = get_wazuh_alerts({})
                assert isinstance(result, str)
                assert len(result) > 0
                # Should have found at least 1 alert
                assert "agent1" in result or "Test alert" in result

    def test_get_wazuh_alerts_severity_filter(self):
        from familiar.skills.wazuh.skill import get_wazuh_alerts

        minimal_result = {"hits": {"hits": []}}

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            with patch("familiar.skills.wazuh.skill._indexer_search", return_value=minimal_result):
                # Should not raise, just return "no alerts" message
                result = get_wazuh_alerts({"severity": "high"})
                assert isinstance(result, str)

    def test_get_wazuh_alerts_exception_returns_degraded(self):
        from familiar.skills.wazuh.skill import get_wazuh_alerts

        # _indexer_search catches all exceptions internally and returns {}.
        # When it returns {}, get_wazuh_alerts sees empty hits and returns a "no alerts" string.
        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            with patch(
                "familiar.skills.wazuh.skill._indexer_search",
                return_value={},
            ):
                result = get_wazuh_alerts({})
                # Empty result from indexer → "No alerts found" message
                assert isinstance(result, str)
                assert len(result) > 0
                assert "no" in result.lower() or "alerts" in result.lower()

    def test_get_wazuh_alerts_limit(self):
        from familiar.skills.wazuh.skill import get_wazuh_alerts

        hit = {
            "_source": {
                "rule": {"description": "Test", "level": 5},
                "agent": {"name": "host1"},
                "timestamp": "2026-01-01T00:00:00Z",
            }
        }
        mock_result = {"hits": {"hits": [hit] * 3}}

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            with patch("familiar.skills.wazuh.skill._indexer_search", return_value=mock_result):
                result = get_wazuh_alerts({"limit": 5})
                assert isinstance(result, str)


class TestGetWazuhAgents:
    def test_get_wazuh_agents_degrades(self):
        from familiar.skills.wazuh.skill import get_wazuh_agents

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=False):
            result = get_wazuh_agents({})
            assert "not configured" in result.lower() or "wazuh" in result.lower()

    def test_get_wazuh_agents_returns_list(self):
        from familiar.skills.wazuh.skill import get_wazuh_agents

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "data": {
                "affected_items": [
                    {
                        "id": "001",
                        "name": "agent1",
                        "status": "active",
                        "version": "v4.9.0",
                        "os": {"name": "Ubuntu"},
                        "ip": "192.168.1.1",
                    }
                ],
                "total_affected_items": 1,
            }
        }

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            with patch("familiar.skills.wazuh.skill._api_get", return_value=mock_resp):
                result = get_wazuh_agents({})
                assert "agent1" in result
                assert "active" in result


class TestGetVulnerabilitySummary:
    def test_get_vulnerability_summary_degrades(self):
        from familiar.skills.wazuh.skill import get_vulnerability_summary

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=False):
            result = get_vulnerability_summary({})
            assert "not configured" in result.lower() or "wazuh" in result.lower()

    def test_get_vulnerability_summary_aggregates(self):
        from familiar.skills.wazuh.skill import get_vulnerability_summary

        agents_resp = MagicMock()
        agents_resp.status_code = 200
        agents_resp.json.return_value = {
            "data": {
                "affected_items": [{"id": "001", "name": "host1"}],
                "total_affected_items": 1,
            }
        }

        vulns_resp = MagicMock()
        vulns_resp.status_code = 200
        vulns_resp.json.return_value = {
            "data": {
                "affected_items": [{"critical": 1, "high": 2, "medium": 3, "low": 4, "none": 0}],
                "total_affected_items": 1,
            }
        }

        def _mock_api_get(path, params=None):
            if "/agents" in path:
                return agents_resp
            return vulns_resp

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            with patch("familiar.skills.wazuh.skill._api_get", side_effect=_mock_api_get):
                result = get_vulnerability_summary({})
                assert isinstance(result, str)
                assert "critical" in result.lower() or "vulnerability" in result.lower()


class TestGetCompliancePosture:
    def test_get_compliance_posture_degrades(self):
        from familiar.skills.wazuh.skill import get_compliance_posture

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=False):
            result = get_compliance_posture({})
            assert "not configured" in result.lower() or "wazuh" in result.lower()

    def test_get_compliance_posture_returns_results(self):
        from familiar.skills.wazuh.skill import get_compliance_posture

        agents_resp = MagicMock()
        agents_resp.status_code = 200
        agents_resp.json.return_value = {
            "data": {
                "affected_items": [{"id": "001", "name": "host1"}],
                "total_affected_items": 1,
            }
        }

        sca_resp = MagicMock()
        sca_resp.status_code = 200
        sca_resp.json.return_value = {
            "data": {
                "affected_items": [
                    {
                        "policy_id": "cis_debian_linux_7",
                        "name": "CIS Benchmark for Debian Linux",
                        "passed": 40,
                        "failed": 10,
                        "pass": 40,
                        "fail": 10,
                        "score": 80,
                    }
                ],
                "total_affected_items": 1,
            }
        }

        def _mock_api_get(path, params=None):
            if "/agents" in path and "/sca" not in path:
                return agents_resp
            return sca_resp

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            with patch("familiar.skills.wazuh.skill._api_get", side_effect=_mock_api_get):
                result = get_compliance_posture({"framework": "cis"})
                assert isinstance(result, str)
                # framework label should appear
                assert "cis" in result.lower() or "compliance" in result.lower()


class TestRunWazuhScaScan:
    def test_run_wazuh_sca_scan_requires_confirm(self):
        from familiar.skills.wazuh.skill import run_wazuh_sca_scan

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=True):
            # The skill takes data dict; agent_id is required
            result = run_wazuh_sca_scan({"agent_id": "001"})
            assert isinstance(result, str)

    def test_run_wazuh_sca_scan_degrades(self):
        from familiar.skills.wazuh.skill import run_wazuh_sca_scan

        with patch("familiar.skills.wazuh.skill._wazuh_available", return_value=False):
            result = run_wazuh_sca_scan({"agent_id": "001"})
            assert "not configured" in result.lower() or "wazuh" in result.lower()


# ---------------------------------------------------------------------------
# CrowdSec tests
# ---------------------------------------------------------------------------


class TestCrowdSecAvailability:
    def test_crowdsec_available_false_when_no_key(self):
        from familiar.skills.crowdsec.skill import _crowdsec_available

        with patch.dict(
            os.environ, {"CROWDSEC_API_KEY": "", "CROWDSEC_URL": "http://localhost:8080"}
        ):
            assert _crowdsec_available() is False

    def test_crowdsec_available_true(self):
        from familiar.skills.crowdsec.skill import _crowdsec_available

        with patch.dict(
            os.environ, {"CROWDSEC_API_KEY": "somekey", "CROWDSEC_URL": "http://localhost:8080"}
        ):
            assert _crowdsec_available() is True


class TestGetCrowdSecDecisions:
    def test_get_crowdsec_decisions_degrades(self):
        from familiar.skills.crowdsec.skill import get_crowdsec_decisions

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=False):
            result = get_crowdsec_decisions({})
            assert isinstance(result, str)
            assert "not configured" in result.lower() or "crowdsec" in result.lower()

    def test_get_crowdsec_decisions_returns_list(self):
        from familiar.skills.crowdsec.skill import get_crowdsec_decisions

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = [
            {
                "id": 1,
                "type": "ban",
                "value": "1.2.3.4",
                "duration": "24h",
                "scenario": "crowdsecurity/ssh-bf",
                "origin": "crowdsec",
            }
        ]

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=True):
            with patch("familiar.skills.crowdsec.skill._api_get", return_value=mock_resp):
                result = get_crowdsec_decisions({})
                assert "1.2.3.4" in result
                assert "ban" in result

    def test_get_crowdsec_decisions_type_filter(self):
        from familiar.skills.crowdsec.skill import get_crowdsec_decisions

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = [
            {
                "id": 1,
                "type": "ban",
                "value": "1.2.3.4",
                "duration": "24h",
                "scenario": "ssh-bf",
                "origin": "crowdsec",
            },
            {
                "id": 2,
                "type": "captcha",
                "value": "5.6.7.8",
                "duration": "1h",
                "scenario": "web-scan",
                "origin": "crowdsec",
            },
        ]

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=True):
            with patch("familiar.skills.crowdsec.skill._api_get", return_value=mock_resp):
                # With type filter, API filters at the server level; our test ensures no exception
                result = get_crowdsec_decisions({"type": "ban"})
                assert isinstance(result, str)
                assert "1.2.3.4" in result


class TestGetCrowdSecAlerts:
    def test_get_crowdsec_alerts_degrades(self):
        from familiar.skills.crowdsec.skill import get_crowdsec_alerts

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=False):
            result = get_crowdsec_alerts({})
            assert "not configured" in result.lower() or "crowdsec" in result.lower()

    def test_get_crowdsec_alerts_returns_list(self):
        from familiar.skills.crowdsec.skill import get_crowdsec_alerts

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = [
            {
                "id": 1,
                "scenario": "ssh-bf",
                "decisions": [],
                "source": {"ip": "5.5.5.5", "cn": "RU"},
                "created_at": "2026-01-01T00:00:00Z",
                "events_count": 10,
            }
        ]

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=True):
            with patch("familiar.skills.crowdsec.skill._api_get", return_value=mock_resp):
                result = get_crowdsec_alerts({})
                assert "5.5.5.5" in result
                assert "ssh-bf" in result


class TestAddCrowdSecDecision:
    def test_add_crowdsec_decision_requires_ip(self):
        from familiar.skills.crowdsec.skill import add_crowdsec_decision

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=True):
            result = add_crowdsec_decision({})
            assert "ip is required" in result.lower() or "required" in result.lower()

    def test_add_crowdsec_decision_success(self):
        from familiar.skills.crowdsec.skill import add_crowdsec_decision

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = [{"nbinserted": 1}]

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=True):
            with patch("familiar.skills.crowdsec.skill._api_post", return_value=mock_resp):
                result = add_crowdsec_decision(
                    {"ip": "1.2.3.4", "duration": "24h", "reason": "test block", "type": "ban"}
                )
                assert isinstance(result, str)
                assert "1.2.3.4" in result


class TestDeleteCrowdSecDecision:
    def test_delete_crowdsec_decision_success(self):
        from familiar.skills.crowdsec.skill import delete_crowdsec_decision

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"nbdeleted": 1}

        with patch("familiar.skills.crowdsec.skill._crowdsec_available", return_value=True):
            with patch("familiar.skills.crowdsec.skill._api_delete", return_value=mock_resp):
                result = delete_crowdsec_decision({"id": 42})
                assert isinstance(result, str)
                # Should confirm removal
                assert "decision" in result.lower() or "removed" in result.lower()


# ---------------------------------------------------------------------------
# Sysaudit tests
# ---------------------------------------------------------------------------


class TestRunLynisAudit:
    def test_run_lynis_audit_missing_tool(self):
        from familiar.skills.sysaudit.skill import run_lynis_audit

        with patch("familiar.skills.sysaudit.skill._tool_available", return_value=False):
            result = run_lynis_audit({})
            assert isinstance(result, str)
            assert "lynis" in result.lower()
            assert "not installed" in result.lower() or "install" in result.lower()

    def test_run_lynis_audit_parses_output(self):
        from familiar.skills.sysaudit.skill import run_lynis_audit

        lynis_stdout = (
            "  Hardening index : 67\n"
            "  Warning: something [TEST-001]\n"
            "  Suggestion: do thing [SYST-001]\n"
        )

        with patch("shutil.which", return_value="/usr/bin/lynis"):
            with patch(
                "familiar.skills.sysaudit.skill._run",
                return_value=(0, lynis_stdout, ""),
            ):
                result = run_lynis_audit({})
                assert isinstance(result, str)
                assert "67" in result or "hardening" in result.lower()


class TestScanNetworkPorts:
    def test_scan_network_ports_missing_nmap(self):
        from familiar.skills.sysaudit.skill import scan_network_ports

        with patch("familiar.skills.sysaudit.skill._tool_available", return_value=False):
            result = scan_network_ports({"target": "192.168.1.1"})
            assert isinstance(result, str)
            assert "nmap" in result.lower()
            assert "not installed" in result.lower() or "install" in result.lower()

    def test_scan_network_ports_basic_scan(self):
        from familiar.skills.sysaudit.skill import scan_network_ports

        nmap_stdout = "Starting Nmap...\nNmap done: 1 IP address (1 host up)"

        with patch("familiar.skills.sysaudit.skill._tool_available", return_value=True):
            with patch(
                "familiar.skills.sysaudit.skill._run",
                return_value=(0, nmap_stdout, ""),
            ):
                result = scan_network_ports({"target": "192.168.1.1"})
                assert isinstance(result, str)
                assert "192.168.1.1" in result or "nmap" in result.lower()


class TestScanContainerVulnerabilities:
    def test_scan_container_vulnerabilities_missing_trivy(self):
        from familiar.skills.sysaudit.skill import scan_container_vulnerabilities

        with patch("familiar.skills.sysaudit.skill._tool_available", return_value=False):
            result = scan_container_vulnerabilities({"image": "nginx:latest"})
            assert isinstance(result, str)
            assert "trivy" in result.lower()
            assert "not installed" in result.lower() or "install" in result.lower()

    def test_scan_container_vulnerabilities_parses_json(self):
        from familiar.skills.sysaudit.skill import scan_container_vulnerabilities

        trivy_json = json.dumps(
            {
                "Results": [
                    {
                        "Target": "nginx:latest",
                        "Vulnerabilities": [
                            {
                                "Severity": "CRITICAL",
                                "VulnerabilityID": "CVE-2025-0001",
                                "PkgName": "openssl",
                                "InstalledVersion": "1.1.1",
                                "FixedVersion": "1.1.2",
                                "Title": "test vuln",
                            }
                        ],
                    }
                ]
            }
        )

        with patch("familiar.skills.sysaudit.skill._tool_available", return_value=True):
            with patch(
                "familiar.skills.sysaudit.skill._run",
                return_value=(0, trivy_json, ""),
            ):
                result = scan_container_vulnerabilities({"image": "nginx:latest"})
                assert isinstance(result, str)
                assert "CRITICAL" in result or "CVE-2025-0001" in result


class TestGetSuricataAlerts:
    def test_get_suricata_alerts_no_log_file(self):
        from familiar.skills.sysaudit.skill import get_suricata_alerts

        with patch.dict(os.environ, {"SURICATA_EVE_LOG": "/nonexistent/path/eve.json"}):
            # Reload module-level SURICATA_LOG with patched path
            import familiar.skills.sysaudit.skill as sysaudit_mod

            orig = sysaudit_mod.SURICATA_LOG
            sysaudit_mod.SURICATA_LOG = Path("/nonexistent/path/eve.json")
            try:
                result = get_suricata_alerts({})
                assert isinstance(result, str)
                # Should say log not found or return empty
                assert (
                    "not found" in result.lower()
                    or "suricata" in result.lower()
                    or "install" in result.lower()
                )
            finally:
                sysaudit_mod.SURICATA_LOG = orig

    def test_get_suricata_alerts_parses_eve_json(self):
        import familiar.skills.sysaudit.skill as sysaudit_mod
        from familiar.skills.sysaudit.skill import get_suricata_alerts

        # Create a temporary EVE JSON log file with alert events.
        # The sysaudit skill checks for '"event_type":"alert"' (no spaces),
        # so we must use compact JSON separators.
        alert_event = json.dumps(
            {
                "timestamp": "2026-01-01T00:00:00Z",
                "event_type": "alert",
                "src_ip": "10.0.0.5",
                "dest_ip": "10.0.0.1",
                "dest_port": 80,
                "alert": {
                    "signature": "ET SCAN Nmap",
                    "category": "Attempted Information Leak",
                    "severity": 2,
                },
            },
            separators=(",", ":"),
        )
        dns_event = json.dumps(
            {"timestamp": "2026-01-01T00:00:01Z", "event_type": "dns"},
            separators=(",", ":"),
        )

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
            tmp.write(alert_event + "\n")
            tmp.write(dns_event + "\n")
            tmp_path = tmp.name

        orig = sysaudit_mod.SURICATA_LOG
        sysaudit_mod.SURICATA_LOG = Path(tmp_path)
        try:
            result = get_suricata_alerts({})
            assert isinstance(result, str)
            assert "10.0.0.5" in result or "ET SCAN" in result or "Nmap" in result
        finally:
            sysaudit_mod.SURICATA_LOG = orig
            os.unlink(tmp_path)
