use iced::widget::{
    button, checkbox, column, container, markdown, mouse_area, row,
    scrollable, stack, text, text_editor, tooltip, rule,
    Shader, Space,
};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Shadow, Subscription};
use iced::{mouse, keyboard, window, Point, Vector};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;

static OPEN_QUEUE: OnceLock<Mutex<Vec<PathBuf>>> = OnceLock::new();

pub fn open_queue() -> &'static Mutex<Vec<PathBuf>> {
    OPEN_QUEUE.get_or_init(|| Mutex::new(Vec::new()))
}

use cord_expr::{classify, classify_from, expr_to_sdf, parse_expr, parse_expr_scene, ExprInfo};
use crate::highlight::{CordHighlighter, CordHighlighterSettings, format_token};
use crate::viewport::SdfViewport;

const UNDO_LIMIT: usize = 200;
const MAX_RECENTS: usize = 10;

pub struct App {
    source: text_editor::Content,
    mode: InputMode,
    info: Option<ExprInfo>,
    error: Option<String>,
    viewport: SdfViewport,
    mouse_pos: Point,
    status: Option<String>,
    md_items: Vec<markdown::Item>,
    undo_stack: Vec<String>,
    redo_stack: Vec<String>,
    current_path: Option<PathBuf>,
    dirty: bool,
    recents: Vec<PathBuf>,
    scene_objects: Vec<String>,
    selected_object: Option<String>,
    needs_cast: bool,
    needs_plot: bool,
    context_menu_pos: Option<Point>,
    menu_ready: bool,
    cursor_line: usize,
    line_eval_text: Option<String>,
    mesh_path: Option<PathBuf>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputMode { Expr, Scad }

#[derive(Debug, Clone)]
pub enum Message {
    EditorAction(text_editor::Action),
    New,
    Open,
    OpenPath(PathBuf),
    Save,
    SaveAs,
    SaveSources,
    ExportSingle,
    ExportIndividual,
    ExportMesh(MeshFormat),
    Undo,
    Redo,
    CopyText,
    CutText,
    PasteText,
    SelectAll,
    MousePress,
    MouseRelease,
    MouseMove(Point),
    MouseScroll(f32),
    RightClick,
    ToggleShadows(bool),
    ToggleAO(bool),
    ToggleGround(bool),
    MarkdownUrl(markdown::Uri),
    FileDrop(PathBuf),
    Transform(TransformAction),
    ResetView,
    SelectObject(String),
    DismissOverlay,
    RenderObjects,
    RenderPlots,
    RenderAll,
    DecomposeMesh,
    Tick,
}

#[derive(Debug, Clone, Copy)]
pub enum MeshFormat { Stl, ThreeMf, Step, Scad }

#[derive(Debug, Clone)]
pub enum TransformAction {
    Translate(f32, f32, f32),
    RotateX, RotateY, RotateZ,
    ScaleUp, ScaleDown,
    MirrorX, MirrorY, MirrorZ,
}

fn recents_path() -> PathBuf {
    let mut p = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    p.push(".cord");
    p.push("recents.json");
    p
}

fn load_recents() -> Vec<PathBuf> {
    let p = recents_path();
    std::fs::read_to_string(&p)
        .ok()
        .and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
        .map(|v| v.into_iter().map(PathBuf::from).filter(|p| p.exists()).collect())
        .unwrap_or_default()
}

fn save_recents(recents: &[PathBuf]) {
    let p = recents_path();
    if let Some(parent) = p.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let strs: Vec<&str> = recents.iter().filter_map(|p| p.to_str()).collect();
    if let Ok(json) = serde_json::to_string_pretty(&strs) {
        let _ = std::fs::write(&p, json);
    }
}

impl App {
    pub fn new() -> Self {
        let initial = "let s = sphere(3)\ncast()";
        let mut app = Self {
            source: text_editor::Content::with_text(initial),
            mode: InputMode::Expr,
            info: None,
            error: None,
            viewport: SdfViewport::new(),
            mouse_pos: Point::ORIGIN,
            status: None,
            md_items: Vec::new(),
            undo_stack: vec![initial.to_string()],
            redo_stack: Vec::new(),
            current_path: None,
            dirty: false,
            recents: load_recents(),
            scene_objects: Vec::new(),
            selected_object: None,
            needs_cast: false,
            needs_plot: false,
            context_menu_pos: None,
            menu_ready: false,
            cursor_line: 0,
            line_eval_text: None,
            mesh_path: None,
        };
        app.reparse();

        if let Some(path) = std::env::args_os().nth(1) {
            let p = std::path::PathBuf::from(path);
            if p.exists() {
                app.open_path(&p);
            }
        }

        app
    }

    pub fn title(&self) -> String {
        let name = self.current_path.as_ref()
            .and_then(|p| p.file_name())
            .and_then(|n| n.to_str())
            .unwrap_or("untitled");
        let dirty = if self.dirty { " *" } else { "" };
        format!("{name}{dirty} — Cord")
    }

    pub fn update(&mut self, message: Message) {
        match &message {
            Message::MouseMove(_) | Message::MouseScroll(_) | Message::Tick => {}
            _ => { self.context_menu_pos = None; }
        }

        match message {
            Message::EditorAction(action) => {
                let is_edit = action.is_edit();
                self.source.perform(action);

                let new_line = self.source.cursor().position.line;
                if new_line != self.cursor_line {
                    self.cursor_line = new_line;
                    self.line_eval_text = self.eval_line_at(new_line);
                }

                if is_edit {
                    self.dirty = true;
                    self.status = None;
                    let snap = self.source.text();
                    if self.undo_stack.last().map_or(true, |s| *s != snap) {
                        self.undo_stack.push(snap);
                        if self.undo_stack.len() > UNDO_LIMIT {
                            self.undo_stack.remove(0);
                        }
                        self.redo_stack.clear();
                    }
                    self.detect_mode();
                    self.reparse();
                    self.update_markdown();
                }
            }
            Message::New => self.new_file(),
            Message::Open => self.open_dialog(),
            Message::OpenPath(path) => self.open_path(&path),
            Message::Save => self.save(),
            Message::SaveAs => self.save_as(),
            Message::SaveSources => self.save_sources_dialog(),
            Message::ExportSingle => self.export_single(),
            Message::ExportIndividual => self.export_individual(),
            Message::ExportMesh(fmt) => self.export_mesh(fmt),
            Message::MousePress => {
                if self.mouse_pos.x > 420.0 {
                    self.viewport.start_drag(self.mouse_pos);
                }
            }
            Message::MouseRelease => {
                self.viewport.end_drag();
            }
            Message::MouseMove(pos) => {
                self.mouse_pos = pos;
                if self.viewport.is_dragging() {
                    self.viewport.drag_to(pos);
                }
            }
            Message::MouseScroll(delta) => {
                self.viewport.on_scroll(delta);
            }
            Message::RightClick => {
                if self.mouse_pos.x < 420.0 && self.mouse_pos.y > 30.0 {
                    self.context_menu_pos = Some(self.mouse_pos);
                }
            }
            Message::ToggleShadows(v) => self.viewport.render_flags.shadows = v,
            Message::ToggleAO(v) => self.viewport.render_flags.ao = v,
            Message::ToggleGround(v) => self.viewport.render_flags.ground = v,
            Message::Undo => {
                if self.undo_stack.len() > 1 {
                    let current = self.undo_stack.pop().unwrap();
                    self.redo_stack.push(current);
                    if let Some(prev) = self.undo_stack.last() {
                        self.source = text_editor::Content::with_text(prev);
                        self.detect_mode();
                        self.reparse();
                        self.update_markdown();
                    }
                }
            }
            Message::Redo => {
                if let Some(next) = self.redo_stack.pop() {
                    self.undo_stack.push(next.clone());
                    self.source = text_editor::Content::with_text(&next);
                    self.detect_mode();
                    self.reparse();
                    self.update_markdown();
                }
            }
            Message::CopyText => {
                if let Some(sel) = self.source.selection() {
                    if let Ok(mut cb) = arboard::Clipboard::new() {
                        let _ = cb.set_text(&sel);
                    }
                }
            }
            Message::CutText => {
                if let Some(sel) = self.source.selection() {
                    if let Ok(mut cb) = arboard::Clipboard::new() {
                        let _ = cb.set_text(&sel);
                    }
                    self.source.perform(text_editor::Action::Edit(
                        text_editor::Edit::Backspace,
                    ));
                    self.dirty = true;
                    self.detect_mode();
                    self.reparse();
                    self.update_markdown();
                }
            }
            Message::PasteText => {
                if let Ok(mut cb) = arboard::Clipboard::new() {
                    if let Ok(t) = cb.get_text() {
                        self.source.perform(text_editor::Action::Edit(
                            text_editor::Edit::Paste(Arc::new(t)),
                        ));
                        self.dirty = true;
                        self.detect_mode();
                        self.reparse();
                        self.update_markdown();
                    }
                }
            }
            Message::SelectAll => {
                self.source.perform(text_editor::Action::SelectAll);
            }
            Message::MarkdownUrl(_url) => {}
            Message::FileDrop(path) => self.open_path(&path),
            Message::Transform(action) => self.apply_transform(action),
            Message::ResetView => self.viewport.reset_camera(),
            Message::SelectObject(name) => {
                self.selected_object = Some(name);
            }
            Message::DismissOverlay => {}
            Message::RenderObjects => {
                if self.needs_cast {
                    self.insert_line("cast()");
                }
            }
            Message::RenderPlots => {
                if self.needs_plot {
                    self.insert_line("plot()");
                }
            }
            Message::RenderAll => {
                let mut added = false;
                if self.needs_cast {
                    self.insert_line("cast()");
                    added = true;
                }
                if self.needs_plot {
                    self.insert_line("plot()");
                    added = true;
                }
                if !added {
                    self.reparse();
                }
            }
            Message::DecomposeMesh => self.decompose_mesh(),
            Message::Tick => {
                if !self.menu_ready {
                    setup_native_menu();
                    self.menu_ready = true;
                }
                self.poll_menu_events();
                if let Ok(mut q) = open_queue().lock() {
                    for path in q.drain(..) {
                        self.open_path(&path);
                    }
                }
                let new_line = self.source.cursor().position.line;
                if new_line != self.cursor_line {
                    self.cursor_line = new_line;
                    self.line_eval_text = self.eval_line_at(new_line);
                }
            }
        }
    }

