//! video2slides 核心：存储布局、后台任务进度、ffmpeg 抽帧、逐帧灰度差异。
//!
//! 存储布局（data 根下）::
//!
//!     <root>/<client_id>/<video_id>/
//!         source.<ext>      原始上传
//!         frames/000001.jpg 抽出的帧（彩色，限宽，用作悬停大图预览）
//!         thumbs/000001.jpg 网格缩略图（小、压得狠）
//!         meta.json         { name, size, duration, interval, status, frames:[{idx,t,diff}] }

use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};

use image::imageops::FilterType;
use serde::{Deserialize, Serialize};

pub const PREVIEW_MAX_W: u32 = 1280; // 抽帧时直接限宽，悬停大图用这个
pub const THUMB_W: u32 = 320; // 网格缩略图宽度
pub const DIFF_W: u32 = 256; // 算差异时降采样宽度（灰度）
pub const JPEG_Q_FRAME: &str = "4"; // ffmpeg -q:v（越小越清晰）
pub const JPEG_Q_THUMB: u8 = 70;

/// clientId / videoId 只允许字母数字和 `_` `-`，挡路径穿越。
pub fn safe_id(s: &str) -> String {
    let cleaned: String = s
        .chars()
        .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
        .take(64)
        .collect();
    if cleaned.is_empty() {
        "anon".into()
    } else {
        cleaned
    }
}

pub fn client_dir(root: &Path, client: &str) -> PathBuf {
    root.join(safe_id(client))
}

pub fn video_dir(root: &Path, client: &str, vid: &str) -> PathBuf {
    client_dir(root, client).join(safe_id(vid))
}

// ---------------------------------------------------------------------------
// meta.json
// ---------------------------------------------------------------------------

#[derive(Serialize, Deserialize, Clone)]
pub struct FrameMeta {
    pub idx: u32,
    pub t: f64,
    pub diff: Option<f64>,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct Meta {
    pub name: String,
    pub size: u64,
    pub duration: f64,
    pub interval: Option<f64>,
    pub status: String, // uploaded | processing | done | error
    #[serde(default)]
    pub frames: Vec<FrameMeta>,
}

pub fn read_meta(root: &Path, client: &str, vid: &str) -> Option<Meta> {
    let p = video_dir(root, client, vid).join("meta.json");
    let data = std::fs::read(p).ok()?;
    serde_json::from_slice(&data).ok()
}

pub fn write_meta(root: &Path, client: &str, vid: &str, meta: &Meta) -> std::io::Result<()> {
    let d = video_dir(root, client, vid);
    std::fs::create_dir_all(&d)?;
    let data = serde_json::to_vec(meta).expect("serialize meta");
    std::fs::write(d.join("meta.json"), data)
}

#[derive(Serialize)]
pub struct VideoSummary {
    pub video_id: String,
    pub name: String,
    pub size: u64,
    pub duration: f64,
    pub interval: Option<f64>,
    pub status: String,
    pub frame_count: usize,
}

pub fn list_videos(root: &Path, client: &str) -> Vec<VideoSummary> {
    let base = client_dir(root, client);
    let Ok(entries) = std::fs::read_dir(&base) else {
        return vec![];
    };
    let mut dirs: Vec<String> = entries
        .flatten()
        .filter(|e| e.path().is_dir())
        .map(|e| e.file_name().to_string_lossy().into_owned())
        .collect();
    dirs.sort();
    dirs.reverse(); // 新的在前（video_id 用 uuid，无时间序，但保证稳定顺序）
    dirs.into_iter()
        .filter_map(|vid| {
            let m = read_meta(root, client, &vid)?;
            Some(VideoSummary {
                video_id: vid,
                name: m.name,
                size: m.size,
                duration: m.duration,
                interval: m.interval,
                status: m.status,
                frame_count: m.frames.len(),
            })
        })
        .collect()
}

pub fn delete_video(root: &Path, client: &str, vid: &str) -> bool {
    let d = video_dir(root, client, vid);
    if d.is_dir() {
        std::fs::remove_dir_all(&d).is_ok()
    } else {
        false
    }
}

// ---------------------------------------------------------------------------
// ffprobe 时长
// ---------------------------------------------------------------------------

pub fn probe_duration(path: &Path) -> f64 {
    let out = Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
        ])
        .arg(path)
        .output();
    match out {
        Ok(o) => String::from_utf8_lossy(&o.stdout).trim().parse().unwrap_or(0.0),
        Err(_) => 0.0,
    }
}

// ---------------------------------------------------------------------------
// 任务进度（内存态，进程级）
// ---------------------------------------------------------------------------

