//! Picture edit panel commands (plan W10-16, W10-20): size helpers, rotation,
//! reread / relink, file information, "Edit Image".

// Ported from xfig src/e_edit.c (xfig licence, see LICENSE-xfig).

use super::{num, opt_num, string, CmdResult, Command};
use crate::{import, Engine, EngineError};
use rustfig_geom::Point;
use rustfig_model::{ObjectId, ObjectKind, Picture};
use serde_json::{json, Value};
use std::sync::Arc as Shared;

/// Fig units per pixel of a picture at its original size (80 pixels per inch).
pub const UNITS_PER_PIXEL: f64 = 15.0;

/// What the panel shows about a picture's file.
#[derive(Debug, Clone, PartialEq)]
pub struct PictureInfo {
    /// The file is there.
    pub exists: bool,
    /// "PNG", "JPEG", "GIF", "EPS", "PDF", or the file's extension.
    pub kind: String,
    pub width: u32,
    pub height: u32,
    /// Distinct colours (counting stops at 257).
    pub colours: usize,
    /// Some pixel is fully transparent.
    pub transparent: bool,
}

/// D11 (`w_capture.c`): the default file name of a screen capture: the figure's name and the
/// time in seconds since 1970 (xfig's `figure1_828065129.pcx`), as PNG; `capture_<secs>.png`
/// while the figure has no name.
pub fn capture_file_name(figure: Option<&str>, secs: u64) -> String {
    let stem = figure
        .and_then(|f| std::path::Path::new(f).file_stem())
        .and_then(|s| s.to_str())
        .filter(|s| !s.is_empty())
        .unwrap_or("capture");
    format!("{stem}_{secs}.png")
}

/// Type of a picture file: its first bytes, else its extension.
pub fn picture_type(path: &str) -> String {
    use std::io::Read;
    let mut head = [0u8; 8];
    let mut n = std::fs::File::open(path)
        .and_then(|mut f| f.read(&mut head))
        .unwrap_or(0);
    // A picture kept in memory (the browser, C2).
    if n == 0 {
        if let Some(b) = import::stored_picture(path) {
            n = b.len().min(8);
            head[..n].copy_from_slice(b.get(..n).unwrap_or_default());
        }
    }
    let h = head.get(..n).unwrap_or(&[]);
    if h.starts_with(b"\x89PNG") {
        "PNG".into()
    } else if h.starts_with(&[0xFF, 0xD8]) {
        "JPEG".into()
    } else if h.starts_with(b"GIF8") {
        "GIF".into()
    } else if h.starts_with(b"%PDF") {
        "PDF".into()
    } else if h.starts_with(b"%!PS") || h.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) {
        "EPS".into()
    } else {
        std::path::Path::new(path)
            .extension()
            .and_then(|e| e.to_str())
            .map(str::to_uppercase)
            .unwrap_or_default()
    }
}

/// Information about a picture (read-only).
pub fn info(pic: &Picture) -> PictureInfo {
    // A relative name resolves against the figure's folder, not the working directory `is_file`
    // looks in: a loaded image then proves the file was found. An absolute name is checked on disk.
    let path = std::path::Path::new(&pic.file);
    let exists =
        path.is_file() || import::stored_picture(&pic.file).is_some() || (pic.image.is_some() && path.is_relative());
    let (mut width, mut height, mut colours, mut transparent) = (0, 0, 0, false);
    if let Some(img) = &pic.image {
        width = img.width;
        height = img.height;
        let mut set = std::collections::HashSet::new();
        for px in img.rgba.chunks(4) {
            let &[r, g, b, a] = px else { continue };
            if a == 0 {
                transparent = true;
            } else if set.len() <= 256 {
                set.insert((r, g, b));
            }
        }
        colours = set.len();
    }
    PictureInfo {
        exists,
        kind: picture_type(&pic.file),
        width,
        height,
        colours,
        transparent,
    }
}

/// xfig's rotation of the picture box from the corner order: 0, 90, 180, 270.
pub fn rotation_of(p1: Point, p2: Point) -> i32 {
    let (dx, dy) = (p2.x - p1.x, p2.y - p1.y);
    if dx < 0.0 && dy < 0.0 {
        180
    } else if dx < 0.0 {
        270
    } else if dy < 0.0 {
        90
    } else {
        0
    }
}