    fn eval_line_at(&self, line: usize) -> Option<String> {
        let src = self.source.text();
        let lines: Vec<&str> = src.lines().collect();
        if line >= lines.len() { return None; }

        let target = lines[line].trim();
        if target.is_empty() || target.starts_with("//") || target.starts_with("/*")
            || target == "cast()" || target == "plot()"
        {
            return None;
        }

        let partial: String = lines[..=line].join("\n");
        match parse_expr(&partial) {
            Ok(graph) => {
                let info = classify(&graph);
                let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0);
                if val.is_finite() {
                    if info.dimensions == 0 {
                        Some(format!("= {}", fmt_val(val).unwrap_or_else(|| "?".into())))
                    } else {
                        Some(format!("at origin: {}", fmt_val(val).unwrap_or_else(|| "?".into())))
                    }
                } else {
                    None
                }
            }
            Err(_) => None,
        }
    }

    fn poll_menu_events(&mut self) {
        while let Ok(event) = muda::MenuEvent::receiver().try_recv() {
            let msg = match event.id().as_ref() {
                "new" => Some(Message::New),
                "open" => Some(Message::Open),
                "save" => Some(Message::Save),
                "save_as" => Some(Message::SaveAs),
                "save_sources" => Some(Message::SaveSources),
                "export_single" => Some(Message::ExportSingle),
                "export_individual" => Some(Message::ExportIndividual),
                "export_stl" => Some(Message::ExportMesh(MeshFormat::Stl)),
                "export_3mf" => Some(Message::ExportMesh(MeshFormat::ThreeMf)),
                "export_step" => Some(Message::ExportMesh(MeshFormat::Step)),
                "export_scad" => Some(Message::ExportMesh(MeshFormat::Scad)),
                "undo" => Some(Message::Undo),
                "redo" => Some(Message::Redo),
                "decompose" => Some(Message::DecomposeMesh),
                "render_objects" => Some(Message::RenderObjects),
                "render_plots" => Some(Message::RenderPlots),
                "render_all" => Some(Message::RenderAll),
                _ => None,
            };
            if let Some(msg) = msg {
                self.update(msg);
            }
        }
    }

    fn insert_line(&mut self, line: &str) {
        let src = self.source.text();
        let trimmed = src.trim_end();
        let new_src = if trimmed.is_empty() {
            line.to_string()
        } else {
            format!("{trimmed}\n{line}")
        };
        self.source = text_editor::Content::with_text(&new_src);
        self.dirty = true;
        self.undo_stack.push(new_src);
        if self.undo_stack.len() > UNDO_LIMIT {
            self.undo_stack.remove(0);
        }
        self.redo_stack.clear();
        self.detect_mode();
        self.reparse();
        self.update_markdown();
    }

    fn detect_mode(&mut self) {
        if let Some(ext) = self.current_path.as_ref()
            .and_then(|p| p.extension())
            .and_then(|e| e.to_str())
        {
            match ext.to_lowercase().as_str() {
                "scad" => { self.mode = InputMode::Scad; return; }
                "crd" | "zcd" | "obj" | "stl" | "3mf" => { self.mode = InputMode::Expr; return; }
                _ => {}
            }
        }
        let s = self.source.text();
        let s = s.trim();
        let has_scad_blocks = s.contains('{') && s.contains(';');
        let has_scad_keywords = s.starts_with("module ")
            || s.starts_with("difference") || s.starts_with("union")
            || s.starts_with("intersection");
        if has_scad_keywords || (has_scad_blocks && !s.starts_with("let ") && !s.starts_with("sch ") && !s.starts_with("//")) {
            self.mode = InputMode::Scad;
        } else {
            self.mode = InputMode::Expr;
        }
    }

    fn reparse(&mut self) {
        let src = self.source.text();
        let src = src.trim();
        if src.is_empty() {
            self.info = None;
            self.error = None;
            return;
        }

        if self.mode == InputMode::Scad {
            match parse_scad(src) {
                Ok((graph, bounds)) => {
                    self.info = Some(classify(&graph));
                    self.error = None;
                    self.viewport.set_graph(&graph);
                    self.viewport.set_bounds(bounds);
                }
                Err(e) => {
                    self.info = None;
                    self.error = Some(e);
                    self.viewport.set_graph(&default_sphere_graph());
                    self.viewport.set_bounds(2.0);
                }
            }
            return;
        }

        match parse_expr_scene(src) {
            Ok(scene) => {
                let mut graph = scene.graph;
                let mut sdf_roots: Vec<cord_trig::ir::NodeId> = Vec::new();

                let render_objects: Vec<(String, cord_trig::ir::NodeId)> = if scene.cast_all {
                    scene.all_vars.clone()
                } else if !scene.casts.is_empty() {
                    scene.casts.clone()
                } else {
                    Vec::new()
                };
                for &(_, id) in &render_objects {
                    sdf_roots.push(id);
                }

                let mut plot_nodes: Vec<cord_trig::ir::NodeId> = if scene.plot_all {
                    let mut nodes: Vec<_> = scene.all_vars.iter().map(|(_, id)| *id).collect();
                    nodes.extend(&scene.bare_exprs);
                    nodes
                } else {
                    Vec::new()
                };
                plot_nodes.extend(&scene.plots);
                for &node_id in &plot_nodes {
                    let info = classify_from(&graph, node_id);
                    let sdf = expr_to_sdf(&mut graph, node_id, info.dimensions, 0.05, 10.0);
                    sdf_roots.push(sdf);
                }

                if sdf_roots.len() >= 2 {
                    let mut root = sdf_roots[0];
                    for &node in &sdf_roots[1..] {
                        root = graph.push(cord_trig::TrigOp::Min(root, node));
                    }
                    graph.set_output(root);
                } else if sdf_roots.len() == 1 {
                    graph.set_output(sdf_roots[0]);
                } else {
                    let empty = graph.push(cord_trig::TrigOp::Const(1e10));
                    graph.set_output(empty);
                }

                self.info = Some(classify(&graph));
                self.error = None;
                let has_plots = !plot_nodes.is_empty();
                let mut bounds = estimate_bounds(&graph);
                if has_plots {
                    bounds = bounds.max(10.0);
                }
                self.viewport.set_graph(&graph);
                self.viewport.set_bounds(bounds);
                self.scene_objects = scene.objects.iter().map(|(n, _)| n.clone()).collect();
                self.needs_cast = scene.needs_cast;
                self.needs_plot = scene.needs_plot;
                if let Some(ref sel) = self.selected_object {
                    if !self.scene_objects.contains(sel) {
                        self.selected_object = self.scene_objects.first().cloned();
                    }
                } else if !self.scene_objects.is_empty() {
                    self.selected_object = self.scene_objects.first().cloned();
                }
            }
            Err(e) => {
                self.info = None;
                self.error = Some(e);
                self.scene_objects.clear();
                self.needs_cast = false;
                self.needs_plot = false;
                self.viewport.set_graph(&default_sphere_graph());
                self.viewport.set_bounds(2.0);
            }
        }
    }

    fn update_markdown(&mut self) {
        let src = self.source.text();
        let md = build_notebook_md(&src);
        if md.is_empty() {
            self.md_items.clear();
        } else {
            self.md_items = markdown::parse(&md).collect();
        }
    }

    // === File operations ===

    fn new_file(&mut self) {
        let initial = "";
        self.source = text_editor::Content::with_text(initial);
        self.undo_stack = vec![initial.to_string()];
        self.redo_stack.clear();
        self.current_path = None;
        self.mesh_path = None;
        self.dirty = false;
        self.mode = InputMode::Expr;
        self.info = None;
        self.error = None;
        self.scene_objects.clear();
        self.selected_object = None;
        self.needs_cast = false;
        self.needs_plot = false;
        self.md_items.clear();
        self.viewport.set_graph(&default_sphere_graph());
        self.viewport.set_bounds(2.0);
        self.status = Some("new file".into());
    }

    fn decompose_mesh(&mut self) {
        let path = match self.mesh_path.clone() {
            Some(p) => p,
            None => {
                self.status = Some("no mesh loaded to decompose".into());
                return;
            }
        };

        use cord_decompile::mesh::TriangleMesh;
        use cord_decompile::{decompile, DecompileConfig};

        let mesh = match TriangleMesh::load(&path) {
            Ok(m) => m,
            Err(e) => {
                self.status = Some(format!("mesh load error: {e}"));
                return;
            }
        };

        let config = DecompileConfig::default();
        let source = match decompile(&mesh, &config) {
            Ok(result) => cord_sdf::sdf_to_cordial(&result.sdf),
            Err(e) => {
                self.status = Some(format!("decompose error: {e}"));
                return;
            }
        };

        self.source = text_editor::Content::with_text(&source);
        self.undo_stack.push(source);
        if self.undo_stack.len() > UNDO_LIMIT {
            self.undo_stack.remove(0);
        }
        self.redo_stack.clear();
        self.dirty = true;
        self.detect_mode();
        self.reparse();
        self.update_markdown();
        self.status = Some("decomposed mesh to Cordial".into());
    }

    fn open_dialog(&mut self) {
        let path = rfd::FileDialog::new()
            .add_filter("All Supported", &["crd", "cord", "zcd", "scad", "stl", "obj", "3mf", "step", "stp"])
            .add_filter("Cord Files", &["zcd", "crd", "cord"])
            .add_filter("3D Models", &["step", "stp", "obj", "stl", "3mf"])
            .add_filter("OpenSCAD", &["scad"])
            .add_filter("All Files", &["*"])
            .pick_file();
        if let Some(p) = path {
            self.open_path(&p);
        }
    }

