//! Top-level render dispatch — composes header, page views, modals, and effects each frame.

use std::collections::HashSet;
use std::time::Duration;

use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table, Wrap};
use ratatui::Frame;

use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::{ExecutionTrace, SourceSpan};
use crate::page::Page;
use crate::state::{
    EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, RenameTarget,
    SidePanel,
};
use crate::theme;
use crate::views::highlight::{self, highlight_line_with_runtime};
use crate::widgets::{
    hint_line, render_props_form, render_search_bar, ConfirmModal, ModalFrame, NavMinimap, NavTile,
    SampleBrowser, TextInputModal,
};

use super::{
    dict_view, engine_view, help_view, main_view, options_view, patterns_view, script_view,
    title_view,
};

fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<SourceSpan> {
    let ls = line_start as u32;
    let ll = line_len as u32;
    if span.end <= ls || span.start >= ls + ll {
        return None;
    }
    Some(SourceSpan {
        start: span.start.max(ls) - ls,
        end: span.end.min(ls + ll) - ls,
    })
}

pub fn adjust_spans_for_line(
    spans: &[SourceSpan],
    line_start: usize,
    line_len: usize,
) -> Vec<SourceSpan> {
    spans
        .iter()
        .filter_map(|s| clip_span(*s, line_start, line_len))
        .collect()
}

pub fn adjust_resolved_for_line(
    resolved: &[(SourceSpan, String)],
    line_start: usize,
    line_len: usize,
) -> Vec<(SourceSpan, String)> {
    resolved
        .iter()
        .filter_map(|(s, display)| {
            clip_span(*s, line_start, line_len).map(|cs| (cs, display.clone()))
        })
        .collect()
}

pub fn highlight_script_lines(
    script: &str,
    trace: Option<&ExecutionTrace>,
    user_words: &HashSet<String>,
    max_lines: usize,
) -> Vec<Line<'static>> {
    let resolved_display: Vec<(SourceSpan, String)> = trace
        .map(|t| {
            t.resolved
                .iter()
                .map(|(s, v)| (*s, v.display()))
                .collect()
        })
        .unwrap_or_default();

    let mut line_start = 0usize;
    script
        .lines()
        .take(max_lines)
        .map(|line_str| {
            let tokens = if let Some(t) = trace {
                let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
                let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
                let res = adjust_resolved_for_line(&resolved_display, line_start, line_str.len());
                highlight_line_with_runtime(line_str, &exec, &sel, &res, user_words)
            } else {
                highlight_line_with_runtime(line_str, &[], &[], &[], user_words)
            };
            line_start += line_str.len() + 1;
            let spans: Vec<Span> = tokens
                .into_iter()
                .map(|(style, text, _)| Span::styled(text, style))
                .collect();
            Line::from(spans)
        })
        .collect()
}

pub fn horizontal_padding(width: u16) -> u16 {
    if width >= 120 {
        4
    } else if width >= 80 {
        2
    } else {
        1
    }
}

