//! Admin-triggered resync of a single manga's metadata + cover, or a
//! single chapter's content.
//!
//! The cron tick already retries covers and chapter content on its own
//! schedule. This module exists for the operator-controlled path:
//! "this manga's metadata is stale / its cover never landed / this
//! chapter is broken — pull from source now, not at the next daily
//! tick." Wired into the admin API, never into the queue, so the work
//! happens synchronously with the HTTP request and the admin sees the
//! refreshed row in the response.
//!
//! Shares the daemon's [`BrowserManager`], rate limiter, HTTP client,
//! and TOR controller so a force resync respects the same per-host
//! pacing and recircuit budget the daily crawl uses — admin actions
//! must not let an operator accidentally hammer the source.

use std::sync::Arc;

use anyhow::Context;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;

use crate::crawler::browser_manager::BrowserManager;
use crate::crawler::content::{self, SyncOutcome};
use crate::crawler::pipeline;
use crate::crawler::rate_limit::HostRateLimiters;
use crate::crawler::safety::DownloadAllowlist;
use crate::crawler::source::target::TargetSource;
use crate::crawler::source::{FetchContext, Source, SourceMangaRef};
use crate::crawler::tor::TorController;
use crate::repo;
use crate::repo::crawler::UpsertStatus;
use crate::storage::Storage;

/// Outcome of [`ResyncService::resync_manga`]. Mirrors the bits the
/// admin UI cares about — was the row actually re-upserted, did the
/// cover land — so the response can show "metadata refreshed, cover
/// re-downloaded" or "metadata unchanged" without a second round-trip.
#[derive(Debug, Clone, Copy)]
pub struct MangaResyncOutcome {
    pub manga_id: Uuid,
    pub metadata_status: UpsertStatus,
    pub cover_fetched: bool,
}

/// Outcome of [`ResyncService::resync_chapter`]. `Fetched(pages)` is the
/// success case; `Skipped` means the source row was already gone or the
/// chapter had no live source.
#[derive(Debug, Clone)]
pub enum ChapterResyncOutcome {
    Fetched { chapter_id: Uuid, pages: usize },
    Skipped { chapter_id: Uuid, reason: String },
}

/// Service exposed by the daemon to the admin API. Optional on
/// [`AppState`] — `None` when the crawler daemon is disabled
/// (`CRAWLER_DAEMON=false`), in which case admin handlers return 503.
#[async_trait]
pub trait ResyncService: Send + Sync {
    async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome>;
    async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome>;
}

/// Errors with a stable shape so the API layer can map them to the
/// right HTTP status (404 vs 422 vs 5xx). Anything else surfaces as a
/// generic 500.
#[derive(Debug, thiserror::Error)]
pub enum ResyncError {
    #[error("manga has no source to resync from")]
    NoMangaSource,
    #[error("chapter has no source to resync from")]
    NoChapterSource,
}

pub struct RealResyncService {
    pub browser_manager: Arc<BrowserManager>,
    pub db: PgPool,
    pub storage: Arc<dyn Storage>,
    pub http: reqwest::Client,
    pub rate: Arc<HostRateLimiters>,
    pub download_allowlist: DownloadAllowlist,
    pub max_image_bytes: usize,
    /// Per-chapter image-count cap (see `CrawlerConfig::max_images_per_chapter`).
    pub max_images_per_chapter: usize,
    pub tor: Option<Arc<TorController>>,
}