    fn open_path(&mut self, path: &std::path::Path) {
        let ext = path.extension()
            .and_then(|e| e.to_str())
            .unwrap_or("")
            .to_lowercase();

        let is_mesh = matches!(ext.as_str(), "obj" | "stl" | "3mf");
        let mut fallback_note: Option<&str> = None;

        let result = match ext.as_str() {
            "zcd" => load_zcd(path),
            "crd" | "scad" => std::fs::read_to_string(path).map_err(|e| e.to_string()),
            "cord" | "bin" => {
                Err("binary format (.cord) — no source to edit".into())
            }
            "obj" | "stl" | "3mf" => {
                match import_mesh(path) {
                    Ok(imp) => {
                        if imp.is_fallback {
                            fallback_note = Some(" (bounding box — full decompose failed)");
                        }
                        Ok(imp.source)
                    }
                    Err(e) => Err(e),
                }
            }
            "step" | "stp" => {
                Err(format!("import for .{ext} not yet implemented"))
            }
            _ => std::fs::read_to_string(path).map_err(|e| e.to_string()),
        };

        match result {
            Ok(source) => {
                self.source = text_editor::Content::with_text(&source);
                self.undo_stack = vec![source];
                self.redo_stack.clear();
                self.current_path = Some(path.to_path_buf());
                self.mesh_path = if is_mesh { Some(path.to_path_buf()) } else { None };
                self.dirty = false;
                self.detect_mode();
                self.reparse();
                self.update_markdown();
                self.push_recent(path);
                let note = fallback_note.unwrap_or("");
                self.status = Some(format!("opened: {}{note}", path.display()));
            }
            Err(e) => {
                self.status = Some(format!("error: {e}"));
            }
        }
    }

    fn save(&mut self) {
        if let Some(path) = self.current_path.clone() {
            self.save_to(&path);
        } else {
            self.save_as();
        }
    }

    fn save_as(&mut self) {
        let default_name = self.current_path.as_ref()
            .and_then(|p| p.file_name())
            .and_then(|n| n.to_str())
            .unwrap_or("scene.zcd")
            .to_string();

        let path = rfd::FileDialog::new()
            .set_file_name(&default_name)
            .add_filter("Cord Archive", &["zcd"])
            .add_filter("Cord Source", &["crd"])
            .add_filter("OpenSCAD", &["scad"])
            .save_file();

        if let Some(p) = path {
            self.save_to(&p);
        }
    }

    fn save_to(&mut self, path: &std::path::Path) {
        let ext = path.extension()
            .and_then(|e| e.to_str())
            .unwrap_or("")
            .to_lowercase();

        let src = self.source.text();
        let src_trimmed = src.trim();

        let result = match ext.as_str() {
            "zcd" => {
                let graph = self.current_graph();
                match graph {
                    Some(g) => {
                        let wgsl = cord_shader::generate_wgsl_from_trig(&g);
                        let trig_bytes = g.to_bytes();
                        let config = cord_cordic::compiler::CompileConfig::default();
                        let cordic = cord_cordic::CORDICProgram::compile(&g, &config);
                        let cordic_bytes = cordic.to_bytes();
                        write_zcd(path, src_trimmed, &wgsl, &trig_bytes, &cordic_bytes, self.mode)
                    }
                    None => Err("cannot compile — fix errors first".into()),
                }
            }
            "crd" | "scad" | _ => {
                std::fs::write(path, src_trimmed).map_err(|e| e.to_string())
            }
        };

        match result {
            Ok(()) => {
                self.current_path = Some(path.to_path_buf());
                self.dirty = false;
                self.push_recent(path);
                self.status = Some(format!("saved: {}", path.display()));
            }
            Err(e) => {
                self.status = Some(format!("error: {e}"));
            }
        }
    }

    fn save_sources_dialog(&mut self) {
        let src = self.source.text();
        if src.trim().is_empty() {
            self.status = Some("nothing to save".into());
            return;
        }

        let result = rfd::MessageDialog::new()
            .set_title("Save Sources")
            .set_description(
                "Save all objects as a single file or individual files?\n\n\
                 You can also use \u{2318}E for single file or \u{21e7}\u{2318}E for individual files."
            )
            .set_buttons(rfd::MessageButtons::OkCancelCustom(
                "Single File".into(),
                "Individual Files".into(),
            ))
            .show();

        match result {
            rfd::MessageDialogResult::Ok => self.export_single(),
            rfd::MessageDialogResult::Cancel => self.export_individual(),
            _ => {}
        }
    }

    fn export_single(&mut self) {
        let src = self.source.text();
        let src_trimmed = src.trim();
        if src_trimmed.is_empty() {
            self.status = Some("nothing to export".into());
            return;
        }

        let default_name = self.current_path.as_ref()
            .and_then(|p| p.file_stem())
            .and_then(|n| n.to_str())
            .unwrap_or("scene");

        let path = rfd::FileDialog::new()
            .set_file_name(&format!("{default_name}.crd"))
            .add_filter("Cord Source", &["crd"])
            .add_filter("OpenSCAD", &["scad"])
            .save_file();

        if let Some(p) = path {
            match std::fs::write(&p, src_trimmed) {
                Ok(()) => self.status = Some(format!("saved: {}", p.display())),
                Err(e) => self.status = Some(format!("error: {e}")),
            }
        }
    }

    fn export_individual(&mut self) {
        let src = self.source.text();
        let src_trimmed = src.trim();
        if src_trimmed.is_empty() {
            self.status = Some("nothing to export".into());
            return;
        }

        if self.scene_objects.is_empty() {
            self.status = Some("no named objects to export individually".into());
            return;
        }

        let folder = rfd::FileDialog::new()
            .set_title("Choose folder for individual source files")
            .pick_folder();

        let folder = match folder {
            Some(f) => f,
            None => return,
        };

        let lines: Vec<&str> = src_trimmed.lines().collect();
        let base_src: String = lines.iter()
            .filter(|l| {
                let t = l.trim();
                t != "cast()" && t != "plot()" && !t.starts_with("cast(") && !t.starts_with("plot(")
            })
            .copied()
            .collect::<Vec<_>>()
            .join("\n");

        let mut count = 0;
        for name in &self.scene_objects {
            let content = format!("{base_src}\ncast({name})");
            let path = folder.join(format!("{name}.crd"));
            match std::fs::write(&path, &content) {
                Ok(()) => count += 1,
                Err(e) => {
                    self.status = Some(format!("error writing {name}.crd: {e}"));
                    return;
                }
            }
        }
        self.status = Some(format!("saved {count} source files to {}", folder.display()));
    }

    fn export_mesh(&mut self, format: MeshFormat) {
        let graph = match self.current_graph() {
            Some(g) => g,
            None => {
                self.status = Some("cannot export — fix errors first".into());
                return;
            }
        };

        let (ext, filter_name) = match format {
            MeshFormat::Stl => ("stl", "STL Mesh"),
            MeshFormat::ThreeMf => ("3mf", "3MF Archive"),
            MeshFormat::Step => ("step", "STEP File"),
            MeshFormat::Scad => ("scad", "OpenSCAD"),
        };

        if matches!(format, MeshFormat::Scad) {
            let src = self.source.text();
            let path = rfd::FileDialog::new()
                .set_file_name(&format!("scene.{ext}"))
                .add_filter(filter_name, &[ext])
                .save_file();
            if let Some(p) = path {
                let scad_src = if self.mode == InputMode::Scad {
                    src.trim().to_string()
                } else {
                    cordial_to_scad(src.trim())
                };
                match std::fs::write(&p, &scad_src) {
                    Ok(()) => self.status = Some(format!("exported: {}", p.display())),
                    Err(e) => self.status = Some(format!("error: {e}")),
                }
            }
            return;
        }

        let bounds = estimate_bounds(&graph);
        let resolution = 64u32;
        let mesh = marching_cubes(&graph, bounds, resolution);

        if mesh.is_empty() {
            self.status = Some("mesh generation produced no triangles".into());
            return;
        }

        let path = rfd::FileDialog::new()
            .set_file_name(&format!("scene.{ext}"))
            .add_filter(filter_name, &[ext])
            .save_file();

        let path = match path {
            Some(p) => p,
            None => return,
        };

        let result = match format {
            MeshFormat::Stl => write_stl_binary(&path, &mesh),
            MeshFormat::ThreeMf => write_3mf(&path, &mesh),
            MeshFormat::Step => {
                Err("STEP export not yet implemented".into())
            }
            MeshFormat::Scad => unreachable!(),
        };

        match result {
            Ok(()) => self.status = Some(format!("exported: {}", path.display())),
            Err(e) => self.status = Some(format!("error: {e}")),
        }
    }

    fn current_graph(&self) -> Option<cord_trig::TrigGraph> {
        let src = self.source.text();
        let src = src.trim();
        if src.is_empty() { return None; }
        if self.mode == InputMode::Scad {
            parse_scad(src).ok().map(|(g, _)| g)
        } else {
            parse_expr(src).ok()
        }
    }

    fn push_recent(&mut self, path: &std::path::Path) {
        let pb = path.to_path_buf();
        self.recents.retain(|p| p != &pb);
        self.recents.insert(0, pb);
        self.recents.truncate(MAX_RECENTS);
        save_recents(&self.recents);
    }

    fn apply_transform(&mut self, action: TransformAction) {
        let src = self.source.text();
        let trimmed = src.trim();
        if trimmed.is_empty() { return; }

        let lines: Vec<&str> = trimmed.lines().collect();

        let target_idx = if let Some(ref obj_name) = self.selected_object {
            find_object_line(&lines, obj_name)
        } else {
            None
        };

        let target_idx = target_idx.unwrap_or_else(|| {
            lines.iter().rposition(|l| {
                let t = l.trim();
                !t.is_empty() && !t.starts_with("//") && !t.starts_with("/*")
            }).unwrap_or(lines.len() - 1)
        });

        let target_line = lines[target_idx].trim();

        let (line_prefix, effective_target) = if let Some(semi_pos) = target_line.rfind(';') {
            let after = target_line[semi_pos + 1..].trim();
            if after.is_empty() {
                ("", target_line.trim_end_matches(';').trim())
            } else {
                (&target_line[..semi_pos + 1], after)
            }
        } else {
            ("", target_line)
        };

        let (is_let_binding, let_prefix, expr_part) = if effective_target.starts_with("let ") {
            if let Some(eq_pos) = effective_target.find('=') {
                let before_eq = &effective_target[..eq_pos + 1];
                let after_eq = effective_target[eq_pos + 1..].trim().trim_end_matches(';');
                (true, before_eq.to_string(), after_eq.to_string())
            } else {
                (false, String::new(), effective_target.trim_end_matches(';').to_string())
            }
        } else {
            (false, String::new(), effective_target.trim_end_matches(';').to_string())
        };

        let new_expr = apply_transform_collapsed(&expr_part, &action);

        let wrapped = if is_let_binding {
            format!("{let_prefix} {new_expr}")
        } else {
            new_expr
        };
        let new_line = if line_prefix.is_empty() {
            wrapped
        } else {
            format!("{line_prefix} {wrapped}")
        };

        let mut result_lines: Vec<&str> = lines[..target_idx].to_vec();
        let new_line_ref = new_line.as_str();
        result_lines.push(new_line_ref);
        result_lines.extend_from_slice(&lines[target_idx + 1..]);
        let new_src = result_lines.join("\n");

        self.source = text_editor::Content::with_text(&new_src);
        self.dirty = true;
        let snap = new_src.clone();
        self.undo_stack.push(snap);
        if self.undo_stack.len() > UNDO_LIMIT {
            self.undo_stack.remove(0);
        }
        self.redo_stack.clear();
        self.detect_mode();
        self.reparse();
        self.update_markdown();
    }

