"""
tests/test_vaulthound.py — VaultHound v1.0.0
Comprehensive test suite — 80+ tests, no real network calls.
"""

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

sys.path.insert(0, str(Path(__file__).parent.parent))

import pytest

from core.patterns import PATTERNS, get_patterns, Pattern
from core.scanner import Scanner
from utils.models import ScanConfig, ScanMode, ScanResult, SecretFinding, Severity


# ══════════════════════════════════════════════════════════════════════════════
# Fixtures
# ══════════════════════════════════════════════════════════════════════════════

def make_finding(**kwargs) -> SecretFinding:
    defaults = dict(
        pattern_name="AWS Access Key ID",
        severity="critical",
        match="AKIAIOSFODNN7EXAMPLE",
        source="/repo/.env",
        line_number=5,
        line_content='AWS_KEY = "AKIA****MPLE"',
        context="AWS access key",
    )
    defaults.update(kwargs)
    return SecretFinding(**defaults)


def make_result(**kwargs) -> ScanResult:
    r = ScanResult(target="example.com", mode="url", start_time="20260101_120000")
    for k, v in kwargs.items():
        setattr(r, k, v)
    return r


# ══════════════════════════════════════════════════════════════════════════════
# Severity
# ══════════════════════════════════════════════════════════════════════════════

class TestSeverity:
    def test_critical_rank_highest(self):   assert Severity.rank("critical") > Severity.rank("high")
    def test_high_rank(self):               assert Severity.rank("high") > Severity.rank("medium")
    def test_medium_rank(self):             assert Severity.rank("medium") > Severity.rank("low")
    def test_low_rank(self):               assert Severity.rank("low") > Severity.rank("info")
    def test_info_rank_lowest(self):        assert Severity.rank("info") > 0
    def test_unknown_returns_zero(self):    assert Severity.rank("bogus") == 0
    def test_case_insensitive(self):        assert Severity.rank("CRITICAL") == Severity.rank("critical")


# ══════════════════════════════════════════════════════════════════════════════
# SecretFinding
# ══════════════════════════════════════════════════════════════════════════════

class TestSecretFinding:
    def test_display_match_redacts(self):
        f = make_finding(match="AKIAIOSFODNN7EXAMPLE")
        assert "AKIAIOSFODNN7EXAMPLE" not in f.display_match

    def test_display_match_short_key(self):
        f = make_finding(match="short")
        dm = f.display_match
        assert "****" in dm

    def test_display_match_keeps_prefix(self):
        f = make_finding(match="sk-TESTING_FAKE_NOT_REAL_KEY_XYZ1234")
        assert f.display_match.startswith("sk-TES")

    def test_to_dict_returns_dict(self):
        assert isinstance(make_finding().to_dict(), dict)

    def test_to_dict_no_raw_match(self):
        f = make_finding(match="AKIAIOSFODNN7EXAMPLE")
        d = f.to_dict()
        assert "AKIAIOSFODNN7EXAMPLE" not in str(d)

    def test_to_dict_has_required_fields(self):
        d = make_finding().to_dict()
        for key in ["pattern_name", "severity", "match", "source"]:
            assert key in d

    def test_redacted_flag_true_by_default(self):
        assert make_finding().redacted is True


# ══════════════════════════════════════════════════════════════════════════════
# ScanResult
# ══════════════════════════════════════════════════════════════════════════════