/// The opposite corner for rotation `deg`, keeping the corner `p1` and the
/// box's width and height.
pub fn with_rotation(p1: Point, p2: Point, deg: i32) -> Option<Point> {
    let (w, h) = ((p2.x - p1.x).abs(), (p2.y - p1.y).abs());
    let (sx, sy) = match deg {
        0 => (1.0, 1.0),
        90 => (1.0, -1.0),
        180 => (-1.0, -1.0),
        270 => (-1.0, 1.0),
        _ => return None,
    };
    Some(Point::new(p1.x + sx * w, p1.y + sy * h))
}

/// How the size buttons change the box.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Resize {
    /// "Shrink to orig": keep the proportions, shrink the longer side.
    Shrink,
    /// "Expand to orig": keep the proportions, grow the shorter side.
    Expand,
    /// "Use original size".
    Original,
    /// "Scale by %" (of the original size).
    Percent(f64),
}

/// New opposite corner after a size button (xfig `shrink_pic`, `expand_pic`,
/// `origsize_pic`, `scale_percent_pic`). `width`/`height` are the picture's
/// pixels. `None` when the box has no area or the picture no size.
pub fn resize_corner(mode: Resize, p1: Point, p2: Point, flipped: bool, width: u32, height: u32) -> Option<Point> {
    if width == 0 || height == 0 {
        return None;
    }
    let (dx, dy) = (p2.x - p1.x, p2.y - p1.y);
    if dx == 0.0 || dy == 0.0 || !dx.is_finite() || !dy.is_finite() {
        return None;
    }
    let orig_ratio = f64::from(height) / f64::from(width);
    let (sx, sy) = (dx.signum(), dy.signum());
    let rot = rotation_of(p1, p2);
    let upright = (rot == 0 || rot == 180) != flipped;
    let size = |pct: f64| {
        Point::new(
            p1.x + sx * (f64::from(width) * UNITS_PER_PIXEL * pct / 100.0),
            p1.y + sy * (f64::from(height) * UNITS_PER_PIXEL * pct / 100.0),
        )
    };
    let grow = match mode {
        Resize::Original => return Some(size(100.0)),
        Resize::Percent(pct) => {
            let pct = if pct.is_finite() && pct > 0.0 {
                pct.min(100_000.0)
            } else {
                0.001
            };
            return Some(size(pct));
        }
        Resize::Expand => true,
        Resize::Shrink => false,
    };
    let (ax, ay) = (dx.abs(), dy.abs());
    // The box's ratio in the picture's own direction.
    let ratio = if upright { ay / ax } else { ax / ay };
    // Expand changes the side that makes the box larger, shrink the one
    // that makes it smaller.
    let change_first = if grow { ratio < orig_ratio } else { ratio > orig_ratio };
    let mut q = p2;
    if upright {
        if change_first {
            q.y = p1.y + sy * (ax * orig_ratio).trunc();
        } else {
            q.x = p1.x + sx * (ay / orig_ratio).trunc();
        }
    } else if change_first {
        q.x = p1.x + sx * (ay * orig_ratio).trunc();
    } else {
        q.y = p1.y + sy * (ax / orig_ratio).trunc();
    }
    Some(q)
}

pub(super) fn commands() -> Vec<Command> {
    let cmd = |id, label, params, mutates, run| Command {
        id,
        label,
        menu: "",
        shortcut: "",
        params,
        mutates,
        enabled: super::always,
        run,
    };
    vec![
        cmd(
            "picture.resize",
            "Resize picture",
            "{\"id\", \"mode\": shrink|expand|original|scale, \"percent\"?: number (scale)} — the picture panel's size buttons; moves the opposite corner",
            true,
            resize,
        ),
        cmd(
            "picture.rotate",
            "Rotate picture box",
            "{\"id\", \"rotation\": 0|90|180|270} — the picture panel's rotation; changes the corner order",
            true,
            rotate,
        ),
        cmd(
            "picture.set_file",
            "Reread / relink picture",
            "{\"id\", \"path\"?: string (default: the picture's file; Reread)} — loads the file again or points the picture at another one (Relink)",
            true,
            set_file,
        ),
        cmd(
            "picture.info",
            "Picture information",
            "{\"id\"} -> {file, exists, type, width, height, colours, transparent, rotation, flipped}",
            false,
            info_cmd,
        ),
        cmd(
            "picture.store",
            "Keep a picture in memory",
            "{\"name\": string, \"base64\": string} — keeps the picture's bytes under its file name for the session; pictures with that file name (Picture mode, Reread, Relink, a merged or opened figure) then load from memory when the file is not on disk. The browser has no files, so its Picture mode uses this",
            false,
            store,
        ),
        Command {
            id: "picture.import_objects",
            label: "Import picture as objects",
            menu: "",
            shortcut: "",
            params: "{\"id\"?} — replaces a PDF picture (default: the selected one) with the first page of its file as Fig objects in one compound that fills the picture's box, at the picture's depth; the importer's warnings go to the message panel. PDF pictures only (an EPS picture cannot be imported)",
            mutates: true,
            enabled: can_import_objects,
            run: import_objects,
        },
        cmd(
            "picture.edit_image",
            "Edit picture's image",
            "{\"id\"} — opens the picture's file in the system's default application (native only)",
            false,
            edit_image,
        ),
    ]
}