pub fn render(
    frame: &mut Frame,
    app: &App,
    link: &LinkState,
    snapshot: &SequencerSnapshot,
    elapsed: Duration,
) {
    let term = frame.area();

    let theme = theme::get();
    let bg_color = theme.ui.bg;

    frame.render_widget(Clear, term);
    frame.render_widget(Block::new().style(Style::default().bg(bg_color)), term);

    if app.ui.show_title {
        title_view::render(frame, term, &app.ui, app.plugin_mode);

        let mut fx = app.ui.title_fx.borrow_mut();
        if let Some(effect) = fx.as_mut() {
            effect.process(elapsed, frame.buffer_mut(), term);
            if !effect.running() {
                *fx = None;
            }
        }
        return;
    }

    let h_pad = horizontal_padding(term.width);
    let padded = Rect {
        x: term.x + h_pad,
        y: term.y + 1,
        width: term.width.saturating_sub(h_pad * 2),
        height: term.height.saturating_sub(2),
    };

    let perf = app.ui.performance_mode;

    let [header_area, _padding, body_area, _bottom_padding, footer_area] = Layout::vertical([
        Constraint::Length(if perf { 0 } else { header_height(padded.width) }),
        Constraint::Length(if perf { 0 } else { 1 }),
        Constraint::Fill(1),
        Constraint::Length(if perf { 0 } else { 1 }),
        Constraint::Length(if perf { 0 } else { 3 }),
    ])
    .areas(padded);

    if !perf {
        render_header(frame, app, link, snapshot, header_area);
    }

    let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
        if body_area.width >= 120 {
            let panel_width = body_area.width * 35 / 100;
            let [main, side] =
                Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
                    .areas(body_area);
            (main, Some(side))
        } else {
            let panel_height = body_area.height * 40 / 100;
            let [main, side] =
                Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
                    .areas(body_area);
            (main, Some(side))
        }
    } else {
        (body_area, None)
    };

    match app.page {
        Page::Main => main_view::render(frame, app, snapshot, page_area),
        Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
        Page::Engine => engine_view::render(frame, app, page_area),
        Page::Options => options_view::render(frame, app, link, page_area),
        Page::Help => help_view::render(frame, app, page_area),
        Page::Dict => dict_view::render(frame, app, page_area),
        Page::Script => script_view::render(frame, app, snapshot, page_area),
    }

    if let Some(side_area) = panel_area {
        render_side_panel(frame, app, side_area);
    }

    if !perf {
        render_footer(frame, app, footer_area);
    }
    let modal_area = render_modal(frame, app, snapshot, term);

    if app.ui.nav_indicator_visible() {
        let nav_area = render_nav_indicator(frame, app, term);
        let mut fx = app.ui.nav_fx.borrow_mut();
        if let Some(effect) = fx.as_mut() {
            effect.process(elapsed, frame.buffer_mut(), nav_area);
            if !effect.running() {
                *fx = None;
            }
        }
    }

    if app.ui.show_minimap() {
        let tiles: Vec<NavTile> = Page::ALL
            .iter()
            .filter(|p| p.visible_in_minimap())
            .map(|p| {
                let (col, row) = p.grid_pos();
                NavTile {
                    col,
                    row,
                    name: p.name(),
                }
            })
            .collect();
        let selected = app.page.grid_pos();
        NavMinimap::new(&tiles, selected).render_centered(frame, term);
    }

    app.ui
        .effects
        .borrow_mut()
        .process_effects(elapsed, frame.buffer_mut(), term);

    if let Some(area) = modal_area {
        let mut fx = app.ui.modal_fx.borrow_mut();
        if let Some(effect) = fx.as_mut() {
            effect.process(elapsed, frame.buffer_mut(), area);
            if !effect.running() {
                *fx = None;
            }
        }
    }
}

fn render_nav_indicator(frame: &mut Frame, app: &App, term: Rect) -> Rect {
    let theme = theme::get();
    let bank = &app.project_state.project.banks[app.editor_ctx.bank];
    let pattern = &bank.patterns[app.editor_ctx.pattern];

    let bank_num = format!("{:02}", app.editor_ctx.bank + 1);
    let pattern_num = format!("{:02}", app.editor_ctx.pattern + 1);
    let bank_name = bank.name.as_deref().unwrap_or("");
    let pattern_name = pattern.name.as_deref().unwrap_or("");

    let inner = ModalFrame::new("")
        .width(34)
        .height(5)
        .border_color(theme.modal.border_accent)
        .render_centered(frame, term);

    let bank_style = Style::new().fg(theme.header.bank_fg).bold();
    let pattern_style = Style::new().fg(theme.header.pattern_fg).bold();
    let dim = Style::new().fg(theme.ui.text_dim);
    let divider = Style::new().fg(theme.ui.border);

    let line1 = Line::from(vec![
        Span::styled(" BANK          ", bank_style),
        Span::styled("│", divider),
        Span::styled(" PATTERN          ", pattern_style),
    ]);
    let line2 = Line::from(vec![
        Span::styled(format!(" {bank_num}  "), bank_style),
        Span::styled(format!("{bank_name:<10}"), dim),
        Span::styled("│", divider),
        Span::styled(format!(" {pattern_num}  "), pattern_style),
        Span::styled(format!("{pattern_name:<11}"), dim),
    ]);

    frame.render_widget(
        Paragraph::new(line1),
        Rect::new(inner.x, inner.y, inner.width, 1),
    );
    frame.render_widget(
        Paragraph::new(line2),
        Rect::new(inner.x, inner.y + 1, inner.width, 1),
    );

    Rect::new(
        inner.x.saturating_sub(1),
        inner.y.saturating_sub(1),
        inner.width + 2,
        inner.height + 2,
    )
}

fn header_height(_width: u16) -> u16 {
    3
}

fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
    let focused = app.panel.focus == PanelFocus::Side;
    match &app.panel.side {
        Some(SidePanel::SampleBrowser(state)) => {
            let [tree_area, preview_area] =
                Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(area);

            let entries = state.entries();
            SampleBrowser::new(&entries, state.cursor)
                .scroll_offset(state.scroll_offset)
                .search(&state.search_query, state.search_active)
                .focused(focused)
                .render(frame, tree_area);

            if let Some(sample) = state
                .sample_key()
                .and_then(|key| app.audio.sample_registry.as_ref()?.get(&key))
                .filter(|s| s.frame_count >= s.total_frames)
            {
                use crate::widgets::Waveform;
                use std::cell::RefCell;
                thread_local! {
                    static MONO_BUF: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
                }

                let [wave_area, info_area] =
                    Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
                        .areas(preview_area);

                MONO_BUF.with(|buf| {
                    let mut buf = buf.borrow_mut();
                    let channels = sample.channels as usize;
                    let frame_count = sample.frame_count as usize;
                    buf.clear();
                    buf.reserve(frame_count);
                    for i in 0..frame_count {
                        buf.push(sample.frames[i * channels]);
                    }
                    frame.render_widget(Waveform::new(&buf), wave_area);
                });

                let duration = sample.total_frames as f32 / app.audio.config.sample_rate;
                let ch_label = if sample.channels == 1 {
                    "mono"
                } else {
                    "stereo"
                };
                let info = Paragraph::new(format!(" {duration:.1}s · {ch_label}"))
                    .style(Style::new().fg(theme::get().ui.text_dim));
                frame.render_widget(info, info_area);
            }
        }
        None => {}
    }
}

fn render_header(
    frame: &mut Frame,
    app: &App,
    link: &LinkState,
    snapshot: &SequencerSnapshot,
    area: Rect,
) {
    use crate::model::PatternSpeed;

    let theme = theme::get();
    let bank = &app.project_state.project.banks[app.editor_ctx.bank];
    let pattern = &bank.patterns[app.editor_ctx.pattern];

    let pad = Padding::vertical(1);

    let [logo_area, transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] =
        Layout::horizontal([
            Constraint::Length(5),
            Constraint::Min(12),
            Constraint::Length(9),
            Constraint::Min(14),
            Constraint::Fill(1),
            Constraint::Fill(2),
            Constraint::Min(20),
        ])
        .areas(area);

    // Logo
    let logo_style = Style::new()
        .bg(theme.header.bank_bg)
        .fg(theme.ui.accent)
        .add_modifier(Modifier::BOLD);
    frame.render_widget(
        Paragraph::new("\u{28ff}")
            .block(Block::default().padding(pad).style(logo_style))
            .alignment(Alignment::Center),
        logo_area,
    );

    // Transport block
    let (transport_bg, transport_text) = if app.playback.playing {
        (theme.status.playing_bg, " ▶ PLAYING ")
    } else {
        (theme.status.stopped_bg, " ■ STOPPED ")
    };
    let transport_style = Style::new().bg(transport_bg).fg(theme.ui.text_primary);
    frame.render_widget(
        Paragraph::new(transport_text)
            .block(Block::default().padding(pad).style(transport_style))
            .alignment(Alignment::Center),
        transport_area,
    );

    // Fill indicator
    let fill = app.live_keys.fill();
    let fill_fg = if fill {
        theme.status.fill_on
    } else {
        theme.status.fill_off
    };
    let fill_style = Style::new().bg(theme.status.fill_bg).fg(fill_fg);
    frame.render_widget(
        Paragraph::new(if fill { "F" } else { "·" })
            .block(Block::default().padding(pad).style(fill_style))
            .alignment(Alignment::Center),
        live_area,
    );

    // Tempo block
    let tempo_style = Style::new()
        .bg(theme.header.tempo_bg)
        .fg(theme.ui.text_primary)
        .add_modifier(Modifier::BOLD);
    frame.render_widget(
        Paragraph::new(format!(" {:.1} BPM ", link.tempo()))
            .block(Block::default().padding(pad).style(tempo_style))
            .alignment(Alignment::Center),
        tempo_area,
    );

    // Bank block
    let bank_name = bank
        .name
        .as_deref()
        .map(|n| format!(" {n} "))
        .unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1));
    let bank_style = Style::new()
        .bg(theme.header.bank_bg)
        .fg(theme.ui.text_primary);
    frame.render_widget(
        Paragraph::new(bank_name)
            .block(Block::default().padding(pad).style(bank_style))
            .alignment(Alignment::Center),
        bank_area,
    );

    // Pattern block (name + length + speed + page + iter)
    let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1);
    let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name);
    let speed_info = if pattern.speed != PatternSpeed::NORMAL {
        format!(" · {}", pattern.speed.label())
    } else {
        String::new()
    };
    let total_pages = pattern.length.div_ceil(32);
    let page_info = if total_pages > 1 {
        let current_page = app.editor_ctx.step / 32 + 1;
        format!(" · {current_page}/{total_pages}")
    } else {
        String::new()
    };
    let iter_info = snapshot
        .get_iter(app.editor_ctx.bank, app.editor_ctx.pattern)
        .map(|iter| format!(" · #{}", iter + 1))
        .unwrap_or_default();
    let pattern_text = format!(
        " {} · {} steps{}{}{} ",
        pattern_name, pattern.length, speed_info, page_info, iter_info
    );
    let pattern_style = Style::new()
        .bg(theme.header.pattern_bg)
        .fg(theme.ui.text_primary);
    frame.render_widget(
        Paragraph::new(pattern_text)
            .block(Block::default().padding(pad).style(pattern_style))
            .alignment(Alignment::Center),
        pattern_area,
    );

    // Stats block
    let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
    let peers = link.peers();
    let voices = app.metrics.active_voices;
    let cpu_color = if cpu_pct >= 80.0 {
        theme.flash.error_fg
    } else if cpu_pct >= 50.0 {
        theme.ui.accent
    } else {
        theme.header.stats_fg
    };
    let dim = Style::new()
        .bg(theme.header.stats_bg)
        .fg(theme.header.stats_fg);
    let stats_line = Line::from(vec![
        Span::styled(format!(" CPU {cpu_pct:.0}%"), dim.fg(cpu_color)),
        Span::styled(format!("  V:{voices}  L:{peers} "), dim),
    ]);
    let block_style = Style::new().bg(theme.header.stats_bg);
    frame.render_widget(
        Paragraph::new(stats_line)
            .block(Block::default().padding(pad).style(block_style))
            .alignment(Alignment::Center),
        stats_area,
    );
}

fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    let theme = theme::get();
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::new().fg(theme.ui.border));
    let inner = block.inner(area);
    let available_width = inner.width as usize;

    let page_indicator = match app.page {
        Page::Main => " MAIN ",
        Page::Patterns => " PATTERNS ",
        Page::Engine => " ENGINE ",
        Page::Options => " OPTIONS ",
        Page::Help => " HELP ",
        Page::Dict => " DICT ",
        Page::Script => " SCRIPT ",
    };

    let content = if let Some(ref msg) = app.ui.status_message {
        Line::from(vec![
            Span::styled(
                page_indicator.to_string(),
                Style::new().bg(theme.view_badge.bg).fg(theme.view_badge.fg),
            ),
            Span::raw("  "),
            Span::styled(msg.clone(), Style::new().fg(theme.modal.confirm)),
        ])
    } else {
        let bindings: Vec<(&str, &str)> = match app.page {
            Page::Main => vec![
                ("Space", "Play"),
                ("Enter", "Edit"),
                ("t", "On/Off"),
                ("Tab", "Samples"),
                ("?", "Keys"),
            ],
            Page::Patterns => vec![
                ("Enter", "Select"),
                ("Space", "Play"),
                ("c", "Commit"),
                ("r", "Rename"),
                ("?", "Keys"),
            ],
            Page::Engine => vec![
                ("Tab", "Section"),
                ("←→", "Switch/Adjust"),
                ("A", "Add Samples"),
                ("D", "Remove"),
                ("R", "Restart"),
                ("?", "Keys"),
            ],
            Page::Options => vec![
                ("Tab", "Next"),
                ("←→", "Toggle"),
                ("Space", "Play"),
                ("?", "Keys"),
            ],
            Page::Help => match app.ui.help_focus {
                crate::state::HelpFocus::Content => vec![
                    ("n", "Next Example"),
                    ("p", "Previous Example"),
                    ("Enter", "Evaluate"),
                    ("Tab", "Topics"),
                ],
                crate::state::HelpFocus::Topics => vec![
                    ("↑↓", "Navigate"),
                    ("Tab", "Content"),
                    ("/", "Search"),
                    ("?", "Keys"),
                ],
            },
            Page::Dict => vec![
                ("Tab", "Focus"),
                ("↑↓", "Navigate"),
                ("/", "Search"),
                ("?", "Keys"),
            ],
            Page::Script => vec![
                ("Esc", "Save & Back"),
                ("C-e", "Eval"),
                ("[ ]", "Speed"),
                ("C-s", "Stack"),
                ("?", "Keys"),
            ],
        };

        let page_width = page_indicator.chars().count();
        let bindings_content_width: usize = bindings
            .iter()
            .map(|(k, a)| k.chars().count() + 1 + a.chars().count())
            .sum();

        let n = bindings.len();
        let total_content = page_width + bindings_content_width;
        let total_gaps = available_width.saturating_sub(total_content);
        let gap_count = n + 1;
        let base_gap = total_gaps / gap_count;
        let extra = total_gaps % gap_count;

        let mut spans = vec![
            Span::styled(
                page_indicator.to_string(),
                Style::new().bg(theme.view_badge.bg).fg(theme.view_badge.fg),
            ),
            Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })),
        ];

        for (i, (key, action)) in bindings.into_iter().enumerate() {
            spans.push(Span::styled(
                key.to_string(),
                Style::new().fg(theme.hint.key),
            ));
            spans.push(Span::styled(
                format!(": {action}"),
                Style::new().fg(theme.hint.text),
            ));

            if i < n - 1 {
                let gap = base_gap + if i + 1 < extra { 1 } else { 0 };
                spans.push(Span::raw(" ".repeat(gap)));
            }
        }

        Line::from(spans)
    };

    let footer = Paragraph::new(content).block(block);
    frame.render_widget(footer, area);
}

