"""
Core scanning engine — no licensing, no commercial dependencies.

This module is intentionally isolated so it can be open-sourced independently.
It has no imports from app.services, app.config.pricing, or any paid feature.
"""

import csv
import io
import json
import logging
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

# Opengrep version bundled in this build
OPENGREP_VERSION = "v1.17.0"


def _opengrep_exe() -> str:
    """Return the path to the opengrep CLI executable.

    ── Frozen build (PyInstaller) ───────────────────────────────────────────
    opengrep binary is bundled as a data file in bin/ inside the frozen bundle.

    ── Development (non-frozen) ─────────────────────────────────────────────
    Looks for opengrep on PATH, then falls back to bin/ relative to this file
    (for local dev installs).
    """
    if getattr(sys, 'frozen', False):
        # Frozen: binary is at <_MEIPASS>/bin/opengrep[.exe]
        suffix = '.exe' if sys.platform == 'win32' else ''
        exe = Path(sys._MEIPASS) / 'bin' / f'opengrep{suffix}'
        if exe.exists():
            return str(exe)
        raise FileNotFoundError(f"opengrep binary not found in frozen bundle: {exe}")

    # Dev mode: check PATH first
    which = shutil.which('opengrep')
    if which:
        return which

    # Dev fallback: bin/ directory alongside this project
    suffix = '.exe' if sys.platform == 'win32' else ''
    local = Path(__file__).parent.parent.parent / 'bin' / f'opengrep{suffix}'
    if local.exists():
        return str(local)

    raise FileNotFoundError(
        "opengrep not found. Add opengrep to PATH or place the binary at "
        f"rivet_server/bin/opengrep{suffix}. "
        f"Download from https://github.com/opengrep/opengrep/releases/tag/{OPENGREP_VERSION}"
    )


# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------

@dataclass
class ScanResult:
    findings: list
    files_scanned: list
    errors: list
    raw: dict = field(default_factory=dict)       # full scan JSON output
    raw_sarif: str = ""                            # full SARIF text (sarif format only)

    @property
    def finding_count(self) -> int:
        return len(self.findings)


# ---------------------------------------------------------------------------
# Rule resolution
# ---------------------------------------------------------------------------