/// Corners and picture of picture object `id`.
fn target(e: &Engine, p: &Value) -> Result<(ObjectId, Point, Point, Picture), EngineError> {
    let id = ObjectId(num(p, "id")?.round().max(0.0) as u64);
    let o = e
        .doc()
        .get(id)
        .ok_or_else(|| EngineError::not_found(format!("no top-level object with id {}", id.0)))?;
    if let ObjectKind::Polyline(pl) = &o.kind {
        if let (Some(pic), Some(a), Some(b)) = (&pl.picture, pl.points.first(), pl.points.get(2)) {
            return Ok((id, *a, *b, pic.clone()));
        }
    }
    Err(EngineError::bad_params("object is not a picture"))
}

fn set_corners(e: &mut Engine, id: ObjectId, a: Point, b: Point) {
    if let Some(o) = e.doc_mut().get_mut(id) {
        if let ObjectKind::Polyline(pl) = &mut o.kind {
            pl.points = vec![a, Point::new(b.x, a.y), b, Point::new(a.x, b.y)];
        }
    }
}

fn resize(e: &mut Engine, p: &Value) -> CmdResult {
    let (id, a, b, pic) = target(e, p)?;
    let mode = match string(p, "mode")?.as_str() {
        "shrink" => Resize::Shrink,
        "expand" => Resize::Expand,
        "original" => Resize::Original,
        "scale" => Resize::Percent(opt_num(p, "percent")?.unwrap_or(100.0)),
        _ => {
            return Err(EngineError::bad_params(
                "mode must be shrink, expand, original or scale",
            ))
        }
    };
    let (w, h) = pic.image.as_ref().map(|i| (i.width, i.height)).unwrap_or((0, 0));
    let q = resize_corner(mode, a, b, pic.flipped, w, h)
        .ok_or_else(|| EngineError::bad_params("the picture has no size or the box no area"))?;
    if q.x.round() == a.x.round() || q.y.round() == a.y.round() || !q.is_finite() {
        return Err(EngineError::bad_params("the box would have no area"));
    }
    set_corners(e, id, a, q);
    Ok(json!({"id": id.0, "corner": [q.x, q.y]}))
}

fn rotate(e: &mut Engine, p: &Value) -> CmdResult {
    let (id, a, b, _) = target(e, p)?;
    let deg = num(p, "rotation")?.round() as i32;
    let q = with_rotation(a, b, deg).ok_or_else(|| EngineError::bad_params("rotation must be 0, 90, 180 or 270"))?;
    set_corners(e, id, a, q);
    Ok(json!({"id": id.0, "rotation": deg}))
}

fn store(_e: &mut Engine, p: &Value) -> CmdResult {
    let name = string(p, "name")?;
    let bytes = crate::base64_decode(&string(p, "base64")?);
    // Only what the image crate or readpics can read is worth keeping.
    if bytes.is_empty() {
        return Err(EngineError::bad_params("no picture data"));
    }
    import::store_picture(&name, bytes).map_err(EngineError::bad_params)?;
    Ok(json!({"name": name}))
}

fn set_file(e: &mut Engine, p: &Value) -> CmdResult {
    let (id, _, _, pic) = target(e, p)?;
    let path = match p.get("path") {
        None | Some(Value::Null) => pic.file.clone(),
        Some(_) => string(p, "path")?,
    };
    if path.is_empty() {
        return Err(EngineError::bad_params("the picture has no file name"));
    }
    let img = import::load_picture(std::path::Path::new(&path)).map_err(EngineError::io)?;
    if let Some(o) = e.doc_mut().get_mut(id) {
        if let ObjectKind::Polyline(pl) = &mut o.kind {
            if let Some(pic) = &mut pl.picture {
                pic.file = path.clone();
                pic.image = Some(Shared::new(img));
            }
        }
    }
    Ok(json!({"id": id.0, "file": path}))
}

