# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Dual-layer scan orchestrator for MergeGuide.

Runs regex engine (PolicyEngine) and Semgrep engine (SemgrepScanner)
against the same set of files, then merges and deduplicates the results
into a unified set of Violation objects.

DEV-516: Create dual-layer scan orchestrator module
DEV-518: Result merging and deduplication
DEV-519: Vendor file exclusion
DEV-520: Graceful fallback
DEV-522: Performance optimization (parallel execution)
"""

from __future__ import annotations

import fnmatch
import logging
import os
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mergeguide.engine.reporter import Violation

if TYPE_CHECKING:
    from mergeguide.engine.evaluator import EvaluationContext, PolicyInput, PolicyOutput

logger = logging.getLogger(__name__)

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

DEFAULT_EXCLUSION_PATTERNS: list[str] = [
    # Minified / bundled files
    "*.min.js",
    "*.min.css",
    "*.bundle.js",
    "*.chunk.js",
    "*.map",
    "*.packed.js",
    # Package manager directories
    "**/node_modules/**",
    "**/vendor/**",
    "**/bower_components/**",
    "**/.venv/**",
    "**/venv/**",
    "**/site-packages/**",
    # Build output
    "**/dist/**",
    "**/build/**",
    # Cache / metadata
    "**/__pycache__/**",
    "**/.git/**",
    # Third-party / vendored libraries
    "**/third_party/**",
    "**/lib/**/*.min.js",
    "**/libs/**",
    "**/external/**",
    # Well-known JS libraries (FP generators)
    "**/jquery*.js",
    "**/jquery-ui*.js",
    "**/bootstrap*.js",
    "**/angular*.js",
    "**/react*.production*.js",
    "**/raphael*.js",
    "**/d3*.js",
    "**/lodash*.js",
    "**/moment*.js",
    "**/underscore*.js",
    "**/backbone*.js",
    "**/ember*.js",
    "**/vue*.js",
    "**/chart*.js",
    # Test fixtures and mock data
    "**/fixtures/**",
    "**/testdata/**",
    # Java / Maven / Gradle test directories
    "**/src/test/**",
    "**/src/it/**",
    "**/src/integration-test/**",
    # Lock files (package metadata, not source code)
    "**/package-lock.json",
    "**/yarn.lock",
    "**/composer.lock",
    "**/Pipfile.lock",
    "**/poetry.lock",
    "**/Gemfile.lock",
    # Test fixture/data files (reduce FPs from test credentials and mock data)
    "**/__tests__/**",
    "**/*.test.js",
    "**/*.spec.js",
    "**/*.test.ts",
    "**/*.spec.ts",
    "**/test/**/*-test.js",
    "**/test/**/*-test.ts",
    # Configuration files (reduce hardcoded-secrets FPs from config values)
    "**/config/env/**",
    "**/*.config.js",
    "**/*.config.ts",
    "**/setup.php",
    "**/config.inc.php",
    "**/.env.*",
    # Documentation and non-code files
    "**/docs/**/*.js",
    "**/*.md",
    "**/CHANGELOG*",
    "**/LICENSE*",
    # Build tool configuration (not application code)
    "**/Gruntfile.js",
    "**/Gulpfile.js",
    "**/Rakefile",
    "**/Makefile",
    "**/webpack.config.js",
    "**/rollup.config.js",
    "**/vite.config.js",
    "**/babel.config.js",
]

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

_CATEGORY_PATTERNS: list[tuple[str, str]] = [
    ("sql-injection", "sql-injection"),
    ("sql-string-concat", "sql-injection"),
    ("sql-concat", "sql-injection"),
    ("sql-interpolation", "sql-injection"),
    ("sqli", "sql-injection"),
    ("xss", "xss"),
    ("cross-site", "xss"),
    ("command-injection", "command-injection"),
    ("cmd-injection", "command-injection"),
    ("dangerous-function", "command-injection"),
    ("path-traversal", "path-traversal"),
    ("file-inclusion", "path-traversal"),
    ("deserialization", "deserialization"),
    ("crypto", "weak-crypto"),
    ("weak-crypto", "weak-crypto"),
    ("code-injection", "code-injection"),
    ("eval", "code-injection"),
    ("ssrf", "ssrf"),
    ("csrf", "csrf"),
    ("csrf-exempt", "csrf"),
    ("xxe", "xxe"),
    ("nosql", "nosql-injection"),
    ("file-upload", "file-upload"),
    ("hardcoded-secret", "secrets"),
    ("hardcoded-password", "secrets"),
    ("no-hardcoded", "secrets"),
    ("session-fixation", "session-fixation"),
    ("sensitive-data", "sensitive-data"),
    ("console-log", "code-quality"),
    ("todo", "code-quality"),
    ("debug-statement", "code-quality"),
    ("debug-mode", "code-quality"),
    # CI/CD categories (DEV-518 extension — regex+Semgrep dedup for cicd scanner)
    ("cicd-injection", "cicd-injection"),
    ("cicd-hardcoded", "cicd-secrets"),
    ("cicd-secret", "cicd-secrets"),
    ("cicd-unpinned", "cicd-supply-chain"),
    ("cicd-privileged", "cicd-container"),
    ("cicd-container", "cicd-container"),
    ("mg-gha-injection", "cicd-injection"),
    ("mg-gha-hardcoded", "cicd-secrets"),
    ("mg-gha-secret", "cicd-secrets"),
    ("mg-gha-unpinned", "cicd-supply-chain"),
    ("mg-gha-privileged", "cicd-container"),
    ("mg-gitlab-injection", "cicd-injection"),
    ("mg-gitlab-hardcoded", "cicd-secrets"),
    ("mg-gitlab-unpinned", "cicd-supply-chain"),
]


def _vuln_category(rule_id: str) -> str:
    """Derive a vulnerability category from a rule ID.

    Used to determine whether a regex finding and a Semgrep finding
    refer to the same class of issue on the same line.

    Returns the matched category string, or the original rule_id
    if no known pattern matches.
    """
    rule_lower = rule_id.lower()
    for pattern, category in _CATEGORY_PATTERNS:
        if pattern in rule_lower:
            return category
    return rule_lower


# ---------------------------------------------------------------------------
# Exclusion helpers
# ---------------------------------------------------------------------------


# Directories/patterns that indicate ambiguous context (config, setup, etc.)
_LOW_CONFIDENCE_PATH_PATTERNS: list[str] = [
    "**/setup/**",
    "**/config/**",
    "**/configs/**",
    "**/settings/**",
    "**/.github/**",
    "**/ci/**",
    "**/docker/**",
    "**/deploy/**",
    "**/scripts/**",
    "**/migrations/**",
    "**/db/**",
]

# File extensions that indicate application source code (higher confidence)
_APP_CODE_EXTENSIONS: set[str] = {
    ".py",
    ".js",
    ".ts",
    ".jsx",
    ".tsx",
    ".java",
    ".rb",
    ".php",
    ".go",
    ".cs",
    ".kt",
    ".swift",
    ".rs",
    ".c",
    ".cpp",
    ".h",
}


def _assign_regex_confidence(violation: Violation) -> Violation:
    """Assign confidence level to a regex-only violation based on context.

    - MEDIUM (default): Regex match in application code with correct file type
    - LOW: Regex match in ambiguous context (setup dirs, config files, etc.)

    Semgrep findings are always HIGH and are handled separately.
    """
    file_path = violation.file_path
    file_ext = Path(file_path).suffix.lower()

    # Check for ambiguous context patterns
    is_ambiguous = False
    for pat in _LOW_CONFIDENCE_PATH_PATTERNS:
        if matches_exclusion_pattern(file_path, [pat]):
            is_ambiguous = True
            break

    # Non-application-code file extensions are lower confidence
    if file_ext and file_ext not in _APP_CODE_EXTENSIONS:
        is_ambiguous = True

    confidence = "LOW" if is_ambiguous else "MEDIUM"
    category = _vuln_category(violation.rule_id)

    return Violation(
        rule_id=violation.rule_id,
        rule_name=violation.rule_name,
        message=violation.message,
        severity=violation.severity,
        file_path=violation.file_path,
        line_no=violation.line_no,
        column_start=violation.column_start,
        column_end=violation.column_end,
        matched_text=violation.matched_text,
        line_content=violation.line_content,
        remediation=violation.remediation,
        confidence=confidence,
        category=category,
        cwe_id=violation.cwe_id,  # BUG-NEW-1: preserve CWE through confidence assignment
    )


def matches_exclusion_pattern(
    file_path: str,
    patterns: list[str] | None = None,
) -> bool:
    """Check if *file_path* matches any exclusion pattern.

    Supports the same glob semantics as ``cli._matches_skip_pattern``.

    Args:
        file_path: Relative or absolute path to the file.
        patterns: List of glob patterns.  ``DEFAULT_EXCLUSION_PATTERNS``
            is used when *None*.

    Returns:
        True if the file should be excluded.
    """
    if patterns is None:
        patterns = DEFAULT_EXCLUSION_PATTERNS

    file_name = Path(file_path).name

    for pat in patterns:
        if fnmatch.fnmatch(file_path, pat):
            return True
        if fnmatch.fnmatch(file_name, pat):
            return True

        # Handle ** patterns
        if "**" in pat:
            if pat.startswith("**/"):
                suffix = pat[3:]
                if fnmatch.fnmatch(file_path, suffix):
                    return True
                if fnmatch.fnmatch(file_name, suffix):
                    return True
                if "/**" in suffix:
                    segment = suffix.replace("/**", "")
                    if f"/{segment}/" in f"/{file_path}/" or file_path.startswith(f"{segment}/"):
                        return True
            elif pat.endswith("/**"):
                prefix = pat[:-3]
                if file_path.startswith(f"{prefix}/") or file_path == prefix:
                    return True
            else:
                base = pat.replace("**/", "").replace("/**", "")
                if fnmatch.fnmatch(file_path, base) or fnmatch.fnmatch(file_name, base):
                    return True
                if base and base in file_path:
                    return True

    return False


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


def deduplicate_violations(
    regex_violations: list[Violation],
    semgrep_violations: list[Violation],
) -> list[Violation]:
    """Merge regex and Semgrep violations, removing duplicates.

    When both engines flag the same ``(file_path, line_no, category)``,
    the Semgrep finding is kept because it carries richer metadata
    (CWE, OWASP, taint trace).

    Every returned Violation has its ``rule_id`` prefixed with the
    engine source for traceability: ``[regex]`` or ``[semgrep]``.

    Args:
        regex_violations: Violations from the regex engine.
        semgrep_violations: Violations from Semgrep.

    Returns:
        Deduplicated list of Violation objects.
    """
    # Index Semgrep findings by (file, line, category) for O(1) lookup
    semgrep_keys: set[tuple[str, int, str]] = set()
    for v in semgrep_violations:
        key = (v.file_path, v.line_no, _vuln_category(v.rule_id))
        semgrep_keys.add(key)

    merged: list[Violation] = []

    # Keep all Semgrep violations (they take priority, always HIGH confidence)
    for v in semgrep_violations:
        merged.append(
            Violation(
                rule_id=v.rule_id,
                rule_name=v.rule_name,
                message=v.message,
                severity=v.severity,
                file_path=v.file_path,
                line_no=v.line_no,
                column_start=v.column_start,
                column_end=v.column_end,
                matched_text=v.matched_text,
                line_content=v.line_content,
                remediation=v.remediation,
                confidence=v.confidence if v.confidence != "MEDIUM" else "HIGH",
                category=v.category or _vuln_category(v.rule_id),
                cwe_id=v.cwe_id,  # BUG-NEW-1: preserve CWE through dedup
            )
        )

    # Keep regex violations that do NOT overlap with Semgrep
    for v in regex_violations:
        key = (v.file_path, v.line_no, _vuln_category(v.rule_id))
        if key not in semgrep_keys:
            merged.append(
                Violation(
                    rule_id=v.rule_id,
                    rule_name=v.rule_name,
                    message=v.message,
                    severity=v.severity,
                    file_path=v.file_path,
                    line_no=v.line_no,
                    column_start=v.column_start,
                    column_end=v.column_end,
                    matched_text=v.matched_text,
                    line_content=v.line_content,
                    remediation=v.remediation,
                    confidence=v.confidence,
                    category=v.category or _vuln_category(v.rule_id),
                    cwe_id=v.cwe_id,  # BUG-NEW-1: preserve CWE through dedup
                )
            )
        else:
            logger.debug(
                "Dedup: suppressed regex finding %s at %s:%d (Semgrep finding takes priority)",
                v.rule_id,
                v.file_path,
                v.line_no,
            )

    return merged


# ---------------------------------------------------------------------------
# Scan statistics  (for verbose output)
# ---------------------------------------------------------------------------


@dataclass
class EngineStats:
    """Statistics for a single engine run."""

    engine: str
    rules_count: int = 0
    files_scanned: int = 0
    violations_found: int = 0
    duration_ms: int = 0


@dataclass
class OrchestratorResult:
    """Combined result from dual-layer scanning."""

    violations: list[Violation] = field(default_factory=list)
    files_checked: list[str] = field(default_factory=list)
    regex_stats: EngineStats = field(default_factory=lambda: EngineStats(engine="regex"))
    semgrep_stats: EngineStats | None = None
    semgrep_available: bool = False
    dedup_count: int = 0

    @property
    def engines_used(self) -> list[str]:
        """List of detection engines that participated in this scan."""
        engines = ["regex"]
        if self.semgrep_available and self.semgrep_stats is not None:
            engines.append("semgrep")
        return engines


# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------


class DualLayerOrchestrator:
    """Orchestrates regex + Semgrep scanning and merges results.

    Usage::

        orchestrator = DualLayerOrchestrator(engine, semgrep_enabled=True)
        result = orchestrator.scan(files_to_check)
    """

    def __init__(
        self,
        regex_engine: Any,  # PolicyEngine
        *,
        semgrep_enabled: bool = True,
        semgrep_timeout: int = 300,
        exclusion_patterns: list[str] | None = None,
        parallel: bool = True,
    ) -> None:
        """Initialise the orchestrator.

        Args:
            regex_engine: A ``PolicyEngine`` instance (already loaded
                with regex rules).
            semgrep_enabled: Whether to attempt Semgrep scanning.
            semgrep_timeout: Timeout for the Semgrep process.
            exclusion_patterns: File-level exclusion globs.  Defaults
                to ``DEFAULT_EXCLUSION_PATTERNS``.
            parallel: Run regex and Semgrep concurrently when possible.
        """
        self.regex_engine = regex_engine
        self.semgrep_enabled = semgrep_enabled
        self.semgrep_timeout = semgrep_timeout
        self.exclusion_patterns = exclusion_patterns or DEFAULT_EXCLUSION_PATTERNS
        self.parallel = parallel

        # Lazily initialised
        self._scanner: Any | None = None
        self._semgrep_available: bool | None = None

    @property
    def semgrep_available(self) -> bool:
        """Return True if Semgrep is installed and usable."""
        if self._semgrep_available is None:
            if not self.semgrep_enabled:
                self._semgrep_available = False
            else:
                from mergeguide.engine.semgrep import is_semgrep_available

                self._semgrep_available = is_semgrep_available()
        return self._semgrep_available

    def _get_scanner(self) -> Any:
        """Lazily create a SemgrepScanner."""
        if self._scanner is None:
            from mergeguide.engine.semgrep import SemgrepScanner

            self._scanner = SemgrepScanner(
                timeout=self.semgrep_timeout,
                verify=False,
            )
        return self._scanner

    # ----- public API -----

    def scan(
        self,
        files: list[Path],
        *,
        verbose: bool = False,
    ) -> OrchestratorResult:
        """Run dual-layer scan on *files*.

        Args:
            files: List of file paths to scan.
            verbose: If True, compute per-engine statistics.

        Returns:
            An ``OrchestratorResult`` with merged violations.
        """
        # Filter out vendor / minified files
        filtered_files = [
            f for f in files if not matches_exclusion_pattern(str(f), self.exclusion_patterns)
        ]

        excluded_count = len(files) - len(filtered_files)
        if excluded_count > 0:
            logger.debug("Excluded %d vendor/minified files from scan", excluded_count)

        if not filtered_files:
            return OrchestratorResult()

        use_semgrep = self.semgrep_available and self.semgrep_enabled

        if self.parallel and use_semgrep:
            return self._scan_parallel(filtered_files, verbose=verbose)
        else:
            return self._scan_sequential(filtered_files, use_semgrep=use_semgrep, verbose=verbose)

    def scan_diff(
        self,
        diff: str,
        context: EvaluationContext | None = None,
    ) -> PolicyOutput:
        """Run dual-layer scan on a unified diff string.

        This method bridges the diff-string API (used by PR Gate) to the
        file-path-based ``scan()`` method.  It:

        1. Runs the regex engine via ``PolicyEngine.evaluate_diff()`` (which
           understands unified diffs natively).
        2. Extracts added-line content from the diff into temporary files.
        3. Runs Semgrep on those temp files (if available).
        4. Deduplicates regex + Semgrep violations.
        5. Wraps the result in a ``PolicyOutput`` for PRHandler compatibility.

        Args:
            diff: Unified diff content.
            context: Optional evaluation context with PR metadata.

        Returns:
            A ``PolicyOutput`` compatible with ``PRHandler``.
        """
        import shutil
        import uuid

        from mergeguide.engine.evaluator import EvaluationContext, PolicyOutput
        from mergeguide.engine.parser import DiffParser, LineType
        from mergeguide.engine.reporter import EvaluationReport

        if context is None:
            context = EvaluationContext()

        start_time = time.time()
        evaluation_id = f"eval-{uuid.uuid4().hex[:12]}"

        # Step 1: Run regex engine on the raw diff (it parses diffs natively)
        regex_output = self.regex_engine.evaluate_diff(diff, context)
        regex_violations = regex_output.violations
        files_checked = list(regex_output.files_checked)

        # Step 2: Attempt Semgrep scan on extracted diff content
        semgrep_violations: list[Violation] = []
        use_semgrep = self.semgrep_available and self.semgrep_enabled

        if use_semgrep:
            temp_dir = None
            try:
                # Parse the diff to extract added-line content per file
                parser = DiffParser()
                diff_files = parser.parse(diff)

                temp_dir = tempfile.mkdtemp(prefix="mg-prgate-")
                temp_paths: list[Path] = []

                for diff_file in diff_files:
                    if diff_file.is_binary:
                        continue
                    if matches_exclusion_pattern(diff_file.path, self.exclusion_patterns):
                        continue

                    # Reconstruct file content from added lines
                    added_lines = []
                    for hunk in diff_file.hunks:
                        for line in hunk.lines:
                            if line.line_type in (LineType.ADDITION, LineType.CONTEXT):
                                added_lines.append(line.content)

                    if not added_lines:
                        continue

                    # Write to temp file preserving the original path structure
                    safe_path = diff_file.path.lstrip("/")
                    temp_file = Path(temp_dir) / safe_path
                    temp_file.parent.mkdir(parents=True, exist_ok=True)
                    temp_file.write_text("\n".join(added_lines))
                    temp_paths.append(temp_file)

                if temp_paths:
                    semgrep_violations, _ = self._run_semgrep(temp_paths)

                    # Remap temp file paths back to original diff paths
                    for i, v in enumerate(semgrep_violations):
                        if temp_dir and v.file_path.startswith(temp_dir):
                            original_path = v.file_path[len(temp_dir) :].lstrip(os.sep)
                            semgrep_violations[i] = Violation(
                                rule_id=v.rule_id,
                                rule_name=v.rule_name,
                                message=v.message,
                                severity=v.severity,
                                file_path=original_path,
                                line_no=v.line_no,
                                column_start=v.column_start,
                                column_end=v.column_end,
                                matched_text=v.matched_text,
                                line_content=v.line_content,
                                remediation=v.remediation,
                                confidence=v.confidence,
                                category=v.category,
                                cwe_id=v.cwe_id,  # BUG-NEW-1: preserve CWE through path remap
                            )

            except Exception as exc:
                logger.warning("Semgrep scan on diff failed (falling back to regex-only): %s", exc)
                semgrep_violations = []
            finally:
                if temp_dir:
                    shutil.rmtree(temp_dir, ignore_errors=True)

        # Step 3: Deduplicate
        if semgrep_violations:
            total_before = len(regex_violations) + len(semgrep_violations)
            merged = deduplicate_violations(regex_violations, semgrep_violations)
            dedup_count = total_before - len(merged)
            logger.info(
                "Dual-layer PR Gate scan: %d regex + %d semgrep = %d merged (%d deduped)",
                len(regex_violations),
                len(semgrep_violations),
                len(merged),
                dedup_count,
            )
        else:
            merged = regex_violations

        duration_ms = int((time.time() - start_time) * 1000)

        report = EvaluationReport.create(
            evaluation_id=evaluation_id,
            violations=merged,
            files_checked=files_checked,
            duration_ms=duration_ms,
        )

        return PolicyOutput(
            evaluation_id=evaluation_id,
            passed=report.summary.passed,
            violations=merged,
            files_checked=files_checked,
            duration_ms=duration_ms,
            report=report,
        )

    def evaluate(
        self,
        input_data: PolicyInput,
    ) -> PolicyOutput:
        """Run dual-layer evaluation on a ``PolicyInput`` (inline content).

        This method provides API parity with ``PolicyEngine.evaluate()`` so
        the Evaluate Lambda can swap ``PolicyEngine`` for
        ``DualLayerOrchestrator`` without changing its call site.

        For DIFF inputs it delegates to ``scan_diff()``.  For FILE and
        CONTENT inputs it:

        1. Runs the regex engine via ``PolicyEngine.evaluate(input_data)``.
        2. Writes the content to a temporary file and runs Semgrep on it.
        3. Deduplicates regex + Semgrep violations.
        4. Returns a ``PolicyOutput``.

        Args:
            input_data: A ``PolicyInput`` with inline code, diff, or content.

        Returns:
            A ``PolicyOutput`` with merged violations from both engines.
        """
        import shutil
        import uuid

        from mergeguide.engine.evaluator import (
            EvaluationContext,
            InputType,
            PolicyOutput,
        )
        from mergeguide.engine.reporter import EvaluationReport

        # DIFF inputs already have a dedicated dual-layer path
        if input_data.input_type == InputType.DIFF:
            context = EvaluationContext()
            if hasattr(input_data, "context") and input_data.context:
                context = EvaluationContext(
                    org_id=getattr(input_data.context, "org_id", "default"),
                )
            return self.scan_diff(input_data.content, context)

        start_time = time.time()
        evaluation_id = f"eval-{uuid.uuid4().hex[:12]}"

        # Step 1: Run regex engine on the input (it handles PolicyInput natively)
        regex_output = self.regex_engine.evaluate(input_data)
        regex_violations = regex_output.violations
        files_checked = list(regex_output.files_checked)

        # Step 2: Attempt Semgrep scan on the inline content
        semgrep_violations: list[Violation] = []
        use_semgrep = self.semgrep_available and self.semgrep_enabled

        if use_semgrep and input_data.content.strip():
            temp_dir = None
            try:
                temp_dir = tempfile.mkdtemp(prefix="mg-evaluate-")

                # Determine file extension from the supplied file_path
                file_path = input_data.file_path or "input.txt"
                safe_path = file_path.lstrip("/") or "input.txt"
                temp_file = Path(temp_dir) / safe_path
                temp_file.parent.mkdir(parents=True, exist_ok=True)
                temp_file.write_text(input_data.content)

                semgrep_violations, _ = self._run_semgrep([temp_file])

                # Remap temp file paths back to the original file_path
                for i, v in enumerate(semgrep_violations):
                    if temp_dir and v.file_path.startswith(temp_dir):
                        original_path = v.file_path[len(temp_dir) :].lstrip(os.sep)
                        semgrep_violations[i] = Violation(
                            rule_id=v.rule_id,
                            rule_name=v.rule_name,
                            message=v.message,
                            severity=v.severity,
                            file_path=original_path,
                            line_no=v.line_no,
                            column_start=v.column_start,
                            column_end=v.column_end,
                            matched_text=v.matched_text,
                            line_content=v.line_content,
                            remediation=v.remediation,
                            confidence=v.confidence,
                            category=v.category,
                        )

            except Exception as exc:
                logger.warning(
                    "Semgrep scan on inline content failed (falling back to regex-only): %s",
                    exc,
                )
                semgrep_violations = []
            finally:
                if temp_dir:
                    shutil.rmtree(temp_dir, ignore_errors=True)

        # Step 3: Deduplicate
        if semgrep_violations:
            total_before = len(regex_violations) + len(semgrep_violations)
            merged = deduplicate_violations(regex_violations, semgrep_violations)
            dedup_count = total_before - len(merged)
            logger.info(
                "Dual-layer evaluate: %d regex + %d semgrep = %d merged (%d deduped)",
                len(regex_violations),
                len(semgrep_violations),
                len(merged),
                dedup_count,
            )
        else:
            merged = regex_violations

        duration_ms = int((time.time() - start_time) * 1000)

        report = EvaluationReport.create(
            evaluation_id=evaluation_id,
            violations=merged,
            files_checked=files_checked,
            duration_ms=duration_ms,
        )

        return PolicyOutput(
            evaluation_id=evaluation_id,
            passed=report.summary.passed,
            violations=merged,
            files_checked=files_checked,
            duration_ms=duration_ms,
            report=report,
        )

    # ----- internal -----

    def _run_regex(self, files: list[Path]) -> tuple[list[Violation], list[str], int]:
        """Run the regex engine on *files*.

        Returns (violations, files_checked, duration_ms).
        """
        start = time.time()
        all_violations: list[Violation] = []
        files_checked: list[str] = []

        for file_path in files:
            try:
                content = file_path.read_text()
                result = self.regex_engine.evaluate_file(str(file_path), content)
                # Assign confidence levels based on file context
                for v in result.violations:
                    all_violations.append(_assign_regex_confidence(v))
                files_checked.append(str(file_path))
            except Exception as exc:
                logger.warning("Regex engine error on %s: %s", file_path, exc)

        duration_ms = int((time.time() - start) * 1000)
        return all_violations, files_checked, duration_ms

    def _run_semgrep(self, files: list[Path]) -> tuple[list[Violation], int]:
        """Run Semgrep on *files* as a single batch invocation.

        Returns (violations, duration_ms).
        """
        from mergeguide.engine.semgrep import map_finding_to_violation

        start = time.time()
        scanner = self._get_scanner()

        if not scanner.available:
            return [], 0

        sg_result = scanner.scan_files([str(f) for f in files])

        violations: list[Violation] = []
        for finding in sg_result.findings:
            v_dict = map_finding_to_violation(finding)
            rule_id = v_dict["rule_id"]
            violations.append(
                Violation(
                    rule_id=rule_id,
                    rule_name=v_dict["rule_name"],
                    message=v_dict["message"],
                    severity=v_dict["severity"],
                    file_path=v_dict["file_path"],
                    line_no=v_dict["line_no"],
                    column_start=v_dict["column_start"],
                    column_end=v_dict["column_end"],
                    matched_text=v_dict["matched_text"],
                    line_content=v_dict["line_content"],
                    remediation=v_dict.get("remediation", ""),  # BUG-399: extract from metadata
                    confidence="HIGH",  # Semgrep = taint analysis = HIGH confidence
                    category=_vuln_category(rule_id),
                    cwe_id=v_dict.get("cwe", ""),  # BUG-NEW-1: propagate CWE from Semgrep finding
                )
            )

        if sg_result.errors:
            for err in sg_result.errors:
                logger.warning("Semgrep error: %s", err)

        duration_ms = int((time.time() - start) * 1000)
        return violations, duration_ms

    def _scan_sequential(
        self,
        files: list[Path],
        *,
        use_semgrep: bool,
        verbose: bool = False,
    ) -> OrchestratorResult:
        """Run engines sequentially."""
        regex_violations, files_checked, regex_ms = self._run_regex(files)

        regex_stats = EngineStats(
            engine="regex",
            rules_count=len(self.regex_engine.rule_engine.rules),
            files_scanned=len(files_checked),
            violations_found=len(regex_violations),
            duration_ms=regex_ms,
        )

        semgrep_stats: EngineStats | None = None
        semgrep_violations: list[Violation] = []

        if use_semgrep:
            semgrep_violations, sg_ms = self._run_semgrep(files)
            semgrep_stats = EngineStats(
                engine="semgrep",
                rules_count=0,  # difficult to count without parsing all YAML
                files_scanned=len(files),
                violations_found=len(semgrep_violations),
                duration_ms=sg_ms,
            )

        # Merge & dedup
        if semgrep_violations:
            total_before = len(regex_violations) + len(semgrep_violations)
            merged = deduplicate_violations(regex_violations, semgrep_violations)
            dedup_count = total_before - len(merged)
        else:
            merged = regex_violations
            dedup_count = 0

        return OrchestratorResult(
            violations=merged,
            files_checked=files_checked,
            regex_stats=regex_stats,
            semgrep_stats=semgrep_stats,
            semgrep_available=use_semgrep,
            dedup_count=dedup_count,
        )

    def _scan_parallel(
        self,
        files: list[Path],
        *,
        verbose: bool = False,
    ) -> OrchestratorResult:
        """Run regex and Semgrep concurrently via ThreadPoolExecutor."""
        regex_violations: list[Violation] = []
        regex_files: list[str] = []
        regex_ms = 0
        semgrep_violations: list[Violation] = []
        sg_ms = 0

        with ThreadPoolExecutor(max_workers=2) as executor:
            regex_future = executor.submit(self._run_regex, files)
            semgrep_future = executor.submit(self._run_semgrep, files)

            regex_violations, regex_files, regex_ms = regex_future.result()
            semgrep_violations, sg_ms = semgrep_future.result()

        regex_stats = EngineStats(
            engine="regex",
            rules_count=len(self.regex_engine.rule_engine.rules),
            files_scanned=len(regex_files),
            violations_found=len(regex_violations),
            duration_ms=regex_ms,
        )

        semgrep_stats = EngineStats(
            engine="semgrep",
            rules_count=0,
            files_scanned=len(files),
            violations_found=len(semgrep_violations),
            duration_ms=sg_ms,
        )

        # Merge & dedup
        total_before = len(regex_violations) + len(semgrep_violations)
        merged = deduplicate_violations(regex_violations, semgrep_violations)
        dedup_count = total_before - len(merged)

        return OrchestratorResult(
            violations=merged,
            files_checked=regex_files,
            regex_stats=regex_stats,
            semgrep_stats=semgrep_stats,
            semgrep_available=True,
            dedup_count=dedup_count,
        )