    pub fn subscription(&self) -> Subscription<Message> {
        let events = iced::event::listen_with(|event, status, _id| {
            let captured = matches!(status, iced::event::Status::Captured);
            match event {
                iced::Event::Keyboard(keyboard::Event::KeyPressed {
                    key: keyboard::Key::Character(c),
                    modifiers,
                    ..
                }) if modifiers.command() => {
                    match c.as_ref() {
                        "z" if modifiers.shift() => Some(Message::Redo),
                        "z" => Some(Message::Undo),
                        "n" => Some(Message::New),
                        "o" => Some(Message::Open),
                        "s" if modifiers.shift() => Some(Message::SaveAs),
                        "s" => Some(Message::Save),
                        "e" if modifiers.shift() => Some(Message::ExportIndividual),
                        "e" => Some(Message::ExportSingle),
                        "c" if !captured => Some(Message::CopyText),
                        "x" if !captured => Some(Message::CutText),
                        "v" if !captured => Some(Message::PasteText),
                        "a" if !captured => Some(Message::SelectAll),
                        "r" => Some(Message::RenderAll),
                        "2" => Some(Message::RenderPlots),
                        "3" => Some(Message::RenderObjects),
                        _ => None,
                    }
                }
                iced::Event::Window(window::Event::FileDropped(path)) => {
                    Some(Message::FileDrop(path))
                }
                iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if !captured => {
                    Some(Message::MousePress)
                }
                iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
                    Some(Message::MouseRelease)
                }
                iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if !captured => {
                    Some(Message::RightClick)
                }
                iced::Event::Mouse(mouse::Event::CursorMoved { position }) => {
                    Some(Message::MouseMove(position))
                }
                iced::Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
                    let y = match delta {
                        mouse::ScrollDelta::Lines { y, .. } => y,
                        mouse::ScrollDelta::Pixels { y, .. } => y / 50.0,
                    };
                    Some(Message::MouseScroll(y))
                }
                _ => None,
            }
        });

        let tick = iced::time::every(Duration::from_millis(100)).map(|_| Message::Tick);

        Subscription::batch([events, tick])
    }

    pub fn view(&self) -> Element<'_, Message> {
        let has_md = !self.md_items.is_empty();
        let has_recents = !self.recents.is_empty() && self.current_path.is_none()
            && self.source.text().trim() == "let s = sphere(3)\ncast()";
        let editor_height: Length = if has_md {
            Length::FillPortion(3)
        } else {
            Fill
        };

        let editor = text_editor(&self.source)
            .on_action(Message::EditorAction)
            .highlight_with::<CordHighlighter>(CordHighlighterSettings, format_token)
            .padding(10)
            .size(14)
            .height(editor_height);

        let editor_el: Element<Message> = editor.into();

        // Status line
        let mode_label = match self.mode {
            InputMode::Expr => "expr",
            InputMode::Scad => "scad",
        };
        let mut status_parts = mode_label.to_string();
        if let Some(ref info) = self.info {
            status_parts += &format!(
                " | {} | {}n | {}C",
                info.dimension_label(),
                info.node_count,
                info.total_cordic_passes(),
            );
        }
        if let Some(ref tip) = self.line_eval_text {
            status_parts += &format!(" | ln {}: {tip}", self.cursor_line + 1);
        }
        let mut status_line: Vec<Element<Message>> = vec![
            text(status_parts).size(11).color([0.6, 0.6, 0.6]).into(),
        ];
        if let Some(ref e) = self.error {
            status_line.push(
                text(e.clone()).size(11).color([1.0, 0.4, 0.4]).into()
            );
        }
        if let Some(ref s) = self.status {
            status_line.push(
                text(s.clone()).size(10).color([0.5, 0.8, 0.5]).into()
            );
        }

        let rf = &self.viewport.render_flags;
        let toggles = row![
            ttip(
                checkbox(rf.shadows).label("Shad").on_toggle(Message::ToggleShadows).text_size(10),
                "Toggle soft shadows",
            ),
            ttip(
                checkbox(rf.ao).label("AO").on_toggle(Message::ToggleAO).text_size(10),
                "Toggle ambient occlusion",
            ),
            ttip(
                checkbox(rf.ground).label("Gnd").on_toggle(Message::ToggleGround).text_size(10),
                "Toggle ground plane",
            ),
        ]
        .spacing(8);

        let bottom_bar = row![
            column(status_line).spacing(2).width(Fill),
            toggles,
        ]
        .spacing(8)
        .align_y(iced::Alignment::End);

        let mut sidebar_parts: Vec<Element<Message>> = vec![
            editor_el,
        ];

        // Recents panel
        if has_recents {
            let mut recents_col: Vec<Element<Message>> = vec![
                text("Recent files").size(12).color([0.6, 0.6, 0.6]).into(),
            ];
            for path in &self.recents {
                let label = path.file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("?")
                    .to_string();
                let p = path.clone();
                recents_col.push(
                    button(text(label).size(11))
                        .on_press(Message::OpenPath(p))
                        .padding([2, 8])
                        .into()
                );
            }
            sidebar_parts.push(
                column(recents_col).spacing(4).into()
            );
        }

        if !self.md_items.is_empty() {
            let md_view = markdown::view(&self.md_items, iced::Theme::Dark)
                .map(Message::MarkdownUrl);
            let md_scroll = scrollable(md_view)
                .height(Length::FillPortion(1));
            sidebar_parts.push(md_scroll.into());
        }

        sidebar_parts.push(bottom_bar.into());

        let sidebar = column(sidebar_parts)
            .spacing(6)
            .padding(10)
            .width(Length::Fixed(420.0))
            .height(Fill);

        let viewport_el: Element<Message> =
            Shader::new(&self.viewport).width(Fill).height(Fill).into();

        // Object selector bar
        let obj_bar: Element<Message> = if !self.scene_objects.is_empty() {
            let mut obj_buttons: Vec<Element<Message>> = Vec::new();
            for name in &self.scene_objects {
                let is_selected = self.selected_object.as_ref() == Some(name);
                let n = name.clone();
                let tip_text = format!("Select {n} for transforms");
                obj_buttons.push(
                    ttip(
                        button(text(name.clone()).size(13).center().color(
                            if is_selected {
                                Color::from_rgb(0.95, 0.85, 0.2)
                            } else {
                                Color::from_rgb(0.85, 0.85, 0.85)
                            }
                        ))
                        .on_press(Message::SelectObject(name.clone()))
                        .padding([6, 16])
                        .style(move |_theme, status| {
                            let bg = if is_selected {
                                Color::from_rgb(0.15, 0.42, 0.48)
                            } else {
                                match status {
                                    button::Status::Hovered => Color::from_rgb(0.18, 0.38, 0.42),
                                    _ => Color::from_rgb(0.12, 0.30, 0.35),
                                }
                            };
                            button::Style {
                                background: Some(Background::Color(bg)),
                                text_color: Color::WHITE,
                                border: Border::default().rounded(4),
                                shadow: Shadow::default(),
                                snap: false,
                            }
                        }),
                        &tip_text,
                    )
                );
            }
            row(obj_buttons).spacing(6).padding([4, 6]).width(Fill).into()
        } else {
            Space::new().into()
        };

        // Transform toolbar with colored axis buttons
        let t = |x: f32, y: f32, z: f32| Message::Transform(TransformAction::Translate(x, y, z));
        let x_color = Color::from_rgb(0.78, 0.22, 0.22);
        let y_color = Color::from_rgb(0.22, 0.65, 0.22);
        let z_color = Color::from_rgb(0.28, 0.40, 0.82);
        let gray = Color::from_rgb(0.28, 0.28, 0.30);

        let transform_bar = row![
            axis_btn("X+", t(1.0, 0.0, 0.0), x_color, "Translate +1 along X"),
            axis_btn("X-", t(-1.0, 0.0, 0.0), x_color, "Translate -1 along X"),
            container(Space::new()).width(8),
            axis_btn("Y+", t(0.0, 1.0, 0.0), y_color, "Translate +1 along Y"),
            axis_btn("Y-", t(0.0, -1.0, 0.0), y_color, "Translate -1 along Y"),
            container(Space::new()).width(8),
            axis_btn("Z+", t(0.0, 0.0, 1.0), z_color, "Translate +1 along Z"),
            axis_btn("Z-", t(0.0, 0.0, -1.0), z_color, "Translate -1 along Z"),
            container(Space::new()).width(12),
            axis_btn("Rx", Message::Transform(TransformAction::RotateX), gray, "Rotate 30\u{00b0} around X"),
            axis_btn("Ry", Message::Transform(TransformAction::RotateY), gray, "Rotate 30\u{00b0} around Y"),
            axis_btn("Rz", Message::Transform(TransformAction::RotateZ), gray, "Rotate 30\u{00b0} around Z"),
            container(Space::new()).width(6),
            axis_btn("S+", Message::Transform(TransformAction::ScaleUp), gray, "Scale up 1.5\u{00d7}"),
            axis_btn("S-", Message::Transform(TransformAction::ScaleDown), gray, "Scale down 0.667\u{00d7}"),
            container(Space::new()).width(6),
            axis_btn("Mx", Message::Transform(TransformAction::MirrorX), gray, "Mirror across YZ plane"),
            axis_btn("My", Message::Transform(TransformAction::MirrorY), gray, "Mirror across XZ plane"),
            axis_btn("Mz", Message::Transform(TransformAction::MirrorZ), gray, "Mirror across XY plane"),
            container(Space::new()).width(6),
            axis_btn("Rst", Message::ResetView, gray, "Reset camera view"),
        ]
        .spacing(2)
        .padding([2, 4])
        .width(Fill);

        let base_layout = column![
            row![sidebar, viewport_el].height(Fill),
            obj_bar,
            transform_bar,
        ];

        let mut layers: Vec<Element<Message>> = vec![base_layout.into()];
        if self.context_menu_pos.is_some() {
            layers.push(
                mouse_area(container(Space::new()).width(Fill).height(Fill))
                    .on_press(Message::DismissOverlay)
                    .into()
            );
        } else {
            layers.push(container(Space::new()).width(0).height(0).into());
        }
        if let Some(pos) = self.context_menu_pos {
            layers.push(render_context_menu(pos));
        } else {
            layers.push(container(Space::new()).width(0).height(0).into());
        }
        stack(layers).into()
    }
}

