use gix::bstr::{BStr, ByteSlice};
use ratatui::{
    Frame,
    layout::{Constraint, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Clear, Paragraph},
};

use crate::{
    app::{App, AttributionKind, CommitRow, State},
    history::{DecorationKind, Decorations},
};

pub(crate) fn draw(frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations, mailmap: &gix::mailmap::Snapshot) {
    let [body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area());
    app.viewport_rows = body.height as usize;
    app.ensure_visible();
    let start = app.offset.min(app.rows.len());
    let end = start.saturating_add(app.viewport_rows).min(app.rows.len());
    let visible_rows = &app.rows[start..end];
    let content = Rect::new(
        body.x.saturating_add(2),
        body.y,
        body.width.saturating_sub(2),
        body.height,
    );
    let max_lane_width = visible_rows
        .iter()
        .map(|row| row.lane.trim_end().chars().count().saturating_add(1))
        .max()
        .unwrap_or_default();
    let pane_limit = ((body.width as usize) / 3)
        .saturating_sub(2)
        .max(1)
        .min(content.width as usize);
    let pane_width = max_lane_width.min(pane_limit);
    let graph_is_wide = max_lane_width > pane_limit;
    let pin_metadata = app.pin_metadata.unwrap_or(graph_is_wide);
    let show_committer_date = app.show_committer_date;
    let show_author_name = app.show_author_name;
    let show_trailers = app.show_trailers;
    let show_special_refs = app.show_special_refs;
    let selected = app.selected;
    let metadata: Vec<_> = visible_rows
        .iter()
        .enumerate()
        .map(|(index, row)| {
            metadata_line(
                row,
                app.title(row),
                decorations,
                mailmap,
                MetadataOptions {
                    show_committer_date,
                    show_author_name,
                    show_trailers,
                    use_mailmap: app.use_mailmap,
                    show_special_refs,
                    selected: selected == Some(start + index),
                },
            )
        })
        .collect();
    let max_offset = if pin_metadata {
        max_lane_width.saturating_sub(pane_width)
    } else {
        visible_rows
            .iter()
            .zip(&metadata)
            .map(|(row, metadata)| row.lane.chars().count().saturating_add(metadata.width()))
            .max()
            .unwrap_or_default()
            .saturating_sub(content.width as usize)
    }
    .min(u16::MAX as usize);
    let horizontal_offset = app.horizontal_offset.min(max_offset) as u16;

    let visible_rows = &app.rows[start..end];
    for (index, (row, metadata)) in visible_rows.iter().zip(metadata).enumerate() {
        let y = body.y.saturating_add(index as u16);
        let selected = app.selected == Some(start + index);
        let style = if selected {
            Style::default().add_modifier(Modifier::REVERSED)
        } else {
            Style::default()
        };
        frame.render_widget(
            Paragraph::new(if selected { "> " } else { "  " }).style(style),
            Rect::new(body.x, y, body.width.min(2), 1),
        );

        let row_area = Rect::new(content.x, y, content.width, 1);
        if pin_metadata {
            frame.render_widget(
                Paragraph::new(row.lane.as_str())
                    .style(style)
                    .scroll((0, horizontal_offset)),
                row_area,
            );
            color_graph(frame, row_area, &row.lane, horizontal_offset as usize, selected);
            let pane = Rect::new(
                content.x.saturating_add(pane_width as u16),
                y,
                content.width.saturating_sub(pane_width as u16),
                1,
            );
            frame.render_widget(Clear, pane);
            frame.render_widget(Paragraph::new(metadata), pane);
        } else {
            let mut spans = Vec::with_capacity(metadata.spans.len() + 1);
            spans.push(Span::styled(row.lane.as_str(), style));
            spans.extend(metadata.spans);
            frame.render_widget(
                Paragraph::new(Line::from(spans)).scroll((0, horizontal_offset)),
                row_area,
            );
            color_graph(frame, row_area, &row.lane, horizontal_offset as usize, selected);
        }
    }
    app.set_horizontal_bounds(content.width as usize, max_offset);

    let status = match app.state {
        State::Loading => "loading",
        State::Cancelling => "cancelling",
        State::Complete => "complete",
        State::Cancelled => "cancelled",
    };
    let mut footer_spans = vec![Span::raw(format!(
        "{} commits · {status} · ↑↓/jk move · h/l pan · [ pane · ] natural",
        app.rows.len()
    ))];
    if app.has_hidden_filter {
        footer_spans.extend([
            Span::raw(" · "),
            toggle(
                if app.show_hidden {
                    "v hide hidden"
                } else {
                    "v show hidden"
                },
                app.show_hidden,
            ),
        ]);
    }
    for (label, enabled) in [
        ("d date", app.show_committer_date),
        ("n name", app.show_author_name),
        ("m mailmap", app.use_mailmap),
        ("t trailers", app.show_trailers),
        ("r refs", app.show_special_refs),
    ] {
        footer_spans.extend([Span::raw(" · "), toggle(label, enabled)]);
    }
    footer_spans.extend([Span::raw(" · y copy")]);
    if app.state == State::Loading {
        footer_spans.push(Span::raw(" · Esc cancel"));
    }
    footer_spans.push(Span::raw(" · q quit"));
    frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer);
}

