//! ## Working with mutable/non-cloneable things.
//! Sometimes when working with existing code, libraries or even std things like
//! files, can introduce an impedance mismatch with a dataflow-style model.
//! Often it is enough to wrap the object in question into another layer of `Arc`s
//! and `RWLock`s in addition to what Gorby already does with its shared state
//! store.
//!
//! For heavier work, `ComputedState` can run background tasks and hold the latest
//! value. Use `Option<T>` when a value may be absent while a computation runs.
//!
//! But sometimes that isn't enough, e.g. when you want to display some application
//! global state. This is why `NotebookCtx::state` and `NotebookCtx::view` are carefully
//! designed to stay independent from any dataflow runtime. Instead they can be used,
//! like any other mutable rust type, via the typed `StateId` handle.
//!

#![allow(non_snake_case)]

pub mod card_ctx;
pub mod cards;
pub mod dataflow;
pub(crate) mod floating;
mod headless;
pub mod prelude;
pub mod state;
#[cfg(feature = "telemetry")]
pub mod telemetry;
pub mod themes;
pub mod widgets;

pub use gorbie_macros::notebook;

use crate::themes::industrial_dark;
use crate::themes::industrial_fonts;
use crate::themes::industrial_light;
use eframe::egui::{self};
use std::any::TypeId;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

use dark_light::Mode;


#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct SourceLocation {
    file: String,
    line: u32,
    column: u32,
}

impl SourceLocation {
    fn from_location(location: &'static std::panic::Location<'static>) -> Self {
        Self {
            file: location.file().to_string(),
            line: location.line(),
            column: location.column(),
        }
    }

    fn format_arg(&self, template: &str) -> String {
        let file = &self.file;
        let line = self.line;
        let column = self.column;
        template
            .replace("{file}", file)
            .replace("{line}", &line.to_string())
            .replace("{column}", &column.to_string())
    }

    fn file_line_column(&self) -> String {
        let file = &self.file;
        let line = self.line;
        let column = self.column;
        format!("{file}:{line}:{column}")
    }
}

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
enum CardIdentityKey {
    Stateless {
        source: Option<SourceLocation>,
        function: TypeId,
    },
    Stateful {
        source: Option<SourceLocation>,
        state: egui::Id,
        function: TypeId,
    },
    Custom,
}

#[derive(Clone, Debug)]
pub struct EditorCommand {
    program: String,
    args: Vec<String>,
}

impl EditorCommand {
    pub fn new(program: impl Into<String>) -> Self {
        Self {
            program: program.into(),
            args: Vec::new(),
        }
    }

    pub fn arg(mut self, arg: impl Into<String>) -> Self {
        self.args.push(arg.into());
        self
    }

    fn open(&self, source: &SourceLocation) -> std::io::Result<()> {
        let mut cmd = Command::new(&self.program);
        if self.args.is_empty() {
            cmd.arg(source.file_line_column());
        } else {
            for arg in &self.args {
                cmd.arg(source.format_arg(arg));
            }
        }
        let _child = cmd.spawn()?;
        Ok(())
    }
}

struct CardEntry {
    card: Box<dyn cards::Card + 'static>,
    source: Option<SourceLocation>,
    identity: egui::Id,
}

#[derive(Clone, Default)]
struct NotebookState {
    card_detached: Vec<bool>,
    card_placeholder_sizes: Vec<egui::Vec2>,
    card_identities: Vec<Option<egui::Id>>,
}

impl NotebookState {
    fn sync_len(&mut self, len: usize) {
        self.card_detached.resize(len, false);
        self.card_placeholder_sizes.resize(len, egui::Vec2::ZERO);
        self.card_identities.resize(len, None);
    }

    fn ensure_card_identity(&mut self, index: usize, identity: egui::Id) {
        let slot = self
            .card_identities
            .get_mut(index)
            .expect("card_identities synced to cards");
        if slot.map_or(true, |prev| prev != identity) {
            *slot = Some(identity);
            *self
                .card_detached
                .get_mut(index)
                .expect("card_detached synced to cards") = false;
            *self
                .card_placeholder_sizes
                .get_mut(index)
                .expect("card_placeholder_sizes synced to cards") = egui::Vec2::ZERO;
        }
    }
}

/// Configuration for a notebook application.
pub struct NotebookConfig {
    title: String,
    editor: Option<EditorCommand>,
    headless_capture: Option<HeadlessCaptureConfig>,
    headless_settle_timeout: Option<Duration>,
}

#[derive(Clone)]
struct HeadlessCaptureConfig {
    output_dir: PathBuf,
    card_width: f32,
    pixels_per_point: f32,
    settle_timeout: Duration,
}

