//! Integration tests for the admin force-resync endpoints.
//!
//! Real resync work requires Chromium, so these tests swap in a stub
//! [`ResyncService`] to assert the handler-level contract: routing,
//! admin gate, 503 when the daemon is disabled, 404 / 422 mapping for
//! missing-resource / no-source cases, and the audit-log side effect.

mod common;

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;

use mangalord::crawler::resync::{
    ChapterResyncOutcome, MangaResyncOutcome, ResyncError, ResyncService,
};
use mangalord::repo;
use mangalord::repo::crawler::UpsertStatus;

/// Stub that records call counts and returns a canned outcome.
struct StubResync {
    manga_calls: AtomicUsize,
    chapter_calls: AtomicUsize,
    /// When true, returns NoMangaSource / NoChapterSource.
    no_source: bool,
}

impl StubResync {
    fn new() -> Arc<Self> {
        Arc::new(Self {
            manga_calls: AtomicUsize::new(0),
            chapter_calls: AtomicUsize::new(0),
            no_source: false,
        })
    }
    fn no_source() -> Arc<Self> {
        Arc::new(Self {
            manga_calls: AtomicUsize::new(0),
            chapter_calls: AtomicUsize::new(0),
            no_source: true,
        })
    }
}

#[async_trait]
impl ResyncService for StubResync {
    async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome> {
        self.manga_calls.fetch_add(1, Ordering::SeqCst);
        if self.no_source {
            return Err(ResyncError::NoMangaSource.into());
        }
        Ok(MangaResyncOutcome {
            manga_id,
            metadata_status: UpsertStatus::Updated,
            cover_fetched: true,
        })
    }
    async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome> {
        self.chapter_calls.fetch_add(1, Ordering::SeqCst);
        if self.no_source {
            return Err(ResyncError::NoChapterSource.into());
        }
        Ok(ChapterResyncOutcome::Fetched {
            chapter_id,
            pages: 7,
        })
    }
}

async fn promote_admin(pool: &PgPool, username: &str) {
    let u = repo::user::find_by_username(pool, username)
        .await
        .unwrap()
        .unwrap();
    repo::user::set_is_admin_unchecked(pool, u.id, true)
        .await
        .unwrap();
}

async fn insert_manga(pool: &PgPool, title: &str) -> Uuid {
    let (id,): (Uuid,) = sqlx::query_as(
        "INSERT INTO mangas (title, status, alt_titles) VALUES ($1, 'ongoing', ARRAY[]::text[]) RETURNING id",
    )
    .bind(title)
    .fetch_one(pool)
    .await
    .unwrap();
    id
}

async fn insert_chapter(pool: &PgPool, manga_id: Uuid, number: i32, pages: i32) -> Uuid {
    let (id,): (Uuid,) = sqlx::query_as(
        "INSERT INTO chapters (manga_id, number, title, page_count) VALUES ($1, $2, NULL, $3) RETURNING id",
    )
    .bind(manga_id)
    .bind(number)
    .bind(pages)
    .fetch_one(pool)
    .await
    .unwrap();
    id
}

// ----- manga resync ---------------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_calls_service_and_returns_refreshed_detail(pool: PgPool) {
    let stub = StubResync::new();
    let h = common::harness_with_resync(pool.clone(), stub.clone());
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;
    let manga_id = insert_manga(&pool, "Hello").await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/mangas/{manga_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    // Stub returned Updated + cover_fetched=true.
    assert_eq!(body["metadata_status"], "updated");
    assert_eq!(body["cover_fetched"], true);
    // Response includes the refreshed manga detail.
    assert_eq!(body["manga"]["id"], manga_id.to_string());
    assert_eq!(body["manga"]["title"], "Hello");

    assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 1);

    // Audit row written.
    let (audit_count,): (i64,) =
        sqlx::query_as("SELECT count(*) FROM admin_audit WHERE action = 'manga_resync' AND target_id = $1")
            .bind(manga_id)
            .fetch_one(&pool)
            .await
            .unwrap();
    assert_eq!(audit_count, 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_returns_404_for_unknown_id(pool: PgPool) {
    let stub = StubResync::new();
    let h = common::harness_with_resync(pool.clone(), stub.clone());
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/mangas/{}/resync", Uuid::new_v4()),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    // Service must not have been called when the manga doesn't exist.
    assert_eq!(stub.manga_calls.load(Ordering::SeqCst), 0);
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_maps_no_source_to_422(pool: PgPool) {
    let stub = StubResync::no_source();
    let h = common::harness_with_resync(pool.clone(), stub);
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;
    let manga_id = insert_manga(&pool, "Manual upload, no crawler source").await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/mangas/{manga_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["details"]["manga"], "no_source");
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_returns_503_when_daemon_disabled(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;
    let manga_id = insert_manga(&pool, "Z").await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/mangas/{manga_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "service_unavailable");
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_resync_requires_admin(pool: PgPool) {
    let stub = StubResync::new();
    let h = common::harness_with_resync(pool.clone(), stub);
    // Non-admin user.
    let (_u, cookie) = common::register_user(&h.app).await;
    let manga_id = insert_manga(&pool, "M").await;
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/mangas/{manga_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ----- chapter resync -------------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_calls_service_and_returns_refreshed_chapter(pool: PgPool) {
    let stub = StubResync::new();
    let h = common::harness_with_resync(pool.clone(), stub.clone());
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;
    let manga_id = insert_manga(&pool, "M").await;
    let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/chapters/{chapter_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["outcome"], "fetched");
    assert_eq!(body["pages"], 7);
    assert_eq!(body["chapter"]["id"], chapter_id.to_string());
    assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 1);

    let (audit_count,): (i64,) = sqlx::query_as(
        "SELECT count(*) FROM admin_audit WHERE action = 'chapter_resync' AND target_id = $1",
    )
    .bind(chapter_id)
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(audit_count, 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_returns_404_for_unknown_id(pool: PgPool) {
    let stub = StubResync::new();
    let h = common::harness_with_resync(pool.clone(), stub.clone());
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/chapters/{}/resync", Uuid::new_v4()),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    assert_eq!(stub.chapter_calls.load(Ordering::SeqCst), 0);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_maps_no_source_to_422(pool: PgPool) {
    let stub = StubResync::no_source();
    let h = common::harness_with_resync(pool.clone(), stub);
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;
    let manga_id = insert_manga(&pool, "M").await;
    let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/chapters/{chapter_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["details"]["chapter"], "no_source");
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_returns_503_when_daemon_disabled(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (username, cookie) = common::register_user(&h.app).await;
    promote_admin(&pool, &username).await;
    let manga_id = insert_manga(&pool, "M").await;
    let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/chapters/{chapter_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_resync_requires_admin(pool: PgPool) {
    let stub = StubResync::new();
    let h = common::harness_with_resync(pool.clone(), stub);
    let (_u, cookie) = common::register_user(&h.app).await;
    let manga_id = insert_manga(&pool, "M").await;
    let chapter_id = insert_chapter(&pool, manga_id, 1, 0).await;
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/chapters/{chapter_id}/resync"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
