//! Split-view layout. The FOCUSED pane lives in `app::run`'s loose locals; this
//! module bundles the OTHER pane (`Pane`) and provides the pure compositor that
//! stitches two half-width frames into one full-width frame. No terminal I/O.

use crate::line_index::LineIndex;
use crate::render::Cell;
use crate::source::Source;
use crate::viewport::{Frame, RowStyle, Viewport};

/// Width of the inter-pane divider, in columns.
pub const DIVIDER: usize = 1;

/// One side of a split: its own source, index, viewport, and per-pane
/// follow/animation bookkeeping. The focused pane lives in app::run's loose
/// locals; this is the stashed partner swapped in on focus change.
pub struct Pane {
    pub src: Box<dyn Source>,
    pub idx: LineIndex,
    pub viewport: Viewport,
    pub last_revision: u64,
    #[cfg(feature = "image")]
    pub last_tick: std::time::Instant,
}

/// Left/right content widths for a split at `cols` columns (1-col divider).
/// Right gets the extra column on odd widths. Returns `(cols, 0)` when there's
/// no room to split — caller renders the focused pane full-width.
pub fn split_widths(cols: u16) -> (u16, u16) {
    const MIN: usize = 8; // each pane needs a usable minimum
    let c = cols as usize;
    if c < 2 * MIN + DIVIDER {
        return (cols, 0);
    }
    let usable = c - DIVIDER;
    let left = usable / 2;
    (left as u16, (usable - left) as u16)
}


/// Re-derive a pane's top line under scroll-lock. `focused_top` is the focused
/// pane's current top; `focused_offset`/`pane_offset` are the two panes' offsets
/// relative to the leftmost pane (captured at lock enable). Clamped to
/// `0..=pane_max`. Recomputed from the fixed offsets each call (no drift;
/// restores after a clamp). Tab-invariant.
pub fn locked_pane_top(focused_top: usize, focused_offset: isize, pane_offset: isize, pane_max: usize) -> usize {
    let raw = focused_top as isize + (pane_offset - focused_offset);
    raw.clamp(0, pane_max as isize) as usize
}

/// Column widths for `n` vertical panes at `cols` columns (n-1 dividers).
/// Remainder columns go to the rightmost panes (matching the 2-pane legacy
/// convention where the right pane receives the extra column). Returns a single
/// `[cols]` entry (the too-narrow fallback — caller renders only the focused
/// pane full-width) when each pane would fall below the usable minimum.
pub fn split_widths_n(cols: u16, n: usize) -> Vec<u16> {
    const MIN: usize = 8;
    if n <= 1 {
        return vec![cols];
    }
    let c = cols as usize;
    let dividers = n - 1;
    if c < n * MIN + dividers {
        return vec![cols];
    }
    let usable = c - dividers;
    let base = usable / n;
    let rem = usable % n;
    // Remainder goes to the rightmost panes, matching split_widths's convention
    // of giving the right pane the extra column on odd widths.
    (0..n).map(|i| (base + if i >= n - rem { 1 } else { 0 }) as u16).collect()
}

