//! `/api/v1/admin/groups/{id}/{kv,docs,files}*` — read-only operator inspection
//! of a group's §11.6 SHARED collections (M4). Mirrors the per-app `kv_api` /
//! `files_api` admin surface for groups so `pic {kv,docs,files} ls --group` (and
//! a future dashboard tab) can browse shared data without a script.
//!
//! **Read-only by design** — shared writes go through the SDK
//! (`kv::shared_collection(...)` etc.), which run the reads-open / writes-authed
//! authz + fire `shared = true` triggers; an admin write would bypass both. The
//! deferral (operator write/delete on shared blobs) stands.
//!
//! Capabilities: `GroupKvRead` / `GroupDocsRead` / `GroupFilesRead`, resolved
//! against the group loaded from the path (the same tier the SDK read path uses).

use std::sync::Arc;

use axum::extract::{Path, Query, State};
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{GroupId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::group_dead_letter_repo::GroupDeadLetterRepo;
use crate::group_docs_repo::GroupDocsRepo;
use crate::group_files_repo::GroupFilesRepo;
use crate::group_kv_repo::GroupKvRepo;
use crate::group_repo::GroupRepository;

/// Default/max page size for the dead-letters listing (an operator view, not a
/// bulk export).
const DEAD_LETTERS_LIMIT_DEFAULT: i64 = 100;
const DEAD_LETTERS_LIMIT_MAX: i64 = 500;

#[derive(Clone)]
pub struct GroupBlobsState {
    pub kv: Arc<dyn GroupKvRepo>,
    pub docs: Arc<dyn GroupDocsRepo>,
    pub files: Arc<dyn GroupFilesRepo>,
    pub dead_letters: Arc<dyn GroupDeadLetterRepo>,
    pub groups: Arc<dyn GroupRepository>,
    pub authz: Arc<dyn AuthzRepo>,
}

pub fn group_blobs_router(state: GroupBlobsState) -> Router {
    Router::new()
        .route("/groups/{id}/kv", get(list_kv))
        .route("/groups/{id}/kv/{collection}/{key}", get(get_kv))
        .route("/groups/{id}/docs", get(list_docs))
        .route("/groups/{id}/docs/{collection}/{doc_id}", get(get_doc))
        .route("/groups/{id}/files", get(list_files))
        .route("/groups/{id}/files/{collection}/{file_id}", get(get_file))
        .route("/groups/{id}/dead-letters", get(list_dead_letters))
        .with_state(state)
}

#[derive(Debug, Deserialize)]
pub struct ListQuery {
    pub collection: String,
    #[serde(default)]
    pub cursor: Option<String>,
    #[serde(default)]
    pub limit: Option<u32>,
}

#[derive(Debug, Serialize)]
struct ListKeysResponse {
    keys: Vec<String>,
    next_cursor: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct DeadLettersQuery {
    #[serde(default)]
    pub unresolved: bool,
    #[serde(default)]
    pub limit: Option<u32>,
}

/// One group dead-letter, as returned by the operator listing.
#[derive(Debug, Serialize)]
struct DeadLetterDto {
    id: Uuid,
    collection: String,
    source: String,
    op: String,
    attempt_count: u32,
    last_error: String,
    created_at: String,
    resolved_at: Option<String>,
    payload: serde_json::Value,
}

impl From<crate::group_dead_letter_repo::GroupDeadLetterRow> for DeadLetterDto {
    fn from(r: crate::group_dead_letter_repo::GroupDeadLetterRow) -> Self {
        Self {
            id: r.id.into_inner(),
            collection: r.collection,
            source: r.source,
            op: r.op,
            attempt_count: r.attempt_count,
            last_error: r.last_error,
            created_at: r.created_at.to_rfc3339(),
            resolved_at: r.resolved_at.map(|t| t.to_rfc3339()),
            payload: r.payload,
        }
    }
}

async fn list_kv(
    State(s): State<GroupBlobsState>,
    Extension(principal): Extension<Principal>,
    Path(ident): Path<String>,
    Query(q): Query<ListQuery>,
) -> Result<Json<ListKeysResponse>, GroupBlobsError> {
    let group_id = resolve_group(&*s.groups, &ident).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupKvRead(group_id),
    )
    .await?;
    let page =
        s.kv.list(
            group_id,
            &q.collection,
            q.cursor.as_deref(),
            q.limit.unwrap_or(0),
        )
        .await
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
    Ok(Json(ListKeysResponse {
        keys: page.keys,
        next_cursor: page.next_cursor,
    }))
}

async fn get_kv(
    State(s): State<GroupBlobsState>,
    Extension(principal): Extension<Principal>,
    Path((ident, collection, key)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
    let group_id = resolve_group(&*s.groups, &ident).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupKvRead(group_id),
    )
    .await?;
    let value =
        s.kv.get(group_id, &collection, &key)
            .await
            .map_err(|e| GroupBlobsError::Backend(e.to_string()))?
            .ok_or(GroupBlobsError::NotFound)?;
    Ok(Json(json!({ "value": value })))
}

#[derive(Debug, Serialize)]
struct DocEntry {
    id: String,
    data: serde_json::Value,
}

#[derive(Debug, Serialize)]
struct ListDocsResponse {
    docs: Vec<DocEntry>,
    next_cursor: Option<String>,
}

async fn list_docs(
    State(s): State<GroupBlobsState>,
    Extension(principal): Extension<Principal>,
    Path(ident): Path<String>,
    Query(q): Query<ListQuery>,
) -> Result<Json<ListDocsResponse>, GroupBlobsError> {
    let group_id = resolve_group(&*s.groups, &ident).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupDocsRead(group_id),
    )
    .await?;
    let page = s
        .docs
        .list(
            group_id,
            &q.collection,
            q.cursor.as_deref(),
            q.limit.unwrap_or(0),
        )
        .await
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
    Ok(Json(ListDocsResponse {
        docs: page
            .docs
            .into_iter()
            .map(|d| DocEntry {
                id: d.id.to_string(),
                data: d.data,
            })
            .collect(),
        next_cursor: page.next_cursor,
    }))
}