#[derive(Clone)]
struct AppIcons {
    light: Arc<egui::IconData>,
    dark: Arc<egui::IconData>,
}

struct NotebookCore {
    config: NotebookConfig,
    body: Box<dyn FnMut(&mut NotebookCtx)>,
    state_store: Arc<state::StateStore>,
    settled: Arc<AtomicBool>,
}

struct Notebook {
    core: NotebookCore,
    icons: Option<AppIcons>,
    icon_is_dark: Option<bool>,
    #[cfg(feature = "telemetry")]
    #[allow(dead_code)] // kept alive to flush/close the telemetry sink on shutdown
    telemetry: Option<telemetry::Telemetry>,
}

/// Frame-scoped notebook builder used to collect cards in immediate mode.
pub struct NotebookCtx {
    state_id: egui::Id,
    cards: Vec<CardEntry>,
    state_store: Arc<state::StateStore>,
    settled: Arc<AtomicBool>,
}

pub use card_ctx::CardCtx;
pub use card_ctx::Grid;
pub use card_ctx::GRID_COL_WIDTH;
pub use card_ctx::GRID_COLUMNS;
pub use card_ctx::GRID_GUTTER;

pub(crate) const NOTEBOOK_COLUMN_WIDTH: f32 = 768.0;
const NOTEBOOK_MIN_HEIGHT: f32 = 360.0;
const HEADLESS_DEFAULT_PIXELS_PER_POINT: f32 = 2.0;
const HEADLESS_DEFAULT_SETTLE_TIMEOUT: Duration = Duration::from_millis(2000);

impl Default for NotebookConfig {
    fn default() -> Self {
        Self::new(String::new())
    }
}

impl NotebookConfig {
    pub fn new(name: impl Into<String>) -> Self {
        let title = name.into();
        Self {
            title,
            editor: editor_from_env(),
            headless_capture: None,
            headless_settle_timeout: None,
        }
    }

    pub fn with_editor(mut self, editor: EditorCommand) -> Self {
        self.editor = Some(editor);
        self
    }

    pub fn with_headless_capture(mut self, output_dir: impl Into<PathBuf>) -> Self {
        let settle_timeout = self
            .headless_settle_timeout
            .unwrap_or(HEADLESS_DEFAULT_SETTLE_TIMEOUT);
        self.headless_capture = Some(HeadlessCaptureConfig {
            output_dir: output_dir.into(),
            card_width: NOTEBOOK_COLUMN_WIDTH,
            pixels_per_point: HEADLESS_DEFAULT_PIXELS_PER_POINT,
            settle_timeout,
        });
        self
    }

    pub fn with_headless_capture_scaled(
        mut self,
        output_dir: impl Into<PathBuf>,
        pixels_per_point: f32,
    ) -> Self {
        let pixels_per_point = if pixels_per_point > 0.0 {
            pixels_per_point
        } else {
            HEADLESS_DEFAULT_PIXELS_PER_POINT
        };
        let settle_timeout = self
            .headless_settle_timeout
            .unwrap_or(HEADLESS_DEFAULT_SETTLE_TIMEOUT);
        self.headless_capture = Some(HeadlessCaptureConfig {
            output_dir: output_dir.into(),
            card_width: NOTEBOOK_COLUMN_WIDTH,
            pixels_per_point,
            settle_timeout,
        });
        self
    }

    pub fn with_headless_settle_timeout(mut self, timeout: Duration) -> Self {
        self.headless_settle_timeout = Some(timeout);
        if let Some(headless) = &mut self.headless_capture {
            headless.settle_timeout = timeout;
        }
        self
    }

    fn state_id(&self) -> egui::Id {
        egui::Id::new(("gorbie_notebook_state", self.title.as_str()))
    }

