use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
    Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
};

use super::input::clipped_input_line;
use super::scroll::{clamp_scroll_start, scrollbar_thumb_position};
use super::task_display::{description_or_placeholder, labels_display};
use super::timestamps::local_timestamp_display;
use super::truncate::truncate_width;
use crate::query::TaskListItem;
use crate::tui::app::WidgetState;
use crate::tui::markdown::render_markdown;
use crate::tui::overlay::TextInputView;
use crate::tui::store::TuiStore;
use crate::tui::theme::{
    self, ACCENT, BG, BG_PANEL, BORDER, FG, FG_DIM, FG_MUTED, INVERSE_FG, ORANGE, RED,
};
use crate::tui::widgets::{priority_short, status_chip, status_span};
use unicode_width::UnicodeWidthStr;

const DETAIL_DEPENDENCY_TREE_CAP: usize = 3;

#[derive(Debug, Clone, Copy)]
enum DependencyDirection {
    Blocker,
    Dependent,
}

impl DependencyDirection {
    fn marker(self) -> &'static str {
        match self {
            Self::Blocker => "←",
            Self::Dependent => "→",
        }
    }
}

#[derive(Debug)]
struct DetailContentLayout {
    body_area: Rect,
    content_area: Rect,
    metadata_area: Rect,
}

#[derive(Debug)]
struct DetailContentRenderModel {
    sticky_lines: Vec<Line<'static>>,
    lines: Vec<Line<'static>>,
    content_height: usize,
    body_start: usize,
    scrollbar_position: usize,
}

pub(crate) struct DetailChildHit {
    pub(crate) task_id: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DetailMetadataTarget {
    Status,
    Priority,
}

fn render_detail(
    frame: &mut Frame,
    item: &TaskListItem,
    scroll: u16,
    inline_title_editor: Option<&TextInputView>,
    hovered_child_task_id: Option<&str>,
) {
    let layout = detail_content_layout(frame.area());
    frame.render_widget(Clear, layout.body_area);
    frame.render_widget(Block::new().style(Style::new().bg(BG)), layout.body_area);
    if layout.body_area.width == 0 || layout.body_area.height == 0 {
        return;
    }

    let model = build_detail_content_model(
        item,
        layout.content_area,
        scroll,
        inline_title_editor,
        hovered_child_task_id,
    );
    render_detail_content_from_model(frame, layout.content_area, &model);
    if layout.metadata_area.width > 0 {
        render_detail_metadata(frame, item, layout.metadata_area);
    }
}

fn detail_content_layout(frame_area: Rect) -> DetailContentLayout {
    let body = detail_body_area(frame_area);

    let [content_area, metadata_area] = if body.width >= 96 {
        Layout::horizontal([Constraint::Fill(1), Constraint::Length(34)]).areas(body)
    } else {
        [body, Rect::default()]
    };
    let content_area = content_area.inner(detail_content_margin());
    DetailContentLayout {
        body_area: body,
        content_area,
        metadata_area,
    }
}

fn detail_body_area(frame_area: Rect) -> Rect {
    let [_, body, _] = Layout::vertical([
        Constraint::Length(2),
        Constraint::Fill(1),
        Constraint::Length(2),
    ])
    .areas(frame_area);
    body
}

fn keycap_style() -> Style {
    Style::new()
        .fg(FG)
        .bg(BG_PANEL)
        .add_modifier(Modifier::BOLD)
}

pub(crate) fn detail_scroll_cap(
    item: &TaskListItem,
    terminal_width: u16,
    terminal_height: u16,
) -> u16 {
    let layout = detail_content_layout(Rect::new(0, 0, terminal_width, terminal_height));
    let model = build_detail_content_model(item, layout.content_area, 0, None, None);
    let sticky_height = model
        .sticky_lines
        .len()
        .min(layout.content_area.height as usize);
    model.content_height.saturating_sub(
        layout
            .content_area
            .height
            .saturating_sub(sticky_height as u16) as usize,
    ) as u16
}

fn detail_content_margin() -> Margin {
    Margin {
        horizontal: 2,
        vertical: 1,
    }
}

fn build_detail_content_model(
    item: &TaskListItem,
    area: Rect,
    scroll: u16,
    inline_title_editor: Option<&TextInputView>,
    hovered_child_task_id: Option<&str>,
) -> DetailContentRenderModel {
    let sticky_lines = detail_header_options(item, area.width as usize, inline_title_editor);
    let body_lines = detail_body_lines(item, area.width as usize, hovered_child_task_id);
    let content_height = body_lines.len().max(1);
    let sticky_height = sticky_lines.len().min(area.height as usize);
    let visible = (area.height as usize).saturating_sub(sticky_height);
    let start = clamp_scroll_start(scroll, content_height, visible.max(1));
    let lines = body_lines.into_iter().skip(start).collect();
    let scrollbar_position = if content_height > visible {
        scrollbar_thumb_position(start, content_height, visible.max(1))
    } else {
        0
    };
    DetailContentRenderModel {
        sticky_lines,
        lines,
        content_height,
        body_start: start,
        scrollbar_position,
    }
}

fn render_detail_content_from_model(
    frame: &mut Frame,
    area: Rect,
    model: &DetailContentRenderModel,
) {
    let visible = area.height as usize;
    let sticky_height = model.sticky_lines.len().min(visible);
    let [sticky_area, body_area] = Layout::vertical([
        Constraint::Length(sticky_height as u16),
        Constraint::Fill(1),
    ])
    .areas(area);
    frame.render_widget(
        Paragraph::new(Text::from(model.sticky_lines.clone())).style(Style::new().fg(FG).bg(BG)),
        sticky_area,
    );
    frame.render_widget(
        Paragraph::new(Text::from(model.lines.clone())).style(Style::new().fg(FG).bg(BG)),
        body_area,
    );
    let body_visible = body_area.height as usize;
    if model.content_height > body_visible {
        frame.render_stateful_widget(
            Scrollbar::new(ScrollbarOrientation::VerticalRight)
                .style(Style::new().fg(FG_DIM).bg(BG))
                .thumb_style(Style::new().fg(FG_MUTED)),
            body_area,
            &mut ScrollbarState::new(model.content_height)
                .position(model.scrollbar_position)
                .viewport_content_length(body_visible.max(1)),
        );
    }
}

#[cfg(test)]
fn detail_content_lines(
    item: &TaskListItem,
    width: usize,
    inline_title_editor: Option<&TextInputView>,
) -> Vec<Line<'static>> {
    let mut lines = detail_header_options(item, width, inline_title_editor);
    lines.extend(detail_body_lines(item, width, None));
    lines
}