async fn get_doc(
    State(s): State<GroupBlobsState>,
    Extension(principal): Extension<Principal>,
    Path((ident, collection, doc_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
    let group_id = resolve_group(&*s.groups, &ident).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupDocsRead(group_id),
    )
    .await?;
    let id = doc_id
        .parse::<Uuid>()
        .map_err(|_| GroupBlobsError::NotFound)?;
    let row = s
        .docs
        .get(group_id, &collection, id)
        .await
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))?
        .ok_or(GroupBlobsError::NotFound)?;
    Ok(Json(json!({ "id": row.id.to_string(), "data": row.data })))
}

async fn list_files(
    State(s): State<GroupBlobsState>,
    Extension(principal): Extension<Principal>,
    Path(ident): Path<String>,
    Query(q): Query<ListQuery>,
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
    let group_id = resolve_group(&*s.groups, &ident).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupFilesRead(group_id),
    )
    .await?;
    let page = s
        .files
        .list(
            group_id,
            &q.collection,
            q.cursor.as_deref(),
            q.limit.unwrap_or(0),
        )
        .await
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
    // FileMeta is Serialize; return the metadata list + cursor.
    Ok(Json(json!({
        "files": page.files,
        "next_cursor": page.next_cursor,
    })))
}

async fn get_file(
    State(s): State<GroupBlobsState>,
    Extension(principal): Extension<Principal>,
    Path((ident, collection, file_id)): Path<(String, String, String)>,
) -> Result<Response, GroupBlobsError> {
    let group_id = resolve_group(&*s.groups, &ident).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupFilesRead(group_id),
    )
    .await?;
    let id = file_id
        .parse::<Uuid>()
        .map_err(|_| GroupBlobsError::NotFound)?;
    let meta = s
        .files
        .head(group_id, &collection, id)
        .await
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))?
        .ok_or(GroupBlobsError::NotFound)?;
    let bytes = s
        .files
        .get(group_id, &collection, id)
        .await
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))?
        .ok_or(GroupBlobsError::NotFound)?;
    // Same download hardening as the per-app path (`files_api::get_file`): a
    // sanitized content-type, forced `attachment` disposition with a header-safe
    // filename, plus nosniff/CSP so a stored HTML/SVG shared file can't render
    // inline (stored-XSS) and no attacker-influenced byte can panic the builder.
    let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
    let disposition = format!(
        "attachment; filename=\"{}\"",
        picloud_shared::sanitize_stored_filename(&meta.name)
    );
    let len = bytes.len();
    Response::builder()
        .status(StatusCode::OK)
        .header(CONTENT_TYPE, safe_ct)
        .header(CONTENT_DISPOSITION, disposition)
        .header(CONTENT_LENGTH, len)
        .header("X-Content-Type-Options", "nosniff")
        .header(
            "Content-Security-Policy",
            "default-src 'none'; sandbox; frame-ancestors 'none'",
        )
        .header("Referrer-Policy", "no-referrer")
        .body(axum::body::Body::from(bytes))
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))
}

