"""Trusted, offline source scanner. Never executes repository code."""

import argparse
from collections import Counter
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import subprocess
import uuid

import yaml


def run_tool(args: list[str], output: Path, timeout: int = 600) -> None:
    env = {
        "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
        "HOME": "/tmp",
        "TMPDIR": os.environ.get("TMPDIR", "/tmp"),
        "TRIVY_CACHE_DIR": os.environ.get("TRIVY_CACHE_DIR", "/tmp/trivy-cache"),
        "SEMGREP_SEND_METRICS": "off",
        "SEMGREP_ENABLE_VERSION_CHECK": "0",
    }
    with output.open("wb") as stream:
        result = subprocess.run(
            args, stdout=stream, stderr=subprocess.DEVNULL, env=env,
            timeout=timeout, check=False, cwd="/tmp",
        )
    if result.returncode:
        raise RuntimeError(f"{Path(args[0]).name} failed (exit {result.returncode})")


def crypto_bom(results: dict, rules: dict, source: Path, revision: str) -> dict:
    known = {rule["id"]: rule["metadata"] for rule in rules["rules"]}
    assets: dict[str, dict] = {}
    for match in results.get("results", []):
        rule_id = match["check_id"].split(".")[-1]
        if rule_id not in known:
            raise ValueError("Unknown crypto rule in scanner output")
        metadata = known[rule_id]
        algorithm = metadata["algorithm"]
        path = Path(match["path"])
        if not path.is_absolute():
            path = source / path
        relative = path.resolve().relative_to(source.resolve()).as_posix()
        asset = assets.setdefault(algorithm, {
            "type": "cryptographic-asset",
            "bom-ref": f"crypto:{algorithm}",
            "name": algorithm,
            "cryptoProperties": {
                "assetType": "algorithm",
                "algorithmProperties": {"primitive": metadata["primitive"]},
            },
            "evidence": {"occurrences": []},
            "properties": [
                {"name": "giokclaw:coverage", "value": "static-api-candidate"},
            ],
        })
        occurrence = {"location": relative, "line": int(match["start"]["line"])}
        if occurrence not in asset["evidence"]["occurrences"]:
            asset["evidence"]["occurrences"].append(occurrence)
        if "review" in metadata:
            prop = {"name": "giokclaw:review", "value": metadata["review"]}
            if prop not in asset["properties"]:
                asset["properties"].append(prop)
    return {
        "bomFormat": "CycloneDX", "specVersion": "1.6",
        "serialNumber": f"urn:uuid:{uuid.uuid4()}", "version": 1,
        "metadata": {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "properties": [
                {"name": "giokclaw:revision", "value": revision},
                {"name": "giokclaw:coverage", "value":
                 "Partial static API inventory for Go, Python and JS/TS. "
                 "No runtime tracing, key/certificate extraction, dependency crypto "
                 "inventory, alias completeness, reachability or compliance claim."},
            ],
        },
        "components": sorted(assets.values(), key=lambda asset: asset["name"]),
    }


def scan(source: Path, output: Path, revision: str) -> dict:
    rules_path = Path(__file__).with_name("crypto-rules.yaml")
    output.mkdir(parents=True, exist_ok=True)
    run_tool(["trivy", "--version"], output / "trivy-version.txt", 30)
    run_tool(["semgrep", "--version"], output / "semgrep-version.txt", 30)
    common = ["trivy", "fs", "--quiet", "--timeout", "10m", "--offline-scan",
              "--skip-dirs", str(source / ".git")]
    run_tool(
        [*common, "--format", "cyclonedx", "--output", str(output / "sbom.cdx.json"),
         str(source)], output / "trivy-sbom.log",
    )
    run_tool(
        [*common, "--scanners", "vuln", "--format", "json",
         "--output", str(output / "vulnerabilities.json"), str(source)],
        output / "trivy-vuln.log",
    )
    raw = output / "semgrep-raw.json"
    try:
        run_tool(
            ["semgrep", "scan", "--oss-only", "--config", str(rules_path),
             "--json", "--metrics", "off", "--disable-version-check",
             "--no-git-ignore", "--project-root", str(source),
             "--exclude", ".git", "--exclude", "node_modules",
             "--exclude", "vendor", str(source)], raw,
        )
        results = json.loads(raw.read_text())
        if results.get("errors"):
            raise RuntimeError("Crypto scan incomplete: parser or scanner errors")
        cbom = crypto_bom(results, yaml.safe_load(rules_path.read_text()), source, revision)
    finally:
        # Raw scanner output can contain matched source or secrets; never publish it.
        raw.unlink(missing_ok=True)
    sbom = json.loads((output / "sbom.cdx.json").read_text())
    if sbom.get("bomFormat") != "CycloneDX" or not isinstance(sbom.get("components", []), list):
        raise RuntimeError("Trivy returned an invalid SBOM")
    vulnerabilities = json.loads((output / "vulnerabilities.json").read_text())
    counts = Counter(
        item.get("Severity", "UNKNOWN")
        for result in vulnerabilities.get("Results", [])
        for item in result.get("Vulnerabilities", [])
    )
    (output / "cbom.cdx.json").write_text(json.dumps(cbom, indent=2) + "\n")
    summary = {
        "revision": revision, "software_components": len(sbom.get("components", [])),
        "crypto_algorithms": [asset["name"] for asset in cbom["components"]],
        "crypto_files_scanned": len(results.get("paths", {}).get("scanned", [])),
        "vulnerability_counts": dict(counts),
        "cbom_coverage": cbom["metadata"]["properties"][1]["value"],
        "trivy_version": (output / "trivy-version.txt").read_text().strip(),
        "semgrep_version": (output / "semgrep-version.txt").read_text().strip(),
        "scope": "Repository source only; no builds, installs, tests or image scan.",
        "crypto_exclusions": "Semgrep ignore rules/defaults plus .git, node_modules and vendor; oversized files may be skipped.",
    }
    (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
    return summary


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--source", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--revision", required=True)
    args = parser.parse_args()
    scan(args.source.resolve(), args.output.resolve(), args.revision)