class TestScanResult:
    def test_total_count(self):
        r = make_result(findings=[make_finding(), make_finding(severity="high")])
        assert r.total == 2

    def test_critical_count(self):
        r = make_result(findings=[
            make_finding(severity="critical"),
            make_finding(severity="critical"),
            make_finding(severity="high"),
        ])
        assert r.critical_count == 2

    def test_high_count(self):
        r = make_result(findings=[make_finding(severity="high")])
        assert r.high_count == 1

    def test_medium_count(self):
        r = make_result(findings=[make_finding(severity="medium")])
        assert r.medium_count == 1

    def test_low_count(self):
        r = make_result(findings=[make_finding(severity="low")])
        assert r.low_count == 1

    def test_by_pattern(self):
        r = make_result(findings=[
            make_finding(pattern_name="AWS Access Key ID"),
            make_finding(pattern_name="AWS Access Key ID"),
            make_finding(pattern_name="GitHub PAT"),
        ])
        assert r.by_pattern["AWS Access Key ID"] == 2
        assert r.by_pattern["GitHub PAT"] == 1

    def test_filter_by_severity_critical_only(self):
        r = make_result(findings=[
            make_finding(severity="critical"),
            make_finding(severity="low"),
            make_finding(severity="info"),
        ])
        filtered = r.filter_by_severity("high")
        assert len(filtered) == 1
        assert filtered[0].severity == "critical"

    def test_filter_by_severity_all(self):
        r = make_result(findings=[
            make_finding(severity="critical"),
            make_finding(severity="low"),
        ])
        assert len(r.filter_by_severity("info")) == 2

    def test_to_dict_structure(self):
        r = make_result()
        d = r.to_dict()
        for key in ["target", "total_findings", "critical", "high", "medium", "low", "findings"]:
            assert key in d

    def test_by_source_groups_correctly(self):
        r = make_result(findings=[
            make_finding(source="/a.env"),
            make_finding(source="/a.env"),
            make_finding(source="/b.js"),
        ])
        assert len(r.by_source["/a.env"]) == 2
        assert len(r.by_source["/b.js"]) == 1


# ══════════════════════════════════════════════════════════════════════════════
# ScanConfig
# ══════════════════════════════════════════════════════════════════════════════

class TestScanConfig:
    def test_default_mode_url(self):
        cfg = ScanConfig(target="https://example.com")
        assert cfg.mode == ScanMode.URL

    def test_dir_mode(self):
        cfg = ScanConfig(target="/tmp/repo", mode=ScanMode.DIR)
        assert cfg.mode == ScanMode.DIR

    def test_default_severity_low(self):
        cfg = ScanConfig(target="x")
        assert cfg.min_severity == "low"

    def test_default_scan_js_true(self):
        cfg = ScanConfig(target="x")
        assert cfg.scan_js is True

    def test_to_dict_has_target(self):
        cfg = ScanConfig(target="test.com")
        assert cfg.to_dict()["target"] == "test.com"

    def test_to_dict_has_mode(self):
        cfg = ScanConfig(target="x", mode=ScanMode.DIR)
        assert cfg.to_dict()["mode"] == "dir"


# ══════════════════════════════════════════════════════════════════════════════
# Patterns
# ══════════════════════════════════════════════════════════════════════════════

class TestPatterns:
    def test_patterns_not_empty(self):
        assert len(PATTERNS) > 30

    def test_all_have_name(self):
        for p in PATTERNS:
            assert p.name and isinstance(p.name, str)

    def test_all_have_valid_severity(self):
        valid = {"critical", "high", "medium", "low", "info"}
        for p in PATTERNS:
            assert p.severity in valid, f"{p.name} has invalid severity: {p.severity}"

    def test_all_have_compiled_regex(self):
        import re
        for p in PATTERNS:
            assert hasattr(p.regex, "search"), f"{p.name} has invalid regex"

    def test_aws_access_key_pattern_exists(self):
        names = [p.name for p in PATTERNS]
        assert any("AWS Access Key" in n for n in names)

    def test_aws_secret_pattern_exists(self):
        names = [p.name for p in PATTERNS]
        assert any("AWS Secret" in n for n in names)

    def test_github_pat_pattern_exists(self):
        names = [p.name for p in PATTERNS]
        assert any("GitHub" in n for n in names)

    def test_stripe_pattern_exists(self):
        names = [p.name for p in PATTERNS]
        assert any("Stripe" in n for n in names)

    def test_private_key_pattern_exists(self):
        names = [p.name for p in PATTERNS]
        assert any("Private Key" in n for n in names)

    def test_get_patterns_returns_list(self):
        assert isinstance(get_patterns(), list)

    def test_get_patterns_no_custom_same_as_default(self):
        assert get_patterns() == get_patterns(None)


