"""Security checks using bandit, semgrep, and detect-secrets.

Two variants:
- SecurityLocalCheck: Local-only checks (bandit + semgrep + detect-secrets)
- SecurityCheck: Full audit including dependency scanning via pip-audit

Runs sub-checks concurrently for speed. Reports only HIGH/MEDIUM findings
to reduce noise while catching real security issues.

Note: These are cross-cutting security checks that apply to any project
with code files, not just Python projects.
"""

import json
import shutil
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional

from slopmop.checks.base import (
    BaseCheck,
    ConfigField,
    Flaw,
    GateCategory,
    GateLevel,
    PythonCheckMixin,
    ToolContext,
)
from slopmop.constants import NO_ISSUES_FOUND
from slopmop.core.result import CheckResult, CheckStatus

_SCANNER_NOT_INSTALLED = "{name} (not installed)"

EXCLUDED_DIRS = [
    "venv",
    ".venv",
    "node_modules",
    "cursor-rules",
    "archives",
    "logs",
    "tests",  # Test files often have intentional security "violations"
    "*/.venv",  # Nested venvs
    "*/venv",  # Nested venvs
]


@dataclass
class SecuritySubResult:
    """Result from a single security scanner."""

    name: str
    passed: bool
    findings: str


class SecurityLocalCheck(BaseCheck, PythonCheckMixin):
    """Local security scanning (no network required).

    Wraps bandit, semgrep, and detect-secrets in parallel.
    Reports only HIGH/MEDIUM severity findings to reduce noise
    while catching real security issues.

    Level: scour

    Configuration:
      scanners: ["bandit", "semgrep", "detect-secrets"] — all three
          run in parallel for speed. Each covers different classes
          of vulnerability.
      exclude_dirs: venv, node_modules, tests, etc. — test files
          often have intentional security "violations" (hardcoded
          test credentials, etc.).

    Common failures:
      bandit HIGH/MEDIUM: Fix the flagged code pattern. Common
          issues: hardcoded passwords, SQL injection, unsafe eval.
      semgrep findings: Follow the rule description in the output.
      detect-secrets: Rotate the leaked secret, then add to
          .secrets.baseline if it's a false positive.

    Re-check:
      ./sm swab -g myopia:vulnerability-blindness.py --verbose
    """

    tool_context = ToolContext.SM_TOOL

    @property
    def name(self) -> str:
        return "vulnerability-blindness.py"

    @property
    def display_name(self) -> str:
        return "🔐 Security Scan (code analysis)"

    @property
    def gate_description(self) -> str:
        return "🔐 bandit + semgrep + detect-secrets"

    @property
    def category(self) -> GateCategory:
        return GateCategory.MYOPIA

    @property
    def flaw(self) -> Flaw:
        return Flaw.MYOPIA

    @property
    def superseded_by(self) -> Optional[str]:
        return "myopia:dependency-risk.py"

    @property
    def config_schema(self) -> List[ConfigField]:
        return [
            ConfigField(
                name="scanners",
                field_type="string[]",
                default=["bandit", "semgrep", "detect-secrets"],
                description="Security scanners to run",
                permissiveness="more_is_stricter",
            ),
            ConfigField(
                name="exclude_dirs",
                field_type="string[]",
                default=EXCLUDED_DIRS.copy(),
                description="Directories to exclude from scanning",
                permissiveness="fewer_is_stricter",
            ),
            ConfigField(
                name="bandit_config_file",
                field_type="string",
                default=None,
                description=(
                    "Path to bandit config file (e.g. .bandit, pyproject.toml). "
                    "Separate from the standard config_file_path which is used "
                    "by detect-secrets (.secrets.baseline)"
                ),
                required=False,
            ),
        ]

    def is_applicable(self, project_root: str) -> bool:
        """Check if there are any source files to scan."""
        from pathlib import Path

        root = Path(project_root)
        # Look for Python or JS files
        has_py = any(root.rglob("*.py"))
        has_js = any(root.rglob("*.js")) or any(root.rglob("*.ts"))
        return has_py or has_js

    def skip_reason(self, project_root: str) -> str:
        """Return reason for skipping - no source files to scan."""
        return "No Python, JavaScript, or TypeScript files found to scan for security issues"

    @staticmethod
    def _is_scanner_available(name: str, importable: dict[str, str]) -> bool:
        """Check if a scanner tool is available on this system.

        For Python-based scanners (bandit, detect-secrets), checks
        importability. For external binaries (semgrep), checks PATH.
        """
        if name in importable:
            try:
                import importlib

                importlib.import_module(importable[name])
                return True
            except ImportError:
                return False
        # External binary (semgrep, etc.)
        return shutil.which(name) is not None

    def run(self, project_root: str) -> CheckResult:
        """Run configured security checks in parallel.

        Respects the ``scanners`` config to determine which tools to run.
        Scanners that aren't installed are skipped with a warning rather
        than counted as failures — this allows graceful degradation when
        heavy tools like semgrep aren't available locally.
        """
        start_time = time.time()

        scanner_map: dict[str, Callable[[str], SecuritySubResult]] = {
            "bandit": self._run_bandit,
            "semgrep": self._run_semgrep,
            "detect-secrets": self._run_detect_secrets,
        }

        configured = self.config.get("scanners", list(scanner_map.keys()))
        # Filter to only configured scanners that we know about
        sub_checks: List[Callable[[str], SecuritySubResult]] = []
        skipped: List[str] = []
        # Map scanner names to their Python module for importability check
        _importable = {
            "bandit": "bandit",
            "detect-secrets": "detect_secrets",  # pragma: allowlist secret
        }
        for name in configured:
            if name not in scanner_map:
                continue
            available = self._is_scanner_available(name, _importable)
            if available:
                sub_checks.append(scanner_map[name])
            else:
                skipped.append(_SCANNER_NOT_INSTALLED.format(name=name))

        if not sub_checks:
            duration = time.time() - start_time
            skip_msg = ", ".join(skipped) if skipped else "none configured"
            return self._create_result(
                status=CheckStatus.SKIPPED,
                duration=duration,
                output=f"No security scanners available ({skip_msg})",
            )

        results: List[SecuritySubResult] = []

        with ThreadPoolExecutor(max_workers=len(sub_checks)) as executor:
            futures = {
                executor.submit(fn, project_root): fn.__name__ for fn in sub_checks
            }
            for future in futures:
                try:
                    results.append(future.result(timeout=180))
                except Exception as e:
                    name = futures[future].replace("_run_", "")
                    results.append(SecuritySubResult(name, False, f"Error: {e}"))

        duration = time.time() - start_time
        failures = [r for r in results if not r.passed]

        if not failures:
            tools = ", ".join(r.name for r in results)
            skip_note = f" [skipped: {', '.join(skipped)}]" if skipped else ""
            return self._create_result(
                status=CheckStatus.PASSED,
                duration=duration,
                output=f"All security checks passed ({tools}){skip_note}",
            )

        detail = "\n\n".join(f"[{f.name}]\n{f.findings}" for f in failures)
        return self._create_result(
            status=CheckStatus.FAILED,
            duration=duration,
            output=detail,
            error=f"{len(failures)} security scanner(s) found issues",
            fix_suggestion="Address HIGH severity issues first. They block merge.",
        )

    def _get_exclude_dirs(self) -> List[str]:
        """Get directories to exclude from config or defaults."""
        return self.config.get("exclude_dirs", EXCLUDED_DIRS)

    def _run_bandit(self, project_root: str) -> SecuritySubResult:
        """Run bandit static analysis."""
        # Check for bandit-specific config file (e.g., .bandit, pyproject.toml with [tool.bandit])
        # Note: config_file_path in user config may be for detect-secrets (.secrets.baseline),
        # not bandit. Only use it for bandit if it's a known bandit config format.
        config_file = self.config.get("bandit_config_file")

        cmd = [
            sys.executable,
            "-m",
            "bandit",
            "-r",
            ".",
            "--format",
            "json",
            "--quiet",
        ]

        # Always apply exclude paths
        exclude_paths = ",".join(f"./{d}" for d in self._get_exclude_dirs())
        cmd.extend(["--exclude", exclude_paths])

        # Use config file if specified for bandit, otherwise use skip defaults
        if config_file and Path(project_root, config_file).exists():
            cmd.extend(["--configfile", config_file])
        else:
            # B101 = assert usage, B110 = try-except-pass (common patterns)
            cmd.extend(["--skip", "B101,B110"])

        result = self._run_command(cmd, cwd=project_root, timeout=120)

        # Try to parse JSON from stdout only - stderr contains warnings that aren't issues
        # Bandit returns non-zero for any findings including LOW severity
        try:
            report = json.loads(result.stdout)
            issues = [
                r
                for r in report.get("results", [])
                if r.get("issue_severity") in ("HIGH", "MEDIUM")
            ]
            if not issues:
                return SecuritySubResult("bandit", True, "No HIGH/MEDIUM issues")

            detail = "\n".join(
                f"  [{r['issue_severity']}] {r['issue_text']} ({r['test_name']}) "
                f"- {r.get('filename', '')}:{r.get('line_number', '')}"
                for r in issues[:10]
            )
            return SecuritySubResult("bandit", False, detail)
        except json.JSONDecodeError:
            # If JSON parsing fails, check stderr for actual errors
            if result.stderr and "error" in result.stderr.lower():
                return SecuritySubResult("bandit", False, result.stderr[-500:])
            # Otherwise bandit ran but produced no JSON (likely no issues)
            return SecuritySubResult("bandit", True, NO_ISSUES_FOUND)

    def _run_semgrep(self, project_root: str) -> SecuritySubResult:
        """Run semgrep static analysis."""
        cmd = ["semgrep", "scan", "--config=auto", "--json", "--quiet"]
        for d in self._get_exclude_dirs():
            cmd.extend(["--exclude", d])

        result = self._run_command(cmd, cwd=project_root, timeout=120)

        if result.success:
            return SecuritySubResult("semgrep", True, NO_ISSUES_FOUND)

        try:
            report = json.loads(result.stdout)
            findings = report.get("results", [])
            if not findings:
                return SecuritySubResult("semgrep", True, NO_ISSUES_FOUND)

            critical = [
                f
                for f in findings
                if f.get("extra", {}).get("severity") in ("ERROR", "WARNING")
            ]
            if not critical:
                return SecuritySubResult("semgrep", True, "Only informational findings")

            detail = "\n".join(
                f"  [{f.get('extra', {}).get('severity', '?')}] "
                f"{f.get('extra', {}).get('message', '')[:80]} "
                f"- {f.get('path', '')}:{f.get('start', {}).get('line', '')}"
                for f in critical[:10]
            )
            return SecuritySubResult("semgrep", False, detail)
        except json.JSONDecodeError:
            if result.returncode == 1 and result.stderr:
                return SecuritySubResult("semgrep", False, result.stderr[-300:])
            return SecuritySubResult("semgrep", True, "Scan completed")

    def _run_detect_secrets(self, project_root: str) -> SecuritySubResult:
        """Run detect-secrets hook."""
        # Check for baseline file
        config_file = self.config.get("config_file_path")

        cmd = [sys.executable, "-m", "detect_secrets", "scan"]
        if config_file:
            cmd.extend(["--baseline", config_file])

        result = self._run_command(cmd, cwd=project_root, timeout=60)

        if result.success:
            try:
                report = json.loads(result.output)
                detected = report.get("results", {})
                real_secrets = {
                    k: v for k, v in detected.items() if v and "constants.py" not in k
                }
                if not real_secrets:
                    return SecuritySubResult(
                        "detect-secrets", True, "No secrets detected"
                    )

                detail = "\n".join(
                    f"  Potential secret in {path}: "
                    f"{', '.join(str(s.get('type', '?')) for s in secrets)}"
                    for path, secrets in real_secrets.items()
                )
                return SecuritySubResult("detect-secrets", False, detail)
            except json.JSONDecodeError:
                return SecuritySubResult("detect-secrets", True, "Scan completed")

        return SecuritySubResult(
            "detect-secrets",
            False,
            result.output[-300:] if result.output else "Scan failed",
        )