fn info_cmd(e: &mut Engine, p: &Value) -> CmdResult {
    let (id, a, b, pic) = target(e, p)?;
    let i = info(&pic);
    Ok(json!({
        "id": id.0, "file": pic.file, "exists": i.exists, "type": i.kind,
        "width": i.width, "height": i.height, "colours": i.colours,
        "transparent": i.transparent, "rotation": rotation_of(a, b), "flipped": pic.flipped,
    }))
}

fn edit_image(e: &mut Engine, p: &Value) -> CmdResult {
    let (id, _, _, pic) = target(e, p)?;
    if !std::path::Path::new(&pic.file).is_file() {
        return Err(EngineError::not_found(format!("{}: file not found", pic.file)));
    }
    let editor = e.prefs.str("image_editor").unwrap_or_default();
    open_external(&pic.file, &editor)?;
    Ok(json!({"id": id.0, "file": pic.file}))
}

/// The words of an editor command line with `%f` replaced by the file (the
/// file is added as the last word when there is no `%f`), as xfig's Image
/// editor setting; `None` for xfig's default (the system's own program).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn editor_command(editor: &str, file: &str) -> Option<Vec<String>> {
    let words: Vec<&str> = editor.split_whitespace().collect();
    // The system's opener under any system's name means "the default".
    if words.is_empty()
        || matches!(editor.trim(), "xdg-open" | "open" | "start")
        || editor.trim() == crate::prefs::SYSTEM_OPEN
    {
        return None;
    }
    let mut out: Vec<String> = words.iter().map(|w| w.replace("%f", file)).collect();
    if !editor.contains("%f") {
        out.push(file.to_string());
    }
    Some(out)
}

#[cfg(not(target_arch = "wasm32"))]
fn open_external(file: &str, editor: &str) -> Result<(), EngineError> {
    use std::process::Command as Process;
    if let Some(words) = editor_command(editor, file) {
        let Some((prog, args)) = words.split_first() else {
            return Err(EngineError::io("the image editor command is empty"));
        };
        return Process::new(prog)
            .args(args)
            .spawn()
            .map(|_| ())
            .map_err(|err| EngineError::io(format!("can't start the image editor {prog}: {err}")));
    }
    let mut c = if cfg!(target_os = "macos") {
        let mut c = Process::new("open");
        c.arg(file);
        c
    } else if cfg!(target_os = "windows") {
        let mut c = Process::new("cmd");
        c.args(["/C", "start", ""]).arg(file);
        c
    } else {
        let mut c = Process::new("xdg-open");
        c.arg(file);
        c
    };
    c.spawn()
        .map(|_| ())
        .map_err(|err| EngineError::io(format!("can't start an editor for {file}: {err}")))
}

#[cfg(target_arch = "wasm32")]
fn open_external(_file: &str, _editor: &str) -> Result<(), EngineError> {
    Err(EngineError::io("Edit Image is not available in the web app"))
}

// ---- G6: Import as objects ----

/// The affine map of a PDF page (`pw` x `ph` Fig units, origin top-left) onto
/// the picture box `min`..`min + (bw, bh)` as the canvas draws the picture: the
/// box's own corners, the picture transposed when flipped (Fig's "flipped
/// about the diagonal").
pub fn page_to_box(pw: f64, ph: f64, min: Point, bw: f64, bh: f64, flipped: bool) -> rustfig_geom::Affine {
    use rustfig_geom::Affine;
    if flipped {
        Affine {
            a: 0.0,
            b: bh / pw.max(1.0e-9),
            c: bw / ph.max(1.0e-9),
            d: 0.0,
            e: min.x,
            f: min.y,
        }
    } else {
        Affine {
            a: bw / pw.max(1.0e-9),
            b: 0.0,
            c: 0.0,
            d: bh / ph.max(1.0e-9),
            e: min.x,
            f: min.y,
        }
    }
}

/// Whether top-level object `id` is a picture whose file is a PDF (by content).
fn is_pdf_picture(e: &Engine, id: ObjectId) -> bool {
    let Some(o) = e.doc().get(id) else { return false };
    let ObjectKind::Polyline(pl) = &o.kind else {
        return false;
    };
    pl.picture
        .as_ref()
        .is_some_and(|pic| pl.points.len() >= 3 && picture_type(&pic.file) == "PDF")
}

/// G6: enabled while a PDF picture is selected.
fn can_import_objects(e: &Engine) -> bool {
    e.selection().iter().any(|&id| is_pdf_picture(e, id))
}