def build_rules(
    target: str,
    mode: str = "all",
    custom_config: Optional[str] = None,
) -> list:
    """Return a list of --config values for TARGET.

    Resolution order:
    1. custom_config (user-supplied) — used as-is
    2. code_analysis_rules.yaml rules matching detected languages
    3. 'auto' fallback

    Args:
        target: File or directory to scan.
        mode:   'all' = all security rules; 'pqc' = quantum/crypto rules only.
        custom_config: Override (registry ID, URL, or local path).
    """
    if custom_config:
        return [custom_config]

    try:
        from app.code_analyzer.code_analysis_settings import code_analysis_settings

        # Collect file extensions present in the target.
        # For extensionless files (e.g. "Dockerfile"), also collect the
        # filename itself so it can match language configs that list bare
        # filenames like "Dockerfile" in their extensions array.
        if os.path.isfile(target):
            p = Path(target)
            candidate_exts = {p.suffix.lower()} if p.suffix else set()
            candidate_names = {p.name.lower()}
        else:
            candidate_exts = set()
            candidate_names = set()
            for dirpath, _, filenames in os.walk(target):
                for fname in filenames:
                    fp = Path(fname)
                    if fp.suffix:
                        candidate_exts.add(fp.suffix.lower())
                    else:
                        candidate_names.add(fp.name.lower())

        # Map extensions/filenames -> configured languages
        detected_langs: set = set()
        for lang, lang_cfg in code_analysis_settings.languages.items():
            for ext in lang_cfg.extensions:
                if ext.lower() in candidate_exts or ext.lower() in candidate_names:
                    detected_langs.add(lang)
                    break

        if not detected_langs:
            if getattr(sys, 'frozen', False):
                logger.warning("No language detected for target and no 'auto' in frozen build; no rules to run.")
                return []
            return ["auto"]

        # In a PyInstaller bundle, __file__ is inside _internal/ so relative paths
        # like ../rules/... would resolve incorrectly. Use _MEIPASS/app as the anchor
        # so that "../rules/custom-rules" resolves to _MEIPASS/rules/custom-rules.
        if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
            backend_dir = Path(sys._MEIPASS) / "app"
        else:
            backend_dir = Path(__file__).parent.parent.parent  # rivet_server/
        base_paths = [(backend_dir / p).resolve() for p in code_analysis_settings.rules_base_paths]

        all_rules: list = []
        for lang in detected_langs:
            lang_cfg = code_analysis_settings.languages[lang]

            if mode == "pqc":
                pqc_keywords = (
                    "crypto", "quantum", "cipher", "tls", "ssl",
                    "hash", "key", "encrypt", "certificate",
                )
                rules = [r for r in lang_cfg.security_rules
                         if any(kw in r for kw in pqc_keywords)]
            else:
                rules = list(lang_cfg.security_rules)

            for rule in rules:
                # Registry/remote rules: p/python, r/python.lang.security.*, auto, URLs.
                # Local rule paths always end with .yaml — never treat those as registry rules
                # even if they start with "r/" (e.g. the R language rules: r/lang/security/foo.yaml).
                is_registry = (
                    rule.startswith(("http://", "https://"))
                    or rule == "auto"
                    or (rule.startswith(("p/", "r/")) and not rule.endswith(".yaml"))
                )
                if is_registry:
                    if getattr(sys, 'frozen', False):
                        logger.debug("Skipping registry/remote rule in frozen build: %s", rule)
                        continue
                    all_rules.append(rule)
                elif os.path.isabs(rule):
                    all_rules.append(rule)
                else:
                    if rule.startswith("custom-rules/"):
                        candidate = (base_paths[1] / rule[len("custom-rules/"):]).resolve()
                        if candidate.exists():
                            all_rules.append(str(candidate))
                        else:
                            logger.debug("Custom rule not found, skipping: %s", rule)
                    else:
                        # Try base paths in order, skip if not found
                        resolved = None
                        for base in base_paths:
                            candidate = (base / rule).resolve()
                            if candidate.exists():
                                resolved = str(candidate)
                                break
                        if resolved:
                            all_rules.append(resolved)
                        else:
                            logger.debug("Rule not found in any base path, skipping: %s", rule)

        if all_rules:
            return all_rules
        if getattr(sys, 'frozen', False):
            logger.warning("No matching rules found in frozen build; skipping 'auto' fallback.")
            return []
        return ["auto"]

    except Exception as exc:
        logger.debug("Rule resolution fell back to auto: %s", exc)
        if getattr(sys, 'frozen', False):
            return []
        return ["auto"]


# ---------------------------------------------------------------------------
# Scan execution
# ---------------------------------------------------------------------------

