#![forbid(unsafe_code)]
//! # epiphany-editor-gui
//!
//! A **thin native GUI shell** over [`epiphany_editor_core::EditorSession`]: the
//! smallest real surface that proves the editor seam end to end. It renders the
//! session's resolved layout with `epiphany-render-svg`, rasterizes that SVG with
//! `resvg` into an `egui` texture, resolves clicks back to world coordinates to
//! select, and drives the note-editing intents from a toolbar and keyboard.
//!
//! Intentionally narrow (per the GUI vertical-slice plan): no duration editing and no
//! undo UI — the debug panel just shows the selection and the last applied op, enough
//! to confirm the op log is usable. A **pencil mode** turns a click into a
//! click-to-insert ([`EditorSession::insert_note_at`]) with make-room overwrite, on a
//! quarter-note grid. The GUI is the thing meant to surface the next real core gaps.
//!
//! It is a demo binary; there is no headless way to assert its rendering here, so the
//! one piece of nontrivial logic — the screen↔world coordinate map a click depends on
//! — is a pure [`ViewMap`] with a round-trip unit test.

use eframe::egui;

use epiphany_editor_core::{EditOutcome, EditorError, EditorSession, GridResolution};
use epiphany_engrave::Engraver;
use epiphany_layout_ir::{BoundingBox, HitShape, Point};
use epiphany_ops::{OperationKind, OperationPayload};
use epiphany_render_svg::{render, RenderOptions};

fn main() -> eframe::Result<()> {
    let options = eframe::NativeOptions::default();
    eframe::run_native(
        "Epiphany editor (demo)",
        options,
        Box::new(|_cc| Ok(Box::new(EditorApp::new()))),
    )
}

/// Maps between staff-space **world** coordinates (the hit-test map's space) and
/// on-screen points, given where the rendered image is currently drawn.
///
/// `epiphany-render-svg` lays the score out in a viewBox of `view_box = [min_x,
/// min_y, width, height]` staff spaces and draws it under a single
/// `translate(-min_x, max_y) scale(1 -1)` group (world is y-up, screen is y-down),
/// where `max_y = min_y + height`. Displaying that image in `rect` therefore makes
/// the world↔screen transform a uniform scale + flip, which this inverts.
struct ViewMap {
    min_x: f32,
    max_y: f32,
    vb_width: f32,
    rect: egui::Rect,
}

impl ViewMap {
    fn new(view_box: [f32; 4], rect: egui::Rect) -> Self {
        ViewMap {
            min_x: view_box[0],
            max_y: view_box[1] + view_box[3],
            vb_width: view_box[2],
            rect,
        }
    }

    /// On-screen points per staff space, derived from the displayed rect (so it is
    /// correct at any zoom/fit). Uniform: the image keeps the layout's aspect ratio.
    fn scale(&self) -> f32 {
        if self.vb_width > 0.0 {
            self.rect.width() / self.vb_width
        } else {
            1.0
        }
    }

    fn screen_to_world(&self, p: egui::Pos2) -> Point {
        let s = self.scale();
        Point::new(
            self.min_x + (p.x - self.rect.min.x) / s,
            self.max_y - (p.y - self.rect.min.y) / s,
        )
    }

    fn world_to_screen(&self, x: f32, y: f32) -> egui::Pos2 {
        let s = self.scale();
        egui::pos2(
            self.rect.min.x + (x - self.min_x) * s,
            self.rect.min.y + (self.max_y - y) * s,
        )
    }
}

/// The screen rectangle covering a hit shape, for the selection highlight.
fn shape_rect(shape: &HitShape, vm: &ViewMap) -> egui::Rect {
    match shape {
        HitShape::Box(BoundingBox {
            left,
            bottom,
            right,
            top,
        }) => egui::Rect::from_two_pos(
            vm.world_to_screen(left.0, top.0),
            vm.world_to_screen(right.0, bottom.0),
        ),
        HitShape::Segment { from, to, .. } => egui::Rect::from_two_pos(
            vm.world_to_screen(from.x.0, from.y.0),
            vm.world_to_screen(to.x.0, to.y.0),
        ),
    }
}

/// A short label for the last applied op, for the debug panel.
fn payload_label(payload: &OperationPayload) -> &'static str {
    match payload {
        OperationPayload::Primitive(kind) => match kind {
            OperationKind::Transpose(_) => "Transpose",
            OperationKind::ModifyIdentifiedPitch(_) => "ModifyIdentifiedPitch",
            OperationKind::DeleteIdentifiedPitch(_) => "DeleteIdentifiedPitch",
            OperationKind::DeleteEvent(_) => "DeleteEvent",
            OperationKind::InsertIdentifiedPitch(_) => "InsertIdentifiedPitch",
            OperationKind::InsertEvent(_) => "InsertEvent",
            OperationKind::RespellPitch(_) => "RespellPitch",
            OperationKind::DeclareTransaction(_) => "DeclareTransaction",
            _ => "primitive",
        },
        OperationPayload::ResolveConflict(_) => "ResolveConflict",
        OperationPayload::UndoTransaction(_) => "UndoTransaction",
    }
}