// === Context menu helpers ===

fn menu_item<'a>(label: &str, shortcut: &str, msg: Message) -> Element<'a, Message> {
    button(
        row![
            text(label.to_string()).size(13).width(Fill),
            text(shortcut.to_string()).size(11).color(Color::from_rgb(0.5, 0.5, 0.5)),
        ]
        .spacing(16)
        .align_y(iced::Alignment::Center)
    )
    .on_press(msg)
    .padding([4, 12])
    .width(Length::Fixed(210.0))
    .style(|_theme, status| {
        let bg = match status {
            button::Status::Hovered => Color::from_rgba(1.0, 1.0, 1.0, 0.1),
            _ => Color::TRANSPARENT,
        };
        button::Style {
            background: Some(Background::Color(bg)),
            text_color: Color::from_rgb(0.88, 0.88, 0.88),
            border: Border::default().rounded(2),
            shadow: Shadow::default(),
            snap: false,
        }
    })
    .into()
}

fn menu_sep<'a>() -> Element<'a, Message> {
    container(rule::horizontal(1))
        .padding([2, 8])
        .width(Length::Fixed(210.0))
        .into()
}

fn dropdown_container(items: Vec<Element<'_, Message>>) -> Element<'_, Message> {
    container(column(items).spacing(1))
        .padding(4)
        .style(|_theme| container::Style {
            background: Some(Background::Color(Color::from_rgba(0.14, 0.14, 0.17, 0.97))),
            border: Border {
                color: Color::from_rgb(0.3, 0.3, 0.35),
                width: 1.0,
                radius: 6.0.into(),
            },
            shadow: Shadow {
                color: Color::from_rgba(0.0, 0.0, 0.0, 0.4),
                offset: Vector::new(2.0, 4.0),
                blur_radius: 12.0,
            },
            ..Default::default()
        })
        .into()
}

fn render_context_menu(pos: Point) -> Element<'static, Message> {
    let items = vec![
        menu_item("Undo", "\u{2318}Z", Message::Undo),
        menu_item("Redo", "\u{21e7}\u{2318}Z", Message::Redo),
        menu_sep(),
        menu_item("Cut", "\u{2318}X", Message::CutText),
        menu_item("Copy", "\u{2318}C", Message::CopyText),
        menu_item("Paste", "\u{2318}V", Message::PasteText),
        menu_sep(),
        menu_item("Select All", "\u{2318}A", Message::SelectAll),
    ];

    let menu = dropdown_container(items);

    let top = pos.y.max(0.0);
    let left = pos.x.max(0.0);

    container(menu)
        .width(Fill)
        .height(Fill)
        .padding(Padding { top, right: 0.0, bottom: 0.0, left })
        .into()
}

// === Styled button helpers ===

fn axis_btn<'a>(label: &str, msg: Message, color: Color, tip: &str) -> Element<'a, Message> {
    ttip(
        button(text(label.to_string()).size(13).center())
            .on_press(msg)
            .padding([6, 0])
            .width(Fill)
            .style(move |_theme, status| {
                let bg = match status {
                    button::Status::Hovered | button::Status::Pressed => {
                        Color::from_rgb(
                            (color.r * 1.3).min(1.0),
                            (color.g * 1.3).min(1.0),
                            (color.b * 1.3).min(1.0),
                        )
                    }
                    _ => color,
                };
                button::Style {
                    background: Some(Background::Color(bg)),
                    text_color: Color::WHITE,
                    border: Border::default().rounded(3),
                    shadow: Shadow::default(),
                    snap: false,
                }
            }),
        tip,
    )
}

fn ttip<'a>(content: impl Into<Element<'a, Message>>, tip: &str) -> Element<'a, Message> {
    tooltip(
        content,
        container(text(tip.to_string()).size(11))
            .padding([2, 6])
            .style(|_theme| container::Style {
                background: Some(Background::Color(Color::from_rgba(0.1, 0.1, 0.13, 0.95))),
                border: Border {
                    color: Color::from_rgb(0.3, 0.3, 0.35),
                    width: 1.0,
                    radius: 4.0.into(),
                },
                ..Default::default()
            }),
        tooltip::Position::Top,
    )
    .delay(Duration::from_secs(3))
    .into()
}

// === Free functions ===

fn build_notebook_md(src: &str) -> String {
    let mut md = String::new();
    let mut code_lines = String::new();
    let mut in_block_comment = false;
    let mut block_buf = String::new();

    for line in src.lines() {
        if in_block_comment {
            if let Some(end) = line.find("*/") {
                block_buf.push_str(&line[..end]);
                let block = block_buf.trim();
                if !block.is_empty() {
                    if !md.is_empty() { md.push('\n'); }
                    md.push_str(block);
                }
                block_buf.clear();
                in_block_comment = false;
                let rest = &line[end + 2..];
                if !rest.trim().is_empty() {
                    code_lines.push_str(rest);
                    code_lines.push('\n');
                }
            } else {
                block_buf.push_str(line);
                block_buf.push('\n');
            }
            continue;
        }

        let trimmed = line.trim();

        if trimmed.starts_with("/=") {
            let expr_text = trimmed[2..].trim();
            if !expr_text.is_empty() {
                let full = format!("{code_lines}{expr_text}");
                let result = match parse_expr(&full) {
                    Ok(graph) => format_multi_eval(&graph),
                    Err(e) => format!("*error: {e}*"),
                };
                if !md.is_empty() { md.push_str("\n\n"); }
                md.push_str(&format!("`{expr_text}` = {result}"));
            }
            continue;
        }

        if trimmed.starts_with("//") {
            let text = trimmed[2..].strip_prefix(' ').unwrap_or(&trimmed[2..]);
            if !md.is_empty() { md.push_str("\n\n"); }
            md.push_str(text);
            continue;
        }

        if let Some(start) = trimmed.find("/*") {
            let before = &line[..line.find("/*").unwrap()];
            if !before.trim().is_empty() {
                code_lines.push_str(before);
                code_lines.push('\n');
            }
            let after = &trimmed[start + 2..];
            if let Some(end) = after.find("*/") {
                let text = after[..end].trim();
                if !text.is_empty() {
                    if !md.is_empty() { md.push('\n'); }
                    md.push_str(text);
                }
                let rest = &after[end + 2..];
                if !rest.trim().is_empty() {
                    code_lines.push_str(rest);
                    code_lines.push('\n');
                }
            } else {
                in_block_comment = true;
                let text = after.strip_prefix(' ').unwrap_or(after);
                block_buf.push_str(text);
                block_buf.push('\n');
            }
            continue;
        }

        code_lines.push_str(line);
        code_lines.push('\n');
    }

    md
}

fn fmt_val(val: f64) -> Option<String> {
    if val.is_nan() || val.is_infinite() {
        None
    } else if val == val.round() && val.abs() < 1e12 {
        Some(format!("{}", val as i64))
    } else {
        Some(format!("{val:.6}"))
    }
}

fn format_multi_eval(graph: &cord_trig::TrigGraph) -> String {
    let info = classify(graph);
    let eval = |x, y, z| cord_trig::eval::evaluate(graph, x, y, z);

    let origin = eval(0.0, 0.0, 0.0);

    if info.dimensions == 0 {
        return fmt_val(origin)
            .map(|s| format!("**{s}**"))
            .unwrap_or_else(|| "**NaN**".into());
    }

    let samples: [(f64, f64, f64); 6] = [
        (1.0, 0.0, 0.0), (-1.0, 0.0, 0.0),
        (0.0, 1.0, 0.0), (0.0, -1.0, 0.0),
        (0.0, 0.0, 1.0), (0.0, 0.0, -1.0),
    ];
    let vals: Vec<f64> = std::iter::once(origin)
        .chain(samples.iter().map(|&(x, y, z)| eval(x, y, z)))
        .collect();

    let finite: Vec<&f64> = vals.iter().filter(|v| v.is_finite()).collect();
    if finite.is_empty() {
        return "**NaN**".into();
    }

    if finite.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-15) {
        return fmt_val(*finite[0])
            .map(|s| format!("**{s}**"))
            .unwrap_or_else(|| "**NaN**".into());
    }

    let mut parts = Vec::new();
    if let Some(s) = fmt_val(origin) {
        parts.push(format!("origin: **{s}**"));
    }

    let axis_labels = [
        (0, 1, "x", info.uses_x),
        (2, 3, "y", info.uses_y),
        (4, 5, "z", info.uses_z),
    ];
    for &(pos_i, neg_i, label, used) in &axis_labels {
        if !used { continue; }
        let vp = vals[pos_i + 1];
        let vn = vals[neg_i + 1];
        match (fmt_val(vp), fmt_val(vn)) {
            (Some(a), Some(b)) if a == b => {
                parts.push(format!("\u{00b1}{label}: **{a}**"));
            }
            (Some(a), Some(b)) => {
                parts.push(format!("\u{00b1}{label}: **{a}**, **{b}**"));
            }
            (Some(a), None) => parts.push(format!("+{label}: **{a}**")),
            (None, Some(b)) => parts.push(format!("-{label}: **{b}**")),
            (None, None) => {}
        }
    }

    parts.join(" | ")
}

