pub mod chat;
pub mod dast;
pub mod graph;
pub mod pentest;

use std::sync::Arc;

#[allow(unused_imports)]
use axum::extract::{Extension, Path, Query};
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use mongodb::bson::doc;
use serde::{Deserialize, Serialize};

use compliance_core::models::*;

use crate::agent::ComplianceAgent;

#[derive(Deserialize)]
pub struct PaginationParams {
    #[serde(default = "default_page")]
    pub page: u64,
    #[serde(default = "default_limit")]
    pub limit: i64,
}

fn default_page() -> u64 {
    1
}
fn default_limit() -> i64 {
    50
}

#[derive(Deserialize)]
pub struct FindingsFilter {
    #[serde(default)]
    pub repo_id: Option<String>,
    #[serde(default)]
    pub severity: Option<String>,
    #[serde(default)]
    pub scan_type: Option<String>,
    #[serde(default)]
    pub status: Option<String>,
    #[serde(default)]
    pub q: Option<String>,
    #[serde(default)]
    pub sort_by: Option<String>,
    #[serde(default)]
    pub sort_order: Option<String>,
    #[serde(default = "default_page")]
    pub page: u64,
    #[serde(default = "default_limit")]
    pub limit: i64,
}

#[derive(Serialize)]
pub struct ApiResponse<T: Serialize> {
    pub data: T,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub total: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub page: Option<u64>,
}

#[derive(Serialize)]
pub struct OverviewStats {
    pub total_repositories: u64,
    pub total_findings: u64,
    pub critical_findings: u64,
    pub high_findings: u64,
    pub medium_findings: u64,
    pub low_findings: u64,
    pub total_sbom_entries: u64,
    pub total_cve_alerts: u64,
    pub total_issues: u64,
    pub recent_scans: Vec<ScanRun>,
}

#[derive(Deserialize)]
pub struct AddRepositoryRequest {
    pub name: String,
    pub git_url: String,
    #[serde(default = "default_branch")]
    pub default_branch: String,
    pub auth_token: Option<String>,
    pub auth_username: Option<String>,
    pub tracker_type: Option<TrackerType>,
    pub tracker_owner: Option<String>,
    pub tracker_repo: Option<String>,
    pub tracker_token: Option<String>,
    pub scan_schedule: Option<String>,
}

#[derive(Deserialize)]
pub struct UpdateRepositoryRequest {
    pub name: Option<String>,
    pub default_branch: Option<String>,
    pub auth_token: Option<String>,
    pub auth_username: Option<String>,
    pub tracker_type: Option<TrackerType>,
    pub tracker_owner: Option<String>,
    pub tracker_repo: Option<String>,
    pub tracker_token: Option<String>,
    pub scan_schedule: Option<String>,
}

fn default_branch() -> String {
    "main".to_string()
}

#[derive(Deserialize)]
pub struct UpdateStatusRequest {
    pub status: String,
}

#[derive(Deserialize)]
pub struct BulkUpdateStatusRequest {
    pub ids: Vec<String>,
    pub status: String,
}

#[derive(Deserialize)]
pub struct UpdateFeedbackRequest {
    pub feedback: String,
}

#[derive(Deserialize)]
pub struct SbomFilter {
    #[serde(default)]
    pub repo_id: Option<String>,
    #[serde(default)]
    pub package_manager: Option<String>,
    #[serde(default)]
    pub q: Option<String>,
    #[serde(default)]
    pub has_vulns: Option<bool>,
    #[serde(default)]
    pub license: Option<String>,
    #[serde(default = "default_page")]
    pub page: u64,
    #[serde(default = "default_limit")]
    pub limit: i64,
}

#[derive(Deserialize)]
pub struct SbomExportParams {
    pub repo_id: String,
    #[serde(default = "default_export_format")]
    pub format: String,
}

fn default_export_format() -> String {
    "cyclonedx".to_string()
}

#[derive(Deserialize)]
pub struct SbomDiffParams {
    pub repo_a: String,
    pub repo_b: String,
}

#[derive(Serialize)]
pub struct LicenseSummary {
    pub license: String,
    pub count: u64,
    pub is_copyleft: bool,
    pub packages: Vec<String>,
}

