# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Integration tests for dual-layer scanning (regex + Semgrep).

DEV-521: Tests for the DualLayerOrchestrator, deduplication,
vendor exclusion, and graceful fallback.
"""

from __future__ import annotations

from pathlib import Path
import pytest

from mergeguide.engine.orchestrator import (
    DEFAULT_EXCLUSION_PATTERNS,
    DualLayerOrchestrator,
    EngineStats,
    OrchestratorResult,
    _vuln_category,
    deduplicate_violations,
    matches_exclusion_pattern,
)
from mergeguide.engine.reporter import Violation
from mergeguide.engine.rules import Rule, RuleType, Severity
from mergeguide.policies.defaults import get_default_rules


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_violation(
    rule_id: str = "test-rule",
    file_path: str = "app.py",
    line_no: int = 10,
    severity: str = "error",
    message: str = "Test violation",
    matched_text: str = "bad_code()",
) -> Violation:
    """Create a Violation for testing."""
    return Violation(
        rule_id=rule_id,
        rule_name=rule_id.replace("-", " ").title(),
        message=message,
        severity=severity,
        file_path=file_path,
        line_no=line_no,
        column_start=1,
        column_end=len(matched_text) + 1,
        matched_text=matched_text,
        line_content=matched_text,
        remediation="",
    )


def _make_engine(rules: list[Rule] | None = None):
    """Create a PolicyEngine with optional rules."""
    from mergeguide.engine.evaluator import PolicyEngine

    return PolicyEngine(rules=rules or [])


# ---------------------------------------------------------------------------
# Vendor / minified file exclusion  (DEV-519)
# ---------------------------------------------------------------------------


class TestVendorExclusion:
    """Tests for matches_exclusion_pattern()."""

    def test_minified_js_excluded(self):
        assert matches_exclusion_pattern("jquery.min.js") is True

    def test_minified_css_excluded(self):
        assert matches_exclusion_pattern("styles.min.css") is True

    def test_bundle_js_excluded(self):
        assert matches_exclusion_pattern("app.bundle.js") is True

    def test_chunk_js_excluded(self):
        assert matches_exclusion_pattern("vendor.chunk.js") is True

    def test_source_map_excluded(self):
        assert matches_exclusion_pattern("app.js.map") is True

    def test_node_modules_excluded(self):
        assert matches_exclusion_pattern("node_modules/express/index.js") is True

    def test_vendor_dir_excluded(self):
        assert matches_exclusion_pattern("vendor/autoload.php") is True

    def test_pycache_excluded(self):
        assert matches_exclusion_pattern("__pycache__/module.cpython-311.pyc") is True

    def test_venv_excluded(self):
        assert matches_exclusion_pattern(".venv/lib/python3.11/site.py") is True

    def test_dist_excluded(self):
        assert matches_exclusion_pattern("dist/bundle.js") is True

    def test_build_excluded(self):
        assert matches_exclusion_pattern("build/output.js") is True

    def test_git_dir_excluded(self):
        assert matches_exclusion_pattern(".git/objects/pack/foo") is True

    def test_jquery_lib_excluded(self):
        assert matches_exclusion_pattern("lib/jquery-3.6.0.js") is True

    def test_bootstrap_lib_excluded(self):
        assert matches_exclusion_pattern("static/bootstrap.js") is True

    def test_react_production_excluded(self):
        assert matches_exclusion_pattern("react.production.min.js") is True

    def test_normal_py_not_excluded(self):
        assert matches_exclusion_pattern("src/app.py") is False

    def test_normal_js_not_excluded(self):
        assert matches_exclusion_pattern("src/index.js") is False

    def test_normal_php_not_excluded(self):
        assert matches_exclusion_pattern("src/Controller.php") is False

    def test_custom_patterns(self):
        patterns = ["*.generated.*"]
        assert matches_exclusion_pattern("api.generated.ts", patterns) is True
        assert matches_exclusion_pattern("api.ts", patterns) is False

    def test_site_packages_excluded(self):
        assert matches_exclusion_pattern("site-packages/requests/api.py") is True

    def test_third_party_excluded(self):
        assert matches_exclusion_pattern("third_party/protobuf/message.py") is True


# ---------------------------------------------------------------------------
# Vulnerability category mapping  (DEV-518)
# ---------------------------------------------------------------------------


class TestVulnCategory:
    """Tests for _vuln_category() rule_id normalisation."""

    def test_sql_injection_variants(self):
        assert _vuln_category("python-sql-injection-raw-query") == "sql-injection"
        assert _vuln_category("sqli-cursor-execute") == "sql-injection"

    def test_xss_variants(self):
        assert _vuln_category("reflected-xss-template") == "xss"
        assert _vuln_category("cross-site-scripting-dom") == "xss"

    def test_command_injection_variants(self):
        assert _vuln_category("os-command-injection") == "command-injection"
        assert _vuln_category("cmd-injection-subprocess") == "command-injection"

    def test_path_traversal(self):
        assert _vuln_category("path-traversal-open") == "path-traversal"

    def test_deserialization(self):
        assert _vuln_category("insecure-deserialization-pickle") == "deserialization"

    def test_weak_crypto(self):
        assert _vuln_category("weak-crypto-md5") == "weak-crypto"
        assert _vuln_category("crypto-des-usage") == "weak-crypto"

    def test_secrets(self):
        assert _vuln_category("hardcoded-secret-api-key") == "secrets"
        assert _vuln_category("hardcoded-password-db") == "secrets"
        assert _vuln_category("no-hardcoded-credentials") == "secrets"

    def test_unknown_returns_original(self):
        assert _vuln_category("custom-lint-rule-42") == "custom-lint-rule-42"


# ---------------------------------------------------------------------------
# Deduplication  (DEV-518)
# ---------------------------------------------------------------------------


class TestDeduplication:
    """Tests for deduplicate_violations()."""

    def test_no_overlap_keeps_all(self):
        """When findings don't overlap, all are kept."""
        regex = [_make_violation(rule_id="regex-xss", line_no=10)]
        semgrep = [_make_violation(rule_id="semgrep-sqli", line_no=20)]
        merged = deduplicate_violations(regex, semgrep)
        assert len(merged) == 2

    def test_same_line_same_category_deduplicates(self):
        """Semgrep wins when both flag the same (file, line, category)."""
        regex = [
            _make_violation(
                rule_id="regex-sql-injection-raw",
                line_no=15,
                message="Regex found SQL injection",
            )
        ]
        semgrep = [
            _make_violation(
                rule_id="python-sql-injection-taint",
                line_no=15,
                message="Semgrep taint: SQL injection",
            )
        ]
        merged = deduplicate_violations(regex, semgrep)
        assert len(merged) == 1
        assert "Semgrep taint" in merged[0].message

    def test_same_line_different_category_keeps_both(self):
        """Different categories on the same line are NOT deduped."""
        regex = [_make_violation(rule_id="hardcoded-secret-key", line_no=5)]
        semgrep = [_make_violation(rule_id="python-sql-injection", line_no=5)]
        merged = deduplicate_violations(regex, semgrep)
        assert len(merged) == 2

    def test_same_category_different_line_keeps_both(self):
        """Same category on different lines are NOT deduped."""
        regex = [_make_violation(rule_id="sql-injection-1", line_no=10)]
        semgrep = [_make_violation(rule_id="sql-injection-2", line_no=20)]
        merged = deduplicate_violations(regex, semgrep)
        assert len(merged) == 2

    def test_same_category_different_file_keeps_both(self):
        """Same category, same line, but different files are NOT deduped."""
        regex = [_make_violation(rule_id="xss-reflect", file_path="a.py", line_no=5)]
        semgrep = [_make_violation(rule_id="xss-dom", file_path="b.py", line_no=5)]
        merged = deduplicate_violations(regex, semgrep)
        assert len(merged) == 2

    def test_empty_inputs(self):
        """Empty lists produce empty output."""
        assert deduplicate_violations([], []) == []

    def test_regex_only(self):
        """When no Semgrep findings, all regex are kept."""
        regex = [_make_violation(rule_id="r1"), _make_violation(rule_id="r2", line_no=20)]
        merged = deduplicate_violations(regex, [])
        assert len(merged) == 2

    def test_semgrep_only(self):
        """When no regex findings, all Semgrep are kept."""
        semgrep = [_make_violation(rule_id="s1"), _make_violation(rule_id="s2", line_no=20)]
        merged = deduplicate_violations([], semgrep)
        assert len(merged) == 2

    def test_multiple_overlaps(self):
        """Multiple overlapping findings are all deduped correctly."""
        regex = [
            _make_violation(rule_id="sql-injection-r", line_no=10),
            _make_violation(rule_id="xss-regex", line_no=20),
            _make_violation(rule_id="hardcoded-secret-1", line_no=30),
        ]
        semgrep = [
            _make_violation(rule_id="sqli-taint", line_no=10),
            _make_violation(rule_id="xss-taint", line_no=20),
        ]
        merged = deduplicate_violations(regex, semgrep)
        # Line 10 and 20 deduped (Semgrep wins), line 30 regex kept
        assert len(merged) == 3
        rule_ids = [v.rule_id for v in merged]
        assert "sqli-taint" in rule_ids
        assert "xss-taint" in rule_ids
        assert "hardcoded-secret-1" in rule_ids


