shuire 0.1.1

Vim-like TUI git diff viewer
use anyhow::Result;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};

use crate::cli::DiffRange;
use crate::diff::{DiffLine, LineKind};
use crate::state::App;
use crate::{git, highlight};

/// Per-tick bookkeeping shared by the async app loop.
pub fn tick(app: &mut App) {
    if let Some(max_lines) = app.pending_expand.take() {
        let _ = try_expand_fold(app, max_lines);
        // Splicing in expanded lines can push the cursor off-screen when the
        // fold sat near the top/bottom of the viewport; re-anchor it.
        app.ensure_cursor_visible();
    }
    if let Some(target) = app.pending_range_change.take() {
        apply_range_change(app, target);
    }
}

pub fn apply_range_change(app: &mut App, target: String) {
    let new_range = DiffRange::UncommittedAgainst {
        base: target.clone(),
    };
    match git::load_diff_simple(&new_range) {
        Ok(mut files) => {
            highlight::highlight_files(&mut files);
            app.diff_fingerprint = crate::storage::diff_fingerprint(&files);
            app.files = files;
            app.range = new_range;
            app.selected = 0;
            app.cursor_line = app.first_navigable_line();
            app.diff_scroll = 0;
            app.comment_focus = None;
            app.set_flash(format!("Switched to {}", target));
        }
        Err(e) => {
            app.set_flash(format!("Load failed: {}", e));
        }
    }
}

fn try_expand_fold(app: &mut App, max_lines: usize) -> Result<()> {
    let file = app
        .files
        .get(app.selected)
        .ok_or(anyhow::anyhow!("no file"))?;
    let line = file
        .lines
        .get(app.cursor_line)
        .ok_or(anyhow::anyhow!("no line"))?;

    match line.kind {
        LineKind::FoldDown => expand_down(app, max_lines),
        LineKind::FoldUp => expand_up(app, max_lines),
        _ => Ok(()),
    }
}

fn expand_down(app: &mut App, max_lines: usize) -> Result<()> {
    let line = &app.files[app.selected].lines[app.cursor_line];
    let new_start = line.new_lineno.unwrap_or(1);
    let old_start = line.old_lineno.unwrap_or(1);
    let total = line.fold_count as usize;
    let expand = total.min(max_lines);
    let path = app.files[app.selected].path.clone();
    let hunk_header = line.hunk_header.clone();

    let content = git::load_file_content(&app.range, &path)?;
    let file_start = (new_start as usize).saturating_sub(1);

    let mut new_lines: Vec<DiffLine> = Vec::with_capacity(expand + 1);
    for (i, text) in content.lines().skip(file_start).take(expand).enumerate() {
        new_lines.push(DiffLine::body(
            LineKind::Context,
            text.to_string(),
            Some(old_start + i as u32),
            Some(new_start + i as u32),
        ));
    }

    let remaining = total - expand;
    if remaining > 0 {
        let down_header = if remaining > 20 {
            String::new()
        } else {
            hunk_header
        };
        new_lines.push(DiffLine::fold_down(
            old_start + expand as u32,
            new_start + expand as u32,
            remaining as u32,
            down_header,
        ));
    }

    let cursor = app.cursor_line;
    let file = &mut app.files[app.selected];
    file.lines.splice(cursor..=cursor, new_lines);

    let fold_up_idx = cursor + expand + if remaining > 0 { 1 } else { 0 };
    if fold_up_idx < file.lines.len() && file.lines[fold_up_idx].kind == LineKind::FoldUp {
        if remaining > 20 {
            update_fold(
                &mut file.lines[fold_up_idx],
                remaining as u32,
                old_start + expand as u32,
                new_start + expand as u32,
            );
        } else {
            file.lines.remove(fold_up_idx);
            if remaining == 0 {
                remove_adjacent_hunk_header_if_merged(file, fold_up_idx);
            }
        }
    }

    highlight::highlight_files(&mut app.files[app.selected..=app.selected]);
    Ok(())
}