#[derive(Serialize)]
pub struct SbomDiffResult {
    pub only_in_a: Vec<SbomDiffEntry>,
    pub only_in_b: Vec<SbomDiffEntry>,
    pub version_changed: Vec<SbomVersionDiff>,
    pub common_count: u64,
}

#[derive(Serialize)]
pub struct SbomDiffEntry {
    pub name: String,
    pub version: String,
    pub package_manager: String,
}

#[derive(Serialize)]
pub struct SbomVersionDiff {
    pub name: String,
    pub package_manager: String,
    pub version_a: String,
    pub version_b: String,
}

type AgentExt = Extension<Arc<ComplianceAgent>>;
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;

#[tracing::instrument(skip_all)]
pub async fn health() -> Json<serde_json::Value> {
    Json(serde_json::json!({ "status": "ok" }))
}

#[tracing::instrument(skip_all)]
pub async fn stats_overview(Extension(agent): AgentExt) -> ApiResult<OverviewStats> {
    let db = &agent.db;

    let total_repositories = db
        .repositories()
        .count_documents(doc! {})
        .await
        .unwrap_or(0);
    let total_findings = db.findings().count_documents(doc! {}).await.unwrap_or(0);
    let critical_findings = db
        .findings()
        .count_documents(doc! { "severity": "critical" })
        .await
        .unwrap_or(0);
    let high_findings = db
        .findings()
        .count_documents(doc! { "severity": "high" })
        .await
        .unwrap_or(0);
    let medium_findings = db
        .findings()
        .count_documents(doc! { "severity": "medium" })
        .await
        .unwrap_or(0);
    let low_findings = db
        .findings()
        .count_documents(doc! { "severity": "low" })
        .await
        .unwrap_or(0);
    let total_sbom_entries = db
        .sbom_entries()
        .count_documents(doc! {})
        .await
        .unwrap_or(0);
    let total_cve_alerts = db.cve_alerts().count_documents(doc! {}).await.unwrap_or(0);
    let total_issues = db
        .tracker_issues()
        .count_documents(doc! {})
        .await
        .unwrap_or(0);

    let recent_scans: Vec<ScanRun> = match db
        .scan_runs()
        .find(doc! {})
        .sort(doc! { "started_at": -1 })
        .limit(10)
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch recent scans: {e}");
            Vec::new()
        }
    };

    Ok(Json(ApiResponse {
        data: OverviewStats {
            total_repositories,
            total_findings,
            critical_findings,
            high_findings,
            medium_findings,
            low_findings,
            total_sbom_entries,
            total_cve_alerts,
            total_issues,
            recent_scans,
        },
        total: None,
        page: None,
    }))
}

#[tracing::instrument(skip_all)]
pub async fn list_repositories(
    Extension(agent): AgentExt,
    Query(params): Query<PaginationParams>,
) -> ApiResult<Vec<TrackedRepository>> {
    let db = &agent.db;
    let skip = (params.page.saturating_sub(1)) * params.limit as u64;
    let total = db
        .repositories()
        .count_documents(doc! {})
        .await
        .unwrap_or(0);

    let repos = match db
        .repositories()
        .find(doc! {})
        .skip(skip)
        .limit(params.limit)
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch repositories: {e}");
            Vec::new()
        }
    };

    Ok(Json(ApiResponse {
        data: repos,
        total: Some(total),
        page: Some(params.page),
    }))
}

#[tracing::instrument(skip_all)]
pub async fn add_repository(
    Extension(agent): AgentExt,
    Json(req): Json<AddRepositoryRequest>,
) -> Result<Json<ApiResponse<TrackedRepository>>, (StatusCode, String)> {
    // Validate repository access before saving
    let creds = crate::pipeline::git::RepoCredentials {
        ssh_key_path: Some(agent.config.ssh_key_path.clone()),
        auth_token: req.auth_token.clone(),
        auth_username: req.auth_username.clone(),
    };

    if let Err(e) = crate::pipeline::git::GitOps::test_access(&req.git_url, &creds) {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("Cannot access repository: {e}"),
        ));
    }

    let mut repo = TrackedRepository::new(req.name, req.git_url);
    repo.default_branch = req.default_branch;
    repo.auth_token = req.auth_token;
    repo.auth_username = req.auth_username;
    repo.tracker_type = req.tracker_type;
    repo.tracker_owner = req.tracker_owner;
    repo.tracker_repo = req.tracker_repo;
    repo.tracker_token = req.tracker_token;
    repo.scan_schedule = req.scan_schedule;

    agent
        .db
        .repositories()
        .insert_one(&repo)
        .await
        .map_err(|_| {
            (
                StatusCode::CONFLICT,
                "Repository already exists".to_string(),
            )
        })?;

    Ok(Json(ApiResponse {
        data: repo,
        total: None,
        page: None,
    }))
}