/// Distribute `total` units across `sizes.len()` panes. `Some(pct)` panes take
/// round(pct% * total); `None` (auto) panes split the remainder equally. Every
/// pane is clamped to >= `min`; if explicit percentages leave the autos below
/// `min`, the explicit panes are scaled down. Rounding drift is absorbed by the
/// largest pane so the result sums to exactly `total`. All-None == even split.
/// Caller guarantees `total >= sizes.len() * min` (else it uses its own fallback).
pub fn split_axis_weighted(total: u16, sizes: &[Option<u16>], min: u16) -> Vec<u16> {
    let n = sizes.len();
    if n == 0 { return vec![]; }
    if n == 1 { return vec![total]; }
    let total_u = total as u32;
    let min_u = min as u32;
    let auto_count = sizes.iter().filter(|s| s.is_none()).count() as u32;

    let mut out: Vec<u32> = sizes.iter().map(|s| match s {
        Some(p) => (((*p as u32) * total_u + 50) / 100).max(min_u),
        None => 0,
    }).collect();

    // If explicit panes + autos' minimum don't fit, scale explicit down.
    let explicit_sum: u32 = out.iter().sum();
    let auto_reserve = auto_count * min_u;
    if explicit_sum + auto_reserve > total_u && explicit_sum > 0 {
        let avail = total_u.saturating_sub(auto_reserve);
        for (i, s) in sizes.iter().enumerate() {
            if s.is_some() {
                out[i] = (out[i] * avail / explicit_sum).max(min_u);
            }
        }
    }

    // Autos split the remainder equally; remainder columns go to the rightmost
    // auto panes, matching split_widths_n's convention (right-gets-extra).
    if auto_count > 0 {
        let used: u32 = sizes.iter().enumerate()
            .filter(|(_, s)| s.is_some()).map(|(i, _)| out[i]).sum();
        let remainder = total_u.saturating_sub(used);
        let base = remainder / auto_count;
        let extra = remainder % auto_count;
        // Count auto panes to track which are the last `extra` ones.
        let auto_total = auto_count;
        let mut auto_seen = 0u32;
        for (i, s) in sizes.iter().enumerate() {
            if s.is_none() {
                auto_seen += 1;
                // Give +1 to the last `extra` auto panes (rightmost-first).
                let add = if auto_seen > auto_total - extra { 1u32 } else { 0 };
                out[i] = (base + add).max(min_u);
            }
        }
    }

    // Absorb rounding drift one cell at a time so the result sums to exactly
    // `total`. Each iteration adjusts the largest eligible pane by ±1; drift
    // magnitude is small (a few cells at most), so this terminates quickly.
    loop {
        let sum: u32 = out.iter().sum();
        if sum == total_u { break; }
        if sum > total_u {
            // Trim 1 from the largest pane that is still above min.
            if let Some(idx) = (0..n)
                .filter(|&i| out[i] > min_u)
                .max_by_key(|&i| out[i])
            {
                out[idx] -= 1;
            } else {
                break; // unreachable given caller guarantee, but don't spin
            }
        } else {
            // Grow 1 on the largest pane (no upper clamp needed).
            let idx = (0..n).max_by_key(|&i| out[i]).unwrap();
            out[idx] += 1;
        }
    }

    out.iter().map(|&x| x as u16).collect()
}

/// Weighted vertical split: same divider/min/too-narrow accounting as
/// `split_widths_n`, but distributing the usable columns via percentages.
pub fn split_widths_n_weighted(cols: u16, sizes: &[Option<u16>]) -> Vec<u16> {
    const MIN: usize = 8;
    let n = sizes.len();
    if n <= 1 { return vec![cols]; }
    let c = cols as usize;
    let dividers = n - 1;
    if c < n * MIN + dividers { return vec![cols]; }
    let usable = (c - dividers) as u16;
    split_axis_weighted(usable, sizes, MIN as u16)
}

/// Weighted horizontal split: mirrors `split_heights_n` (MIN 2, no divider row).
pub fn split_heights_n_weighted(rows: u16, sizes: &[Option<u16>]) -> Vec<u16> {
    const MIN: usize = 2;
    let n = sizes.len();
    if n <= 1 { return vec![rows]; }
    let r = rows as usize;
    if r < n * MIN { return vec![rows]; }
    split_axis_weighted(rows, sizes, MIN as u16)
}

