//! The vision model's system prompt and the bounds applied to its output.
//!
//! The system prompt is deliberately terse: with a context budget as low
//! as ~8192 tokens, the page image dominates, so the instructions + schema
//! must stay small and the output is capped via `max_tokens` plus the
//! length bounds enforced in [`crate::analysis::vision::sanitize`].

/// OCR text kinds the model may emit. Kept in sync with the
/// `page_ocr_text.kind` CHECK and [`crate::domain::page_analysis::OcrKind`].
pub const OCR_KINDS: [&str; 6] =
    ["speech", "thought", "narration", "sfx", "title", "caption"];

/// Closed content-warning vocabulary. Kept in sync with the
/// `page_content_warnings.warning` CHECK and
/// [`crate::domain::page_analysis::ContentWarning`].
pub const CONTENT_WARNINGS: [&str; 5] =
    ["sexual", "nudity", "gore", "violence", "disturbing"];

/// Target tag count we ask the model for (a hint in the prompt; not
/// hard-enforced beyond the [`MAX_TAGS`] cap).
pub const MIN_TAGS: usize = 5;
pub const MAX_TAGS: usize = 10;

/// Output bounds enforced at sanitize time so one verbose response can't
/// bloat the row or the search document. The OCR cap is generous because a
/// long sliced page legitimately accumulates many text pieces (the tsvector
/// handles it).
pub const MAX_OCR_PIECES: usize = 200;
pub const MAX_OCR_TEXT_CHARS: usize = 500;
pub const MAX_SCENE_CHARS: usize = 1000;

/// Hard per-call caps baked into the JSON schemas (`maxItems`/`maxLength`),
/// so a repetition-prone model is grammar-bounded and can't loop a single
/// call into the token ceiling. Kept per-call/per-slice; the cross-slice
/// total is bounded separately by [`MAX_OCR_PIECES`].
pub const MAX_OCR_PER_CALL: usize = 50;
pub const MAX_TAGS_PER_CALL: usize = 12;

/// The default system prompt. Self-contained: it carries the exact JSON
/// schema so the same string drives both the model and (implicitly) the
/// [`crate::domain::page_analysis::VisionAnalysis`] parser. An admin can
/// override it at runtime (see [`crate::config::AnalysisConfig::system_prompt`]);
/// this const is the fallback and the "reset to default" target.
pub const SYSTEM_PROMPT_DEFAULT: &str = concat!(
    "You are a manga/comic page analyzer. Look at the single page image and ",
    "return ONLY one minified JSON object — no prose, no markdown fences.\n",
    "Schema:\n",
    "{\"ocr_results\":[{\"text\":string,\"kind\":",
    "\"speech|thought|narration|sfx|title|caption\"}],",
    "\"tagging_results\":[string],",
    "\"scene_description\":string,",
    "\"safety_flag\":{\"is_nsfw\":boolean,\"content_type\":[",
    "\"sexual|nudity|gore|violence|disturbing\"]}}\n",
    "Rules: transcribe every visible text element verbatim into ocr_results ",
    "with its kind, each ONCE (never repeat or loop), at most 50; if there is ",
    "no text use []. tagging_results: 5-10 short, lowercase, distinct content ",
    "tags (characters, actions, setting, mood, genre); include explicit/",
    "sexual tags when present. scene_description: one or two sentences ",
    "describing setting, characters, and action. safety_flag.content_type: ",
    "only values from the listed set, [] if none; set is_nsfw true if the ",
    "page contains sexual, nudity, gore, violence, or disturbing content. ",
    "CRITICAL: do not repeat elements, tags or words; STOP when done. Output ",
    "must be valid minified JSON and nothing else."
);

// --- Two-pass prompts (long-page slicing) -----------------------------------

/// Pass A: OCR-only over a single vertical slice of a tall page. Kept
/// narrow so each slice call is fast and never truncates. Runtime-overridable
/// default — see [`crate::config::AnalysisConfig::ocr_prompt`].
pub const OCR_PROMPT_DEFAULT: &str = concat!(
    "You are an OCR engine for manga/comic pages. The image is one vertical ",
    "slice of a larger page. Transcribe EVERY visible text element verbatim ",
    "into ocr_results, each with its kind ",
    "(speech|thought|narration|sfx|title|caption) and y — the vertical center ",
    "of the text as a fraction from 0.0 (top) to 1.0 (bottom) of THIS image. ",
    "List them ONCE each, in top-to-bottom order. CRITICAL: transcribe each ",
    "distinct text element exactly once — never repeat an element, never loop ",
    "or pad the output. At most 50 elements. The moment you have transcribed ",
    "all visible text, STOP. If there is no text, return {\"ocr_results\":[]}. ",
    "Output ONLY one minified JSON object, no prose, no markdown."
);

/// Pass B: tags + scene + safety for the whole page, grounded in the merged
/// OCR text (supplied as a separate user message part by the client).
/// Runtime-overridable default — see
/// [`crate::config::AnalysisConfig::grounding_prompt`].
pub const GROUNDING_PROMPT_DEFAULT: &str = concat!(
    "You are a manga/comic page analyzer. You are given the page image ",
    "(possibly downscaled) AND the OCR text already extracted from it. Using ",
    "both, return ONLY one minified JSON object with: tagging_results (5-10 ",
    "short lowercase content tags — characters, actions, setting, mood, ",
    "genre; include explicit/sexual tags when present; each tag distinct, no ",
    "duplicates); scene_description (one or two sentences on setting, ",
    "characters, and action, referencing the dialogue where relevant); ",
    "safety_flag (is_nsfw boolean + content_type from ",
    "sexual|nudity|gore|violence|disturbing, [] if none). CRITICAL: do not ",
    "repeat tags, words or sentences; keep each field concise and STOP when ",
    "done. No prose, no markdown."
);