#[tracing::instrument(skip_all, fields(repo_id = %id))]
pub async fn update_repository(
    Extension(agent): AgentExt,
    Path(id): Path<String>,
    Json(req): Json<UpdateRepositoryRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;

    let mut set_doc = doc! { "updated_at": mongodb::bson::DateTime::now() };

    if let Some(name) = &req.name {
        set_doc.insert("name", name);
    }
    if let Some(branch) = &req.default_branch {
        set_doc.insert("default_branch", branch);
    }
    if let Some(token) = &req.auth_token {
        set_doc.insert("auth_token", token);
    }
    if let Some(username) = &req.auth_username {
        set_doc.insert("auth_username", username);
    }
    if let Some(tracker_type) = &req.tracker_type {
        set_doc.insert("tracker_type", tracker_type.to_string());
    }
    if let Some(owner) = &req.tracker_owner {
        set_doc.insert("tracker_owner", owner);
    }
    if let Some(repo) = &req.tracker_repo {
        set_doc.insert("tracker_repo", repo);
    }
    if let Some(token) = &req.tracker_token {
        set_doc.insert("tracker_token", token);
    }
    if let Some(schedule) = &req.scan_schedule {
        set_doc.insert("scan_schedule", schedule);
    }

    let result = agent
        .db
        .repositories()
        .update_one(doc! { "_id": oid }, doc! { "$set": set_doc })
        .await
        .map_err(|e| {
            tracing::warn!("Failed to update repository: {e}");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    if result.matched_count == 0 {
        return Err(StatusCode::NOT_FOUND);
    }

    Ok(Json(serde_json::json!({ "status": "updated" })))
}

#[tracing::instrument(skip_all)]
pub async fn get_ssh_public_key(
    Extension(agent): AgentExt,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let public_path = format!("{}.pub", agent.config.ssh_key_path);
    let public_key = std::fs::read_to_string(&public_path).map_err(|_| StatusCode::NOT_FOUND)?;
    Ok(Json(serde_json::json!({ "public_key": public_key.trim() })))
}

#[tracing::instrument(skip_all, fields(repo_id = %id))]
pub async fn trigger_scan(
    Extension(agent): AgentExt,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let agent_clone = (*agent).clone();
    tokio::spawn(async move {
        if let Err(e) = agent_clone.run_scan(&id, ScanTrigger::Manual).await {
            tracing::error!("Manual scan failed for {id}: {e}");
        }
    });

    Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
}

/// Return the webhook secret for a repository (used by dashboard to display it)
pub async fn get_webhook_config(
    Extension(agent): AgentExt,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
    let repo = agent
        .db
        .repositories()
        .find_one(doc! { "_id": oid })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or(StatusCode::NOT_FOUND)?;

    let tracker_type = repo
        .tracker_type
        .as_ref()
        .map(|t| t.to_string())
        .unwrap_or_else(|| "gitea".to_string());

    Ok(Json(serde_json::json!({
        "webhook_secret": repo.webhook_secret,
        "tracker_type": tracker_type,
    })))
}

#[tracing::instrument(skip_all, fields(repo_id = %id))]
pub async fn delete_repository(
    Extension(agent): AgentExt,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
    let db = &agent.db;

    // Delete the repository
    let result = db
        .repositories()
        .delete_one(doc! { "_id": oid })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if result.deleted_count == 0 {
        return Err(StatusCode::NOT_FOUND);
    }

    // Cascade delete all related data
    let _ = db.findings().delete_many(doc! { "repo_id": &id }).await;
    let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await;
    let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await;
    let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await;
    let _ = db
        .tracker_issues()
        .delete_many(doc! { "repo_id": &id })
        .await;
    let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await;
    let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await;
    let _ = db.graph_builds().delete_many(doc! { "repo_id": &id }).await;
    let _ = db
        .impact_analyses()
        .delete_many(doc! { "repo_id": &id })
        .await;
    let _ = db
        .code_embeddings()
        .delete_many(doc! { "repo_id": &id })
        .await;
    let _ = db
        .embedding_builds()
        .delete_many(doc! { "repo_id": &id })
        .await;

    Ok(Json(serde_json::json!({ "status": "deleted" })))
}

#[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, severity = ?filter.severity, scan_type = ?filter.scan_type))]
pub async fn list_findings(
    Extension(agent): AgentExt,
    Query(filter): Query<FindingsFilter>,
) -> ApiResult<Vec<Finding>> {
    let db = &agent.db;
    let mut query = doc! {};
    if let Some(repo_id) = &filter.repo_id {
        query.insert("repo_id", repo_id);
    }
    if let Some(severity) = &filter.severity {
        query.insert("severity", severity);
    }
    if let Some(scan_type) = &filter.scan_type {
        query.insert("scan_type", scan_type);
    }
    if let Some(status) = &filter.status {
        query.insert("status", status);
    }
    // Text search across title, description, file_path, rule_id
    if let Some(q) = &filter.q {
        if !q.is_empty() {
            let regex = doc! { "$regex": q, "$options": "i" };
            query.insert(
                "$or",
                mongodb::bson::bson!([
                    { "title": regex.clone() },
                    { "description": regex.clone() },
                    { "file_path": regex.clone() },
                    { "rule_id": regex },
                ]),
            );
        }
    }

    // Dynamic sort
    let sort_field = filter.sort_by.as_deref().unwrap_or("created_at");
    let sort_dir: i32 = match filter.sort_order.as_deref() {
        Some("asc") => 1,
        _ => -1,
    };
    let sort_doc = doc! { sort_field: sort_dir };

    let skip = (filter.page.saturating_sub(1)) * filter.limit as u64;
    let total = db
        .findings()
        .count_documents(query.clone())
        .await
        .unwrap_or(0);

    let findings = match db
        .findings()
        .find(query)
        .sort(sort_doc)
        .skip(skip)
        .limit(filter.limit)
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch findings: {e}");
            Vec::new()
        }
    };

    Ok(Json(ApiResponse {
        data: findings,
        total: Some(total),
        page: Some(filter.page),
    }))
}