#[derive(Serialize, Clone)]
pub struct Job {
    pub status: String, // queued | extracting | processing | done | error
    pub progress: f64,  // 0..1
    pub message: String,
    pub frame_count: u32,
    pub error: String,
}

impl Default for Job {
    fn default() -> Self {
        Job {
            status: "queued".into(),
            progress: 0.0,
            message: "排队中…".into(),
            frame_count: 0,
            error: String::new(),
        }
    }
}

pub type JobMap = Arc<Mutex<HashMap<String, Arc<Mutex<Job>>>>>;

fn job_key(client: &str, vid: &str) -> String {
    format!("{}/{}", safe_id(client), safe_id(vid))
}

pub fn get_job(jobs: &JobMap, client: &str, vid: &str) -> Option<Job> {
    let map = jobs.lock().ok()?;
    let j = map.get(&job_key(client, vid))?;
    j.lock().ok().map(|g| g.clone())
}

fn upd(job: &Mutex<Job>, f: impl FnOnce(&mut Job)) {
    if let Ok(mut g) = job.lock() {
        f(&mut g);
    }
}

// ---------------------------------------------------------------------------
// 差异计算
// ---------------------------------------------------------------------------

fn round2(x: f64) -> f64 {
    (x * 100.0).round() / 100.0
}

fn mae_0_100(a: &image::GrayImage, b: &image::GrayImage) -> f64 {
    let (pa, pb) = (a.as_raw(), b.as_raw());
    let n = pa.len().min(pb.len());
    if n == 0 {
        return 0.0;
    }
    let mut sum: u64 = 0;
    for i in 0..n {
        sum += (pa[i] as i32 - pb[i] as i32).unsigned_abs() as u64;
    }
    round2(sum as f64 / n as f64 / 255.0 * 100.0)
}

// ---------------------------------------------------------------------------
// 抽帧 + 处理（在 spawn_blocking 里跑）
// ---------------------------------------------------------------------------

fn run_extract(
    src: &Path,
    frames_dir: &Path,
    interval: f64,
    duration: f64,
    job: &Mutex<Job>,
) -> Result<(), String> {
    std::fs::create_dir_all(frames_dir).map_err(|e| e.to_string())?;
    let vf = format!("fps=1/{interval},scale='min({PREVIEW_MAX_W},iw)':-2");
    let mut child = Command::new("ffmpeg")
        .args(["-nostdin", "-y", "-i"])
        .arg(src)
        .args(["-vf", &vf, "-q:v", JPEG_Q_FRAME, "-progress", "pipe:1", "-loglevel", "error"])
        .arg(frames_dir.join("%06d.jpg"))
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| format!("启动 ffmpeg 失败: {e}"))?;

    let dur_us = (duration.max(0.001)) * 1_000_000.0;
    if let Some(stdout) = child.stdout.take() {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if let Some(rest) = line.strip_prefix("out_time_us=") {
                if let Ok(us) = rest.trim().parse::<f64>() {
                    let p = (us / dur_us).min(1.0);
                    upd(job, |j| j.progress = p * 0.5);
                }
            }
        }
    }
    let status = child.wait().map_err(|e| e.to_string())?;
    if !status.success() {
        let mut err = String::new();
        if let Some(mut s) = child.stderr.take() {
            let _ = s.read_to_string(&mut err);
        }
        return Err(format!("ffmpeg 抽帧失败: {}", err.trim().chars().take(500).collect::<String>()));
    }
    upd(job, |j| j.progress = 0.5);
    Ok(())
}

fn frame_index(p: &Path) -> Option<u32> {
    p.file_stem()?.to_str()?.parse().ok()
}

fn save_thumb(img: &image::DynamicImage, dst: &Path) -> Result<(), String> {
    let (w, h) = (img.width().max(1), img.height().max(1));
    let nh = ((THUMB_W as f64) * h as f64 / w as f64).round().max(1.0) as u32;
    let small = img.resize_exact(THUMB_W, nh, FilterType::Triangle).to_rgb8();
    let mut f = std::fs::File::create(dst).map_err(|e| e.to_string())?;
    let mut buf = Vec::new();
    let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, JPEG_Q_THUMB);
    enc.encode_image(&small).map_err(|e| e.to_string())?;
    f.write_all(&buf).map_err(|e| e.to_string())
}

fn gray_small(img: &image::DynamicImage) -> image::GrayImage {
    let (w, h) = (img.width().max(1), img.height().max(1));
    let nh = ((DIFF_W as f64) * h as f64 / w as f64).round().max(1.0) as u32;
    img.resize_exact(DIFF_W, nh, FilterType::Triangle).to_luma8()
}

