//! `impl Routes` for the storyboard subcommand.
//!
//! Application-specific endpoints:
//!   GET /state            — version counter + clip count + title (for UI polling)
//!   GET /manifest         — the full manifest JSON (UI fetches on load + on version bump)
//!   GET /video/:id/raw    — range-streamed raw clip
//!   GET /video/:id/fast   — range-streamed fast clip
//!   GET /poster/:id       — first-frame JPG (generated lazily if missing)
//!   GET /image/:id        — optional screenshot PNG
//!   GET /console-png/:id  — optional console-output PNG
//!   GET /final-video/raw  — concatenated final raw webm
//!   GET /final-video/fast — concatenated final fast webm

use super::manifest::{Clip, Manifest};
use crate::ffmpeg;
use crate::webapp::{video as webapp_video, RouteOutcome, Routes};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use tiny_http::{Header, Method, Request, Response};

const UI_HTML: &str = include_str!("ui.html");
const FAVICON_SVG: &str = include_str!("icon.svg");

pub struct StoryboardRoutes {
    pub manifest: Arc<RwLock<Manifest>>,
    pub version: Arc<AtomicU64>,
    /// Path to the backing manifest file on disk. Needed because
    /// `POST /concat` mutates the manifest and persists the new
    /// `final_fast_path` / `final_raw_path` back to the same file
    /// the file-watcher is monitoring.
    pub manifest_path: std::path::PathBuf,
}

impl StoryboardRoutes {
    /// Look up a clip by id and return a clone (so the lock can be
    /// released immediately).
    fn find_clip(&self, id: &str) -> Option<Clip> {
        self.manifest
            .read()
            .unwrap()
            .clips
            .iter()
            .find(|c| c.id == id)
            .cloned()
    }
}

impl Routes for StoryboardRoutes {
    fn ui_html(&self) -> &str {
        UI_HTML
    }

    fn favicon_svg(&self) -> &str {
        FAVICON_SVG
    }

    fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome {
        // Exact matches first.
        match (method, url) {
            (Method::Get, "/state") => return RouteOutcome::Handled(self.handle_state(request)),
            (Method::Get, "/manifest") => {
                return RouteOutcome::Handled(self.handle_manifest(request));
            }
            (Method::Post, "/concat") => {
                return RouteOutcome::Handled(self.handle_concat(request));
            }
            _ => {}
        }

        // Dynamic (prefix-based) GET routes. Every one below takes
        // ownership of `request`, so we commit to the branch as soon
        // as its prefix matches.
        if let Method::Get = method {
            if let Some(rest) = url.strip_prefix("/video/") {
                if let Some((id, kind)) = rest.rsplit_once('/') {
                    if kind == "raw" || kind == "fast" {
                        return RouteOutcome::Handled(self.handle_video_for(request, id, kind));
                    }
                }
            }
            if let Some(kind) = url.strip_prefix("/final-video/") {
                if kind == "raw" || kind == "fast" {
                    return RouteOutcome::Handled(self.handle_final_video(request, kind));
                }
            }
            if let Some(id) = url.strip_prefix("/poster/") {
                return RouteOutcome::Handled(self.handle_poster_for(request, id));
            }
            if let Some(id) = url.strip_prefix("/image/") {
                return RouteOutcome::Handled(self.handle_image_for(request, id));
            }
            if let Some(id) = url.strip_prefix("/console-png/") {
                return RouteOutcome::Handled(self.handle_console_png_for(request, id));
            }
        }

        RouteOutcome::NotHandled(request)
    }
}

impl StoryboardRoutes {
    fn handle_state(&self, request: Request) -> Result<(), String> {
        let m = self.manifest.read().unwrap();
        let version = self.version.load(Ordering::Relaxed);
        let body = format!(
            r#"{{"version":{},"clipCount":{},"title":{}}}"#,
            version,
            m.clips.len(),
            serde_json::to_string(&m.title).unwrap()
        );
        request
            .respond(json_response(200, &body))
            .map_err(|e| e.to_string())
    }