fn expand_up(app: &mut App, max_lines: usize) -> Result<()> {
    let line = &app.files[app.selected].lines[app.cursor_line];
    let new_start = line.new_lineno.unwrap_or(1);
    let old_start = line.old_lineno.unwrap_or(1);
    let total = line.fold_count as usize;
    let expand = total.min(max_lines);
    let path = app.files[app.selected].path.clone();
    let hunk_header = line.hunk_header.clone();

    let content = git::load_file_content(&app.range, &path)?;
    let file_start = (new_start as usize).saturating_sub(1);
    let skip = file_start + total - expand;

    let mut new_lines: Vec<DiffLine> = Vec::with_capacity(expand + 1);
    let remaining = total - expand;
    if remaining > 0 {
        new_lines.push(DiffLine::fold_up(
            old_start,
            new_start,
            remaining as u32,
            hunk_header,
        ));
    }

    let offset = total - expand;
    for (i, text) in content.lines().skip(skip).take(expand).enumerate() {
        new_lines.push(DiffLine::body(
            LineKind::Context,
            text.to_string(),
            Some(old_start + (offset + i) as u32),
            Some(new_start + (offset + i) as u32),
        ));
    }

    let cursor = app.cursor_line;
    let file = &mut app.files[app.selected];
    let len_before = file.lines.len();
    file.lines.splice(cursor..=cursor, new_lines);

    let fold_down_idx = cursor.checked_sub(1);
    if let Some(idx) = fold_down_idx {
        if file.lines[idx].kind == LineKind::FoldDown {
            if remaining > 20 {
                update_fold(&mut file.lines[idx], remaining as u32, old_start, new_start);
            } else if remaining > 0 {
                file.lines.remove(idx);
                if app.cursor_line > 0 {
                    app.cursor_line -= 1;
                }
            } else {
                file.lines.remove(idx);
                if app.cursor_line > 0 {
                    app.cursor_line -= 1;
                }
                remove_adjacent_hunk_header_if_merged(file, idx.saturating_sub(1));
            }
        }
    }

    let len_after = file.lines.len();
    let delta = len_after.saturating_sub(len_before);
    app.diff_scroll = app.diff_scroll.saturating_add(delta);

    highlight::highlight_files(&mut app.files[app.selected..=app.selected]);
    Ok(())
}

fn update_fold(line: &mut DiffLine, count: u32, old_start: u32, new_start: u32) {
    line.fold_count = count;
    line.old_lineno = Some(old_start);
    line.new_lineno = Some(new_start);
    let arrow = match line.kind {
        LineKind::FoldDown => "",
        LineKind::FoldUp => "",
        _ => " ",
    };
    line.text = format!("{arrow} {count} lines");
}

pub fn handle_mouse(app: &mut App, mouse: MouseEvent) {
    let col = mouse.column;
    let row = mouse.row;

    match mouse.kind {
        MouseEventKind::Down(MouseButton::Left) => {
            let fl = app.file_list_area;
            let da = app.diff_area;

            if col > fl.x && col < fl.x + fl.width - 1 && row > fl.y && row < fl.y + fl.height - 1 {
                let clicked_row = (row - fl.y - 1) as usize;
                let tree = crate::ui::file_tree_indices(
                    &app.files,
                    &app.comments,
                    &app.expanded_dirs,
                    app.file_filter.as_deref(),
                );
                if let Some(&Some(file_idx)) = tree.get(clicked_row) {
                    if file_idx < app.files.len() {
                        app.selected = file_idx;
                        app.cursor_line = app.first_navigable_line();
                        app.diff_scroll = 0;
                        app.file_tree_cursor = clicked_row;
                    }
                }
                app.focus = crate::state::Focus::Files;
            } else if col > da.x
                && col < da.x + da.width - 1
                && row > da.y
                && row < da.y + da.height - 1
            {
                let visual_row = (row - da.y - 1) as usize;
                if let Some(&target) = app.diff_row_map.get(visual_row) {
                    let (navigable, is_fold) = app
                        .current()
                        .and_then(|f| f.lines.get(target))
                        .map(|l| (l.kind.is_navigable(), l.kind.is_fold()))
                        .unwrap_or((false, false));
                    if navigable {
                        app.cursor_line = target;
                    }
                    if is_fold {
                        app.cursor_line = target;
                        app.pending_expand = Some(20);
                    }
                }
                app.focus = crate::state::Focus::Diff;
            }
        }
        MouseEventKind::ScrollDown => {
            if row >= app.diff_area.y && row < app.diff_area.y + app.diff_area.height {
                app.move_cursor_down(3);
            } else if row >= app.file_list_area.y
                && row < app.file_list_area.y + app.file_list_area.height
            {
                app.select_next_file();
            }
        }
        MouseEventKind::ScrollUp => {
            if row >= app.diff_area.y && row < app.diff_area.y + app.diff_area.height {
                app.move_cursor_up(3);
            } else if row >= app.file_list_area.y
                && row < app.file_list_area.y + app.file_list_area.height
            {
                app.select_prev_file();
            }
        }
        _ => {}
    }
}

fn remove_adjacent_hunk_header_if_merged(file: &mut crate::diff::FileDiff, check_idx: usize) {
    if check_idx >= file.lines.len() {
        return;
    }
    if file.lines[check_idx].kind == LineKind::HunkHeader {
        let before_is_context = check_idx > 0 && file.lines[check_idx - 1].kind.is_code();
        let after_is_context =
            check_idx + 1 < file.lines.len() && file.lines[check_idx + 1].kind.is_code();
        if before_is_context && after_is_context {
            file.lines.remove(check_idx);
        }
    }
}