#[allow(clippy::too_many_arguments)]
fn process(
    root: PathBuf,
    client: String,
    vid: String,
    src: PathBuf,
    interval: f64,
    duration: f64,
    name: String,
    size: u64,
    job: Arc<Mutex<Job>>,
) {
    let vdir = video_dir(&root, &client, &vid);
    let frames_dir = vdir.join("frames");
    let thumbs_dir = vdir.join("thumbs");

    let result = (|| -> Result<usize, String> {
        upd(&job, |j| {
            j.status = "extracting".into();
            j.message = "抽帧中…".into();
        });
        std::fs::create_dir_all(&thumbs_dir).map_err(|e| e.to_string())?;
        run_extract(&src, &frames_dir, interval, duration, &job)?;

        let mut frame_files: Vec<PathBuf> = std::fs::read_dir(&frames_dir)
            .map_err(|e| e.to_string())?
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().map(|x| x == "jpg").unwrap_or(false))
            .collect();
        frame_files.sort();
        let total = frame_files.len();
        if total == 0 {
            return Err("没抽到任何帧，检查视频是否有效。".into());
        }

        upd(&job, |j| {
            j.status = "processing".into();
            j.message = "比对差异中…".into();
        });

        let mut frames_meta: Vec<FrameMeta> = Vec::with_capacity(total);
        let mut prev_gray: Option<image::GrayImage> = None;
        for (i, fp) in frame_files.iter().enumerate() {
            let idx = frame_index(fp).unwrap_or((i + 1) as u32);
            let img = image::open(fp).map_err(|e| format!("解码 {fp:?} 失败: {e}"))?;
            save_thumb(&img, &thumbs_dir.join(format!("{idx:06}.jpg")))?;
            let gray = gray_small(&img);
            let diff = prev_gray.as_ref().map(|p| mae_0_100(p, &gray));
            prev_gray = Some(gray);
            frames_meta.push(FrameMeta {
                idx,
                t: round2((idx.saturating_sub(1)) as f64 * interval),
                diff,
            });
            let p = 0.5 + 0.5 * (i + 1) as f64 / total as f64;
            upd(&job, |j| {
                j.progress = p;
                j.frame_count = (i + 1) as u32;
            });
        }

        let meta = Meta {
            name: name.clone(),
            size,
            duration,
            interval: Some(interval),
            status: "done".into(),
            frames: frames_meta,
        };
        write_meta(&root, &client, &vid, &meta).map_err(|e| e.to_string())?;
        Ok(total)
    })();

    match result {
        Ok(total) => upd(&job, |j| {
            j.status = "done".into();
            j.progress = 1.0;
            j.message = format!("完成，共 {total} 帧");
        }),
        Err(e) => {
            upd(&job, |j| {
                j.status = "error".into();
                j.error = e.clone();
                j.message = format!("出错: {e}");
            });
            // 把 meta 标成 error，供无任务时回退
            let mut meta = read_meta(&root, &client, &vid).unwrap_or(Meta {
                name,
                size,
                duration,
                interval: Some(interval),
                status: "error".into(),
                frames: vec![],
            });
            meta.status = "error".into();
            let _ = write_meta(&root, &client, &vid, &meta);
        }
    }
}

/// 启动后台分析。清掉旧 frames/thumbs（保留 source），允许换间隔重跑。
pub fn start_analysis(
    root: PathBuf,
    jobs: JobMap,
    client: String,
    vid: String,
    interval: f64,
) -> Result<(), String> {
    let vdir = video_dir(&root, &client, &vid);
    let src = std::fs::read_dir(&vdir)
        .map_err(|_| "源视频不存在".to_string())?
        .flatten()
        .map(|e| e.path())
        .find(|p| p.file_stem().map(|s| s == "source").unwrap_or(false))
        .ok_or_else(|| "源视频不存在".to_string())?;

    let duration = probe_duration(&src);
    let prev = read_meta(&root, &client, &vid);
    let name = prev
        .as_ref()
        .map(|m| m.name.clone())
        .unwrap_or_else(|| src.file_name().unwrap_or_default().to_string_lossy().into_owned());
    let size = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);

    let _ = std::fs::remove_dir_all(vdir.join("frames"));
    let _ = std::fs::remove_dir_all(vdir.join("thumbs"));

    write_meta(
        &root,
        &client,
        &vid,
        &Meta {
            name: name.clone(),
            size,
            duration,
            interval: Some(interval),
            status: "processing".into(),
            frames: vec![],
        },
    )
    .map_err(|e| e.to_string())?;

    let job = Arc::new(Mutex::new(Job::default()));
    if let Ok(mut map) = jobs.lock() {
        map.insert(job_key(&client, &vid), job.clone());
    }

    tokio::task::spawn_blocking(move || {
        process(root, client, vid, src, interval, duration, name, size, job);
    });
    Ok(())
}
