from __future__ import annotations

import argparse
import json
import sys
from dataclasses import replace
from pathlib import Path
from typing import Iterable

from dsc.config import ConfigError, deep_merge, load_config, load_preset, parse_csv
from dsc.engine.rulepack_loader import LoadedRule, RulepackLoader
from dsc.postprocessors import REGISTRY as POSTPROCESSOR_REGISTRY
from dsc.scanner.engine import DEFAULT_RULEPACK
from dsc.formatters.agent_json import format_agent_json
from dsc.formatters.json_fmt import format_json
from dsc.formatters.junit import format_junit
from dsc.formatters.oscal_ar import format_oscal_ar
from dsc.formatters.sarif import format_sarif
from dsc.formatters.terminal import format_terminal
from dsc.githistory import scan_git_history
from dsc.license_check import evaluate_components, load_policy
from dsc.sbom import (
    OSVClient,
    collect_components,
    format_cyclonedx,
    format_spdx,
    scan_dependencies,
)
from dsc.scanner.engine import ScanEngine
from dsc.scanner.models import Finding, ScanResult, Severity
from dsc.ui.desktop import launch_desktop_ui
from dsc.version import __version__


def _print_table(rows: list[list[str]]) -> str:
    if not rows:
        return ""
    widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
    out_lines: list[str] = []
    for r in rows:
        out_lines.append("  ".join(col.ljust(widths[i]) for i, col in enumerate(r)))
    return "\n".join(out_lines) + "\n"


def _write_output(text: str, output_path: str | None) -> None:
    if output_path:
        Path(output_path).write_text(text, encoding="utf-8")
    else:
        sys.stdout.write(text)


def _resolve_scan_targets(
    path_arg: str | None,
    cfg: dict[str, object],
    cfg_path: Path | None,
) -> list[Path]:
    if path_arg:
        return [Path(path_arg).resolve()]

    scan_cfg = cfg.get("scan")
    paths_cfg = scan_cfg.get("paths") if isinstance(scan_cfg, dict) else None
    if not isinstance(paths_cfg, list):
        return [Path.cwd().resolve()]

    base_dir = cfg_path.parent if cfg_path is not None else Path.cwd().resolve()
    targets: list[Path] = []
    seen: set[str] = set()
    for raw in paths_cfg:
        txt = str(raw).strip()
        if not txt:
            continue
        p = Path(txt)
        resolved = p.resolve() if p.is_absolute() else (base_dir / p).resolve()
        key = str(resolved)
        if key in seen:
            continue
        seen.add(key)
        targets.append(resolved)
    return targets or [Path.cwd().resolve()]


def _parse_severity_overrides(raw: object) -> dict[str, Severity]:
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        raise ConfigError("detectors.severity_override must be a mapping of rule->severity.")

    overrides: dict[str, Severity] = {}
    for key, value in raw.items():
        try:
            overrides[str(key).upper()] = Severity.from_str(str(value))
        except ValueError as exc:
            raise ConfigError(f"Invalid severity override for {key!r}: {value!r}") from exc
    return overrides


def _apply_severity_overrides(
    findings: Iterable[Finding],
    overrides: dict[str, Severity],
) -> list[Finding]:
    if not overrides:
        return list(findings)

    adjusted: list[Finding] = []
    for finding in findings:
        override = overrides.get(finding.rule_id.upper()) or overrides.get(finding.cwe.upper())
        if override is not None and override != finding.severity:
            adjusted.append(replace(finding, severity=override))
        else:
            adjusted.append(finding)
    return adjusted


def _merge_scan_results(results: list[ScanResult], *, preset_name: str | None) -> ScanResult:
    if not results:
        return ScanResult(scanner_version=__version__, preset=preset_name)
    if len(results) == 1:
        return results[0]

    findings: list[Finding] = []
    files_scanned = 0
    scan_duration_ms = 0
    errors: list[str] = []
    detector_timings_ms: dict[str, int] = {}
    active_detector_cwes: set[str] = set()
    for result in results:
        findings.extend(result.findings)
        files_scanned += result.files_scanned
        scan_duration_ms += result.scan_duration_ms
        errors.extend(result.errors)
        active_detector_cwes.update(result.active_detector_cwes)
        for detector_id, elapsed in result.detector_timings_ms.items():
            detector_timings_ms[detector_id] = detector_timings_ms.get(detector_id, 0) + elapsed

    findings.sort(key=lambda f: (-int(f.severity), f.file_path, f.line_start, f.rule_id))
    return ScanResult(
        findings=findings,
        files_scanned=files_scanned,
        scan_duration_ms=scan_duration_ms,
        scanner_version=__version__,
        preset=preset_name,
        errors=errors,
        detector_timings_ms=detector_timings_ms,
        active_detector_cwes=sorted(active_detector_cwes),
    )


def cmd_init(args: argparse.Namespace) -> int:
    out = Path(args.path).resolve() / ".dsc.yml"
    if out.exists() and not args.force:
        sys.stderr.write(f".dsc.yml already exists at {out}\n")
        return 2

    template = """# DSC configuration
version: 1

scan:
  paths:
    - "."
  ignore:
    - "tests/"
    - "migrations/"
  languages:
    - "python"
    - "javascript"
    - "typescript"
    - "go"
    - "java"
    - "ruby"
    - "rust"
    - "json"
    - "yaml"
    - "toml"
    - "ini"
    - "xml"
    - "html"
    - "dockerfile"
    - "dockercompose"
    - "terraform"
    - "requirements"
    - "lockfile"
    - "gomod"
    - "gradle"
    - "runtime"
    - "swift"
    - "objc"
    - "plist"
    - "kotlin"
    - "php"
    - "csharp"
    - "dart"
    - "c"
    - "cpp"

detectors:
  enabled: all
  disabled: []
  severity_override: {}

compliance:
  - "hipaa"
  - "fda"

fail_on: high
"""
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(template, encoding="utf-8")
    sys.stdout.write(f"Wrote {out}\n")
    return 0


def _load_rulepack() -> list[LoadedRule]:
    loader = RulepackLoader(
        DEFAULT_RULEPACK,
        known_post_processors=POSTPROCESSOR_REGISTRY.names(),
        strict=False,
    )
    return list(loader.load().rules)


def cmd_list_rules(_args: argparse.Namespace) -> int:
    rows: list[list[str]] = [["ID", "CWE", "Severity", "Languages", "Tier"]]
    for rule in sorted(_load_rulepack(), key=lambda r: r.id):
        rows.append(
            [
                rule.id,
                rule.cwe,
                rule.severity.name,
                ",".join(rule.languages),
                rule.precision_tier,
            ]
        )
    sys.stdout.write(_print_table(rows))
    return 0


