"""
FastAPI routes for CRA (Cyber Resilience Act) Compliance Projects.

Endpoints:
- GET    /v1/cra/projects                         List all CRA projects for a tenant
- POST   /v1/cra/projects                         Create a new CRA project
- GET    /v1/cra/projects/{id}                    Get a single CRA project
- PATCH  /v1/cra/projects/{id}                    Update intake fields / status
- DELETE /v1/cra/projects/{id}                    Archive project
- POST   /v1/cra/projects/{id}/scope-check        Run deterministic Annex III/IV match
- POST   /v1/cra/projects/{id}/path-select        Set conformity path (validated against classification)

Tenant scoping via X-Tenant-ID header (see tenant_utils.get_tenant_id).
"""

import json
import logging
import socket
import ssl
from datetime import date
from typing import Optional
from uuid import UUID

import httpx
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from sqlalchemy import text

from database import SessionLocal
from .cra_annex_i_data import (
    ANNEX_I_REQUIREMENTS, MEASURES, DEADLINES, SEVERITY_WEIGHT,
)
from .tenant_utils import get_tenant_id

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/cra/projects", tags=["cra"])


# =============================================================================
# CONSTANTS — Annex III/IV mapping (source: migration 059_wiki_cra_annex_i_detail.sql)
# =============================================================================

ANNEX_IV_CRITICAL = [
    "hardware-security-module", "hardware security module", "hsm",
    "smartcard-chip", "smartcard chip", "smart-meter-gateway", "smart meter gateway",
    "secure-element", "secure element",
]

ANNEX_III_CLASS_II = [
    "betriebssystem", "operating system",
    "hypervisor",
    "container-runtime", "container runtime",
    "public-key-infrastruktur", "public key infrastructure", "pki",
    "industrial-control", "industrial control", "ics", "scada",
]

ANNEX_III_CLASS_I = [
    "passwort-manager", "password manager", "passwort manager",
    "vpn-software", "vpn software",
    "firewall",
    "router",
    "smart-home", "smart home",
    "iot-sensor", "iot sensor", "iot-geraet",
    "siem", "siem-system",
]

CLASSIFICATIONS = {"NOT_IN_SCOPE", "STANDARD", "IMPORTANT_I", "IMPORTANT_II", "CRITICAL"}
CONFORMITY_PATHS = {"self_assessment", "harmonized_standard", "eucc", "notified_body"}

# Allowed paths per classification (CRA Art. 32 / Anhang VIII).
# IMPORTANT_II darf NICHT selbst bewerten — eine harmonisierte Norm allein genuegt
# hier nicht; es braucht benannte Stelle (Modul B+C/H) oder EUCC. CRITICAL ebenso,
# mit EUCC als regulaerem Weg.
ALLOWED_PATHS = {
    "NOT_IN_SCOPE":  set(),
    "STANDARD":      {"self_assessment", "harmonized_standard", "eucc", "notified_body"},
    "IMPORTANT_I":   {"self_assessment", "harmonized_standard", "eucc", "notified_body"},
    "IMPORTANT_II":  {"eucc", "notified_body"},
    "CRITICAL":      {"eucc", "notified_body"},
}

STATUS_WHITELIST = {
    "draft", "scoped", "classified", "path_selected", "requirements_mapped",
    "evidence_pending", "gaps_open", "remediation", "ready_for_review",
    "declaration_ready", "post_market", "archived",
}


# =============================================================================
# REQUEST / RESPONSE MODELS
# =============================================================================

class CreateProjectRequest(BaseModel):
    name: str
    description: str = ""
    gap_project_id: Optional[str] = None