fn detail_body_lines(
    item: &TaskListItem,
    width: usize,
    hovered_child_task_id: Option<&str>,
) -> Vec<Line<'static>> {
    let mut lines = detail_epic_child_lines(item, width, hovered_child_task_id);
    if !lines.is_empty() {
        lines.push(Line::from(""));
    }
    lines.extend(quoted_block_lines(
        &description_or_placeholder(&item.task.description),
        width,
        Style::new().fg(FG_MUTED),
    ));
    lines.push(Line::from(""));
    lines.push(Line::from(vec![
        Span::styled(
            "NOTES",
            Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD),
        ),
        Span::styled(" (", Style::new().fg(FG_DIM)),
        Span::styled("n", keycap_style()),
        Span::styled(" add)", Style::new().fg(FG_DIM)),
    ]));
    if item.notes.is_empty() {
        lines.push(Line::from(Span::styled("none", Style::new().fg(FG_MUTED))));
    } else {
        for note in &item.notes {
            lines.push(Line::from(""));
            lines.push(Line::from(vec![
                Span::styled(
                    local_timestamp_display(&note.created_at),
                    Style::new().fg(FG_DIM),
                ),
                Span::styled("  you", Style::new().fg(ACCENT)),
            ]));
            lines.extend(quoted_block_lines(&note.body, width, Style::new().fg(FG)));
        }
    }
    lines.extend(detail_dependency_lines(item, width));
    lines
}

fn detail_epic_child_lines(
    item: &TaskListItem,
    width: usize,
    hovered_child_task_id: Option<&str>,
) -> Vec<Line<'static>> {
    if !item.task.is_epic {
        return Vec::new();
    }

    let open = item
        .epic_children
        .iter()
        .filter(|link| link.unresolved)
        .count();
    let mut lines = vec![Line::from(vec![
        Span::styled(
            "CHILD TASKS",
            Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!(" open={open} total={}", item.epic_children.len()),
            Style::new().fg(FG_DIM),
        ),
    ])];

    if item.epic_children.is_empty() {
        lines.push(Line::from(Span::styled("none", Style::new().fg(FG_MUTED))));
        return lines;
    }

    let links = item
        .epic_children
        .iter()
        .filter(|link| link.unresolved)
        .chain(item.epic_children.iter().filter(|link| !link.unresolved))
        .collect::<Vec<_>>();
    for (index, link) in links.iter().enumerate() {
        let is_last = index + 1 == links.len();
        let hovered = hovered_child_task_id == Some(link.task_id.as_str());
        lines.extend(epic_child_tree_item_lines(link, is_last, width, hovered));
    }

    lines
}

