"""Compliance evaluation API endpoints."""

from __future__ import annotations

import base64
import json

import structlog
from fastapi import APIRouter, HTTPException, Query, Request

from fastapi.responses import HTMLResponse

from dsc_api.models.compliance_models import (
    Attestation,
    AttestationActivity,
    AttestRequest,
    BaselineRequest,
    BulkAttestRequest,
    BulkAttestResult,
    BulkRevokeRequest,
    BulkRevokeResult,
    ComplianceBaseline,
    ComplianceFramework,
    ComplianceReport,
    DriftReport,
    EvaluateRequest,
    EvidencePackage,
    EvidenceRequest,
)
from dsc_api.services import compliance_engine, custom_frameworks
from dsc_api.services.custom_frameworks import FrameworkValidationError
from dsc_api.services.scanner import get_scanner_service

router = APIRouter()
log = structlog.get_logger()


# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------

def _extract_user(request: Request) -> tuple[str, str]:
    """Extract (user_id, user_email) from the Authorization Bearer JWT.

    Trust boundary: the desktop backend listens on 127.0.0.1 and trusts
    its caller on that loopback. JWT *signatures* are never verified
    here; the backend reads ``sub`` and ``email`` from the token
    payload and uses them as identity claims for attestations and
    audit records. End-to-end token verification only happens at the
    Cloud Run LLM proxy, which signs/verifies tokens against the
    Firebase JWKS before relaying LLM traffic.

    Implication: any local process that can hit ``localhost:8000``
    (a co-resident attacker on the same workstation, a malicious VS
    Code extension, a compromised dev script) can forge an arbitrary
    Bearer token and submit attestations under any user_id. This is
    acceptable for a single-user desktop IDE because the attestation
    record itself is auditable and the scan results never leave the
    machine, but it must not be relied on as an identity boundary if
    the backend is ever exposed to a network interface or a multi-
    tenant context. If that requirement changes, swap this helper
    for a real JWKS verification path before binding to anything
    other than 127.0.0.1.

    Returns ("", "") if no token is present.
    """
    auth = request.headers.get("authorization", "")
    if not auth.startswith("Bearer "):
        return ("", "")
    token = auth[7:]
    try:
        # JWT has 3 parts separated by '.'; payload is the second
        parts = token.split(".")
        if len(parts) < 2:
            return ("", "")
        # Add padding for base64
        payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
        payload = json.loads(base64.urlsafe_b64decode(payload_b64))
        return (payload.get("sub", payload.get("user_id", "")), payload.get("email", ""))
    except Exception:
        return ("", "")


def _resolve_scan_context(scan_id: str | None, repo_identifier: str, user_id: str):
    """Resolve the completed scan context used for compliance actions."""
    context = get_scanner_service().get_compliance_context(
        scan_id=scan_id,
        repo_identifier=repo_identifier,
        user_id=user_id,
    )
    if context is None:
        raise HTTPException(status_code=409, detail="no_scan_results")
    return context


def _attestations_snapshot_bytes(
    user_id: str,
    repo_identifier: str,
    frameworks: list[str],
) -> bytes:
    """Return a JSON snapshot of active attestations for the given frameworks.

    Reads from the same ``compliance_engine.get_attestations`` source the IDE
    posture editor calls via ``GET /api/compliance/attestations``, so the
    signed evidence package and the UI cannot disagree on attestation state.
    """
    snapshot: dict[str, list[dict]] = {}
    for fw in frameworks:
        atts = compliance_engine.get_attestations(user_id, repo_identifier, fw)
        snapshot[fw.upper()] = [a.model_dump(by_alias=True) for a in atts]
    payload = {
        "schema": "deva.attestations.v1",
        "userId": user_id,
        "repoIdentifier": repo_identifier,
        "frameworks": snapshot,
    }
    return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")


# ---------------------------------------------------------------------------
# Read-only endpoints (no auth required)
# ---------------------------------------------------------------------------