def cmd_explain(args: argparse.Namespace) -> int:
    rules_by_id = {r.id: r for r in _load_rulepack()}
    rule = rules_by_id.get(args.rule_id)
    if rule is None:
        sys.stderr.write(f"Unknown rule id: {args.rule_id}\n")
        return 2

    sys.stdout.write(f"{rule.id} ({rule.cwe})\n")
    sys.stdout.write(f"Default severity: {rule.severity.name}\n")
    sys.stdout.write(f"Precision tier: {rule.precision_tier}\n")
    sys.stdout.write(f"Confidence: {rule.confidence:.2f}\n")
    if rule.languages:
        sys.stdout.write(f"Languages: {', '.join(rule.languages)}\n")
    if rule.realtime_eligible:
        sys.stdout.write("Realtime: eligible (Layer 1)\n")
    if rule.post_processor:
        sys.stdout.write(f"Post-processor: {rule.post_processor}\n")
    sys.stdout.write("\n")
    sys.stdout.write(rule.message.strip() + "\n")
    return 0

    if md.vulnerable_example:
        sys.stdout.write("\nVulnerable example:\n")
        sys.stdout.write(md.vulnerable_example.rstrip() + "\n")
    if md.secure_example:
        sys.stdout.write("\nSecure example:\n")
        sys.stdout.write(md.secure_example.rstrip() + "\n")
    if md.fix_suggestion:
        sys.stdout.write("\nFix:\n")
        sys.stdout.write(md.fix_suggestion.rstrip() + "\n")
    if md.references:
        sys.stdout.write("\nReferences:\n")
        for r in md.references:
            sys.stdout.write(f"- {r}\n")

    return 0


def cmd_scan(args: argparse.Namespace) -> int:
    config_target = Path(args.path).resolve() if args.path else Path.cwd().resolve()

    if not config_target.exists():
        sys.stderr.write(f"Target path does not exist: {config_target}\n")
        return 2

    try:
        cfg, cfg_path = load_config(config_target)
    except ConfigError as exc:
        sys.stderr.write(str(exc) + "\n")
        return 2

    preset_cfg = load_preset(args.preset) if args.preset else None
    if args.preset and preset_cfg:
        cfg = deep_merge(cfg, preset_cfg)

    fail_on = args.fail_on or cfg.get("fail_on") or "high"
    try:
        fail_sev = Severity.from_str(str(fail_on))
    except ValueError as exc:
        sys.stderr.write(f"Invalid --fail-on severity: {fail_on} ({exc})\n")
        return 2

    cfg_detectors = cfg.get("detectors") or {}
    enabled_cfg = cfg_detectors.get("enabled", "all")
    enabled_ids: list[str] | None
    if isinstance(enabled_cfg, str) and enabled_cfg.lower() == "all":
        enabled_ids = None
    elif isinstance(enabled_cfg, list):
        enabled_ids = [str(x) for x in enabled_cfg]
    elif isinstance(enabled_cfg, str):
        enabled_ids = [enabled_cfg]
    else:
        enabled_ids = None

    disabled_ids = [str(x) for x in (cfg_detectors.get("disabled") or [])]
    languages = [str(x) for x in ((cfg.get("scan") or {}).get("languages") or [])]
    ignore_patterns = [str(x) for x in ((cfg.get("scan") or {}).get("ignore") or [])]
    # `--ignore PATTERN` (repeatable) lets the IDE forward .devaignore entries
    # without persisting them to .dsc.yml. CLI users can also pass them
    # explicitly; the standalone CLI does not auto-discover .devaignore.
    cli_ignore = getattr(args, "ignore_patterns", None) or []
    if cli_ignore:
        seen = set(ignore_patterns)
        for raw in cli_ignore:
            pattern = str(raw).strip()
            if pattern and pattern not in seen:
                ignore_patterns.append(pattern)
                seen.add(pattern)
    try:
        severity_overrides = _parse_severity_overrides(cfg_detectors.get("severity_override"))
    except ConfigError as exc:
        sys.stderr.write(str(exc) + "\n")
        return 2

    tags: list[str] = []
    tags.extend([str(x) for x in (cfg_detectors.get("tags") or [])])
    compliance_frameworks = parse_csv(args.compliance)
    # Phase 11: a preset can declare ``compliance_evaluation.enabled: true``
    # so selecting ``--preset hipaa`` automatically runs the HIPAA matrix.
    # Explicit ``--compliance`` always wins over the preset default so a
    # user can override at the CLI without editing the preset.
    if not compliance_frameworks:
        comp_eval_cfg = cfg.get("compliance_evaluation")
        if isinstance(comp_eval_cfg, dict) and comp_eval_cfg.get("enabled"):
            preset_frameworks = comp_eval_cfg.get("frameworks") or []
            if isinstance(preset_frameworks, list):
                compliance_frameworks = [str(f) for f in preset_frameworks]
    # Note: compliance frameworks are NOT added to tags — they are used for
    # enrichment only (adding compliance control references to findings), not
    # for filtering which detectors run.
    if args.preset and not preset_cfg:
        tags.append(args.preset)
    tag_filter = sorted(set(tags)) or None

    scan_targets = _resolve_scan_targets(args.path, cfg, cfg_path)

    engine = ScanEngine()
    results = [
        engine.scan(
            target,
            diff_only=bool(args.diff),
            extra_ignore_patterns=ignore_patterns,
            enabled_ids=enabled_ids,
            disabled_ids=disabled_ids,
            languages=languages,
            tags=tag_filter,
            threads=int(args.threads),
            use_cache=not bool(args.no_cache),
            preset_name=args.preset,
            compliance_frameworks=compliance_frameworks or None,
            scan_profile=args.scan_profile,
            min_confidence=float(args.min_confidence),
            include_suppressed=bool(args.include_suppressed),
            enable_llm_bridge=bool(args.enable_llm_bridge),
        )
        for target in scan_targets
    ]
    result = _merge_scan_results(results, preset_name=args.preset)
    result = replace(
        result,
        findings=_apply_severity_overrides(result.findings, severity_overrides),
    )

    if args.format == "terminal":
        text = format_terminal(result, verbose=bool(args.verbose))
    elif args.format == "json":
        text = format_json(result, json_lines=bool(args.json_lines))
    elif args.format == "sarif":
        text = format_sarif(result)
    elif args.format == "agent-json":
        text = format_agent_json(result)
    elif args.format == "junit":
        text = format_junit(result)
    elif args.format == "oscal-ar":
        text = format_oscal_ar(result, repo_identifier=str(args.path or ""))
    else:  # pragma: no cover
        sys.stderr.write(f"Unknown format: {args.format}\n")
        return 2

    _write_output(text, args.output)

    # Phase 6: when a scan was run with --compliance, append the scope
    # disclaimer footer to stderr so structured stdout (json/sarif/oscal)
    # stays parseable. The footer always goes to stderr regardless of
    # --output so pipeline consumers see it even when stdout is
    # redirected to a file.
    if compliance_frameworks:
        from dsc.compliance.scope_disclaimer import CLI_FOOTER
        sys.stderr.write(CLI_FOOTER + "\n")

    # Non-fatal errors (file-parse warnings, detector failures) are included
    # in result.errors but should NOT force exit code 2.  Fatal config/parse
    # errors are already handled above (early return 2).  Let findings drive
    # the exit code: 1 if any finding meets/exceeds fail_sev, else 0.
    fail = any(f.severity >= fail_sev for f in result.findings)
    return 1 if fail else 0