fn epic_child_tree_item_lines(
    link: &crate::query::TaskDependencyLink,
    is_last: bool,
    width: usize,
    hovered: bool,
) -> Vec<Line<'static>> {
    let tree_glyph = if is_last { "└─ " } else { "├─ " };
    let ref_style = if hovered {
        Style::new()
            .fg(ACCENT)
            .bg(BG_PANEL)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::new().fg(ACCENT)
    };
    let title_style = if hovered {
        Style::new().fg(FG).bg(BG_PANEL)
    } else {
        Style::new().fg(FG)
    };
    let tree_style = if hovered {
        Style::new().fg(BORDER).bg(BG_PANEL)
    } else {
        Style::new().fg(BORDER)
    };
    let gap_style = if hovered {
        Style::new().fg(FG_DIM).bg(BG_PANEL)
    } else {
        Style::new().fg(FG_DIM)
    };
    let prefix = vec![
        Span::styled(tree_glyph, tree_style),
        Span::styled(link.display_ref.clone(), ref_style),
        Span::styled("  ", gap_style),
    ];
    dependency_node_lines_with_title_style(
        prefix,
        &link.title,
        &link.status,
        &link.priority,
        width,
        title_style,
    )
}

fn detail_dependency_lines(item: &TaskListItem, width: usize) -> Vec<Line<'static>> {
    if item.depends_on.is_empty() && item.blocks.is_empty() {
        return Vec::new();
    }

    let mut lines = vec![Line::from("")];

    if !item.depends_on.is_empty() {
        lines.push(dependency_heading("WHY BLOCKED", &item.depends_on));
        lines.extend(dependency_branch_lines(
            &item.depends_on,
            DependencyDirection::Blocker,
            width,
        ));
    }

    if !item.blocks.is_empty() {
        lines.push(Line::from(""));
        lines.push(dependency_heading("WHAT THIS UNLOCKS", &item.blocks));
        lines.extend(dependency_branch_lines(
            &item.blocks,
            DependencyDirection::Dependent,
            width,
        ));
    }

    lines
}

fn dependency_branch_lines(
    links: &[crate::query::TaskDependencyLink],
    direction: DependencyDirection,
    width: usize,
) -> Vec<Line<'static>> {
    let visible = links.len().min(DETAIL_DEPENDENCY_TREE_CAP);
    let hidden = links.len().saturating_sub(visible);
    let rendered_len = visible + usize::from(hidden > 0);
    let mut lines = Vec::with_capacity(rendered_len);

    for (index, link) in links.iter().take(visible).enumerate() {
        let is_last = index + 1 == rendered_len;
        lines.extend(dependency_tree_item_lines(link, direction, is_last, width));
    }

    if hidden > 0 {
        lines.push(Line::from(vec![
            Span::styled("└─ ", Style::new().fg(BORDER)),
            Span::styled(format!("+{hidden} more"), Style::new().fg(FG_MUTED)),
        ]));
    }

    lines
}

fn dependency_heading(
    label: &'static str,
    links: &[crate::query::TaskDependencyLink],
) -> Line<'static> {
    let open = links.iter().filter(|link| link.unresolved).count();
    Line::from(vec![
        Span::styled(label, Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD)),
        Span::styled(
            format!(" open={open} total={}", links.len()),
            Style::new().fg(FG_DIM),
        ),
    ])
}

fn dependency_tree_item_lines(
    link: &crate::query::TaskDependencyLink,
    direction: DependencyDirection,
    is_last: bool,
    width: usize,
) -> Vec<Line<'static>> {
    let tree_glyph = if is_last { "└─ " } else { "├─ " };
    let prefix = vec![
        Span::styled(tree_glyph, Style::new().fg(BORDER)),
        Span::styled(direction.marker(), Style::new().fg(FG_DIM)),
        Span::styled(" ", Style::new().fg(FG_DIM)),
        Span::styled(link.display_ref.clone(), Style::new().fg(ACCENT)),
        Span::styled("  ", Style::new().fg(FG_DIM)),
    ];
    dependency_node_lines(prefix, &link.title, &link.status, &link.priority, width)
}

fn dependency_node_lines(
    spans: Vec<Span<'static>>,
    title: &str,
    status: &str,
    priority: &str,
    width: usize,
) -> Vec<Line<'static>> {
    dependency_node_lines_with_title_style(
        spans,
        title,
        status,
        priority,
        width,
        Style::new().fg(FG),
    )
}

fn dependency_node_lines_with_title_style(
    mut spans: Vec<Span<'static>>,
    title: &str,
    status: &str,
    priority: &str,
    width: usize,
    title_style: Style,
) -> Vec<Line<'static>> {
    let title_width = dependency_title_width(&spans, status, priority, width);
    if title_width > 0 {
        spans.push(Span::styled(
            truncate_width(title, title_width),
            title_style,
        ));
        spans.push(Span::styled("  ", Style::new().fg(FG_DIM)));
        spans.push(status_span(status));
        spans.push(Span::styled("  ", Style::new().fg(FG_DIM)));
        spans.push(Span::styled(
            priority_short(priority),
            theme::priority_style(priority).add_modifier(Modifier::BOLD),
        ));
        return vec![Line::from(spans)];
    }

    let continuation_prefix = dependency_continuation_prefix(&spans);
    let mut lines = vec![Line::from(spans)];
    lines.push(Line::from(vec![
        Span::styled(continuation_prefix.clone(), Style::new().fg(BORDER)),
        Span::styled(
            truncate_width(title, width.saturating_sub(continuation_prefix.width())),
            title_style,
        ),
    ]));
    lines.push(Line::from(vec![
        Span::styled(continuation_prefix.clone(), Style::new().fg(BORDER)),
        status_span(status),
        Span::styled("  ", Style::new().fg(FG_DIM)),
        Span::styled(
            priority_short(priority),
            theme::priority_style(priority).add_modifier(Modifier::BOLD),
        ),
    ]));
    lines
}