/// Map a screen column to the 0-based visible pane index, given the per-pane
/// widths from `split_widths_n`. Panes are laid out left-to-right separated by a
/// 1-column `DIVIDER` (as in `compose_panes`). A column inside a pane's content
/// returns that pane; a column on the divider after pane `i` resolves to pane `i`
/// (the left pane); a column past the end clamps to the last pane. A single-entry
/// `widths` (single pane / too-narrow fallback) always returns 0.
pub fn pane_at_column(col: u16, widths: &[u16]) -> usize {
    let last = widths.len().saturating_sub(1);
    let mut x = 0usize;
    let col = col as usize;
    for (i, &w) in widths.iter().enumerate() {
        let content_end = x + w as usize; // exclusive
        if col < content_end {
            return i;
        }
        if i == last {
            return last; // past the last pane's content → clamp
        }
        if col == content_end {
            return i; // divider column belongs to the pane on its left
        }
        x = content_end + DIVIDER;
    }
    last
}

fn divider_cell() -> Cell {
    Cell::Char {
        ch: '\u{2502}', // │
        width: 1,
        style: crate::ansi::Style { dim: true, ..Default::default() },
        hyperlink: None,
    }
}

/// `--dim` is a per-row style, but a merged row spans two panes that may differ.
/// Flatten a pane's row-level dim into its cells so the merged row can carry it.
fn flatten_dim(cells: &mut [Cell]) {
    for c in cells.iter_mut() {
        if let Cell::Char { style, .. } = c {
            style.dim = true;
        }
    }
}

/// Fit a pane's status to `w` display columns, prefixing the focused pane's with
/// a `*` marker. Width-aware (so `×`/`»` glyphs count as 1).
fn fit_pane_status(s: &str, w: usize, focused: bool) -> String {
    use unicode_width::UnicodeWidthChar;
    let marked = if focused { format!("*{s}") } else { s.to_string() };
    let mut out = String::with_capacity(w);
    let mut width = 0usize;
    for ch in marked.chars() {
        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
        if width + cw > w {
            break;
        }
        out.push(ch);
        width += cw;
    }
    for _ in width..w {
        out.push(' ');
    }
    out
}

/// Stitch N pre-rendered column frames left-to-right with dividers between them.
/// `widths[i]` is column i's content width; `focused_idx` marks which pane's
/// status carries the `*`. Per-row cells concatenated (resized to width), each
/// frame's highlight ranges shifted by its running column origin, row-level dim
/// flattened into cells, statuses fit-to-width + joined by the divider char.
/// Pure; cell-mode only (raw_rows/image_blob = none).
pub fn compose_panes(frames: &[Frame], widths: &[u16], cols: u16, focused_idx: usize) -> Frame {
    let body_rows = frames.iter().map(|f| f.body.len()).max().unwrap_or(0);
    let mut body = Vec::with_capacity(body_rows);
    let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
    let mut row_styles = Vec::with_capacity(body_rows);
    let empty: Vec<Cell> = Vec::new();
    for r in 0..body_rows {
        let mut row = Vec::with_capacity(cols as usize);
        let mut hl: Vec<std::ops::Range<usize>> = Vec::new();
        let mut origin = 0usize;
        for (i, f) in frames.iter().enumerate() {
            let w = widths[i] as usize;
            let mut cells = f.body.get(r).cloned().unwrap_or_else(|| empty.clone());
            cells.resize(w, Cell::Empty);
            if f.row_styles.get(r) == Some(&RowStyle::Dim) {
                flatten_dim(&mut cells);
            }
            row.extend(cells);
            if let Some(ranges) = f.highlights.get(r) {
                hl.extend(ranges.iter().map(|x| (x.start + origin)..(x.end + origin)));
            }
            origin += w;
            if i + 1 < frames.len() {
                row.push(divider_cell());
                origin += DIVIDER;
            }
        }
        body.push(row);
        highlights.push(hl);
        row_styles.push(RowStyle::Normal);
    }
    let status: String = frames
        .iter()
        .enumerate()
        .map(|(i, f)| fit_pane_status(&f.status, widths[i] as usize, i == focused_idx))
        .collect::<Vec<_>>()
        .join("\u{2502}");
    Frame {
        body,
        row_styles,
        highlights,
        status,
        status_style: frames.get(focused_idx).map(|f| f.status_style).unwrap_or_default(),
        raw_rows: vec![None; body_rows],
        image_blob: None,
    }
}

