import re
import os
from dataclasses import dataclass

@dataclass
class StaticFinding:
    file:     str
    line:     int
    code:     str
    issue:    str
    message:  str
    severity: str


# Crypto-relevant terms — division only flagged when these appear on the same line
CRYPTO_TERMS = [
    "key", "secret", "sk", "dk", "ek", "priv", "cipher",
    "poly", "ntt", "coeff", "mont", "barrett", "mask",
    "modulus", "kyber", "dilithium", "kem", "dsa", "pqc"
]

# Each tuple: (name, pattern, message, severity, requires_crypto_context)
PATTERNS = [
    ("memcmp",
     r'\bmemcmp\b',
     "memcmp() short-circuits on mismatch -- use hmac.compare_digest() instead",
     "CRITICAL", False),

    ("strcmp",
     r'\bstrcmp\b',
     "strcmp() is not constant-time",
     "CRITICAL", False),

    ("secret_branch",
     r'\bif\b.{0,60}\b(key|secret|sk|dk|ek|priv|seed)\b',
     "Branch condition depends on secret value -- timing varies with secret",
     "CRITICAL", False),

    ("division",
     r'\b\w+\s*/\s*\w+\b',
     "Division may not be constant-time -- use Montgomery or Barrett reduction",
     "HIGH", True),   # requires_crypto_context=True -- only flag in crypto code

    ("table_lookup",
     r'\b\w+\[(key|sk|secret|priv|dk|ek)\w*\]',
     "Secret-indexed table lookup -- vulnerable to cache timing attacks",
     "HIGH", False),

    ("early_return",
     r'\breturn\b.{0,40}\b(err|fail|invalid|False|None|0)\b',
     "Early return on failure may leak which check failed via timing",
     "MEDIUM", False),

    ("memcmp_alias",
     r'\b(bcmp|strncmp|strcasecmp)\b',
     "This comparison function is not constant-time",
     "HIGH", False),

    ("rand_not_secrets",
     r'\brandom\.(?!SystemRandom)',
     "random module is not cryptographically secure -- use secrets module",
     "MEDIUM", False),

    ("python_pow",
     r'\bpow\s*\(\s*\w+\s*,\s*\w+\s*[^,]',
     "pow(x, y) without modulus may not be constant-time for crypto use",
     "MEDIUM", True),
]

EXTENSIONS = {".py", ".c", ".cpp", ".h", ".rs"}


class StaticAnalyzer:
    """
    Scans PQC source files for non-constant-time patterns.

    Key improvement over naive regex scanning:
    - Division is only flagged when crypto-relevant terms appear on the same line
    - This eliminates false positives from path separators, URL slashes, comments
    - Each pattern has a configurable crypto_context_required flag
    """

    def _is_crypto_context(self, line: str) -> bool:
        """
        Returns True if the line contains crypto-relevant variable names.
        Used to filter noisy patterns like division that appear everywhere.
        """
        line_lower = line.lower()
        return any(term in line_lower for term in CRYPTO_TERMS)

    def scan_file(self, filepath: str) -> list[StaticFinding]:
        if os.path.splitext(filepath)[1].lower() not in EXTENSIONS:
            return []

        findings = []
        try:
            with open(filepath, encoding="utf-8", errors="ignore") as f:
                lines = f.readlines()
        except Exception:
            return []

        for i, line in enumerate(lines, 1):
            stripped = line.strip()

            # Skip blank lines and comments
            if not stripped:
                continue
            if stripped.startswith(("#", "//", "*", "/*", "'")):
                continue
            # Skip inline comments (everything after # or //)
            code_part = re.split(r'#|//', line)[0]
            if not code_part.strip():
                continue

            for name, pattern, message, severity, needs_crypto in PATTERNS:
                if re.search(pattern, line, re.IGNORECASE):
                    # For noisy patterns, require crypto context
                    if needs_crypto and not self._is_crypto_context(line):
                        continue
                    findings.append(StaticFinding(
                        file=os.path.basename(filepath),
                        line=i,
                        code=stripped[:120],
                        issue=name,
                        message=message,
                        severity=severity
                    ))
                    break  # one finding per line max

        return findings

    def scan_directory(self, directory: str) -> list[StaticFinding]:
        all_findings = []
        for root, _, files in os.walk(directory):
            # Skip test directories and __pycache__
            if any(skip in root for skip in ["__pycache__", ".git", "test", "docs"]):
                continue
            for fname in files:
                all_findings.extend(
                    self.scan_file(os.path.join(root, fname))
                )
        return all_findings