fn render_modal(
    frame: &mut Frame,
    app: &App,
    snapshot: &SequencerSnapshot,
    term: Rect,
) -> Option<Rect> {
    let theme = theme::get();
    let inner = match &app.ui.modal {
        Modal::None => return None,
        Modal::Confirm { action, selected } => {
            ConfirmModal::new("Confirm", &action.message(), *selected).render_centered(frame, term)
        }
        Modal::FileBrowser(state) => {
            use crate::state::file_browser::FileBrowserMode;
            use crate::widgets::FileBrowserModal;
            let (title, border_color) = match state.mode {
                FileBrowserMode::Save => ("Save As", theme.flash.success_fg),
                FileBrowserMode::Load => ("Load From", theme.browser.directory),
            };
            let entries: Vec<(String, bool, bool)> = state
                .entries
                .iter()
                .map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
                .collect();
            FileBrowserModal::new(title, &state.input, &entries)
                .selected(state.selected)
                .scroll_offset(state.scroll_offset)
                .border_color(border_color)
                .width(60)
                .height(18)
                .render_centered(frame, term)
        }
        Modal::Rename { target, name } => {
            let border_color = match target {
                RenameTarget::Step { .. } => theme.modal.input,
                _ => theme.modal.rename,
            };
            TextInputModal::new(&target.title(), name)
                .width(40)
                .border_color(border_color)
                .render_centered(frame, term)
        }
        Modal::SetPattern { field, input } => {
            let (title, hint) = match field {
                PatternField::Length => ("Set Length (1-1024)", "Enter number"),
                PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"),
            };
            TextInputModal::new(title, input)
                .hint(hint)
                .width(45)
                .border_color(theme.modal.confirm)
                .render_centered(frame, term)
        }
        Modal::SetScript { field, input } => {
            use crate::state::ScriptField;
            let (title, hint) = match field {
                ScriptField::Length => ("Set Script Length (1-256)", "Enter number"),
                ScriptField::Speed => ("Set Script Speed", "e.g. 1/3, 2/5, 1x, 2x"),
            };
            TextInputModal::new(title, input)
                .hint(hint)
                .width(45)
                .border_color(theme.modal.confirm)
                .render_centered(frame, term)
        }
        Modal::JumpToStep(input) => {
            let pattern_len = app.current_edit_pattern().length;
            let title = format!("Jump to Step (1-{})", pattern_len);
            TextInputModal::new(&title, input)
                .hint("Enter step number")
                .width(30)
                .border_color(theme.modal.confirm)
                .render_centered(frame, term)
        }
        Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
            .hint("Enter BPM")
            .width(30)
            .border_color(theme.modal.rename)
            .render_centered(frame, term),
        Modal::AddSamplePath(state) => {
            use crate::widgets::FileBrowserModal;
            let entries: Vec<(String, bool, bool)> = state
                .entries
                .iter()
                .map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
                .collect();
            FileBrowserModal::new("Add Sample Path", &state.input, &entries)
                .selected(state.selected)
                .scroll_offset(state.scroll_offset)
                .border_color(theme.modal.rename)
                .width(60)
                .height(18)
                .render_centered(frame, term)
        }
        Modal::Editor => {
            let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
            render_modal_editor(frame, app, snapshot, &user_words, term)
        }
        Modal::PatternProps {
            bank,
            pattern,
            field,
            name,
            description,
            length,
            speed,
            quantization,
            sync_mode,
            follow_up,
        } => {
            use crate::model::FollowUp;
            use crate::state::PatternPropsField;

            let is_chain = matches!(follow_up, FollowUp::Chain { .. });
            let modal_height = if is_chain { 18 } else { 16 };

            let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
                .width(50)
                .height(modal_height)
                .border_color(theme.modal.input)
                .render_centered(frame, term);

            let speed_label = speed.label();
            let follow_up_label = match follow_up {
                FollowUp::Loop => "Loop".to_string(),
                FollowUp::Stop => "Stop".to_string(),
                FollowUp::Chain { bank: b, pattern: p } => {
                    format!("Chain B{:02}:P{:02}", b + 1, p + 1)
                }
            };
            let mut fields: Vec<(&str, String, bool)> = vec![
                ("Name", name.clone(), *field == PatternPropsField::Name),
                ("Desc", description.clone(), *field == PatternPropsField::Description),
                ("Length", length.clone(), *field == PatternPropsField::Length),
                ("Speed", speed_label, *field == PatternPropsField::Speed),
                ("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization),
                ("Sync Mode", sync_mode.label().to_string(), *field == PatternPropsField::SyncMode),
                ("Follow Up", follow_up_label, *field == PatternPropsField::FollowUp),
            ];
            if is_chain {
                if let FollowUp::Chain { bank: b, pattern: p } = follow_up {
                    fields.push(("  Bank", format!("{:02}", b + 1), *field == PatternPropsField::ChainBank));
                    fields.push(("  Pattern", format!("{:02}", p + 1), *field == PatternPropsField::ChainPattern));
                }
            }

            let fields_ref: Vec<(&str, &str, bool)> = fields.iter().map(|(l, v, s)| (*l, v.as_str(), *s)).collect();
            render_props_form(frame, inner, &fields_ref);

            let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
            let hints = hint_line(&[
                ("↑↓", "nav"),
                ("←→", "change"),
                ("Enter", "save"),
                ("Esc", "cancel"),
            ]);
            frame.render_widget(Paragraph::new(hints), hint_area);

            inner
        }
        Modal::Onboarding { page } => {
            let pages = crate::model::onboarding::for_page(app.page);
            let page_idx = (*page).min(pages.len().saturating_sub(1));
            let (desc, keys) = pages[page_idx];
            let page_count = pages.len();
            let text_width = 51usize;
            let desc_lines = {
                let mut lines = 0u16;
                for line in desc.split('\n') {
                    let mut col = 0usize;
                    for word in line.split_whitespace() {
                        let wlen = word.len();
                        if col > 0 && col + 1 + wlen > text_width {
                            lines += 1;
                            col = wlen;
                        } else {
                            col += if col > 0 { 1 + wlen } else { wlen };
                        }
                    }
                    lines += 1;
                }
                lines
            };
            let key_lines = keys.len() as u16;
            let modal_height =
                (3 + desc_lines + 1 + key_lines + 2).min(term.height.saturating_sub(4));

            let title = if page_count > 1 {
                format!(" {} ({}/{}) ", app.page.name(), page_idx + 1, page_count)
            } else {
                format!(" {} ", app.page.name())
            };
            let inner = ModalFrame::new(&title)
                .width(57)
                .height(modal_height)
                .border_color(theme.modal.confirm)
                .render_centered(frame, term);

            let content_width = inner.width.saturating_sub(4);
            let mut y = inner.y + 1;

            let desc_area = Rect::new(inner.x + 2, y, content_width, desc_lines);
            let body = Paragraph::new(desc)
                .style(Style::new().fg(theme.ui.text_primary))
                .wrap(Wrap { trim: true });
            frame.render_widget(body, desc_area);
            y += desc_lines + 1;

            for &(key, action) in keys {
                if y >= inner.y + inner.height - 1 {
                    break;
                }
                let line = Line::from(vec![
                    Span::raw("  "),
                    Span::styled(format!("{:>8}", key), Style::new().fg(theme.hint.key)),
                    Span::styled(format!("  {action}"), Style::new().fg(theme.hint.text)),
                ]);
                frame.render_widget(
                    Paragraph::new(line),
                    Rect::new(inner.x + 1, y, inner.width.saturating_sub(2), 1),
                );
                y += 1;
            }

            let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
            let mut hints_vec: Vec<(&str, &str)> = Vec::new();
            if page_count > 1 {
                hints_vec.push(("\u{2190}\u{2192}", "page"));
            }
            if app.page.help_topic_index().is_some() {
                hints_vec.push(("?", "help"));
            }
            hints_vec.push(("Enter", "don't show again"));
            let hints = hint_line(&hints_vec);
            frame.render_widget(
                Paragraph::new(hints).alignment(Alignment::Center),
                hint_area,
            );

            inner
        }
        Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
        Modal::EuclideanDistribution {
            source_step,
            field,
            pulses,
            steps,
            rotation,
            ..
        } => {
            let inner = ModalFrame::new(&format!(
                " Euclidean Distribution (Step {:02}) ",
                source_step + 1
            ))
            .width(50)
            .height(11)
            .border_color(theme.modal.input)
            .render_centered(frame, term);

            let fields: Vec<(&str, &str, bool)> = vec![
                ("Pulses", pulses.as_str(), *field == EuclideanField::Pulses),
                ("Steps", steps.as_str(), *field == EuclideanField::Steps),
                (
                    "Rotation",
                    rotation.as_str(),
                    *field == EuclideanField::Rotation,
                ),
            ];

            render_props_form(frame, inner, &fields);

            let preview_y = inner.y + 4;
            if preview_y < inner.y + inner.height {
                let pulses_val: usize = pulses.parse().unwrap_or(0);
                let steps_val: usize = steps.parse().unwrap_or(0);
                let rotation_val: usize = rotation.parse().unwrap_or(0);
                let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val);
                let preview_line = Line::from(vec![
                    Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)),
                    Span::styled(preview, Style::default().fg(theme.modal.input)),
                ]);
                let preview_area =
                    Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1);
                frame.render_widget(Paragraph::new(preview_line), preview_area);
            }

            let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
            let hints = hint_line(&[
                ("↑↓", "nav"),
                ("←→", "adjust"),
                ("Enter", "apply"),
                ("Esc", "cancel"),
            ]);
            frame.render_widget(Paragraph::new(hints), hint_area);

            inner
        }
    };

    // Expand inner rect to include the border
    Some(Rect::new(
        inner.x.saturating_sub(1),
        inner.y.saturating_sub(1),
        inner.width + 2,
        inner.height + 2,
    ))
}

