"""Run external static analysis tools (Ruff, Bandit, Vulture, Semgrep) and convert to Findings."""

from __future__ import annotations

import asyncio
import json
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import Optional

from autodebug.agent.specialists.base import Finding

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Tool availability (cached per-process)
# ---------------------------------------------------------------------------

_TOOL_PATHS: dict[str, Optional[str]] = {}


def _find_tool(name: str) -> Optional[str]:
    if name not in _TOOL_PATHS:
        _TOOL_PATHS[name] = shutil.which(name)
    return _TOOL_PATHS[name]


# ---------------------------------------------------------------------------
# Ruff — ~800 rules, <1s for entire codebase
# ---------------------------------------------------------------------------

async def run_ruff(path: str, project_root: str) -> tuple[list[Finding], float]:
    """Run ruff on a file or directory. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("ruff"):
        return [], 0.0

    target = os.path.join(project_root, path) if not os.path.isabs(path) else path
    cmd = [
        "ruff", "check", "--output-format", "json",
        "--select", ",".join([
            # Security
            "S102",  # exec
            "S105",  # hardcoded password
            "S106",  # hardcoded password in function arg
            "S108",  # insecure temp file
            "S301",  # pickle
            "S302",  # marshal
            "S303",  # insecure hash (md5/sha1)
            "S304",  # insecure cipher
            "S305",  # insecure cipher mode
            "S306",  # mktemp
            "S307",  # eval
            "S312",  # telnet
            "S314",  # xml parse
            "S324",  # insecure hash
            "S506",  # yaml unsafe load
            "S602",  # subprocess with shell=True
            "S608",  # SQL injection
            "S609",  # wildcard injection
            # Real bugs (pyflakes)
            "F811",  # redefined unused name
            "F821",  # undefined name
            "F541",  # f-string without placeholders
            # Logic bugs
            "E711",  # comparison to None (use `is` / `is not`)
            "E712",  # comparison to True/False (use `if x:`)
            "E721",  # type comparison (use isinstance())
            "W605",  # invalid escape sequence
            # Error handling
            "B006",  # mutable default arg
            "B007",  # unused loop variable (use _)
            "B017",  # assertRaises(Exception) too broad
            "B018",  # useless expression
            "B020",  # loop control variable overwritten
            "B023",  # function uses loop variable (late binding bug)
            "B025",  # except duplicate
            "B029",  # except with empty tuple
            "B033",  # duplicate set item
            "B034",  # re.sub without flags on multiline
            "B905",  # zip without strict
            # Return / control flow (only real bugs, not style)
            "RET503",  # missing explicit return at end of function
            # Pylint errors
            "PLE",
            # Pylint warnings (high-signal subset)
            "PLW0127",  # self-assignment
            "PLW0129",  # assert on string literal (always true)
            "PLW2901",  # overwriting loop variable with assignment
        ]),
        "--no-fix",
        target,
    ]

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=30,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []

    if proc.stdout.strip():
        try:
            results = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return [], elapsed

        root = Path(project_root).resolve()
        for item in results:
            try:
                file_path = item.get("filename", "")
                # Make path relative to project root
                try:
                    rel_path = str(Path(file_path).resolve().relative_to(root))
                except ValueError:
                    rel_path = file_path

                line = item.get("location", {}).get("row", 0)
                code = item.get("code", "")
                msg = item.get("message", "")

                # Filter known false positives
                if _is_ruff_false_positive(code, msg):
                    continue

                # Skip hardcoded secret/password findings in test/doc/example files
                _is_non_prod = any(p in rel_path for p in (
                    "test_", "tests/", "test/", "_test.", "conftest", "fixture",
                    "docs/", "docs_src/", "examples/", "example/", "mock", "fake",
                    "sample", "demo", "tutorial",
                ))
                if _is_non_prod and code in ("S105", "S106", "S108", "S301"):
                    continue

                # B018 (useless expression) in test files — often intentional (property access assertions)
                _is_test = any(p in rel_path for p in ("test_", "tests/", "test/", "conftest"))
                if _is_test and code == "B018":
                    continue

                # E721 (type comparison) in test files — tests intentionally check exact type
                if _is_test and code == "E721":
                    continue

                # S102/S301 (exec/pickle) in test files — tests use these deliberately
                if _is_test and code in ("S102", "S301"):
                    continue

                # S105/S106: skip common parameter names that aren't real secrets
                if code in ("S105", "S106"):
                    _common_names = ("password", "token", "secret", "key", "hashed_password",
                                     "x_token", "api_key", "auth_token", "secret_key")
                    _m = re.search(r'"(\w+)"', msg)
                    if _m and _m.group(1).lower() in _common_names:
                        continue

                severity = _ruff_severity(code)
                findings.append(Finding(
                    line=line,
                    severity=severity,
                    message=f"[ruff:{code}] {msg}",
                    specialist="ruff",
                    confidence="DEFINITE",
                    file_path=rel_path,
                ))
            except Exception:
                continue

    return findings, elapsed


# Variable names that are not actually secrets (S105/S106 false positives)
_NOT_SECRETS = frozenset({
    "token_type", "secret_name", "password_pattern", "pass_rate",
    "password_hash", "password_field", "secret_key_name", "token_prefix",
    "auth_type", "credential_type", "secret_type",
})


def _is_ruff_false_positive(code: str, msg: str, source_line: str = "") -> bool:
    """Filter known false positives from ruff findings."""
    # S108: /tmp in regex patterns, docker volume mounts, detection patterns
    if code == "S108":
        # Regex patterns: re.compile(r"/tmp/...")
        if any(kw in msg for kw in ("re.compile", "[\\w", "[^/", "\\d")):
            return True
        # Docker volume mounts and container configs
        if any(kw in msg for kw in (":rw", ":ro", "noexec", "nosuid", "pytest")):
            return True
        # Bare "/tmp" or "/tmp/" in pattern matching, config, or detection code
        if any(msg.endswith(s) for s in ('"/tmp/"', "'/tmp/'", '"/tmp"', "'/tmp'")):
            return True
        return False

    # S105/S106: variable names that aren't actual passwords
    if code in ("S105", "S106"):
        # Extract the variable name from message like 'assigned to: "token_type"'
        import re as _re
        m = _re.search(r'"(\w+)"', msg)
        if m and m.group(1).lower() in _NOT_SECRETS:
            return True
        # Pattern detection variables (contain "PATTERN" or end with "_REGEX")
        if m and ("PATTERN" in m.group(1) or m.group(1).endswith("_REGEX")):
            return True
        return False

    # S324: md5 used for content hashing (not security) — downgrade, don't suppress
    # (handled in _ruff_severity instead)

    # S608: PostgREST query building (select ... from ...) — not raw SQL
    if code == "S608":
        if "postgrest" in msg.lower() or "select" in msg.lower():
            # Check if it's building a REST query, not SQL
            return False  # keep for now, hard to distinguish without source

    # PLE0704: bare raise outside exception handler — almost always intentional
    # (re-raising in error handler methods that receive exception as parameter)
    if code == "PLE0704":
        return True

    return False


def _ruff_severity(code: str) -> str:
    """Map ruff rule codes to severity levels."""
    # Simplification — style, not bugs (must check before S-prefix security rules)
    if code.startswith("SIM"):
        return "LOW"
    # md5/sha1 for content hashing — downgrade to LOW
    if code == "S324":
        return "LOW"
    # Security rules (S series from bandit, but not SIM which is already handled)
    if code.startswith("S"):
        return "HIGH"
    # Logic bugs that cause real crashes/wrong behavior
    if code in ("E711", "E712", "E721", "W605", "B023", "F821", "F811"):
        return "HIGH"
    # Bug-prone patterns (bugbear)
    if code.startswith(("B", "PLE", "PLW")):
        return "MEDIUM"
    # Return — only missing return is medium, rest is style
    if code == "RET503":
        return "MEDIUM"
    if code.startswith("RET"):
        return "LOW"
    # Performance
    if code.startswith(("PERF", "C4")):
        return "LOW"
    # Pyflakes
    if code.startswith("F"):
        return "MEDIUM"
    # Everything else
    return "LOW"


# ---------------------------------------------------------------------------
# Bandit — Python security linter
# ---------------------------------------------------------------------------

async def run_bandit(path: str, project_root: str) -> tuple[list[Finding], float]:
    """Run bandit on a file or directory. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("bandit"):
        return [], 0.0

    target = os.path.join(project_root, path) if not os.path.isabs(path) else path
    is_dir = os.path.isdir(target)

    cmd = ["bandit", "-f", "json", "-l", "-ii"]  # -l = low+, -ii = medium+ confidence
    if is_dir:
        cmd.extend(["-r", target])
    else:
        cmd.append(target)

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=60,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []

    if proc.stdout.strip():
        try:
            data = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return [], elapsed

        root = Path(project_root).resolve()
        for item in data.get("results", []):
            try:
                file_path = item.get("filename", "")
                try:
                    rel_path = str(Path(file_path).resolve().relative_to(root))
                except ValueError:
                    rel_path = file_path

                line = item.get("line_number", 0)
                test_id = item.get("test_id", "")
                issue_text = item.get("issue_text", "")
                severity = item.get("issue_severity", "MEDIUM").upper()
                confidence = item.get("issue_confidence", "MEDIUM").upper()

                # Skip noisy rules
                if test_id in ("B101",):  # assert — too noisy, idiomatic Python
                    continue
                if test_id in ("B403", "B404"):  # import pickle/subprocess — not a bug
                    continue

                # B301 (pickle) in test files — tests often pickle for serialization testing
                _is_test = any(p in rel_path for p in ("test_", "tests/", "test/", "conftest"))
                if _is_test and test_id in ("B301",):
                    continue

                # B324 (weak hash) — demote to MEDIUM; often intentional (digest auth, checksums)
                if test_id == "B324":
                    severity = "MEDIUM"

                findings.append(Finding(
                    line=line,
                    severity=severity,
                    message=f"[bandit:{test_id}] {issue_text}",
                    specialist="bandit",
                    confidence="DEFINITE" if confidence == "HIGH" else "LIKELY",
                    file_path=rel_path,
                ))
            except Exception:
                continue

    return findings, elapsed