    fn handle_manifest(&self, request: Request) -> Result<(), String> {
        let m = self.manifest.read().unwrap();
        // Serialize manifest, then enrich each clip with `mtime_ms` of its
        // raw_path / fast_path so the UI can highlight "NEW since you last
        // saw this page" — fresh re-shoots stand out without forcing the
        // human reviewer to re-watch every clip.
        let mut v = serde_json::to_value(&*m).map_err(|e| e.to_string())?;
        if let Some(clips) = v.get_mut("clips").and_then(|c| c.as_array_mut()) {
            for clip in clips {
                let raw_mtime = clip
                    .get("raw_path")
                    .and_then(|p| p.as_str())
                    .and_then(|p| std::fs::metadata(p).ok())
                    .and_then(|md| md.modified().ok())
                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                    .map(|d| d.as_millis() as u64);
                let fast_mtime = clip
                    .get("fast_path")
                    .and_then(|p| p.as_str())
                    .and_then(|p| std::fs::metadata(p).ok())
                    .and_then(|md| md.modified().ok())
                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                    .map(|d| d.as_millis() as u64);
                if let Some(t) = raw_mtime {
                    clip["raw_mtime_ms"] = serde_json::json!(t);
                }
                if let Some(t) = fast_mtime {
                    clip["fast_mtime_ms"] = serde_json::json!(t);
                }
            }
        }
        let body = serde_json::to_string(&v).map_err(|e| e.to_string())?;
        request
            .respond(json_response(200, &body))
            .map_err(|e| e.to_string())
    }

    /// `POST /concat` — stitch every clip's `fast_path` into a single
    /// `final_fast_path`, write the result back to the manifest file,
    /// and bump the version so the UI re-fetches. Output lands next
    /// to the manifest file, named after the manifest title slug.
    /// Returns `{ok, path, clipCount}` on success, `{ok:false, error}`
    /// on failure.
    ///
    /// This is the endpoint the "Prepare for narration" button in
    /// the unified `adom-video-post app` UI hits to enable the Narrate
    /// phase without the user having to drop back to the CLI.
    fn handle_concat(&self, request: Request) -> Result<(), String> {
        let inputs: Vec<std::path::PathBuf> = {
            let m = self.manifest.read().unwrap();
            m.clips
                .iter()
                .filter_map(|c| c.fast_path.clone())
                .filter(|p| p.exists())
                .collect()
        };
        if inputs.is_empty() {
            let body = r#"{"ok":false,"error":"no fast clips in manifest — every clip needs a fast_path pointing at an existing file"}"#;
            return request
                .respond(json_response(400, body))
                .map_err(|e| e.to_string());
        }

        // Derive an output path next to the manifest file so it
        // lives in a predictable location without the caller having
        // to pick one.
        let parent = self
            .manifest_path
            .parent()
            .unwrap_or_else(|| std::path::Path::new("."));
        let title_slug: String = {
            let m = self.manifest.read().unwrap();
            m.title
                .chars()
                .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
                .collect::<String>()
                .trim_matches('-')
                .to_string()
        };
        let slug = if title_slug.is_empty() {
            "adom-video-post".to_string()
        } else {
            title_slug
        };
        let output = parent.join(format!("{}-final-fast.webm", slug));

        println!(
            "concat: {} clips -> {}",
            inputs.len(),
            output.display()
        );
        if let Err(e) = crate::ffmpeg::concat_videos(&inputs, &output) {
            let body = format!(
                r#"{{"ok":false,"error":"ffmpeg concat failed: {}"}}"#,
                e.replace('"', "\\\"")
            );
            return request
                .respond(json_response(500, &body))
                .map_err(|e| e.to_string());
        }

        // Persist to the manifest and bump the version.
        {
            let mut m = self.manifest.write().unwrap();
            m.final_fast_path = Some(output.clone());
            if let Err(e) = m.save(&self.manifest_path) {
                let body = format!(
                    r#"{{"ok":false,"error":"manifest save failed: {}"}}"#,
                    e.replace('"', "\\\"")
                );
                return request
                    .respond(json_response(500, &body))
                    .map_err(|e| e.to_string());
            }
        }
        self.version
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);