fn render_modal_editor(
    frame: &mut Frame,
    app: &App,
    snapshot: &SequencerSnapshot,
    user_words: &HashSet<String>,
    term: Rect,
) -> Rect {
    let theme = theme::get();
    let width = (term.width * 80 / 100).max(40);
    let height = (term.height * 60 / 100).max(10);

    let flash_kind = app.ui.flash_kind();
    let border_color = match flash_kind {
        Some(FlashKind::Error) => theme.flash.error_fg,
        Some(FlashKind::Info) => theme.ui.text_primary,
        Some(FlashKind::Success) => theme.flash.success_fg,
        None => theme.modal.editor,
    };

    let title = match app.editor_ctx.target {
        EditorTarget::Prelude => "Prelude".to_string(),
        EditorTarget::Step => {
            let step_num = app.editor_ctx.step + 1;
            let step = app.current_edit_pattern().step(app.editor_ctx.step);
            if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
                format!("Step {step_num:02}: {name}")
            } else {
                format!("Step {step_num:02} Script")
            }
        }
    };

    let inner = ModalFrame::new(&title)
        .width(width)
        .height(height)
        .border_color(border_color)
        .render_centered(frame, term);

    let trace = if app.ui.runtime_highlight
        && app.playback.playing
        && app.editor_ctx.target == EditorTarget::Step
    {
        let source = app
            .current_edit_pattern()
            .resolve_source(app.editor_ctx.step);
        snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
    } else {
        None
    };

    let text_lines = app.editor_ctx.editor.lines();
    let mut line_offsets: Vec<usize> = Vec::with_capacity(text_lines.len());
    let mut offset = 0;
    for line in text_lines.iter() {
        line_offsets.push(offset);
        offset += line.len() + 1;
    }

    let resolved_display: Vec<(SourceSpan, String)> = trace
        .map(|t| t.resolved.iter().map(|(s, v)| (*s, v.display())).collect())
        .unwrap_or_default();

    let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> {
        let line_start = line_offsets[row];
        let (exec, sel, res) = match trace {
            Some(t) => (
                adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
                adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
                adjust_resolved_for_line(&resolved_display, line_start, line.len()),
            ),
            None => (Vec::new(), Vec::new(), Vec::new()),
        };
        highlight::highlight_line_with_runtime(line, &exec, &sel, &res, user_words)
    };

    let show_search =
        app.editor_ctx.editor.search_active() || !app.editor_ctx.editor.search_query().is_empty();

    let reserved_lines = 1 + if show_search { 1 } else { 0 };
    let editor_height = inner.height.saturating_sub(reserved_lines);

    let mut y = inner.y;

    let search_area = if show_search {
        let area = Rect::new(inner.x, y, inner.width, 1);
        y += 1;
        Some(area)
    } else {
        None
    };

    let editor_area = Rect::new(inner.x, y, inner.width, editor_height);
    y += editor_height;

    let hint_area = Rect::new(inner.x, y, inner.width, 1);

    if let Some(sa) = search_area {
        render_search_bar(
            frame,
            sa,
            app.editor_ctx.editor.search_query(),
            app.editor_ctx.editor.search_active(),
        );
    }

    if let Some(kind) = flash_kind {
        let bg = match kind {
            FlashKind::Error => theme.flash.error_bg,
            FlashKind::Info => theme.flash.info_bg,
            FlashKind::Success => theme.flash.success_bg,
        };
        let flash_block = Block::default().style(Style::default().bg(bg));
        frame.render_widget(flash_block, editor_area);
    }
    app.editor_ctx
        .editor
        .render(frame, editor_area, &highlighter);

    if app.editor_ctx.editor.search_active() {
        let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]);
        frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
    } else if app.editor_ctx.show_stack {
        let stack_text = app
            .editor_ctx
            .stack_cache
            .borrow()
            .as_ref()
            .map(|c| c.result.clone())
            .unwrap_or_else(|| "Stack: []".to_string());
        let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]);
        let [hint_left, stack_right] = Layout::horizontal([
            Constraint::Length(hints.width() as u16),
            Constraint::Fill(1),
        ])
        .areas(hint_area);
        frame.render_widget(Paragraph::new(hints), hint_left);
        let dim = Style::default().fg(theme.hint.text);
        frame.render_widget(
            Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
            stack_right,
        );
    } else {
        let hints = hint_line(&[
            ("Esc", "save"),
            ("C-e", "eval"),
            ("C-f", "find"),
            ("C-b", "samples"),
            ("C-s", "stack"),
            ("C-u", "/"),
            ("C-r", "undo/redo"),
        ]);
        frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
    }

    inner
}

fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect {
    let theme = theme::get();
    let width = (term.width * 80 / 100).clamp(60, 100);
    let height = (term.height * 80 / 100).max(15);

    let title = format!("Keybindings — {}", app.page.name());
    let inner = ModalFrame::new(&title)
        .width(width)
        .height(height)
        .border_color(theme.modal.editor)
        .render_centered(frame, term);

    let bindings = super::keybindings::bindings_for(app.page, app.plugin_mode);
    let visible_rows = inner.height.saturating_sub(2) as usize;

    let rows: Vec<Row> = bindings
        .iter()
        .enumerate()
        .skip(scroll)
        .take(visible_rows)
        .map(|(i, (key, name, desc))| {
            let bg = if i % 2 == 0 {
                theme.table.row_even
            } else {
                theme.table.row_odd
            };
            Row::new(vec![
                Cell::from(*key).style(Style::default().fg(theme.modal.confirm)),
                Cell::from(*name).style(Style::default().fg(theme.modal.input)),
                Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)),
            ])
            .style(Style::default().bg(bg))
        })
        .collect();

    let table = Table::new(
        rows,
        [
            Constraint::Length(14),
            Constraint::Length(12),
            Constraint::Fill(1),
        ],
    )
    .column_spacing(2);

    let table_area = Rect {
        x: inner.x,
        y: inner.y,
        width: inner.width,
        height: inner.height.saturating_sub(1),
    };
    frame.render_widget(table, table_area);

    let hint_area = Rect {
        x: inner.x,
        y: inner.y + inner.height.saturating_sub(1),
        width: inner.width,
        height: 1,
    };
    let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]);
    frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);

    inner
}

fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
    if pulses == 0 || steps == 0 || pulses > steps {
        return "[invalid]".to_string();
    }

    let mut pattern = vec![false; steps];
    for i in 0..pulses {
        let pos = (i * steps) / pulses;
        pattern[pos] = true;
    }

    if rotation > 0 {
        pattern.rotate_left(rotation % steps);
    }

    let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect();
    format!("[{}]", chars.join(" "))
}