# ---------------------------------------------------------------------------
# Vulture — dead code detection
# ---------------------------------------------------------------------------

async def run_vulture(path: str, project_root: str) -> tuple[list[Finding], float]:
    """Run vulture on a file or directory. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("vulture"):
        return [], 0.0

    target = os.path.join(project_root, path) if not os.path.isabs(path) else path
    cmd = ["vulture", target, "--min-confidence", "80"]

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=60,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []

    # Vulture outputs text: path:line: message (confidence%)
    root = Path(project_root).resolve()
    for raw_line in (proc.stdout or "").splitlines():
        match = re.match(r'^(.+?):(\d+): (.+?) \((\d+)% confidence\)', raw_line)
        if not match:
            continue
        file_path, line_str, message, conf_str = match.groups()
        try:
            rel_path = str(Path(file_path).resolve().relative_to(root))
        except ValueError:
            rel_path = file_path

        confidence_pct = int(conf_str)
        if confidence_pct < 80:
            continue

        # Skip "unused function/variable/class/attribute" — too many false positives
        # on public APIs. Only keep "unreachable code" and "unused import".
        msg_lower = message.lower()
        if "unused" in msg_lower and "import" not in msg_lower:
            continue

        findings.append(Finding(
            line=int(line_str),
            severity="LOW",
            message=f"[vulture] {message}",
            specialist="vulture",
            confidence="DEFINITE" if confidence_pct >= 90 else "LIKELY",
            file_path=rel_path,
        ))

    return findings, elapsed


# ---------------------------------------------------------------------------
# Semgrep — pattern-based analysis (reuse existing installation)
# ---------------------------------------------------------------------------

async def run_semgrep(path: str, project_root: str) -> tuple[list[Finding], float]:
    """Run semgrep with Python security rules. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("semgrep"):
        return [], 0.0

    target = os.path.join(project_root, path) if not os.path.isabs(path) else path

    # Use local config if available, otherwise use free registry security rules
    local_config = os.path.join(project_root, ".semgrep.yaml")
    if os.path.exists(local_config):
        configs = ["--config", local_config]
    else:
        # High-signal free registry rulesets — security-focused, low noise
        configs = [
            "--config", "p/python",
            "--config", "p/javascript",
            "--config", "p/default",
        ]

    cmd = [
        "semgrep", "scan", "--json", "--quiet",
        "--max-target-bytes=1000000",
        "--timeout=10",
        "--no-git-ignore",  # respect our own skip-dirs instead
    ] + configs + [target]

    env = os.environ.copy()
    env.setdefault("PYDANTIC_DISABLE_PLUGINS", "1")
    env.setdefault("LOGFIRE_PYDANTIC_PLUGIN_DISABLED", "1")

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=90,
            env=env,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []

    output = proc.stdout or ""
    # Semgrep sometimes mixes logs with JSON
    json_match = re.search(r'(?:^|\n)\s*\{', output)
    if json_match:
        try:
            data = json.loads(output[json_match.start():])
        except json.JSONDecodeError:
            return [], elapsed
    else:
        return [], elapsed

    root = Path(project_root).resolve()
    for item in data.get("results", []):
        try:
            file_path = item.get("path", "")
            try:
                rel_path = str(Path(file_path).resolve().relative_to(root))
            except ValueError:
                rel_path = file_path

            line = item.get("start", {}).get("line", 0)
            rule_id = item.get("check_id", "").split(".")[-1]
            message = item.get("extra", {}).get("message", "")
            sev = item.get("extra", {}).get("severity", "WARNING").upper()

            severity = "HIGH" if sev == "ERROR" else "MEDIUM" if sev == "WARNING" else "LOW"

            findings.append(Finding(
                line=line,
                severity=severity,
                message=f"[semgrep:{rule_id}] {message[:120]}",
                specialist="semgrep",
                confidence="DEFINITE",
                file_path=rel_path,
            ))
        except Exception:
            continue

    return findings, elapsed


