//! AI page-analysis domain types.
//!
//! Two distinct shapes live here:
//!
//! * The **persisted** row types (`PageAnalysis`) and the closed
//!   vocabularies (`OcrKind`, `ContentWarning`, `AnalysisStatus`) that the
//!   `page_analysis` / `page_ocr_text` / `page_content_warnings` tables
//!   constrain via CHECKs.
//! * The **vision-response** DTOs (`VisionAnalysis` and friends) that the
//!   worker deserializes from the local model's JSON. These are
//!   deliberately lenient — `kind` and `content_type` arrive as free
//!   strings because a small local model can emit anything; mapping to the
//!   closed enums (and dropping the unmappable) happens at persist time via
//!   [`OcrKind::from_model_str`] / [`ContentWarning::from_model_str`].

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

/// Lifecycle of a page's analysis row.
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum AnalysisStatus {
    Pending,
    Done,
    Failed,
}

/// Kind of an OCR'd text piece. Drives the search-ranking weight:
/// `Speech`/`Title` → A, `Narration`/`Thought`/`Caption` → B, `Sfx` → D
/// (the scene description is weighted C separately). See
/// [`OcrKind::weight`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum OcrKind {
    Speech,
    Thought,
    Narration,
    Sfx,
    Title,
    Caption,
}

impl OcrKind {
    /// Postgres `tsvector` weight label for this kind. The default
    /// `ts_rank` weights `{D,C,B,A} = {0.1,0.2,0.4,1.0}` then realize the
    /// intended relevance ordering: speech/title most important, sfx least.
    pub fn weight(self) -> char {
        match self {
            OcrKind::Speech | OcrKind::Title => 'A',
            OcrKind::Narration | OcrKind::Thought | OcrKind::Caption => 'B',
            OcrKind::Sfx => 'D',
        }
    }

    /// Map a model-supplied kind string onto the closed vocabulary. Unknown
    /// or unparseable kinds fall back to `Narration` (the neutral
    /// mid-weight bucket) rather than dropping the text — losing the
    /// transcription entirely is worse than mis-weighting it.
    pub fn from_model_str(raw: &str) -> OcrKind {
        match raw.trim().to_lowercase().as_str() {
            "speech" => OcrKind::Speech,
            "thought" => OcrKind::Thought,
            "narration" => OcrKind::Narration,
            "sfx" => OcrKind::Sfx,
            "title" => OcrKind::Title,
            "caption" => OcrKind::Caption,
            _ => OcrKind::Narration,
        }
    }
}

/// A content-warning category from the closed moderation vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ContentWarning {
    Sexual,
    Nudity,
    Gore,
    Violence,
    Disturbing,
}

impl ContentWarning {
    /// Map a model-supplied content-type string onto the closed
    /// vocabulary, or `None` if it isn't one we recognize. Unlike OCR
    /// kinds, an unknown warning is *dropped* — flagging a page with a
    /// category we can't filter on is meaningless.
    pub fn from_model_str(raw: &str) -> Option<ContentWarning> {
        match raw.trim().to_lowercase().as_str() {
            "sexual" => Some(ContentWarning::Sexual),
            "nudity" => Some(ContentWarning::Nudity),
            "gore" => Some(ContentWarning::Gore),
            "violence" => Some(ContentWarning::Violence),
            "disturbing" => Some(ContentWarning::Disturbing),
            _ => None,
        }
    }

    /// Parse a wire/query-param value strictly (no fallback). Used by the
    /// API layer to validate `cw_include` / `cw_exclude` filters.
    pub fn parse_strict(raw: &str) -> Option<ContentWarning> {
        ContentWarning::from_model_str(raw)
    }
}

/// One result row from the page content-search (`GET /v1/me/page-search`).
/// One row per matching page, carrying the breadcrumb plus the moderation
/// flags and the text-search rank so the UI can badge and order results.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageSearchItem {
    pub page_id: Uuid,
    pub chapter_id: Uuid,
    pub manga_id: Uuid,
    pub page_number: i32,
    pub chapter_number: i32,
    pub chapter_title: Option<String>,
    pub manga_title: String,
    pub storage_key: String,
    pub is_nsfw: bool,
    /// Deduped content warnings on this page (canonical lowercase names).
    pub content_warnings: Vec<String>,
    /// `ts_rank` against the text query; `0` for tag/warning-only searches.
    pub rank: f32,
}

/// Analysis coverage for one manga (admin overview): how many of its pages
/// have a completed analysis vs. how many exist.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct MangaCoverage {
    pub manga_id: Uuid,
    pub title: String,
    pub total_pages: i64,
    pub analyzed_pages: i64,
}

/// Analysis coverage for one chapter.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ChapterCoverage {
    pub chapter_id: Uuid,
    pub number: i32,
    pub title: Option<String>,
    pub total_pages: i64,
    pub analyzed_pages: i64,
}

/// Per-page analysis status for a chapter's page grid. `status` is one of
/// `done` | `failed` | `queued` (a pending/running job) | `none`.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageStatusItem {
    pub page_id: Uuid,
    pub page_number: i32,
    pub status: String,
}