# ══════════════════════════════════════════════════════════════════════════════
# Scanner — pattern matching
# ══════════════════════════════════════════════════════════════════════════════

class TestScannerPatternMatching:
    """Tests that the scanner correctly detects real secrets."""

    def setup_method(self):
        self.scanner = Scanner(min_severity="info")

    def test_detects_aws_access_key(self):
        text = 'AWS_ACCESS_KEY_ID = "AKIAT3STINGFAK3K3Y123"\n'
        findings = self.scanner.scan_text(text, "test.env")
        names = [f.pattern_name for f in findings]
        assert any("AWS" in n for n in names)

    def test_detects_github_pat(self):
        text = 'GITHUB_TOKEN = "ghp_TESTING_FAKE_NOT_REAL_TOKEN_ABCDE"\n'
        findings = self.scanner.scan_text(text, "test.env")
        names = [f.pattern_name for f in findings]
        assert any("GitHub" in n for n in names)

    def test_detects_stripe_secret_key(self):
        text = 'STRIPE_KEY = "sk_live_TESTING_FAKE_NOT_REAL_KEY"\n'
        findings = self.scanner.scan_text(text, "test.js")
        names = [f.pattern_name for f in findings]
        assert any("Stripe" in n for n in names)

    def test_detects_sendgrid_key(self):
        text = 'SG.' + 'a'*22 + '.' + 'B'*43 + '\n'
        findings = self.scanner.scan_text(text, "test.env")
        names = [f.pattern_name for f in findings]
        assert any("SendGrid" in n for n in names)

    def test_detects_openai_key(self):
        text = 'OPENAI_API_KEY = "sk-TESTING_FAKE_NOT_REAL_OPENAI_KEY_ABCDEFGHIJKLMNO"\n'
        findings = self.scanner.scan_text(text, "config.py")
        names = [f.pattern_name for f in findings]
        assert any("OpenAI" in n for n in names)

    def test_detects_rsa_private_key(self):
        text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n"
        findings = self.scanner.scan_text(text, "id_rsa")
        names = [f.pattern_name for f in findings]
        assert any("RSA" in n for n in names)

    def test_detects_gcp_key(self):
        text = 'GCP_KEY = "AIzaTESTING_FAKE_NOT_REAL_GCP_KEY_XYZ"\n'
        findings = self.scanner.scan_text(text, "config.py")
        names = [f.pattern_name for f in findings]
        assert any("GCP" in n for n in names)

    def test_detects_slack_webhook(self):
        text = 'SLACK_URL = "https://hooks.slack.com/services/TABC123/BABC456/abcdefghijklmnop"\n'
        findings = self.scanner.scan_text(text, "app.py")
        names = [f.pattern_name for f in findings]
        assert any("Slack" in n for n in names)

    def test_detects_jwt(self):
        text = "token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U\n"
        findings = self.scanner.scan_text(text, "app.js")
        names = [f.pattern_name for f in findings]
        assert any("JWT" in n for n in names)

    def test_no_false_positive_placeholder(self):
        text = 'API_KEY = "your_api_key_here"\n'
        findings = self.scanner.scan_text(text, "README.md")
        # Should not trigger on obvious placeholders
        high_sev = [f for f in findings if f.severity in ("critical", "high")]
        assert len(high_sev) == 0

    def test_skips_comment_lines(self):
        text = '# AWS_KEY = "AKIAIOSFODNN7EXAMPLE"\n'
        findings = self.scanner.scan_text(text, "test.py")
        assert len(findings) == 0

    def test_returns_list(self):
        assert isinstance(self.scanner.scan_text("hello", "x"), list)

    def test_empty_content_returns_empty(self):
        assert self.scanner.scan_text("", "x") == []

    def test_source_preserved_in_finding(self):
        text = 'TOKEN = "ghp_TESTING_FAKE_NOT_REAL_TOKEN_ABCDE"\n'
        findings = self.scanner.scan_text(text, "myfile.env")
        if findings:
            assert findings[0].source == "myfile.env"

    def test_line_number_recorded(self):
        text = "line1\nline2\nTOKEN = ghp_TESTING_FAKE_NOT_REAL_TOKEN_ABCDE\n"
        findings = self.scanner.scan_text(text, "x")
        if findings:
            assert findings[0].line_number == 3

    def test_match_is_redacted_in_finding(self):
        text = 'KEY = "AKIAIOSFODNN7EXAMPLE"\n'
        findings = self.scanner.scan_text(text, "x")
        for f in findings:
            assert f.redacted is True

    def test_min_severity_filters(self):
        scanner_high = Scanner(min_severity="high")
        scanner_info = Scanner(min_severity="info")
        text = 'host = "192.168.1.1"\n'
        # info-level findings should be filtered at high min_severity
        high_findings = scanner_high.scan_text(text, "x")
        info_findings = scanner_info.scan_text(text, "x")
        assert len(info_findings) >= len(high_findings)


