//! Admin-triggered force resync of a single manga's metadata + cover,
//! or a single chapter's content.
//!
//! Both endpoints are admin-only (`RequireAdmin`, cookie-only) and run
//! synchronously with the request — the response carries the refreshed
//! resource so the UI can swap it in without a follow-up GET. The work
//! itself is delegated to [`ResyncService`] (set on AppState by
//! `app::build` when the crawler daemon is enabled); when the daemon
//! is disabled, both handlers return 503.

use axum::extract::{Path, State};
use axum::routing::post;
use axum::{Json, Router};
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;

use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::crawler::resync::{ChapterResyncOutcome, ResyncError};
use crate::domain::manga::MangaDetail;
use crate::domain::Chapter;
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::crawler::UpsertStatus;

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/admin/mangas/:id/resync", post(resync_manga))
        .route("/admin/chapters/:id/resync", post(resync_chapter))
}

#[derive(Debug, Serialize)]
pub struct MangaResyncResponse {
    pub manga: MangaDetail,
    /// `"new" | "updated" | "unchanged"` — mirrors [`UpsertStatus`].
    pub metadata_status: &'static str,
    pub cover_fetched: bool,
}

#[derive(Debug, Serialize)]
pub struct ChapterResyncResponse {
    pub chapter: Chapter,
    /// `"fetched" | "skipped"` — whether new pages landed or the
    /// service short-circuited (e.g. chapter already had pages and the
    /// session was lost so force was downgraded).
    pub outcome: &'static str,
    /// Page count when `outcome == "fetched"`. `None` for `skipped`.
    pub pages: Option<usize>,
}

async fn resync_manga(
    State(state): State<AppState>,
    admin: RequireAdmin,
    Path(manga_id): Path<Uuid>,
) -> AppResult<Json<MangaResyncResponse>> {
    if !repo::manga::exists(&state.db, manga_id).await? {
        return Err(AppError::NotFound);
    }
    let resync = state
        .resync()
        .ok_or_else(|| AppError::ServiceUnavailable(
            "crawler daemon is disabled; force resync unavailable".into(),
        ))?;

    let outcome = resync.resync_manga(manga_id).await.map_err(map_resync_err)?;

    // Audit the action with the actor + the resync outcome so an
    // operator-of-operators can answer "who refetched this manga, and
    // did the cover land?" from the log alone.
    repo::admin_audit::insert(
        &state.db,
        admin.0.id,
        "manga_resync",
        "manga",
        Some(manga_id),
        json!({
            "metadata_status": status_str(outcome.metadata_status),
            "cover_fetched": outcome.cover_fetched,
        }),
    )
    .await?;

    let manga = repo::manga::get_detail(&state.db, manga_id).await?;
    Ok(Json(MangaResyncResponse {
        manga,
        metadata_status: status_str(outcome.metadata_status),
        cover_fetched: outcome.cover_fetched,
    }))
}

async fn resync_chapter(
    State(state): State<AppState>,
    admin: RequireAdmin,
    Path(chapter_id): Path<Uuid>,
) -> AppResult<Json<ChapterResyncResponse>> {
    let resync = state
        .resync()
        .ok_or_else(|| AppError::ServiceUnavailable(
            "crawler daemon is disabled; force resync unavailable".into(),
        ))?;

    // Look up the manga the chapter belongs to so we can return the
    // refreshed chapter row in the response and 404 for unknown ids.
    let manga_id: Option<Uuid> =
        sqlx::query_scalar("SELECT manga_id FROM chapters WHERE id = $1")
            .bind(chapter_id)
            .fetch_optional(&state.db)
            .await?;
    let Some(manga_id) = manga_id else {
        return Err(AppError::NotFound);
    };

    let outcome = resync
        .resync_chapter(chapter_id)
        .await
        .map_err(map_resync_err)?;

    let (outcome_str, pages) = match &outcome {
        ChapterResyncOutcome::Fetched { pages, .. } => ("fetched", Some(*pages)),
        ChapterResyncOutcome::Skipped { .. } => ("skipped", None),
    };

    repo::admin_audit::insert(
        &state.db,
        admin.0.id,
        "chapter_resync",
        "chapter",
        Some(chapter_id),
        json!({
            "outcome": outcome_str,
            "pages": pages,
        }),
    )
    .await?;

    let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
        .await?
        .ok_or(AppError::NotFound)?;
    Ok(Json(ChapterResyncResponse {
        chapter,
        outcome: outcome_str,
        pages,
    }))
}

fn status_str(s: UpsertStatus) -> &'static str {
    match s {
        UpsertStatus::New => "new",
        UpsertStatus::Updated => "updated",
        UpsertStatus::Unchanged => "unchanged",
    }
}

/// Map [`ResyncError`] (and the anyhow envelopes wrapping it) onto the
/// right [`AppError`]. Anything else surfaces as a generic 500 via the
/// `Other` arm — the operator sees the underlying anyhow chain in
/// server logs, the client sees a clean envelope.
fn map_resync_err(err: anyhow::Error) -> AppError {
    if let Some(rerr) = err.downcast_ref::<ResyncError>() {
        match rerr {
            ResyncError::NoMangaSource => AppError::ValidationFailed {
                message: "manga has no live crawler source — cannot resync".into(),
                details: json!({ "manga": "no_source" }),
            },
            ResyncError::NoChapterSource => AppError::ValidationFailed {
                message: "chapter has no live crawler source — cannot resync".into(),
                details: json!({ "chapter": "no_source" }),
            },
        }
    } else {
        AppError::Other(err)
    }
}