def cmd_ui(args: argparse.Namespace) -> int:
    return launch_desktop_ui(Path(args.workspace).resolve())


def cmd_sbom(args: argparse.Namespace) -> int:
    target = Path(args.path).resolve()
    if not target.exists():
        sys.stderr.write(f"Target path does not exist: {target}\n")
        return 2

    components = collect_components(target)

    # Optional vulnerability lookup. We do this once and pass the resulting
    # map into every emitter so a multi-format output (--format both) doesn't
    # double-query OSV.
    vuln_map = None
    findings: list[dict] = []
    if getattr(args, "vulnerabilities", False) or args.format == "findings":
        client = OSVClient(offline=getattr(args, "offline", False))
        result = scan_dependencies(
            target,
            osv_client=client,
            check_abandoned=not getattr(args, "no_abandoned", False),
        )
        findings = result["findings"]
        # Re-build the map keyed by (ecosystem, name, version) so emitters
        # can attach vuln entries to each component without re-querying.
        vuln_map = {}
        for block in result["vulnerabilities"]:
            comp_name, _, comp_version = block["component"].rpartition("@")
            key = (block["ecosystem"], comp_name, comp_version)
            # Reconstruct OSVVulnerability instances from the dict shape so
            # the emitters can use the typed accessors.
            from dsc.sbom.osv_client import OSVVulnerability
            vuln_map[key] = [
                OSVVulnerability(
                    id=v["id"],
                    summary=v["summary"],
                    severity=v["severity"],
                    cvss_score=v["cvss_score"],
                    cwes=tuple(v["cwes"]),
                    aliases=tuple(v["aliases"]),
                    cve_ids=tuple(v["cve_ids"]),
                    fixed_versions=tuple(v["fixed_versions"]),
                    modified=v["modified"],
                    published=v["published"],
                )
                for v in block["vulnerabilities"]
            ]

    output_dir = getattr(args, "output_dir", None)
    if output_dir:
        # Evidence-pack mode: write cyclonedx.json + spdx.json to a directory.
        out_dir = Path(output_dir).resolve()
        out_dir.mkdir(parents=True, exist_ok=True)
        (out_dir / "cyclonedx.json").write_text(
            format_cyclonedx(components, target, vulnerabilities=vuln_map),
            encoding="utf-8",
        )
        (out_dir / "spdx.json").write_text(
            format_spdx(components, target, vulnerabilities=vuln_map),
            encoding="utf-8",
        )
        if findings:
            (out_dir / "findings.json").write_text(
                json.dumps(findings, indent=2) + "\n", encoding="utf-8",
            )
        sys.stdout.write(f"Wrote SBOM artifacts to {out_dir}\n")
        return 0

    if args.format == "cyclonedx":
        text = format_cyclonedx(components, target, vulnerabilities=vuln_map)
    elif args.format == "spdx":
        text = format_spdx(components, target, vulnerabilities=vuln_map)
    elif args.format == "findings":
        text = json.dumps(findings, indent=2) + "\n"
    elif args.format == "json":
        text = json.dumps(
            [
                {
                    "name": c.name,
                    "version": c.version,
                    "ecosystem": c.ecosystem,
                    "license": c.license,
                    "manifest": c.manifest,
                    "direct": c.direct,
                    "dev_only": c.dev_only,
                    "scope": c.scope,
                    "purl": c.purl,
                }
                for c in components
            ],
            indent=2,
        ) + "\n"
    else:  # pragma: no cover
        sys.stderr.write(f"Unknown SBOM format: {args.format}\n")
        return 2

    _write_output(text, args.output)
    return 0


def cmd_git_history(args: argparse.Namespace) -> int:
    target = Path(args.path).resolve()
    if not target.exists():
        sys.stderr.write(f"Target path does not exist: {target}\n")
        return 2

    max_commits: int | None = None if args.all_history else int(args.max_commits)

    # --classify-vfc short-circuits the rule-replay path: just classify
    # commit messages and emit the VFC list.
    if getattr(args, "classify_vfc", False):
        from dsc.githistory import classify_history

        try:
            scores = classify_history(
                target,
                max_commits=max_commits,
                all_branches=not args.current_branch_only,
            )
        except RuntimeError as exc:
            sys.stderr.write(f"{exc}\n")
            return 2

        vfcs = [s for s in scores if s.is_vulnerability_fixing]
        if args.format == "json":
            text = json.dumps([s.as_dict() for s in vfcs], indent=2) + "\n"
        else:
            if not vfcs:
                text = "No vulnerability-fixing commits identified.\n"
            else:
                rows: list[list[str]] = [["SCORE", "COMMIT", "CVE/GHSA/CWE", "SIGNALS"]]
                for s in vfcs:
                    refs = ",".join((*s.cves, *s.ghsas, *s.cwes)) or "-"
                    rows.append([
                        f"{s.score:.2f}",
                        s.commit[:10],
                        refs[:40],
                        ",".join(s.signals)[:60],
                    ])
                text = _print_table(rows)
                text += (
                    f"\n{len(vfcs)} vulnerability-fixing commit(s) "
                    f"out of {len(scores)} total scanned.\n"
                )
        _write_output(text, args.output)
        return 0

    try:
        findings = scan_git_history(
            target,
            max_commits=max_commits,
            all_branches=not args.current_branch_only,
        )
    except ValueError as exc:
        sys.stderr.write(f"{exc}\n")
        return 2
    except RuntimeError as exc:
        sys.stderr.write(f"{exc}\n")
        return 2

    if args.format == "json":
        text = json.dumps([f.as_dict() for f in findings], indent=2) + "\n"
    else:
        # terminal
        if not findings:
            text = "No secrets found in git history.\n"
        else:
            rows: list[list[str]] = [["COMMIT", "FILE:LINE", "PROVIDER", "MESSAGE"]]
            for f in findings:
                rows.append([
                    f.commit[:10],
                    f"{f.file}:{f.line}",
                    f.provider or "-",
                    f.message.splitlines()[0][:80],
                ])
            text = _print_table(rows)
            text += f"\n{len(findings)} finding(s) across git history.\n"

    _write_output(text, args.output)
    return 1 if findings else 0