# ══════════════════════════════════════════════════════════════════════════════
# Scanner — file scanning
# ══════════════════════════════════════════════════════════════════════════════

class TestScannerFileScan:
    def test_scan_file_returns_list(self):
        scanner = Scanner()
        with tempfile.NamedTemporaryFile(suffix=".env", mode="w", delete=False) as f:
            f.write('DB_PASS = "supersecret123!"\n')
            path = Path(f.name)
        try:
            findings = scanner.scan_file(path)
            assert isinstance(findings, list)
        finally:
            path.unlink(missing_ok=True)

    def test_scan_file_detects_secret(self):
        scanner = Scanner(min_severity="info")
        with tempfile.NamedTemporaryFile(suffix=".env", mode="w", delete=False) as f:
            f.write('GITHUB_TOKEN = "ghp_TESTING_FAKE_NOT_REAL_TOKEN_ABCDE"\n')
            path = Path(f.name)
        try:
            findings = scanner.scan_file(path)
            assert len(findings) > 0
        finally:
            path.unlink(missing_ok=True)

    def test_scan_file_nonexistent_returns_empty(self):
        scanner = Scanner()
        findings = scanner.scan_file(Path("/nonexistent/file.env"))
        assert findings == []

    def test_scan_file_source_is_filepath(self):
        scanner = Scanner(min_severity="info")
        with tempfile.NamedTemporaryFile(suffix=".env", mode="w", delete=False) as f:
            f.write('GITHUB_TOKEN = "ghp_TESTING_FAKE_NOT_REAL_TOKEN_ABCDE"\n')
            path = Path(f.name)
        try:
            findings = scanner.scan_file(path)
            if findings:
                assert str(path) in findings[0].source
        finally:
            path.unlink(missing_ok=True)


# ══════════════════════════════════════════════════════════════════════════════
# Directory scanner
# ══════════════════════════════════════════════════════════════════════════════