# ---------------------------------------------------------------------------
# Orchestrator  (DEV-516 / DEV-517 / DEV-520 / DEV-522)
# ---------------------------------------------------------------------------


class TestDualLayerOrchestrator:
    """Tests for the DualLayerOrchestrator class."""

    def test_semgrep_disabled_flag(self):
        """When semgrep_enabled=False, semgrep_available is False."""
        engine = _make_engine()
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False)
        assert orch.semgrep_available is False

    def test_semgrep_not_installed(self):
        """When Semgrep binary is missing, orchestrator falls back to regex only."""
        import mergeguide.engine.semgrep as sg_mod

        original = sg_mod._semgrep_available
        try:
            sg_mod._semgrep_available = False
            engine = _make_engine()
            orch = DualLayerOrchestrator(engine, semgrep_enabled=True)
            assert orch.semgrep_available is False
        finally:
            sg_mod._semgrep_available = original

    def test_semgrep_detected(self):
        """When Semgrep is installed, orchestrator reports it available."""
        import mergeguide.engine.semgrep as sg_mod

        original = sg_mod._semgrep_available
        try:
            sg_mod._semgrep_available = True
            engine = _make_engine()
            orch = DualLayerOrchestrator(engine, semgrep_enabled=True)
            assert orch.semgrep_available is True
        finally:
            sg_mod._semgrep_available = original

    def test_scan_excludes_vendor_files(self, tmp_path):
        """Vendor files are excluded before scanning."""
        # Create a normal file and a vendor file
        normal = tmp_path / "app.py"
        normal.write_text("import os\n")

        vendor = tmp_path / "node_modules" / "express" / "index.js"
        vendor.parent.mkdir(parents=True)
        vendor.write_text("module.exports = {}\n")

        engine = _make_engine()
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False)
        result = orch.scan([normal, vendor])

        # Only the normal file should be checked
        assert len(result.files_checked) == 1
        assert str(normal) in result.files_checked

    def test_scan_regex_only_produces_results(self, tmp_path):
        """Regex-only scan produces violations from the regex engine."""
        vuln_file = tmp_path / "vuln.py"
        vuln_file.write_text("password = 'hardcoded123'\n")

        rules = get_default_rules()
        engine = _make_engine(rules)
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False)
        result = orch.scan([vuln_file])

        assert len(result.files_checked) == 1
        assert result.regex_stats.files_scanned == 1
        assert result.semgrep_stats is None
        assert result.semgrep_available is False

    def test_scan_empty_file_list(self):
        """Empty file list returns empty result."""
        engine = _make_engine()
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False)
        result = orch.scan([])
        assert result.violations == []
        assert result.files_checked == []

    def test_scan_all_excluded_returns_empty(self, tmp_path):
        """If all files match exclusion patterns, result is empty."""
        vendor_file = tmp_path / "jquery.min.js"
        vendor_file.write_text("/* minified */\n")

        engine = _make_engine()
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False)
        result = orch.scan([vendor_file])
        assert result.violations == []
        assert result.files_checked == []