/// Rasterizes a rendered SVG string to an `egui` image, returning the image and the
/// SVG's **logical** (sub-pixel, pre-`ceil`) size. The pixmap dimensions are the
/// logical size rounded up to whole pixels; the logical size is what the image must
/// be *displayed* at so the click plane maps back to the layout exactly (displaying
/// the rounded-up pixmap size would stretch the mapping past the content). The score
/// is drawn over an opaque white background so the pixmap's premultiplied alpha is
/// fully opaque, matching `from_rgba_unmultiplied`.
fn rasterize(svg: &str) -> Option<(egui::ColorImage, egui::Vec2)> {
    let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).ok()?;
    let size = tree.size();
    let logical = egui::vec2(size.width(), size.height());
    let width = size.width().ceil().max(1.0) as u32;
    let height = size.height().ceil().max(1.0) as u32;
    let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?;
    pixmap.fill(resvg::tiny_skia::Color::WHITE);
    resvg::render(
        &tree,
        resvg::tiny_skia::Transform::identity(),
        &mut pixmap.as_mut(),
    );
    let image =
        egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], pixmap.data());
    Some((image, logical))
}

struct EditorApp {
    session: EditorSession,
    texture: Option<egui::TextureHandle>,
    /// `[min_x, min_y, width, height]` of the last render, in staff spaces.
    view_box: [f32; 4],
    /// The logical (sub-pixel) on-screen size of the last render, in points — what
    /// the texture is displayed at (its pixmap is this rounded up). Kept in step with
    /// `texture` / `view_box` so the click plane matches the displayed content.
    logical_size: egui::Vec2,
    px_per_staff_space: f32,
    needs_render: bool,
    /// Pencil ("insert") mode: a click on the staff inserts a note at that pitch and
    /// beat (with make-room overwrite) instead of selecting.
    pencil: bool,
    status: String,
}

impl EditorApp {
    fn new() -> Self {
        let score = epiphany_testkit::fixtures::ten_measure_single_staff(0);
        let session = EditorSession::open(score, Box::new(Engraver))
            .expect("the ten-measure fixture renders under the real engraver");
        EditorApp {
            session,
            texture: None,
            view_box: [0.0, 0.0, 1.0, 1.0],
            logical_size: egui::vec2(1.0, 1.0),
            px_per_staff_space: 12.0,
            needs_render: true,
            pencil: false,
            status: "opened ten_measure_single_staff".to_string(),
        }
    }

    /// Runs an intent, recording its outcome (or error) in the status line and
    /// scheduling a re-render.
    fn run(
        &mut self,
        name: &str,
        intent: impl FnOnce(&mut EditorSession) -> Result<EditOutcome, EditorError>,
    ) {
        self.status = match intent(&mut self.session) {
            Ok(outcome) => format!(
                "{name}: ok (changed={}, selection kept={})",
                outcome.graph_changed, outcome.selection_preserved
            ),
            Err(err) => format!("{name}: {err}"),
        };
        self.needs_render = true;
    }

    /// Re-renders the session's resolved layout to a texture. The view box, logical
    /// size, and texture are updated together (only on a successful rasterization), so
    /// the displayed pixels never disagree with the click plane; on failure the score
    /// view is cleared and the failure is surfaced rather than left stale.
    fn rerender(&mut self, ctx: &egui::Context) {
        let options = RenderOptions {
            px_per_staff_space: self.px_per_staff_space,
            ..Default::default()
        };
        let output = render(self.session.resolved(), &options);
        match rasterize(&output.svg) {
            Some((image, logical)) => {
                self.view_box = output.stats.view_box;
                self.logical_size = logical;
                self.texture = Some(ctx.load_texture("score", image, egui::TextureOptions::LINEAR));
            }
            None => {
                self.texture = None;
                self.status = "render failed: could not rasterize the score".to_string();
            }
        }
    }