def cmd_license_check(args: argparse.Namespace) -> int:
    target = Path(args.path).resolve()
    if not target.exists():
        sys.stderr.write(f"Target path does not exist: {target}\n")
        return 2

    explicit_policy = Path(args.policy).resolve() if args.policy else None
    try:
        policy = load_policy(target, explicit_policy)
    except ConfigError as exc:
        sys.stderr.write(f"{exc}\n")
        return 2

    components = collect_components(target)
    violations = evaluate_components(components, policy)

    if args.format == "json":
        text = json.dumps(
            {
                "components_checked": len(components),
                "violations": [v.as_dict() for v in violations],
            },
            indent=2,
        ) + "\n"
    else:
        if not violations:
            text = f"OK -- {len(components)} components evaluated, no license policy violations.\n"
        else:
            rows: list[list[str]] = [["SEVERITY", "KIND", "PACKAGE", "LICENSE", "MANIFEST"]]
            for v in violations:
                rows.append([
                    v.severity.upper(),
                    v.kind.value,
                    f"{v.component.ecosystem}:{v.component.name}@{v.component.version}",
                    v.license or "-",
                    v.component.manifest,
                ])
            text = _print_table(rows)
            text += f"\n{len(violations)} violation(s) across {len(components)} component(s).\n"

    _write_output(text, args.output)

    has_error = any(v.severity == "error" for v in violations)
    if has_error:
        return 1
    if violations and policy.unknown == "error":
        return 1
    return 0


def cmd_compliance_evidence(args: argparse.Namespace) -> int:
    """Generate a Phase 6 compliance evidence pack from a scan JSON output."""
    import json as _json
    from dsc.compliance import build_evidence_pack, render_html, render_markdown
    from dsc.scanner.models import ScanResult

    scan_path = Path(args.scan_path)
    if not scan_path.exists():
        sys.stderr.write(f"scan output not found: {scan_path}\n")
        return 2

    payload = _json.loads(scan_path.read_text())
    result = ScanResult.from_dict(payload)
    rules = _load_rulepack()
    pack = build_evidence_pack(
        result.findings,
        rules,
        scan_target=str(scan_path),
        scanner_version=result.scanner_version,
    )
    if args.format == "json":
        text = _json.dumps(pack, indent=2, sort_keys=True) + "\n"
    elif args.format == "html":
        text = render_html(pack)
    else:
        text = render_markdown(pack) + "\n"
    _write_output(text, args.output)
    return 0


def cmd_evidence_build(args: argparse.Namespace) -> int:
    """Build a signed audit-grade evidence package from a scan JSON output."""
    import json as _json

    from dsc.compliance.evidence_builder import build_evidence_package
    from dsc.compliance.evidence_signer import load_or_create_local_keypair
    from dsc.scanner.compliance_evaluator import ComplianceScanEvaluation
    from dsc.scanner.models import ScanResult

    scan_path = Path(args.scan_path)
    if not scan_path.exists():
        sys.stderr.write(f"scan output not found: {scan_path}\n")
        return 2

    payload = _json.loads(scan_path.read_text())
    result = ScanResult.from_dict(payload)

    evaluation = result.compliance_evaluation
    if evaluation is None:
        # Empty evaluation if the scan did not run a compliance pass.
        evaluation = ComplianceScanEvaluation(frameworks={}, overall_score=0.0)

    keys_dir = Path(args.keys_dir).expanduser() if args.keys_dir else None
    keypair = load_or_create_local_keypair(keys_dir=keys_dir)

    output = Path(args.output)
    built = build_evidence_package(
        scan_result=result,
        evaluation=evaluation,
        output_path=output,
        scan_id=args.scan_id,
        repo_identifier=args.repo_identifier,
        keypair=keypair,
    )
    sys.stdout.write(
        f"wrote signed evidence package: {built.tarball_path}\n"
        f"  package uuid: {built.package_uuid}\n"
        f"  signer key:   {keypair.key_id()}\n"
        f"  artifacts:    {len(built.manifest.artifacts)}\n"
    )
    # Phase 6: scope footer on every compliance-output command.
    from dsc.compliance.scope_disclaimer import CLI_FOOTER
    sys.stderr.write(CLI_FOOTER + "\n")
    return 0


def cmd_compliance_gate(args: argparse.Namespace) -> int:
    """Phase 11: CI/CD compliance gate.

    Runs a scan with a single framework's compliance matrix and exits 0 if
    that framework's score is at or above the configured threshold, exit 1
    otherwise. Threshold defaults come from the preset's
    ``compliance_evaluation.threshold`` block when ``--threshold`` is not
    given so a clean ``dsc compliance-gate --preset hipaa`` works in CI.

    Output:
      ``--format sarif``   SARIF 2.1.0 to a file or stdout (consumed by
                           GitHub Code Scanning).
      ``--format junit``   JUnit XML to a file or stdout (consumed by
                           Jenkins / GitLab pipeline test reports).
      ``--format both``    Writes both files; both must have ``--output-dir``.
    """

    target = Path(args.path).resolve()
    if not target.exists():
        sys.stderr.write(f"Target path does not exist: {target}\n")
        return 2

    # Resolve the preset (if any) to merge config and pull the threshold default.
    cfg, cfg_path = load_config(target)
    preset_cfg = load_preset(args.preset) if args.preset else None
    if args.preset and preset_cfg:
        cfg = deep_merge(cfg, preset_cfg)

    framework = (args.framework or "").strip().upper()
    if not framework:
        comp_cfg = cfg.get("compliance_evaluation")
        if isinstance(comp_cfg, dict):
            frameworks = comp_cfg.get("frameworks") or []
            if isinstance(frameworks, list) and frameworks:
                framework = str(frameworks[0]).upper()
    if not framework:
        sys.stderr.write(
            "compliance-gate: --framework is required (no preset framework declared)\n"
        )
        return 2

    threshold = args.threshold
    if threshold is None:
        comp_cfg = cfg.get("compliance_evaluation")
        if isinstance(comp_cfg, dict) and comp_cfg.get("threshold") is not None:
            threshold = int(comp_cfg["threshold"])
    if threshold is None:
        threshold = 80
    threshold = max(0, min(100, int(threshold)))

    # Reuse the scan engine via cmd_scan-equivalent logic. Keep it simple:
    # run a fast scan with the preset's detector filter and the chosen
    # framework as the compliance target.
    cfg_detectors = cfg.get("detectors") or {}
    enabled_cfg = cfg_detectors.get("enabled", "all")
    if isinstance(enabled_cfg, str) and enabled_cfg.lower() == "all":
        enabled_ids = None
    elif isinstance(enabled_cfg, list):
        enabled_ids = [str(x) for x in enabled_cfg]
    else:
        enabled_ids = None
    disabled_ids = [str(x) for x in (cfg_detectors.get("disabled") or [])]
    languages = [str(x) for x in ((cfg.get("scan") or {}).get("languages") or [])]
    ignore_patterns = [str(x) for x in ((cfg.get("scan") or {}).get("ignore") or [])]
    severity_overrides = _parse_severity_overrides(cfg_detectors.get("severity_override"))
    tag_filter_list = sorted({str(x) for x in (cfg_detectors.get("tags") or [])}) or None

    engine = ScanEngine()
    scan_targets = _resolve_scan_targets(args.path, cfg, cfg_path)
    results = [
        engine.scan(
            t,
            extra_ignore_patterns=ignore_patterns,
            enabled_ids=enabled_ids,
            disabled_ids=disabled_ids,
            languages=languages,
            tags=tag_filter_list,
            threads=int(args.threads),
            use_cache=False,
            preset_name=args.preset,
            compliance_frameworks=[framework],
            scan_profile="fast",
        )
        for t in scan_targets
    ]
    result = _merge_scan_results(results, preset_name=args.preset)
    result = replace(
        result,
        findings=_apply_severity_overrides(result.findings, severity_overrides),
    )

    evaluation = result.compliance_evaluation
    framework_result = None
    if evaluation is not None:
        framework_result = (evaluation.frameworks or {}).get(framework)
        if framework_result is None:
            # Fall back to a case-insensitive match (seed framework_id may
            # be lowercase / hyphenated differently).
            for fid, fr in (evaluation.frameworks or {}).items():
                if fid.upper().replace("_", "-") == framework:
                    framework_result = fr
                    framework = fid
                    break

    if framework_result is None:
        sys.stderr.write(
            f"compliance-gate: framework {framework!r} not found in evaluation\n"
        )
        return 2

    score = float(getattr(framework_result, "clean_pct", 0.0) or 0.0)
    pass_gate = score >= threshold

    # SARIF / JUnit output for CI integration.
    fmt = (args.format or "sarif").lower()
    out_dir = Path(args.output_dir).resolve() if args.output_dir else None
    if out_dir is not None:
        out_dir.mkdir(parents=True, exist_ok=True)

    def _write(name: str, body: str) -> None:
        if out_dir is not None:
            (out_dir / name).write_text(body, encoding="utf-8")
        else:
            sys.stdout.write(body)

    if fmt in ("sarif", "both"):
        _write("compliance-gate.sarif.json", format_sarif(result))
    if fmt in ("junit", "both"):
        _write("compliance-gate.junit.xml", _compliance_gate_junit(framework, score, threshold, pass_gate))

    # Phase 6: scope footer.
    from dsc.compliance.scope_disclaimer import CLI_FOOTER
    sys.stderr.write(
        f"compliance-gate: framework={framework} score={score:.1f}% "
        f"threshold={threshold}% result={'PASS' if pass_gate else 'FAIL'}\n"
    )
    sys.stderr.write(CLI_FOOTER + "\n")

    return 0 if pass_gate else 1