class TestOrchestratorWithMockedSemgrep:
    """Tests with Semgrep mocked to verify dual-layer merge logic."""

    def _mock_semgrep_findings(self, violations):
        """Create a mock that makes _run_semgrep return given violations."""
        return lambda files: (violations, 42)

    def test_dual_layer_merge(self, tmp_path):
        """Dual-layer scan merges findings from both engines."""
        vuln_file = tmp_path / "vuln.py"
        vuln_file.write_text("password = 'secret'\nos.system(user_input)\n")

        rules = get_default_rules()
        engine = _make_engine(rules)
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False, parallel=False)

        # Mock Semgrep as available and returning a finding
        semgrep_violation = _make_violation(
            rule_id="python-command-injection",
            file_path=str(vuln_file),
            line_no=2,
            message="Semgrep: command injection via taint",
        )

        orch._semgrep_available = True
        orch.semgrep_enabled = True
        original_run = orch._run_semgrep
        orch._run_semgrep = lambda files: ([semgrep_violation], 50)

        result = orch._scan_sequential(
            [vuln_file], use_semgrep=True, verbose=False
        )

        # Should have regex findings + the Semgrep finding (deduped)
        assert len(result.files_checked) == 1
        assert result.semgrep_stats is not None
        assert result.semgrep_stats.violations_found == 1

    def test_parallel_mode_works(self, tmp_path):
        """Parallel scan produces the same result structure as sequential."""
        vuln_file = tmp_path / "app.py"
        vuln_file.write_text("x = 1\n")

        engine = _make_engine()
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False, parallel=True)

        # Mock both engines
        orch._semgrep_available = True
        orch.semgrep_enabled = True
        orch._run_semgrep = lambda files: ([], 10)

        result = orch._scan_parallel([vuln_file], verbose=False)

        assert isinstance(result, OrchestratorResult)
        assert result.regex_stats.engine == "regex"
        assert result.semgrep_stats.engine == "semgrep"