fn write_zcd(
    path: &std::path::Path,
    source: &str,
    wgsl: &str,
    trig_bytes: &[u8],
    cordic_bytes: &[u8],
    mode: InputMode,
) -> Result<(), String> {
    use cord_format::write::ZcdWriter;

    let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
    let buf = std::io::BufWriter::new(file);
    let mut writer = ZcdWriter::new(buf);
    match mode {
        InputMode::Expr => writer.write_source_crd(source).map_err(|e| e.to_string())?,
        InputMode::Scad => writer.write_source_scad(source).map_err(|e| e.to_string())?,
    }
    writer.write_trig(trig_bytes).map_err(|e| e.to_string())?;
    writer.write_shader(wgsl).map_err(|e| e.to_string())?;
    writer.write_cordic(cordic_bytes, 32).map_err(|e| e.to_string())?;
    writer.finish().map_err(|e| e.to_string())?;
    Ok(())
}

fn estimate_bounds(graph: &cord_trig::TrigGraph) -> f64 {
    let eval = |x, y, z| cord_trig::eval::evaluate(graph, x, y, z);

    let directions: [(f64, f64, f64); 6] = [
        (1.0, 0.0, 0.0), (-1.0, 0.0, 0.0),
        (0.0, 1.0, 0.0), (0.0, -1.0, 0.0),
        (0.0, 0.0, 1.0), (0.0, 0.0, -1.0),
    ];

    let mut max_r = 1.0_f64;

    for &(dx, dy, dz) in &directions {
        let mut r = 1.0;
        for _ in 0..20 {
            let v = eval(dx * r, dy * r, dz * r);
            if v.is_finite() && v > 0.0 { break; }
            r *= 2.0;
        }
        if r > max_r { max_r = r; }
    }

    let diag = 1.0 / 3.0_f64.sqrt();
    let diagonals: [(f64, f64, f64); 8] = [
        (diag, diag, diag), (diag, diag, -diag),
        (diag, -diag, diag), (diag, -diag, -diag),
        (-diag, diag, diag), (-diag, diag, -diag),
        (-diag, -diag, diag), (-diag, -diag, -diag),
    ];

    for &(dx, dy, dz) in &diagonals {
        let mut r = 1.0;
        for _ in 0..20 {
            let v = eval(dx * r, dy * r, dz * r);
            if v.is_finite() && v > 0.0 { break; }
            r *= 2.0;
        }
        if r > max_r { max_r = r; }
    }

    max_r.clamp(0.5, 10000.0)
}

fn default_sphere_graph() -> cord_trig::TrigGraph {
    use cord_trig::ir::{TrigGraph, TrigOp};
    let mut g = TrigGraph::new();
    let x = g.push(TrigOp::InputX);
    let y = g.push(TrigOp::InputY);
    let z = g.push(TrigOp::InputZ);
    let xy = g.push(TrigOp::Hypot(x, y));
    let mag = g.push(TrigOp::Hypot(xy, z));
    let r = g.push(TrigOp::Const(2.0));
    let out = g.push(TrigOp::Sub(mag, r));
    g.set_output(out);
    g
}

fn parse_scad(src: &str) -> Result<(cord_trig::TrigGraph, f64), String> {
    use cord_parse::lexer::Lexer;
    use cord_parse::parser::Parser;
    use cord_sdf::lower::lower_program;
    use cord_sdf::sdf_to_trig;

    let tokens = Lexer::new(src).tokenize().map_err(|e| e.to_string())?;
    let program = Parser::new(tokens).parse_program().map_err(|e| e.to_string())?;
    let sdf = lower_program(&program).map_err(|e| e.to_string())?;
    let bounds = sdf.bounding_radius();
    let graph = sdf_to_trig(&sdf);
    Ok((graph, bounds))
}

fn load_zcd(path: &std::path::Path) -> Result<String, String> {
    use cord_format::read::ZcdReader;

    let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
    let buf = std::io::BufReader::new(file);
    let mut reader = ZcdReader::new(buf).map_err(|e| e.to_string())?;

    if let Ok(Some(source)) = reader.read_source() {
        return Ok(source);
    }

    if let Ok(Some(trig_bytes)) = reader.read_trig() {
        if let Some(_graph) = cord_trig::TrigGraph::from_bytes(&trig_bytes) {
            return Err("no source in .zcd (has TrigGraph IR but no decompiler to source yet)".into());
        }
    }

    Err("no readable layers in .zcd".into())
}

struct MeshImport {
    source: String,
    is_fallback: bool,
}

fn import_mesh(path: &std::path::Path) -> Result<MeshImport, String> {
    use cord_decompile::mesh::TriangleMesh;
    use cord_decompile::{decompile, DecompileConfig};

    let mesh = TriangleMesh::load(path).map_err(|e| e.to_string())?;
    let config = DecompileConfig::default();
    match decompile(&mesh, &config) {
        Ok(result) => Ok(MeshImport {
            source: cord_sdf::sdf_to_cordial(&result.sdf),
            is_fallback: false,
        }),
        Err(_) => Ok(MeshImport {
            source: mesh_bounding_source(&mesh),
            is_fallback: true,
        }),
    }
}

fn mesh_bounding_source(mesh: &cord_decompile::mesh::TriangleMesh) -> String {
    let b = &mesh.bounds;
    let cx = (b.min.x + b.max.x) / 2.0;
    let cy = (b.min.y + b.max.y) / 2.0;
    let cz = (b.min.z + b.max.z) / 2.0;
    let hx = (b.max.x - b.min.x) / 2.0;
    let hy = (b.max.y - b.min.y) / 2.0;
    let hz = (b.max.z - b.min.z) / 2.0;
    format!(
        "// bounding box approximation (decompose failed)\n\
         let result: Obj = translate(box({hx:.4}, {hy:.4}, {hz:.4}), {cx:.4}, {cy:.4}, {cz:.4})\n\
         cast(result)\n"
    )
}

fn find_object_line(lines: &[&str], name: &str) -> Option<usize> {
    for (i, line) in lines.iter().enumerate() {
        let t = line.trim();
        if t.starts_with("let ") {
            let rest = t[4..].trim();
            if rest.starts_with(name) {
                let after_name = rest[name.len()..].trim();
                if after_name.starts_with('=') || after_name.starts_with(':') {
                    return Some(i);
                }
            }
        } else if t.starts_with(name) {
            let after = t[name.len()..].trim();
            if after.starts_with('=') && !after.starts_with("==") {
                return Some(i);
            }
        }
    }
    None
}

fn split_outer_call(expr: &str) -> Option<(&str, &str, &str)> {
    let paren = expr.find('(')?;
    let func = expr[..paren].trim();
    if !expr.ends_with(')') { return None; }
    let body = &expr[paren + 1..expr.len() - 1];

    let mut depth = 0;
    for (i, c) in body.char_indices() {
        match c {
            '(' => depth += 1,
            ')' => depth -= 1,
            ',' if depth == 0 => {
                return Some((func, body[..i].trim(), body[i + 1..].trim()));
            }
            _ => {}
        }
    }
    Some((func, body.trim(), ""))
}

const TRANSFORM_FUNCS: &[&str] = &[
    "translate", "rotate_x", "rotate_y", "rotate_z",
    "scale", "mirror_x", "mirror_y", "mirror_z",
    "rx", "ry", "rz", "mx", "my", "mz", "mov", "move",
];

#[derive(Default)]
struct TransformStack {
    translate: Option<(f32, f32, f32)>,
    rotate_x: Option<f32>,
    rotate_y: Option<f32>,
    rotate_z: Option<f32>,
    scale: Option<f32>,
    mirror_x: bool,
    mirror_y: bool,
    mirror_z: bool,
}

fn peel_transforms<'a>(expr: &'a str, stack: &mut TransformStack) -> &'a str {
    let mut current = expr.trim();
    loop {
        let Some((func, inner, args)) = split_outer_call(current) else { break };
        if !TRANSFORM_FUNCS.contains(&func) { break; }

        match func {
            "translate" | "mov" | "move" => {
                let parts: Vec<&str> = args.splitn(3, ',').collect();
                if parts.len() != 3 { break; }
                let Ok(tx) = parts[0].trim().parse::<f32>() else { break };
                let Ok(ty) = parts[1].trim().parse::<f32>() else { break };
                let Ok(tz) = parts[2].trim().parse::<f32>() else { break };
                let (ox, oy, oz) = stack.translate.unwrap_or((0.0, 0.0, 0.0));
                stack.translate = Some((ox + tx, oy + ty, oz + tz));
            }
            "rotate_x" | "rx" => {
                let Ok(a) = args.trim().parse::<f32>() else { break };
                stack.rotate_x = Some(stack.rotate_x.unwrap_or(0.0) + a);
            }
            "rotate_y" | "ry" => {
                let Ok(a) = args.trim().parse::<f32>() else { break };
                stack.rotate_y = Some(stack.rotate_y.unwrap_or(0.0) + a);
            }
            "rotate_z" | "rz" => {
                let Ok(a) = args.trim().parse::<f32>() else { break };
                stack.rotate_z = Some(stack.rotate_z.unwrap_or(0.0) + a);
            }
            "scale" => {
                let Ok(s) = args.trim().parse::<f32>() else { break };
                stack.scale = Some(stack.scale.unwrap_or(1.0) * s);
            }
            "mirror_x" | "mx" => stack.mirror_x = !stack.mirror_x,
            "mirror_y" | "my" => stack.mirror_y = !stack.mirror_y,
            "mirror_z" | "mz" => stack.mirror_z = !stack.mirror_z,
            _ => break,
        }
        current = inner;
    }
    current
}