fn import_objects(e: &mut Engine, p: &Value) -> CmdResult {
    let id = match opt_num(p, "id")? {
        Some(n) => ObjectId(n.round().max(0.0) as u64),
        None => e
            .selection()
            .iter()
            .copied()
            .find(|&id| is_pdf_picture(e, id))
            .ok_or_else(|| EngineError::bad_params("select a PDF picture or give an \"id\""))?,
    };
    let (id, a, b, pic) = target(e, &json!({"id": id.0}))?;
    let dir = e
        .path()
        .and_then(|p| std::path::Path::new(p).parent())
        .map(std::path::Path::to_path_buf);
    let path = import::resolve(&pic.file, dir.as_deref());
    let bytes: Vec<u8> = match std::fs::metadata(&path) {
        Ok(m) if m.len() > rustfig_format::MAX_FILE_BYTES as u64 => {
            return Err(EngineError::io(format!("{}: file is too large", pic.file)))
        }
        Ok(_) => std::fs::read(&path).map_err(|err| EngineError::io(format!("{}: {err}", pic.file)))?,
        Err(_) => match import::stored_picture(&pic.file) {
            Some(b) => b.to_vec(),
            None => return Err(EngineError::not_found(format!("{}: file not found", pic.file))),
        },
    };
    if !import::pdf::is_pdf(&bytes) {
        return Err(EngineError::bad_params(
            "only a PDF picture can be imported as objects (an EPS picture cannot)",
        ));
    }
    let r = rustfig_geom::Rect::from_points([a, b]);
    let (bw, bh) = (r.max.x - r.min.x, r.max.y - r.min.y);
    if !(bw.is_finite() && bh.is_finite() && bw >= 1.0 && bh >= 1.0) {
        return Err(EngineError::bad_params("the picture box has no area"));
    }
    // The vectors, even when the PDF carries an embedded RustFig figure: the
    // user asked for objects drawn from the page.
    let (pw, ph) = import::pdf::page_size(&bytes, 1).map_err(EngineError::format)?;
    let stem = super::file::pdf_stem(&pic.file);
    let (doc, warnings) = super::file::load_pdf(&bytes, &json!({}), &stem, dir.as_deref())?;
    let depth = e.doc().get(id).map(|o| o.depth()).unwrap_or(50);
    let t = page_to_box(pw, ph, r.min, bw, bh, pic.flipped);
    let mut objects = super::file::mapped_objects(e, doc);
    if objects.is_empty() {
        return Err(EngineError::bad_params("the PDF page has nothing to import"));
    }
    for o in &mut objects {
        o.transform(&t, true);
    }
    let mut comp = rustfig_model::Object::new(ObjectKind::Compound(rustfig_model::Compound { objects }));
    comp.set_depth(depth);
    let Some(index) = e.doc().index_of(id) else {
        return Err(EngineError::not_found("the picture is gone"));
    };
    e.doc_mut().objects.remove(index);
    let new_id = e.add_object(comp);
    let objs = &mut e.doc_mut().objects;
    if let Some(o) = objs.pop() {
        let at = index.min(objs.len());
        objs.insert(at, o);
    }
    e.set_selection(vec![new_id]);
    if !warnings.is_empty() {
        e.set_status(format!("Imported the PDF as objects: {}", warnings.join("; ")));
    }
    Ok(json!({"id": new_id.0, "warnings": warnings}))
}

#[cfg(test)]
mod editor_tests {
    use super::{capture_file_name, editor_command};

    #[test]
    fn image_editor_command_takes_the_file_for_percent_f() {
        assert_eq!(editor_command("xdg-open", "a.png"), None);
        assert_eq!(editor_command("  ", "a.png"), None);
        assert_eq!(
            editor_command("gimp %f", "a.png"),
            Some(vec!["gimp".to_string(), "a.png".to_string()])
        );
        // Without %f the file is the last word.
        assert_eq!(
            editor_command("xv -quit", "a.png"),
            Some(vec!["xv".to_string(), "-quit".to_string(), "a.png".to_string()])
        );
    }

    #[test]
    fn capture_file_name_follows_xfig() {
        assert_eq!(
            capture_file_name(Some("/a/b/figure1.fig"), 828_065_129),
            "figure1_828065129.png"
        );
        assert_eq!(capture_file_name(None, 7), "capture_7.png");
        assert_eq!(capture_file_name(Some(""), 7), "capture_7.png");
    }
}
