# 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 OSINT skill functions."""

import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest


@pytest.fixture(autouse=True)
def isolated_auth_db(tmp_path):
    """Redirect the OSINT auth DB to a temp directory for each test."""
    db_path = tmp_path / "osint_auth.db"
    with patch("familiar.skills.osint.auth._get_db_path", return_value=db_path):
        yield db_path


class TestConfirmOsintTarget:
    def test_confirm_osint_target_creates_auth(self):
        from familiar.skills.osint.skill import confirm_osint_target

        result = confirm_osint_target(
            {
                "target": "target.com",
                "justification": "CTF research",
                "scope": "passive recon",
            }
        )
        assert isinstance(result, str)
        assert "target.com" in result
        assert "authorization" in result.lower() or "authorized" in result.lower()

    def test_confirm_osint_target_validates_empty_justification(self):
        from familiar.skills.osint.skill import confirm_osint_target

        result = confirm_osint_target(
            {"target": "target.com", "justification": "", "scope": "passive"}
        )
        assert isinstance(result, str)
        # Should refuse without justification
        assert "justification" in result.lower() or "required" in result.lower()

    def test_confirm_osint_target_returns_expiry(self):
        from familiar.skills.osint.skill import confirm_osint_target

        result = confirm_osint_target(
            {
                "target": "expiry-test.com",
                "justification": "pen test authorized",
                "scope": "full",
            }
        )
        assert isinstance(result, str)
        # Should mention expiry
        assert "valid until" in result.lower() or "expires" in result.lower() or "2026" in result


class TestDnsEnumerate:
    def test_dns_enumerate_no_dns_tools(self):
        from familiar.skills.osint.skill import dns_enumerate

        with patch.dict("sys.modules", {"dns": None, "dns.resolver": None}):
            with patch("shutil.which", return_value=None):
                result = dns_enumerate({"domain": "example.com"})
                assert isinstance(result, str)
                # Should degrade gracefully — no crash
                assert len(result) > 0

    def test_dns_enumerate_with_dig(self):
        from familiar.skills.osint.skill import dns_enumerate

        completed = MagicMock()
        completed.returncode = 0
        completed.stdout = "1.2.3.4\n"
        completed.stderr = ""

        # Patch to make dnspython unavailable but dig available
        def _which(name):
            if name == "dig":
                return "/usr/bin/dig"
            return None

        with patch.dict("sys.modules", {"dns": None, "dns.resolver": None}):
            with patch("shutil.which", side_effect=_which):
                with patch("familiar.skills.osint.skill._run", return_value=(0, "1.2.3.4\n", "")):
                    result = dns_enumerate({"domain": "example.com"})
                    assert isinstance(result, str)
                    assert "example.com" in result

    def test_dns_enumerate_returns_dict_with_domain(self):
        from familiar.skills.osint.skill import dns_enumerate

        def _which(name):
            if name == "dig":
                return "/usr/bin/dig"
            return None

        with patch.dict("sys.modules", {"dns": None, "dns.resolver": None}):
            with patch("shutil.which", side_effect=_which):
                with patch(
                    "familiar.skills.osint.skill._run",
                    return_value=(0, "1.2.3.4\n", ""),
                ):
                    result = dns_enumerate({"domain": "queried-domain.com"})
                    assert "queried-domain.com" in result


class TestWhoisLookup:
    def test_whois_lookup_no_tools(self):
        from familiar.skills.osint.skill import whois_lookup

        with patch.dict("sys.modules", {"whois": None}):
            with patch("shutil.which", return_value=None):
                result = whois_lookup({"target": "example.com"})
                assert isinstance(result, str)
                assert len(result) > 0  # graceful degradation message

    def test_whois_lookup_with_cli(self):
        from familiar.skills.osint.skill import whois_lookup

        whois_output = (
            "Domain Name: EXAMPLE.COM\n"
            "Registrar: Example Registrar, Inc.\n"
            "Registrar IANA ID: 12345\n"
            "Updated Date: 2025-01-01T00:00:00Z\n"
            "Creation Date: 2000-01-01T00:00:00Z\n"
            "Registry Expiry Date: 2030-01-01T00:00:00Z\n"
        )

        with patch.dict("sys.modules", {"whois": None}):
            with patch("shutil.which", return_value="/usr/bin/whois"):
                with patch(
                    "familiar.skills.osint.skill._run",
                    return_value=(0, whois_output, ""),
                ):
                    result = whois_lookup({"target": "example.com"})
                    assert isinstance(result, str)
                    assert len(result) > 0