fn toggle(label: &'static str, enabled: bool) -> Span<'static> {
    Span::styled(
        label,
        if enabled {
            Style::default()
        } else {
            Style::default().add_modifier(Modifier::DIM)
        },
    )
}

#[derive(Clone, Copy)]
struct MetadataOptions {
    show_committer_date: bool,
    show_author_name: bool,
    show_trailers: bool,
    use_mailmap: bool,
    show_special_refs: bool,
    selected: bool,
}

fn metadata_line<'a>(
    row: &'a CommitRow,
    title: &'a BStr,
    decorations: &'a Decorations,
    mailmap: &'a gix::mailmap::Snapshot,
    options: MetadataOptions,
) -> Line<'a> {
    let MetadataOptions {
        show_committer_date,
        show_author_name,
        show_trailers,
        use_mailmap,
        show_special_refs,
        selected,
    } = options;
    let id = row.id.to_hex().to_string();
    let id_style = color(Color::Magenta).add_modifier(Modifier::BOLD);
    let mut spans = vec![Span::styled(
        id[..7].to_owned(),
        if selected {
            id_style.add_modifier(Modifier::REVERSED)
        } else {
            id_style
        },
    )];
    let mut labels = decorations
        .get(&row.id)
        .into_iter()
        .flatten()
        .filter(|decoration| show_special_refs || decoration.kind != DecorationKind::Special)
        .peekable();
    if labels.peek().is_some() {
        spans.push(Span::raw(" ("));
        for (index, decoration) in labels.enumerate() {
            if index != 0 {
                spans.push(Span::raw(", "));
            }
            spans.push(Span::styled(
                decoration.name.to_str_lossy(),
                decoration_style(decoration.kind),
            ));
        }
        spans.push(Span::raw(") "));
    } else {
        spans.push(Span::raw(" "));
    }
    if show_committer_date {
        spans.push(Span::styled(
            format!("{} ", row.committer_time.format_or_unix(gix::date::time::format::SHORT)),
            color(Color::Blue),
        ));
    }
    if show_author_name {
        let author = if use_mailmap {
            mailmap
                .try_resolve_ref(gix::actor::SignatureRef {
                    name: row.author.name,
                    email: row.author.email,
                    time: "",
                })
                .and_then(|resolved| resolved.name)
                .unwrap_or(row.author.name)
        } else {
            row.author.name
        }
        .to_str_lossy();
        spans.push(Span::styled(
            if row.author.is_bot() {
                format!("[{author}] ")
            } else {
                format!("{author} ")
            },
            color(if row.author.is_bot() {
                Color::LightYellow
            } else {
                Color::Green
            }),
        ));
        if show_trailers {
            for (kind, marker) in [
                (AttributionKind::CoAuthor, "Co: "),
                (AttributionKind::Reviewed, "Re: "),
                (AttributionKind::Acked, "Ack: "),
                (AttributionKind::Tested, "Te: "),
                (AttributionKind::SignedOff, "So: "),
            ] {
                let mut actors = row.attributions.iter().filter(|actor| actor.kind == kind).peekable();
                if actors.peek().is_none() {
                    continue;
                }
                spans.push(Span::styled(
                    marker,
                    color(Color::LightYellow).add_modifier(Modifier::DIM),
                ));
                for (index, actor) in actors.enumerate() {
                    if index != 0 {
                        spans.push(Span::raw(", "));
                    }
                    let name = actor.author.name.to_str_lossy();
                    spans.push(Span::styled(
                        if actor.author.is_bot() {
                            format!("[{name}]")
                        } else {
                            name.into_owned()
                        },
                        color(if actor.author.is_bot() {
                            Color::LightYellow
                        } else {
                            Color::Green
                        }),
                    ));
                }
                spans.push(Span::raw(" "));
            }
        }
    }
    spans.push(Span::raw(title.to_str_lossy()));
    Line::from(spans)
}