#[tracing::instrument(skip_all, fields(finding_id = %id))]
pub async fn get_finding(
    Extension(agent): AgentExt,
    Path(id): Path<String>,
) -> Result<Json<ApiResponse<Finding>>, StatusCode> {
    let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
    let finding = agent
        .db
        .findings()
        .find_one(doc! { "_id": oid })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or(StatusCode::NOT_FOUND)?;

    Ok(Json(ApiResponse {
        data: finding,
        total: None,
        page: None,
    }))
}

#[tracing::instrument(skip_all, fields(finding_id = %id))]
pub async fn update_finding_status(
    Extension(agent): AgentExt,
    Path(id): Path<String>,
    Json(req): Json<UpdateStatusRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;

    agent
        .db
        .findings()
        .update_one(
            doc! { "_id": oid },
            doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
        )
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(serde_json::json!({ "status": "updated" })))
}

#[tracing::instrument(skip_all)]
pub async fn bulk_update_finding_status(
    Extension(agent): AgentExt,
    Json(req): Json<BulkUpdateStatusRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let oids: Vec<mongodb::bson::oid::ObjectId> = req
        .ids
        .iter()
        .filter_map(|id| mongodb::bson::oid::ObjectId::parse_str(id).ok())
        .collect();

    if oids.is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }

    let result = agent
        .db
        .findings()
        .update_many(
            doc! { "_id": { "$in": oids } },
            doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
        )
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(
        serde_json::json!({ "status": "updated", "modified_count": result.modified_count }),
    ))
}

#[tracing::instrument(skip_all)]
pub async fn update_finding_feedback(
    Extension(agent): AgentExt,
    Path(id): Path<String>,
    Json(req): Json<UpdateFeedbackRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;

    agent
        .db
        .findings()
        .update_one(
            doc! { "_id": oid },
            doc! { "$set": { "developer_feedback": &req.feedback, "updated_at": mongodb::bson::DateTime::now() } },
        )
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(serde_json::json!({ "status": "updated" })))
}

