//! `/api/v1/admin/apps/{id}/files*` — minimal files admin endpoints
//! backing the dashboard's files view (v1.1.5).
//!
//! Two operations only, both operator-facing:
//!   * `GET  /apps/{id}/files?collection=<c>&cursor=&limit=` — list file
//!     metadata for a collection (cursor-paginated).
//!   * `DELETE /apps/{id}/files/{collection}/{file_id}` — remove a file.
//!
//! These talk to the `FilesRepo` directly (like `triggers_api` talks to
//! `TriggerRepo`), guarded by the same capability model as the SDK
//! (`AppFilesRead` / `AppFilesWrite`). **Admin deletes do NOT emit a
//! `files:delete` trigger event** — they're operator cleanup actions,
//! not script mutations (see HANDBACK §7). The capability binds to the
//! resource's `app_id` after the app is loaded.

use std::sync::Arc;

use axum::body::Body;
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::{validate_files_collection, AppId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::files_repo::{FilesRepo, FilesRepoError};

#[derive(Clone)]
pub struct FilesAdminState {
    pub files: Arc<dyn FilesRepo>,
    pub apps: Arc<dyn AppRepository>,
    pub authz: Arc<dyn AuthzRepo>,
}

pub fn files_admin_router(state: FilesAdminState) -> Router {
    Router::new()
        .route("/apps/{app_id}/files", get(list_files))
        .route(
            "/apps/{app_id}/files/{collection}/{file_id}",
            get(get_file).delete(delete_file),
        )
        .with_state(state)
}

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

#[derive(Debug, Serialize)]
struct FileMetaDto {
    id: String,
    collection: String,
    name: String,
    content_type: String,
    size: u64,
    checksum: String,
    created_at: String,
    updated_at: String,
}

#[derive(Debug, Serialize)]
struct ListFilesResponse {
    files: Vec<FileMetaDto>,
    next_cursor: Option<String>,
}

async fn list_files(
    State(s): State<FilesAdminState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Query(q): Query<ListFilesQuery>,
) -> Result<Json<ListFilesResponse>, FilesApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppFilesRead(app_id),
    )
    .await?;
    // Audit 2026-06-11 (F-FS-002) — the admin endpoints hit the repo
    // directly (not via FilesServiceImpl, which validates), so a
    // traversal-shaped collection would otherwise only be caught by the
    // repo's `guard_collection` as an opaque 500. Validate here for a
    // clean 422 and as the outer layer of the belt-and-suspenders guard.
    validate_files_collection(&q.collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
    let page = s
        .files
        .list(
            app_id,
            &q.collection,
            q.cursor.as_deref(),
            q.limit.unwrap_or(0),
        )
        .await?;
    let files = page
        .files
        .into_iter()
        .map(|m| FileMetaDto {
            id: m.id.to_string(),
            collection: m.collection,
            name: m.name,
            content_type: m.content_type,
            size: m.size,
            checksum: m.checksum,
            created_at: m.created_at.to_rfc3339(),
            updated_at: m.updated_at.to_rfc3339(),
        })
        .collect();
    Ok(Json(ListFilesResponse {
        files,
        next_cursor: page.next_cursor,
    }))
}

/// `GET /apps/{id}/files/{collection}/{file_id}` — stream the file's
/// bytes as a download. The dashboard's Download button hits this;
/// metadata is read via `FilesRepo::head`, bytes via `FilesRepo::get`
/// (which checksum-verifies on read).
///
/// Audit 2026-06-11 C-2 — response hardening. The previous handler
/// served `Content-Disposition: inline` with the user-supplied
/// `Content-Type` and no `nosniff` / CSP, so an SVG or HTML upload
/// rendered same-origin and hijacked the admin session. Now:
/// * `Content-Disposition: attachment` always.
/// * `Content-Type` re-sanitized to the storage allowlist as
///   belt-and-suspenders (`files_service::create` / `update` already
///   coerce at write, but older rows may pre-date that change).
/// * `X-Content-Type-Options: nosniff` blocks MIME-sniffing fallbacks.
/// * Restrictive CSP scopes any residual rendering attempt.
async fn get_file(
    State(s): State<FilesAdminState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, collection, file_id)): Path<(String, String, String)>,
) -> Result<Response, FilesApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppFilesRead(app_id),
    )
    .await?;
    validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
    let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
    let meta = s
        .files
        .head(app_id, &collection, id)
        .await?
        .ok_or(FilesApiError::NotFound)?;
    let bytes = s
        .files
        .get(app_id, &collection, id)
        .await?
        .ok_or(FilesApiError::NotFound)?;
    let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
    // The stored name is upload-supplied; sanitize to header-safe ASCII so the
    // response builder can never error on a control byte (which `.expect()`
    // below would turn into a per-request panic).
    let disposition = format!(
        "attachment; filename=\"{}\"",
        picloud_shared::sanitize_stored_filename(&meta.name)
    );
    let len = bytes.len();
    Ok(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(Body::from(bytes))
        .expect("build files download response"))
}

async fn delete_file(
    State(s): State<FilesAdminState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, collection, file_id)): Path<(String, String, String)>,
) -> Result<StatusCode, FilesApiError> {
    let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppFilesWrite(app_id),
    )
    .await?;
    validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
    let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
    if s.files.delete(app_id, &collection, id).await?.is_none() {
        return Err(FilesApiError::NotFound);
    }
    Ok(StatusCode::NO_CONTENT)
}

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

#[derive(Debug, thiserror::Error)]
pub enum FilesApiError {
    #[error("app not found")]
    AppNotFound,
    #[error("file not found")]
    NotFound,
    #[error("invalid request: {0}")]
    Invalid(String),
    #[error("forbidden")]
    Forbidden,
    #[error("authorization repo error: {0}")]
    AuthzRepo(String),
    #[error("files backend: {0}")]
    Backend(String),
}

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

impl From<AuthzError> for FilesApiError {
    fn from(e: AuthzError) -> Self {
        Self::AuthzRepo(e.to_string())
    }
}

impl From<FilesRepoError> for FilesApiError {
    fn from(e: FilesRepoError) -> Self {
        Self::Backend(e.to_string())
    }
}

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