    fn toolbar(&mut self, ui: &mut egui::Ui) {
        ui.horizontal_wrapped(|ui| {
            if ui.button("Delete").clicked() {
                self.run("delete", |s| s.delete_selection());
            }
            if ui.button("Transpose ♯").clicked() {
                self.run("transpose +1", |s| s.transpose_selection(1));
            }
            if ui.button("Transpose ♭").clicked() {
                self.run("transpose -1", |s| s.transpose_selection(-1));
            }
            if ui.button("Move ↑").clicked() {
                self.run("move up", |s| s.move_selection_staff_step(1));
            }
            if ui.button("Move ↓").clicked() {
                self.run("move down", |s| s.move_selection_staff_step(-1));
            }
            if ui.button("Add chord note").clicked() {
                self.run("add chord note", |s| s.add_note_to_selection());
            }
            if ui.button("Insert after").clicked() {
                self.run("insert after", |s| s.insert_note_after_selection());
            }
            ui.separator();
            ui.toggle_value(&mut self.pencil, "✏ Pencil (insert)");
            ui.separator();
            if ui
                .add(egui::Slider::new(&mut self.px_per_staff_space, 6.0..=28.0).text("zoom"))
                .changed()
            {
                self.needs_render = true;
            }
        });
    }

    fn debug_panel(&self, ui: &mut egui::Ui) {
        ui.heading("State");
        ui.label(format!("status: {}", self.status));
        ui.separator();
        match self.session.selection() {
            Some(sel) => {
                ui.label(format!("selection source: {:?}", sel.source));
                ui.label(format!("layout id: {:?}", sel.layout_object));
            }
            None => {
                ui.label("selection: none");
            }
        }
        ui.separator();
        ui.label(format!(
            "ops applied: {}",
            self.session.applied_operations().len()
        ));
        match self.session.last_applied() {
            Some(env) => {
                ui.label(format!("last op: {}", payload_label(&env.payload)));
                ui.label(format!("op id: {:?}", env.id));
            }
            None => {
                ui.label("last op: none");
            }
        }
        ui.separator();
        ui.label(format!(
            "Click a notehead to select. Pencil mode: {}.\n\
             Keys: Del delete · ↑/↓ staff-step move · +/− transpose · A add chord · I insert after · P pencil",
            if self.pencil { "ON — click to insert" } else { "off" }
        ));
    }

    fn handle_keys(&mut self, ctx: &egui::Context) {
        let k = ctx.input(|i| Keys {
            delete: i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace),
            move_up: i.key_pressed(egui::Key::ArrowUp),
            move_down: i.key_pressed(egui::Key::ArrowDown),
            sharp: i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
            flat: i.key_pressed(egui::Key::Minus),
            add: i.key_pressed(egui::Key::A),
            insert: i.key_pressed(egui::Key::I),
            pencil: i.key_pressed(egui::Key::P),
        });
        if k.pencil {
            self.pencil = !self.pencil;
            self.status = format!("pencil mode {}", if self.pencil { "on" } else { "off" });
        }
        if k.delete {
            self.run("delete", |s| s.delete_selection());
        }
        if k.move_up {
            self.run("move up", |s| s.move_selection_staff_step(1));
        }
        if k.move_down {
            self.run("move down", |s| s.move_selection_staff_step(-1));
        }
        if k.sharp {
            self.run("transpose +1", |s| s.transpose_selection(1));
        }
        if k.flat {
            self.run("transpose -1", |s| s.transpose_selection(-1));
        }
        if k.add {
            self.run("add chord note", |s| s.add_note_to_selection());
        }
        if k.insert {
            self.run("insert after", |s| s.insert_note_after_selection());
        }
    }

    fn score_view(&mut self, ui: &mut egui::Ui) {
        let Some(texture) = self.texture.clone() else {
            return;
        };
        // Display at the logical size (not the rounded-up pixmap size), cropping the
        // texture's uv to the logical content — so the click plane maps screen points
        // back to the layout exactly, with no sub-pixel stretch from the `ceil`.
        let pixmap = texture.size_vec2();
        let logical = self.logical_size;
        let (rect, response) = ui.allocate_exact_size(logical, egui::Sense::click());
        let uv = egui::Rect::from_min_max(
            egui::pos2(0.0, 0.0),
            egui::pos2(logical.x / pixmap.x, logical.y / pixmap.y),
        );
        ui.painter()
            .image(texture.id(), rect, uv, egui::Color32::WHITE);

        if response.clicked() {
            if let Some(pos) = response.interact_pointer_pos() {
                let world = ViewMap::new(self.view_box, rect).screen_to_world(pos);
                if self.pencil {
                    // Insert a note at the clicked pitch/beat on a quarter-note grid,
                    // making room over whatever it overlaps. This runs *after* this
                    // frame's rerender slot, so request a repaint to draw the edit, and
                    // return before the selection overlay below paints the new hit map
                    // over the old (not-yet-rerendered) texture.
                    self.run("insert note", |s| {
                        s.insert_note_at(world, &GridResolution::quarter())
                    });
                    ui.ctx().request_repaint();
                    return;
                } else {
                    // Select the topmost hit; on empty staff, report what a pencil
                    // click *would* insert (the pitch and grid-snapped beat).
                    let pitch = self.session.staff_pitch_at(world);
                    let position = self.session.position_at(world, &GridResolution::quarter());
                    self.status = match self.session.click(world) {
                        Some(_) => "selected".to_string(),
                        None => match (pitch, position) {
                            (Some(p), Some(gp)) => format!(
                                "empty — pencil would insert {:?}{} at beat {:.3}",
                                p.nominal,
                                p.octave,
                                gp.position.0.to_f64()
                            ),
                            (Some(p), None) => {
                                format!("empty — pencil would insert {:?}{}", p.nominal, p.octave)
                            }
                            _ => "cleared selection".to_string(),
                        },
                    };
                }
            }
        }

        if let Some(sel) = self.session.selection() {
            if let Some(region) = self
                .session
                .hit_test()
                .regions
                .iter()
                .find(|r| r.layout_object == sel.layout_object)
            {
                let vm = ViewMap::new(self.view_box, rect);
                ui.painter().rect_stroke(
                    shape_rect(&region.shape, &vm),
                    0.0,
                    egui::Stroke::new(2.0, egui::Color32::from_rgb(0, 120, 215)),
                );
            }
        }
    }
}

