//! 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::upload::{parse_image, UploadedImage};

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))
}

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?;

    let mut metadata: Option<NewChapter> = None;
    let mut pages: Vec<UploadedImage> = Vec::new();

    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") => {
                let bytes = read_field_bytes(field).await?.to_vec();
                let field_name = format!("page[{}]", pages.len());
                pages.push(parse_image(bytes, state.upload.max_file_bytes, &field_name)?);
            }
            _ => continue,
        }
    }

    let metadata = metadata.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 pages.is_empty() {
        return Err(AppError::ValidationFailed {
            message: "at least one page is required".into(),
            details: json!({ "page": "at least one required" }),
        });
    }

    // Transactional create. If any storage put or page-row insert
    // fails mid-loop, the chapter row + any earlier page rows are
    // rolled back so we don't leave a chapter with stale page_count=0
    // and orphaned page rows. Bytes already written to storage on a
    // rolled-back transaction become orphans on disk; a future reaper
    // can sweep them. DB consistency wins over storage tidiness here.
    let mut tx = state.db.begin().await?;
    let mut chapter = repo::chapter::create(
        &mut *tx,
        manga_id,
        metadata.number,
        metadata.title.as_deref(),
        Some(user.id),
    )
    .await?;

    let mut page_ids: Vec<Uuid> = Vec::with_capacity(pages.len());
    for (idx, page) in pages.iter().enumerate() {
        let page_number = (idx + 1) as i32;
        let nnnn = format!("{:04}", page_number);
        let key = format!(
            "mangas/{}/chapters/{}/pages/{}.{}",
            manga_id, chapter.id, nnnn, page.ext
        );
        state.storage.put(&key, &page.bytes).await?;
        let created = repo::page::create(
            &mut *tx,
            chapter.id,
            page_number,
            &key,
            page.mime,
            page.bytes.len() as i64,
        )
        .await?;
        page_ids.push(created.id);
    }

    let page_count = pages.len() as i32;
    repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?;
    chapter.page_count = page_count;
    // `repo::chapter::create` returned the row before any pages existed, so
    // its `size_bytes` is a stale 0. Every uploaded page's size was just
    // captured, so the true total is the sum of their byte lengths — set it
    // on the response so the 201 body matches the persisted state.
    chapter.size_bytes = Some(pages.iter().map(|p| p.bytes.len() as i64).sum());

    tx.commit().await?;

    // 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)))
}

#[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 }))
}