# ---------------------------------------------------------------------------
# mypy — type checking (finds real runtime crashes)
# ---------------------------------------------------------------------------


async def run_mypy(path: str, project_root: str) -> tuple[list[Finding], float]:
    """Run mypy for type errors. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("mypy"):
        return [], 0.0

    target = os.path.join(project_root, path) if not os.path.isabs(path) else path

    # Only run on Python files/dirs
    if os.path.isfile(target) and not target.endswith(".py"):
        return [], 0.0

    cmd = [
        "mypy",
        "--no-error-summary",
        "--no-color-output",
        "--ignore-missing-imports",  # don't fail on missing third-party stubs
        "--no-incremental",          # clean run, no cache dependency
        "--show-error-codes",
        "--show-column-numbers",
        target,
    ]

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=120,
            cwd=project_root,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []
    root = Path(project_root).resolve()

    # Parse mypy output: file.py:line:col: error: message  [error-code]
    for line in (proc.stdout or "").splitlines():
        m = re.match(r'^(.+?):(\d+):\d+:\s*(error|warning):\s*(.+?)(?:\s+\[(.+?)\])?$', line)
        if not m:
            continue

        file_path, lineno, level, msg, code = m.groups()
        try:
            rel_path = str(Path(file_path).resolve().relative_to(root))
        except ValueError:
            rel_path = file_path

        # Skip common noise
        if code in ("import", "import-untyped"):
            continue
        # Skip test files for certain errors
        _is_test = any(p in rel_path for p in ("test_", "tests/", "test/", "conftest"))
        if _is_test and code in ("no-untyped-def", "no-untyped-call"):
            continue

        # Skip noisy mypy codes that flag typing style, not real bugs
        if code in ("var-annotated", "no-redef", "no-untyped-def",
                     "no-untyped-call", "valid-type", "override"):
            continue

        # assignment: "expression has type None" — optional import pattern (x = None; try: import x)
        if code == "assignment" and "type \"None\"" in msg:
            continue

        # Type errors are MEDIUM (common and often intentional), not HIGH
        # Only flag as HIGH for truly dangerous errors
        severity = "MEDIUM"
        if code in ("return-value", "arg-type", "call-arg", "operator"):
            severity = "HIGH"  # These cause real runtime crashes
        # Skip findings without an error code — too noisy and low signal
        if not code:
            continue

        tag = f"[mypy:{code}]"
        findings.append(Finding(
            line=int(lineno),
            severity=severity,
            message=f"{tag} {msg}",
            specialist="mypy",
            confidence="DEFINITE",
            file_path=rel_path,
        ))

    return findings, elapsed


# ---------------------------------------------------------------------------
# Pyright — fast type checking (catches errors mypy misses)
# ---------------------------------------------------------------------------


async def run_pyright(path: str, project_root: str) -> tuple[list[Finding], float]:
    """Run pyright for type errors. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("pyright"):
        return [], 0.0

    target = os.path.join(project_root, path) if not os.path.isabs(path) else path

    # Only run on Python files/dirs
    if os.path.isfile(target) and not target.endswith(".py"):
        return [], 0.0

    cmd = ["pyright", "--outputjson", target]

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=120,
            cwd=project_root,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []
    root = Path(project_root).resolve()

    if proc.stdout.strip():
        try:
            data = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return [], elapsed

        # Only keep high-signal rules that indicate real runtime bugs
        _KEEP_RULES = frozenset({
            "reportOptionalMemberAccess",   # x.attr where x could be None
            "reportOptionalCall",           # x() where x could be None
            "reportOptionalSubscript",      # x[i] where x could be None
            "reportOptionalIterable",       # for y in x where x could be None
            "reportReturnType",             # function returns wrong type
            "reportIndexIssue",             # bad index access
            "reportUndefinedVariable",      # truly undefined names
        })

        for item in data.get("generalDiagnostics", []):
            try:
                severity = item.get("severity", "information")
                if severity != "error":
                    continue

                rule = item.get("rule", "")
                if rule not in _KEEP_RULES:
                    continue

                file_path = item.get("file", "")
                try:
                    rel_path = str(Path(file_path).resolve().relative_to(root))
                except ValueError:
                    rel_path = file_path

                line = item.get("range", {}).get("start", {}).get("line", 0)
                msg = item.get("message", "")

                # Skip test/docs/example files — type errors there are often intentional
                _skip_path = any(p in rel_path for p in (
                    "test_", "tests/", "test/", "conftest",
                    "docs_src/", "docs/", "examples/", "example/",
                    "tutorial", "sample", "demo",
                ))
                if _skip_path:
                    continue

                tag = f"[pyright:{rule}]" if rule else "[pyright]"
                findings.append(Finding(
                    line=line,
                    severity="HIGH",
                    message=f"{tag} {msg}",
                    specialist="pyright",
                    confidence="DEFINITE",
                    file_path=rel_path,
                ))
            except Exception:
                continue

    return findings, elapsed