#[async_trait]
impl ResyncService for RealResyncService {
    async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome> {
        // Pick the freshest live source row. Multi-source mangas
        // (theoretical — only one Source impl today) get the row whose
        // `last_seen_at` is newest; soft-dropped rows are skipped.
        let row: Option<(String, String, String)> = sqlx::query_as(
            "SELECT source_id, source_manga_key, source_url \
               FROM manga_sources \
              WHERE manga_id = $1 AND dropped_at IS NULL \
              ORDER BY last_seen_at DESC \
              LIMIT 1",
        )
        .bind(manga_id)
        .fetch_optional(&self.db)
        .await
        .context("look up manga_sources for resync")?;
        let Some((_source_id, source_manga_key, source_url)) = row else {
            return Err(ResyncError::NoMangaSource.into());
        };

        let lease = self
            .browser_manager
            .acquire()
            .await
            .context("acquire browser lease for manga resync")?;
        let browser_ref: &chromiumoxide::Browser = &lease;
        let ctx = FetchContext {
            browser: browser_ref,
            rate: &self.rate,
            tor: self.tor.as_deref(),
        };

        // Parse chapters too — a force resync is "make this manga fully
        // current," not just metadata. The full pipeline handles the
        // partial-render guard for us; we replicate the same caution
        // here by skipping the chapter sync when the parser returned
        // empty but the manga previously had chapters.
        let source = TargetSource::new(source_url.clone());
        let r = SourceMangaRef {
            source_manga_key: source_manga_key.clone(),
            title: String::new(),
            url: source_url.clone(),
        };
        let manga = source
            .fetch_manga(&ctx, &r)
            .await
            .with_context(|| format!("fetch_manga during resync of {manga_id}"))?;

        // Partial-render guard: same logic as run_metadata_pass.
        let source_id = source.id();
        if !manga.chapters.is_empty() || {
            let prior = repo::crawler::live_chapter_count_for_source_manga(
                &self.db,
                source_id,
                &source_manga_key,
            )
            .await
            .unwrap_or(0);
            prior == 0
        } {
            // Either the new fetch surfaced chapters, or there were
            // none before either — chapter sync is safe to run.
        } else {
            tracing::warn!(
                %manga_id,
                source_url = %source_url,
                "resync_manga: fetch returned empty chapters but prior count > 0; skipping chapter sync to avoid soft-drop"
            );
        }

        let upsert = repo::crawler::upsert_manga_from_source(
            &self.db,
            source_id,
            &source_url,
            &manga,
        )
        .await
        .with_context(|| format!("upsert_manga_from_source during resync of {manga_id}"))?;

        // Cover refetch: force-download regardless of UpsertStatus.
        // Admin clicked "resync" because they want the cover too.
        let mut cover_fetched = false;
        if let Some(cover_url) = manga.cover_url.as_deref() {
            match pipeline::download_and_store_cover(
                &self.db,
                self.storage.as_ref(),
                &self.http,
                &self.rate,
                &source_url,
                upsert.manga_id,
                cover_url,
                &self.download_allowlist,
                self.max_image_bytes,
            )
            .await
            {
                Ok(()) => cover_fetched = true,
                Err(e) => tracing::warn!(
                    %manga_id,
                    error = ?e,
                    "resync_manga: cover download failed"
                ),
            }
        }

        // Chapter sync — only when the partial-render guard above
        // didn't bail.
        let prior_chapter_count = repo::crawler::live_chapter_count_for_source_manga(
            &self.db,
            source_id,
            &source_manga_key,
        )
        .await
        .unwrap_or(0);
        if !manga.chapters.is_empty() || prior_chapter_count == 0 {
            match repo::crawler::sync_manga_chapters(
                &self.db,
                source_id,
                upsert.manga_id,
                &manga.chapters,
            )
            .await
            {
                Ok(diff) => tracing::info!(
                    %manga_id,
                    new = diff.new,
                    refreshed = diff.refreshed,
                    dropped = diff.dropped,
                    "resync_manga: chapters synced"
                ),
                Err(e) => tracing::warn!(
                    %manga_id,
                    error = ?e,
                    "resync_manga: chapter sync failed"
                ),
            }
        }

        drop(lease);
        Ok(MangaResyncOutcome {
            manga_id: upsert.manga_id,
            metadata_status: upsert.status,
            cover_fetched,
        })
    }

    async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome> {
        let row = repo::chapter::dispatch_target(&self.db, chapter_id)
            .await
            .context("look up chapter_sources for resync")?;
        let Some((manga_id, source_url, _title, _number)) = row else {
            return Err(ResyncError::NoChapterSource.into());
        };

        let lease = self
            .browser_manager
            .acquire()
            .await
            .context("acquire browser lease for chapter resync")?;
        let result = content::sync_chapter_content(
            &lease,
            &self.db,
            self.storage.as_ref(),
            &self.http,
            &self.rate,
            chapter_id,
            manga_id,
            &source_url,
            true,
            &self.download_allowlist,
            self.max_image_bytes,
            self.max_images_per_chapter,
            self.tor.as_deref(),
            // Admin resync isn't a daemon worker slot — no live status.
            None,
            // Resync re-fetches existing pages (same ids); analysis isn't
            // re-enqueued here — use the admin force re-analyze endpoint.
            false,
        )
        .await;
        drop(lease);

        match result? {
            SyncOutcome::Fetched { pages } => {
                Ok(ChapterResyncOutcome::Fetched { chapter_id, pages })
            }
            SyncOutcome::Skipped => Ok(ChapterResyncOutcome::Skipped {
                chapter_id,
                reason: "chapter already had pages on disk".to_string(),
            }),
            SyncOutcome::SessionExpired => {
                anyhow::bail!("source session expired — operator must refresh PHPSESSID")
            }
        }
    }
}
