//! `/api/v1/admin/apps/{id}/docs*` — read-only docs inspection.
//!
//! Mirrors `kv_api` / `files_api` so the `pic docs … --app` CLI (and a
//! future dashboard tab) can browse stored documents without a script.
//! The per-group equivalent shipped first (`group_blobs_api`); this is
//! its per-app counterpart, filling the one admin-read gap the other
//! data-plane services already covered. **Read-only by design** — docs
//! writes go through `docs::create/update/delete` in scripts, which emit
//! change events the trigger framework depends on; an admin write would
//! bypass that, so it is deliberately out of scope (matching kv/files).
//!
//! Two operations:
//!   * `GET /apps/{id}/docs?collection=<c>&cursor=&limit=` — list docs in
//!     a collection (cursor-paginated).
//!   * `GET /apps/{id}/docs/{collection}/{doc_id}` — fetch one document.
//!
//! Capability: `AppDocsRead`, resolved against the app loaded from the
//! path (same tier the SDK read path uses).

use std::sync::Arc;

use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::docs_repo::DocsRepo;

#[derive(Clone)]
pub struct DocsAdminState {
    pub docs: Arc<dyn DocsRepo>,
    pub apps: Arc<dyn AppRepository>,
    pub authz: Arc<dyn AuthzRepo>,
}

pub fn docs_admin_router(state: DocsAdminState) -> Router {
    Router::new()
        .route("/apps/{app_id}/docs", get(list_docs))
        .route("/apps/{app_id}/docs/{collection}/{doc_id}", get(get_doc))
        .with_state(state)
}

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

/// Mirrors the `group_blobs_api` docs shape so the CLI can share one
/// deserialize across `--app` and `--group`.
#[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<DocsAdminState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Query(q): Query<ListDocsQuery>,
) -> Result<Json<ListDocsResponse>, DocsApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppDocsRead(app_id),
    )
    .await?;
    let page = s
        .docs
        .list(
            app_id,
            &q.collection,
            q.cursor.as_deref(),
            q.limit.unwrap_or(0),
        )
        .await
        .map_err(|e| DocsApiError::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<DocsAdminState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, collection, doc_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>, DocsApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppDocsRead(app_id),
    )
    .await?;
    let id = doc_id.parse::<Uuid>().map_err(|_| DocsApiError::NotFound)?;
    let row = s
        .docs
        .get(app_id, &collection, id)
        .await
        .map_err(|e| DocsApiError::Backend(e.to_string()))?
        .ok_or(DocsApiError::NotFound)?;
    Ok(Json(json!({ "id": row.id.to_string(), "data": row.data })))
}

async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, DocsApiError> {
    crate::app_repo::resolve_app(apps, ident)
        .await
        .map_err(|e| DocsApiError::Backend(e.to_string()))?
        .map(|l| l.app.id)
        .ok_or(DocsApiError::AppNotFound)
}

#[derive(Debug, thiserror::Error)]
pub enum DocsApiError {
    #[error("app not found")]
    AppNotFound,
    #[error("document not found")]
    NotFound,
    #[error("forbidden")]
    Forbidden,
    #[error("authorization repo error: {0}")]
    AuthzRepo(String),
    #[error("docs backend: {0}")]
    Backend(String),
}

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

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