//! Chapter list + get + multipart upload.
//!
//! Reads are public. Uploads (POST) require auth and use the same
//! multipart conventions as `POST /api/v1/mangas`:
//! - `metadata` part (JSON) with `{ number, title? }`.
//! - One or more `page` parts (images, ordered by arrival).

use axum::extract::{Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;

use crate::api::mangas::{next_field, read_field_bytes};
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::chapter::NewChapter;
use crate::domain::{Chapter, Page};
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::storage::Storage;
use crate::upload::{stage_image_part, StagedImage};

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/mangas/:manga_id/chapters", get(list).post(create))
        .route("/mangas/:manga_id/chapters/:chapter_id", get(get_one))
        .route(
            "/mangas/:manga_id/chapters/:chapter_id/pages",
            get(list_pages),
        )
}

#[derive(Debug, Deserialize)]
pub struct ListParams {
    #[serde(default = "default_limit")]
    pub limit: i64,
    #[serde(default)]
    pub offset: i64,
}

fn default_limit() -> i64 {
    50
}

async fn list(
    State(state): State<AppState>,
    Path(manga_id): Path<Uuid>,
    Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<Chapter>>> {
    repo::manga::get(&state.db, manga_id).await?;

    let limit = params.limit.clamp(1, 200);
    let offset = params.offset.max(0);
    let items = repo::chapter::list_for_manga(&state.db, manga_id, limit, offset).await?;
    Ok(Json(PagedResponse::new(items, limit, offset)))
}

async fn get_one(
    State(state): State<AppState>,
    Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>,
) -> AppResult<Json<Chapter>> {
    repo::manga::get(&state.db, manga_id).await?;
    let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
        .await?
        .ok_or(AppError::NotFound)?;
    Ok(Json(chapter))
}

/// Add a chapter to a manga.
///
/// **Authorization is intentionally open**: any authenticated principal —
/// a browser session *or* a bot API token — may add a chapter to *any*
/// manga, including crawler-imported rows. This is by design: chapters are
/// treated as community contributions, unlike the manga record itself
/// (title/cover/metadata), whose edits gate through `require_can_edit`
/// (see [`crate::api::mangas`]). The `CurrentUser` binding still requires a
/// valid identity, so contributions are attributable, just not owner-scoped.
///
/// This contract is locked by `tests/api_chapters.rs`
/// (`non_owner_can_upload_chapter`); changing it to owner-only is a
/// deliberate decision, not a drive-by tightening. Until a richer
/// contributor/moderation model lands this is acknowledged, intended
/// behaviour — see the auth note in CLAUDE.md.
async fn create(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Path(manga_id): Path<Uuid>,
    mut multipart: Multipart,
) -> AppResult<(StatusCode, Json<Chapter>)> {
    repo::manga::get(&state.db, manga_id).await?;

    // Each `page` part is streamed straight to a staging key as it arrives,
    // so at most one page's bytes sit in memory — the whole chapter is never
    // buffered (previously every page was held at once, bounded only by the
    // 200 MiB body limit and amplified by concurrency). The staged blobs are
    // promoted to their final chapter-scoped keys in `finalize_chapter` once
    // the chapter id exists; any early exit cleans them up.
    let upload_id = Uuid::new_v4();
    let mut metadata: Option<NewChapter> = None;
    let mut staged: Vec<StagedImage> = Vec::new();

    let stage_result: AppResult<NewChapter> = async {
        while let Some(field) = next_field(&mut multipart).await? {
            match field.name() {
                Some("metadata") => {
                    let bytes = read_field_bytes(field).await?;
                    metadata = Some(serde_json::from_slice(&bytes).map_err(|e| {
                        AppError::ValidationFailed {
                            message: "metadata is not valid JSON".into(),
                            details: json!({ "metadata": e.to_string() }),
                        }
                    })?);
                }
                Some("page") => {
                    if state.upload.max_pages_per_chapter != 0
                        && staged.len() >= state.upload.max_pages_per_chapter
                    {
                        return Err(AppError::PayloadTooLarge(format!(
                            "chapter exceeds the {}-page limit",
                            state.upload.max_pages_per_chapter
                        )));
                    }
                    let field_name = format!("page[{}]", staged.len());
                    let img = stage_image_part(
                        state.storage.as_ref(),
                        field,
                        upload_id,
                        staged.len(),
                        state.upload.max_file_bytes,
                        &field_name,
                    )
                    .await?;
                    staged.push(img);
                }
                _ => continue,
            }
        }

        let metadata = metadata.take().ok_or_else(|| AppError::ValidationFailed {
            message: "metadata part is required".into(),
            details: json!({ "metadata": "required" }),
        })?;
        // Chapter number is 1-indexed everywhere (URLs, upload form,
        // reader). Reject 0 / negative numbers up front so the row never
        // makes it into the DB. Mirrors the page>=1 rule on bookmarks.
        if metadata.number < 1 {
            return Err(AppError::ValidationFailed {
                message: "chapter number must be 1 or greater".into(),
                details: json!({ "number": "must be >= 1" }),
            });
        }
        if staged.is_empty() {
            return Err(AppError::ValidationFailed {
                message: "at least one page is required".into(),
                details: json!({ "page": "at least one required" }),
            });
        }
        Ok(metadata)
    }
    .await;

    let metadata = match stage_result {
        Ok(m) => m,
        Err(e) => {
            // Reject before any DB write — remove every page we staged.
            cleanup_staging(state.storage.as_ref(), &staged).await;
            return Err(e);
        }
    };

    finalize_chapter(&state, manga_id, user.id, &metadata, &staged).await
}

/// Promote the staged pages into a real chapter, all-or-nothing. Creates the
/// chapter row, renames each staged blob to its final chapter-scoped key,
/// and inserts the page rows in one transaction. On any failure the DB rolls
/// back and every blob (already-finalized and still-staged) is removed, so a
/// rejected upload leaves neither partial rows nor orphaned files.
async fn finalize_chapter(
    state: &AppState,
    manga_id: Uuid,
    user_id: Uuid,
    metadata: &NewChapter,
    staged: &[StagedImage],
) -> AppResult<(StatusCode, Json<Chapter>)> {
    let storage = state.storage.as_ref();
    let mut tx = match state.db.begin().await {
        Ok(tx) => tx,
        Err(e) => {
            cleanup_staging(storage, staged).await;
            return Err(e.into());
        }
    };
    let mut chapter = match repo::chapter::create(
        &mut *tx,
        manga_id,
        metadata.number,
        metadata.title.as_deref(),
        Some(user_id),
    )
    .await
    {
        Ok(c) => c,
        Err(e) => {
            cleanup_staging(storage, staged).await;
            return Err(e);
        }
    };

    let mut page_ids: Vec<Uuid> = Vec::with_capacity(staged.len());
    let mut finalized: Vec<String> = Vec::with_capacity(staged.len());
    for (idx, page) in staged.iter().enumerate() {
        let page_number = (idx + 1) as i32;
        let final_key = format!(
            "mangas/{}/chapters/{}/pages/{:04}.{}",
            manga_id, chapter.id, page_number, page.ext
        );
        if let Err(e) = storage.rename(&page.staging_key, &final_key).await {
            cleanup_keys(storage, &finalized).await;
            cleanup_staging(storage, &staged[idx..]).await;
            return Err(e.into());
        }
        finalized.push(final_key.clone());
        match repo::page::create(
            &mut *tx,
            chapter.id,
            page_number,
            &final_key,
            page.mime,
            page.size_bytes,
        )
        .await
        {
            Ok(created) => page_ids.push(created.id),
            Err(e) => {
                cleanup_keys(storage, &finalized).await;
                cleanup_staging(storage, &staged[idx + 1..]).await;
                return Err(e);
            }
        }
    }

    let page_count = staged.len() as i32;
    if let Err(e) = repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await {
        cleanup_keys(storage, &finalized).await;
        return Err(e);
    }
    chapter.page_count = page_count;
    // `repo::chapter::create` returned the row before any pages existed, so
    // its `size_bytes` is a stale 0. Each staged page carried its byte
    // length, so their sum is the chapter's true storage — set it on the
    // response so the 201 body matches the persisted state.
    chapter.size_bytes = Some(staged.iter().map(|p| p.size_bytes).sum());

    if let Err(e) = tx.commit().await {
        cleanup_keys(storage, &finalized).await;
        return Err(e.into());
    }

    // Enqueue AI content-analysis for each new page. Done after commit so a
    // rolled-back upload never leaves jobs pointing at nonexistent pages; a
    // failed enqueue is logged but doesn't fail the upload (the admin
    // re-enqueue endpoint can backfill).
    if state.analysis_enabled() {
        for page_id in page_ids {
            if let Err(e) = repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await {
                tracing::warn!(%page_id, error = %e, "failed to enqueue page analysis");
            }
        }
    }

    Ok((StatusCode::CREATED, Json(chapter)))
}

/// Best-effort removal of staged page blobs after a rejected upload.
async fn cleanup_staging(storage: &dyn Storage, staged: &[StagedImage]) {
    for page in staged {
        let _ = storage.delete(&page.staging_key).await;
    }
}

/// Best-effort removal of already-finalized page blobs when the upload
/// fails after some renames have landed.
async fn cleanup_keys(storage: &dyn Storage, keys: &[String]) {
    for key in keys {
        let _ = storage.delete(key).await;
    }
}

#[derive(Debug, serde::Serialize)]
struct PagesResponse {
    pages: Vec<Page>,
}

async fn list_pages(
    State(state): State<AppState>,
    Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>,
) -> AppResult<Json<PagesResponse>> {
    repo::manga::get(&state.db, manga_id).await?;
    let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
        .await?
        .ok_or(AppError::NotFound)?;
    let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?;
    Ok(Json(PagesResponse { pages }))
}