    pub fn run(self, body: impl FnMut(&mut NotebookCtx) + 'static) -> eframe::Result {
        let config = self;
        if let Some(headless) = config.headless_capture.clone() {
            return headless::run_headless(NotebookCore::new(config, Box::new(body)), headless)
                .map_err(eframe::Error::AppCreation);
        }

        let window_title = if config.title.is_empty() {
            "GORBIE".to_owned()
        } else {
            config.title.clone()
        };

        let icons = load_app_icons();
        let mut native_options = eframe::NativeOptions::default();
        native_options.persist_window = true;
        native_options.viewport = native_options
            .viewport
            .with_inner_size(egui::vec2(1200.0, 800.0))
            .with_min_inner_size(egui::vec2(NOTEBOOK_COLUMN_WIDTH, NOTEBOOK_MIN_HEIGHT));

        if let Some(icons) = icons.as_ref() {
            let icon = match dark_light::detect() {
                Ok(Mode::Light) => icons.light.clone(),
                Ok(Mode::Dark) => icons.dark.clone(),
                Ok(Mode::Unspecified) | Err(_) => icons.dark.clone(),
            };
            native_options.viewport = native_options.viewport.with_icon(icon);
        }

        let body = Box::new(body);
        eframe::run_native(
            &window_title,
            native_options,
            Box::new(|cc| {
                let ctx = cc.egui_ctx.clone();
                ctrlc::set_handler(move || ctx.send_viewport_cmd(egui::ViewportCommand::Close))
                    .expect("failed to set exit signal handler");

                cc.egui_ctx.set_fonts(industrial_fonts());

                cc.egui_ctx
                    .set_style_of(egui::Theme::Light, industrial_light());
                cc.egui_ctx
                    .set_style_of(egui::Theme::Dark, industrial_dark());

                #[cfg(feature = "telemetry")]
                let telemetry_title = config.title.clone();
                Ok(Box::new(Notebook {
                    core: NotebookCore::new(config, body),
                    icons,
                    icon_is_dark: None,
                    #[cfg(feature = "telemetry")]
                    telemetry: telemetry::Telemetry::install_global_from_env(&telemetry_title),
                }))
            }),
        )
    }
}

fn load_app_icons() -> Option<AppIcons> {
    let light =
        eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon_light.png")).ok()?;
    let dark = eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon_dark.png")).ok()?;
    Some(AppIcons {
        light: Arc::new(light),
        dark: Arc::new(dark),
    })
}

fn editor_from_env() -> Option<EditorCommand> {
    let gorbie_editor = std::env::var("GORBIE_EDITOR")
        .ok()
        .filter(|value| !value.trim().is_empty());
    if gorbie_editor.is_none() {
        log_missing_editor_hint();
        return None;
    }
    let editor = gorbie_editor?;

    let mut parts = editor.split_whitespace();
    let program = parts.next()?.to_string();
    let args = parts.map(str::to_string);
    let mut command = EditorCommand::new(program);
    for arg in args {
        command = command.arg(arg);
    }
    Some(command)
}

fn log_missing_editor_hint() {
    static ONCE: std::sync::Once = std::sync::Once::new();
    ONCE.call_once(|| {
        log::info!(
            "GORBIE_EDITOR is not set. Set it to an editor command with placeholders {{file}} {{line}} {{column}} to enable open-in-editor. Example: GORBIE_EDITOR='code -g {{file}}:{{line}}:{{column}}'."
        );
    });
}

impl state::StateAccess for NotebookCtx {
    fn store(&self) -> &state::StateStore {
        &self.state_store
    }
}

impl NotebookCtx {
    fn new(config: &NotebookConfig, state_store: Arc<state::StateStore>, settled: Arc<AtomicBool>) -> Self {
        Self {
            state_id: config.state_id(),
            cards: Vec::new(),
            state_store,
            settled,
        }
    }

    /// Signal that notebook content is fully loaded and ready for capture.
    /// In headless mode this triggers immediate capture instead of waiting
    /// for the settle timeout. In interactive mode this is a no-op.
    pub fn settled(&self) {
        self.settled.store(true, Ordering::Relaxed);
    }

    #[track_caller]
    pub fn view<F>(&mut self, function: F)
    where
        F: for<'a, 'b> FnMut(&'a mut CardCtx<'b>) + 'static,
    {
        let source = SourceLocation::from_location(std::panic::Location::caller());
        let identity = self.card_identity(CardIdentityKey::Stateless {
            source: Some(source.clone()),
            function: TypeId::of::<F>(),
        });
        let card = cards::StatelessCard::new(function);
        self.push_with_source(Box::new(card), Some(source), identity);
    }

    #[track_caller]
    pub fn state<K, T, F>(&mut self, key: &K, init: T, function: F) -> state::StateId<T>
    where
        K: std::hash::Hash + ?Sized,
        T: Send + Sync + 'static,
        F: for<'a, 'b> FnMut(&'a mut CardCtx<'b>, &mut T) + 'static,
    {
        let source = SourceLocation::from_location(std::panic::Location::caller());
        let state_id = self.state_id_for(key);
        let identity = self.card_identity(CardIdentityKey::Stateful {
            source: Some(source.clone()),
            state: state_id,
            function: TypeId::of::<F>(),
        });
        let state = state::StateId::new(state_id);
        let handle = state;
        self.state_store.get_or_insert(state, init);
        let card = cards::StatefulCard::new(state, function);
        self.push_with_source(Box::new(card), Some(source), identity);
        handle
    }

    pub fn push(&mut self, card: Box<dyn cards::Card>) {
        let identity = self.card_identity(CardIdentityKey::Custom);
        self.push_with_source(card, None, identity);
    }