fn decoration_style(kind: DecorationKind) -> Style {
    match kind {
        DecorationKind::Head => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
        DecorationKind::Local => Style::default().fg(Color::Cyan),
        DecorationKind::Remote => Style::default().fg(Color::Yellow),
        DecorationKind::Tag => Style::default().fg(Color::Magenta),
        DecorationKind::AnnotatedTag => Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
        DecorationKind::Special => Style::default().fg(Color::Blue),
    }
}

fn color(color: Color) -> Style {
    Style::default().fg(color)
}

fn color_graph(frame: &mut Frame<'_>, area: Rect, graph: &str, offset: usize, selected: bool) {
    for (x, symbol) in graph.chars().skip(offset).take(area.width as usize).enumerate() {
        if symbol.is_whitespace() {
            continue;
        }
        let mut style = if symbol == '●' {
            Style::default().fg(Color::Blue)
        } else {
            graph_style(offset.saturating_add(x) / 2)
        };
        if selected {
            style = style.add_modifier(Modifier::REVERSED);
        }
        frame.buffer_mut()[(area.x + x as u16, area.y)].set_style(style);
    }
}

fn graph_style(column: usize) -> Style {
    const COLORS: [Color; 7] = [
        Color::Magenta,
        Color::Yellow,
        Color::Cyan,
        Color::Green,
        Color::Reset,
        Color::White,
        Color::Red,
    ];
    let index = column % 14;
    let style = Style::default().fg(COLORS[index % COLORS.len()]);
    if index >= COLORS.len() {
        style.add_modifier(Modifier::BOLD)
    } else {
        style
    }
}

