"""Advanced analyzers (CodeQL + Semgrep) for deeper static analysis."""

from __future__ import annotations

import json
import logging
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Iterable, List, Optional

from .runner import Finding

logger = logging.getLogger("oncecheck")

_SOURCE_EXTENSIONS = {
    "javascript": {".js", ".jsx", ".ts", ".tsx"},
    "java": {".java", ".kt"},
    "swift": {".swift"},
}

_CODEQL_QUERY_SUITES = {
    "javascript": "codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls",
    "java": "codeql/java-queries:codeql-suites/java-security-and-quality.qls",
    "swift": "codeql/swift-queries:codeql-suites/swift-security-and-quality.qls",
}

_SEMGREP_GLOBS = {
    "javascript": "*.js,*.jsx,*.ts,*.tsx",
    "java": "*.java,*.kt",
    "swift": "*.swift",
}


def available_engines() -> dict[str, bool]:
    """Return availability of analysis engines."""
    return {
        "semgrep": shutil.which("semgrep") is not None,
        "codeql": shutil.which("codeql") is not None,
    }


def detected_languages(project_root: Path) -> list[str]:
    """Infer analyzable languages from source extensions in a project."""
    root = Path(project_root)
    found: set[str] = set()
    for path in root.rglob("*"):
        if not path.is_file():
            continue
        rel = str(path.relative_to(root))
        if any(skip in rel for skip in ("node_modules/", "dist/", "build/", ".next/", ".gradle/", ".git/")):
            continue
        suffix = path.suffix.lower()
        for language, exts in _SOURCE_EXTENSIONS.items():
            if suffix in exts:
                found.add(language)
    return sorted(found)


def _normalize_rule_id(raw: str, prefix: str) -> str:
    token = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw or "").strip("-")
    if not token:
        token = "unknown"
    return f"{prefix}-{token}"[:120]


def _severity_from_semgrep(result: dict) -> str:
    extra = result.get("extra", {}) or {}
    sev = str(extra.get("severity", "")).upper()
    if sev in ("ERROR", "HIGH", "CRITICAL"):
        return "FAIL"
    if sev in ("WARNING", "MEDIUM"):
        return "WARN"
    return "INFO"


def _severity_from_sarif(level: str, properties: Optional[dict]) -> str:
    lvl = (level or "").lower()
    if lvl == "error":
        return "FAIL"
    if lvl == "warning":
        return "WARN"
    props = properties or {}
    sec = str(props.get("security-severity", "")).strip()
    if sec:
        try:
            score = float(sec)
            if score >= 8.0:
                return "FAIL"
            if score >= 4.0:
                return "WARN"
        except ValueError:
            pass
    return "INFO"