fn apply_action(stack: &mut TransformStack, action: &TransformAction) {
    match action {
        TransformAction::Translate(dx, dy, dz) => {
            let (ox, oy, oz) = stack.translate.unwrap_or((0.0, 0.0, 0.0));
            stack.translate = Some((ox + dx, oy + dy, oz + dz));
        }
        TransformAction::RotateX => {
            stack.rotate_x = Some(stack.rotate_x.unwrap_or(0.0) + std::f32::consts::FRAC_PI_6);
        }
        TransformAction::RotateY => {
            stack.rotate_y = Some(stack.rotate_y.unwrap_or(0.0) + std::f32::consts::FRAC_PI_6);
        }
        TransformAction::RotateZ => {
            stack.rotate_z = Some(stack.rotate_z.unwrap_or(0.0) + std::f32::consts::FRAC_PI_6);
        }
        TransformAction::ScaleUp => {
            stack.scale = Some(stack.scale.unwrap_or(1.0) * 1.5);
        }
        TransformAction::ScaleDown => {
            stack.scale = Some(stack.scale.unwrap_or(1.0) * 0.667);
        }
        TransformAction::MirrorX => stack.mirror_x = !stack.mirror_x,
        TransformAction::MirrorY => stack.mirror_y = !stack.mirror_y,
        TransformAction::MirrorZ => stack.mirror_z = !stack.mirror_z,
    }
}

// Order: mirror -> scale -> rotate -> translate (inside-out)
fn recompose_transforms(inner: &str, stack: &TransformStack) -> String {
    let mut result = inner.to_string();

    if stack.mirror_x { result = format!("mirror_x({result})"); }
    if stack.mirror_y { result = format!("mirror_y({result})"); }
    if stack.mirror_z { result = format!("mirror_z({result})"); }

    if let Some(s) = stack.scale {
        if (s - 1.0).abs() > 1e-6 {
            result = format!("scale({result}, {s:.4})");
        }
    }

    if let Some(a) = stack.rotate_x {
        if a.abs() > 1e-6 {
            result = format!("rotate_x({result}, {a:.6})");
        }
    }
    if let Some(a) = stack.rotate_y {
        if a.abs() > 1e-6 {
            result = format!("rotate_y({result}, {a:.6})");
        }
    }
    if let Some(a) = stack.rotate_z {
        if a.abs() > 1e-6 {
            result = format!("rotate_z({result}, {a:.6})");
        }
    }

    if let Some((tx, ty, tz)) = stack.translate {
        if tx.abs() > 1e-6 || ty.abs() > 1e-6 || tz.abs() > 1e-6 {
            result = format!("translate({result}, {tx}, {ty}, {tz})");
        }
    }

    result
}

fn apply_transform_collapsed(expr: &str, action: &TransformAction) -> String {
    let mut stack = TransformStack::default();
    let inner = peel_transforms(expr, &mut stack);
    apply_action(&mut stack, action);
    recompose_transforms(inner, &stack)
}

fn cordial_to_scad(src: &str) -> String {
    let mut out = String::new();
    for line in src.lines() {
        let t = line.trim();
        if t.is_empty() || t.starts_with("//") || t == "cast()" || t == "plot()" {
            out.push_str(line);
            out.push('\n');
            continue;
        }
        if t.starts_with("cast(") || t.starts_with("plot(") {
            continue;
        }
        out.push_str(line);
        out.push('\n');
    }
    out
}

#[derive(Clone)]
struct Triangle {
    v: [[f32; 3]; 3],
    n: [f32; 3],
}

fn marching_cubes(graph: &cord_trig::TrigGraph, bounds: f64, res: u32) -> Vec<Triangle> {
    let step = (bounds * 2.2) / res as f64;
    let origin = -bounds * 1.1;
    let n = res + 1;
    let mut field = vec![0.0f64; (n * n * n) as usize];

    for iz in 0..n {
        for iy in 0..n {
            for ix in 0..n {
                let x = origin + ix as f64 * step;
                let y = origin + iy as f64 * step;
                let z = origin + iz as f64 * step;
                let idx = (iz * n * n + iy * n + ix) as usize;
                field[idx] = cord_trig::eval::evaluate(graph, x, y, z);
            }
        }
    }

    let mut tris = Vec::new();
    let idx = |ix: u32, iy: u32, iz: u32| (iz * n * n + iy * n + ix) as usize;

    for iz in 0..res {
        for iy in 0..res {
            for ix in 0..res {
                let corners = [
                    idx(ix, iy, iz),
                    idx(ix + 1, iy, iz),
                    idx(ix + 1, iy + 1, iz),
                    idx(ix, iy + 1, iz),
                    idx(ix, iy, iz + 1),
                    idx(ix + 1, iy, iz + 1),
                    idx(ix + 1, iy + 1, iz + 1),
                    idx(ix, iy + 1, iz + 1),
                ];

                let vals: [f64; 8] = std::array::from_fn(|i| field[corners[i]]);

                let mut cube_idx = 0u8;
                for i in 0..8 {
                    if vals[i] < 0.0 {
                        cube_idx |= 1 << i;
                    }
                }

                if cube_idx == 0 || cube_idx == 255 {
                    continue;
                }

                let pos: [[f64; 3]; 8] = [
                    [origin + ix as f64 * step, origin + iy as f64 * step, origin + iz as f64 * step],
                    [origin + (ix + 1) as f64 * step, origin + iy as f64 * step, origin + iz as f64 * step],
                    [origin + (ix + 1) as f64 * step, origin + (iy + 1) as f64 * step, origin + iz as f64 * step],
                    [origin + ix as f64 * step, origin + (iy + 1) as f64 * step, origin + iz as f64 * step],
                    [origin + ix as f64 * step, origin + iy as f64 * step, origin + (iz + 1) as f64 * step],
                    [origin + (ix + 1) as f64 * step, origin + iy as f64 * step, origin + (iz + 1) as f64 * step],
                    [origin + (ix + 1) as f64 * step, origin + (iy + 1) as f64 * step, origin + (iz + 1) as f64 * step],
                    [origin + ix as f64 * step, origin + (iy + 1) as f64 * step, origin + (iz + 1) as f64 * step],
                ];

                let edge_table = mc_edge_table(cube_idx);
                let tri_table = mc_tri_table(cube_idx);

                let mut verts = [[0.0f64; 3]; 12];
                for e in 0..12 {
                    if edge_table & (1 << e) != 0 {
                        let (a, b) = MC_EDGES[e];
                        let va = vals[a];
                        let vb = vals[b];
                        let t = if (va - vb).abs() > 1e-10 { va / (va - vb) } else { 0.5 };
                        for d in 0..3 {
                            verts[e][d] = pos[a][d] + t * (pos[b][d] - pos[a][d]);
                        }
                    }
                }

                let mut i = 0;
                while i < 16 && tri_table[i] != -1 {
                    let a = tri_table[i] as usize;
                    let b = tri_table[i + 1] as usize;
                    let c = tri_table[i + 2] as usize;

                    let va = verts[a].map(|x| x as f32);
                    let vb = verts[b].map(|x| x as f32);
                    let vc = verts[c].map(|x| x as f32);

                    let e1 = [vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]];
                    let e2 = [vc[0] - va[0], vc[1] - va[1], vc[2] - va[2]];
                    let mut norm = [
                        e1[1] * e2[2] - e1[2] * e2[1],
                        e1[2] * e2[0] - e1[0] * e2[2],
                        e1[0] * e2[1] - e1[1] * e2[0],
                    ];
                    let len = (norm[0] * norm[0] + norm[1] * norm[1] + norm[2] * norm[2]).sqrt();
                    if len > 1e-10 {
                        norm[0] /= len;
                        norm[1] /= len;
                        norm[2] /= len;
                    }

                    tris.push(Triangle { v: [va, vb, vc], n: norm });
                    i += 3;
                }
            }
        }
    }

    tris
}

const MC_EDGES: [(usize, usize); 12] = [
    (0, 1), (1, 2), (2, 3), (3, 0),
    (4, 5), (5, 6), (6, 7), (7, 4),
    (0, 4), (1, 5), (2, 6), (3, 7),
];

fn mc_edge_table(idx: u8) -> u16 {
    MC_EDGE_TABLE[idx as usize]
}

fn mc_tri_table(idx: u8) -> [i8; 16] {
    MC_TRI_TABLE[idx as usize]
}

include!("mc_tables.rs");

fn write_stl_binary(path: &std::path::Path, tris: &[Triangle]) -> Result<(), String> {
    use std::io::Write;
    let mut buf = Vec::with_capacity(84 + tris.len() * 50);
    buf.extend_from_slice(&[0u8; 80]);
    buf.extend_from_slice(&(tris.len() as u32).to_le_bytes());
    for tri in tris {
        for &c in &tri.n { buf.extend_from_slice(&c.to_le_bytes()); }
        for v in &tri.v {
            for &c in v { buf.extend_from_slice(&c.to_le_bytes()); }
        }
        buf.extend_from_slice(&[0u8; 2]);
    }
    let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
    file.write_all(&buf).map_err(|e| e.to_string())
}

fn write_3mf(path: &std::path::Path, tris: &[Triangle]) -> Result<(), String> {
    use std::io::Write;

    let mut verts: Vec<[f32; 3]> = Vec::new();
    let mut indices: Vec<[usize; 3]> = Vec::new();
    let mut vert_map: std::collections::HashMap<[u32; 3], usize> = std::collections::HashMap::new();

    for tri in tris {
        let mut face = [0usize; 3];
        for (i, v) in tri.v.iter().enumerate() {
            let key = [v[0].to_bits(), v[1].to_bits(), v[2].to_bits()];
            let idx = vert_map.entry(key).or_insert_with(|| {
                let n = verts.len();
                verts.push(*v);
                n
            });
            face[i] = *idx;
        }
        indices.push(face);
    }

    let mut model_xml = String::from(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
         <model unit=\"millimeter\" xmlns=\"http://schemas.microsoft.com/3dmanufacturing/core/2015/02\">\n\
         <resources><object id=\"1\" type=\"model\"><mesh>\n<vertices>\n"
    );
    for v in &verts {
        model_xml += &format!("<vertex x=\"{}\" y=\"{}\" z=\"{}\" />\n", v[0], v[1], v[2]);
    }
    model_xml += "</vertices>\n<triangles>\n";
    for f in &indices {
        model_xml += &format!("<triangle v1=\"{}\" v2=\"{}\" v3=\"{}\" />\n", f[0], f[1], f[2]);
    }
    model_xml += "</triangles>\n</mesh></object></resources>\n\
                  <build><item objectid=\"1\" /></build>\n</model>";

    let content_types = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
        <Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n\
        <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\" />\n\
        <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\" />\n\
        </Types>";

    let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
        <Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
        <Relationship Target=\"/3D/3dmodel.model\" Id=\"rel0\" \
        Type=\"http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel\" />\n\
        </Relationships>";

    let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
    let mut zip = zip::ZipWriter::new(file);
    let options = zip::write::SimpleFileOptions::default()
        .compression_method(zip::CompressionMethod::Deflated);

    zip.start_file("[Content_Types].xml", options).map_err(|e| e.to_string())?;
    zip.write_all(content_types.as_bytes()).map_err(|e| e.to_string())?;

    zip.start_file("_rels/.rels", options).map_err(|e| e.to_string())?;
    zip.write_all(rels.as_bytes()).map_err(|e| e.to_string())?;

    zip.start_file("3D/3dmodel.model", options).map_err(|e| e.to_string())?;
    zip.write_all(model_xml.as_bytes()).map_err(|e| e.to_string())?;

    zip.finish().map_err(|e| e.to_string())?;
    Ok(())
}