def _compliance_gate_junit(framework: str, score: float, threshold: int, passed: bool) -> str:
    """Render a one-test-case JUnit XML for the compliance gate.

    Jenkins, GitLab, CircleCI, and most JUnit consumers render this as a
    single test in a ``Compliance / <framework>`` suite. A failed gate
    becomes a failed test with the score and threshold in the failure
    message so the CI UI shows the gap.
    """
    from xml.sax.saxutils import escape

    name = f"compliance-gate.{framework}"
    if passed:
        return (
            f'<?xml version="1.0" encoding="UTF-8"?>\n'
            f'<testsuites name="deva-compliance" tests="1" failures="0" errors="0">\n'
            f'  <testsuite name="Compliance / {escape(framework)}" '
            f'tests="1" failures="0" errors="0">\n'
            f'    <testcase classname="deva.compliance" name="{escape(name)}" />\n'
            f'  </testsuite>\n'
            f'</testsuites>\n'
        )
    detail = (
        f"Framework {framework} scored {score:.1f}%, below threshold "
        f"{threshold}%. Resolve compliance findings to raise the score."
    )
    return (
        f'<?xml version="1.0" encoding="UTF-8"?>\n'
        f'<testsuites name="deva-compliance" tests="1" failures="1" errors="0">\n'
        f'  <testsuite name="Compliance / {escape(framework)}" '
        f'tests="1" failures="1" errors="0">\n'
        f'    <testcase classname="deva.compliance" name="{escape(name)}">\n'
        f'      <failure type="ComplianceGateFailure" '
        f'message="{escape(detail)}"/>\n'
        f'    </testcase>\n'
        f'  </testsuite>\n'
        f'</testsuites>\n'
    )


def cmd_evidence_verify(args: argparse.Namespace) -> int:
    """Verify a signed evidence package's signature and artifact hashes."""
    from dsc.compliance.evidence_verifier import verify_evidence_package_safe

    package_path = Path(args.package_path)
    ok, message = verify_evidence_package_safe(package_path)
    if ok:
        sys.stdout.write(f"OK: {message}\n")
        return 0
    sys.stderr.write(f"FAIL: {message}\n")
    return 1


def _path_detector_command(reader, args: argparse.Namespace) -> int:
    """Phase 13 helper for detectors that walk a workspace path."""
    target = Path(args.path).resolve()
    if not target.exists():
        sys.stderr.write(f"path does not exist: {target}\n")
        return 2
    findings = reader(target)
    body = json.dumps(
        findings if isinstance(findings, list) else findings,
        indent=2, default=str,
    ) + "\n"
    _write_output(body, args.output)
    from dsc.compliance.scope_disclaimer import CLI_FOOTER
    sys.stderr.write(CLI_FOOTER + "\n")
    return 0


def cmd_erasure_check(args: argparse.Namespace) -> int:
    """Phase 13 #3."""
    from dsc.compliance.framework_detectors import detect_missing_erasure_endpoints
    return _path_detector_command(detect_missing_erasure_endpoints, args)


def cmd_change_management(args: argparse.Namespace) -> int:
    """Phase 13 #4."""
    from dsc.compliance.framework_detectors import audit_change_management
    return _path_detector_command(audit_change_management, args)


def cmd_baa_tracker(args: argparse.Namespace) -> int:
    """Phase 13 #5."""
    from dsc.compliance.framework_detectors import detect_baa_required
    return _path_detector_command(detect_baa_required, args)


def cmd_breach_assessment(args: argparse.Namespace) -> int:
    """Phase 13 #8."""
    from dsc.compliance.framework_detectors import assess_breach_notification
    from dsc.scanner.models import ScanResult

    scan_path = Path(args.scan_path)
    if not scan_path.exists():
        sys.stderr.write(f"scan output not found: {scan_path}\n")
        return 2
    payload = json.loads(scan_path.read_text(encoding="utf-8"))
    result = ScanResult.from_dict(payload)
    findings = [f.to_dict() if hasattr(f, "to_dict") else f for f in result.findings]
    assessment = assess_breach_notification(findings)
    body = json.dumps(assessment, indent=2) + "\n"
    _write_output(body, args.output)
    from dsc.compliance.scope_disclaimer import CLI_FOOTER
    sys.stderr.write(CLI_FOOTER + "\n")
    return 0


def cmd_cross_border(args: argparse.Namespace) -> int:
    """Phase 13 #9."""
    from dsc.compliance.framework_detectors import detect_cross_border_transfers
    return _path_detector_command(detect_cross_border_transfers, args)


def cmd_dpia_triggers(args: argparse.Namespace) -> int:
    """Phase 13 #10."""
    from dsc.compliance.framework_detectors import detect_dpia_triggers
    return _path_detector_command(detect_dpia_triggers, args)


def cmd_logging_verify(args: argparse.Namespace) -> int:
    """Phase 13 #12."""
    from dsc.compliance.framework_detectors import verify_logging
    return _path_detector_command(verify_logging, args)


