use crate::plot::legend::{LegendEntry, LegendGroup};
use crate::render::layout::{ComputedLayout, Layout, DEFAULT_FONT_FAMILY};
use crate::render::plots::Plot;
use crate::render::render::{
    collect_legend_entries, render_legend_at, render_multiple, render_twin_y, Primitive, Scene,
    TextAnchor,
};

#[derive(Debug, Clone)]
pub enum FigureLegendPosition {
    // Right side (3 vertical alignments)
    /// Right side, vertically centred (kept for backward compatibility).
    Right,
    RightTop,
    RightMiddle,
    RightBottom,
    // Left side
    LeftTop,
    LeftMiddle,
    LeftBottom,
    // Top edge
    TopLeft,
    TopCenter,
    TopRight,
    // Bottom edge
    /// Bottom edge, horizontally centred (kept for backward compatibility).
    Bottom,
    BottomLeft,
    BottomCenter,
    BottomRight,
    /// Arbitrary pixel position within the figure canvas.
    Custom(f64, f64),
}

#[derive(Debug, Clone)]
pub enum LabelStyle {
    Uppercase,
    Lowercase,
    Numeric,
    Custom(Vec<String>),
}

#[derive(Debug, Clone)]
pub struct LabelConfig {
    pub style: LabelStyle,
    pub size: u32,
    pub bold: bool,
}

impl Default for LabelConfig {
    fn default() -> Self {
        Self {
            style: LabelStyle::Uppercase,
            size: 16,
            bold: true,
        }
    }
}

impl LabelConfig {
    fn label_for(&self, index: usize) -> String {
        match &self.style {
            LabelStyle::Uppercase => {
                let c = (b'A' + index as u8) as char;
                c.to_string()
            }
            LabelStyle::Lowercase => {
                let c = (b'a' + index as u8) as char;
                c.to_string()
            }
            LabelStyle::Numeric => (index + 1).to_string(),
            LabelStyle::Custom(labels) => labels.get(index).cloned().unwrap_or_default(),
        }
    }
}

#[derive(Debug, Clone)]
pub enum SharedAxis {
    AllRows,
    AllColumns,
    Row(usize),
    Column(usize),
    RowSlice {
        row: usize,
        col_start: usize,
        col_end: usize,
    },
    ColumnSlice {
        col: usize,
        row_start: usize,
        row_end: usize,
    },
}

pub struct Figure {
    rows: usize,
    cols: usize,
    structure: Vec<Vec<usize>>,
    plots: Vec<Vec<Plot>>,
    layouts: Vec<Layout>,
    title: Option<String>,
    title_size: u32,
    labels: Option<LabelConfig>,
    shared_x: Vec<SharedAxis>,
    shared_y: Vec<SharedAxis>,
    spacing: f64,
    padding: f64,
    cell_width: f64,
    cell_height: f64,
    figure_width: Option<f64>,
    figure_height: Option<f64>,
    shared_legend: Option<FigureLegendPosition>,
    shared_legend_entries: Option<Vec<LegendEntry>>,
    keep_panel_legends: bool,
    /// Sparse list of twin-Y cells: (cell_index, primary_plots, secondary_plots).
    twin_y_plots: Vec<(usize, Vec<Plot>, Vec<Plot>)>,
    /// Per-row height overrides (indexed by row, 0-based). None = use cell_height.
    row_heights: Vec<Option<f64>>,
    /// Per-col width overrides (indexed by col, 0-based). None = use cell_width.
    col_widths: Vec<Option<f64>>,
}

impl Figure {
    pub fn new(rows: usize, cols: usize) -> Self {
        let structure: Vec<Vec<usize>> = (0..rows * cols).map(|i| vec![i]).collect();
        Self {
            rows,
            cols,
            structure,
            plots: Vec::new(),
            layouts: Vec::new(),
            title: None,
            title_size: 20,
            labels: None,
            shared_x: Vec::new(),
            shared_y: Vec::new(),
            spacing: 15.0,
            padding: 10.0,
            cell_width: 500.0,
            cell_height: 380.0,
            figure_width: None,
            figure_height: None,
            shared_legend: None,
            shared_legend_entries: None,
            keep_panel_legends: false,
            twin_y_plots: Vec::new(),
            row_heights: Vec::new(),
            col_widths: Vec::new(),
        }
    }

    pub fn with_title<S: Into<String>>(mut self, title: S) -> Self {
        self.title = Some(title.into());
        self
    }

    pub fn with_title_size(mut self, size: u32) -> Self {
        self.title_size = size;
        self
    }

    pub fn with_structure(mut self, structure: Vec<Vec<usize>>) -> Self {
        self.structure = structure;
        self
    }

