"""Deterministic pattern-based security scanner.

Rules are defined in security_patterns.json alongside this file.
Each rule specifies a regex pattern, file globs to apply to, severity,
OWASP category, CWE, and remediation guidance.

Design goals:
- Data-driven: add new patterns by editing the JSON, not the Python code.
- High precision: conservative patterns to minimize false positives.
- Fast: pure regex, no LLM calls, no network I/O.
- Scalable: works on any codebase size; files are scanned one at a time.

The LLM handles logic/flow issues (IDOR, missing auth, business logic).
This scanner handles structural patterns that regex can detect reliably.
"""

import fnmatch
import json
import re
from pathlib import Path
from typing import Any

# ---------------------------------------------------------------------------
# Rule loading
# ---------------------------------------------------------------------------

_RULES_PATH = Path(__file__).parent / "security_patterns.json"

# Module-level cache of compiled rules — loaded once per process lifetime.
_COMPILED_RULES: list[dict] | None = None


def _load_rules() -> list[dict]:
    global _COMPILED_RULES
    if _COMPILED_RULES is not None:
        return _COMPILED_RULES

    raw: list[dict] = json.loads(_RULES_PATH.read_text())
    compiled: list[dict] = []
    for rule in raw:
        try:
            flags = re.MULTILINE
            if rule.get("ignore_case"):
                flags |= re.IGNORECASE
            if rule.get("multiline"):
                flags |= re.DOTALL
            pattern = re.compile(rule["pattern"], flags)

            skip_re = None
            if rule.get("skip_if_line_matches"):
                skip_re = re.compile(rule["skip_if_line_matches"], re.IGNORECASE)

            compiled.append({**rule, "_pattern": pattern, "_skip_re": skip_re})
        except re.error:
            pass  # skip malformed rules silently

    _COMPILED_RULES = compiled
    return compiled


# ---------------------------------------------------------------------------
# File routing helpers
# ---------------------------------------------------------------------------

_TEST_PATH_RE = re.compile(
    r"[\\/](?:__tests__|tests?|spec|e2e|fixtures?|mocks?|stubs?)[\\/]"
    r"|\.(?:test|spec)\.[a-z]+$",
    re.IGNORECASE,
)


def _matches_any_glob(file_path: str, globs: list[str]) -> bool:
    """Return True if the file path matches any of the given glob patterns.

    Supports:
    - ``**/*.ext``  — match at any directory depth (most common)
    - ``*.ext``     — match in any directory (treated same as above)
    - Exact filenames like ``**/next.config.js``
    """
    norm = file_path.replace("\\", "/")
    fname = norm.rsplit("/", 1)[-1]

    for glob in globs:
        glob_norm = glob.replace("\\", "/")
        # Strip leading **/ for fnmatch — we match against both full path and basename.
        bare = glob_norm.lstrip("*/").lstrip("/") if glob_norm.startswith("**/") else glob_norm

        if fnmatch.fnmatch(norm, glob_norm):
            return True
        if fnmatch.fnmatch(fname, bare):
            return True
        # Also try matching the basename against the bare glob (e.g. "*.py")
        if fnmatch.fnmatch(fname, glob_norm):
            return True

    return False


# ---------------------------------------------------------------------------
# Per-file scanning
# ---------------------------------------------------------------------------

def _line_number(content: str, pos: int) -> int:
    return content[:pos].count("\n") + 1


def _scan_file(file_path: str, content: str, rules: list[dict]) -> list[dict[str, Any]]:
    findings: list[dict[str, Any]] = []
    lines = content.splitlines()

    for rule in rules:
        file_globs: list[str] = rule.get("file_globs", ["**/*"])
        if not _matches_any_glob(file_path, file_globs):
            continue

        pattern: re.Pattern = rule["_pattern"]
        skip_re: re.Pattern | None = rule["_skip_re"]

        # Special-case: rules that detect *absence* of a pattern in a file
        # (e.g. "next.config has no headers() call"). These rules use a sentinel
        # that never truly matches via finditer — skip them here; they need a
        # whole-file presence check instead.
        # We detect them by id suffix convention: rules whose pattern is a
        # negative lookahead anchored to ^ are whole-file absence checks.
        if rule["id"].endswith("_missing_security_headers"):
            if not re.search(r"\bheaders\s*\(", content):
                findings.append(_make_finding(rule, file_path, 1, ""))
            continue

        seen_lines: set[int] = set()

        for match in pattern.finditer(content):
            line_no = _line_number(content, match.start())

            # One finding per line per rule to avoid noise on long matches.
            if line_no in seen_lines:
                continue
            seen_lines.add(line_no)

            line_text = lines[line_no - 1].strip() if line_no <= len(lines) else ""

            # Skip if the line also matches the negative/exclusion pattern.
            if skip_re and skip_re.search(line_text):
                continue

            findings.append(_make_finding(rule, file_path, line_no, line_text[:150]))

    return findings


def _make_finding(rule: dict, file_path: str, line_no: int, snippet: str) -> dict[str, Any]:
    return {
        "ruleId": rule["id"],
        "title": rule["title"],
        "description": rule["description"],
        "severity": rule["severity"],
        "confidence": rule.get("confidence", "high"),
        "owaspCategory": rule.get("owasp", "A05"),
        "cweId": rule.get("cwe", "CWE-693"),
        "filePath": file_path,
        "lineStart": line_no,
        "codeSnippet": snippet,
        "recommendation": rule.get("recommendation", ""),
        "referenceLinks": rule.get("references", []),
    }


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def scan_patterns(
    file_contents: dict[str, str],
) -> tuple[list[dict[str, Any]], str | None]:
    """Scan source files using deterministic regex rules from security_patterns.json.

    Args:
        file_contents: Dict mapping relative file paths to their string contents.

    Returns:
        (findings, error) — findings is a list of issue dicts in the standard
        scanner format; error is a human-readable string on failure or None on success.
    """
    try:
        rules = _load_rules()
    except Exception as exc:
        return [], f"Pattern scanner failed to load rules: {exc}"

    findings: list[dict[str, Any]] = []
    try:
        for file_path, content in file_contents.items():
            if not content:
                continue
            norm = file_path.replace("\\", "/")
            if _TEST_PATH_RE.search(norm):
                continue
            findings.extend(_scan_file(file_path, content, rules))
    except Exception as exc:
        return [], f"Pattern scanner error: {exc}"

    return findings, None