/// Row heights for `n` stacked panes at `rows` rows. Each pane is `body + 1`
/// status row; the per-pane status rows ARE the separators, so there is NO
/// divider row and the heights sum to exactly `rows`. MIN = 2 (1 body + 1
/// status). Remainder rows go to the bottom panes. Returns `[rows]` (the
/// too-short fallback) when each pane would fall below MIN.
pub fn split_heights_n(rows: u16, n: usize) -> Vec<u16> {
    const MIN: usize = 2;
    if n <= 1 {
        return vec![rows];
    }
    let r = rows as usize;
    if r < n * MIN {
        return vec![rows];
    }
    let base = r / n;
    let rem = r % n;
    (0..n).map(|i| (base + if i >= n - rem { 1 } else { 0 }) as u16).collect()
}

/// Map a screen row to the 0-based stacked-pane index, given `split_heights_n`
/// heights (no divider rows). Past the end → the last pane; single entry → 0.
pub fn pane_at_row(row: u16, heights: &[u16]) -> usize {
    let last = heights.len().saturating_sub(1);
    let mut y = 0usize;
    let row = row as usize;
    for (i, &h) in heights.iter().enumerate() {
        let end = y + h as usize;
        if row < end {
            return i;
        }
        y = end;
    }
    last
}

/// Stitch N pre-rendered pane frames top-to-bottom. Pane `i` was rendered at
/// `(cols, heights[i])` → `heights[i]-1` body rows + a status. Panes 0..n-1
/// contribute body rows AND their status row to the composite body (status as a
/// separator line); the bottom pane's status is lifted to `frame.status` (the
/// screen's bottom line). Composite body length = `rows-1`. The focused pane's
/// status carries `*`. Cell-mode only.
pub fn compose_panes_horizontal(frames: &[Frame], heights: &[u16], rows: u16, focused_idx: usize) -> Frame {
    let n = frames.len();
    // Pane render width: the body-row width, but never narrower than the widest
    // pane's own (marked) status so a status row is never truncated below its
    // content. In real use bodies are full screen width and this is a no-op.
    let body_w = frames.first().and_then(|f| f.body.first().map(|r| r.len())).unwrap_or(0);
    let status_w = frames.iter().map(|f| f.status.chars().count() + 1).max().unwrap_or(0);
    let cols_hint = body_w.max(status_w);
    let mut body: Vec<Vec<Cell>> = Vec::new();
    let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::new();
    let mut row_styles: Vec<RowStyle> = Vec::new();
    let mut bottom_status = String::new();
    let _ = heights; // heights drive the per-pane render upstream; not needed to stack here

    for (i, f) in frames.iter().enumerate() {
        for (r, cells) in f.body.iter().enumerate() {
            let mut cells = cells.clone();
            if f.row_styles.get(r) == Some(&RowStyle::Dim) {
                flatten_dim(&mut cells);
            }
            body.push(cells);
            highlights.push(f.highlights.get(r).cloned().unwrap_or_default());
            row_styles.push(RowStyle::Normal);
        }
        if i + 1 < n {
            // Non-bottom pane: its status becomes a separator row in the body.
            let text = fit_pane_status(&f.status, cols_hint, i == focused_idx);
            let cells: Vec<Cell> = text.chars()
                .map(|ch| Cell::Char { ch, width: 1, style: crate::ansi::Style { dim: true, ..Default::default() }, hyperlink: None })
                .collect();
            body.push(cells);
            highlights.push(Vec::new());
            row_styles.push(RowStyle::Normal);
        } else {
            // Bottom pane: its status is the screen's status line.
            bottom_status = fit_pane_status(&f.status, cols_hint, i == focused_idx);
        }
    }

    Frame {
        body,
        row_styles,
        highlights,
        status: bottom_status,
        status_style: frames.get(focused_idx).map(|f| f.status_style).unwrap_or_default(),
        raw_rows: vec![None; rows.saturating_sub(1) as usize],
        image_blob: None,
    }
}

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

    fn cell(ch: char) -> Cell {
        Cell::Char { ch, width: 1, style: Style::default(), hyperlink: None }
    }
    fn frame(rows: Vec<Vec<Cell>>, status: &str) -> Frame {
        let n = rows.len();
        Frame {
            body: rows,
            row_styles: vec![RowStyle::Normal; n],
            highlights: vec![Vec::new(); n],
            status: status.to_string(),
            status_style: Style::default(),
            raw_rows: vec![None; n],
            image_blob: None,
        }
    }

    #[test]
    fn split_widths_even_odd_and_too_small() {
        assert_eq!(split_widths(33), (16, 16));
        assert_eq!(split_widths(34), (16, 17));
        assert_eq!(split_widths(10), (10, 0));
    }

    #[test]
    fn compose_panes_flattens_dim_row_into_cells() {
        let mut a = frame(vec![vec![cell('a')]], "A");
        a.row_styles[0] = RowStyle::Dim;
        let b = frame(vec![vec![cell('b')]], "B");
        let out = compose_panes(&[a, b], &[1u16, 1], 3, 0);
        match &out.body[0][0] {
            Cell::Char { style, .. } => assert!(style.dim, "dim flattened into the cell"),
            _ => panic!("expected a Char cell"),
        }
        assert_eq!(out.row_styles[0], RowStyle::Normal, "merged row style is Normal");
    }

    #[test]
    fn compose_panes_status_marks_focused_and_truncates() {
        // focused pane (index 0) gets `*` and its status fit-truncates to width 4:
        // "*LongStatus" → "*Lon"; divider at column 4.
        let a = frame(vec![vec![cell('a')]], "LongStatus");
        let b = frame(vec![vec![cell('b')]], "B");
        let out = compose_panes(&[a, b], &[4u16, 4], 9, 0);
        assert!(out.status.starts_with("*Lon"), "focused status marked + truncated: {:?}", out.status);
        assert_eq!(out.status.find('\u{2502}'), Some(4), "left status occupies exactly 4 cols");
    }

    #[test]
    fn split_widths_n_even() {
        // 3 panes, 2 dividers: usable = 32, base 10, remainder 2 → last two get +1
        // (rightmost-first distribution, matching split_widths's right-gets-extra convention).
        assert_eq!(super::split_widths_n(34, 3), vec![10, 11, 11]);
    }
    #[test]
    fn split_widths_n_two_matches_legacy() {
        let (l, r) = super::split_widths(34);
        assert_eq!(super::split_widths_n(34, 2), vec![l, r]);
    }
    #[test]
    fn split_widths_n_too_narrow_falls_back_to_one() {
        // 3 panes need >= 3*8 + 2 = 26 cols; at 20 fall back to a single full-width entry.
        assert_eq!(super::split_widths_n(20, 3), vec![20]);
    }
    #[test]
    fn split_widths_n_one_is_full_width() {
        assert_eq!(super::split_widths_n(80, 1), vec![80]);
    }

    #[test]
    fn compose_panes_stitches_n_with_dividers() {
        use crate::render::Cell;
        use crate::viewport::{Frame, RowStyle};
        let mk = |ch: char, w: usize| Frame {
            body: vec![vec![Cell::Char { ch, width: 1, style: Default::default(), hyperlink: None }; w]],
            row_styles: vec![RowStyle::Normal], highlights: vec![vec![]],
            status: format!("{ch}"), status_style: Default::default(),
            raw_rows: vec![None], image_blob: None,
        };
        let frames = vec![mk('a', 3), mk('b', 3), mk('c', 3)];
        let widths = vec![3u16, 3, 3];
        let cols = 3 + 1 + 3 + 1 + 3; // 11
        let out = super::compose_panes(&frames, &widths, cols as u16, 0);
        assert!(matches!(out.body[0][3], Cell::Char { ch: '\u{2502}', .. }));
        assert!(matches!(out.body[0][7], Cell::Char { ch: '\u{2502}', .. }));
        assert!(matches!(out.body[0][0], Cell::Char { ch: 'a', .. }));
        assert!(matches!(out.body[0][4], Cell::Char { ch: 'b', .. }));
        assert!(matches!(out.body[0][8], Cell::Char { ch: 'c', .. }));
    }

    #[test]
    fn locked_pane_top_derives_from_offsets() {
        // offsets relative to pane 0: [0, 240, 100]. Focused = pane 1 (offset 240) at top 345.
        // pane 0 target = 345 + (0 - 240) = 105; pane 2 = 345 + (100 - 240) = 205.
        assert_eq!(super::locked_pane_top(345, 240, 0, 1_000_000), 105);
        assert_eq!(super::locked_pane_top(345, 240, 100, 1_000_000), 205);
    }
    #[test]
    fn locked_pane_top_clamps_and_restores() {
        assert_eq!(super::locked_pane_top(10, 240, 0, 1_000_000), 0);     // would be negative → 0
        assert_eq!(super::locked_pane_top(300, 240, 0, 1_000_000), 60);   // restores after clamp (no drift)
        assert_eq!(super::locked_pane_top(10_000, 0, 240, 5_100), 5_100); // high clamp
    }
    #[test]
    fn locked_pane_top_tab_invariant() {
        // tops 100/340/200 → offsets 0/240/100. Deriving pane 2 from focused=pane0 vs focused=pane1 agree.
        assert_eq!(super::locked_pane_top(100, 0, 100, 1_000_000), 200);
        assert_eq!(super::locked_pane_top(340, 240, 100, 1_000_000), 200);
    }

    #[test]
    fn pane_at_column_single_pane_is_always_zero() {
        let w = vec![80u16];
        assert_eq!(pane_at_column(0, &w), 0);
        assert_eq!(pane_at_column(79, &w), 0);
        assert_eq!(pane_at_column(500, &w), 0);
    }

    #[test]
    fn pane_at_column_two_panes_with_divider() {
        // split_widths_n(80, 2) == [39, 40]: pane0 cols 0..=38, divider at 39,
        // pane1 cols 40..=79. The divider resolves to the left pane.
        let w = split_widths_n(80, 2);
        assert_eq!(w, vec![39, 40]);
        assert_eq!(pane_at_column(0, &w), 0);
        assert_eq!(pane_at_column(38, &w), 0);
        assert_eq!(pane_at_column(39, &w), 0); // divider → left pane
        assert_eq!(pane_at_column(40, &w), 1);
        assert_eq!(pane_at_column(79, &w), 1);
        assert_eq!(pane_at_column(80, &w), 1); // past end → last pane
        assert_eq!(pane_at_column(999, &w), 1);
    }

    #[test]
    fn pane_at_column_three_panes() {
        // split_widths_n(80, 3) == [26,26,26]: p0 0..=25, div 26, p1 27..=52,
        // div 53, p2 54..=79.
        let w = split_widths_n(80, 3);
        assert_eq!(w, vec![26, 26, 26]);
        assert_eq!(pane_at_column(0, &w), 0);
        assert_eq!(pane_at_column(25, &w), 0);
        assert_eq!(pane_at_column(26, &w), 0); // first divider → left pane (0)
        assert_eq!(pane_at_column(27, &w), 1);
        assert_eq!(pane_at_column(52, &w), 1);
        assert_eq!(pane_at_column(53, &w), 1); // second divider → left pane (1)
        assert_eq!(pane_at_column(54, &w), 2);
        assert_eq!(pane_at_column(79, &w), 2);
        assert_eq!(pane_at_column(200, &w), 2); // past end → last pane
    }

    #[test]
    fn compose_panes_offsets_right_highlights() {
        use crate::render::Cell;
        use crate::viewport::{Frame, RowStyle};
        let mk = |w: usize, hl: Vec<std::ops::Range<usize>>| Frame {
            body: vec![vec![Cell::Char { ch: 'x', width: 1, style: Default::default(), hyperlink: None }; w]],
            row_styles: vec![RowStyle::Normal], highlights: vec![hl],
            status: String::new(), status_style: Default::default(),
            raw_rows: vec![None], image_blob: None,
        };
        let frames = vec![mk(3, vec![0..1]), mk(3, vec![1..2])];
        let out = super::compose_panes(&frames, &vec![3u16, 3], 7, 0);
        assert!(out.highlights[0].contains(&(0..1)));
        assert!(out.highlights[0].contains(&(5..6))); // right pane offset by 3 + DIVIDER(1) = 4
    }

    #[test]
    fn split_heights_n_even_odd_and_too_short() {
        assert_eq!(split_heights_n(24, 1), vec![24]);
        assert_eq!(split_heights_n(24, 2), vec![12, 12]);
        assert_eq!(split_heights_n(25, 2), vec![12, 13]); // remainder to the bottom
        assert_eq!(split_heights_n(24, 3), vec![8, 8, 8]);
        assert_eq!(split_heights_n(3, 2), vec![3]);        // too short (<2*2) → fallback
        assert_eq!(split_heights_n(4, 2), vec![2, 2]);     // exact MIN fit
    }

    #[test]
    fn pane_at_row_bands() {
        let h = split_heights_n(24, 3); // [8,8,8]
        assert_eq!(pane_at_row(0, &h), 0);
        assert_eq!(pane_at_row(7, &h), 0);
        assert_eq!(pane_at_row(8, &h), 1);
        assert_eq!(pane_at_row(15, &h), 1);
        assert_eq!(pane_at_row(16, &h), 2);
        assert_eq!(pane_at_row(99, &h), 2);   // past end → last
        assert_eq!(pane_at_row(5, &[24]), 0); // single pane
    }

    #[test]
    fn compose_panes_horizontal_stacks_and_lifts_bottom_status() {
        let a = frame(vec![vec![cell('a')]], "A");
        let b = frame(vec![vec![cell('b')]], "B");
        let out = compose_panes_horizontal(&[a, b], &[2, 2], 4, 0);
        assert_eq!(out.body.len(), 3); // rows-1
        assert!(out.status.contains('B')); // bottom pane status lifted
        let mid: String = out.body[1].iter().filter_map(|c| match c {
            Cell::Char { ch, .. } => Some(*ch), _ => None }).collect();
        assert!(mid.contains('*') && mid.contains('A'), "focused top pane status row: {mid}");
    }

    #[test]
    fn split_axis_weighted_all_auto_is_even() {
        // All-None must match the even distribution split_widths_n produces.
        let none3 = [None, None, None];
        assert_eq!(split_axis_weighted(33, &none3, 8), vec![11, 11, 11]);
        let none2 = [None, None];
        assert_eq!(split_axis_weighted(33, &none2, 8), vec![16, 17]); // remainder to the right
    }

    #[test]
    fn split_axis_weighted_one_explicit_rest_auto() {
        // 60% of 100 = 60; remaining 40 split equally between two autos = 20,20.
        assert_eq!(split_axis_weighted(100, &[Some(60), None, None], 8), vec![60, 20, 20]);
    }

    #[test]
    fn split_axis_weighted_explicit_sum_normalized_when_over() {
        // 80 + 80 with no autos, total 100 -> scaled to fit, sum stays 100.
        let out = split_axis_weighted(100, &[Some(80), Some(80)], 8);
        assert_eq!(out.iter().sum::<u16>(), 100);
        assert!(out[0] > 8 && out[1] > 8);
    }

    #[test]
    fn split_axis_weighted_clamps_to_min() {
        // 1% of 100 would be 1, below min 8 -> clamped to 8; auto gets the rest.
        let out = split_axis_weighted(100, &[Some(1), None], 8);
        assert!(out[0] >= 8);
        assert_eq!(out.iter().sum::<u16>(), 100);
    }

    #[test]
    fn split_axis_weighted_sum_is_exact() {
        for total in [40u16, 41, 80, 100, 137] {
            let out = split_axis_weighted(total, &[Some(33), None, None], 8);
            assert_eq!(out.iter().sum::<u16>(), total, "total={total}");
        }
    }

    #[test]
    fn weighted_wrappers_match_even_split_when_all_auto() {
        // The wrappers must be byte-identical to the even fns for all-None.
        assert_eq!(split_widths_n_weighted(34, &[None, None, None]), split_widths_n(34, 3));
        assert_eq!(split_heights_n_weighted(24, &[None, None]), split_heights_n(24, 2));
    }

    /// Regression: the reported starved-explicit case must sum to exactly total
    /// and all elements must meet the minimum.
    #[test]
    fn split_axis_weighted_starved_explicit_regression() {
        let out = split_axis_weighted(24, &[Some(1), Some(50), Some(50)], 8);
        assert_eq!(out.iter().sum::<u16>(), 24, "sum must equal total: {:?}", out);
        for &v in &out {
            assert!(v >= 8, "every element must be >= min: {:?}", out);
        }
    }

    /// Property sweep: for various n, total, and size vectors the result must
    /// sum to exactly total and every element must be >= min.
    #[test]
    fn split_axis_weighted_sum_exactness_sweep() {
        const MIN: u16 = 8;

        // A representative set of size vectors per pane count n.
        // Vectors are chosen to exercise: all-None, one-big-rest-auto,
        // two-big-one-auto, all-explicit-over-100, tiny-explicit-rest-auto.
        let cases_2: &[&[Option<u16>]] = &[
            &[None, None],
            &[Some(70), None],
            &[Some(1), None],
            &[Some(50), Some(50)],
            &[Some(90), Some(90)],
            &[Some(1), Some(50)],
        ];
        let cases_3: &[&[Option<u16>]] = &[
            &[None, None, None],
            &[Some(60), None, None],
            &[Some(1), Some(50), Some(50)],
            &[Some(50), Some(50), None],
            &[Some(90), Some(90), None],
            &[Some(1), Some(5), None],
            &[Some(33), Some(33), Some(33)],
        ];
        let cases_4: &[&[Option<u16>]] = &[
            &[None, None, None, None],
            &[Some(40), None, None, None],
            &[Some(1), Some(1), None, None],
            &[Some(50), Some(50), None, None],
            &[Some(25), Some(25), Some(25), Some(25)],
            &[Some(1), Some(50), Some(50), None],
        ];

        let all_cases: &[(usize, &[&[Option<u16>]])] = &[
            (2, cases_2),
            (3, cases_3),
            (4, cases_4),
        ];

        for &(n, cases) in all_cases {
            let min_total = (n as u16) * MIN;
            for total in min_total..=120 {
                for sizes in cases.iter() {
                    assert_eq!(sizes.len(), n, "test case length mismatch");
                    let out = split_axis_weighted(total, sizes, MIN);
                    let sum: u16 = out.iter().sum();
                    assert_eq!(
                        sum, total,
                        "n={n} total={total} sizes={sizes:?} → {out:?} sums to {sum}, want {total}"
                    );
                    for &v in &out {
                        assert!(
                            v >= MIN,
                            "n={n} total={total} sizes={sizes:?} → {out:?}: element {v} < min {MIN}"
                        );
                    }
                }
            }
        }
    }

    #[test]
    fn weighted_width_one_big_pane() {
        // cols 100, 1 divider per gap (2 dividers for 3 panes) -> usable 98.
        let out = split_widths_n_weighted(100, &[Some(60), None, None]);
        assert_eq!(out.len(), 3);
        assert_eq!(out.iter().sum::<u16>() as usize, 100 - 2); // usable = cols - dividers
        assert!(out[0] > out[1] && out[0] > out[2]);
    }
}