    pub fn with_plots(mut self, plots: Vec<Vec<Plot>>) -> Self {
        self.plots = plots;
        self
    }

    pub fn with_layouts(mut self, layouts: Vec<Layout>) -> Self {
        self.layouts = layouts;
        self
    }

    /// Add bold uppercase panel labels (A, B, C, ...)
    pub fn with_labels(mut self) -> Self {
        self.labels = Some(LabelConfig::default());
        self
    }

    pub fn with_labels_numeric(mut self) -> Self {
        self.labels = Some(LabelConfig {
            style: LabelStyle::Numeric,
            ..LabelConfig::default()
        });
        self
    }

    pub fn with_labels_lowercase(mut self) -> Self {
        self.labels = Some(LabelConfig {
            style: LabelStyle::Lowercase,
            ..LabelConfig::default()
        });
        self
    }

    pub fn with_labels_custom(mut self, labels: Vec<&str>, config: LabelConfig) -> Self {
        self.labels = Some(LabelConfig {
            style: LabelStyle::Custom(labels.into_iter().map(|s| s.to_string()).collect()),
            size: config.size,
            bold: config.bold,
        });
        self
    }

    /// Share X axis across all columns (blanket).
    pub fn with_shared_x_all(mut self) -> Self {
        self.shared_x.push(SharedAxis::AllColumns);
        self
    }

    /// Share Y axis across all rows (blanket).
    pub fn with_shared_y_all(mut self) -> Self {
        self.shared_y.push(SharedAxis::AllRows);
        self
    }

    /// Share X axis within a single column.
    pub fn with_shared_x(mut self, col: usize) -> Self {
        self.shared_x.push(SharedAxis::Column(col));
        self
    }

    /// Share Y axis within a single row.
    pub fn with_shared_y(mut self, row: usize) -> Self {
        self.shared_y.push(SharedAxis::Row(row));
        self
    }

    /// Share X axis within a column for a slice of rows.
    pub fn with_shared_x_slice(mut self, col: usize, row_start: usize, row_end: usize) -> Self {
        self.shared_x.push(SharedAxis::ColumnSlice {
            col,
            row_start,
            row_end,
        });
        self
    }

    /// Share Y axis within a row for a slice of columns.
    pub fn with_shared_y_slice(mut self, row: usize, col_start: usize, col_end: usize) -> Self {
        self.shared_y.push(SharedAxis::RowSlice {
            row,
            col_start,
            col_end,
        });
        self
    }

    pub fn with_spacing(mut self, px: f64) -> Self {
        self.spacing = px;
        self
    }

    pub fn with_padding(mut self, px: f64) -> Self {
        self.padding = px;
        self
    }

    pub fn with_cell_size(mut self, w: f64, h: f64) -> Self {
        self.cell_width = w;
        self.cell_height = h;
        self
    }

    /// Override the height of a specific grid row (0-based).
    /// Useful for thin legend rows or tall data rows. Unset rows use `cell_height`.
    pub fn with_row_height(mut self, row: usize, height: f64) -> Self {
        if self.row_heights.len() <= row {
            self.row_heights.resize(row + 1, None);
        }
        self.row_heights[row] = Some(height);
        self
    }

    /// Override the width of a specific grid column (0-based).
    /// Unset columns use `cell_width`.
    pub fn with_col_width(mut self, col: usize, width: f64) -> Self {
        if self.col_widths.len() <= col {
            self.col_widths.resize(col + 1, None);
        }
        self.col_widths[col] = Some(width);
        self
    }

    /// Set the total figure size in pixels; cells auto-compute to fit.
    /// Takes precedence over `with_cell_size` when both are set.
    /// Explicit per-row/col sizes (from `with_row_height`/`with_col_width`) are
    /// subtracted first; the remaining space is divided among un-constrained rows/cols.
    pub fn with_figure_size(mut self, w: f64, h: f64) -> Self {
        self.figure_width = Some(w);
        self.figure_height = Some(h);
        self
    }

