# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Semgrep integration for MergeGuide detection engine.

This module provides integration with Semgrep for advanced pattern matching
including data flow and taint analysis capabilities.

DEV-475: Integrate Semgrep Detection Engine

Note: This integration uses custom rules only. Do NOT use Semgrep Registry
rules (p/python, p/javascript, etc.) due to licensing restrictions.
Custom rules should be placed in src/mergeguide/rules/{language}/.
"""

from __future__ import annotations

import json
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

# Default rules directory relative to this file
DEFAULT_RULES_DIR = Path(__file__).parent.parent / "rules"

# Cached result for Semgrep availability check
_semgrep_available: bool | None = None


def is_semgrep_available() -> bool:
    """Check if the semgrep CLI is installed and accessible.

    Caches the result for the lifetime of the process so we only
    shell out once per session.

    Returns:
        True if semgrep is installed and runnable, False otherwise.
    """
    global _semgrep_available
    if _semgrep_available is not None:
        return _semgrep_available

    try:
        result = subprocess.run(
            ["semgrep", "--version"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        _semgrep_available = result.returncode == 0
        if _semgrep_available:
            logger.debug("Semgrep available: version %s", result.stdout.strip())
        else:
            logger.warning("Semgrep check returned non-zero: %s", result.stderr.strip())
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        _semgrep_available = False
        logger.warning("Semgrep not found on PATH -- running regex-only scan")

    return _semgrep_available


@dataclass
class SemgrepFinding:
    """A finding from Semgrep analysis.

    Represents a single vulnerability or issue detected by Semgrep,
    with all relevant metadata for reporting.
    """

    check_id: str
    path: str
    line: int
    column: int
    end_line: int
    end_column: int
    message: str
    severity: str
    matched_code: str
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def cwe(self) -> str | None:
        """Extract CWE identifier from metadata."""
        cwe = self.metadata.get("cwe")
        if isinstance(cwe, list) and cwe:
            return cwe[0]
        return cwe if isinstance(cwe, str) else None

    @property
    def owasp(self) -> str | None:
        """Extract OWASP category from metadata."""
        owasp = self.metadata.get("owasp")
        if isinstance(owasp, list) and owasp:
            return owasp[0]
        return owasp if isinstance(owasp, str) else None


@dataclass
class SemgrepResult:
    """Result from a Semgrep scan.

    Contains all findings plus metadata about the scan execution.
    """

    findings: list[SemgrepFinding] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)
    paths_scanned: list[str] = field(default_factory=list)
    duration_ms: int = 0

    @property
    def has_findings(self) -> bool:
        """Check if any findings were detected."""
        return len(self.findings) > 0


class SemgrepScanner:
    """Wrapper for the Semgrep CLI.

    Provides methods to scan files or content using Semgrep's pattern matching
    and taint analysis capabilities with custom rules only.

    Example:
        >>> scanner = SemgrepScanner()
        >>> result = scanner.scan_files(["/path/to/file.py"])
        >>> for finding in result.findings:
        ...     print(f"{finding.check_id}: {finding.message}")
    """

    def __init__(
        self,
        rules_dir: Path | str | None = None,
        timeout: int = 300,
        verify: bool = True,
    ) -> None:
        """Initialize the Semgrep scanner.

        Args:
            rules_dir: Directory containing Semgrep rules. If None, uses
                the default rules directory at src/mergeguide/rules/.
            timeout: Timeout in seconds for Semgrep execution.
            verify: If True, raise RuntimeError when Semgrep is not
                installed (legacy behaviour). If False, only log a
                warning and set self.available accordingly.
        """
        self.rules_dir = Path(rules_dir) if rules_dir else DEFAULT_RULES_DIR
        self.timeout = timeout
        self.available: bool = True

        # Verify Semgrep is available
        if verify:
            self._verify_semgrep()
        else:
            self.available = is_semgrep_available()

    def _verify_semgrep(self) -> None:
        """Verify Semgrep is installed and accessible.

        Raises RuntimeError if Semgrep is not found (legacy behaviour
        preserved for callers that expect it).
        """
        try:
            result = subprocess.run(
                ["semgrep", "--version"],
                capture_output=True,
                text=True,
                timeout=10,
            )
            if result.returncode != 0:
                raise RuntimeError(f"Semgrep check failed: {result.stderr}")
            logger.debug(f"Semgrep version: {result.stdout.strip()}")
        except FileNotFoundError:
            raise RuntimeError("Semgrep not found. Install with: pip install semgrep")

    def _get_rules_for_language(self, language: str) -> Path | None:
        """Get the rules directory for a specific language.

        Args:
            language: Language identifier (python, javascript, java, etc.)

        Returns:
            Path to language-specific rules directory, or None if not found.
        """
        lang_dir = self.rules_dir / language.lower()
        if lang_dir.exists() and any(lang_dir.glob("*.yaml")) or any(lang_dir.glob("*.yml")):
            return lang_dir
        return None

    def _build_config_args(self, languages: list[str] | None = None) -> list[str]:
        """Build Semgrep config arguments for the specified languages.

        Args:
            languages: List of languages to include rules for.
                If None, includes all available rules.

        Returns:
            List of --config arguments for Semgrep CLI.
        """
        config_args = []

        # Always include common rules
        common_dir = self.rules_dir / "common"
        if common_dir.exists():
            yaml_files = list(common_dir.glob("*.yaml")) + list(common_dir.glob("*.yml"))
            if yaml_files:
                config_args.extend(["--config", str(common_dir)])

        # Add language-specific rules
        if languages:
            for lang in languages:
                lang_dir = self._get_rules_for_language(lang)
                if lang_dir:
                    config_args.extend(["--config", str(lang_dir)])
        else:
            # Include all language directories with rules
            for lang_dir in self.rules_dir.iterdir():
                if lang_dir.is_dir() and lang_dir.name != "common":
                    yaml_files = list(lang_dir.glob("*.yaml")) + list(lang_dir.glob("*.yml"))
                    if yaml_files:
                        config_args.extend(["--config", str(lang_dir)])

        return config_args

    def scan_files(
        self,
        paths: list[str | Path],
        languages: list[str] | None = None,
    ) -> SemgrepResult:
        """Scan files using Semgrep.

        Args:
            paths: List of file or directory paths to scan.
            languages: Optional list of languages to filter rules.

        Returns:
            SemgrepResult with all findings.
        """
        config_args = self._build_config_args(languages)

        if not config_args:
            logger.warning("No Semgrep rules found")
            return SemgrepResult()

        cmd = [
            "semgrep",
            "scan",
            *config_args,
            "--json",
            "--no-git-ignore",
            "--metrics=off",  # Disable telemetry
            *[str(p) for p in paths],
        ]

        logger.debug(f"Running: {' '.join(cmd)}")

        # BUG-297: Suppress semgrep "requires login" and telemetry
        # messages by setting environment variables that prevent
        # semgrep from checking login state or phoning home.
        env = {**os.environ}
        env["SEMGREP_SEND_METRICS"] = "off"
        # Prevent "Some rules require login" banner
        env["SEMGREP_RULES_CACHE_PATH"] = ""
        # Suppress version-check nag
        env["SEMGREP_ENABLE_VERSION_CHECK"] = "0"

        try:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=self.timeout,
                env=env,
            )

            # Exit code 0 = no findings, 1 = findings found,
            # 2 = rule parse errors (results may still exist),
            # other = fatal error
            if result.returncode not in (0, 1, 2):
                logger.error(f"Semgrep error: {result.stderr}")
                return SemgrepResult(errors=[result.stderr])

            if result.returncode == 2:
                logger.warning("Semgrep had rule parse errors (some rules skipped)")

            return self._parse_output(result.stdout)

        except subprocess.TimeoutExpired:
            logger.error(f"Semgrep timed out after {self.timeout}s")
            return SemgrepResult(errors=["Semgrep scan timed out"])
        except Exception as e:
            logger.exception("Semgrep execution failed")
            return SemgrepResult(errors=[str(e)])

    def scan_content(
        self,
        content: str,
        filename: str = "input.py",
        language: str | None = None,
    ) -> SemgrepResult:
        """Scan code content using Semgrep.

        Writes content to a temporary file and scans it.

        Args:
            content: Source code content to scan.
            filename: Filename to use (affects language detection).
            language: Optional explicit language.

        Returns:
            SemgrepResult with all findings.
        """
        # Detect language from filename if not provided
        if language is None:
            suffix = Path(filename).suffix.lower()
            language = self._detect_language(suffix)

        # Write content to temp file
        with tempfile.NamedTemporaryFile(
            mode="w",
            suffix=Path(filename).suffix,
            delete=False,
        ) as f:
            f.write(content)
            temp_path = f.name

        try:
            languages = [language] if language else None
            result = self.scan_files([temp_path], languages=languages)

            # Remap temp path to original filename in findings
            for finding in result.findings:
                if finding.path == temp_path:
                    finding.path = filename

            return result
        finally:
            # Clean up temp file
            Path(temp_path).unlink(missing_ok=True)

    def _detect_language(self, suffix: str) -> str | None:
        """Detect language from file suffix."""
        language_map = {
            ".py": "python",
            ".pyw": "python",
            ".js": "javascript",
            ".jsx": "javascript",
            ".ts": "typescript",
            ".tsx": "typescript",
            ".java": "java",
            ".go": "go",
            ".rb": "ruby",
            ".php": "php",
            ".c": "c",
            ".cpp": "cpp",
            ".cc": "cpp",
            ".h": "c",
            ".hpp": "cpp",
            ".cs": "csharp",
            ".rs": "rust",
            ".swift": "swift",
            ".kt": "kotlin",
            ".scala": "scala",
        }
        return language_map.get(suffix)

    def _parse_output(self, output: str) -> SemgrepResult:
        """Parse Semgrep JSON output into SemgrepResult.

        Args:
            output: Raw JSON output from Semgrep CLI.

        Returns:
            Parsed SemgrepResult.
        """
        if not output.strip():
            return SemgrepResult()

        try:
            data = json.loads(output)
        except json.JSONDecodeError as e:
            logger.error(f"Failed to parse Semgrep output: {e}")
            return SemgrepResult(errors=[f"JSON parse error: {e}"])

        findings = []
        errors = []

        # Parse results
        for result in data.get("results", []):
            finding = SemgrepFinding(
                check_id=result.get("check_id", "unknown"),
                path=result.get("path", ""),
                line=result.get("start", {}).get("line", 0),
                column=result.get("start", {}).get("col", 0),
                end_line=result.get("end", {}).get("line", 0),
                end_column=result.get("end", {}).get("col", 0),
                message=result.get("extra", {}).get("message", ""),
                severity=result.get("extra", {}).get("severity", "WARNING"),
                matched_code=result.get("extra", {}).get("lines", ""),
                metadata=result.get("extra", {}).get("metadata", {}),
            )
            findings.append(finding)

        # Parse errors
        for error in data.get("errors", []):
            error_msg = error.get("message", str(error))
            errors.append(error_msg)

        # Get paths scanned
        paths_scanned = data.get("paths", {}).get("scanned", [])

        return SemgrepResult(
            findings=findings,
            errors=errors,
            paths_scanned=paths_scanned,
        )


def _normalize_rule_id(check_id: str) -> str:
    """Normalize a Semgrep rule ID by stripping system path prefixes.

    Semgrep rule IDs can contain full system paths like:
        development.src.mergeguide.rules.java.mg-java-xxe-documentbuilder

    This strips everything before the last 'rules.' segment to produce:
        java.mg-java-xxe-documentbuilder

    If the rule ID contains an 'mg-' portion, extract from there instead:
        mg-java-xxe-documentbuilder

    BUG-400: System paths in rule IDs.
    """
    # Strategy 1: Extract mg-* portion if present
    if "mg-" in check_id:
        idx = check_id.index("mg-")
        return check_id[idx:]

    # Strategy 2: Strip everything before the last 'rules.' segment
    if "rules." in check_id:
        idx = check_id.rindex("rules.")
        return check_id[idx + len("rules.") :]

    return check_id


def _extract_remediation(finding: SemgrepFinding) -> str:
    """Extract remediation guidance from a Semgrep finding.

    Checks metadata.fix first (Semgrep's native fix field), then
    falls back to the message field which often contains remediation
    guidance after the vulnerability description.

    BUG-399: Semgrep remediation field empty.
    """
    # Check metadata.fix first (Semgrep autofix / remediation field)
    fix = finding.metadata.get("fix", "")
    if fix:
        return fix

    # Check metadata.fix_regex (Semgrep regex-based autofix)
    fix_regex = finding.metadata.get("fix_regex", "")
    if fix_regex:
        return str(fix_regex)

    # Check for remediation in metadata directly
    remediation = finding.metadata.get("remediation", "")
    if remediation:
        return remediation

    # BUG-NEW-2: Fall back to second paragraph of message field.
    # Our Semgrep rules embed remediation after the first newline in the message,
    # e.g., "SQL Injection: ...\nUse PreparedStatement with parameterized queries."
    if finding.message and "\n" in finding.message:
        parts = finding.message.split("\n", 1)
        guidance = parts[1].strip()
        if guidance:
            return guidance

    return ""


def _resolve_matched_code(finding: SemgrepFinding) -> str:
    """Resolve matched_code, replacing Semgrep login banners with actual source.

    BUG-404: Semgrep's 'Some rules require login' banner can leak into
    the matched_code field. When detected, read the actual source line
    from the file at the matched line number.
    """
    code = finding.matched_code
    stripped = code.strip().lower() if code else ""

    # Detect the "requires login" banner leak
    if not code or stripped == "requires login" or "some rules require login" in stripped:
        # Try to read the actual source line from the file
        try:
            file_path = Path(finding.path)
            if file_path.exists() and finding.line > 0:
                with open(file_path) as f:
                    for i, line in enumerate(f, start=1):
                        if i == finding.line:
                            return line.rstrip("\n")
        except (OSError, UnicodeDecodeError):
            pass
        return ""

    return code


def map_finding_to_violation(finding: SemgrepFinding) -> dict[str, Any]:
    """Map a Semgrep finding to MergeGuide violation format.

    Args:
        finding: SemgrepFinding from scanner.

    Returns:
        Dictionary in MergeGuide violation format.
    """
    # Normalize severity
    severity_map = {
        "ERROR": "error",
        "WARNING": "warning",
        "INFO": "info",
    }
    severity = severity_map.get(finding.severity.upper(), "warning")

    # BUG-400: Normalize rule IDs to strip system path prefixes
    rule_id = _normalize_rule_id(finding.check_id)

    # BUG-404: Resolve matched_code (replace login banner with actual source)
    matched_code = _resolve_matched_code(finding)

    # BUG-399: Extract remediation from Semgrep finding metadata
    remediation = _extract_remediation(finding)

    return {
        "rule_id": rule_id,
        "rule_name": rule_id.replace("-", " ").replace("_", " ").title(),
        "message": finding.message,
        "severity": severity,
        "file_path": finding.path,
        "line_no": finding.line,
        "column_start": finding.column,
        "column_end": finding.end_column,
        "matched_text": matched_code,
        "line_content": matched_code.split("\n")[0] if matched_code else "",
        "remediation": remediation,
        "cwe": finding.cwe,
        "owasp": finding.owasp,
        "metadata": finding.metadata,
    }


def create_scanner(rules_dir: Path | str | None = None) -> SemgrepScanner:
    """Factory function to create a SemgrepScanner.

    Args:
        rules_dir: Optional custom rules directory.

    Returns:
        Configured SemgrepScanner instance.
    """
    return SemgrepScanner(rules_dir=rules_dir)