fn dependency_continuation_prefix(prefix: &[Span<'static>]) -> String {
    " ".repeat(prefix.iter().map(Span::width).sum::<usize>().min(4))
}

fn dependency_title_width(
    prefix: &[Span<'static>],
    status: &str,
    priority: &str,
    width: usize,
) -> usize {
    let prefix_width: usize = prefix.iter().map(Span::width).sum();
    let trailing_width = 4 + status_span(status).width() + priority_short(priority).width();
    width.saturating_sub(prefix_width + trailing_width)
}

fn detail_header_options(
    item: &TaskListItem,
    width: usize,
    inline_title_editor: Option<&TextInputView>,
) -> Vec<Line<'static>> {
    vec![
        detail_title_line(item, width, inline_title_editor),
        Line::from(Span::styled("─".repeat(width), Style::new().fg(BORDER))),
        Line::from(vec![
            Span::styled(item.display_ref.clone(), Style::new().fg(FG_DIM)),
            Span::styled("   ", Style::new().fg(FG_DIM)),
            status_span(item.task.status.as_str()),
            Span::styled("   ", Style::new().fg(FG_DIM)),
            Span::styled(
                priority_short(item.task.priority.as_str()),
                theme::priority_style(item.task.priority.as_str()).add_modifier(Modifier::BOLD),
            ),
        ]),
        Line::from(""),
    ]
}

fn detail_title_line(
    item: &TaskListItem,
    width: usize,
    inline_title_editor: Option<&TextInputView>,
) -> Line<'static> {
    if let Some(editor) = inline_title_editor {
        let mut line = clipped_input_line(&editor.input, editor.cursor, width);
        for span in &mut line.spans {
            span.style = Style::new()
                .fg(FG)
                .add_modifier(Modifier::BOLD)
                .patch(span.style);
        }
        return line;
    }

    Line::from(vec![Span::styled(
        item.task.title.clone(),
        Style::new().fg(FG).add_modifier(Modifier::BOLD),
    )])
}

fn quoted_block_lines(body: &str, width: usize, style: Style) -> Vec<Line<'static>> {
    let content_width = width.saturating_sub(3).max(1);
    render_markdown(body, content_width)
        .into_iter()
        .map(|line| {
            let mut spans = line_with_base_style(line, style).spans;
            spans.insert(0, Span::styled("│ ", Style::new().fg(BORDER)));
            Line::from(spans)
        })
        .collect()
}