def cmd_secure_architecture(args: argparse.Namespace) -> int:
    """Phase 13 #13."""
    from dsc.compliance.framework_detectors import detect_secure_architecture_smells
    return _path_detector_command(detect_secure_architecture_smells, args)


def _maturity_command(reader, label: str, args: argparse.Namespace) -> int:
    """Phase 13 helper for the three thin maturity readers."""
    from dsc.scanner.models import ScanResult

    scan_path = Path(args.scan_path)
    if not scan_path.exists():
        sys.stderr.write(f"scan output not found: {scan_path}\n")
        return 2
    payload = json.loads(scan_path.read_text(encoding="utf-8"))
    result = ScanResult.from_dict(payload)
    evaluation = result.compliance_evaluation
    if evaluation is None:
        sys.stderr.write(
            f"scan JSON has no compliance_evaluation; rerun with --compliance {label} first.\n"
        )
        return 2
    out = reader(evaluation)
    body = json.dumps(out.to_dict(), indent=2) + "\n"
    _write_output(body, args.output)

    from dsc.compliance.scope_disclaimer import CLI_FOOTER
    sys.stderr.write(CLI_FOOTER + "\n")
    return 0


def cmd_hitrust_maturity(args: argparse.Namespace) -> int:
    """Phase 13 #6: HITRUST maturity-level evaluation."""
    from dsc.compliance.maturity_evaluators import evaluate_hitrust_maturity
    return _maturity_command(evaluate_hitrust_maturity, "HITRUST", args)


def cmd_cmmc_levels(args: argparse.Namespace) -> int:
    """Phase 13 #7: CMMC level-aware evaluation."""
    from dsc.compliance.maturity_evaluators import evaluate_cmmc_levels
    return _maturity_command(evaluate_cmmc_levels, "CMMC", args)


def cmd_csf_profile(args: argparse.Namespace) -> int:
    """Phase 13 #11: NIST CSF 2.0 current-vs-target profile."""
    from dsc.compliance.maturity_evaluators import generate_csf_profile
    return _maturity_command(generate_csf_profile, "NIST-CSF", args)


def cmd_sprs(args: argparse.Namespace) -> int:
    """Phase 13: compute the CMMC / NIST 800-171 SPRS score.

    DFARS 252.204-7019 self-assessment scoring: 110 points minus the per-
    practice subtraction value for every NOT IMPLEMENTED practice. Reads
    the per-practice subtraction values from the CMMC seed shipped in
    Phase 8.
    """
    from dsc.compliance.sprs import calculate, render_terminal
    from dsc.scanner.models import ScanResult

    scan_path = Path(args.scan_path)
    if not scan_path.exists():
        sys.stderr.write(f"scan output not found: {scan_path}\n")
        return 2
    payload = json.loads(scan_path.read_text(encoding="utf-8"))
    result = ScanResult.from_dict(payload)
    evaluation = result.compliance_evaluation
    if evaluation is None:
        sys.stderr.write(
            "scan JSON has no compliance_evaluation; rerun the scan with "
            "--compliance CMMC first.\n"
        )
        return 2

    sprs = calculate(evaluation)

    if args.format == "json":
        body = json.dumps(sprs.to_dict(), indent=2) + "\n"
    else:
        body = render_terminal(sprs)

    _write_output(body, args.output)

    # Phase 6: scope footer.
    from dsc.compliance.scope_disclaimer import CLI_FOOTER
    sys.stderr.write(CLI_FOOTER + "\n")

    # Exit 1 when the score falls below --min-score (CI gate).
    if args.min_score is not None and sprs.score < float(args.min_score):
        sys.stderr.write(
            f"sprs: score {sprs.score:.1f} below --min-score {args.min_score}\n"
        )
        return 1
    return 0