/// §11.6 D3: list a group's shared-queue dead-letters (newest-first). Guarded by
/// `GroupKvRead` (the same read tier as the shared collections above; no new
/// capability). `?unresolved=true` filters to still-open rows; `?limit=` caps
/// the page (default 100, max 500).
async fn list_dead_letters(
    State(s): State<GroupBlobsState>,
    Extension(principal): Extension<Principal>,
    Path(ident): Path<String>,
    Query(q): Query<DeadLettersQuery>,
) -> Result<Json<Vec<DeadLetterDto>>, GroupBlobsError> {
    let group_id = resolve_group(&*s.groups, &ident).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupKvRead(group_id),
    )
    .await?;
    let limit = q.limit.map_or(DEAD_LETTERS_LIMIT_DEFAULT, |n| {
        i64::from(n).clamp(1, DEAD_LETTERS_LIMIT_MAX)
    });
    let rows = s
        .dead_letters
        .list_for_group(group_id, q.unresolved, limit)
        .await
        .map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
    Ok(Json(rows.into_iter().map(DeadLetterDto::from).collect()))
}

async fn resolve_group(
    groups: &dyn GroupRepository,
    ident: &str,
) -> Result<GroupId, GroupBlobsError> {
    let found = if let Ok(uuid) = ident.parse::<Uuid>() {
        groups
            .get_by_id(uuid.into())
            .await
            .map_err(|e| GroupBlobsError::Backend(e.to_string()))?
    } else {
        groups
            .get_by_slug(ident)
            .await
            .map_err(|e| GroupBlobsError::Backend(e.to_string()))?
    };
    found.map(|g| g.id).ok_or(GroupBlobsError::GroupNotFound)
}

#[derive(Debug, thiserror::Error)]
pub enum GroupBlobsError {
    #[error("group not found")]
    GroupNotFound,
    #[error("not found")]
    NotFound,
    #[error("forbidden")]
    Forbidden,
    #[error("authorization repo error: {0}")]
    AuthzRepo(String),
    #[error("backend: {0}")]
    Backend(String),
}

impl From<AuthzDenied> for GroupBlobsError {
    fn from(d: AuthzDenied) -> Self {
        match d {
            AuthzDenied::Denied => Self::Forbidden,
            AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
        }
    }
}

impl IntoResponse for GroupBlobsError {
    fn into_response(self) -> Response {
        use axum::http::StatusCode;
        let (status, body) = match &self {
            Self::GroupNotFound | Self::NotFound => {
                (StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
            }
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::AuthzRepo(e) | Self::Backend(e) => {
                tracing::error!(error = %e, "group blobs admin error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}