class UpdateProjectRequest(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    repo_url: Optional[str] = None
    primary_language: Optional[str] = None
    has_firmware: Optional[bool] = None
    connected_to_internet: Optional[bool] = None
    has_software_updates: Optional[bool] = None
    processes_personal_data: Optional[bool] = None
    is_critical_infra_supplier: Optional[bool] = None
    intended_use: Optional[str] = None
    status: Optional[str] = None


class PathSelectRequest(BaseModel):
    conformity_path: str


# =============================================================================
# HELPERS
# =============================================================================

def _row_to_response(row) -> dict:
    """Convert a DB row to dict."""
    return {
        "id": str(row.id),
        "tenant_id": row.tenant_id,
        "name": row.name,
        "description": row.description or "",
        "gap_project_id": str(row.gap_project_id) if row.gap_project_id else None,
        "repo_url": row.repo_url,
        "primary_language": row.primary_language,
        "has_firmware": bool(row.has_firmware),
        "connected_to_internet": bool(row.connected_to_internet),
        "has_software_updates": bool(row.has_software_updates),
        "processes_personal_data": bool(row.processes_personal_data),
        "is_critical_infra_supplier": bool(row.is_critical_infra_supplier),
        "intended_use": row.intended_use or "",
        "cra_classification": row.cra_classification,
        "classification_rationale": (
            row.classification_rationale
            if isinstance(row.classification_rationale, list)
            else json.loads(row.classification_rationale or "[]")
        ),
        "conformity_path": row.conformity_path,
        "status": row.status,
        "created_at": row.created_at.isoformat() if row.created_at else "",
        "updated_at": row.updated_at.isoformat() if row.updated_at else "",
    }


def _classify(intake: dict) -> tuple[str, list[str]]:
    """Deterministic Annex III/IV match. Returns (classification, rationale_list)."""
    haystack = " ".join(
        str(intake.get(f) or "").lower()
        for f in ("intended_use", "primary_language", "name", "description")
    )

    def _match(buckets: list[str]) -> Optional[str]:
        for token in buckets:
            if token.lower() in haystack:
                return token
        return None

    rationale: list[str] = []

    hit = _match(ANNEX_IV_CRITICAL)
    if hit:
        rationale.append(f"Annex IV (Critical): Treffer auf '{hit}'")
        return "CRITICAL", rationale

    hit = _match(ANNEX_III_CLASS_II)
    if hit:
        rationale.append(f"Annex III Klasse II (Important_II): Treffer auf '{hit}'")
        return "IMPORTANT_II", rationale

    hit = _match(ANNEX_III_CLASS_I)
    if hit:
        rationale.append(f"Annex III Klasse I (Important_I): Treffer auf '{hit}'")
        return "IMPORTANT_I", rationale

    flags = [
        ("connected_to_internet", "Produkt ist mit dem Internet verbunden"),
        ("has_software_updates", "Produkt hat Software-Updates (digitales Element)"),
        ("processes_personal_data", "Produkt verarbeitet personenbezogene Daten"),
        ("is_critical_infra_supplier", "Produkt wird in kritischer Infrastruktur eingesetzt"),
    ]
    for field, msg in flags:
        if intake.get(field):
            rationale.append(msg)

    if rationale:
        return "STANDARD", rationale

    rationale.append("Kein digitales Element und keine Annex III/IV-Kategorie erkannt")
    return "NOT_IN_SCOPE", rationale


# =============================================================================
# ENDPOINTS
# =============================================================================

@router.get("")
async def list_projects(
    tenant_id: str = Depends(get_tenant_id),
    include_archived: bool = False,
):
    """List all CRA projects for the tenant."""
    db = SessionLocal()
    try:
        if include_archived:
            sql = """
                SELECT * FROM compliance_cra_projects
                WHERE tenant_id = :tenant_id
                ORDER BY created_at DESC
            """
        else:
            sql = """
                SELECT * FROM compliance_cra_projects
                WHERE tenant_id = :tenant_id AND status != 'archived'
                ORDER BY created_at DESC
            """
        rows = db.execute(text(sql), {"tenant_id": tenant_id}).fetchall()
        return {
            "projects": [_row_to_response(row) for row in rows],
            "total": len(rows),
        }
    finally:
        db.close()


@router.post("", status_code=201)
async def create_project(
    body: CreateProjectRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Create a new CRA project. Optionally pre-populate intake from a gap_project."""
    db = SessionLocal()
    try:
        gap_intake: dict = {}
        if body.gap_project_id:
            try:
                gap_uuid = UUID(body.gap_project_id)
            except ValueError:
                raise HTTPException(status_code=400, detail="Invalid gap_project_id (must be UUID)")
            gap_row = db.execute(
                text("""
                    SELECT connected_to_internet, has_software_updates,
                           processes_personal_data, is_critical_infra_supplier,
                           description
                    FROM gap_projects
                    WHERE id = :gid AND tenant_id = CAST(:tid AS uuid)
                """),
                {"gid": str(gap_uuid), "tid": tenant_id},
            ).fetchone()
            if gap_row:
                gap_intake = {
                    "connected_to_internet": bool(gap_row.connected_to_internet),
                    "has_software_updates": bool(gap_row.has_software_updates),
                    "processes_personal_data": bool(gap_row.processes_personal_data),
                    "is_critical_infra_supplier": bool(gap_row.is_critical_infra_supplier),
                    "intended_use": gap_row.description or "",
                }

        result = db.execute(
            text("""
                INSERT INTO compliance_cra_projects
                    (tenant_id, name, description, gap_project_id,
                     connected_to_internet, has_software_updates,
                     processes_personal_data, is_critical_infra_supplier,
                     intended_use, status)
                VALUES
                    (:tenant_id, :name, :description, :gap_project_id,
                     :connected_to_internet, :has_software_updates,
                     :processes_personal_data, :is_critical_infra_supplier,
                     :intended_use, 'draft')
                RETURNING *
            """),
            {
                "tenant_id": tenant_id,
                "name": body.name,
                "description": body.description,
                "gap_project_id": body.gap_project_id,
                "connected_to_internet": gap_intake.get("connected_to_internet", False),
                "has_software_updates": gap_intake.get("has_software_updates", False),
                "processes_personal_data": gap_intake.get("processes_personal_data", False),
                "is_critical_infra_supplier": gap_intake.get("is_critical_infra_supplier", False),
                "intended_use": gap_intake.get("intended_use", ""),
            },
        )
        row = result.fetchone()
        db.commit()
        logger.info("Created CRA project %s for tenant %s", row.id, tenant_id)
        return _row_to_response(row)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.get("/{project_id}")
async def get_project(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Get a single CRA project (tenant-scoped)."""
    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                SELECT * FROM compliance_cra_projects
                WHERE id = :pid AND tenant_id = :tid
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="CRA project not found")
        return _row_to_response(row)
    finally:
        db.close()


@router.patch("/{project_id}")
async def update_project(
    project_id: str,
    body: UpdateProjectRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Update intake / status fields. Status is whitelist-validated (no transitions)."""
    db = SessionLocal()
    try:
        updates: dict = {"pid": project_id, "tid": tenant_id}
        set_parts = ["updated_at = NOW()"]

        for field in (
            "name", "description", "repo_url", "primary_language",
            "has_firmware", "connected_to_internet", "has_software_updates",
            "processes_personal_data", "is_critical_infra_supplier",
            "intended_use", "status",
        ):
            val = getattr(body, field)
            if val is None:
                continue
            if field == "status" and val not in STATUS_WHITELIST:
                raise HTTPException(
                    status_code=400,
                    detail=f"Invalid status '{val}'. Allowed: {sorted(STATUS_WHITELIST)}",
                )
            set_parts.append(f"{field} = :{field}")
            updates[field] = val

        if len(set_parts) == 1:
            raise HTTPException(status_code=400, detail="No fields to update")

        result = db.execute(
            text(f"""
                UPDATE compliance_cra_projects
                SET {', '.join(set_parts)}
                WHERE id = :pid AND tenant_id = :tid
                RETURNING *
            """),
            updates,
        )
        row = result.fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="CRA project not found")
        db.commit()
        return _row_to_response(row)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.delete("/{project_id}")
async def archive_project(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Soft-archive a CRA project."""
    db = SessionLocal()
    try:
        result = db.execute(
            text("""
                UPDATE compliance_cra_projects
                SET status = 'archived', updated_at = NOW()
                WHERE id = :pid AND tenant_id = :tid AND status != 'archived'
                RETURNING id
            """),
            {"pid": project_id, "tid": tenant_id},
        )
        row = result.fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="Project not found or already archived")
        db.commit()
        return {"success": True, "id": str(row.id), "status": "archived"}
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.post("/{project_id}/scope-check")
async def scope_check(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Run deterministic Annex III/IV classification on the project's intake fields.

    Writes cra_classification + classification_rationale + status update.
    """
    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                SELECT * FROM compliance_cra_projects
                WHERE id = :pid AND tenant_id = :tid
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="CRA project not found")

        intake = {
            "name": row.name,
            "description": row.description,
            "intended_use": row.intended_use,
            "primary_language": row.primary_language,
            "connected_to_internet": row.connected_to_internet,
            "has_software_updates": row.has_software_updates,
            "processes_personal_data": row.processes_personal_data,
            "is_critical_infra_supplier": row.is_critical_infra_supplier,
        }
        classification, rationale = _classify(intake)
        new_status = "scoped" if classification == "NOT_IN_SCOPE" else "classified"

        updated = db.execute(
            text("""
                UPDATE compliance_cra_projects
                SET cra_classification = :cls,
                    classification_rationale = CAST(:rat AS jsonb),
                    status = :status,
                    updated_at = NOW()
                WHERE id = :pid AND tenant_id = :tid
                RETURNING *
            """),
            {
                "pid": project_id,
                "tid": tenant_id,
                "cls": classification,
                "rat": json.dumps(rationale, ensure_ascii=False),
                "status": new_status,
            },
        ).fetchone()
        db.commit()
        logger.info(
            "Scope-check %s for tenant %s -> %s",
            project_id, tenant_id, classification,
        )
        return _row_to_response(updated)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.get("/{project_id}/requirements")
async def list_requirements(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Liste der 40 Annex-I-Requirements mit per-Projekt-Status.

    Phase-2-Logik: Status default 'unbewertet' (kein evidence_check linked).
    Phase-3 wird per-req Status aus compliance_evidence_check_results
    berechnen.
    """
    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                SELECT id, cra_classification FROM compliance_cra_projects
                WHERE id = :pid AND tenant_id = :tid
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="CRA project not found")

        items = []
        for req in ANNEX_I_REQUIREMENTS:
            items.append({
                **req,
                "status": "unbewertet",
                "mapped_measure_names": [
                    {"id": m, "name": MEASURES.get(m, m)} for m in req["mapped_measures"]
                ],
            })
        return {
            "project_id": project_id,
            "classification": row.cra_classification,
            "total": len(items),
            "items": items,
        }
    finally:
        db.close()


@router.get("/{project_id}/backlog")
async def get_backlog(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Priorisierte TODO-Liste — gleiche Daten wie /requirements, aber sortiert.

    Sortierung: severity_weight (desc), effort_days (asc), n (asc).
    Deadline-Druck: Tage bis 11.12.2027 (CRA-CE-Pflicht).
    """
    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                SELECT id, cra_classification FROM compliance_cra_projects
                WHERE id = :pid AND tenant_id = :tid
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="CRA project not found")

        ce_deadline = date(2027, 12, 11)
        today = date.today()
        days_to_ce = max(0, (ce_deadline - today).days)
        max_days = 365 * 3  # 3 Jahre als Skalierungsbasis

        items = []
        for req in ANNEX_I_REQUIREMENTS:
            severity_w = SEVERITY_WEIGHT.get(req["severity"], 0)
            deadline_pressure = 1.0 - min(days_to_ce / max_days, 1.0)  # 0..1
            effort_factor = 1.0 / max(req["effort_days"], 1)
            priority_score = (
                severity_w * (1.0 + 0.5 * deadline_pressure) * (1.0 + 0.1 * effort_factor)
            )
            items.append({
                "req_id": req["req_id"],
                "title": req["title"],
                "category": req["category"],
                "severity": req["severity"],
                "annex_anchor": req["annex_anchor"],
                "description": req["description"],
                "effort_days": req["effort_days"],
                "mapped_measure_names": [
                    {"id": m, "name": MEASURES.get(m, m)} for m in req["mapped_measures"]
                ],
                "status": "unbewertet",
                "priority_score": round(priority_score, 1),
            })

        items.sort(key=lambda x: (-x["priority_score"], x["effort_days"]))
        for idx, it in enumerate(items, 1):
            it["rank"] = idx

        return {
            "project_id": project_id,
            "classification": row.cra_classification,
            "days_to_ce_deadline": days_to_ce,
            "deadlines": DEADLINES,
            "total": len(items),
            "items": items,
        }
    finally:
        db.close()


@router.post("/{project_id}/path-select")
async def path_select(
    project_id: str,
    body: PathSelectRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Set conformity_path. Validates path is allowed for the project's classification."""
    if body.conformity_path not in CONFORMITY_PATHS:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid conformity_path. Allowed: {sorted(CONFORMITY_PATHS)}",
        )

    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                SELECT cra_classification FROM compliance_cra_projects
                WHERE id = :pid AND tenant_id = :tid
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="CRA project not found")
        if not row.cra_classification:
            raise HTTPException(
                status_code=400,
                detail="Run scope-check before selecting a conformity path",
            )

        allowed = ALLOWED_PATHS.get(row.cra_classification, set())
        if body.conformity_path not in allowed:
            raise HTTPException(
                status_code=400,
                detail=(
                    f"Path '{body.conformity_path}' not allowed for classification "
                    f"'{row.cra_classification}'. Allowed: {sorted(allowed)}"
                ),
            )

        updated = db.execute(
            text("""
                UPDATE compliance_cra_projects
                SET conformity_path = :path,
                    status = 'path_selected',
                    updated_at = NOW()
                WHERE id = :pid AND tenant_id = :tid
                RETURNING *
            """),
            {"pid": project_id, "tid": tenant_id, "path": body.conformity_path},
        ).fetchone()
        db.commit()
        return _row_to_response(updated)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