    pub(crate) fn state_id_for<K: std::hash::Hash + ?Sized>(&self, key: &K) -> egui::Id {
        self.state_id.with(("state", key))
    }

    fn card_identity(&self, key: CardIdentityKey) -> egui::Id {
        self.state_id.with(("card", key))
    }

    fn push_with_source(
        &mut self,
        card: Box<dyn cards::Card>,
        source: Option<SourceLocation>,
        identity: egui::Id,
    ) {
        self.cards.push(CardEntry {
            card,
            source,
            identity,
        });
    }
}

impl NotebookCore {
    fn new(config: NotebookConfig, body: Box<dyn FnMut(&mut NotebookCtx)>) -> Self {
        Self {
            config,
            body,
            state_store: Arc::new(state::StateStore::default()),
            settled: Arc::new(AtomicBool::new(false)),
        }
    }

    fn has_settled(&self) -> bool {
        self.settled.swap(false, Ordering::Relaxed)
    }

    fn build_notebook(&mut self) -> NotebookCtx {
        let mut notebook = NotebookCtx::new(&self.config, self.state_store.clone(), self.settled.clone());
        (self.body)(&mut notebook);
        notebook
    }

    fn draw_card(
        &self,
        ctx: &egui::Context,
        notebook: &mut NotebookCtx,
        index: usize,
        card_width: f32,
    ) -> Option<f32> {
        if index >= notebook.cards.len() {
            return None;
        }

        let store = notebook.state_store.clone();
        let mut measured_height: Option<f32> = None;
        let panel_fill = ctx.style().visuals.window_fill;
        egui::CentralPanel::default()
            .frame(egui::Frame::NONE.fill(panel_fill))
            .show(ctx, |ui| {
                ui.set_min_size(egui::vec2(card_width, 0.0));
                if let Some(entry) = notebook.cards.get_mut(index) {
                    let card: &mut dyn cards::Card = entry.card.as_mut();
                    let rect = draw_card_body(ui, card_width, card, store.as_ref(), None);
                    measured_height = Some(rect.height());
                }
            });

        measured_height
    }
}

impl Notebook {
    fn update_app_icon(&mut self, ctx: &egui::Context) {
        let Some(icons) = self.icons.as_ref() else {
            return;
        };
        let is_dark = matches!(ctx.theme(), egui::Theme::Dark);
        if self.icon_is_dark == Some(is_dark) {
            return;
        }

        let icon = if is_dark {
            icons.dark.clone()
        } else {
            icons.light.clone()
        };
        ctx.send_viewport_cmd(egui::ViewportCommand::Icon(Some(icon)));
        self.icon_is_dark = Some(is_dark);
    }
}

impl eframe::App for Notebook {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        self.update_app_icon(ctx);
        ctx.style_mut(|style| {
            style.visuals.clip_rect_margin = 0.0;
        });

        #[cfg(feature = "telemetry")]
        let _telemetry_frame = tracing::info_span!("frame").entered();

        let mut notebook = {
            #[cfg(feature = "telemetry")]
            let _build_span = tracing::info_span!("build_notebook").entered();
            self.core.build_notebook()
        };
        let config = &self.core.config;

        let state_id = config.state_id();
        let mut runtime = ctx.data_mut(|data| {
            let slot = data.get_temp_mut_or_insert_with(state_id, NotebookState::default);
            std::mem::take(slot)
        });