        let body = format!(
            r#"{{"ok":true,"path":"{}","clipCount":{}}}"#,
            output.display().to_string().replace('"', "\\\""),
            inputs.len()
        );
        request
            .respond(json_response(200, &body))
            .map_err(|e| e.to_string())
    }

    fn handle_video_for(&self, request: Request, id: &str, kind: &str) -> Result<(), String> {
        let clip = match self.find_clip(id) {
            Some(c) => c,
            None => {
                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
                return Ok(());
            }
        };
        let path = match kind {
            "raw" => clip.raw_path,
            "fast" => clip.fast_path,
            _ => None,
        };
        let Some(path) = path else {
            let _ = request.respond(text_response(
                404,
                &format!("clip {:?} has no {} path", id, kind),
            ));
            return Ok(());
        };
        if !path.exists() {
            let _ = request.respond(text_response(
                404,
                &format!("clip {:?} {} file missing on disk: {}", id, kind, path.display()),
            ));
            return Ok(());
        }
        webapp_video::serve_file_range(request, &path, "video/webm")
    }

    fn handle_poster_for(&self, request: Request, id: &str) -> Result<(), String> {
        // Look up the clip and figure out which source we can use
        // to generate a poster from. Prefer fast_path (already
        // processed, smaller, faster to seek), fall back to raw.
        let clip = match self.find_clip(id) {
            Some(c) => c,
            None => {
                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
                return Ok(());
            }
        };
        if let Some(p) = &clip.poster_path {
            if p.exists() {
                return webapp_video::serve_file_range(request, p, "image/jpeg");
            }
        }
        let source = clip
            .fast_path
            .as_ref()
            .filter(|p| p.exists())
            .or_else(|| clip.raw_path.as_ref().filter(|p| p.exists()));
        let Some(source) = source else {
            let _ = request.respond(text_response(
                404,
                &format!("clip {:?} has no video path to generate poster from", id),
            ));
            return Ok(());
        };
        // Generate lazily next to the source video.
        let dest: PathBuf = source.with_extension("poster.jpg");
        if !dest.exists() {
            if let Err(e) = ffmpeg::generate_poster(source, &dest) {
                let _ = request.respond(text_response(500, &format!("poster generation failed: {}", e)));
                return Ok(());
            }
        }
        // Write the generated path back into the manifest so a reload
        // picks it up. Best-effort — a write failure is not fatal.
        {
            let mut m = self.manifest.write().unwrap();
            if let Some(c) = m.clips.iter_mut().find(|c| c.id == id) {
                c.poster_path = Some(dest.clone());
            }
        }
        webapp_video::serve_file_range(request, &dest, "image/jpeg")
    }

    fn handle_image_for(&self, request: Request, id: &str) -> Result<(), String> {
        let clip = match self.find_clip(id) {
            Some(c) => c,
            None => {
                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
                return Ok(());
            }
        };
        let Some(path) = clip.image_path else {
            let _ = request.respond(text_response(404, &format!("clip {:?} has no image_path", id)));
            return Ok(());
        };
        if !path.exists() {
            let _ = request.respond(text_response(
                404,
                &format!("clip {:?} image missing: {}", id, path.display()),
            ));
            return Ok(());
        }
        webapp_video::serve_file_range(request, &path, "image/png")
    }

    fn handle_console_png_for(&self, request: Request, id: &str) -> Result<(), String> {
        let clip = match self.find_clip(id) {
            Some(c) => c,
            None => {
                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
                return Ok(());
            }
        };
        let Some(path) = clip.console_png_path else {
            let _ = request.respond(text_response(404, &format!("clip {:?} has no console_png_path", id)));
            return Ok(());
        };
        if !path.exists() {
            let _ = request.respond(text_response(
                404,
                &format!("clip {:?} console-png missing: {}", id, path.display()),
            ));
            return Ok(());
        }
        webapp_video::serve_file_range(request, &path, "image/png")
    }

    fn handle_final_video(&self, request: Request, kind: &str) -> Result<(), String> {
        let m = self.manifest.read().unwrap();
        let path = match kind {
            "raw" => m.final_raw_path.clone(),
            "fast" => m.final_fast_path.clone(),
            _ => None,
        };
        drop(m);
        let Some(path) = path else {
            let _ = request.respond(text_response(
                404,
                &format!("no final_{}_path in manifest", kind),
            ));
            return Ok(());
        };
        if !path.exists() {
            let _ = request.respond(text_response(
                404,
                &format!("final {} file missing: {}", kind, path.display()),
            ));
            return Ok(());
        }
        webapp_video::serve_file_range(request, &path, "video/webm")
    }
}

fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(body)
        .with_status_code(status)
        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
}

fn text_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(body)
        .with_status_code(status)
        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap())
}