def cmd_poam(args: argparse.Namespace) -> int:
    """Phase 13: emit an OSCAL Plan of Action and Milestones from a scan JSON.

    Pairs with ``dsc evidence-build``: where evidence-build produces a signed
    AR + report tarball, ``poam`` produces the companion document that lists
    every failing control and a remediation milestone with a deadline. Both
    are required for a complete federal authorization package.
    """
    from dsc.compliance.poam_emitter import (
        DEFAULT_DEADLINES_DAYS,
        summarize_poam,
        to_oscal_poam,
    )
    from dsc.scanner.compliance_evaluator import ComplianceScanEvaluation
    from dsc.scanner.models import ScanResult

    scan_path = Path(args.scan_path)
    if not scan_path.exists():
        sys.stderr.write(f"scan output not found: {scan_path}\n")
        return 2

    payload = json.loads(scan_path.read_text(encoding="utf-8"))
    result = ScanResult.from_dict(payload)
    evaluation = result.compliance_evaluation
    if evaluation is None:
        sys.stderr.write(
            "scan JSON has no compliance_evaluation; rerun the scan with "
            "--compliance <framework> first.\n"
        )
        return 2

    deadlines = dict(DEFAULT_DEADLINES_DAYS)
    if args.deadline_critical is not None:
        deadlines["critical"] = int(args.deadline_critical)
    if args.deadline_high is not None:
        deadlines["high"] = int(args.deadline_high)
    if args.deadline_medium is not None:
        deadlines["medium"] = int(args.deadline_medium)
    if args.deadline_low is not None:
        deadlines["low"] = int(args.deadline_low)

    frameworks: list[str] | None = parse_csv(args.framework) or None

    doc = to_oscal_poam(
        evaluation=evaluation,
        findings=list(result.findings),
        scan_id=args.scan_id,
        repo_identifier=args.repo_identifier,
        frameworks=frameworks,
        import_ssp_href=args.import_ssp,
        deadlines=deadlines,
    )

    output = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
    _write_output(output, args.output)

    summary = summarize_poam(doc)
    sys.stderr.write(
        f"poam: {summary['total_items']} item(s) emitted "
        f"({', '.join(f'{k}={v}' for k, v in summary['by_severity'].items())})\n"
    )

    # Phase 6: scope footer.
    from dsc.compliance.scope_disclaimer import CLI_FOOTER
    sys.stderr.write(CLI_FOOTER + "\n")

    return 0


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="dsc", description="DSC local code security scanner")
    p.add_argument("--version", action="version", version=f"dsc {__version__}")

    sub = p.add_subparsers(dest="command", required=True)

    scan = sub.add_parser("scan", help="Scan a path")
    scan.add_argument("path", nargs="?", default=None)
    scan.add_argument("--diff", action="store_true", help="Scan only changed files")
    scan.add_argument(
        "--format",
        default="terminal",
        choices=["terminal", "json", "sarif", "junit", "agent-json", "oscal-ar"],
        help="Output format",
    )
    scan.add_argument("--output", help="Write output to a file")
    scan.add_argument("--compliance", help="Comma-separated compliance modes (e.g. hipaa,fda)")
    scan.add_argument("--preset", help="Preset/tag filter (e.g. openclaw)")
    scan.add_argument("--fail-on", dest="fail_on", help="Exit 1 if any finding is at/above this severity")
    scan.add_argument("--threads", type=int, default=4)
    scan.add_argument("--no-cache", action="store_true")
    scan.add_argument("--scan-profile", dest="scan_profile", default="balanced", choices=["precision", "balanced", "coverage", "deep"], help="Scan quality profile (default: balanced)")
    scan.add_argument("--min-confidence", dest="min_confidence", type=float, default=0.0, help="Minimum confidence threshold (0.0-1.0)")
    scan.add_argument("--include-suppressed", dest="include_suppressed", action="store_true", help="Include suppressed findings in output")
    scan.add_argument("--enable-llm-bridge", dest="enable_llm_bridge", action="store_true", help="Enable the Security-LLM bridge detector for full scans")
    scan.add_argument(
        "--ignore",
        dest="ignore_patterns",
        action="append",
        default=None,
        metavar="PATTERN",
        help="Additional path pattern to exclude. May be repeated. Merged with `scan.ignore` from .dsc.yml.",
    )
    scan.add_argument("--verbose", action="store_true")
    scan.add_argument("--json-lines", action="store_true", help="JSON Lines output (format=json)")
    scan.set_defaults(func=cmd_scan)

    init = sub.add_parser("init", help="Create a .dsc.yml config file")
    init.add_argument("--path", default=".")
    init.add_argument("--force", action="store_true")
    init.set_defaults(func=cmd_init)

    lr = sub.add_parser("list-rules", help="List all available rules")
    lr.set_defaults(func=cmd_list_rules)

    ex = sub.add_parser("explain", help="Explain a rule")
    ex.add_argument("rule_id")
    ex.set_defaults(func=cmd_explain)

    ui = sub.add_parser("ui", help="Launch the desktop application")
    ui.add_argument(
        "--workspace",
        default=".",
        help="Workspace path used for command execution (default: current directory).",
    )
    ui.set_defaults(func=cmd_ui)

    sbom = sub.add_parser(
        "sbom",
        help="Generate a Software Bill of Materials (CycloneDX) for a workspace",
    )
    sbom.add_argument("path", nargs="?", default=".")
    sbom.add_argument(
        "--format",
        default="cyclonedx",
        choices=["cyclonedx", "spdx", "json", "findings"],
        help=(
            "SBOM output format. 'cyclonedx' / 'spdx' produce SBOM JSON; "
            "'json' produces the raw component list; 'findings' produces "
            "the dependency-vulnerability finding list (implies --vulnerabilities). "
            "Default: cyclonedx."
        ),
    )
    sbom.add_argument("--output", help="Write SBOM output to a single file")
    sbom.add_argument(
        "--output-dir",
        help=(
            "Write evidence-pack-shaped output to this directory: "
            "cyclonedx.json + spdx.json (+ findings.json when --vulnerabilities is set)."
        ),
    )
    sbom.add_argument(
        "--vulnerabilities",
        action="store_true",
        help=(
            "Query OSV.dev for known CVEs/GHSAs affecting each component "
            "and include them in the SBOM (CycloneDX vulnerabilities[] / "
            "SPDX SECURITY external refs)."
        ),
    )
    sbom.add_argument(
        "--offline",
        action="store_true",
        help="Skip network calls; serve from disk cache only.",
    )
    sbom.add_argument(
        "--no-abandoned",
        action="store_true",
        help="Disable abandoned-package detection (skip registry lookups).",
    )
    sbom.set_defaults(func=cmd_sbom)

    gh = sub.add_parser(
        "git-history",
        help="Scan git history for hardcoded credentials in previously-committed code",
    )
    gh.add_argument("path", nargs="?", default=".")
    gh.add_argument(
        "--max-commits",
        dest="max_commits",
        type=int,
        default=1000,
        help="Limit the number of commits scanned (default: 1000)",
    )
    gh.add_argument(
        "--all-history",
        dest="all_history",
        action="store_true",
        help="Scan every commit (overrides --max-commits). Slow on large repos.",
    )
    gh.add_argument(
        "--current-branch-only",
        dest="current_branch_only",
        action="store_true",
        help="Only scan the current branch's commits (default: --all).",
    )
    gh.add_argument(
        "--classify-vfc",
        dest="classify_vfc",
        action="store_true",
        help=(
            "Skip rule replay; instead classify each commit message "
            "as vulnerability-fixing or not (CVE / GHSA / CWE / "
            "security vocabulary heuristics). Useful for triaging "
            "OSS history in vulnerability database curation."
        ),
    )
    gh.add_argument(
        "--format",
        default="terminal",
        choices=["terminal", "json"],
        help="Output format",
    )
    gh.add_argument("--output", help="Write output to a file")
    gh.set_defaults(func=cmd_git_history)

    lic = sub.add_parser(
        "license-check",
        help="Check dependency licenses against an allow/deny policy",
    )
    lic.add_argument("path", nargs="?", default=".")
    lic.add_argument(
        "--policy",
        help="Path to a license policy file (defaults to .dsc.license-policy.yml in the workspace).",
    )
    lic.add_argument(
        "--format",
        default="terminal",
        choices=["terminal", "json"],
        help="Output format",
    )
    lic.add_argument("--output", help="Write output to a file")
    lic.set_defaults(func=cmd_license_check)

    ev = sub.add_parser(
        "compliance-evidence",
        help="Generate a per-framework compliance evidence pack from a scan output",
    )
    ev.add_argument(
        "scan_path",
        help="Path to a JSON scan output (from `dsc scan ... --format json`).",
    )
    ev.add_argument(
        "--format",
        default="markdown",
        choices=["json", "markdown", "html"],
        help="Evidence-pack output format (default: markdown).",
    )
    ev.add_argument("--output", help="Write the evidence pack to a file.")
    ev.set_defaults(func=cmd_compliance_evidence)

    eb = sub.add_parser(
        "evidence-build",
        help="Build a signed audit-grade evidence package (.tar.gz) from a scan output",
    )
    eb.add_argument(
        "scan_path",
        help="Path to a JSON scan output (from `dsc scan ... --format json`).",
    )
    eb.add_argument(
        "--output",
        required=True,
        help="Destination .tar.gz path for the signed evidence package.",
    )
    eb.add_argument(
        "--scan-id",
        dest="scan_id",
        default=None,
        help="Stable scan identifier; deterministic UUIDs in OSCAL output.",
    )
    eb.add_argument(
        "--repo",
        dest="repo_identifier",
        default=None,
        help="Repo identifier (e.g. github.com/org/repo) embedded in the manifest.",
    )
    eb.add_argument(
        "--keys-dir",
        dest="keys_dir",
        default=None,
        help="Override the directory containing the Ed25519 keypair "
            "(default: ~/.devseccode/evidence-keys/).",
    )
    eb.set_defaults(func=cmd_evidence_build)

    evf = sub.add_parser(
        "evidence-verify",
        help="Verify a signed evidence package's signature and artifact hashes",
    )
    evf.add_argument(
        "package_path",
        help="Path to a signed evidence-package .tar.gz.",
    )
    evf.set_defaults(func=cmd_evidence_verify)

    cg = sub.add_parser(
        "compliance-gate",
        help=(
            "CI/CD compliance gate: scan, evaluate one framework, "
            "exit 0 if score >= threshold else exit 1"
        ),
    )
    cg.add_argument("path", nargs="?", default=".", help="Workspace path to scan")
    cg.add_argument(
        "--framework",
        help=(
            "Framework id to evaluate against (e.g. HIPAA, PCI-DSS, "
            "SOC2, NIST-800-53). Defaults to the preset's "
            "compliance_evaluation.frameworks[0]."
        ),
    )
    cg.add_argument(
        "--threshold",
        type=int,
        default=None,
        help=(
            "Minimum framework score (0-100) to pass the gate. "
            "Defaults to the preset's compliance_evaluation.threshold "
            "or 80 if neither is set."
        ),
    )
    cg.add_argument(
        "--preset",
        help=(
            "Preset name (e.g. hipaa, pci-dss). Drives detector filter, "
            "severity overrides, and threshold default."
        ),
    )
    cg.add_argument(
        "--format",
        choices=["sarif", "junit", "both"],
        default="sarif",
        help=(
            "Output format. 'sarif' for GitHub Code Scanning, 'junit' "
            "for CI test report integration. 'both' requires --output-dir."
        ),
    )
    cg.add_argument(
        "--output-dir",
        help="Write report files to this directory instead of stdout.",
    )
    cg.add_argument(
        "--threads",
        type=int,
        default=4,
        help="Detector worker threads (default: 4)",
    )
    cg.set_defaults(func=cmd_compliance_gate)

    poam = sub.add_parser(
        "poam",
        help=(
            "Emit an OSCAL Plan of Action and Milestones from a scan JSON. "
            "Pairs with `dsc evidence-build` to produce a complete federal "
            "authorization package (FedRAMP, RMF, CMMC)."
        ),
    )
    poam.add_argument(
        "scan_path",
        help="Path to a scan JSON file (output of `dsc scan --format json`).",
    )
    poam.add_argument(
        "--output",
        help="Write the POA&M JSON to this file (default: stdout).",
    )
    poam.add_argument(
        "--framework",
        help=(
            "Comma-separated framework ids to include. Defaults to every "
            "framework in the scan's compliance evaluation."
        ),
    )
    poam.add_argument(
        "--scan-id",
        dest="scan_id",
        help="Stable scan identifier; passed through to the OSCAL emitter so UUIDs are deterministic.",
    )
    poam.add_argument(
        "--repo-identifier",
        dest="repo_identifier",
        help="Repo identifier embedded in the POA&M document title.",
    )
    poam.add_argument(
        "--import-ssp",
        dest="import_ssp",
        help="URI reference to the System Security Plan (defaults to a synthetic URN).",
    )
    poam.add_argument("--deadline-critical", type=int, help="Days to remediate critical-severity items (default: 30).")
    poam.add_argument("--deadline-high", type=int, help="Days to remediate high-severity items (default: 60).")
    poam.add_argument("--deadline-medium", type=int, help="Days to remediate medium-severity items (default: 90).")
    poam.add_argument("--deadline-low", type=int, help="Days to remediate low-severity items (default: 180).")
    poam.set_defaults(func=cmd_poam)

    sprs = sub.add_parser(
        "sprs",
        help=(
            "Calculate the DFARS SPRS (Supplier Performance Risk System) "
            "score from a CMMC compliance evaluation. Use for NIST 800-171 "
            "self-assessment submissions."
        ),
    )
    sprs.add_argument(
        "scan_path",
        help="Path to a scan JSON with a CMMC compliance evaluation.",
    )
    sprs.add_argument(
        "--output",
        help="Write the SPRS report to this file (default: stdout).",
    )
    sprs.add_argument(
        "--format", choices=["terminal", "json"], default="terminal",
        help="Output format (default: terminal).",
    )
    sprs.add_argument(
        "--min-score", type=float, default=None,
        help="If set, exit 1 when the computed score is below this threshold (CI gate).",
    )
    sprs.set_defaults(func=cmd_sprs)

    for cmd_name, helper, framework_label in (
        ("hitrust-maturity", cmd_hitrust_maturity, "HITRUST"),
        ("cmmc-levels", cmd_cmmc_levels, "CMMC"),
        ("csf-profile", cmd_csf_profile, "NIST-CSF"),
    ):
        sp = sub.add_parser(
            cmd_name,
            help=f"Phase 13 maturity reader for the {framework_label} framework.",
        )
        sp.add_argument(
            "scan_path",
            help=f"Path to a scan JSON with a {framework_label} compliance evaluation.",
        )
        sp.add_argument(
            "--output",
            help="Write the JSON report to this file (default: stdout).",
        )
        sp.set_defaults(func=helper)

    # Phase 13 framework-detector subcommands. All of them walk a workspace
    # path and emit a finding-shaped JSON list (or a structured assessment).
    for cmd_name, helper, blurb in (
        ("erasure-check", cmd_erasure_check,
         "Phase 13 #3: detect REST resources without a DELETE endpoint (GDPR Art.17)."),
        ("change-management", cmd_change_management,
         "Phase 13 #4: audit branch protection + recent commit review (SOC 2 / PCI / ISO)."),
        ("baa-tracker", cmd_baa_tracker,
         "Phase 13 #5: flag external vendor calls in PHI-handling code (HIPAA)."),
        ("cross-border", cmd_cross_border,
         "Phase 13 #9: flag non-EU vendor calls in PII-handling code (GDPR Art.46)."),
        ("dpia-triggers", cmd_dpia_triggers,
         "Phase 13 #10: surface GDPR Art.35 trigger keywords (profiling, biometrics, etc.)."),
        ("logging-verify", cmd_logging_verify,
         "Phase 13 #12: confirm a logging framework is imported (ISO A.8.15)."),
        ("secure-architecture", cmd_secure_architecture,
         "Phase 13 #13: detect ISO A.8.27 anti-patterns (debug, wildcard CORS, root, exposed admin)."),
    ):
        sp = sub.add_parser(cmd_name, help=blurb)
        sp.add_argument("path", nargs="?", default=".",
            help="Workspace path to walk (default: current directory).")
        sp.add_argument("--output", help="Write the JSON report to this file (default: stdout).")
        sp.set_defaults(func=helper)

    bn = sub.add_parser(
        "breach-assessment",
        help="Phase 13 #8: derive a HIPAA breach-notification posture from a scan JSON.",
    )
    bn.add_argument("scan_path", help="Path to a scan JSON output.")
    bn.add_argument("--output", help="Write the assessment to this file (default: stdout).")
    bn.set_defaults(func=cmd_breach_assessment)

    return p


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return int(args.func(args))


if __name__ == "__main__":
    raise SystemExit(main())