fn line_with_base_style(mut line: Line<'static>, base: Style) -> Line<'static> {
    for span in &mut line.spans {
        span.style = base.patch(span.style);
    }
    line
}

fn render_detail_metadata(frame: &mut Frame, item: &TaskListItem, area: Rect) {
    let block = Block::new()
        .borders(Borders::LEFT)
        .border_style(Style::new().fg(BORDER))
        .padding(Padding::horizontal(1))
        .style(Style::new().bg(BG));
    let inner = block.inner(area);
    frame.render_widget(block, area);
    frame.render_widget(
        Paragraph::new(Text::from(detail_metadata_lines(item))).style(Style::new().fg(FG).bg(BG)),
        inner,
    );
}

fn detail_metadata_lines(item: &TaskListItem) -> Vec<Line<'static>> {
    let mut lines = vec![
        Line::from(Span::styled(
            if item.task.is_epic {
                " EPIC "
            } else {
                " TASK "
            },
            Style::new()
                .fg(INVERSE_FG)
                .bg(BORDER)
                .add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        metadata_label("PROJECT"),
        Line::from(vec![
            Span::styled(
                "● ",
                Style::new().fg(theme::project_color(&item.task.project_key)),
            ),
            Span::styled(
                item.task.project_key.clone(),
                Style::new().fg(FG).add_modifier(Modifier::BOLD),
            ),
        ]),
        Line::from(""),
        metadata_label("STATUS"),
        status_chip(item.task.status.as_str()),
        Line::from(""),
        metadata_label("PRIORITY"),
        Line::from(Span::styled(
            priority_short(item.task.priority.as_str()),
            theme::priority_style(item.task.priority.as_str()).add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        metadata_label("LABELS"),
        Line::from(labels_display(&item.labels, ", ")),
        Line::from(""),
        metadata_label("REF"),
        Line::from(Span::styled(
            item.display_ref.clone(),
            Style::new().fg(FG).add_modifier(Modifier::BOLD),
        )),
        Line::from(""),
        metadata_label("CREATED"),
        Line::from(Span::styled(
            local_timestamp_display(&item.task.created_at),
            Style::new().fg(FG_MUTED),
        )),
        Line::from(""),
        metadata_label("UPDATED"),
        Line::from(Span::styled(
            local_timestamp_display(&item.task.updated_at),
            Style::new().fg(FG_MUTED),
        )),
    ];
    lines.extend(detail_epic_metadata_lines(item));
    if item.has_conflict {
        lines.extend([
            Line::from(""),
            metadata_label("CONFLICTS"),
            Line::from(Span::styled(
                "yes",
                Style::new().fg(ORANGE).add_modifier(Modifier::BOLD),
            )),
        ]);
    }
    if item.task.deleted {
        lines.extend([
            Line::from(""),
            metadata_label("DELETED"),
            Line::from(Span::styled(
                "yes",
                Style::new().fg(RED).add_modifier(Modifier::BOLD),
            )),
        ]);
    }
    lines
}

fn detail_epic_metadata_lines(item: &TaskListItem) -> Vec<Line<'static>> {
    if !item.task.is_epic {
        return Vec::new();
    }

    let open = item
        .epic_children
        .iter()
        .filter(|link| link.unresolved)
        .count();
    let lines = vec![
        Line::from(""),
        metadata_label("CHILDREN"),
        Line::from(Span::styled(
            format!("open={open} total={}", item.epic_children.len()),
            Style::new().fg(FG_DIM),
        )),
    ];

    if item.epic_children.is_empty() {
        return lines
            .into_iter()
            .chain(std::iter::once(Line::from(Span::styled(
                "none",
                Style::new().fg(FG_MUTED),
            ))))
            .collect();
    }

    lines
}

fn metadata_label(label: &'static str) -> Line<'static> {
    Line::from(Span::styled(
        label,
        Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD),
    ))
}

pub(crate) fn detail_child_task_at_position(
    item: &TaskListItem,
    terminal_width: u16,
    terminal_height: u16,
    column: u16,
    row: u16,
    scroll: u16,
) -> Option<DetailChildHit> {
    let layout = detail_content_layout(Rect::new(0, 0, terminal_width, terminal_height));
    if column < layout.content_area.x
        || column
            >= layout
                .content_area
                .x
                .saturating_add(layout.content_area.width)
        || row < layout.content_area.y
        || row
            >= layout
                .content_area
                .y
                .saturating_add(layout.content_area.height)
    {
        return None;
    }
    let model = build_detail_content_model(item, layout.content_area, scroll, None, None);
    let sticky_height = model
        .sticky_lines
        .len()
        .min(layout.content_area.height as usize) as u16;
    let body_y = layout.content_area.y.saturating_add(sticky_height);
    if row < body_y {
        return None;
    }
    let body_row = row.saturating_sub(body_y) as usize;
    let body_index = model.body_start.saturating_add(body_row);
    let task_id = detail_epic_child_task_id_at_body_line(
        item,
        layout.content_area.width as usize,
        body_index,
    )?;
    Some(DetailChildHit { task_id })
}

fn detail_epic_child_task_id_at_body_line(
    item: &TaskListItem,
    width: usize,
    body_line_index: usize,
) -> Option<String> {
    if !item.task.is_epic || body_line_index == 0 {
        return None;
    }
    let links = item
        .epic_children
        .iter()
        .filter(|link| link.unresolved)
        .chain(item.epic_children.iter().filter(|link| !link.unresolved))
        .collect::<Vec<_>>();
    let mut line_index = 1;
    for (index, link) in links.iter().enumerate() {
        let is_last = index + 1 == links.len();
        let line_count = epic_child_tree_item_lines(link, is_last, width, false).len();
        if (line_index..line_index + line_count).contains(&body_line_index) {
            return Some(link.task_id.clone());
        }
        line_index += line_count;
    }
    None
}

pub(crate) fn detail_metadata_target_at(
    terminal_width: u16,
    terminal_height: u16,
    column: u16,
    row: u16,
) -> Option<(DetailMetadataTarget, u16, u16)> {
    let layout = detail_content_layout(Rect::new(0, 0, terminal_width, terminal_height));
    if layout.metadata_area.width == 0 {
        return None;
    }
    let body = detail_body_area(Rect::new(0, 0, terminal_width, terminal_height));
    let line = metadata_content_row(layout.metadata_area, body, column, row)?;
    let target = match line {
        6 => DetailMetadataTarget::Status,
        9 => DetailMetadataTarget::Priority,
        _ => return None,
    };
    Some((target, column, row))
}

fn metadata_content_row(metadata_area: Rect, body: Rect, column: u16, row: u16) -> Option<u16> {
    if column <= metadata_area.x
        || column >= metadata_area.x.saturating_add(metadata_area.width)
        || row < body.y
        || row >= body.y.saturating_add(body.height)
    {
        return None;
    }
    Some(row.saturating_sub(body.y))
}

pub(super) fn render_detail_underlay(
    frame: &mut Frame,
    store: &TuiStore,
    widgets: &mut WidgetState,
    scroll: u16,
    inline_title_editor: Option<&TextInputView>,
    hovered_child_task_id: Option<&str>,
) {
    if let Some(task) = store.selected_task(widgets.table.selected()) {
        render_detail(
            frame,
            task,
            scroll,
            inline_title_editor,
            hovered_child_task_id,
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::choices::{TaskPriority, TaskStatus};

    #[test]
    fn detail_content_includes_notes() {
        let item = detail_test_item();
        let rendered = detail_content_lines(&item, 60, None)
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("Fix token refresh race"));
        assert!(rendered.contains("Confirmed race in useTokenRefresh.ts"));
        assert!(!rendered.contains("2026-06-20T12:00:00Z"));
    }

    #[test]
    fn detail_content_renders_dependency_tree() {
        let item = detail_test_item();
        let rendered = detail_content_lines(&item, 80, None)
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("WHY BLOCKED open=1 total=1"));
        assert!(rendered.contains("└─ ← APP-7KQ1"));
        assert!(rendered.contains("Ship auth service"));
        assert!(rendered.contains("WHAT THIS UNLOCKS open=1 total=1"));
        assert!(rendered.contains("└─ → APP-7KQ2"));
        assert!(rendered.contains("Write rollout notes"));
    }

    #[test]
    fn detail_dependency_tree_caps_long_blockers() {
        let mut item = detail_test_item();
        item.depends_on = (0..5)
            .map(|index| crate::query::TaskDependencyLink {
                task_id: format!("blocker-id-{index}"),
                display_ref: format!("APP-B{index}"),
                title: format!("blocker {index}"),
                status: "todo".to_string(),
                priority: "medium".to_string(),
                unresolved: true,
            })
            .collect();
        item.blocks.clear();

        let rendered = detail_content_lines(&item, 80, None)
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("├─ ← APP-B0"));
        assert!(rendered.contains("├─ ← APP-B2"));
        assert!(rendered.contains("└─ +2 more"));
        assert!(!rendered.contains("APP-B3"));
    }

    #[test]
    fn detail_dependency_tree_caps_long_dependents() {
        let mut item = detail_test_item();
        item.blocks = (0..5)
            .map(|index| crate::query::TaskDependencyLink {
                task_id: format!("dependent-id-{index}"),
                display_ref: format!("APP-D{index}"),
                title: format!("dependent {index}"),
                status: "inbox".to_string(),
                priority: "low".to_string(),
                unresolved: true,
            })
            .collect();
        item.depends_on.clear();

        let rendered = detail_content_lines(&item, 80, None)
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("├─ → APP-D0"));
        assert!(rendered.contains("├─ → APP-D2"));
        assert!(rendered.contains("└─ +2 more"));
        assert!(!rendered.contains("APP-D3"));
    }

    #[test]
    fn detail_dependency_tree_truncates_titles_in_narrow_width() {
        let mut item = detail_test_item();
        item.depends_on[0].title =
            "A very long title that should fit the dependency row".to_string();

        let rendered = detail_content_lines(&item, 40, None);

        let blocker_line = rendered
            .iter()
            .find(|line| line.to_string().contains("APP-7KQ1"))
            .expect("blocker line rendered");

        assert!(
            blocker_line.width() <= 40,
            "line width {} exceeded 40",
            blocker_line.width()
        );
        assert!(blocker_line.to_string().contains('…'));
    }

    #[test]
    fn detail_dependency_tree_stacks_when_narrow() {
        let mut item = detail_test_item();
        item.depends_on[0].title =
            "A very long title that should stack in the dependency tree".to_string();

        let rendered = detail_dependency_lines(&item, 20);
        let rendered_text = rendered
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered_text.contains("└─ ← APP-7KQ1"));
        assert!(rendered_text.contains("A very long tit…"));
        assert!(rendered_text.contains("□ todo  ● high"));
        for line in rendered.into_iter().filter(|line| {
            let text = line.to_string();
            text.contains("APP-") || text.contains("todo")
        }) {
            assert!(
                line.width() <= 20,
                "line width {} exceeded 20: {line:?}",
                line.width()
            );
        }
    }

    #[test]
    fn detail_dependency_tree_is_omitted_without_links() {
        let mut item = detail_test_item();
        item.depends_on.clear();
        item.blocks.clear();

        let rendered = detail_content_lines(&item, 60, None)
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(!rendered.contains("WHY BLOCKED"));
        assert!(!rendered.contains("WHAT THIS UNLOCKS"));
    }

    #[test]
    fn detail_content_renders_markdown_description_and_notes() {
        let mut item = detail_test_item();
        item.task.description = "## Context\n- **One** item".to_string();
        item.notes[0].body = "Use `aven show` after edits".to_string();

        let rendered = detail_content_lines(&item, 60, None)
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("Context"));
        assert!(rendered.contains("- One item"));
        assert!(rendered.contains("aven show"));
        assert!(!rendered.contains("`aven show`"));
    }

    #[test]
    fn detail_description_lines_keep_quote_rail() {
        let mut item = detail_test_item();
        item.task.description = "## Context\nsecond line".to_string();
        let lines = detail_content_lines(&item, 60, None);
        let description_lines: Vec<_> = lines
            .into_iter()
            .filter(|line| {
                let text = line.to_string();
                text.contains("Context") || text.contains("second")
            })
            .collect();
        assert!(!description_lines.is_empty());
        for line in description_lines {
            assert!(
                line.spans
                    .first()
                    .is_some_and(|span| span.content.as_ref() == "│ "),
                "missing quote rail: {line:?}"
            );
        }
    }

    #[test]
    fn detail_note_lines_keep_quote_rail() {
        let mut item = detail_test_item();
        item.notes[0].body = "Use `aven` here".to_string();
        let lines = detail_content_lines(&item, 60, None);
        let note_lines: Vec<_> = lines
            .into_iter()
            .filter(|line| line.to_string().contains("aven"))
            .collect();
        assert_eq!(note_lines.len(), 1);
        let line = &note_lines[0];
        assert!(
            line.spans
                .first()
                .is_some_and(|span| span.content.as_ref() == "│ "),
            "missing quote rail: {line:?}"
        );
        let code_span = line
            .spans
            .iter()
            .find(|span| span.content.as_ref() == "aven")
            .expect("missing rendered inline code span");
        assert_eq!(
            code_span.style.fg,
            Some(crate::tui::theme::BLUE),
            "inline code foreground style was not preserved"
        );
        assert!(
            !line.to_string().contains('`'),
            "inline code markers leaked into rendered note text"
        );
    }

    #[test]
    fn detail_content_lists_epic_children() {
        let item = detail_test_epic_item();

        let lines = detail_content_lines(&item, 80, None)
            .into_iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>();
        let rendered = lines.join("\n");
        let child_heading_index = lines
            .iter()
            .position(|line| line.contains("CHILD TASKS"))
            .unwrap();

        assert_eq!(lines[child_heading_index.saturating_sub(1)], "");
        assert!(lines[child_heading_index.saturating_sub(2)].contains("active"));

        assert!(rendered.contains("CHILD TASKS open=1 total=2"));
        assert!(rendered.contains("├─ APP-CHLD"));
        assert!(rendered.contains("Build the first child task"));
        assert!(rendered.contains("└─ APP-DONE"));
        assert!(rendered.contains("Finished child task"));
        assert!(
            rendered.find("CHILD TASKS").unwrap()
                < rendered.find("Two token refresh requests").unwrap()
        );
    }

    #[test]
    fn detail_child_hit_maps_child_rows() {
        let item = detail_test_epic_item();

        let hit = detail_child_task_at_position(&item, 120, 30, 4, 8, 0).unwrap();

        assert_eq!(hit.task_id, "child-task-id");
    }

    #[test]
    fn hovered_detail_child_uses_link_style() {
        let item = detail_test_epic_item();
        let lines = detail_body_lines(&item, 80, Some("child-task-id"));
        let line = lines
            .iter()
            .find(|line| line.to_string().contains("APP-CHLD"))
            .unwrap();
        let ref_span = line
            .spans
            .iter()
            .find(|span| span.content.as_ref() == "APP-CHLD")
            .unwrap();

        assert_eq!(ref_span.style.bg, Some(BG_PANEL));
    }

    #[test]
    fn detail_metadata_includes_operational_fields() {
        let item = detail_test_item();
        let rendered = detail_metadata_lines(&item)
            .into_iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("PROJECT\n● app"));
        assert!(rendered.contains("STATUS\n● active"));
        assert!(rendered.contains("PRIORITY\n▲ urgent"));
        assert!(rendered.contains("LABELS\nbug, mobile"));
        assert!(rendered.contains("CONFLICTS\nyes"));
    }

    #[test]
    fn detail_metadata_marks_epics_and_counts_children() {
        let mut item = detail_test_item();
        item.task.is_epic = true;
        item.epic_children = vec![
            crate::query::TaskDependencyLink {
                task_id: "child-task-id".to_string(),
                display_ref: "APP-CHLD".to_string(),
                title: "Build the first child task".to_string(),
                status: "todo".to_string(),
                priority: "medium".to_string(),
                unresolved: true,
            },
            crate::query::TaskDependencyLink {
                task_id: "done-child-task-id".to_string(),
                display_ref: "APP-DONE".to_string(),
                title: "Finished child task".to_string(),
                status: "done".to_string(),
                priority: "none".to_string(),
                unresolved: false,
            },
        ];

        let rendered = detail_metadata_lines(&item)
            .into_iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains(" EPIC "));
        assert!(rendered.contains("CHILDREN\nopen=1 total=2"));
        assert!(!rendered.contains("APP-CHLD"));
        assert!(!rendered.contains("Build the first child task"));
        assert!(!rendered.contains("APP-DONE"));
    }

    #[test]
    fn detail_metadata_marks_epics_without_children() {
        let mut item = detail_test_item();
        item.task.is_epic = true;

        let rendered = detail_metadata_lines(&item)
            .into_iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains(" EPIC "));
        assert!(rendered.contains("CHILDREN\nopen=0 total=0\nnone"));
    }

    #[test]
    fn detail_content_layout_matches_wide_and_narrow_metadata_rules() {
        let wide = detail_content_layout(Rect::new(0, 0, 120, 30));

        assert_eq!(wide.body_area, Rect::new(0, 2, 120, 26));
        assert!(wide.metadata_area.width > 0);
        assert_eq!(wide.content_area.y, 3);

        let narrow = detail_content_layout(Rect::new(0, 0, 80, 30));

        assert_eq!(narrow.metadata_area, Rect::default());
        assert_eq!(narrow.content_area.x, 2);
    }

    #[test]
    fn detail_metadata_target_maps_status_and_priority_values() {
        assert_eq!(
            detail_metadata_target_at(120, 30, 88, 8),
            Some((DetailMetadataTarget::Status, 88, 8))
        );
        assert_eq!(
            detail_metadata_target_at(120, 30, 88, 11),
            Some((DetailMetadataTarget::Priority, 88, 11))
        );
        assert_eq!(detail_metadata_target_at(120, 30, 88, 7), None);
        assert_eq!(detail_metadata_target_at(80, 30, 70, 9), None);
    }

    #[test]
    fn detail_content_model_prepares_render_lines_and_scrollbar() {
        let mut item = detail_test_item();
        item.task.description = (0..20)
            .map(|index| format!("line {index}"))
            .collect::<Vec<_>>()
            .join("\n");

        let model = build_detail_content_model(&item, Rect::new(0, 0, 60, 5), 4, None, None);

        assert_eq!(
            model.content_height,
            detail_body_lines(&item, 60, None).len()
        );
        assert_eq!(
            model.sticky_lines.len(),
            detail_header_options(&item, 60, None).len()
        );
        assert_eq!(model.sticky_lines[0].to_string(), "Fix token refresh race");
        assert_eq!(model.lines.len(), model.content_height.saturating_sub(4));
        assert!(model.scrollbar_position > 0);
    }

    fn detail_test_epic_item() -> TaskListItem {
        let mut item = detail_test_item();
        item.task.is_epic = true;
        item.epic_children = vec![
            crate::query::TaskDependencyLink {
                task_id: "child-task-id".to_string(),
                display_ref: "APP-CHLD".to_string(),
                title: "Build the first child task".to_string(),
                status: "todo".to_string(),
                priority: "medium".to_string(),
                unresolved: true,
            },
            crate::query::TaskDependencyLink {
                task_id: "done-child-task-id".to_string(),
                display_ref: "APP-DONE".to_string(),
                title: "Finished child task".to_string(),
                status: "done".to_string(),
                priority: "none".to_string(),
                unresolved: false,
            },
        ];
        item
    }

    fn detail_test_item() -> TaskListItem {
        TaskListItem {
            task: crate::types::Task {
                id: "7KQ9A1X".to_string(),
                workspace_id: "workspace-1".to_string(),
                title: "Fix token refresh race".to_string(),
                description: "Two token refresh requests fire together.".to_string(),
                project_id: "project-id".to_string(),
                project_key: "app".to_string(),
                project_prefix: "APP".to_string(),
                status: TaskStatus::Active,
                priority: TaskPriority::Urgent,
                created_at: "2026-06-19T12:00:00Z".to_string(),
                updated_at: "2026-06-20T12:00:00Z".to_string(),
                queue_activity_at: "2026-06-20T12:00:00Z".to_string(),
                deleted: false,
                is_epic: false,
            },
            display_ref: "APP-7KQ9A1X".to_string(),
            labels: vec!["bug".to_string(), "mobile".to_string()],
            notes: vec![crate::query::TaskNote {
                body: "Confirmed race in useTokenRefresh.ts".to_string(),
                created_at: "2026-06-20T12:00:00Z".to_string(),
            }],
            has_conflict: true,
            unresolved_blocker_count: 0,
            dependent_count: 0,
            depends_on: vec![crate::query::TaskDependencyLink {
                task_id: "blocker-task-id".to_string(),
                display_ref: "APP-7KQ1".to_string(),
                title: "Ship auth service".to_string(),
                status: "todo".to_string(),
                priority: "high".to_string(),
                unresolved: true,
            }],
            blocks: vec![crate::query::TaskDependencyLink {
                task_id: "dependent-task-id".to_string(),
                display_ref: "APP-7KQ2".to_string(),
                title: "Write rollout notes".to_string(),
                status: "inbox".to_string(),
                priority: "none".to_string(),
                unresolved: true,
            }],
            epic_children: Vec::new(),
            epic_parent: None,
            queue: Default::default(),
        }
    }
}
