//! Storyboard manifest schema.
//!
//! A manifest is the single source of truth for a multi-clip video
//! review session. It's written incrementally by the user's demo
//! script (via `adom-video-post manifest add`, called once per step), and
//! read by every post-processing subcommand (`storyboard`, `process`,
//! `voiceover`, `publish`, `concat`).
//!
//! The demo script stays 100% custom bash/python — adom-video-post does
//! not try to execute recording logic itself. Only the handoff
//! artifact is structured.

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

/// Top-level manifest. `clips` is ordered — `concat` stitches them
/// in this order, `storyboard` renders them top-to-bottom, etc.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Manifest {
    /// Shown in the storyboard header and anywhere a human-readable
    /// label is needed. Defaults to "Untitled" when `init` is called
    /// without --title.
    pub title: String,

    /// Ordered list of clips. Append-only in normal usage; `remove`
    /// is available for re-shoots.
    #[serde(default)]
    pub clips: Vec<Clip>,

    /// Absolute path to the raw concat produced by `adom-video-post concat
    /// --kind raw`. Set by that subcommand when it runs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub final_raw_path: Option<PathBuf>,

    /// Absolute path to the fast concat produced by `adom-video-post concat
    /// --kind fast`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub final_fast_path: Option<PathBuf>,

    /// Optional publish target — where the final narrated video
    /// should land when the user eventually runs `adom-video-post publish`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub publish: Option<PublishTarget>,
}

/// One clip in the review. All paths are absolute. Any missing
/// file generates a UI warning on the affected card, never a hard
/// error, so partial / in-progress manifests work.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Clip {
    /// Stable string id — used in URLs for the storyboard's per-clip
    /// `/video/:id/raw` etc. endpoints. Typically `step-01`, `step-02`.
    pub id: String,

    /// Human-readable title shown in the card header and timeline strip.
    pub title: String,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Raw silent recording for this step (absolute path).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub raw_path: Option<PathBuf>,

    /// Post-speedup version of `raw_path` (absolute path). Produced
    /// by `adom-video-post process`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fast_path: Option<PathBuf>,

    /// Optional pre-generated poster JPG for the video card. When
    /// unset, the storyboard subcommand generates one lazily on load
    /// and writes the path back here.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub poster_path: Option<PathBuf>,

    /// Optional screenshot (e.g. a Fusion grid capture) shown as a
    /// thumbnail alongside the clip. Click-to-zoom lightbox.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub image_path: Option<PathBuf>,

    /// Optional console-log PNG — a screenshot of a cmdline output
    /// panel. Useful for data-heavy steps where the sped-up video
    /// blurs through the actual exported values.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub console_png_path: Option<PathBuf>,

    /// Zero or more speedup events that were applied (or will be
    /// applied) inside this step's clip. `adom-video-post process` uses
    /// them to build its ffmpeg filter graph; the storyboard UI
    /// surfaces them as colored badges on the card.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub speedup_events: Vec<SpeedupEvent>,

    /// Opaque action log bullets for the card's "what happened here"
    /// list. Free-form strings; the script writer decides what to
    /// put in them.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub actions: Vec<String>,

    /// Per-card warnings surfaced in the UI (missing file, speedup
    /// out of range, etc.). Populated automatically by the
    /// storyboard loader when it notices problems, plus any strings
    /// the manifest writer injected via `--warning`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<String>,

    /// The TTS narration script for this clip — the exact text that
    /// was rendered via adom-tts / edge-tts and muxed into the audio
    /// track. Surfaced in the storyboard UI under a collapsible
    /// "▸ TTS narration" disclosure. Distinct from `description`,
    /// which is the demo-author's INTENT for the clip; narration is
    /// the spoken text the viewer will hear.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub narration: Option<String>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SpeedupEvent {
    pub speed: u32,
    pub label: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PublishTarget {
    /// Wiki page ref like `apps/adom-desktop`.
    pub page: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub caption: Option<String>,
}

impl Manifest {
    pub fn load(path: &Path) -> Result<Self, String> {
        let body = fs::read_to_string(path)
            .map_err(|e| format!("read {}: {}", path.display(), e))?;
        serde_json::from_str(&body)
            .map_err(|e| format!("parse {}: {}", path.display(), e))
    }

    /// Atomic write via `<path>.tmp` + rename. Concurrent readers
    /// either see the old file or the new one, never a half-written
    /// one — critical for the storyboard's file-watcher thread.
    pub fn save(&self, path: &Path) -> Result<(), String> {
        let tmp = path.with_extension("json.tmp");
        let body = serde_json::to_string_pretty(self)
            .map_err(|e| format!("serialize manifest: {}", e))?;
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                fs::create_dir_all(parent)
                    .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
            }
        }
        fs::write(&tmp, body)
            .map_err(|e| format!("write {}: {}", tmp.display(), e))?;
        fs::rename(&tmp, path)
            .map_err(|e| format!("rename {} -> {}: {}", tmp.display(), path.display(), e))?;
        Ok(())
    }

    pub fn init(title: &str) -> Self {
        Self {
            title: title.to_string(),
            clips: Vec::new(),
            final_raw_path: None,
            final_fast_path: None,
            publish: None,
        }
    }
}

/// Parse a `speed:label` pair as supplied on the command line
/// (e.g. `--speedup "20:Exporting gerbers"`). Returns an error
/// with a hint if the format is wrong.
pub fn parse_speedup_arg(s: &str) -> Result<SpeedupEvent, String> {
    let (speed_str, label) = s
        .split_once(':')
        .ok_or_else(|| format!(
            "--speedup must be formatted as 'SPEED:LABEL', got {:?}. Hint: try '20:Exporting gerbers'",
            s
        ))?;
    let speed: u32 = speed_str
        .parse()
        .map_err(|_| format!("--speedup speed must be an integer, got {:?}", speed_str))?;
    if !(2..=100).contains(&speed) {
        return Err(format!(
            "--speedup speed must be in [2, 100], got {}. Hint: anything above 100x looks like a glitch",
            speed
        ));
    }
    Ok(SpeedupEvent {
        speed,
        label: label.to_string(),
    })
}