/// Keyboard edges read once per frame (so a held key fires one edit).
struct Keys {
    delete: bool,
    move_up: bool,
    move_down: bool,
    sharp: bool,
    flat: bool,
    add: bool,
    insert: bool,
    pencil: bool,
}

impl eframe::App for EditorApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        self.handle_keys(ctx);

        egui::TopBottomPanel::top("toolbar").show(ctx, |ui| self.toolbar(ui));
        egui::SidePanel::right("debug")
            .default_width(280.0)
            .show(ctx, |ui| self.debug_panel(ui));

        if self.needs_render {
            self.rerender(ctx);
            self.needs_render = false;
        }

        egui::CentralPanel::default().show(ctx, |ui| {
            egui::ScrollArea::both().show(ui, |ui| self.score_view(ui));
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn view_map_round_trips_screen_and_world() {
        // A viewBox offset in both axes (min_y negative), shown in an off-origin rect.
        let view_box = [3.0_f32, -5.0, 40.0, 12.0];
        let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(400.0, 120.0));
        let vm = ViewMap::new(view_box, rect);

        for &(sx, sy) in &[(100.0, 50.0), (500.0, 170.0), (300.0, 110.0), (250.0, 75.0)] {
            let world = vm.screen_to_world(egui::pos2(sx, sy));
            let back = vm.world_to_screen(world.x.0, world.y.0);
            assert!(
                (back.x - sx).abs() < 1e-3,
                "x round-trips: {} vs {sx}",
                back.x
            );
            assert!(
                (back.y - sy).abs() < 1e-3,
                "y round-trips: {} vs {sy}",
                back.y
            );
        }
    }

    #[test]
    fn click_plane_uses_the_logical_not_ceiled_size() {
        // 82.4564 staff spaces at 12 px/space is 989.4768 logical px, which a pixmap
        // rounds up to 990. The click plane must map the *logical* rect to the
        // viewBox, so the scale is exactly px-per-staff-space — using the rounded-up
        // pixmap size instead would stretch it.
        let view_box = [0.0_f32, 0.0, 82.4564, 30.0];
        let pps = 12.0_f32;
        let logical = egui::vec2(view_box[2] * pps, view_box[3] * pps);

        let logical_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), logical);
        let correct = ViewMap::new(view_box, logical_rect);
        assert!(
            (correct.scale() - pps).abs() < 1e-3,
            "logical rect gives scale == px/space, got {}",
            correct.scale()
        );

        // The rounded-up pixmap size must NOT be used as the display rect — a
        // regression guard that it would give a different (wrong) scale.
        let ceiled = egui::vec2(logical.x.ceil(), logical.y.ceil());
        let ceiled_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), ceiled);
        assert!(
            (ViewMap::new(view_box, ceiled_rect).scale() - pps).abs() > 1e-3,
            "the rounded-up size would stretch the click plane"
        );
    }

    #[test]
    fn view_map_corners_respect_the_y_up_world() {
        let view_box = [3.0_f32, -5.0, 40.0, 12.0]; // max_y = -5 + 12 = 7
        let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(400.0, 120.0));
        let vm = ViewMap::new(view_box, rect);

        // Screen top-left is world (min_x, max_y); screen bottom-left is (min_x, min_y).
        let top_left = vm.screen_to_world(rect.min);
        assert!((top_left.x.0 - 3.0).abs() < 1e-3);
        assert!((top_left.y.0 - 7.0).abs() < 1e-3);
        let bottom_left = vm.screen_to_world(egui::pos2(rect.min.x, rect.max.y));
        assert!((bottom_left.y.0 - (-5.0)).abs() < 1e-3);
    }
}