class TestDirScan:
    def test_scan_directory_finds_secrets(self):
        from core.dir_scan import scan_directory
        scanner = Scanner(min_severity="info")

        with tempfile.TemporaryDirectory() as tmp:
            # Use .py file — guaranteed to be scanned on all platforms
            secret_file = Path(tmp) / "config.py"
            secret_file.write_text('GITHUB_TOKEN = "ghp_TESTING_FAKE_NOT_REAL_TOKEN_ABCDE"\n')

            findings, files = scan_directory(tmp, scanner, threads=2)
            assert len(findings) > 0
            assert len(files) > 0

    def test_scan_directory_skips_pycache(self):
        from core.dir_scan import scan_directory
        scanner = Scanner(min_severity="info")

        with tempfile.TemporaryDirectory() as tmp:
            cache = Path(tmp) / "__pycache__"
            cache.mkdir()
            (cache / "evil.py").write_text('KEY = "ghp_TESTING_FAKE_NOT_REAL_TOKEN_ABCDE"\n')
            (Path(tmp) / "clean.py").write_text("print('hello')\n")

            findings, files = scan_directory(tmp, scanner, threads=2)
            sources = [f.source for f in findings]
            assert not any("__pycache__" in s for s in sources)

    def test_scan_directory_empty_dir(self):
        from core.dir_scan import scan_directory
        scanner = Scanner()
        with tempfile.TemporaryDirectory() as tmp:
            findings, files = scan_directory(tmp, scanner, threads=2)
            assert findings == []

    def test_scan_directory_nonexistent(self):
        from core.dir_scan import scan_directory
        scanner = Scanner()
        findings, files = scan_directory("/nonexistent/path", scanner)
        assert findings == []
        assert files == []

    def test_scan_directory_returns_files_list(self):
        from core.dir_scan import scan_directory
        scanner = Scanner()
        with tempfile.TemporaryDirectory() as tmp:
            (Path(tmp) / "app.py").write_text("print('hello')\n")
            findings, files = scan_directory(tmp, scanner, threads=2)
            assert isinstance(files, list)


# ══════════════════════════════════════════════════════════════════════════════
# Reports
# ══════════════════════════════════════════════════════════════════════════════

class TestReports:
    def setup_method(self):
        self.result = make_result(findings=[
            make_finding(severity="critical", pattern_name="AWS Access Key ID"),
            make_finding(severity="high", pattern_name="GitHub PAT"),
            make_finding(severity="low", pattern_name="Generic Password"),
        ])
        self.result.end_time = "20260101_130000"

    def test_json_report_creates_file(self):
        from output.reports import generate_json_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.json"
            generate_json_report(self.result, out)
            assert out.exists()

    def test_json_report_valid_json(self):
        from output.reports import generate_json_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.json"
            generate_json_report(self.result, out)
            data = json.loads(out.read_text())
            assert "target" in data
            assert "findings" in data

    def test_json_report_no_raw_secrets(self):
        from output.reports import generate_json_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.json"
            generate_json_report(self.result, out)
            content = out.read_text()
            assert "AKIAIOSFODNN7EXAMPLE" not in content

    def test_html_report_creates_file(self):
        from output.reports import generate_html_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.html"
            generate_html_report(self.result, out)
            assert out.exists()

    def test_html_report_has_doctype(self):
        from output.reports import generate_html_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.html"
            generate_html_report(self.result, out)
            assert "<!DOCTYPE html>" in out.read_text()

    def test_html_report_contains_target(self):
        from output.reports import generate_html_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.html"
            generate_html_report(self.result, out)
            assert "example.com" in out.read_text()

    def test_html_report_contains_pattern_names(self):
        from output.reports import generate_html_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.html"
            generate_html_report(self.result, out)
            content = out.read_text()
            assert "AWS Access Key ID" in content

    def test_html_report_no_raw_secrets(self):
        from output.reports import generate_html_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.html"
            generate_html_report(self.result, out)
            assert "AKIAIOSFODNN7EXAMPLE" not in out.read_text()

    def test_text_report_creates_file(self):
        from output.reports import generate_text_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.txt"
            generate_text_report(self.result, out)
            assert out.exists()

    def test_text_report_contains_target(self):
        from output.reports import generate_text_report
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.txt"
            generate_text_report(self.result, out)
            assert "example.com" in out.read_text()

    def test_text_report_empty_findings(self):
        from output.reports import generate_text_report
        empty = make_result()
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "report.txt"
            generate_text_report(empty, out)
            assert out.exists()