def _run(cmd: list[str], cwd: Optional[Path] = None, timeout_seconds: int = 180) -> tuple[int, str, str]:
    try:
        result = subprocess.run(
            cmd,
            cwd=str(cwd) if cwd else None,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        logger.warning("Command failed (%s): %s", " ".join(cmd[:3]), exc)
        return 1, "", str(exc)
    return result.returncode, result.stdout, result.stderr


def run_semgrep(
    project_root: Path,
    languages: Optional[Iterable[str]] = None,
    timeout_seconds: int = 180,
) -> List[Finding]:
    """Run Semgrep in JSON mode and map findings into Oncecheck findings."""
    include_globs: list[str] = []
    for lang in (languages or []):
        if lang in _SEMGREP_GLOBS:
            include_globs.extend(_SEMGREP_GLOBS[lang].split(","))
    cmd = ["semgrep", "--config", "auto", "--json", "--quiet"]
    for glob in sorted(set(include_globs)):
        cmd.extend(["--include", glob])
    cmd.append(str(project_root))
    code, stdout, _stderr = _run(cmd, timeout_seconds=timeout_seconds)
    if code != 0 and not stdout.strip():
        return []

    if not stdout.strip():
        return []

    try:
        data = json.loads(stdout)
    except json.JSONDecodeError:
        logger.warning("Semgrep returned non-JSON output")
        return []

    findings: List[Finding] = []
    for item in data.get("results", []):
        extra = item.get("extra", {}) or {}
        msg = str(extra.get("message", "Semgrep finding"))
        rule_id = _normalize_rule_id(str(item.get("check_id", "semgrep")), "SG")
        path = str(item.get("path", ""))
        start = item.get("start", {}) or {}
        line = int(start.get("line", 0) or 0)
        finding = Finding(
            rule_id=rule_id,
            severity=_severity_from_semgrep(item),
            message=msg,
            fix="Review Semgrep finding and remediate according to secure coding guidance.",
            reference="https://semgrep.dev/docs",
            file_path=path,
            line=line,
            engine="semgrep",
            confidence=0.8,
            trace=[f"semgrep:{item.get('check_id', 'unknown')}"],
        )
        findings.append(finding)
    return findings


def _parse_sarif(path: Path, prefix: str) -> list[Finding]:
    try:
        payload = json.loads(path.read_text())
    except (OSError, json.JSONDecodeError):
        return []

    findings: list[Finding] = []
    for run in payload.get("runs", []):
        tool = run.get("tool", {}) or {}
        driver = tool.get("driver", {}) or {}
        tool_name = str(driver.get("name", "")).strip() or prefix
        rules_by_id = {
            str(rule.get("id", "")): rule
            for rule in (driver.get("rules", []) or [])
            if rule.get("id")
        }
        for result in run.get("results", []):
            rid = str(result.get("ruleId", "")).strip() or "unknown"
            rule_meta = rules_by_id.get(rid, {})
            msg = str((result.get("message", {}) or {}).get("text", "")).strip() or f"{tool_name} finding"
            level = str(result.get("level", "warning"))
            properties = (result.get("properties", {}) or {})
            locations = result.get("locations", []) or []
            file_path = ""
            line = 0
            if locations:
                physical = (locations[0].get("physicalLocation", {}) or {})
                artifact = (physical.get("artifactLocation", {}) or {})
                region = (physical.get("region", {}) or {})
                file_path = str(artifact.get("uri", "")).strip()
                line = int(region.get("startLine", 0) or 0)
            findings.append(
                Finding(
                    rule_id=_normalize_rule_id(rid, prefix),
                    severity=_severity_from_sarif(level, properties),
                    message=msg,
                    fix="Review and remediate this issue using secure coding guidance for your stack.",
                    reference="https://codeql.github.com/docs/" if prefix == "CQ" else "https://semgrep.dev/docs",
                    file_path=file_path,
                    line=line,
                    engine="codeql" if prefix == "CQ" else "semgrep",
                    confidence=0.92 if prefix == "CQ" else 0.8,
                    trace=[f"{tool_name}:{rid}"],
                )
            )
    return findings


def _create_codeql_db(project_root: Path, language: str, db_dir: Path) -> bool:
    cmd = [
        "codeql",
        "database",
        "create",
        str(db_dir),
        f"--language={language}",
        f"--source-root={project_root}",
        "--overwrite",
    ]
    if language in {"java", "swift"}:
        # Keep scan runtime stable in local CLI mode even when build system is unavailable.
        cmd.append("--build-mode=none")
    code, _stdout, stderr = _run(cmd, timeout_seconds=600)
    if code != 0:
        logger.warning("CodeQL DB create failed for %s: %s", language, stderr.strip()[:300])
        return False
    return True


def _analyze_codeql_db(db_dir: Path, language: str, out_file: Path) -> bool:
    query_suite = _CODEQL_QUERY_SUITES.get(language)
    if not query_suite:
        return False
    cmd = [
        "codeql",
        "database",
        "analyze",
        str(db_dir),
        query_suite,
        "--format=sarif-latest",
        f"--output={out_file}",
        "--threads=0",
    ]
    code, _stdout, stderr = _run(cmd, timeout_seconds=900)
    if code != 0:
        logger.warning("CodeQL analyze failed for %s: %s", language, stderr.strip()[:300])
        return False
    return out_file.exists()


def run_codeql(
    project_root: Path,
    languages: Optional[Iterable[str]] = None,
    strict: bool = False,
) -> List[Finding]:
    """Run CodeQL end-to-end (DB create + analyze) and return mapped findings."""
    if shutil.which("codeql") is None:
        if strict:
            raise RuntimeError("CodeQL is required but not installed.")
        return []

    chosen = [lang for lang in (languages or []) if lang in _CODEQL_QUERY_SUITES]
    if not chosen:
        return []

    findings: list[Finding] = []
    failures: list[str] = []
    with tempfile.TemporaryDirectory(prefix="oncecheck-codeql-") as tmp:
        tmp_root = Path(tmp)
        for language in chosen:
            db_dir = tmp_root / f"db-{language}"
            if not _create_codeql_db(project_root, language, db_dir):
                failures.append(f"{language}:db_create")
                continue
            sarif = tmp_root / f"{language}.sarif"
            if not _analyze_codeql_db(db_dir, language, sarif):
                failures.append(f"{language}:analyze")
                continue
            findings.extend(_parse_sarif(sarif, "CQ"))

    if strict and failures:
        raise RuntimeError(f"CodeQL strict mode failed: {', '.join(failures)}")
    return findings


def run_advanced_analysis(project_root: Path, strict: bool = False, profile: str = "balanced") -> List[Finding]:
    """Run advanced analyzers and merge findings."""
    engines = available_engines()
    languages = detected_languages(project_root)
    findings: List[Finding] = []
    engine_attempted = False
    had_findings_or_success = False

    normalized_profile = (profile or "balanced").strip().lower()
    use_semgrep = normalized_profile in {"balanced", "semgrep-only", "codeql-first"}
    use_codeql = normalized_profile in {"balanced", "codeql-only", "codeql-first"}

    if engines["semgrep"] and use_semgrep and normalized_profile != "codeql-first":
        engine_attempted = True
        semgrep_findings = run_semgrep(project_root, languages=languages)
        findings.extend(semgrep_findings)
        had_findings_or_success = had_findings_or_success or True
    if engines["codeql"] and use_codeql:
        engine_attempted = True
        codeql_findings = run_codeql(project_root, languages=languages, strict=strict)
        findings.extend(codeql_findings)
        had_findings_or_success = had_findings_or_success or True
    if engines["semgrep"] and use_semgrep and normalized_profile == "codeql-first":
        engine_attempted = True
        semgrep_findings = run_semgrep(project_root, languages=languages)
        findings.extend(semgrep_findings)
        had_findings_or_success = had_findings_or_success or True

    if strict and (not engine_attempted or not had_findings_or_success):
        raise RuntimeError("No advanced compiler-grade engine available for strict mode.")

    dedup: dict[tuple[str, str, int], Finding] = {}
    for f in findings:
        key = (f.rule_id, f.file_path, f.line)
        dedup[key] = f
    return list(dedup.values())