/// One row in the admin analysis-history table: a completed or failed
/// analysis pass resolved to its page/chapter/manga context. Sourced from
/// the persistent `page_analysis` table (not the reaped job queue), so it
/// survives indefinitely. `status` is `done` | `failed`.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct AnalysisHistoryRow {
    pub page_id: Uuid,
    pub page_number: i32,
    pub chapter_id: Uuid,
    pub chapter_number: i32,
    pub manga_id: Uuid,
    pub manga_title: String,
    pub status: String,
    pub is_nsfw: bool,
    pub model: Option<String>,
    pub error: Option<String>,
    pub analyzed_at: Option<DateTime<Utc>>,
    /// Wall-clock the worker spent on this page; `None` for rows analyzed
    /// before duration tracking landed.
    pub duration_ms: Option<i64>,
}

/// Per-model average analysis duration (admin Metrics tab).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ModelDuration {
    pub model: Option<String>,
    pub avg_ms: Option<f64>,
    pub n: i64,
}

/// Aggregate analysis timing/outcome roll-up over a time window.
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisMetrics {
    pub n: i64,
    pub ok: i64,
    pub failed: i64,
    pub avg_ms: Option<f64>,
    pub by_model: Vec<ModelDuration>,
}

/// One OCR line in the page-detail view.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct OcrLine {
    pub kind: OcrKind,
    pub text: String,
}

/// Full analysis result for one page, for the admin detail modal. `status`
/// is `done` | `failed` | `none` (no analysis row yet).
#[derive(Debug, Clone, Serialize)]
pub struct PageAnalysisDetail {
    pub page_id: Uuid,
    pub page_number: i32,
    pub chapter_id: Uuid,
    pub manga_id: Uuid,
    pub status: String,
    pub is_nsfw: bool,
    pub scene_description: Option<String>,
    pub model: Option<String>,
    pub error: Option<String>,
    pub analyzed_at: Option<DateTime<Utc>>,
    pub ocr: Vec<OcrLine>,
    pub tags: Vec<String>,
    pub content_warnings: Vec<ContentWarning>,
}

/// One persisted `page_analysis` row.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageAnalysis {
    pub page_id: Uuid,
    pub status: AnalysisStatus,
    pub scene_description: Option<String>,
    pub is_nsfw: bool,
    pub model: Option<String>,
    pub error: Option<String>,
    pub analyzed_at: Option<DateTime<Utc>>,
}

// --- Vision-response DTOs (deserialized from the local model) ---------

/// The full JSON object the vision model returns for one page. Lenient by
/// design: see the module docs.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct VisionAnalysis {
    #[serde(default)]
    pub ocr_results: Vec<OcrResult>,
    #[serde(default)]
    pub tagging_results: Vec<String>,
    #[serde(default)]
    pub scene_description: String,
    #[serde(default)]
    pub safety_flag: SafetyFlag,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct OcrResult {
    #[serde(default)]
    pub text: String,
    /// Free string from the model; mapped via [`OcrKind::from_model_str`].
    #[serde(default)]
    pub kind: String,
    /// Optional vertical center of the text as a fraction (0.0 top … 1.0
    /// bottom) of the slice image, requested in the OCR pass to dedup
    /// duplicates across slice seams by position. `None` for the combined
    /// single-call path. Internal-only — not persisted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub y: Option<f64>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct SafetyFlag {
    #[serde(default)]
    pub is_nsfw: bool,
    /// Free strings; mapped via [`ContentWarning::from_model_str`].
    #[serde(default)]
    pub content_type: Vec<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ocr_kind_weights_match_spec() {
        assert_eq!(OcrKind::Speech.weight(), 'A');
        assert_eq!(OcrKind::Title.weight(), 'A');
        assert_eq!(OcrKind::Narration.weight(), 'B');
        assert_eq!(OcrKind::Thought.weight(), 'B');
        assert_eq!(OcrKind::Caption.weight(), 'B');
        assert_eq!(OcrKind::Sfx.weight(), 'D');
    }

    #[test]
    fn ocr_kind_from_model_str_falls_back_to_narration() {
        assert_eq!(OcrKind::from_model_str("SPEECH"), OcrKind::Speech);
        assert_eq!(OcrKind::from_model_str(" sfx "), OcrKind::Sfx);
        assert_eq!(OcrKind::from_model_str("dialogue"), OcrKind::Narration);
        assert_eq!(OcrKind::from_model_str(""), OcrKind::Narration);
    }

    #[test]
    fn content_warning_from_model_str_drops_unknown() {
        assert_eq!(
            ContentWarning::from_model_str("Sexual"),
            Some(ContentWarning::Sexual)
        );
        assert_eq!(
            ContentWarning::from_model_str(" gore"),
            Some(ContentWarning::Gore)
        );
        assert_eq!(ContentWarning::from_model_str("spicy"), None);
        assert_eq!(ContentWarning::from_model_str(""), None);
    }

    #[test]
    fn vision_analysis_deserializes_sample_and_tolerates_missing_fields() {
        let json = r#"{
            "ocr_results": [{"text":"Hi","kind":"speech"}],
            "tagging_results": ["action","city"],
            "scene_description": "A street.",
            "safety_flag": {"is_nsfw": false, "content_type": []}
        }"#;
        let v: VisionAnalysis = serde_json::from_str(json).unwrap();
        assert_eq!(v.ocr_results.len(), 1);
        assert_eq!(v.tagging_results, vec!["action", "city"]);
        assert!(!v.safety_flag.is_nsfw);

        // Missing optional fields default rather than failing the parse.
        let sparse: VisionAnalysis = serde_json::from_str("{}").unwrap();
        assert!(sparse.ocr_results.is_empty());
        assert_eq!(sparse.scene_description, "");
        assert!(!sparse.safety_flag.is_nsfw);
    }
}