class SecurityCheck(SecurityLocalCheck):
    """Full security audit including dependency scanning.

    Extends security:local with pip-audit for dependency
    vulnerability checking. Requires network access to query
    the OSV vulnerability database.

    Level: scour (full audit with network access)

    Configuration:
      Same as security:local, plus pip-audit runs automatically.
      pip-audit is fast (~1s) and uses the OSV database.

    Common failures:
      Vulnerable dependency: Update the package to a fixed version
          shown in the output. If no fix exists, evaluate risk and
          consider alternatives.
      pip-audit not available: pip install pip-audit

    Re-check:
      sm scour -g myopia:dependency-risk.py --verbose
    """

    level = GateLevel.SCOUR

    @property
    def name(self) -> str:
        return "dependency-risk.py"

    @property
    def display_name(self) -> str:
        return "🔒 Security Audit (code + dependencies)"

    @property
    def gate_description(self) -> str:
        return "🔒 Full security audit (code + pip-audit)"

    def run(self, project_root: str) -> CheckResult:
        """Run all security checks including dependency scanning."""
        start_time = time.time()

        sub_checks: List[Callable[[str], SecuritySubResult]] = [
            self._run_bandit,
            self._run_semgrep,
            self._run_detect_secrets,
            self._run_pip_audit,
        ]

        results: List[SecuritySubResult] = []

        with ThreadPoolExecutor(max_workers=4) as executor:
            futures = {
                executor.submit(fn, project_root): fn.__name__ for fn in sub_checks
            }
            for future in futures:
                try:
                    results.append(future.result(timeout=180))
                except Exception as e:
                    name = futures[future].replace("_run_", "")
                    results.append(SecuritySubResult(name, False, f"Error: {e}"))

        duration = time.time() - start_time
        failures = [r for r in results if not r.passed]

        if not failures:
            tools = ", ".join(r.name for r in results)
            return self._create_result(
                status=CheckStatus.PASSED,
                duration=duration,
                output=f"All security checks passed ({tools})",
            )

        detail = "\n\n".join(f"[{f.name}]\n{f.findings}" for f in failures)
        return self._create_result(
            status=CheckStatus.FAILED,
            duration=duration,
            output=detail,
            error=f"{len(failures)} security scanner(s) found issues",
            fix_suggestion="Address HIGH severity issues first. They block merge.",
        )

    def _run_pip_audit(self, project_root: str) -> SecuritySubResult:
        """Run pip-audit dependency vulnerability scan.

        pip-audit is fast (~1s), offline-capable, and uses the OSV database.
        Replaces safety which hangs on `safety scan` with no API key.

        Unlike bandit and detect-secrets (which scan source *files*),
        pip-audit audits the *installed packages* of whichever Python
        runs it.  We therefore use the project's Python so that the
        project's dependencies are inspected, not slop-mop's own.
        """
        cmd = [
            self.get_project_python(project_root),
            "-m",
            "pip_audit",
            "--format",
            "json",
        ]

        result = self._run_command(cmd, cwd=project_root, timeout=30)

        try:
            report = json.loads(result.stdout)
            deps = report.get("dependencies", [])
            vulnerable = [d for d in deps if d.get("vulns")]
            if not vulnerable:
                return SecuritySubResult(
                    "pip-audit", True, "No vulnerable dependencies"
                )

            detail = "\n".join(
                f"  {d['name']} {d.get('version', '?')}: "
                + ", ".join(
                    f"{v.get('id', '?')} ({', '.join(map(str, v.get('fix_versions', ['no fix'])))})"
                    for v in d.get("vulns", [])[:3]
                )
                for d in vulnerable[:10]
            )
            return SecuritySubResult("pip-audit", False, detail)
        except json.JSONDecodeError:
            if result.success:
                return SecuritySubResult("pip-audit", True, "No vulnerabilities found")
            return SecuritySubResult(
                "pip-audit",
                False,
                result.output[-300:] if result.output else "pip-audit scan failed",
            )