#[tracing::instrument(skip_all)]
pub async fn sbom_filters(
    Extension(agent): AgentExt,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let db = &agent.db;

    let managers: Vec<String> = db
        .sbom_entries()
        .distinct("package_manager", doc! {})
        .await
        .unwrap_or_default()
        .into_iter()
        .filter_map(|v| v.as_str().map(|s| s.to_string()))
        .filter(|s| !s.is_empty() && s != "unknown" && s != "file")
        .collect();

    let licenses: Vec<String> = db
        .sbom_entries()
        .distinct("license", doc! {})
        .await
        .unwrap_or_default()
        .into_iter()
        .filter_map(|v| v.as_str().map(|s| s.to_string()))
        .filter(|s| !s.is_empty())
        .collect();

    Ok(Json(serde_json::json!({
        "package_managers": managers,
        "licenses": licenses,
    })))
}

#[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, package_manager = ?filter.package_manager))]
pub async fn list_sbom(
    Extension(agent): AgentExt,
    Query(filter): Query<SbomFilter>,
) -> ApiResult<Vec<SbomEntry>> {
    let db = &agent.db;
    let mut query = doc! {};

    if let Some(repo_id) = &filter.repo_id {
        query.insert("repo_id", repo_id);
    }
    if let Some(pm) = &filter.package_manager {
        query.insert("package_manager", pm);
    }
    if let Some(q) = &filter.q {
        if !q.is_empty() {
            query.insert("name", doc! { "$regex": q, "$options": "i" });
        }
    }
    if let Some(has_vulns) = filter.has_vulns {
        if has_vulns {
            query.insert("known_vulnerabilities", doc! { "$exists": true, "$ne": [] });
        } else {
            query.insert("known_vulnerabilities", doc! { "$size": 0 });
        }
    }
    if let Some(license) = &filter.license {
        query.insert("license", license);
    }

    let skip = (filter.page.saturating_sub(1)) * filter.limit as u64;
    let total = db
        .sbom_entries()
        .count_documents(query.clone())
        .await
        .unwrap_or(0);

    let entries = match db
        .sbom_entries()
        .find(query)
        .sort(doc! { "name": 1 })
        .skip(skip)
        .limit(filter.limit)
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch SBOM entries: {e}");
            Vec::new()
        }
    };

    Ok(Json(ApiResponse {
        data: entries,
        total: Some(total),
        page: Some(filter.page),
    }))
}