@router.get("/scope")
async def get_scope_disclaimer(framework: str | None = Query(default=None)) -> dict:
    """Return the canonical scope disclaimer envelope.

    Phase 6 of the compliance evolution plan. Surfaces the same
    code-level-controls-only disclaimer that appears in OSCAL AR
    metadata, the IDE banner, evidence packages, and the CLI footer.

    Without ``framework``, returns the disclaimer for every framework
    that has a curated warning. With ``framework=<id>``, returns just
    that framework's warning (or empty ``perFramework`` if the id is
    unknown).
    """
    from dsc.compliance.scope_disclaimer import (
        all_warnings,
        api_scope_envelope,
        for_framework,
    )

    if framework:
        warning = for_framework(framework)
        if warning is None:
            return api_scope_envelope([])
        return api_scope_envelope([framework])

    # All frameworks: list every curated warning's framework_id.
    all_ids = [w.framework_id for w in all_warnings()]
    return api_scope_envelope(all_ids)


@router.get("/frameworks")
async def list_frameworks() -> list[ComplianceFramework]:
    """Return all available compliance frameworks."""
    try:
        return compliance_engine.get_frameworks()
    except Exception as exc:
        log.error("Failed to list frameworks", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.get("/controls/{framework_id}")
async def list_controls(framework_id: str) -> list[dict]:
    """Return all controls for a framework."""
    try:
        controls = compliance_engine.get_controls(framework_id)
        if not controls:
            raise HTTPException(
                status_code=404,
                detail=f"No controls found for framework '{framework_id}'",
            )
        return controls
    except HTTPException:
        raise
    except Exception as exc:
        log.error("Failed to list controls", framework=framework_id, error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------

@router.post("/evaluate")
async def evaluate_compliance(body: EvaluateRequest, request: Request) -> ComplianceReport:
    """Evaluate compliance for selected frameworks against findings."""
    user_id, _ = _extract_user(request)
    try:
        context = _resolve_scan_context(body.scan_id, body.repo_identifier, user_id)
        report = compliance_engine.evaluate(
            frameworks=body.frameworks,
            findings=context.findings,
            active_detector_cwes=context.active_detector_cwes,
            repo_identifier=context.repo_identifier,
            user_id=user_id or context.user_id,
        )
        return report
    except HTTPException:
        raise
    except Exception as exc:
        log.error("Compliance evaluation failed", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.post("/export/oscal")
async def export_oscal_ar(body: EvaluateRequest, request: Request) -> dict:
    """Export the compliance evaluation as an OSCAL Assessment Results document.

    Produces a NIST OSCAL v1.2.2 AR document, the format required by FedRAMP
    automation (RFC-0024, mandatory September 2026) and consumed by Trestle,
    Lula, RegScale, OPAL, and Heimdall.

    Reuses the same request shape as ``/evaluate`` (scan_id + frameworks +
    optional repo_identifier). The OSCAL UUIDs are derived deterministically
    from ``scan_id``, so calling this endpoint twice with the same scan
    produces identical output.
    """
    # Imports kept lazy so the scanner package is only loaded when the
    # endpoint is actually called (it pulls tree-sitter dependencies).
    from dsc.compliance.oscal_emitter import to_oscal_ar
    from dsc.scanner.compliance_evaluator import (
        ComplianceScanEvaluation,
        evaluate as scanner_evaluate,
    )
    from dsc.scanner.models import Finding

    user_id, _ = _extract_user(request)
    try:
        context = _resolve_scan_context(body.scan_id, body.repo_identifier, user_id)

        # ScanComplianceContext carries findings as dicts; rehydrate so the
        # scanner-side evaluator and OSCAL emitter receive proper objects.
        finding_objs: list[Finding] = []
        for raw in context.findings:
            try:
                finding_objs.append(Finding.from_dict(raw))
            except Exception:
                log.warning("Skipping malformed finding for OSCAL export")

        evaluation: ComplianceScanEvaluation = scanner_evaluate(
            findings=finding_objs,
            frameworks=body.frameworks,
            active_detector_cwes=set(context.active_detector_cwes),
        )
        document = to_oscal_ar(
            evaluation=evaluation,
            findings=finding_objs,
            scan_id=body.scan_id,
            repo_identifier=context.repo_identifier or body.repo_identifier,
        )
        # Phase 6: include the API-shape scope envelope alongside the
        # OSCAL document so a consumer doesn't need a second round-trip
        # to /scope. The OSCAL doc itself already carries the scope prop
        # under metadata.props -- this is the convenience copy.
        from dsc.compliance.scope_disclaimer import api_scope_envelope
        document["devaScope"] = api_scope_envelope(list(body.frameworks))
        return document
    except HTTPException:
        raise
    except Exception as exc:
        log.error("OSCAL export failed", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


# ---------------------------------------------------------------------------
# Baselines
# ---------------------------------------------------------------------------

@router.post("/baseline")
async def save_baseline(body: BaselineRequest, request: Request) -> ComplianceBaseline:
    """Save current compliance state as a baseline."""
    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    try:
        context = _resolve_scan_context(body.scan_id, body.repo_identifier, user_id)
        return compliance_engine.save_baseline(
            user_id=user_id,
            framework=body.framework,
            repo_identifier=context.repo_identifier,
            findings=context.findings,
            active_detector_cwes=context.active_detector_cwes,
        )
    except HTTPException:
        raise
    except Exception as exc:
        log.error("Failed to save baseline", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.get("/baseline")
async def get_baseline(framework: str, repo: str, request: Request) -> ComplianceBaseline | None:
    """Return the latest baseline for a framework/repo."""
    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    return compliance_engine.get_baseline(user_id, repo, framework)


@router.get("/drift")
async def get_drift(
    framework: str,
    repo: str,
    request: Request,
    scan_id: str | None = Query(default=None, alias="scanId"),
) -> DriftReport | None:
    """Return drift report comparing current state to baseline."""
    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    context = _resolve_scan_context(scan_id, repo, user_id)
    return compliance_engine.compute_drift(
        user_id,
        context.repo_identifier,
        framework,
        context.findings,
        context.active_detector_cwes,
    )


# ---------------------------------------------------------------------------
# Attestations
# ---------------------------------------------------------------------------

@router.post("/attest")
async def attest_control(body: AttestRequest, request: Request) -> Attestation:
    """Create or update an attestation for a control."""
    user_id, user_email = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    try:
        return compliance_engine.save_attestation(
            user_id=user_id,
            user_email=user_email,
            framework=body.framework,
            control_id=body.control_id,
            repo_identifier=body.repo_identifier,
            note=body.note,
            status=body.status,
        )
    except Exception as exc:
        log.error("Failed to save attestation", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.get("/attestations")
async def list_attestations(framework: str, repo: str, request: Request) -> list[Attestation]:
    """Return all attestations for a framework/repo."""
    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    return compliance_engine.get_attestations(user_id, repo, framework)


@router.delete("/attestations/{attestation_id}")
async def revoke_attestation(attestation_id: str, request: Request) -> dict:
    """Revoke an attestation."""
    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    try:
        found = compliance_engine.revoke_attestation(attestation_id, user_id)
        if not found:
            raise HTTPException(status_code=404, detail="Attestation not found")
        return {"status": "revoked"}
    except HTTPException:
        raise
    except Exception as exc:
        log.error("Failed to revoke attestation", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.post("/attest/bulk")
async def bulk_attest(body: BulkAttestRequest, request: Request) -> BulkAttestResult:
    """Attest a batch of controls in one call.

    Per-control failures are reported in ``failed`` instead of aborting the
    whole batch so the client can show a partial-success summary.
    """
    user_id, user_email = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    try:
        return compliance_engine.bulk_attest(
            user_id=user_id,
            user_email=user_email,
            framework=body.framework,
            repo_identifier=body.repo_identifier,
            control_ids=list(body.control_ids),
            note=body.note,
            status=body.status,
        )
    except Exception as exc:
        log.error("Bulk attest failed", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.post("/attestations/bulk-revoke")
async def bulk_revoke(body: BulkRevokeRequest, request: Request) -> BulkRevokeResult:
    """Revoke a batch of attestations.

    Requires the explicit ``confirmation`` token "REMOVE" so a routine
    request body cannot accidentally wipe a batch of attestations.
    """
    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    if body.confirmation != "REMOVE":
        raise HTTPException(
            status_code=400,
            detail="Bulk revoke requires confirmation='REMOVE'",
        )
    try:
        return compliance_engine.bulk_revoke(
            user_id=user_id,
            attestation_ids=list(body.attestation_ids),
            reason=body.reason,
        )
    except Exception as exc:
        log.error("Bulk revoke failed", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.get("/attestations/activity")
async def attestation_activity(
    framework: str,
    repo: str,
    request: Request,
    limit: int = Query(default=200, ge=1, le=1000),
) -> list[AttestationActivity]:
    """Return the append-only activity log for a framework/repo.

    Includes ``added``, ``removed``, ``superseded``, ``bulk_added``,
    ``bulk_removed``, and ``expired`` events ordered most recent first.
    """
    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    return compliance_engine.get_attestation_activity(
        user_id=user_id,
        repo_identifier=repo,
        framework=framework,
        limit=limit,
    )


# ---------------------------------------------------------------------------
# Evidence (Phase 3)
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Custom framework CRUD
# ---------------------------------------------------------------------------

@router.get("/custom-frameworks")
async def list_custom_frameworks() -> list[dict]:
    """Return all user-defined compliance frameworks."""
    try:
        return [fw.to_wire_dict() for fw in custom_frameworks.list_frameworks()]
    except Exception as exc:
        log.error("Failed to list custom frameworks", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.get("/custom-frameworks/{framework_id}")
async def get_custom_framework(framework_id: str) -> dict:
    fw = custom_frameworks.get_framework(framework_id)
    if fw is None:
        raise HTTPException(status_code=404, detail="Custom framework not found")
    return fw.to_wire_dict()


@router.post("/custom-frameworks/validate")
async def validate_custom_framework(payload: dict) -> dict:
    """Dry-run validation. Returns {valid, errors[]} without persisting."""
    errors = custom_frameworks.validate_payload(payload)
    return {"valid": not errors, "errors": errors}


@router.post("/custom-frameworks")
async def save_custom_framework(payload: dict) -> dict:
    """Create or update a custom framework (idempotent by frameworkId)."""
    try:
        fw = custom_frameworks.save_framework(payload)
    except FrameworkValidationError as exc:
        raise HTTPException(status_code=422, detail={"errors": exc.errors})
    except Exception as exc:
        log.error("Failed to save custom framework", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))
    compliance_engine.invalidate_cache()
    return fw.to_wire_dict()


@router.delete("/custom-frameworks/{framework_id}")
async def delete_custom_framework(framework_id: str) -> dict:
    found = custom_frameworks.delete_framework(framework_id)
    if not found:
        raise HTTPException(status_code=404, detail="Custom framework not found")
    compliance_engine.invalidate_cache()
    return {"status": "deleted", "frameworkId": framework_id}


@router.get("/cwe-catalog")
async def cwe_catalog(q: str = Query(default="", description="optional search filter")) -> list[dict]:
    """Return the CWE catalog used by the framework builder's picker.

    Each entry contains id, name, description, severity_default, tags, and
    languages. When ``q`` is supplied, results are filtered by case-insensitive
    substring against id/name/description.
    """
    try:
        from dsc.vulnerabilities.catalog import all_vulnerabilities
        rules = all_vulnerabilities()
    except Exception as exc:
        log.error("Failed to load CWE catalog", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))

    needle = q.strip().lower()
    results: list[dict] = []
    for v in rules:
        if needle:
            hay = f"{v.id} {v.cwe} {v.name} {v.description}".lower()
            if needle not in hay:
                continue
        results.append({
            "id": v.id,
            "cwe": v.cwe,
            "name": v.name,
            "description": v.description,
            "severity": getattr(v.severity_default, "value", str(v.severity_default)),
            "tags": list(v.tags),
            "languages": list(v.languages),
        })
    return results


# ---------------------------------------------------------------------------
# Evidence
# ---------------------------------------------------------------------------

@router.post("/evidence")
async def generate_evidence(body: EvidenceRequest, request: Request):
    """Generate an evidence package for a framework."""
    user_id, user_email = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    try:
        context = _resolve_scan_context(body.scan_id, body.repo_identifier, user_id)
        result = compliance_engine.generate_evidence(
            framework=body.framework,
            fmt=body.format,
            repo_identifier=context.repo_identifier,
            user_id=user_id,
            user_email=user_email,
            findings=context.findings,
            active_detector_cwes=context.active_detector_cwes,
        )
        if isinstance(result, str):
            # HTML response
            return HTMLResponse(content=result)
        return result
    except HTTPException:
        raise
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc))
    except Exception as exc:
        log.error("Failed to generate evidence", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


@router.post("/evidence/signed")
async def generate_signed_evidence_package(body: EvaluateRequest, request: Request):
    """Generate a signed audit-grade evidence package as a tarball.

    Distinct from ``/evidence`` (which returns JSON/HTML for a single
    framework). This endpoint produces a ``.tar.gz`` containing SARIF,
    OSCAL AR, a JSON compliance report, and a signed manifest with
    SHA-256 hashes for every artifact. The signature is Ed25519, generated
    by a per-server keypair under ``~/.devseccode/evidence-keys/``.

    Verifiers can re-hash any artifact and check the signature against the
    embedded public key to confirm the package has not been tampered with
    since signing.

    Reuses ``EvaluateRequest`` (scan_id + frameworks + repo_identifier)
    rather than introducing a new request shape.
    """
    import tempfile
    from pathlib import Path

    from fastapi.responses import Response

    from dsc.compliance.evidence_builder import build_evidence_package
    from dsc.scanner.compliance_evaluator import evaluate as scanner_evaluate
    from dsc.scanner.models import Finding, ScanResult

    user_id, _ = _extract_user(request)
    if not user_id:
        raise HTTPException(status_code=401, detail="Authentication required")
    try:
        context = _resolve_scan_context(body.scan_id, body.repo_identifier, user_id)

        # The scan context carries findings as dicts; rehydrate to Finding
        # dataclasses so the scanner-side evaluator and SARIF formatter can
        # operate on them.
        finding_objs: list[Finding] = []
        for raw in context.findings:
            try:
                finding_objs.append(Finding.from_dict(raw))
            except Exception:
                log.warning("Skipping malformed finding while building evidence pack")

        evaluation = scanner_evaluate(
            findings=finding_objs,
            frameworks=body.frameworks,
            active_detector_cwes=set(context.active_detector_cwes),
        )

        scan_result = ScanResult(
            findings=finding_objs,
            files_scanned=0,
            scan_duration_ms=0,
            scanner_version="1.0.0",
        )

        # Bundle the same attestations the posture editor reads so the
        # signed package and the IDE never disagree on attestation state.
        attestations_bytes = _attestations_snapshot_bytes(
            user_id=user_id,
            repo_identifier=context.repo_identifier or body.repo_identifier,
            frameworks=list(body.frameworks),
        )

        # Build into a temp file so we can return the bytes without holding
        # an open file handle past the response.
        with tempfile.TemporaryDirectory() as tmpdir:
            tarball_path = Path(tmpdir) / "evidence.tar.gz"
            built = build_evidence_package(
                scan_result=scan_result,
                evaluation=evaluation,
                output_path=tarball_path,
                scan_id=body.scan_id,
                repo_identifier=context.repo_identifier or body.repo_identifier,
                attestations_json_bytes=attestations_bytes,
            )
            data = tarball_path.read_bytes()

        return Response(
            content=data,
            media_type="application/gzip",
            headers={
                "Content-Disposition": (
                    f'attachment; filename="evidence-{built.package_uuid}.tar.gz"'
                ),
                "X-Deva-Package-UUID": built.package_uuid,
                "X-Deva-Signer-Key-Id": built.manifest.signer.key_id,
            },
        )
    except HTTPException:
        raise
    except Exception as exc:
        log.error("Failed to generate signed evidence package", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


# ---------------------------------------------------------------------------
# Phase 6 evidence pack (spec section 5.8)
# ---------------------------------------------------------------------------
#
# Distinct from /evidence above: that one uses the Firestore-backed
# control catalog and returns an attestation-aware EvidencePackage for a
# single framework. /evidence-pack is rule-driven -- it walks the loaded
# rulepack's metadata.deva.compliance blocks and produces a multi-
# framework matrix from a scan ID alone. No external catalog needed.

@router.get("/evidence-pack")
async def generate_evidence_pack(
    request: Request,
    scan_id: str | None = Query(None),
    repo_identifier: str = Query("local"),
    fmt: str = Query("json", pattern="^(json|markdown|html)$"),
):
    """Generate a Phase 6 rule-driven compliance evidence pack.

    Produces a per-framework matrix (HIPAA / PCI / SOC 2 / ISO 27001 /
    FedRAMP / NIST CSF / OWASP ASVS) directly from each rule's
    ``metadata.deva.compliance`` block plus the scan's findings. Falls
    back to repo_identifier when scan_id is omitted so the IDE can ask
    "give me the latest local-scan evidence" without bookkeeping a
    scan id.
    """
    user_id, _user_email = _extract_user(request)

    try:
        scanner = get_scanner_service()
        context = scanner.get_compliance_context(
            scan_id=scan_id,
            repo_identifier=repo_identifier,
            user_id=user_id,
        )
        if context is None:
            raise HTTPException(status_code=409, detail="no_scan_results")

        # Load the rulepack via the engine loader -- cheap and bypasses
        # the legacy DetectorMetadata path.
        from dsc.engine.rulepack_loader import RulepackLoader
        from dsc.compliance import build_evidence_pack, render_html, render_markdown
        from dsc.scanner.models import Finding, Severity

        loader = RulepackLoader()
        rulepack = loader.load()
        rules = rulepack.rules

        # Convert the context findings (compliance_input dicts) back to
        # Finding objects for the packager. The packager only reads
        # rule_id, file_path, line_start, severity, message, and
        # metadata.deva_compliance, so we don't need to reconstruct
        # every quality field.
        findings: list[Finding] = []
        for raw in context.findings or []:
            try:
                sev = Severity[raw.get("severity", "MEDIUM").upper()]
            except (KeyError, AttributeError):
                sev = Severity.MEDIUM
            findings.append(
                Finding(
                    rule_id=str(raw.get("rule_id", "")),
                    cwe=str(raw.get("cwe", "")),
                    severity=sev,
                    file_path=str(raw.get("file_path", "")),
                    line_start=int(raw.get("line_start") or 0),
                    line_end=int(raw.get("line_end") or 0),
                    column=int(raw.get("column") or 0),
                    message=str(raw.get("message", "")),
                    metadata=dict(raw.get("metadata") or {}),
                    triage_label=str(raw.get("triage_label") or "untriaged"),
                    triage_confidence=float(raw.get("triage_confidence") or 0.0),
                    triage_reasoning=raw.get("triage_reasoning"),
                )
            )

        pack = build_evidence_pack(
            findings,
            rules,
            scan_target=context.repo_identifier or repo_identifier,
            scanner_version=getattr(rulepack, "scanner_version", ""),
        )

        if fmt == "html":
            return HTMLResponse(content=render_html(pack))
        if fmt == "markdown":
            from fastapi.responses import PlainTextResponse
            return PlainTextResponse(content=render_markdown(pack))
        return pack
    except HTTPException:
        raise
    except Exception as exc:
        log.error("Failed to generate evidence pack", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


# ---------------------------------------------------------------------------
# Phase 13: SPRS score (CMMC / NIST 800-171)
# ---------------------------------------------------------------------------

@router.post("/sprs")
async def export_sprs(body: EvaluateRequest, request: Request) -> dict:
    """Compute the DFARS SPRS score from a CMMC evaluation.

    Phase 13 (item 2). Returns a structured score breakdown including the
    final score, per-level statistics, and the list of practices that drove
    deductions. ``EvaluateRequest`` reused for shape consistency, but this
    endpoint always evaluates against CMMC (frameworks list is informational).
    """
    from dsc.compliance.sprs import calculate
    from dsc.scanner.compliance_evaluator import (
        ComplianceScanEvaluation,
        evaluate as scanner_evaluate,
    )
    from dsc.scanner.models import Finding

    user_id, _ = _extract_user(request)
    try:
        context = _resolve_scan_context(body.scan_id, body.repo_identifier, user_id)

        finding_objs: list[Finding] = []
        for raw in context.findings:
            try:
                finding_objs.append(Finding.from_dict(raw))
            except Exception:
                log.warning("Skipping malformed finding for SPRS export")

        # Force CMMC into the framework list since SPRS only applies there.
        frameworks = list({*body.frameworks, "CMMC"})
        evaluation: ComplianceScanEvaluation = scanner_evaluate(
            findings=finding_objs,
            frameworks=frameworks,
            active_detector_cwes=set(context.active_detector_cwes),
        )
        result = calculate(evaluation)
        payload = result.to_dict()
        # Phase 6 scope envelope.
        from dsc.compliance.scope_disclaimer import api_scope_envelope
        payload["devaScope"] = api_scope_envelope(["CMMC"])
        return payload
    except HTTPException:
        raise
    except Exception as exc:
        log.error("SPRS export failed", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


# ---------------------------------------------------------------------------
# Phase 13: POA&M (Plan of Action and Milestones) export
# ---------------------------------------------------------------------------

@router.post("/export/poam")
async def export_poam(body: EvaluateRequest, request: Request) -> dict:
    """Export the compliance evaluation as an OSCAL POA&M document.

    Phase 13 (item 1) of the compliance evolution plan. Pairs with
    ``/api/compliance/export/oscal`` (the AR document): where the AR
    records "what is the current state," the POA&M records "what is
    broken and how it will be fixed." Required by federal authorization
    processes (FedRAMP, RMF, CMMC).

    Reuses the same request shape as ``/evaluate`` and ``/export/oscal``
    (scan_id + frameworks + optional repo_identifier). The POA&M UUIDs
    are derived deterministically from ``scan_id``, so calling this
    endpoint twice with the same scan produces identical output.
    """
    from dsc.compliance.poam_emitter import to_oscal_poam
    from dsc.scanner.compliance_evaluator import (
        ComplianceScanEvaluation,
        evaluate as scanner_evaluate,
    )
    from dsc.scanner.models import Finding

    user_id, _ = _extract_user(request)
    try:
        context = _resolve_scan_context(body.scan_id, body.repo_identifier, user_id)

        finding_objs: list[Finding] = []
        for raw in context.findings:
            try:
                finding_objs.append(Finding.from_dict(raw))
            except Exception:
                log.warning("Skipping malformed finding for POA&M export")

        evaluation: ComplianceScanEvaluation = scanner_evaluate(
            findings=finding_objs,
            frameworks=body.frameworks,
            active_detector_cwes=set(context.active_detector_cwes),
        )
        document = to_oscal_poam(
            evaluation=evaluation,
            findings=finding_objs,
            scan_id=body.scan_id,
            repo_identifier=context.repo_identifier or body.repo_identifier,
            frameworks=list(body.frameworks),
        )
        # Phase 6 scope envelope.
        from dsc.compliance.scope_disclaimer import api_scope_envelope
        document["devaScope"] = api_scope_envelope(list(body.frameworks))
        return document
    except HTTPException:
        raise
    except Exception as exc:
        log.error("POA&M export failed", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))


# ---------------------------------------------------------------------------
# Phase 10: Dependency / SBOM scanner
# ---------------------------------------------------------------------------
#
# Three endpoints:
#
#   GET  /api/compliance/sbom?path=...&format=cyclonedx|spdx|json
#       Generate an SBOM for the given workspace path. Synchronous (a workspace
#       SBOM is fast even for large repos because we just walk manifests, not
#       resolve them).
#
#   GET  /api/compliance/dependencies?path=...&offline=false
#       Run a dependency vulnerability scan: walk manifests, query OSV, return
#       findings + per-component vulnerability blocks. Used by the IDE's
#       Compliance posture editor and the CI gate.
#
#   POST /api/compliance/sbom/upload
#       Accept a pre-collected component list (e.g. produced from a build
#       artifact) and run the OSV lookup. Lets the marketplace's scheduled
#       scans run remotely without re-walking the workspace.

@router.get("/sbom")
async def generate_sbom(
    path: str = Query(..., description="Workspace root to walk"),
    format: str = Query("cyclonedx", pattern="^(cyclonedx|spdx|json)$"),
    vulnerabilities: bool = Query(False, description="Include OSV vulnerability data"),
    offline: bool = Query(False, description="Skip network calls; serve cache only"),
):
    """Generate an SBOM for a workspace path."""
    from pathlib import Path as _P

    from fastapi.responses import PlainTextResponse

    from dsc.sbom import (
        OSVClient,
        collect_components,
        format_cyclonedx,
        format_spdx,
        scan_dependencies,
    )

    target = _P(path).resolve()
    if not target.exists():
        raise HTTPException(status_code=404, detail=f"Workspace not found: {path}")

    components = collect_components(target)
    vuln_map = None
    if vulnerabilities:
        client = OSVClient(offline=offline)
        result = scan_dependencies(target, osv_client=client, check_abandoned=False)
        # Re-build the map from the scan result so the emitters can use it.
        from dsc.sbom.osv_client import OSVVulnerability

        vuln_map = {}
        for block in result["vulnerabilities"]:
            comp_name, _, comp_version = block["component"].rpartition("@")
            key = (block["ecosystem"], comp_name, comp_version)
            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"]
            ]

    if format == "cyclonedx":
        body = format_cyclonedx(components, target, vulnerabilities=vuln_map)
        return PlainTextResponse(content=body, media_type="application/vnd.cyclonedx+json")
    if format == "spdx":
        body = format_spdx(components, target, vulnerabilities=vuln_map)
        return PlainTextResponse(content=body, media_type="application/spdx+json")
    # format == "json"
    return [
        {
            "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
    ]


@router.get("/dependencies")
async def scan_dependencies_endpoint(
    path: str = Query(..., description="Workspace root to walk"),
    offline: bool = Query(False, description="Skip network calls; serve cache only"),
    check_abandoned: bool = Query(True, description="Detect abandoned packages"),
) -> dict:
    """Run a dependency vulnerability scan against a workspace path.

    Returns the canonical scan result: ``components`` + ``vulnerabilities``
    + ``findings``. Findings can be fed straight into the compliance
    evaluator and SARIF formatter.
    """
    from pathlib import Path as _P

    from dsc.sbom import OSVClient, scan_dependencies

    target = _P(path).resolve()
    if not target.exists():
        raise HTTPException(status_code=404, detail=f"Workspace not found: {path}")

    try:
        client = OSVClient(offline=offline)
        return scan_dependencies(
            target,
            osv_client=client,
            check_abandoned=check_abandoned,
        )
    except Exception as exc:
        log.error("Dependency scan failed", error=str(exc))
        raise HTTPException(status_code=500, detail=str(exc))