# =============================================================================
# PHASE 3: SBOM Upload + Automated Checks
# =============================================================================

DEFAULT_CHECKS = [
    {
        "check_code": "cra_security_txt",
        "title": "security.txt (RFC 9116)",
        "description": "Pruefung ob /.well-known/security.txt existiert und Contact-Eintrag enthaelt",
        "check_type": "url_probe",
        "linked_req_ids": ["CRA-AI-35"],  # CVD
    },
    {
        "check_code": "cra_vuln_disclosure_url",
        "title": "Vulnerability-Disclosure-Policy-URL",
        "description": "Manuelle Pflege: URL zur CVD-Policy. Wird in DoC referenziert.",
        "check_type": "manual_review",
        "linked_req_ids": ["CRA-AI-35"],
    },
    {
        "check_code": "cra_update_policy_url",
        "title": "Update-Policy-URL",
        "description": "URL zur dokumentierten Patch-/Update-Policy (Lifecycle, SLAs).",
        "check_type": "manual_review",
        "linked_req_ids": ["CRA-AI-28", "CRA-AI-31", "CRA-AI-34"],
    },
    {
        "check_code": "cra_signed_updates",
        "title": "Signierte Updates",
        "description": "Bestaetigung dass Updates digital signiert ausgeliefert werden.",
        "check_type": "manual_review",
        "linked_req_ids": ["CRA-AI-29", "CRA-AI-30"],
    },
    {
        "check_code": "cra_default_password_check",
        "title": "Keine Default-Passwoerter",
        "description": "Bestaetigung dass das Produkt keine Default-Passwoerter ausliefert.",
        "check_type": "manual_review",
        "linked_req_ids": ["CRA-AI-8"],
    },
    {
        "check_code": "cra_tls_cert_check",
        "title": "TLS-Zertifikat des Produkt-Endpoints",
        "description": "Pruefung ob TLS 1.2+ und gueltiges Zertifikat.",
        "check_type": "tls_probe",
        "linked_req_ids": ["CRA-AI-15"],
    },
]