/// Pass-A schema: `{ ocr_results }` only.
pub fn ocr_json_schema() -> serde_json::Value {
    serde_json::json!({
        "type": "object",
        "additionalProperties": false,
        "properties": {
            "ocr_results": {
                "type": "array",
                "maxItems": MAX_OCR_PER_CALL,
                "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "text": { "type": "string", "maxLength": MAX_OCR_TEXT_CHARS },
                        "kind": { "type": "string", "enum": OCR_KINDS },
                        "y": { "type": "number", "minimum": 0, "maximum": 1 }
                    },
                    "required": ["text", "kind", "y"]
                }
            }
        },
        "required": ["ocr_results"]
    })
}

/// Pass-B schema: `{ tagging_results, scene_description, safety_flag }`.
pub fn grounding_json_schema() -> serde_json::Value {
    serde_json::json!({
        "type": "object",
        "additionalProperties": false,
        "properties": {
            "tagging_results": {
                "type": "array",
                "maxItems": MAX_TAGS_PER_CALL,
                "items": { "type": "string", "maxLength": 64 }
            },
            "scene_description": { "type": "string", "maxLength": MAX_SCENE_CHARS },
            "safety_flag": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                    "is_nsfw": { "type": "boolean" },
                    "content_type": {
                        "type": "array",
                        "maxItems": CONTENT_WARNINGS.len(),
                        "items": { "type": "string", "enum": CONTENT_WARNINGS }
                    }
                },
                "required": ["is_nsfw", "content_type"]
            }
        },
        "required": ["tagging_results", "scene_description", "safety_flag"]
    })
}

/// JSON Schema for the analysis output, used in `response_format:
/// json_schema` mode (OpenAI structured outputs / LM Studio). Mirrors
/// [`crate::domain::page_analysis::VisionAnalysis`]; `additionalProperties:
/// false` + all-required keeps it valid under OpenAI strict mode while
/// staying compatible with LM Studio.
pub fn output_json_schema() -> serde_json::Value {
    serde_json::json!({
        "type": "object",
        "additionalProperties": false,
        "properties": {
            "ocr_results": {
                "type": "array",
                "maxItems": MAX_OCR_PER_CALL,
                "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "text": { "type": "string", "maxLength": MAX_OCR_TEXT_CHARS },
                        "kind": { "type": "string", "enum": OCR_KINDS }
                    },
                    "required": ["text", "kind"]
                }
            },
            "tagging_results": {
                "type": "array",
                "maxItems": MAX_TAGS_PER_CALL,
                "items": { "type": "string", "maxLength": 64 }
            },
            "scene_description": { "type": "string", "maxLength": MAX_SCENE_CHARS },
            "safety_flag": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                    "is_nsfw": { "type": "boolean" },
                    "content_type": {
                        "type": "array",
                        "maxItems": CONTENT_WARNINGS.len(),
                        "items": { "type": "string", "enum": CONTENT_WARNINGS }
                    }
                },
                "required": ["is_nsfw", "content_type"]
            }
        },
        "required": ["ocr_results", "tagging_results", "scene_description", "safety_flag"]
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::page_analysis::{ContentWarning, OcrKind, VisionAnalysis};

    #[test]
    fn vocabularies_match_the_domain_enums() {
        // Every prompt OCR kind must parse back to a real OcrKind (no
        // fallback to narration via a typo).
        for k in OCR_KINDS {
            assert_eq!(
                OcrKind::from_model_str(k),
                match k {
                    "speech" => OcrKind::Speech,
                    "thought" => OcrKind::Thought,
                    "narration" => OcrKind::Narration,
                    "sfx" => OcrKind::Sfx,
                    "title" => OcrKind::Title,
                    "caption" => OcrKind::Caption,
                    _ => unreachable!(),
                }
            );
        }
        for w in CONTENT_WARNINGS {
            assert!(
                ContentWarning::from_model_str(w).is_some(),
                "prompt warning {w} not in the domain vocabulary"
            );
        }
    }

    #[test]
    fn system_prompt_mentions_the_schema_keys() {
        for key in ["ocr_results", "tagging_results", "scene_description", "safety_flag"] {
            assert!(SYSTEM_PROMPT_DEFAULT.contains(key), "prompt missing {key}");
        }
    }

    #[test]
    fn output_schema_top_level_requires_all_four_keys() {
        let schema = output_json_schema();
        let required = schema["required"].as_array().unwrap();
        for key in ["ocr_results", "tagging_results", "scene_description", "safety_flag"] {
            assert!(
                required.iter().any(|v| v == key),
                "schema missing required {key}"
            );
        }
        // The kind enum carries exactly the OCR vocabulary.
        let kind_enum =
            &schema["properties"]["ocr_results"]["items"]["properties"]["kind"]["enum"];
        assert_eq!(kind_enum, &serde_json::json!(OCR_KINDS));
    }

    #[test]
    fn output_schema_accepts_a_real_analysis_shape() {
        // A response matching the domain DTO must deserialize — the schema
        // and the parser describe the same shape.
        let sample = serde_json::json!({
            "ocr_results": [{ "text": "Hi", "kind": "speech" }],
            "tagging_results": ["action"],
            "scene_description": "A street.",
            "safety_flag": { "is_nsfw": false, "content_type": [] }
        });
        let _: VisionAnalysis = serde_json::from_value(sample).unwrap();
        // Schema names the same object key the parser reads.
        assert!(output_json_schema()["properties"]
            .get("safety_flag")
            .is_some());
    }
}