#[tracing::instrument(skip_all)]
pub async fn export_sbom(
    Extension(agent): AgentExt,
    Query(params): Query<SbomExportParams>,
) -> Result<impl IntoResponse, StatusCode> {
    let db = &agent.db;
    let entries: Vec<SbomEntry> = match db
        .sbom_entries()
        .find(doc! { "repo_id": &params.repo_id })
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch SBOM entries for export: {e}");
            Vec::new()
        }
    };

    let body = if params.format == "spdx" {
        // SPDX 2.3 format
        let packages: Vec<serde_json::Value> = entries
            .iter()
            .enumerate()
            .map(|(i, e)| {
                serde_json::json!({
                    "SPDXID": format!("SPDXRef-Package-{i}"),
                    "name": e.name,
                    "versionInfo": e.version,
                    "downloadLocation": "NOASSERTION",
                    "licenseConcluded": e.license.as_deref().unwrap_or("NOASSERTION"),
                    "externalRefs": e.purl.as_ref().map(|p| vec![serde_json::json!({
                        "referenceCategory": "PACKAGE-MANAGER",
                        "referenceType": "purl",
                        "referenceLocator": p,
                    })]).unwrap_or_default(),
                })
            })
            .collect();

        serde_json::json!({
            "spdxVersion": "SPDX-2.3",
            "dataLicense": "CC0-1.0",
            "SPDXID": "SPDXRef-DOCUMENT",
            "name": format!("sbom-{}", params.repo_id),
            "documentNamespace": format!("https://compliance-scanner/sbom/{}", params.repo_id),
            "packages": packages,
        })
    } else {
        // CycloneDX 1.5 format
        let components: Vec<serde_json::Value> = entries
            .iter()
            .map(|e| {
                let mut comp = serde_json::json!({
                    "type": "library",
                    "name": e.name,
                    "version": e.version,
                    "group": e.package_manager,
                });
                if let Some(purl) = &e.purl {
                    comp["purl"] = serde_json::Value::String(purl.clone());
                }
                if let Some(license) = &e.license {
                    comp["licenses"] = serde_json::json!([{ "license": { "id": license } }]);
                }
                if !e.known_vulnerabilities.is_empty() {
                    comp["vulnerabilities"] = serde_json::json!(
                        e.known_vulnerabilities.iter().map(|v| serde_json::json!({
                            "id": v.id,
                            "source": { "name": v.source },
                            "ratings": v.severity.as_ref().map(|s| vec![serde_json::json!({"severity": s})]).unwrap_or_default(),
                        })).collect::<Vec<_>>()
                    );
                }
                comp
            })
            .collect();

        serde_json::json!({
            "bomFormat": "CycloneDX",
            "specVersion": "1.5",
            "version": 1,
            "metadata": {
                "component": {
                    "type": "application",
                    "name": format!("repo-{}", params.repo_id),
                }
            },
            "components": components,
        })
    };

    let json_str =
        serde_json::to_string_pretty(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let filename = if params.format == "spdx" {
        format!("sbom-{}-spdx.json", params.repo_id)
    } else {
        format!("sbom-{}-cyclonedx.json", params.repo_id)
    };

    let disposition = format!("attachment; filename=\"{filename}\"");
    Ok((
        [
            (
                header::CONTENT_TYPE,
                header::HeaderValue::from_static("application/json"),
            ),
            (
                header::CONTENT_DISPOSITION,
                header::HeaderValue::from_str(&disposition)
                    .unwrap_or_else(|_| header::HeaderValue::from_static("attachment")),
            ),
        ],
        json_str,
    ))
}

const COPYLEFT_LICENSES: &[&str] = &[
    "GPL-2.0",
    "GPL-2.0-only",
    "GPL-2.0-or-later",
    "GPL-3.0",
    "GPL-3.0-only",
    "GPL-3.0-or-later",
    "AGPL-3.0",
    "AGPL-3.0-only",
    "AGPL-3.0-or-later",
    "LGPL-2.1",
    "LGPL-2.1-only",
    "LGPL-2.1-or-later",
    "LGPL-3.0",
    "LGPL-3.0-only",
    "LGPL-3.0-or-later",
    "MPL-2.0",
];

#[tracing::instrument(skip_all)]
pub async fn license_summary(
    Extension(agent): AgentExt,
    Query(params): Query<SbomFilter>,
) -> ApiResult<Vec<LicenseSummary>> {
    let db = &agent.db;
    let mut query = doc! {};
    if let Some(repo_id) = &params.repo_id {
        query.insert("repo_id", repo_id);
    }

    let entries: Vec<SbomEntry> = match db.sbom_entries().find(query).await {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch SBOM entries for license summary: {e}");
            Vec::new()
        }
    };

    let mut license_map: std::collections::HashMap<String, Vec<String>> =
        std::collections::HashMap::new();
    for entry in &entries {
        let lic = entry.license.as_deref().unwrap_or("Unknown").to_string();
        license_map.entry(lic).or_default().push(entry.name.clone());
    }

    let mut summaries: Vec<LicenseSummary> = license_map
        .into_iter()
        .map(|(license, packages)| {
            let is_copyleft = COPYLEFT_LICENSES
                .iter()
                .any(|c| license.to_uppercase().contains(&c.to_uppercase()));
            LicenseSummary {
                license,
                count: packages.len() as u64,
                is_copyleft,
                packages,
            }
        })
        .collect();
    summaries.sort_by(|a, b| b.count.cmp(&a.count));

    Ok(Json(ApiResponse {
        data: summaries,
        total: None,
        page: None,
    }))
}