# ---------------------------------------------------------------------------
# Graceful fallback  (DEV-520)
# ---------------------------------------------------------------------------


class TestGracefulFallback:
    """Test that the CLI handles missing Semgrep gracefully."""

    def test_fallback_to_regex_only(self, tmp_path):
        """When Semgrep is unavailable, scan completes with regex only."""
        import mergeguide.engine.semgrep as sg_mod

        original = sg_mod._semgrep_available
        try:
            sg_mod._semgrep_available = False

            test_file = tmp_path / "hello.py"
            test_file.write_text("print('hello')\n")

            engine = _make_engine(get_default_rules())
            orch = DualLayerOrchestrator(engine, semgrep_enabled=True)

            assert orch.semgrep_available is False
            result = orch.scan([test_file])

            # Scan should succeed
            assert len(result.files_checked) == 1
            assert result.semgrep_stats is None
            assert result.semgrep_available is False
        finally:
            sg_mod._semgrep_available = original

    def test_no_semgrep_flag(self, tmp_path):
        """--no-semgrep flag disables Semgrep entirely."""
        test_file = tmp_path / "hello.py"
        test_file.write_text("print('hello')\n")

        engine = _make_engine()
        orch = DualLayerOrchestrator(engine, semgrep_enabled=False)

        result = orch.scan([test_file])
        assert result.semgrep_available is False
        assert result.semgrep_stats is None


# ---------------------------------------------------------------------------
# CLI integration  (DEV-517)
# ---------------------------------------------------------------------------


class TestCLIIntegration:
    """Test the check command uses the orchestrator."""

    def test_check_command_with_no_semgrep(self, tmp_path):
        """check --no-semgrep runs without error."""
        from click.testing import CliRunner

        from mergeguide.cli import main

        test_file = tmp_path / "clean.py"
        test_file.write_text("x = 1\n")

        runner = CliRunner()
        result = runner.invoke(main, ["check", "--no-semgrep", str(test_file)])
        # Should not crash (exit 0 = passed, exit 1 = violations found)
        assert result.exit_code in (0, 1)

    def test_check_command_verbose_shows_engine_stats(self, tmp_path):
        """check -v shows regex engine statistics."""
        from click.testing import CliRunner

        from mergeguide.cli import main

        test_file = tmp_path / "clean.py"
        test_file.write_text("x = 1\n")

        runner = CliRunner()
        result = runner.invoke(
            main, ["-v", "check", "--no-semgrep", str(test_file)]
        )
        assert result.exit_code in (0, 1)
        assert "Regex engine:" in result.output

    def test_check_command_excludes_vendor(self, tmp_path):
        """check command excludes vendor files automatically."""
        from click.testing import CliRunner

        from mergeguide.cli import main

        # Create vendor file with obvious violation
        vendor_dir = tmp_path / "node_modules" / "pkg"
        vendor_dir.mkdir(parents=True)
        vendor_file = vendor_dir / "index.js"
        vendor_file.write_text("var password = 'hardcoded';\n")

        runner = CliRunner()
        result = runner.invoke(
            main, ["check", "--no-semgrep", str(vendor_file)]
        )
        # Vendor file should be excluded, so no violations
        assert result.exit_code == 0


# ---------------------------------------------------------------------------
# EngineStats / OrchestratorResult dataclasses
# ---------------------------------------------------------------------------


class TestDataclasses:
    """Verify dataclass defaults and structure."""

    def test_engine_stats_defaults(self):
        stats = EngineStats(engine="regex")
        assert stats.rules_count == 0
        assert stats.files_scanned == 0
        assert stats.violations_found == 0
        assert stats.duration_ms == 0

    def test_orchestrator_result_defaults(self):
        result = OrchestratorResult()
        assert result.violations == []
        assert result.files_checked == []
        assert result.semgrep_stats is None
        assert result.semgrep_available is False
        assert result.dedup_count == 0