class TestIpGeolocation:
    def test_ip_geolocation_returns_data(self):
        from familiar.skills.osint.skill import ip_geolocation

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "status": "success",
            "country": "US",
            "regionName": "California",
            "city": "Mountain View",
            "org": "AS15169 Google LLC",
            "lat": 37.4,
            "lon": -122.0,
            "query": "8.8.8.8",
        }

        with patch("httpx.get", return_value=mock_resp):
            result = ip_geolocation({"ip": "8.8.8.8"})
            assert isinstance(result, str)
            assert "US" in result
            assert "Mountain View" in result or "California" in result

    def test_ip_geolocation_private_ip_skips_api(self):
        from familiar.skills.osint.skill import ip_geolocation

        # Private IPs should either be noted as private or return gracefully
        # The skill does NOT check for private IP — it calls the API. We ensure no crash.
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "status": "fail",
            "message": "private range",
            "query": "192.168.1.1",
        }

        with patch("httpx.get", return_value=mock_resp):
            result = ip_geolocation({"ip": "192.168.1.1"})
            assert isinstance(result, str)
            assert len(result) > 0

    def test_ip_geolocation_handles_api_error(self):
        import httpx

        from familiar.skills.osint.skill import ip_geolocation

        mock_resp = MagicMock()
        mock_resp.status_code = 429
        mock_resp.request = MagicMock()

        with patch(
            "httpx.get",
            side_effect=httpx.HTTPStatusError(
                "Too Many Requests", request=mock_resp.request, response=mock_resp
            ),
        ):
            result = ip_geolocation({"ip": "8.8.8.8"})
            assert isinstance(result, str)
            assert len(result) > 0  # graceful degradation


class TestCertificateTransparency:
    def test_certificate_transparency_returns_certs(self):
        from familiar.skills.osint.skill import certificate_transparency

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = [
            {
                "name_value": "sub.example.com",
                "issuer_name": "Let's Encrypt",
                "not_before": "2026-01-01T00:00:00",
                "not_after": "2026-04-01T00:00:00",
            }
        ]

        with patch("httpx.get", return_value=mock_resp):
            result = certificate_transparency({"domain": "example.com"})
            assert isinstance(result, str)
            assert "example.com" in result
            assert "sub.example.com" in result

    def test_certificate_transparency_handles_404(self):
        import httpx

        from familiar.skills.osint.skill import certificate_transparency

        mock_resp = MagicMock()
        mock_resp.status_code = 404
        mock_resp.request = MagicMock()

        with patch(
            "httpx.get",
            side_effect=httpx.HTTPError("Not found"),
        ):
            result = certificate_transparency({"domain": "example.com"})
            assert isinstance(result, str)
            assert len(result) > 0  # graceful degradation


class TestHarvestEmails:
    def test_harvest_emails_missing_theharvester(self):
        from familiar.skills.osint.skill import harvest_emails

        with patch("shutil.which", return_value=None):
            with patch("os.path.isfile", return_value=False):
                result = harvest_emails({"domain": "example.com"})
                assert isinstance(result, str)
                assert "not installed" in result.lower() or "theHarvester" in result

    def test_harvest_emails_missing_theharvester_alt_name(self):
        from familiar.skills.osint.skill import harvest_emails

        def _which(name):
            return None  # neither theHarvester nor theHarvester.py

        with patch("shutil.which", side_effect=_which):
            with patch("os.path.isfile", return_value=False):
                result = harvest_emails({"domain": "example.com"})
                assert isinstance(result, str)
                assert (
                    "not installed" in result.lower()
                    or "theharvester" in result.lower()
                    or "install" in result.lower()
                )


class TestEnumerateSubdomains:
    def test_enumerate_subdomains_amass_missing(self):
        import socket as socket_mod

        from familiar.skills.osint.skill import enumerate_subdomains

        # When amass missing, falls back to DNS brute force.
        # The fallback catches socket.gaierror specifically, so we raise that.
        with patch("shutil.which", return_value=None):
            with patch("os.path.isfile", return_value=False):
                with patch(
                    "familiar.skills.osint.skill.socket.gethostbyname",
                    side_effect=socket_mod.gaierror("Name or service not known"),
                ):
                    result = enumerate_subdomains({"domain": "example.com"})
                    assert isinstance(result, str)
                    assert "example.com" in result


class TestShodanLookup:
    def test_shodan_lookup_no_api_key(self):
        from familiar.skills.osint.skill import shodan_lookup

        with patch.dict(os.environ, {"SHODAN_API_KEY": ""}):
            result = shodan_lookup({"query": "hostname:example.com"})
            assert isinstance(result, str)
            assert (
                "not configured" in result.lower()
                or "shodan" in result.lower()
                or "api_key" in result.lower()
                or "api key" in result.lower()
            )


class TestSearchBreachData:
    def test_search_breach_data_no_api_key(self):
        from familiar.skills.osint.skill import search_breach_data

        with patch.dict(os.environ, {"HIBP_API_KEY": ""}):
            result = search_breach_data({"email": "test@example.com"})
            assert isinstance(result, str)
            assert (
                "not configured" in result.lower()
                or "hibp" in result.lower()
                or "have i been pwned" in result.lower()
                or "api key" in result.lower()
            )