        egui::CentralPanel::default().show(ctx, |ui| {
            egui::ScrollArea::vertical()
                .auto_shrink([false; 2])
                .show_viewport(ui, |ui, viewport| {
                    let rect = ui.max_rect();
                    let clip_rect = ui.clip_rect();
                        let scroll_y = viewport.min.y;

                    // Publish scroll info so floating cards can do anchor switching.
                    floating::store_scroll_info(
                        ui.ctx(),
                        floating::NotebookScrollInfo {
                            scroll_y,
                            viewport_top: clip_rect.min.y,
                            clip_rect,
                        },
                    );

                    let column_width = NOTEBOOK_COLUMN_WIDTH;
                    let left_margin_width = 0.0;
                    let card_width = column_width;

                    let left_margin_paint = egui::Rect::from_min_max(
                        egui::pos2(rect.min.x, clip_rect.min.y),
                        egui::pos2(rect.min.x + left_margin_width, clip_rect.max.y),
                    );
                    let left_margin = egui::Rect::from_min_max(
                        rect.min,
                        egui::pos2(rect.min.x + left_margin_width, rect.max.y),
                    );
                    let column_rect = egui::Rect::from_min_max(
                        egui::pos2(left_margin.max.x, rect.min.y),
                        egui::pos2(left_margin.max.x + column_width, rect.max.y),
                    );
                    let right_margin_paint = egui::Rect::from_min_max(
                        egui::pos2(column_rect.max.x, clip_rect.min.y),
                        egui::pos2(rect.max.x, clip_rect.max.y),
                    );
                    let right_margin = egui::Rect::from_min_max(
                        egui::pos2(column_rect.max.x, rect.min.y),
                        rect.max,
                    );

                    paint_dot_grid(ui, left_margin_paint, scroll_y);
                    paint_dot_grid(ui, right_margin_paint, scroll_y);

                    ui.scope_builder(egui::UiBuilder::new().max_rect(column_rect), |ui| {
                        // Keep column/background fills from painting into the margins when
                        // a too-wide widget forces a larger layout rect.
                        let restore_clip_rect = ui.clip_rect();
                        let column_clip_rect = egui::Rect::from_min_max(
                            egui::pos2(column_rect.min.x, restore_clip_rect.min.y),
                            egui::pos2(column_rect.max.x, restore_clip_rect.max.y),
                        );
                        ui.set_clip_rect(column_clip_rect);

                        ui.set_min_size(column_rect.size());
                        ui.set_max_width(column_rect.width());

                        let fill = ui.visuals().window_fill;

                        let card_gap = ui
                            .visuals()
                            .widgets
                            .noninteractive
                            .bg_stroke
                            .width
                            .max(1.0);
                        let card_gap_i8 = card_gap
                            .round()
                            .clamp(0.0, i8::MAX as f32) as i8;
                        let column_inner_margin = egui::Margin {
                            left: 0,
                            right: 0,
                            top: 12,
                            bottom: card_gap_i8,
                        };
                        let column_frame = egui::Frame::new()
                            .fill(fill)
                            .stroke(egui::Stroke::NONE)
                            .corner_radius(0.0)
                            .inner_margin(column_inner_margin)
                            .show(ui, |ui| {
                                // Theme switch is part of the page header (above the first card).
                                ui.horizontal(|ui| {
                                    ui.add_space(16.0);
                                    if !config.title.is_empty() {
                                        let header_title =
                                            egui::RichText::new(config.title.to_uppercase())
                                                .monospace()
                                                .strong();
                                        ui.add(egui::Label::new(header_title).truncate());
                                    }

                                    ui.with_layout(
                                        egui::Layout::right_to_left(egui::Align::Center),
                                        |ui| {
                                            ui.add_space(16.0);
                                            let mut preference =
                                                ui.ctx().options(|opt| opt.theme_preference);
                                            if ui
                                                .add(
                                                    widgets::ChoiceToggle::new(&mut preference)
                                                        .choice(egui::ThemePreference::System, "◐")
                                                        .choice(egui::ThemePreference::Dark, "●")
                                                        .choice(egui::ThemePreference::Light, "○"),
                                                )
                                                .changed()
                                            {
                                                ui.ctx().set_theme(preference);
                                            }
                                        },
                                    );
                                });

                                ui.add_space(12.0);

                                // Separator between header and first card.
                                {
                                    let (gap_rect, _) = ui.allocate_exact_size(
                                        egui::vec2(card_width, card_gap),
                                        egui::Sense::hover(),
                                    );
                                    let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
                                    ui.painter().rect_filled(gap_rect, 0.0, stroke.color);
                                }

                                runtime.sync_len(notebook.cards.len());
                                let store = notebook.state_store.clone();

                                let default_item_spacing = ui.style().spacing.item_spacing;
                                ui.style_mut().spacing.item_spacing.y = 0.0;
                                let cards_len = notebook.cards.len();
                                for (i, entry) in notebook.cards.iter_mut().enumerate() {
                                    let card_identity = entry.identity;
                                    runtime.ensure_card_identity(i, card_identity);
                                    let card_detached = runtime.card_detached
                                        .get_mut(i)
                                        .expect("card_detached synced to cards");
                                    let card_placeholder_size = runtime.card_placeholder_sizes
                                        .get_mut(i)
                                        .expect("card_placeholder_sizes synced to cards");
                                    ui.push_id((i, card_identity), |ui| {
                                        let card_left = column_rect.min.x;
                                        let card: &mut dyn cards::Card = entry.card.as_mut();
                                        let card_rect = if *card_detached {
                                            let placeholder_height =
                                                crate::card_ctx::GRID_ROW_MODULE;
                                            let placeholder_width = card_width;
                                            let (rect, resp) = ui.allocate_exact_size(
                                                egui::vec2(placeholder_width, placeholder_height),
                                                egui::Sense::click(),
                                            );
                                            let fill = ui.visuals().window_fill;
                                            let outline =
                                                ui.visuals().widgets.noninteractive.bg_stroke.color;
                                            ui.painter().rect_filled(rect, 0.0, fill);
                                            paint_hatching(
                                                &ui.painter().with_clip_rect(rect),
                                                rect,
                                                outline,
                                            );
                                            show_postit_tooltip(ui, &resp, "Dock card");
                                            if resp.clicked() {
                                                *card_detached = false;
                                            }

                                            if *card_detached {
                                                let initial_screen_pos = egui::pos2(
                                                    right_margin.min.x + 12.0,
                                                    rect.top(),
                                                );
                                                let detached_id = ui.id().with("detached_card");
                                                let float_resp = floating::show_floating_card(
                                                    ui.ctx(),
                                                    detached_id,
                                                    initial_screen_pos,
                                                    card_width,
                                                    card_placeholder_size.y,
                                                    store.as_ref(),
                                                    "Dock card",
                                                    &mut |ctx| {
                                                        #[cfg(feature = "telemetry")]
                                                        let _detached_span = tracing::info_span!(
                                                            "detached_draw"
                                                        )
                                                        .entered();
                                                        card.draw(ctx);
                                                    },
                                                );
                                                if float_resp.handle_clicked {
                                                    *card_detached = false;
                                                }
                                            }
                                            rect
                                        } else {
                                            let clip_rect = ui.clip_rect();
                                            let card_clip_rect = egui::Rect::from_min_max(
                                                egui::pos2(
                                                    column_rect.min.x,
                                                    clip_rect.min.y,
                                                ),
                                                egui::pos2(
                                                    column_rect.max.x,
                                                    clip_rect.max.y,
                                                ),
                                            );
                                            #[cfg(feature = "telemetry")]
                                            let _card_span = {
                                                let source = entry
                                                    .source
                                                    .as_ref()
                                                    .map(|s| s.file_line_column())
                                                    .unwrap_or_default();
                                                tracing::info_span!(
                                                    "card",
                                                    source = source.as_str()
                                                )
                                                .entered()
                                            };
                                            let inner_rect = draw_card_body(
                                                ui,
                                                card_width,
                                                card,
                                                store.as_ref(),
                                                Some(card_clip_rect),
                                            );
                                            *card_placeholder_size =
                                                egui::vec2(card_width, inner_rect.height());
                                            egui::Rect::from_min_size(
                                                egui::pos2(card_left, inner_rect.min.y),
                                                egui::vec2(card_width, inner_rect.height()),
                                            )
                                        };
                                        if i + 1 < cards_len {
                                            let separator_top = card_rect.bottom().ceil();
                                            let cursor_top = ui.cursor().top();
                                            if separator_top > cursor_top {
                                                ui.add_space(separator_top - cursor_top);
                                            }
                                            let (gap_rect, _) = ui.allocate_exact_size(
                                                egui::vec2(card_width, card_gap),
                                                egui::Sense::hover(),
                                            );
                                            let stroke = ui
                                                .visuals()
                                                .widgets
                                                .noninteractive
                                                .bg_stroke;
                                            ui.painter()
                                                .rect_filled(gap_rect, 0.0, stroke.color);
                                        }

                                        let show_detach_button = !*card_detached;
                                        let show_open_button = show_detach_button
                                            && entry.source.is_some()
                                            && config.editor.is_some();
                                        if show_detach_button {
                                            let tab_size = egui::vec2(20.0, 2.0 * crate::card_ctx::GRID_ROW_MODULE);
                                            let tab_pull = 4.0;
                                            let base_tab_gap = 4.0;
                                            let base_top_offset = 8.0;
                                            let min_top_offset = 0.0;
                                            let min_visible = tab_size.y * 0.4;
                                            let tab_fill = crate::themes::GorbieButtonStyle::from(
                                                ui.style().as_ref(),
                                            )
                                            .fill;

                                            let tab_count = 1 + usize::from(show_open_button);
                                            let available = card_rect.height().max(0.0);
                                            let mut top_offset = base_top_offset;
                                            let mut gap = base_tab_gap;
                                            let required = top_offset
                                                + tab_size.y * tab_count as f32
                                                + gap * (tab_count.saturating_sub(1) as f32);

                                            if required > available {
                                                let extra = required - available;
                                                let max_top_reduce =
                                                    (top_offset - min_top_offset).max(0.0);
                                                let top_reduce = extra.min(max_top_reduce);
                                                top_offset -= top_reduce;
                                                let remaining = extra - top_reduce;

                                                if remaining > 0.0 && tab_count > 1 {
                                                    let min_gap =
                                                        -(tab_size.y - min_visible);
                                                    let max_gap_reduce =
                                                        (gap - min_gap).max(0.0);
                                                    let gap_reduce = remaining.min(max_gap_reduce);
                                                    gap -= gap_reduce;
                                                }
                                            }

                                            let tab_x = card_rect.right().round();
                                            let top_y =
                                                (card_rect.top() + top_offset).round();
                                            let detach_pos = egui::pos2(tab_x, top_y);
                                            let open_pos = show_open_button.then(|| {
                                                egui::pos2(
                                                    tab_x,
                                                    (top_y + tab_size.y + gap).round(),
                                                )
                                            });

                                            ui.push_id((i, card_identity), |ui| {
                                                if let Some(open_pos) = open_pos {
                                                    let open_id =
                                                        ui.id().with("open_button");
                                                    let open_area = egui::Area::new(open_id)
                                                        .order(egui::Order::Middle)
                                                        .fixed_pos(open_pos)
                                                        .movable(false)
                                                        .constrain_to(egui::Rect::EVERYTHING);
                                                    let open_resp =
                                                        open_area.show(ui.ctx(), |ui| {
                                                            let (rect, resp) =
                                                                ui.allocate_exact_size(
                                                                    egui::vec2(
                                                                        tab_size.x + tab_pull,
                                                                        tab_size.y,
                                                                    ),
                                                                    egui::Sense::click(),
                                                                );
                                                            let tab_rect =
                                                                egui::Rect::from_min_size(
                                                                    rect.min,
                                                                    tab_size,
                                                                );
                                                            paint_card_tab_button(
                                                                ui,
                                                                &resp,
                                                                tab_rect,
                                                                "<>",
                                                                tab_fill,
                                                                tab_pull,
                                                            );

                                                            if let Some(source) =
                                                                entry.source.as_ref()
                                                            {
                                                                let file = &source.file;
                                                                let line = source.line;
                                                                let tooltip = format!(
                                                                    "Open in editor\n{file}:{line}"
                                                                );
                                                                show_postit_tooltip(
                                                                    ui,
                                                                    &resp,
                                                                    &tooltip,
                                                                );
                                                            } else {
                                                                show_postit_tooltip(
                                                                    ui,
                                                                    &resp,
                                                                    "Open in editor",
                                                                );
                                                            }
                                                            resp
                                                        });

                                                    if open_resp.inner.clicked() {
                                                        if let (Some(source), Some(editor)) =
                                                            (
                                                                entry.source.as_ref(),
                                                                config.editor.as_ref(),
                                                            )
                                                        {
                                                            if let Err(err) = editor.open(source) {
                                                                log::warn!(
                                                                    "failed to open editor: {err}"
                                                                );
                                                            }
                                                        }
                                                    }
                                                }

                                                let detach_id = ui.id().with("detach_button");
                                                let detach_area = egui::Area::new(detach_id)
                                                    .order(egui::Order::Middle)
                                                    .fixed_pos(detach_pos)
                                                    .movable(false)
                                                    .constrain_to(egui::Rect::EVERYTHING);
                                                let detach_resp =
                                                    detach_area.show(ui.ctx(), |ui| {
                                                        let (rect, resp) =
                                                            ui.allocate_exact_size(
                                                                egui::vec2(
                                                                    tab_size.x + tab_pull,
                                                                    tab_size.y,
                                                                ),
                                                                egui::Sense::click(),
                                                            );
                                                        let tab_rect =
                                                            egui::Rect::from_min_size(
                                                                rect.min,
                                                                tab_size,
                                                            );
                                                        paint_card_tab_button(
                                                            ui,
                                                            &resp,
                                                            tab_rect,
                                                            "[]",
                                                            tab_fill,
                                                            tab_pull,
                                                        );

                                                        let tooltip = if *card_detached {
                                                            "Dock card"
                                                        } else {
                                                            "Detach card"
                                                        };
                                                        show_postit_tooltip(ui, &resp, tooltip);
                                                        resp
                                                    });

                                                if detach_resp.inner.clicked() {
                                                    *card_detached = !*card_detached;
                                                }
                                            });
                                        }

                                    });
                                }

                                ui.style_mut().spacing.item_spacing = default_item_spacing;

                            });
                        ui.set_clip_rect(restore_clip_rect);
                        let frame_rect = column_frame.response.rect;
                        let frame_rect = egui::Rect::from_min_max(
                            egui::pos2(column_rect.min.x, frame_rect.min.y),
                            egui::pos2(column_rect.max.x, frame_rect.max.y),
                        );
                        let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
                        ui.painter()
                            .rect_stroke(frame_rect, 0.0, stroke, egui::StrokeKind::Inside);

                        // Extend scroll content to include content-anchored floating cards.
                        let inline_bottom = frame_rect.bottom();
                        let float_bottom = floating::max_float_content_bottom(ui.ctx());
                        if float_bottom > inline_bottom {
                            ui.allocate_space(egui::vec2(0.0, float_bottom - inline_bottom));
                        }
                    });
                });
        });