# ---------------------------------------------------------------------------
# pip-audit — dependency vulnerability scanning
# ---------------------------------------------------------------------------

async def run_pip_audit(project_root: str) -> tuple[list[Finding], float]:
    """Run pip-audit on requirements. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("pip-audit"):
        return [], 0.0

    # Find requirements file
    req_files = ["requirements.txt", "requirements.in"]
    req_path = None
    for rf in req_files:
        candidate = os.path.join(project_root, rf)
        if os.path.exists(candidate):
            req_path = candidate
            break

    # Fall back to scanning installed packages
    cmd = ["pip-audit", "--format", "json", "--progress-spinner", "off"]
    if req_path:
        cmd.extend(["-r", req_path])

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=120,
            cwd=project_root,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []

    if proc.stdout.strip():
        try:
            data = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return [], elapsed

        vulns = data if isinstance(data, list) else data.get("dependencies", [])
        for dep in vulns:
            dep_vulns = dep.get("vulns", [])
            if not dep_vulns:
                continue
            name = dep.get("name", "unknown")
            version = dep.get("version", "?")
            for vuln in dep_vulns:
                vuln_id = vuln.get("id", "")
                fix = vuln.get("fix_versions", [])
                fix_str = f" — fix: upgrade to {', '.join(fix)}" if fix else ""
                findings.append(Finding(
                    line=0,
                    severity="HIGH",
                    message=f"[pip-audit:{vuln_id}] {name}=={version} has known vulnerability{fix_str}",
                    specialist="pip-audit",
                    confidence="DEFINITE",
                ))

    return findings, elapsed


# ---------------------------------------------------------------------------
# ESLint — JavaScript/TypeScript linter (~500+ rules)
# ---------------------------------------------------------------------------

async def run_eslint(path: str, project_root: str) -> tuple[list[Finding], float]:
    """Run eslint on a file or directory. Returns (findings, elapsed_seconds)."""
    import time

    # Check for eslint — try npx first (project-local), then global
    target = os.path.join(project_root, path) if not os.path.isabs(path) else path

    # Only run on JS/TS files
    if os.path.isfile(target):
        ext = os.path.splitext(target)[1].lower()
        if ext not in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts"):
            return [], 0.0

    # Prefer npx (uses project-local eslint), fall back to global
    eslint_cmd = None
    if _find_tool("npx"):
        eslint_cmd = ["npx", "--no-install", "eslint"]
    elif _find_tool("eslint"):
        eslint_cmd = ["eslint"]
    else:
        return [], 0.0

    cmd = eslint_cmd + [
        "--format", "json",
        "--no-eslintrc",
        "--rule", json.dumps({
            "no-unused-vars": "warn",
            "no-undef": "error",
            "eqeqeq": "warn",
            "no-eval": "error",
            "no-implied-eval": "error",
            "no-new-func": "error",
            "no-script-url": "error",
            "no-self-compare": "warn",
            "no-unreachable": "error",
            "no-constant-condition": "warn",
            "no-empty": "warn",
            "no-dupe-keys": "error",
            "no-duplicate-case": "error",
            "no-loss-of-precision": "warn",
            "no-unsafe-negation": "error",
            "no-fallthrough": "warn",
            "use-isnan": "error",
            "valid-typeof": "error",
        }),
        "--ext", ".js,.jsx,.ts,.tsx,.mjs,.mts",
        target,
    ]

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=30,
            cwd=project_root,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []

    if proc.stdout.strip():
        try:
            results = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return [], elapsed

        root = Path(project_root).resolve()
        for file_result in results:
            file_path = file_result.get("filePath", "")
            try:
                rel_path = str(Path(file_path).resolve().relative_to(root))
            except ValueError:
                rel_path = file_path

            for msg in file_result.get("messages", []):
                rule_id = msg.get("ruleId", "") or "parse-error"
                message = msg.get("message", "")
                line = msg.get("line", 0)
                sev = msg.get("severity", 1)  # 1=warn, 2=error

                severity = "HIGH" if sev >= 2 else "MEDIUM"
                # Downgrade style-ish rules
                if rule_id in ("no-unused-vars", "no-empty", "no-fallthrough"):
                    severity = "LOW"

                findings.append(Finding(
                    line=line,
                    severity=severity,
                    message=f"[eslint:{rule_id}] {message}",
                    specialist="eslint",
                    confidence="DEFINITE",
                    file_path=rel_path,
                ))

    return findings, elapsed


# ---------------------------------------------------------------------------
# npm audit — Node.js dependency vulnerability scanning
# ---------------------------------------------------------------------------

async def run_npm_audit(project_root: str) -> tuple[list[Finding], float]:
    """Run npm audit on package.json. Returns (findings, elapsed_seconds)."""
    import time

    if not _find_tool("npm"):
        return [], 0.0

    # Only run if package.json exists
    pkg_json = os.path.join(project_root, "package.json")
    if not os.path.exists(pkg_json):
        # Check in web/ subdirectory (common monorepo pattern)
        pkg_json = os.path.join(project_root, "web", "package.json")
        if not os.path.exists(pkg_json):
            return [], 0.0

    audit_dir = os.path.dirname(pkg_json)
    # Need node_modules or package-lock.json
    has_lock = os.path.exists(os.path.join(audit_dir, "package-lock.json"))
    has_yarn = os.path.exists(os.path.join(audit_dir, "yarn.lock"))
    if not has_lock and not has_yarn:
        return [], 0.0

    cmd = ["npm", "audit", "--json", "--production"]

    t0 = time.time()
    try:
        proc = await asyncio.to_thread(
            subprocess.run, cmd,
            capture_output=True, text=True, timeout=60,
            cwd=audit_dir,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return [], time.time() - t0

    elapsed = time.time() - t0
    findings: list[Finding] = []

    if proc.stdout.strip():
        try:
            data = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return [], elapsed

        # npm audit v2 format (npm >= 7)
        vulns = data.get("vulnerabilities", {})
        for pkg_name, info in vulns.items():
            sev = info.get("severity", "moderate")
            via = info.get("via", [])
            # Get first advisory description
            desc = ""
            for v in via:
                if isinstance(v, dict):
                    desc = v.get("title", "") or v.get("url", "")
                    break
                elif isinstance(v, str):
                    desc = f"via {v}"
                    break

            severity = "HIGH" if sev in ("high", "critical") else "MEDIUM" if sev == "moderate" else "LOW"
            fix_available = info.get("fixAvailable", False)
            fix_str = " (fix available)" if fix_available else ""

            findings.append(Finding(
                line=0,
                severity=severity,
                message=f"[npm-audit:{sev}] {pkg_name}: {desc}{fix_str}",
                specialist="npm-audit",
                confidence="DEFINITE",
                file_path="package.json",
            ))

    return findings, elapsed


# ---------------------------------------------------------------------------
# Unified runner — run all available tools in parallel
# ---------------------------------------------------------------------------

async def run_external_tools(
    path: str,
    project_root: str,
    single_file: bool = False,
) -> tuple[list[Finding], dict[str, float]]:
    """Run all available external tools in parallel.

    Args:
        path: File or directory path (relative to project_root)
        project_root: Project root directory
        single_file: If True, skip tools that don't work well on single files (vulture, pip-audit)

    Returns:
        (all_findings, timing_dict) where timing maps tool name to seconds.
    """
    tasks = [
        ("ruff", run_ruff(path, project_root)),
        ("bandit", run_bandit(path, project_root)),
    ]
    if not single_file:
        tasks.append(("vulture", run_vulture(path, project_root)))
        tasks.append(("pip-audit", run_pip_audit(project_root)))
    # Note: Semgrep is available via run_semgrep() but excluded from the default
    # pipeline — its rules are too noisy for deterministic output and the agentic
    # path already handles it with its own filtering/dedup.

    results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)

    all_findings: list[Finding] = []
    timing: dict[str, float] = {}

    for (name, _), result in zip(tasks, results):
        if isinstance(result, Exception):
            timing[name] = 0.0
            logger.debug("External tool %s failed: %s", name, result)
            continue
        findings, elapsed = result
        all_findings.extend(findings)
        timing[name] = elapsed

    # Note which tools weren't available
    for tool in ["ruff", "bandit", "vulture", "semgrep", "pip-audit"]:
        if tool not in timing and not _find_tool(tool):
            timing[f"_{tool}_missing"] = 0.0

    return all_findings, timing