class CheckRunRequest(BaseModel):
    target_url: Optional[str] = None


def _cra_project_exists(db, project_id: str, tenant_id: str) -> bool:
    row = db.execute(
        text("""
            SELECT 1 FROM compliance_cra_projects
            WHERE id = :pid AND tenant_id = :tid
        """),
        {"pid": project_id, "tid": tenant_id},
    ).fetchone()
    return bool(row)


@router.post("/{project_id}/sbom/upload")
async def upload_sbom(
    project_id: str,
    file: UploadFile = File(...),
    tenant_id: str = Depends(get_tenant_id),
):
    """Upload CycloneDX or SPDX SBOM (JSON). Persists in compliance_cra_sboms."""
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")

        raw = await file.read()
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as e:
            raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")

        # Format detection
        fmt = None
        spec_version = None
        component_count = 0
        summary: dict = {}

        if isinstance(data, dict) and data.get("bomFormat") == "CycloneDX":
            fmt = "cyclonedx"
            spec_version = data.get("specVersion") or data.get("version")
            components = data.get("components") or []
            component_count = len(components)
            summary = {
                "bomFormat": "CycloneDX",
                "specVersion": spec_version,
                "serialNumber": data.get("serialNumber"),
                "metadata_component": (data.get("metadata") or {}).get("component", {}),
                "sample_components": components[:5],
            }
        elif isinstance(data, dict) and data.get("spdxVersion"):
            fmt = "spdx"
            spec_version = data["spdxVersion"]
            packages = data.get("packages") or []
            component_count = len(packages)
            summary = {
                "spdxVersion": spec_version,
                "name": data.get("name"),
                "sample_packages": [{"name": p.get("name"), "version": p.get("versionInfo")} for p in packages[:5]],
            }
        else:
            raise HTTPException(
                status_code=400,
                detail="Unrecognized SBOM format. Expected CycloneDX (bomFormat='CycloneDX') or SPDX (spdxVersion present).",
            )

        result = db.execute(
            text("""
                INSERT INTO compliance_cra_sboms
                    (cra_project_id, tenant_id, filename, format, spec_version,
                     component_count, raw_content, summary, scan_status)
                VALUES
                    (:pid, :tid, :fn, :fmt, :ver, :cnt,
                     CAST(:raw AS jsonb), CAST(:sum AS jsonb), 'pending')
                RETURNING id, uploaded_at
            """),
            {
                "pid": project_id,
                "tid": tenant_id,
                "fn": file.filename or "unknown.json",
                "fmt": fmt,
                "ver": spec_version,
                "cnt": component_count,
                "raw": json.dumps(data),
                "sum": json.dumps(summary),
            },
        ).fetchone()
        db.commit()
        return {
            "id": str(result.id),
            "uploaded_at": result.uploaded_at.isoformat(),
            "filename": file.filename,
            "format": fmt,
            "spec_version": spec_version,
            "component_count": component_count,
            "summary": summary,
            "scan_status": "pending",
            "note": "osv.dev scan wird durch separates Tool im Team durchgefuehrt.",
        }
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.get("/{project_id}/sboms")
async def list_sboms(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """List all SBOM uploads for this project, newest first."""
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")
        rows = db.execute(
            text("""
                SELECT id, filename, format, spec_version, component_count,
                       summary, scan_status, scan_summary, uploaded_at, scanned_at
                FROM compliance_cra_sboms
                WHERE cra_project_id = :pid AND tenant_id = :tid
                ORDER BY uploaded_at DESC
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchall()
        items = []
        for r in rows:
            items.append({
                "id": str(r.id),
                "filename": r.filename,
                "format": r.format,
                "spec_version": r.spec_version,
                "component_count": r.component_count,
                "summary": r.summary if isinstance(r.summary, dict) else json.loads(r.summary or "{}"),
                "scan_status": r.scan_status,
                "scan_summary": r.scan_summary if isinstance(r.scan_summary, dict) else json.loads(r.scan_summary or "{}"),
                "uploaded_at": r.uploaded_at.isoformat() if r.uploaded_at else None,
                "scanned_at": r.scanned_at.isoformat() if r.scanned_at else None,
            })
        return {"project_id": project_id, "total": len(items), "items": items}
    finally:
        db.close()


@router.get("/{project_id}/checks")
async def list_checks(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """List configured checks + their latest run result."""
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")

        rows = db.execute(
            text("""
                SELECT id, check_code, title, description, check_type, target_url,
                       linked_control_ids, last_run_at, is_active
                FROM compliance_evidence_checks
                WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid_uuid
                  AND check_code LIKE 'cra_%'
                ORDER BY check_code
            """),
            {"tid": tenant_id, "pid_uuid": project_id},
        ).fetchall()

        # Latest result per check
        results: dict = {}
        if rows:
            ids = [str(r.id) for r in rows]
            for r_row in db.execute(
                text("""
                    SELECT check_id, status, message, ran_at
                    FROM compliance_evidence_check_results
                    WHERE check_id::text = ANY(:ids)
                    ORDER BY ran_at DESC
                """),
                {"ids": ids},
            ).fetchall():
                cid = str(r_row.check_id)
                if cid not in results:
                    results[cid] = {
                        "status": r_row.status,
                        "message": r_row.message,
                        "ran_at": r_row.ran_at.isoformat() if r_row.ran_at else None,
                    }

        items = []
        for r in rows:
            linked = r.linked_control_ids
            if isinstance(linked, str):
                try:
                    linked = json.loads(linked)
                except Exception:
                    linked = []
            items.append({
                "id": str(r.id),
                "check_code": r.check_code,
                "title": r.title,
                "description": r.description,
                "check_type": r.check_type,
                "target_url": r.target_url,
                "linked_req_ids": linked or [],
                "last_run_at": r.last_run_at.isoformat() if r.last_run_at else None,
                "is_active": bool(r.is_active),
                "latest_result": results.get(str(r.id)),
            })
        return {"project_id": project_id, "total": len(items), "items": items}
    finally:
        db.close()


@router.post("/{project_id}/checks/init")
async def init_checks(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Create the default set of CRA checks for this project (idempotent)."""
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")

        existing = db.execute(
            text("""
                SELECT check_code FROM compliance_evidence_checks
                WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid AND check_code LIKE 'cra_%'
            """),
            {"tid": tenant_id, "pid": project_id},
        ).fetchall()
        existing_codes = {r.check_code for r in existing}

        created = 0
        for c in DEFAULT_CHECKS:
            if c["check_code"] in existing_codes:
                continue
            db.execute(
                text("""
                    INSERT INTO compliance_evidence_checks
                        (tenant_id, project_id, check_code, title, description,
                         check_type, linked_control_ids, frequency, is_active)
                    VALUES
                        (CAST(:tid AS uuid), :pid, :code, :title, :desc,
                         :ctype, CAST(:linked AS jsonb), 'monthly', true)
                """),
                {
                    "tid": tenant_id,
                    "pid": project_id,
                    "code": c["check_code"],
                    "title": c["title"],
                    "desc": c["description"],
                    "ctype": c["check_type"],
                    "linked": json.dumps(c["linked_req_ids"]),
                },
            )
            created += 1
        db.commit()
        return {"created": created, "already_present": len(existing_codes)}
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.post("/checks/{check_id}/run")
async def run_check(
    check_id: str,
    body: CheckRunRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Manually run a single check. Currently implements:
       - cra_security_txt: HTTP GET, looks for 'Contact:' field
       - cra_tls_cert_check: TLS handshake + cert info
       - others: returns 'manual_review_required' status
    """
    db = SessionLocal()
    try:
        check = db.execute(
            text("""
                SELECT id, check_code, target_url FROM compliance_evidence_checks
                WHERE id = CAST(:cid AS uuid) AND tenant_id = CAST(:tid AS uuid)
            """),
            {"cid": check_id, "tid": tenant_id},
        ).fetchone()
        if not check:
            raise HTTPException(status_code=404, detail="Check not found")

        url = body.target_url or check.target_url
        if url and url != check.target_url:
            db.execute(
                text("UPDATE compliance_evidence_checks SET target_url = :u WHERE id = CAST(:cid AS uuid)"),
                {"u": url, "cid": check_id},
            )

        status = "manual_review_required"
        message = "Dieser Check-Typ erfordert manuelle Pruefung."

        if check.check_code == "cra_security_txt" and url:
            base = url.rstrip("/")
            probe_url = f"{base}/.well-known/security.txt"
            try:
                async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
                    resp = await client.get(probe_url)
                if resp.status_code != 200:
                    status = "fail"
                    message = f"GET {probe_url} -> HTTP {resp.status_code}"
                else:
                    has_contact = any(
                        line.strip().lower().startswith("contact:")
                        for line in resp.text.splitlines()
                    )
                    if has_contact:
                        status = "pass"
                        message = f"security.txt vorhanden mit Contact-Eintrag ({len(resp.text)} bytes)"
                    else:
                        status = "fail"
                        message = "security.txt vorhanden, aber KEIN 'Contact:'-Eintrag"
            except Exception as e:
                status = "fail"
                message = f"Fetch error: {e}"

        elif check.check_code == "cra_tls_cert_check" and url:
            try:
                host = url.replace("https://", "").replace("http://", "").split("/")[0].split(":")[0]
                ctx = ssl.create_default_context()
                with socket.create_connection((host, 443), timeout=10) as sock:
                    with ctx.wrap_socket(sock, server_hostname=host) as ssock:
                        cert = ssock.getpeercert()
                        proto = ssock.version()
                if proto and proto >= "TLSv1.2":
                    status = "pass"
                    message = f"TLS {proto} ok. Issuer: {dict(x[0] for x in cert.get('issuer', [])).get('organizationName', 'unknown')}"
                else:
                    status = "fail"
                    message = f"TLS-Version zu alt: {proto}"
            except Exception as e:
                status = "fail"
                message = f"TLS error: {e}"

        # Persist result
        db.execute(
            text("""
                INSERT INTO compliance_evidence_check_results
                    (check_id, tenant_id, project_id, status, message, ran_at)
                VALUES
                    (CAST(:cid AS uuid), CAST(:tid AS uuid),
                     (SELECT project_id FROM compliance_evidence_checks WHERE id = CAST(:cid AS uuid)),
                     :status, :msg, NOW())
            """),
            {"cid": check_id, "tid": tenant_id, "status": status, "msg": message},
        )
        db.execute(
            text("""
                UPDATE compliance_evidence_checks
                SET last_run_at = NOW()
                WHERE id = CAST(:cid AS uuid)
            """),
            {"cid": check_id},
        )
        db.commit()
        return {
            "check_id": check_id,
            "check_code": check.check_code,
            "status": status,
            "message": message,
            "target_url": url,
        }
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


# =============================================================================
# PHASE 4: Vulnerability Disclosure + Post-Market Monitoring
# =============================================================================

VULN_STATUS_WHITELIST = {"reported", "triaged", "patched", "disclosed", "withdrawn"}


class CreateVulnRequest(BaseModel):
    title: str
    description: str = ""
    cve_id: Optional[str] = None
    severity: Optional[str] = None  # LOW | MEDIUM | HIGH | CRITICAL
    cvss_score: Optional[float] = None
    affected_components: list[str] = []
    reporter_source: str = "internal"
    reporter_contact: Optional[str] = None
    notes: str = ""


class UpdateVulnRequest(BaseModel):
    title: Optional[str] = None
    description: Optional[str] = None
    cve_id: Optional[str] = None
    severity: Optional[str] = None
    cvss_score: Optional[float] = None
    affected_components: Optional[list[str]] = None
    reporter_source: Optional[str] = None
    reporter_contact: Optional[str] = None
    notes: Optional[str] = None
    status: Optional[str] = None
    embargo_until: Optional[str] = None  # ISO datetime
    # Lifecycle timestamps — clients can set these explicitly or via status transition
    triaged_at: Optional[str] = None
    patched_at: Optional[str] = None
    disclosed_at: Optional[str] = None
    reported_to_enisa_at: Optional[str] = None
    detailed_report_at: Optional[str] = None


def _vuln_to_dict(row) -> dict:
    def _iso(v):
        return v.isoformat() if v else None
    components = row.affected_components
    if isinstance(components, str):
        try:
            components = json.loads(components)
        except Exception:
            components = []
    return {
        "id": str(row.id),
        "cra_project_id": str(row.cra_project_id),
        "cve_id": row.cve_id,
        "title": row.title,
        "description": row.description or "",
        "severity": row.severity,
        "cvss_score": float(row.cvss_score) if row.cvss_score is not None else None,
        "affected_components": components or [],
        "reporter_source": row.reporter_source or "internal",
        "reporter_contact": row.reporter_contact,
        "discovered_at": _iso(row.discovered_at),
        "triaged_at": _iso(row.triaged_at),
        "patched_at": _iso(row.patched_at),
        "disclosed_at": _iso(row.disclosed_at),
        "embargo_until": _iso(row.embargo_until),
        "reported_to_enisa_at": _iso(row.reported_to_enisa_at),
        "detailed_report_at": _iso(row.detailed_report_at),
        "status": row.status,
        "notes": row.notes or "",
        "created_at": _iso(row.created_at),
        "updated_at": _iso(row.updated_at),
    }


@router.post("/{project_id}/vulnerabilities", status_code=201)
async def create_vulnerability(
    project_id: str,
    body: CreateVulnRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Create a vulnerability record. discovered_at = now()."""
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")
        if body.severity and body.severity not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"):
            raise HTTPException(status_code=400, detail="Invalid severity")
        if body.cvss_score is not None and not (0.0 <= body.cvss_score <= 10.0):
            raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0")
        row = db.execute(
            text("""
                INSERT INTO compliance_cra_vulnerabilities
                    (cra_project_id, tenant_id, cve_id, title, description,
                     severity, cvss_score, affected_components,
                     reporter_source, reporter_contact, notes)
                VALUES
                    (:pid, :tid, :cve, :title, :desc,
                     :sev, :cvss, CAST(:comp AS jsonb),
                     :src, :rcontact, :notes)
                RETURNING *
            """),
            {
                "pid": project_id, "tid": tenant_id,
                "cve": body.cve_id, "title": body.title, "desc": body.description,
                "sev": body.severity, "cvss": body.cvss_score,
                "comp": json.dumps(body.affected_components),
                "src": body.reporter_source, "rcontact": body.reporter_contact,
                "notes": body.notes,
            },
        ).fetchone()
        db.commit()
        return _vuln_to_dict(row)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.get("/{project_id}/vulnerabilities")
async def list_vulnerabilities(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """List all vulnerabilities for this project, newest first."""
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")
        rows = db.execute(
            text("""
                SELECT * FROM compliance_cra_vulnerabilities
                WHERE cra_project_id = :pid AND tenant_id = :tid
                ORDER BY discovered_at DESC
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchall()
        items = [_vuln_to_dict(r) for r in rows]

        # Compliance summary
        critical_open = sum(1 for v in items if v["severity"] == "CRITICAL" and v["status"] in ("reported", "triaged"))
        breached_24h = 0
        breached_72h = 0
        from datetime import datetime, timezone
        now = datetime.now(timezone.utc)
        for v in items:
            if not v["discovered_at"]:
                continue
            disc = datetime.fromisoformat(v["discovered_at"])
            age_hours = (now - disc).total_seconds() / 3600
            if age_hours > 24 and not v["reported_to_enisa_at"]:
                breached_24h += 1
            if age_hours > 72 and not v["detailed_report_at"]:
                breached_72h += 1
        return {
            "project_id": project_id,
            "total": len(items),
            "summary": {
                "critical_open": critical_open,
                "breached_24h_reporting": breached_24h,
                "breached_72h_reporting": breached_72h,
                "by_status": {s: sum(1 for v in items if v["status"] == s) for s in VULN_STATUS_WHITELIST},
            },
            "items": items,
        }
    finally:
        db.close()


@router.patch("/vulnerabilities/{vuln_id}")
async def update_vulnerability(
    vuln_id: str,
    body: UpdateVulnRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Update vuln fields incl. status transition + lifecycle timestamps."""
    from datetime import datetime
    db = SessionLocal()
    try:
        updates: dict = {"vid": vuln_id, "tid": tenant_id}
        set_parts = ["updated_at = NOW()"]

        for field in (
            "title", "description", "cve_id", "severity", "cvss_score",
            "reporter_source", "reporter_contact", "notes", "status",
        ):
            val = getattr(body, field)
            if val is None:
                continue
            if field == "status" and val not in VULN_STATUS_WHITELIST:
                raise HTTPException(status_code=400, detail=f"Invalid status. Allowed: {sorted(VULN_STATUS_WHITELIST)}")
            if field == "severity" and val not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"):
                raise HTTPException(status_code=400, detail="Invalid severity")
            if field == "cvss_score" and not (0.0 <= float(val) <= 10.0):
                raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0")
            set_parts.append(f"{field} = :{field}")
            updates[field] = val

        if body.affected_components is not None:
            set_parts.append("affected_components = CAST(:comp AS jsonb)")
            updates["comp"] = json.dumps(body.affected_components)

        # Auto-set timestamps on status transitions
        # If client passes status='triaged' and triaged_at is None, set to NOW()
        if body.status == "triaged" and not body.triaged_at:
            set_parts.append("triaged_at = COALESCE(triaged_at, NOW())")
        if body.status == "patched" and not body.patched_at:
            set_parts.append("patched_at = COALESCE(patched_at, NOW())")
        if body.status == "disclosed" and not body.disclosed_at:
            set_parts.append("disclosed_at = COALESCE(disclosed_at, NOW())")

        for ts_field in ("triaged_at", "patched_at", "disclosed_at",
                         "reported_to_enisa_at", "detailed_report_at", "embargo_until"):
            val = getattr(body, ts_field)
            if val is None:
                continue
            try:
                datetime.fromisoformat(val.replace("Z", "+00:00"))
            except ValueError:
                raise HTTPException(status_code=400, detail=f"Invalid ISO datetime for {ts_field}")
            set_parts.append(f"{ts_field} = :{ts_field}")
            updates[ts_field] = val

        if len(set_parts) == 1:
            raise HTTPException(status_code=400, detail="No fields to update")

        row = db.execute(
            text(f"""
                UPDATE compliance_cra_vulnerabilities
                SET {', '.join(set_parts)}
                WHERE id = :vid AND tenant_id = :tid
                RETURNING *
            """),
            updates,
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="Vulnerability not found")
        db.commit()
        return _vuln_to_dict(row)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.delete("/vulnerabilities/{vuln_id}")
async def delete_vulnerability(
    vuln_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Mark vulnerability as withdrawn (soft delete)."""
    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                UPDATE compliance_cra_vulnerabilities
                SET status = 'withdrawn', updated_at = NOW()
                WHERE id = :vid AND tenant_id = :tid AND status != 'withdrawn'
                RETURNING id
            """),
            {"vid": vuln_id, "tid": tenant_id},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="Vulnerability not found or already withdrawn")
        db.commit()
        return {"success": True, "id": str(row.id), "status": "withdrawn"}
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.get("/{project_id}/monitoring")
async def post_market_monitoring(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Combined Post-Market view: CRA timeline + vuln summary + checklist progress."""
    from datetime import datetime, timezone
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")

        vulns = db.execute(
            text("""
                SELECT id, status, severity, discovered_at,
                       reported_to_enisa_at, detailed_report_at
                FROM compliance_cra_vulnerabilities
                WHERE cra_project_id = :pid AND tenant_id = :tid AND status != 'withdrawn'
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchall()

        sbom_count = db.execute(
            text("SELECT count(*) FROM compliance_cra_sboms WHERE cra_project_id = :pid AND tenant_id = :tid"),
            {"pid": project_id, "tid": tenant_id},
        ).scalar()

        checks_count = db.execute(
            text("""
                SELECT count(*) FROM compliance_evidence_checks
                WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid AND check_code LIKE 'cra_%'
            """),
            {"tid": tenant_id, "pid": project_id},
        ).scalar()

        now = datetime.now(timezone.utc)
        breached_24h = 0
        breached_72h = 0
        for v in vulns:
            if not v.discovered_at:
                continue
            age = (now - v.discovered_at).total_seconds() / 3600
            if age > 24 and not v.reported_to_enisa_at:
                breached_24h += 1
            if age > 72 and not v.detailed_report_at:
                breached_72h += 1

        checklist = [
            {"item": "SBOM hochgeladen", "done": (sbom_count or 0) > 0,
             "href_suffix": "sbom"},
            {"item": "Automatisierte Checks konfiguriert", "done": (checks_count or 0) > 0,
             "href_suffix": "checks"},
            {"item": "Vulnerability-Tracking aktiv", "done": len(vulns) > 0,
             "href_suffix": "vuln"},
            {"item": "Keine 24h-Reporting-Pflichten ueberzogen", "done": breached_24h == 0,
             "href_suffix": "vuln"},
            {"item": "Keine 72h-Reporting-Pflichten ueberzogen", "done": breached_72h == 0,
             "href_suffix": "vuln"},
        ]

        return {
            "project_id": project_id,
            "deadlines": DEADLINES,
            "summary": {
                "active_vulns": len(vulns),
                "critical_vulns": sum(1 for v in vulns if v.severity == "CRITICAL"),
                "high_vulns": sum(1 for v in vulns if v.severity == "HIGH"),
                "breached_24h_reporting": breached_24h,
                "breached_72h_reporting": breached_72h,
                "sbom_versions": sbom_count or 0,
                "configured_checks": checks_count or 0,
            },
            "post_market_checklist": checklist,
        }
    finally:
        db.close()


# =============================================================================
# PHASE 5: Document Generation (DoC, Technical Doc, CVD Policy, Update Policy, SBOM Report)
# =============================================================================

from .cra_doc_templates import DOC_GENERATORS, DOC_TYPE_LABELS  # noqa: E402

DOC_STATUS_WHITELIST = {"draft", "reviewed", "approved", "superseded"}


class GenerateDocRequest(BaseModel):
    doc_type: str
    manufacturer: Optional[str] = None
    notified_body: Optional[str] = None
    security_contact: Optional[str] = None


class ApproveDocRequest(BaseModel):
    signed_by: str
    status: str = "approved"


def _doc_row_to_dict(row) -> dict:
    return {
        "id": str(row.id),
        "cra_project_id": str(row.cra_project_id),
        "doc_type": row.doc_type,
        "doc_type_label": DOC_TYPE_LABELS.get(row.doc_type, row.doc_type),
        "title": row.title,
        "content_md": row.content_md,
        "version": row.version,
        "requirements_coverage": (
            row.requirements_coverage
            if isinstance(row.requirements_coverage, dict)
            else json.loads(row.requirements_coverage or "{}")
        ),
        "status": row.status,
        "signed_by": row.signed_by,
        "signed_at": row.signed_at.isoformat() if row.signed_at else None,
        "generated_at": row.generated_at.isoformat() if row.generated_at else None,
        "superseded_at": row.superseded_at.isoformat() if row.superseded_at else None,
    }


@router.post("/{project_id}/documents/generate", status_code=201)
async def generate_document(
    project_id: str,
    body: GenerateDocRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Generate a document of the given type from current project state."""
    if body.doc_type not in DOC_GENERATORS:
        raise HTTPException(
            status_code=400,
            detail=f"Unknown doc_type. Allowed: {sorted(DOC_GENERATORS.keys())}",
        )

    db = SessionLocal()
    try:
        proj_row = db.execute(
            text("""
                SELECT * FROM compliance_cra_projects
                WHERE id = :pid AND tenant_id = :tid
            """),
            {"pid": project_id, "tid": tenant_id},
        ).fetchone()
        if not proj_row:
            raise HTTPException(status_code=404, detail="CRA project not found")

        project = _row_to_response(proj_row)

        # For SBOM report: fetch latest SBOM
        latest_sbom = None
        if body.doc_type == "doc_sbom_report":
            sbom_row = db.execute(
                text("""
                    SELECT id, filename, format, spec_version, component_count,
                           summary, scan_status, uploaded_at
                    FROM compliance_cra_sboms
                    WHERE cra_project_id = :pid AND tenant_id = :tid
                    ORDER BY uploaded_at DESC LIMIT 1
                """),
                {"pid": project_id, "tid": tenant_id},
            ).fetchone()
            if sbom_row:
                latest_sbom = {
                    "filename": sbom_row.filename,
                    "format": sbom_row.format,
                    "spec_version": sbom_row.spec_version,
                    "component_count": sbom_row.component_count,
                    "summary": sbom_row.summary if isinstance(sbom_row.summary, dict) else json.loads(sbom_row.summary or "{}"),
                    "scan_status": sbom_row.scan_status,
                    "uploaded_at": sbom_row.uploaded_at.isoformat() if sbom_row.uploaded_at else None,
                }

        # Invoke generator
        gen = DOC_GENERATORS[body.doc_type]
        kwargs: dict = {}
        if body.doc_type == "doc_eu_conformity":
            kwargs = {"manufacturer": body.manufacturer, "notified_body": body.notified_body}
        elif body.doc_type == "doc_cvd_policy":
            kwargs = {"security_contact": body.security_contact}
        elif body.doc_type == "doc_sbom_report":
            kwargs = {"latest_sbom": latest_sbom}
        title, content, coverage = gen(project, **kwargs)

        # Supersede previous versions of this doc_type
        db.execute(
            text("""
                UPDATE compliance_cra_documents
                SET status = 'superseded', superseded_at = NOW()
                WHERE cra_project_id = :pid AND tenant_id = :tid
                  AND doc_type = :dtype AND status != 'superseded'
            """),
            {"pid": project_id, "tid": tenant_id, "dtype": body.doc_type},
        )

        # Next version number
        next_ver = db.execute(
            text("""
                SELECT COALESCE(MAX(version), 0) + 1 FROM compliance_cra_documents
                WHERE cra_project_id = :pid AND doc_type = :dtype
            """),
            {"pid": project_id, "dtype": body.doc_type},
        ).scalar()

        # Snapshot project context for audit
        gen_context = {
            "project_status": project.get("status"),
            "classification": project.get("cra_classification"),
            "conformity_path": project.get("conformity_path"),
            "generated_for_version": next_ver,
        }

        row = db.execute(
            text("""
                INSERT INTO compliance_cra_documents
                    (cra_project_id, tenant_id, doc_type, title, content_md,
                     version, requirements_coverage, generation_context, status)
                VALUES
                    (:pid, :tid, :dtype, :title, :content,
                     :ver, CAST(:cov AS jsonb), CAST(:ctx AS jsonb), 'draft')
                RETURNING *
            """),
            {
                "pid": project_id, "tid": tenant_id,
                "dtype": body.doc_type, "title": title, "content": content,
                "ver": next_ver,
                "cov": json.dumps(coverage),
                "ctx": json.dumps(gen_context),
            },
        ).fetchone()
        db.commit()
        return _doc_row_to_dict(row)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


@router.get("/{project_id}/documents")
async def list_documents(
    project_id: str,
    tenant_id: str = Depends(get_tenant_id),
    include_superseded: bool = False,
):
    """List documents for a project. By default only latest/active version per type."""
    db = SessionLocal()
    try:
        if not _cra_project_exists(db, project_id, tenant_id):
            raise HTTPException(status_code=404, detail="CRA project not found")

        if include_superseded:
            rows = db.execute(
                text("""
                    SELECT * FROM compliance_cra_documents
                    WHERE cra_project_id = :pid AND tenant_id = :tid
                    ORDER BY doc_type, version DESC
                """),
                {"pid": project_id, "tid": tenant_id},
            ).fetchall()
        else:
            rows = db.execute(
                text("""
                    SELECT DISTINCT ON (doc_type) *
                    FROM compliance_cra_documents
                    WHERE cra_project_id = :pid AND tenant_id = :tid
                      AND status != 'superseded'
                    ORDER BY doc_type, version DESC
                """),
                {"pid": project_id, "tid": tenant_id},
            ).fetchall()

        # Show all doc types — even if not yet generated
        existing_types = {r.doc_type for r in rows}
        items = [_doc_row_to_dict(r) for r in rows]
        for doc_type, label in DOC_TYPE_LABELS.items():
            if doc_type not in existing_types:
                items.append({
                    "id": None,
                    "cra_project_id": project_id,
                    "doc_type": doc_type,
                    "doc_type_label": label,
                    "title": label,
                    "content_md": None,
                    "version": 0,
                    "requirements_coverage": {},
                    "status": "not_generated",
                    "signed_by": None,
                    "signed_at": None,
                    "generated_at": None,
                    "superseded_at": None,
                })
        items.sort(key=lambda x: x["doc_type"])
        return {"project_id": project_id, "total": len(items), "items": items}
    finally:
        db.close()


@router.get("/documents/{doc_id}")
async def get_document(
    doc_id: str,
    tenant_id: str = Depends(get_tenant_id),
):
    """Get full document content (incl. content_md)."""
    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                SELECT * FROM compliance_cra_documents
                WHERE id = :did AND tenant_id = :tid
            """),
            {"did": doc_id, "tid": tenant_id},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="Document not found")
        return _doc_row_to_dict(row)
    finally:
        db.close()


@router.post("/documents/{doc_id}/approve")
async def approve_document(
    doc_id: str,
    body: ApproveDocRequest,
    tenant_id: str = Depends(get_tenant_id),
):
    """Set status to reviewed/approved + signature."""
    if body.status not in DOC_STATUS_WHITELIST:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid status. Allowed: {sorted(DOC_STATUS_WHITELIST)}",
        )
    if not body.signed_by.strip():
        raise HTTPException(status_code=400, detail="signed_by required")

    db = SessionLocal()
    try:
        row = db.execute(
            text("""
                UPDATE compliance_cra_documents
                SET status = :status, signed_by = :signer, signed_at = NOW()
                WHERE id = :did AND tenant_id = :tid AND status != 'superseded'
                RETURNING *
            """),
            {"did": doc_id, "tid": tenant_id, "status": body.status, "signer": body.signed_by},
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="Document not found or already superseded")
        db.commit()
        return _doc_row_to_dict(row)
    except HTTPException:
        raise
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()