        ctx.data_mut(|data| {
            data.insert_temp(state_id, runtime);
        });
    }
}

fn draw_card_body(
    ui: &mut egui::Ui,
    card_width: f32,
    card: &mut dyn cards::Card,
    store: &state::StateStore,
    clip_rect: Option<egui::Rect>,
) -> egui::Rect {
    // Clamp *all* painting (including the frame fill) to the column rect.
    // Without this, a too-wide widget can expand the frame and paint into the
    // notebook margins, covering the dot grid.
    let restore_clip = clip_rect.map(|rect| {
        let restore = ui.clip_rect();
        ui.set_clip_rect(rect);
        restore
    });

    let inner = egui::Frame::group(ui.style())
        .stroke(egui::Stroke::NONE)
        .corner_radius(0.0)
        .inner_margin(egui::Margin::ZERO)
        .show(ui, |ui| {
            ui.reset_style();
            ui.set_width(card_width);
            let mut ctx = CardCtx::new(ui, store);
            card.draw(&mut ctx);
        });

    if let Some(restore) = restore_clip {
        ui.set_clip_rect(restore);
    }
    inner.response.rect
}

fn paint_dot_grid(ui: &egui::Ui, rect: egui::Rect, scroll_y: f32) {
    if rect.width() <= 0.0 || rect.height() <= 0.0 {
        return;
    }

    let painter = ui.painter_at(rect);

    let spacing = 18.0;
    let radius = 1.2;
    let background = ui.visuals().window_fill;
    let outline = ui.visuals().widgets.noninteractive.bg_stroke.color;
    let color = crate::themes::blend(background, outline, 0.35);

    let start_x = (rect.left() / spacing).floor() * spacing + spacing / 2.0;
    let start_y = rect.top() - scroll_y.rem_euclid(spacing) + spacing / 2.0;

    let mut y = start_y;
    while y < rect.bottom() {
        let mut x = start_x;
        while x < rect.right() {
            painter.circle_filled(egui::pos2(x, y), radius, color);
            x += spacing;
        }
        y += spacing;
    }
}