    /// Add a shared legend to the right of the figure (auto-collected from plots).
    pub fn with_shared_legend(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::Right);
        self
    }

    /// Add a shared legend below the figure (auto-collected from plots).
    pub fn with_shared_legend_bottom(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::Bottom);
        self
    }

    /// Override shared legend position.
    pub fn with_shared_legend_position(mut self, pos: FigureLegendPosition) -> Self {
        self.shared_legend = Some(pos);
        self
    }

    /// Place the shared legend at an arbitrary pixel position within the figure canvas.
    pub fn with_shared_legend_at(mut self, x: f64, y: f64) -> Self {
        self.shared_legend = Some(FigureLegendPosition::Custom(x, y));
        self
    }

    pub fn with_shared_legend_right_top(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::RightTop);
        self
    }

    pub fn with_shared_legend_right_middle(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::RightMiddle);
        self
    }

    pub fn with_shared_legend_right_bottom(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::RightBottom);
        self
    }

    pub fn with_shared_legend_left_top(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::LeftTop);
        self
    }

    pub fn with_shared_legend_left_middle(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::LeftMiddle);
        self
    }

    pub fn with_shared_legend_left_bottom(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::LeftBottom);
        self
    }

    pub fn with_shared_legend_top_left(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::TopLeft);
        self
    }

    pub fn with_shared_legend_top_center(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::TopCenter);
        self
    }

    pub fn with_shared_legend_top_right(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::TopRight);
        self
    }

    pub fn with_shared_legend_bottom_left(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::BottomLeft);
        self
    }

    pub fn with_shared_legend_bottom_center(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::BottomCenter);
        self
    }

    pub fn with_shared_legend_bottom_right(mut self) -> Self {
        self.shared_legend = Some(FigureLegendPosition::BottomRight);
        self
    }

    /// Provide manual legend entries instead of auto-collecting.
    pub fn with_shared_legend_entries(mut self, entries: Vec<LegendEntry>) -> Self {
        self.shared_legend_entries = Some(entries);
        self
    }

    /// Keep per-panel legends visible alongside the shared legend.
    pub fn with_keep_panel_legends(mut self) -> Self {
        self.keep_panel_legends = true;
        self
    }

    /// Place a twin-Y plot in a specific cell slot.
    ///
    /// `cell_index` is the zero-based flat cell index (row * cols + col).
    /// Primary plots are drawn against the left Y axis; secondary plots against the right.
    /// If no matching `Layout` is provided via `with_layouts`, the layout is auto-computed
    /// from both plot sets via `Layout::auto_from_twin_y_plots`.
    pub fn with_twin_y_plots(
        mut self,
        cell_index: usize,
        primary: Vec<Plot>,
        secondary: Vec<Plot>,
    ) -> Self {
        self.twin_y_plots.push((cell_index, primary, secondary));
        self
    }

    pub fn render(self) -> Scene {
        let Figure {
            rows,
            cols,
            structure,
            mut plots,
            layouts: user_layouts,
            title,
            title_size,
            labels,
            shared_x,
            shared_y,
            spacing,
            padding,
            mut cell_width,
            mut cell_height,
            figure_width,
            figure_height,
            shared_legend,
            shared_legend_entries,
            keep_panel_legends,
            twin_y_plots,
            row_heights: explicit_row_heights,
            col_widths: explicit_col_widths,
        } = self;

        // Build a lookup from cell_index → (primary, secondary) for twin-Y cells.
        let mut twin_y_map: std::collections::HashMap<usize, (Vec<Plot>, Vec<Plot>)> = twin_y_plots
            .into_iter()
            .map(|(i, p, s)| (i, (p, s)))
            .collect();

        validate_structure(&structure, rows, cols);

        // Collect shared legend entries before we move plots into cells
        let legend_entries: Option<Vec<LegendEntry>> = if shared_legend.is_some() {
            Some(if let Some(manual) = shared_legend_entries {
                manual
            } else {
                // Auto-collect from all panels (regular + twin-Y), deduplicate by label
                let mut all_entries = Vec::new();
                let mut seen_labels = std::collections::HashSet::new();
                for panel_plots in &plots {
                    for entry in collect_legend_entries(panel_plots) {
                        if seen_labels.insert(entry.label.clone()) {
                            all_entries.push(entry);
                        }
                    }
                }
                for (primary, secondary) in twin_y_map.values() {
                    for entry in collect_legend_entries(primary)
                        .into_iter()
                        .chain(collect_legend_entries(secondary))
                    {
                        if seen_labels.insert(entry.label.clone()) {
                            all_entries.push(entry);
                        }
                    }
                }
                all_entries
            })
        } else {
            None
        };

        // Compute shared legend dimensions
        let legend_spacing = 20.0;
        let (legend_width, legend_height) = if let Some(ref entries) = legend_entries {
            if entries.is_empty() {
                (0.0, 0.0)
            } else {
                let max_label_len = entries.iter().map(|e| e.label.len()).max().unwrap_or(0);
                let w = (max_label_len as f64 * 7.0 + 35.0).max(80.0);
                let h = entries.len() as f64 * 18.0 + 20.0;
                (w, h)
            }
        } else {
            (0.0, 0.0)
        };

        // Helpers: which side does the legend land on?
        let has_entries = legend_entries.as_ref().is_some_and(|e| !e.is_empty());
        let legend_on_right = has_entries
            && matches!(
                shared_legend.as_ref(),
                Some(
                    FigureLegendPosition::Right
                        | FigureLegendPosition::RightTop
                        | FigureLegendPosition::RightMiddle
                        | FigureLegendPosition::RightBottom
                )
            );
        let legend_on_left = has_entries
            && matches!(
                shared_legend.as_ref(),
                Some(
                    FigureLegendPosition::LeftTop
                        | FigureLegendPosition::LeftMiddle
                        | FigureLegendPosition::LeftBottom
                )
            );
        let legend_on_top = has_entries
            && matches!(
                shared_legend.as_ref(),
                Some(
                    FigureLegendPosition::TopLeft
                        | FigureLegendPosition::TopCenter
                        | FigureLegendPosition::TopRight
                )
            );
        let legend_on_bottom = has_entries
            && matches!(
                shared_legend.as_ref(),
                Some(
                    FigureLegendPosition::Bottom
                        | FigureLegendPosition::BottomLeft
                        | FigureLegendPosition::BottomCenter
                        | FigureLegendPosition::BottomRight
                )
            );

        // If total figure size is specified, back-compute cell dimensions to fit.
        // Explicit per-row/col sizes are subtracted first; remaining space is shared
        // equally among unconstrained rows/cols.
        if let (Some(fw), Some(fh)) = (figure_width, figure_height) {
            let legend_w_used = if legend_on_right || legend_on_left {
                legend_width + legend_spacing
            } else {
                0.0
            };
            let legend_h_used = if legend_on_top || legend_on_bottom {
                legend_height + legend_spacing
            } else {
                0.0
            };
            let title_h = if title.is_some() { 30.0 } else { 0.0 };

            let explicit_col_total: f64 = (0..cols)
                .filter_map(|c| explicit_col_widths.get(c).copied().flatten())
                .sum();
            let free_cols = (0..cols)
                .filter(|&c| explicit_col_widths.get(c).copied().flatten().is_none())
                .count();
            if free_cols > 0 {
                cell_width = ((fw
                    - legend_w_used
                    - 2.0 * padding
                    - (cols as f64 - 1.0) * spacing
                    - explicit_col_total)
                    / free_cols as f64)
                    .max(1.0);
            }

            let explicit_row_total: f64 = (0..rows)
                .filter_map(|r| explicit_row_heights.get(r).copied().flatten())
                .sum();
            let free_rows = (0..rows)
                .filter(|&r| explicit_row_heights.get(r).copied().flatten().is_none())
                .count();
            if free_rows > 0 {
                cell_height = ((fh
                    - legend_h_used
                    - 2.0 * padding
                    - (rows as f64 - 1.0) * spacing
                    - title_h
                    - explicit_row_total)
                    / free_rows as f64)
                    .max(1.0);
            }
        }

        let figure_title_height = if title.is_some() { 30.0 } else { 0.0 };

        // Build a layout for each structure slot (needed before per-row height calc).
        let mut layouts: Vec<Layout> = Vec::new();
        for i in 0..structure.len() {
            let layout = if i < user_layouts.len() {
                clone_layout(&user_layouts[i])
            } else if let Some((primary, secondary)) = twin_y_map.get(&i) {
                Layout::auto_from_twin_y_plots(primary, secondary)
            } else if i < plots.len() && !plots[i].is_empty() {
                Layout::auto_from_plots(&plots[i])
            } else {
                Layout::new((0.0, 1.0), (0.0, 1.0))
            };
            layouts.push(layout);
        }

        // Apply shared axis rules
        apply_shared_axes(&structure, &shared_y, &shared_x, &mut layouts, rows, cols);

        // Suppress per-panel legends when shared legend is active
        if shared_legend.is_some() && !keep_panel_legends {
            for layout in layouts.iter_mut() {
                layout.show_legend = false;
            }
        }

        // Compute per-grid-row heights.  A grid row's height is the default
        // `cell_height` unless any cell in that row contains a BrickPlot with
        // `row_height_px`, in which case we compute:
        //   canvas_height = row_height_px * num_rows + actual_margin_top + actual_margin_bottom
        //
        // Margins are extracted from a provisional ComputedLayout (margins do not
        // depend on canvas size) so they account for suppress_x_ticks, axis labels,
        // font sizes, etc. — giving exact row heights rather than relying on a fixed
        // margin estimate.
        let mut per_row_heights: Vec<f64> = vec![cell_height; rows];
        for (i, group) in structure.iter().enumerate() {
            let rect = cell_rect(group, cols);
            let grid_row = rect.0;
            if i < plots.len() && i < layouts.len() {
                for plot in &plots[i] {
                    if let Plot::Brick(bp) = plot {
                        if let Some(rh) = bp.row_height_px {
                            let n = bp.num_rows();
                            if n > 0 {
                                // Compute actual margins from the post-shared-axis layout.
                                // Margins do not depend on canvas height, so this is exact.
                                let cl = ComputedLayout::from_layout(&layouts[i]);
                                let overhead = cl.margin_top + cl.margin_bottom;
                                let desired = rh * n as f64 + overhead;
                                // Always set — desired is typically smaller than
                                // the default cell_height, so "> cell_height" would
                                // silently skip every brick row height request.
                                per_row_heights[grid_row] = desired;
                            }
                        }
                    }
                }
            }
        }

        // Apply explicit per-row height overrides (highest priority).
        for (r, rh) in explicit_row_heights.iter().enumerate() {
            if let Some(h) = rh {
                if r < rows {
                    per_row_heights[r] = *h;
                }
            }
        }

        // Build per-column widths, applying any explicit overrides.
        let mut per_col_widths: Vec<f64> = vec![cell_width; cols];
        for (c, cw) in explicit_col_widths.iter().enumerate() {
            if let Some(w) = cw {
                if c < cols {
                    per_col_widths[c] = *w;
                }
            }
        }

        // Prefix sums for fast cell_y / cell_x calculation.
        let row_y_starts: Vec<f64> = {
            let mut starts = vec![0.0f64; rows + 1];
            for r in 0..rows {
                starts[r + 1] = starts[r] + per_row_heights[r] + spacing;
            }
            starts
        };
        let col_x_starts: Vec<f64> = {
            let mut starts = vec![0.0f64; cols + 1];
            for c in 0..cols {
                starts[c + 1] = starts[c] + per_col_widths[c] + spacing;
            }
            starts
        };

        let grid_width =
            per_col_widths.iter().sum::<f64>() + (cols as f64 - 1.0) * spacing + 2.0 * padding;
        let grid_height = per_row_heights.iter().sum::<f64>()
            + (rows as f64 - 1.0) * spacing
            + 2.0 * padding
            + figure_title_height;

        // When the legend is on the left or top, shift all grid cells so the
        // legend occupies the vacated margin.
        let cell_x_offset = if legend_on_left {
            legend_width + legend_spacing
        } else {
            0.0
        };
        let cell_y_offset = if legend_on_top {
            legend_height + legend_spacing
        } else {
            0.0
        };

        let total_width = grid_width
            + if legend_on_right || legend_on_left {
                legend_width + legend_spacing
            } else {
                0.0
            };
        let total_height = grid_height
            + if legend_on_top || legend_on_bottom {
                legend_height + legend_spacing
            } else {
                0.0
            };

        let mut master = Scene::new(total_width, total_height);
        // Inherit font_family and theme from first user layout if set
        let figure_theme = user_layouts
            .first()
            .map(|l| l.theme.clone())
            .unwrap_or_default();
        master.font_family = user_layouts
            .first()
            .and_then(|l| l.font_family.clone())
            .or(figure_theme.font_family.clone())
            .or(Some(DEFAULT_FONT_FAMILY.to_string()));
        master.background_color = Some(figure_theme.background.clone());
        master.text_color = Some(figure_theme.text_color.clone());

        // Pad plots with empty vecs so indexing is safe
        while plots.len() < structure.len() {
            plots.push(Vec::new());
        }

        for (i, group) in structure.iter().enumerate() {
            let rect = cell_rect(group, cols);
            let col_span = rect.3 - rect.1 + 1;
            let row_span = rect.2 - rect.0 + 1;

            let cell_x = cell_x_offset + padding + col_x_starts[rect.1];
            let cell_y = cell_y_offset + padding + figure_title_height + row_y_starts[rect.0];
            let cell_w = (rect.1..rect.1 + col_span)
                .map(|c| per_col_widths[c])
                .sum::<f64>()
                + (col_span as f64 - 1.0) * spacing;
            // Multi-row spans: sum all spanned row heights + inter-row spacings.
            let cell_h = (rect.0..rect.0 + row_span)
                .map(|r| per_row_heights[r])
                .sum::<f64>()
                + (row_span as f64 - 1.0) * spacing;

            let slot_plots = std::mem::take(&mut plots[i]);

            let cell_scene_opt = if let Some((primary, secondary)) = twin_y_map.remove(&i) {
                let mut layout = clone_layout(&layouts[i]);
                layout.width = Some(cell_w);
                layout.height = Some(cell_h);
                Some(render_twin_y(primary, secondary, layout))
            } else if !slot_plots.is_empty() {
                let mut layout = clone_layout(&layouts[i]);
                layout.width = Some(cell_w);
                layout.height = Some(cell_h);
                Some(render_multiple(slot_plots, layout))
            } else {
                None
            };

            if let Some(cell_scene) = cell_scene_opt {
                for def in cell_scene.defs {
                    master.defs.push(def);
                }
                master.add(Primitive::GroupStart {
                    transform: Some(format!("translate({cell_x},{cell_y})")),
                    title: None,
                    extra_attrs: None,
                });
                for elem in cell_scene.elements {
                    master.add(elem);
                }
                master.add(Primitive::GroupEnd);
            }

            if let Some(ref config) = labels {
                let label = config.label_for(i);
                master.add(Primitive::Text {
                    x: cell_x + 8.0,
                    y: cell_y + config.size as f64 + 2.0,
                    content: label,
                    size: config.size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: config.bold,
                    color: None,
                });
            }
        }

        if let Some(title) = title {
            master.add(Primitive::Text {
                x: total_width / 2.0,
                y: 22.0,
                content: title,
                size: title_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }

        // Render shared legend
        if let (Some(ref pos), Some(ref entries)) = (&shared_legend, &legend_entries) {
            if !entries.is_empty() {
                // Pixel extents of just the grid content (cells + padding, no legend margin).
                let inner_right = cell_x_offset + grid_width;
                let inner_bottom = cell_y_offset + grid_height;
                // Vertical centre of the grid content area (below the figure title).
                let grid_mid_y = cell_y_offset
                    + figure_title_height
                    + padding
                    + (grid_height - figure_title_height - 2.0 * padding) / 2.0;

                let (lx, ly) = match pos {
                    // ── Right side ──────────────────────────────────────────────
                    FigureLegendPosition::Right | FigureLegendPosition::RightMiddle => (
                        inner_right + legend_spacing / 2.0,
                        grid_mid_y - legend_height / 2.0,
                    ),
                    FigureLegendPosition::RightTop => (
                        inner_right + legend_spacing / 2.0,
                        cell_y_offset + figure_title_height + padding,
                    ),
                    FigureLegendPosition::RightBottom => (
                        inner_right + legend_spacing / 2.0,
                        inner_bottom - padding - legend_height,
                    ),
                    // ── Left side ────────────────────────────────────────────────
                    FigureLegendPosition::LeftMiddle => {
                        (legend_spacing / 2.0, grid_mid_y - legend_height / 2.0)
                    }
                    FigureLegendPosition::LeftTop => (
                        legend_spacing / 2.0,
                        cell_y_offset + figure_title_height + padding,
                    ),
                    FigureLegendPosition::LeftBottom => {
                        (legend_spacing / 2.0, inner_bottom - padding - legend_height)
                    }
                    // ── Top edge ─────────────────────────────────────────────────
                    FigureLegendPosition::TopLeft => (
                        cell_x_offset + padding,
                        figure_title_height + legend_spacing / 2.0,
                    ),
                    FigureLegendPosition::TopCenter => (
                        cell_x_offset + grid_width / 2.0 - legend_width / 2.0,
                        figure_title_height + legend_spacing / 2.0,
                    ),
                    FigureLegendPosition::TopRight => (
                        cell_x_offset + grid_width - padding - legend_width,
                        figure_title_height + legend_spacing / 2.0,
                    ),
                    // ── Bottom edge ───────────────────────────────────────────────
                    FigureLegendPosition::Bottom | FigureLegendPosition::BottomCenter => (
                        cell_x_offset + grid_width / 2.0 - legend_width / 2.0,
                        inner_bottom + legend_spacing / 2.0,
                    ),
                    FigureLegendPosition::BottomLeft => {
                        (cell_x_offset + padding, inner_bottom + legend_spacing / 2.0)
                    }
                    FigureLegendPosition::BottomRight => (
                        cell_x_offset + grid_width - padding - legend_width,
                        inner_bottom + legend_spacing / 2.0,
                    ),
                    FigureLegendPosition::Custom(x, y) => (*x, *y),
                };
                let body_size = user_layouts.first().map_or(12, |l| l.body_size);
                render_legend_at(
                    entries,
                    None::<&[LegendGroup]>,
                    None,
                    true,
                    &mut master,
                    lx,
                    ly,
                    legend_width,
                    body_size,
                    &figure_theme,
                );
            }
        }

        master
    }
}

/// Clone a Layout field by field.
fn clone_layout(l: &Layout) -> Layout {
    let mut new = Layout::new(l.x_range, l.y_range);
    new.width = l.width;
    new.height = l.height;
    new.data_x_range = l.data_x_range;
    new.data_y_range = l.data_y_range;
    new.ticks = l.ticks;
    new.show_grid = l.show_grid;
    new.x_label = l.x_label.clone();
    new.y_label = l.y_label.clone();
    new.title = l.title.clone();
    new.x_categories = l.x_categories.clone();
    new.y_categories = l.y_categories.clone();
    new.show_legend = l.show_legend;
    new.show_colorbar = l.show_colorbar;
    new.legend_position = l.legend_position;
    new.legend_width = l.legend_width;
    new.legend_entries = l.legend_entries.clone();
    new.legend_title = l.legend_title.clone();
    new.legend_groups = l.legend_groups.clone();
    new.legend_box = l.legend_box;
    new.legend_height = l.legend_height;
    new.stats_entries = l.stats_entries.clone();
    new.stats_title = l.stats_title.clone();
    new.stats_position = l.stats_position;
    new.stats_box = l.stats_box;
    new.log_x = l.log_x;
    new.log_y = l.log_y;
    new.annotations = l.annotations.clone();
    new.reference_lines = l.reference_lines.clone();
    new.shaded_regions = l.shaded_regions.clone();
    new.suppress_x_ticks = l.suppress_x_ticks;
    new.suppress_y_ticks = l.suppress_y_ticks;
    new.font_family = l.font_family.clone();
    new.title_size = l.title_size;
    new.label_size = l.label_size;
    new.tick_size = l.tick_size;
    new.body_size = l.body_size;
    new.axis_line_width = l.axis_line_width;
    new.tick_width = l.tick_width;
    new.tick_length = l.tick_length;
    new.grid_line_width = l.grid_line_width;
    new.theme = l.theme.clone();
    new.palette = None; // Palette is consumed at render_multiple level, not cloned per-cell
    new.x_tick_format = l.x_tick_format.clone();
    new.y_tick_format = l.y_tick_format.clone();
    new.colorbar_tick_format = l.colorbar_tick_format.clone();
    new.y2_range = l.y2_range;
    new.data_y2_range = l.data_y2_range;
    new.y2_label = l.y2_label.clone();
    new.log_y2 = l.log_y2;
    new.y2_tick_format = l.y2_tick_format.clone();
    new.suppress_y2_ticks = l.suppress_y2_ticks;
    new.x_axis_min = l.x_axis_min;
    new.x_axis_max = l.x_axis_max;
    new.y_axis_min = l.y_axis_min;
    new.y_axis_max = l.y_axis_max;
    new.x_datetime = l.x_datetime.clone();
    new.y_datetime = l.y_datetime.clone();
    new.x_tick_rotate = l.x_tick_rotate;
    new.clamp_axis = l.clamp_axis;
    new.clamp_y_axis = l.clamp_y_axis;
    new.x_bin_width = l.x_bin_width;
    new.term_rows = l.term_rows;
    new.x_tick_step = l.x_tick_step;
    new.y_tick_step = l.y_tick_step;
    new.minor_ticks = l.minor_ticks;
    new.show_minor_grid = l.show_minor_grid;
    new.x_label_offset = l.x_label_offset;
    new.y_label_offset = l.y_label_offset;
    new.y2_label_offset = l.y2_label_offset;
    new.scale = l.scale;
    new.polar_r_label_angle = l.polar_r_label_angle;
    new.interactive = l.interactive;
    new.equal_aspect = l.equal_aspect;
    new.brick_notation_tiers = l.brick_notation_tiers;
    new.title_wrap = l.title_wrap;
    new.x_label_wrap = l.x_label_wrap;
    new.y_label_wrap = l.y_label_wrap;
    new.y2_label_wrap = l.y2_label_wrap;
    new.legend_wrap = l.legend_wrap;
    new.horizon_right_annot_px = l.horizon_right_annot_px;
    new.gantt_right_annot_px = l.gantt_right_annot_px;
    new
}

/// Returns (min_row, min_col, max_row, max_col) for a group of cell indices.
fn cell_rect(group: &[usize], cols: usize) -> (usize, usize, usize, usize) {
    let mut min_row = usize::MAX;
    let mut max_row = 0;
    let mut min_col = usize::MAX;
    let mut max_col = 0;
    for &idx in group {
        let row = idx / cols;
        let col = idx % cols;
        min_row = min_row.min(row);
        max_row = max_row.max(row);
        min_col = min_col.min(col);
        max_col = max_col.max(col);
    }
    (min_row, min_col, max_row, max_col)
}

fn validate_structure(structure: &[Vec<usize>], rows: usize, cols: usize) {
    let total_cells = rows * cols;
    let mut seen = vec![false; total_cells];

    for (group_idx, group) in structure.iter().enumerate() {
        assert!(
            !group.is_empty(),
            "Figure structure: group {group_idx} is empty"
        );

        for &idx in group {
            assert!(
                idx < total_cells,
                "Figure structure: cell index {idx} out of bounds (grid is {rows}x{cols} = {total} cells)",
                total = total_cells,
            );
            assert!(
                !seen[idx],
                "Figure structure: cell index {idx} appears in multiple groups"
            );
            seen[idx] = true;
        }

        let (min_row, min_col, max_row, max_col) = cell_rect(group, cols);
        let expected_count = (max_row - min_row + 1) * (max_col - min_col + 1);
        assert!(
            group.len() == expected_count,
            "Figure structure: group {group_idx} is not a filled rectangle \
             (has {} cells, expected {expected_count} for rows {min_row}..={max_row}, cols {min_col}..={max_col})",
            group.len(),
        );

        for r in min_row..=max_row {
            for c in min_col..=max_col {
                let cell = r * cols + c;
                assert!(
                    group.contains(&cell),
                    "Figure structure: group {group_idx} missing cell {cell} \
                     (row {r}, col {c}) — groups must be filled rectangles"
                );
            }
        }
    }
}

fn subplot_grid_pos(
    structure: &[Vec<usize>],
    subplot_idx: usize,
    cols: usize,
) -> Option<(usize, usize)> {
    if subplot_idx >= structure.len() {
        return None;
    }
    let group = &structure[subplot_idx];
    if group.is_empty() {
        return None;
    }
    let (min_row, min_col, _, _) = cell_rect(group, cols);
    Some((min_row, min_col))
}

fn apply_shared_axes(
    structure: &[Vec<usize>],
    shared_y_rules: &[SharedAxis],
    shared_x_rules: &[SharedAxis],
    layouts: &mut [Layout],
    _rows: usize,
    cols: usize,
) {
    for rule in shared_y_rules {
        let groups = matching_groups_for_shared_y(structure, rule, cols);
        if groups.len() < 2 {
            continue;
        }

        let mut y_min = f64::INFINITY;
        let mut y_max = f64::NEG_INFINITY;
        for &idx in &groups {
            if idx < layouts.len() {
                y_min = y_min.min(layouts[idx].y_range.0);
                y_max = y_max.max(layouts[idx].y_range.1);
            }
        }

        let leftmost_col = groups
            .iter()
            .filter_map(|&idx| subplot_grid_pos(structure, idx, cols))
            .map(|(_, col)| col)
            .min()
            .unwrap_or(0);

        for &idx in &groups {
            if idx < layouts.len() {
                layouts[idx].y_range = (y_min, y_max);
                if let Some((_, col)) = subplot_grid_pos(structure, idx, cols) {
                    if col != leftmost_col {
                        layouts[idx].suppress_y_ticks = true;
                        layouts[idx].y_label = None;
                    }
                }
            }
        }
    }

    for rule in shared_x_rules {
        let groups = matching_groups_for_shared_x(structure, rule, cols);
        if groups.len() < 2 {
            continue;
        }

        let mut x_min = f64::INFINITY;
        let mut x_max = f64::NEG_INFINITY;
        for &idx in &groups {
            if idx < layouts.len() {
                x_min = x_min.min(layouts[idx].x_range.0);
                x_max = x_max.max(layouts[idx].x_range.1);
            }
        }

        let bottommost_row = groups
            .iter()
            .filter_map(|&idx| subplot_grid_pos(structure, idx, cols))
            .map(|(row, _)| row)
            .max()
            .unwrap_or(0);

        for &idx in &groups {
            if idx < layouts.len() {
                layouts[idx].x_range = (x_min, x_max);
                if let Some((row, _)) = subplot_grid_pos(structure, idx, cols) {
                    if row != bottommost_row {
                        layouts[idx].suppress_x_ticks = true;
                        layouts[idx].x_label = None;
                    }
                }
            }
        }
    }
}

fn matching_groups_for_shared_y(
    structure: &[Vec<usize>],
    rule: &SharedAxis,
    cols: usize,
) -> Vec<usize> {
    let mut result = Vec::new();
    for (idx, _) in structure.iter().enumerate() {
        if let Some((row, col)) = subplot_grid_pos(structure, idx, cols) {
            let matches = match rule {
                SharedAxis::AllRows => true,
                SharedAxis::Row(r) => row == *r,
                SharedAxis::RowSlice {
                    row: r,
                    col_start,
                    col_end,
                } => row == *r && col >= *col_start && col <= *col_end,
                _ => false,
            };
            if matches {
                result.push(idx);
            }
        }
    }
    result
}

fn matching_groups_for_shared_x(
    structure: &[Vec<usize>],
    rule: &SharedAxis,
    cols: usize,
) -> Vec<usize> {
    let mut result = Vec::new();
    for (idx, _) in structure.iter().enumerate() {
        if let Some((row, col)) = subplot_grid_pos(structure, idx, cols) {
            let matches = match rule {
                SharedAxis::AllColumns => true,
                SharedAxis::Column(c) => col == *c,
                SharedAxis::ColumnSlice {
                    col: c,
                    row_start,
                    row_end,
                } => col == *c && row >= *row_start && row <= *row_end,
                _ => false,
            };
            if matches {
                result.push(idx);
            }
        }
    }
    result
}