#[tracing::instrument(skip_all)]
pub async fn sbom_diff(
    Extension(agent): AgentExt,
    Query(params): Query<SbomDiffParams>,
) -> ApiResult<SbomDiffResult> {
    let db = &agent.db;

    let entries_a: Vec<SbomEntry> = match db
        .sbom_entries()
        .find(doc! { "repo_id": &params.repo_a })
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch SBOM entries for repo_a: {e}");
            Vec::new()
        }
    };

    let entries_b: Vec<SbomEntry> = match db
        .sbom_entries()
        .find(doc! { "repo_id": &params.repo_b })
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch SBOM entries for repo_b: {e}");
            Vec::new()
        }
    };

    // Build maps by (name, package_manager) -> version
    let map_a: std::collections::HashMap<(String, String), String> = entries_a
        .iter()
        .map(|e| {
            (
                (e.name.clone(), e.package_manager.clone()),
                e.version.clone(),
            )
        })
        .collect();
    let map_b: std::collections::HashMap<(String, String), String> = entries_b
        .iter()
        .map(|e| {
            (
                (e.name.clone(), e.package_manager.clone()),
                e.version.clone(),
            )
        })
        .collect();

    let mut only_in_a = Vec::new();
    let mut version_changed = Vec::new();
    let mut common_count: u64 = 0;

    for (key, ver_a) in &map_a {
        match map_b.get(key) {
            None => only_in_a.push(SbomDiffEntry {
                name: key.0.clone(),
                version: ver_a.clone(),
                package_manager: key.1.clone(),
            }),
            Some(ver_b) if ver_a != ver_b => {
                version_changed.push(SbomVersionDiff {
                    name: key.0.clone(),
                    package_manager: key.1.clone(),
                    version_a: ver_a.clone(),
                    version_b: ver_b.clone(),
                });
            }
            Some(_) => common_count += 1,
        }
    }

    let only_in_b: Vec<SbomDiffEntry> = map_b
        .iter()
        .filter(|(key, _)| !map_a.contains_key(key))
        .map(|(key, ver)| SbomDiffEntry {
            name: key.0.clone(),
            version: ver.clone(),
            package_manager: key.1.clone(),
        })
        .collect();

    Ok(Json(ApiResponse {
        data: SbomDiffResult {
            only_in_a,
            only_in_b,
            version_changed,
            common_count,
        },
        total: None,
        page: None,
    }))
}

#[tracing::instrument(skip_all)]
pub async fn list_issues(
    Extension(agent): AgentExt,
    Query(params): Query<PaginationParams>,
) -> ApiResult<Vec<TrackerIssue>> {
    let db = &agent.db;
    let skip = (params.page.saturating_sub(1)) * params.limit as u64;
    let total = db
        .tracker_issues()
        .count_documents(doc! {})
        .await
        .unwrap_or(0);

    let issues = match db
        .tracker_issues()
        .find(doc! {})
        .sort(doc! { "created_at": -1 })
        .skip(skip)
        .limit(params.limit)
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch tracker issues: {e}");
            Vec::new()
        }
    };

    Ok(Json(ApiResponse {
        data: issues,
        total: Some(total),
        page: Some(params.page),
    }))
}

#[tracing::instrument(skip_all)]
pub async fn list_scan_runs(
    Extension(agent): AgentExt,
    Query(params): Query<PaginationParams>,
) -> ApiResult<Vec<ScanRun>> {
    let db = &agent.db;
    let skip = (params.page.saturating_sub(1)) * params.limit as u64;
    let total = db.scan_runs().count_documents(doc! {}).await.unwrap_or(0);

    let scans = match db
        .scan_runs()
        .find(doc! {})
        .sort(doc! { "started_at": -1 })
        .skip(skip)
        .limit(params.limit)
        .await
    {
        Ok(cursor) => collect_cursor_async(cursor).await,
        Err(e) => {
            tracing::warn!("Failed to fetch scan runs: {e}");
            Vec::new()
        }
    };

    Ok(Json(ApiResponse {
        data: scans,
        total: Some(total),
        page: Some(params.page),
    }))
}

pub(crate) async fn collect_cursor_async<T: serde::de::DeserializeOwned + Unpin + Send>(
    mut cursor: mongodb::Cursor<T>,
) -> Vec<T> {
    use futures_util::StreamExt;
    let mut items = Vec::new();
    while let Some(result) = cursor.next().await {
        match result {
            Ok(item) => items.push(item),
            Err(e) => tracing::warn!("Failed to deserialize document: {e}"),
        }
    }
    items
}