fn paint_hatching(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
    let spacing = 12.0;
    let stroke = egui::Stroke::new(1.0, color);

    let h = rect.height();
    let mut x = rect.left() - h;
    while x < rect.right() + h {
        painter.line_segment(
            [egui::pos2(x, rect.top()), egui::pos2(x + h, rect.bottom())],
            stroke,
        );
        x += spacing;
    }
}

pub(crate) fn show_postit_tooltip(ui: &egui::Ui, response: &egui::Response, text: &str) {
    let outline = ui.visuals().widgets.noninteractive.bg_stroke.color;
    let shadow_color = crate::themes::ral(9004);
    let shadow = egui::epaint::Shadow {
        offset: [4, 4],
        blur: 0,
        spread: 0,
        color: shadow_color,
    };

    let frame = egui::Frame::new()
        .fill(crate::themes::ral(1003))
        .stroke(egui::Stroke::new(1.0, outline))
        .shadow(shadow)
        .corner_radius(0.0)
        .inner_margin(egui::Margin::same(10));

    let mut tooltip = egui::containers::Tooltip::for_enabled(response);
    tooltip.popup = tooltip.popup.frame(frame);
    tooltip.show(|ui| {
        ui.set_max_width(ui.spacing().tooltip_width);
        ui.add(
            egui::Label::new(
                egui::RichText::new(text)
                    .monospace()
                    .color(crate::themes::ral(9011)),
            )
            .wrap_mode(egui::TextWrapMode::Extend),
        );
    });
}

fn paint_card_tab_button(
    ui: &egui::Ui,
    response: &egui::Response,
    rect: egui::Rect,
    label: &str,
    fill: egui::Color32,
    pull_out: f32,
) {
    let outline = ui.visuals().widgets.noninteractive.bg_stroke.color;
    let stroke = egui::Stroke::new(1.0, outline);
    let rounding = egui::CornerRadius {
        nw: 0,
        ne: 4,
        sw: 0,
        se: 4,
    };
    let rect = if response.hovered() || response.has_focus() {
        egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x + pull_out, rect.max.y))
    } else {
        rect
    };

    ui.painter().rect_filled(rect, rounding, fill);
    ui.painter()
        .rect_stroke(rect, rounding, stroke, egui::StrokeKind::Inside);
    let text_color = if response.enabled() {
        crate::themes::ral(9011)
    } else {
        crate::themes::blend(crate::themes::ral(9011), fill, 0.55)
    };
    ui.painter().text(
        rect.center(),
        egui::Align2::CENTER_CENTER,
        label,
        egui::FontId::monospace(10.0),
        text_color,
    );
}

// notebook initialization is handled by the #[notebook] attribute macro.