fn setup_native_menu() {
    use muda::*;
    use muda::accelerator::{Accelerator, Modifiers, Code};

    let m = |mods, key| Some(Accelerator::new(Some(mods), key));
    let cmd = Modifiers::SUPER;
    let cmd_shift = Modifiers::SUPER | Modifiers::SHIFT;

    let menu = Menu::new();

    let app_menu = Submenu::with_items("Cord", true, &[
        &PredefinedMenuItem::about(None, None),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::hide(None),
        &PredefinedMenuItem::hide_others(None),
        &PredefinedMenuItem::show_all(None),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::quit(None),
    ]).unwrap();

    let file_menu = Submenu::with_items("File", true, &[
        &MenuItem::with_id("new", "New", true, m(cmd, Code::KeyN)),
        &MenuItem::with_id("open", "Open\u{2026}", true, m(cmd, Code::KeyO)),
        &PredefinedMenuItem::separator(),
        &MenuItem::with_id("save", "Save", true, m(cmd, Code::KeyS)),
        &MenuItem::with_id("save_as", "Save As\u{2026}", true, m(cmd_shift, Code::KeyS)),
        &PredefinedMenuItem::separator(),
        &MenuItem::with_id("save_sources", "Save Sources\u{2026}", true, None),
        &MenuItem::with_id("export_single", "Export Source", true, m(cmd, Code::KeyE)),
        &MenuItem::with_id("export_individual", "Export Sources Individually", true, m(cmd_shift, Code::KeyE)),
        &PredefinedMenuItem::separator(),
        &MenuItem::with_id("export_stl", "Export STL\u{2026}", true, None),
        &MenuItem::with_id("export_3mf", "Export 3MF\u{2026}", true, None),
        &MenuItem::with_id("export_step", "Export STEP\u{2026}", true, None),
        &MenuItem::with_id("export_scad", "Export OpenSCAD\u{2026}", true, None),
        &PredefinedMenuItem::separator(),
        &MenuItem::with_id("decompose", "Decompose Mesh to Cordial", true, m(cmd_shift, Code::KeyD)),
    ]).unwrap();

    let edit_menu = Submenu::with_items("Edit", true, &[
        &MenuItem::with_id("undo", "Undo", true, m(cmd, Code::KeyZ)),
        &MenuItem::with_id("redo", "Redo", true, m(cmd_shift, Code::KeyZ)),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::cut(None),
        &PredefinedMenuItem::copy(None),
        &PredefinedMenuItem::paste(None),
        &PredefinedMenuItem::separator(),
        &PredefinedMenuItem::select_all(None),
    ]).unwrap();

    let render_menu = Submenu::with_items("Render", true, &[
        &MenuItem::with_id("render_objects", "3D Objects and Vars", true, m(cmd, Code::Digit3)),
        &MenuItem::with_id("render_plots", "Functions/Expressions", true, m(cmd, Code::Digit2)),
        &PredefinedMenuItem::separator(),
        &MenuItem::with_id("render_all", "All", true, m(cmd, Code::KeyR)),
    ]).unwrap();

    menu.append(&app_menu).unwrap();
    menu.append(&file_menu).unwrap();
    menu.append(&edit_menu).unwrap();
    menu.append(&render_menu).unwrap();

    #[cfg(target_os = "macos")]
    menu.init_for_nsapp();

    std::mem::forget(menu);
}

#[cfg(test)]
mod tests {
    use cord_expr::parse_expr_scene;
    use cord_sdf::SdfNode;
    use cord_trig::eval::evaluate;

    #[test]
    fn cordial_roundtrip_sphere() {
        let node = SdfNode::Sphere { radius: 5.0 };
        let src = cord_sdf::sdf_to_cordial(&node);
        let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}"));
        let graph = cord_expr::resolve_scene(scene);
        assert!((evaluate(&graph, 5.0, 0.0, 0.0)).abs() < 1e-4);
        assert!(evaluate(&graph, 0.0, 0.0, 0.0) < 0.0);
    }

    #[test]
    fn cordial_roundtrip_translated_box() {
        let node = SdfNode::Translate {
            offset: [3.0, 0.0, 0.0],
            child: Box::new(SdfNode::Box { half_extents: [2.0, 2.0, 2.0] }),
        };
        let src = cord_sdf::sdf_to_cordial(&node);
        let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}"));
        let graph = cord_expr::resolve_scene(scene);
        assert!(evaluate(&graph, 3.0, 0.0, 0.0) < 0.0);
    }

    #[test]
    fn cordial_roundtrip_rotated_cylinder() {
        let node = SdfNode::Rotate {
            axis: [1.0, 0.0, 0.0],
            angle_deg: 90.0,
            child: Box::new(SdfNode::Cylinder { radius: 2.0, height: 10.0 }),
        };
        let src = cord_sdf::sdf_to_cordial(&node);
        assert!(src.contains("rotate_x("), "axis=X should emit rotate_x, got:\n{src}");
        let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}"));
        let _graph = cord_expr::resolve_scene(scene);
    }

    #[test]
    fn cordial_roundtrip_union() {
        let node = SdfNode::Union(vec![
            SdfNode::Sphere { radius: 1.0 },
            SdfNode::Translate {
                offset: [5.0, 0.0, 0.0],
                child: Box::new(SdfNode::Sphere { radius: 1.0 }),
            },
        ]);
        let src = cord_sdf::sdf_to_cordial(&node);
        let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}"));
        let graph = cord_expr::resolve_scene(scene);
        assert!(evaluate(&graph, 0.0, 0.0, 0.0) < 0.0);
        assert!(evaluate(&graph, 5.0, 0.0, 0.0) < 0.0);
        assert!(evaluate(&graph, 2.5, 0.0, 0.0) > 0.0);
    }

    #[test]
    fn mesh_bounding_source_parses() {
        let src = "// bounding box approximation\n\
                    let result: Obj = translate(box(5.0000, 3.0000, 2.0000), 1.0000, 2.0000, 0.0000)\n\
                    cast(result)";
        let scene = parse_expr_scene(src).unwrap_or_else(|e| panic!("{e}\n{src}"));
        let graph = cord_expr::resolve_scene(scene);
        assert!(evaluate(&graph, 1.0, 2.0, 0.0) < 0.0);
    }

    fn sim_reparse(src: &str) -> cord_trig::TrigGraph {
        use cord_expr::{classify_from, expr_to_sdf};
        let src = src.trim();
        let scene = parse_expr_scene(src).expect("parse failed");
        let mut graph = scene.graph;
        let mut sdf_roots: Vec<cord_trig::ir::NodeId> = Vec::new();

        let render_objects: Vec<_> = if scene.cast_all {
            scene.all_vars.clone()
        } else if !scene.casts.is_empty() {
            scene.casts.clone()
        } else {
            Vec::new()
        };
        for &(_, id) in &render_objects {
            sdf_roots.push(id);
        }

        let mut plot_nodes: Vec<cord_trig::ir::NodeId> = if scene.plot_all {
            let mut nodes: Vec<_> = scene.all_vars.iter().map(|(_, id)| *id).collect();
            nodes.extend(&scene.bare_exprs);
            nodes
        } else {
            Vec::new()
        };
        plot_nodes.extend(&scene.plots);
        for &node_id in &plot_nodes {
            let info = classify_from(&graph, node_id);
            let sdf = expr_to_sdf(&mut graph, node_id, info.dimensions, 0.05, 10.0);
            sdf_roots.push(sdf);
        }

        if sdf_roots.len() >= 2 {
            let mut root = sdf_roots[0];
            for &node in &sdf_roots[1..] {
                root = graph.push(cord_trig::TrigOp::Min(root, node));
            }
            graph.set_output(root);
        } else if sdf_roots.len() == 1 {
            graph.set_output(sdf_roots[0]);
        } else {
            let empty = graph.push(cord_trig::TrigOp::Const(1e10));
            graph.set_output(empty);
        }

        graph
    }

    #[test]
    fn open_file_produces_different_wgsl() {
        let default_src = "let s = sphere(3)\ncast()";
        let shell_src = std::fs::read_to_string(
            concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/shell.crd")
        ).expect("can't read shell.crd");
        let bolt_src = std::fs::read_to_string(
            concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/bolt.crd")
        ).expect("can't read bolt.crd");

        let default_graph = sim_reparse(default_src);
        let shell_graph = sim_reparse(&shell_src);
        let bolt_graph = sim_reparse(&bolt_src);

        let default_wgsl = cord_shader::generate_wgsl_from_trig(&default_graph);
        let shell_wgsl = cord_shader::generate_wgsl_from_trig(&shell_graph);
        let bolt_wgsl = cord_shader::generate_wgsl_from_trig(&bolt_graph);

        assert_ne!(default_wgsl, shell_wgsl, "shell.crd should produce different WGSL than default");
        assert_ne!(default_wgsl, bolt_wgsl, "bolt.crd should produce different WGSL than default");
        assert_ne!(shell_wgsl, bolt_wgsl, "shell and bolt should produce different WGSL");

        assert!(shell_graph.nodes.len() > 10, "shell should have a nontrivial graph");
        assert!(bolt_graph.nodes.len() > 10, "bolt should have a nontrivial graph");
    }
}