#[cfg(test)]
mod tests {
    use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};

    use super::*;
    use crate::{
        app::{Action, Attribution, AttributionKind, Author, Commit},
        history::{Decoration, DecorationKind},
    };

    fn author(name: &'static [u8], email: &'static [u8]) -> &'static Author {
        Box::leak(Box::new(Author {
            name: name.as_bstr(),
            email: email.as_bstr(),
        }))
    }

    fn draw(frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations) {
        super::draw(frame, app, decorations, &gix::mailmap::Snapshot::default());
    }

    #[test]
    fn renders_grouped_attributions_and_bot_names() -> Result<(), Box<dyn std::error::Error>> {
        let mut app = App::new(1);
        app.extend_commits(vec![Commit {
            id: gix::ObjectId::Sha1([1; 20]),
            parent_ids: Default::default(),
            lane: String::new(),
            committer_time: gix::date::Time::default(),
            author: author(b"Codex", b"codex@openai.com"),
            attributions: vec![
                Attribution {
                    kind: AttributionKind::CoAuthor,
                    author: author(b"Human", b"human@example.com"),
                },
                Attribution {
                    kind: AttributionKind::CoAuthor,
                    author: author(b"Claude", b"noreply@anthropic.com"),
                },
                Attribution {
                    kind: AttributionKind::Reviewed,
                    author: author(b"Reviewer", b"reviewer@example.com"),
                },
                Attribution {
                    kind: AttributionKind::Acked,
                    author: author(b"Acknowledger", b"ack@example.com"),
                },
                Attribution {
                    kind: AttributionKind::Tested,
                    author: author(b"Tester", b"tester@example.com"),
                },
                Attribution {
                    kind: AttributionKind::SignedOff,
                    author: author(b"Signer", b"signer@example.com"),
                },
            ]
            .into_boxed_slice(),
            title: "subject".into(),
        }]);
        app.selected = None;
        let mut terminal = Terminal::new(TestBackend::new(160, 2))?;

        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;

        let row = rendered_row(&terminal);
        assert!(
            row.contains("[Codex] Co: Human, [Claude] Re: Reviewer Ack: Acknowledger Te: Tester So: Signer subject"),
            "same-kind trailers share one marker and bots use bracketed names"
        );
        let buffer = terminal.backend().buffer();
        let style_at = |needle: &str| {
            let x = row.find(needle).expect("rendered metadata contains the named actor") as u16;
            buffer[(x, 0)].fg
        };
        assert_eq!(
            style_at("[Codex]"),
            Color::LightYellow,
            "bot authors use the agent color"
        );
        assert_eq!(
            style_at("Co:"),
            Color::LightYellow,
            "attribution markers use the agent color"
        );
        let marker_x = row.find("Co:").expect("rendered metadata contains a trailer marker") as u16;
        assert!(
            buffer[(marker_x, 0)].modifier.contains(Modifier::DIM),
            "attribution markers are dimmed"
        );
        assert_eq!(style_at("Human"), Color::Green, "human trailer actors are green");
        assert_eq!(
            style_at("[Claude]"),
            Color::LightYellow,
            "bot co-authors use agent styling"
        );
        assert!(
            rendered_line(&terminal, 1).contains("t trailers"),
            "the footer advertises the trailer toggle"
        );

        app.update(Action::ToggleTrailers);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        assert!(!rendered_row(&terminal).contains("Co:"), "t hides trailer attribution");

        app.update(Action::ToggleTrailers);
        app.update(Action::ToggleName);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        let row = rendered_row(&terminal);
        assert!(!row.contains("Codex"), "n hides the primary actor");
        assert!(
            !row.contains("Reviewer"),
            "n hides trailer actors while trailers are enabled"
        );
        Ok(())
    }

    #[test]
    fn renders_rows_decorations_selection_and_footer() -> Result<(), Box<dyn std::error::Error>> {
        let id = gix::ObjectId::Sha1([1; 20]);
        let mut app = App::new(2);
        app.extend_commits(vec![Commit {
            id,
            parent_ids: Default::default(),
            lane: String::new(),
            committer_time: gix::date::Time::default(),
            author: author(b"author", b"author@example.com"),
            attributions: Box::default(),
            title: "subject".into(),
        }]);
        app.update(Action::Complete);
        let decorations = Decorations::from([(
            id,
            vec![
                Decoration {
                    name: "HEAD".into(),
                    kind: DecorationKind::Head,
                },
                Decoration {
                    name: "refs/patches/main/patch".into(),
                    kind: DecorationKind::Special,
                },
            ],
        )]);
        let mailmap =
            gix::mailmap::Snapshot::from_bytes(b"mapped author <mapped@example.com> author <author@example.com>\n");
        let mut terminal = Terminal::new(TestBackend::new(140, 2))?;

        terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap))?;

        let footer_text = "1 commits · complete · ↑↓/jk move · h/l pan · [ pane · ] natural · d date · n name · m mailmap · t trailers · r refs · y copy · q quit";
        let mut expected = Buffer::with_lines([
            format!("{:<140}", "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"),
            format!("{footer_text:<140}"),
        ]);
        for x in 0..11 {
            expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED));
        }
        expected[(2, 0)].set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED));
        for x in 4..11 {
            expected[(x, 0)].set_style(
                Style::default()
                    .fg(Color::Magenta)
                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
            );
        }
        for x in 13..17 {
            expected[(x, 0)].set_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
        }
        for x in 19..30 {
            expected[(x, 0)].set_style(Style::default().fg(Color::Blue));
        }
        for x in 30..44 {
            expected[(x, 0)].set_style(Style::default().fg(Color::Green));
        }
        let refs = footer_text[..footer_text.find("r refs").expect("the refs toggle is present")]
            .chars()
            .count();
        for x in refs..refs + "r refs".len() {
            expected[(x as u16, 1)].set_style(Style::default().add_modifier(Modifier::DIM));
        }
        terminal.backend().assert_buffer(&expected);
        let row = terminal.backend().buffer();
        assert!(
            row[(10, 0)].modifier.contains(Modifier::REVERSED),
            "selection includes the final hash character"
        );
        assert!(
            !row[(11, 0)].modifier.contains(Modifier::REVERSED),
            "selection ends immediately after the hash"
        );
        assert_eq!(row[(13, 0)].fg, Color::Cyan, "reference colors remain visible");
        assert!(
            !rendered_line(&terminal, 1).contains("Esc cancel"),
            "completed work cannot be cancelled"
        );

        app.update(Action::ToggleMailmap);
        terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap))?;
        assert!(
            rendered_row(&terminal).contains(" author subject"),
            "m restores the original author name"
        );
        assert!(footer_is_dim(&terminal, "m mailmap"), "disabled mailmap is dimmed");
        app.update(Action::ToggleMailmap);

        app.update(Action::ToggleDate);
        app.update(Action::ToggleName);
        terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap))?;
        let row = rendered_row(&terminal);
        assert!(!row.contains("1970-01-01"), "d hides the committer date");
        assert!(!row.contains("author"), "n hides the author name");
        assert!(!row.contains("refs/patches"), "special refs are hidden until requested");
        assert!(row.contains("subject"), "the commit subject remains visible");
        assert!(footer_is_dim(&terminal, "d date"), "disabled date is dimmed");
        assert!(footer_is_dim(&terminal, "n name"), "disabled name is dimmed");

        app.update(Action::ToggleSpecialRefs);
        terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap))?;
        assert!(rendered_row(&terminal).contains("refs/patches"), "r shows special refs");
        assert!(!footer_is_dim(&terminal, "r refs"), "enabled refs are not dimmed");

        app.has_hidden_filter = true;
        terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap))?;
        assert!(
            rendered_line(&terminal, 1).contains("v show hidden"),
            "the footer advertises the configured hidden-history toggle"
        );
        app.show_hidden = true;
        terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap))?;
        assert!(
            rendered_line(&terminal, 1).contains("v hide hidden"),
            "the footer reflects the unfiltered view"
        );
        Ok(())
    }

    #[test]
    fn advertises_cancel_only_while_loading() -> Result<(), Box<dyn std::error::Error>> {
        let mut app = App::new(1);
        let mut terminal = Terminal::new(TestBackend::new(180, 2))?;

        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        assert!(rendered_line(&terminal, 1).contains("Esc cancel"));

        app.update(Action::Cancel);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        assert!(!rendered_line(&terminal, 1).contains("Esc cancel"));
        Ok(())
    }

    #[test]
    fn renders_only_the_visible_rows() -> Result<(), Box<dyn std::error::Error>> {
        let mut app = App::new(2);
        app.extend_commits(
            (1..=3)
                .map(|n| Commit {
                    id: gix::ObjectId::Sha1([n; 20]),
                    parent_ids: Default::default(),
                    lane: String::new(),
                    committer_time: gix::date::Time::default(),
                    author: author(b"author", b"author@example.com"),
                    attributions: Box::default(),
                    title: format!("subject {n}").into(),
                })
                .collect(),
        );
        app.update(Action::Complete);
        app.update(Action::Last);
        let mut terminal = Terminal::new(TestBackend::new(24, 3))?;

        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;

        let buffer = terminal.backend().buffer();
        assert_eq!(buffer[(5, 0)].symbol(), "2", "the viewport starts at the second row");
        assert_eq!(buffer[(5, 1)].symbol(), "3", "the selected third row remains visible");
        assert!(
            buffer[(0, 1)].modifier.contains(Modifier::REVERSED),
            "the slice-local selection highlights the global selection"
        );
        assert_eq!(app.selected, Some(2), "drawing preserves the global selection");
        assert_eq!(app.offset, 1, "drawing preserves the global offset");
        Ok(())
    }

    #[test]
    fn uses_the_tig_palette_without_coloring_the_selection() -> Result<(), Box<dyn std::error::Error>> {
        let id = gix::ObjectId::Sha1([1; 20]);
        let commit = Commit {
            id,
            parent_ids: Default::default(),
            lane: "● │ │ │ │ │ │ │ ".into(),
            committer_time: gix::date::Time::default(),
            author: author(b"author", b"author@example.com"),
            attributions: Box::default(),
            title: "subject".into(),
        };
        let decorations = Decorations::from([(
            id,
            vec![
                Decoration {
                    name: "HEAD".into(),
                    kind: DecorationKind::Head,
                },
                Decoration {
                    name: "main".into(),
                    kind: DecorationKind::Local,
                },
                Decoration {
                    name: "origin/main".into(),
                    kind: DecorationKind::Remote,
                },
                Decoration {
                    name: "tag: v1".into(),
                    kind: DecorationKind::AnnotatedTag,
                },
                Decoration {
                    name: "refs/stash".into(),
                    kind: DecorationKind::Special,
                },
            ],
        )]);
        let mut app = App::new(1);
        app.extend_commits(vec![commit]);
        let row = &app.rows[0];
        let mailmap = gix::mailmap::Snapshot::default();
        let line = metadata_line(
            row,
            app.title(row),
            &decorations,
            &mailmap,
            MetadataOptions {
                show_committer_date: true,
                show_author_name: true,
                show_trailers: true,
                use_mailmap: false,
                show_special_refs: true,
                selected: false,
            },
        );
        let style = |text| {
            line.spans
                .iter()
                .find(|span| span.content == text)
                .expect("the styled field is present")
                .style
        };
        assert_eq!(
            style("0101010"),
            Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
        );
        assert_eq!(style("1970-01-01 "), Style::default().fg(Color::Blue));
        assert_eq!(style("author "), Style::default().fg(Color::Green));
        assert_eq!(
            style("HEAD"),
            Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
        );
        assert_eq!(style("main"), Style::default().fg(Color::Cyan));
        assert_eq!(style("origin/main"), Style::default().fg(Color::Yellow));
        assert_eq!(
            style("tag: v1"),
            Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
        );
        assert_eq!(style("refs/stash"), Style::default().fg(Color::Blue));

        app.selected = None;
        let mut terminal = Terminal::new(TestBackend::new(80, 2))?;
        terminal.draw(|frame| draw(frame, &mut app, &decorations))?;
        let buffer = terminal.backend().buffer();
        assert_eq!(buffer[(2, 0)].fg, Color::Blue, "commit dots use graph-commit");
        assert_eq!(buffer[(4, 0)].fg, Color::Yellow, "lanes cycle through tig's palette");
        assert_eq!(
            buffer[(16, 0)].fg,
            Color::Magenta,
            "the palette repeats after seven lanes"
        );
        assert!(
            buffer[(16, 0)].modifier.contains(Modifier::BOLD),
            "the second palette cycle is bold"
        );
        Ok(())
    }

    #[test]
    fn overlays_metadata_on_wide_graphs_and_allows_natural_flow() -> Result<(), Box<dyn std::error::Error>> {
        let mut app = App::new(1);
        app.extend_commits(vec![Commit {
            id: gix::ObjectId::Sha1([1; 20]),
            parent_ids: Default::default(),
            lane: String::new(),
            committer_time: gix::date::Time::default(),
            author: author(b"author", b"author@example.com"),
            attributions: Box::default(),
            title: "subject".into(),
        }]);
        app.update(Action::Complete);
        app.rows[0].lane = format!("{}{}", "A".repeat(40), "B".repeat(40));
        let mut terminal = Terminal::new(TestBackend::new(60, 2))?;

        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        let pane_column = rendered_row(&terminal)
            .find("0101010")
            .expect("wide graphs automatically pin metadata");
        assert!(
            pane_column < 60,
            "wide graphs automatically pin metadata within the viewport"
        );

        app.update(Action::ScrollRight);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "B");
        assert_eq!(
            rendered_row(&terminal).find("0101010"),
            Some(pane_column),
            "horizontal graph scrolling leaves pinned metadata fixed"
        );

        app.update(Action::ScrollLeft);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        app.update(Action::UnpinMetadata);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        assert!(
            !rendered_row(&terminal).contains("0101010"),
            "] restores natural post-graph placement"
        );

        app.update(Action::ScrollRight);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        assert!(
            rendered_row(&terminal).contains("0101010"),
            "l pages far enough right to reveal natural metadata"
        );

        app.rows[0].lane = "● ".into();
        app.update(Action::PinMetadata);
        terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?;
        assert_eq!(
            terminal.backend().buffer()[(4, 0)].symbol(),
            "0",
            "the pane starts immediately after the widest visible lane"
        );
        Ok(())
    }

    fn rendered_row(terminal: &Terminal<TestBackend>) -> String {
        rendered_line(terminal, 0)
    }

    fn rendered_line(terminal: &Terminal<TestBackend>, y: u16) -> String {
        (0..terminal.backend().buffer().area.width).fold(String::new(), |mut out, x| {
            out.push_str(terminal.backend().buffer()[(x, y)].symbol());
            out
        })
    }

    fn footer_is_dim(terminal: &Terminal<TestBackend>, label: &str) -> bool {
        let footer = rendered_line(terminal, 1);
        let x = footer[..footer.find(label).expect("toggle is visible")].chars().count() as u16;
        terminal.backend().buffer()[(x, 1)].modifier.contains(Modifier::DIM)
    }
}