def run_scan(
    target: str,
    rules: list,
    output_format: str = "json",
    exclude: tuple = (),
    extensions: tuple = (),
    timeout: int = 600,
) -> ScanResult:
    """Run OpenGrep and return a ScanResult.

    Args:
        target:        Absolute path to file or directory.
        rules:         List of --config values (from build_rules()).
        output_format: 'text', 'csv', 'json', or 'sarif'.
        exclude:       Directory names to exclude.
        extensions:    File extensions to include (without leading dot).
        timeout:       Subprocess timeout in seconds.

    Raises:
        FileNotFoundError: If opengrep is not installed.
        subprocess.TimeoutExpired: If scan exceeds timeout.
        RuntimeError: If opengrep exits with an unexpected error code.
    """
    if not rules:
        return ScanResult(findings=[], files_scanned=[], errors=[
            {"message": "No matching rules for target. Check code_analysis_rules.yaml language config."}
        ])

    # OpenGrep uses --sarif for SARIF output, --json otherwise
    fmt_flag = "--sarif" if output_format == "sarif" else "--json"
    scan_args = ["scan", fmt_flag, "--quiet", "--no-git-ignore"]

    for rule in rules:
        scan_args.extend(["--config", rule])

    for ext in extensions:
        scan_args.extend(["--include", f"*.{ext.lstrip('.')}" ])

    for d in exclude:
        scan_args.extend(["--exclude", d])

    scan_args.append(target)

    cmd = [_opengrep_exe(), *scan_args]
    logger.debug("opengrep command: %s", " ".join(cmd))
    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        timeout=timeout,
        encoding="utf-8",
        errors="replace",
    )


    logger.debug("opengrep exit code: %d", result.returncode)
    if result.stderr:
        logger.debug("opengrep stderr: %s", result.stderr[:2000])

    # exit 0 = no findings, exit 1 = findings found — both are success
    if result.returncode not in (0, 1):
        raise RuntimeError(
            f"opengrep exited with code {result.returncode}.\n"
            + (result.stderr or "")
        )

    if output_format == "sarif":
        return ScanResult(
            findings=[],        # count via raw_sarif
            files_scanned=[],
            errors=[],
            raw_sarif=result.stdout,
        )

    try:
        raw = json.loads(result.stdout) if result.stdout else {}
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"Failed to parse opengrep JSON output: {exc}") from exc

    return ScanResult(
        findings=raw.get("results", []),
        files_scanned=raw.get("paths", {}).get("scanned", []),
        errors=raw.get("errors", []),
        raw=raw,
    )


# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------

def count_sarif_findings(sarif_text: str) -> int:
    try:
        data = json.loads(sarif_text)
        return sum(len(run.get("results", [])) for run in data.get("runs", []))
    except Exception:
        return 0


def format_text(scan: ScanResult, target: str) -> str:
    """Render a human-readable summary of scan results."""
    lines = [
        "",
        f"Rivet Scan: {target}",
        f"Files scanned : {len(scan.files_scanned)}",
        f"Total findings: {scan.finding_count}",
        "",
    ]

    if not scan.findings:
        lines.append("No findings. All clear.")
        return "\n".join(lines)

    severity_order = ["CRITICAL", "ERROR", "HIGH", "MEDIUM", "LOW", "WARNING", "INFO"]

    by_sev: dict = {}
    for f in scan.findings:
        sev = (
            f.get("extra", {}).get("severity")
            or f.get("severity")
            or "INFO"
        ).upper()
        by_sev.setdefault(sev, []).append(f)

    for sev in severity_order:
        group = by_sev.get(sev)
        if not group:
            continue
        lines.append(f"[{sev}] {len(group)} finding(s)")
        lines.append("-" * 60)
        for f in group:
            path = f.get("path", "?")
            start = f.get("start") or {}
            line_no = start.get("line") if isinstance(start, dict) else f.get("line", "?")
            rule_id = f.get("check_id", "?").rsplit(".", 1)[-1]
            message = (
                f.get("extra", {}).get("message") or f.get("message") or ""
            ).strip()
            lines.append(f"  {path}:{line_no}  [{rule_id}]")
            for msg_line in message.splitlines()[:3]:
                lines.append(f"    {msg_line}")
        lines.append("")

    return "\n".join(lines)


def format_json(scan: ScanResult) -> str:
    return json.dumps(scan.raw, indent=2)


def format_csv(scan: ScanResult) -> str:
    """Render findings as CSV — one row per finding."""
    out = io.StringIO()
    writer = csv.writer(out)
    writer.writerow(["severity", "file", "line", "rule", "message"])
    for f in scan.findings:
        sev = (f.get("extra", {}).get("severity") or f.get("severity") or "UNKNOWN").upper()
        path = f.get("path", "")
        start = f.get("start") or {}
        line_no = start.get("line", "") if isinstance(start, dict) else f.get("line", "")
        rule_id = f.get("check_id", "").rsplit(".", 1)[-1]
        message = (f.get("extra", {}).get("message") or f.get("message") or "").strip().replace("\n", " ")
        writer.writerow([sev, path, line_no, rule_id, message])
    return out.getvalue()

