/* diff_view.rs
 *
 * Copyright 2026 Christian Kruse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::SystemTime;

use adw::prelude::*;
use gettextrs::{gettext, ngettext};
use gtk::{gdk, glib};
use relm4::prelude::*;

use crate::diff_list_model::{DiffLineData, DiffListModel};
use crate::diff_styling::{apply_word_attrs, new_content_attr_list};
use crate::git::{self, DiffLineKind, DiffMode, FileDiff, FileStatus, strip_trailing_newline};
use crate::menus;

pub struct DiffView {
    repo_path: PathBuf,
    current_file: Option<String>,
    current_wt_status: Option<FileStatus>,
    current_file_mtime: Option<SystemTime>,
    diff_mode: DiffMode,
    diff_model: DiffListModel,
    selection_model: gtk::MultiSelection,
    sticky_header: gtk::Box,
    sticky_hunk_index: Rc<Cell<Option<usize>>>,
    context_menu_popover: gtk::PopoverMenu,
    theme_notify_handler: Option<glib::SignalHandlerId>,
}

impl Drop for DiffView {
    fn drop(&mut self) {
        if let Some(id) = self.theme_notify_handler.take() {
            adw::StyleManager::default().disconnect(id);
        }
    }
}

#[derive(Debug)]
pub enum DiffViewMsg {
    ShowFile(String, Option<FileStatus>, Option<FileStatus>),
    Clear,
    SetMode(DiffMode),
    DiffLoaded(Result<FileDiff, String>),
    RefreshIfChanged,
    StageAction(usize),
    DiscardAction(usize),
    OperationComplete(Result<(), String>),
    ScrollChanged,
    CopyFileLocation(Option<u32>),
    CopyCode(Option<u32>),
}

#[derive(Debug)]
pub enum DiffViewOutput {
    RefreshFiles,
    ShowError(String),
}

#[relm4::component(pub)]
impl Component for DiffView {
    type Init = PathBuf;
    type Input = DiffViewMsg;
    type Output = DiffViewOutput;
    type CommandOutput = DiffViewMsg;

    view! {
        gtk::Box {
            set_orientation: gtk::Orientation::Vertical,

            #[name = "header_box"]
            gtk::Box {
                set_orientation: gtk::Orientation::Horizontal,
                add_css_class: "gap-m",
                add_css_class: "diff-header",

                #[name = "file_label"]
                gtk::Label {
                    set_hexpand: true,
                    set_halign: gtk::Align::Start,
                    set_label: "",
                    set_ellipsize: gtk::pango::EllipsizeMode::Middle,
                    set_xalign: 0.0,
                    add_css_class: "title-4",
                },

                gtk::Box {
                    set_orientation: gtk::Orientation::Horizontal,
                    add_css_class: "linked",

                    #[name = "unstaged_btn"]
                    gtk::ToggleButton {
                        set_label: &gettext("Unstaged"),
                        set_active: true,
                        connect_toggled[sender] => move |btn| {
                            if btn.is_active() {
                                sender.input(DiffViewMsg::SetMode(DiffMode::Unstaged));
                            }
                        },
                    },

                    #[name = "staged_btn"]
                    gtk::ToggleButton {
                        set_label: &gettext("Staged"),
                        set_active: false,
                        connect_toggled[sender] => move |btn| {
                            if btn.is_active() {
                                sender.input(DiffViewMsg::SetMode(DiffMode::Staged));
                            }
                        },
                    },
                },
            },

            #[name = "summary_label"]
            gtk::Label {
                set_label: "",
                set_halign: gtk::Align::Start,
                add_css_class: "diff-summary",
            },

            #[name = "stack"]
            gtk::Stack {
                set_vexpand: true,

                add_named[Some("empty")] = &adw::StatusPage {
                    set_icon_name: Some("document-settings-regular-symbolic"),
                    set_title: &gettext("Diff View"),
                    set_description: Some(&gettext("Select a file to view changes")),
                },

                add_named[Some("diff")] = &gtk::Overlay {
                    #[name = "scrolled_window"]
                    gtk::ScrolledWindow {
                        set_hscrollbar_policy: gtk::PolicyType::Never,

                        #[name = "list_view"]
                        gtk::ListView {
                            add_css_class: "diff-list-view",
                        },
                    },
                },

                add_named[Some("binary")] = &adw::StatusPage {
                    set_icon_name: Some("document-question-mark-regular-symbolic"),
                    set_title: &gettext("Binary File"),
                    set_description: Some(&gettext("Cannot display diff for binary files")),
                },

                add_named[Some("too_large")] = &adw::StatusPage {
                    set_icon_name: Some("document-question-mark-regular-symbolic"),
                    set_title: &gettext("Diff Too Large"),
                    set_description: Some(&gettext("This diff has too many changes to display efficiently.")),
                },

                set_visible_child_name: "empty",
            },
        }
    }

    fn init(repo_path: Self::Init, _root: Self::Root, sender: ComponentSender<Self>) -> ComponentParts<Self> {
        let diff_model = DiffListModel::new();
        let selection_model = gtk::MultiSelection::new(Some(diff_model.clone()));
        let sticky_header = build_sticky_header();
        let sticky_hunk_index = Rc::new(Cell::new(None));

        let context_menu_popover = gtk::PopoverMenu::from_model(None::<&gtk::gio::Menu>);

        let mut model = DiffView {
            repo_path,
            current_file: None,
            current_wt_status: None,
            current_file_mtime: None,
            diff_mode: DiffMode::Unstaged,
            diff_model,
            selection_model: selection_model.clone(),
            sticky_header: sticky_header.clone(),
            sticky_hunk_index: sticky_hunk_index.clone(),
            context_menu_popover,
            theme_notify_handler: None,
        };

        let widgets = view_output!();

        widgets.staged_btn.set_group(Some(&widgets.unstaged_btn));

        // Item factory for diff lines
        let item_factory = gtk::SignalListItemFactory::new();
        setup_item_factory(&item_factory);

        // Header factory for section hunk headers
        let header_factory = gtk::SignalListItemFactory::new();
        setup_header_factory(&header_factory, sender.input_sender(), &selection_model, &model.diff_model);

        widgets.list_view.set_model(Some(&selection_model));
        widgets.list_view.set_factory(Some(&item_factory));
        widgets.list_view.set_header_factory(Some(&header_factory));

        // Re-bind every visible diff row when the system theme flips so
        // word-diff highlights pick up the matching light/dark palette.
        let model_weak = model.diff_model.downgrade();
        let handler_id = adw::StyleManager::default().connect_notify_local(Some("dark"), move |_, _| {
            if let Some(m) = model_weak.upgrade() {
                m.refresh_visible();
            }
        });
        model.theme_notify_handler = Some(handler_id);

        // Add sticky header as overlay
        let overlay = widgets
            .scrolled_window
            .parent()
            .and_downcast::<gtk::Overlay>()
            .expect("ScrolledWindow must be inside Overlay");
        overlay.add_overlay(&sticky_header);

        // Wire sticky header buttons
        let (_, sticky_discard_btn, sticky_stage_btn) = get_hunk_children(&sticky_header);

        let s = sender.input_sender().clone();
        let idx_ref = sticky_hunk_index.clone();
        sticky_discard_btn.connect_clicked(move |_btn| {
            if let Some(idx) = idx_ref.get() {
                s.emit(DiffViewMsg::DiscardAction(idx));
            }
        });

        let s = sender.input_sender().clone();
        let idx_ref = sticky_hunk_index.clone();
        sticky_stage_btn.connect_clicked(move |_btn| {
            if let Some(idx) = idx_ref.get() {
                s.emit(DiffViewMsg::StageAction(idx));
            }
        });

        // Scroll tracking
        let vadjustment = widgets.scrolled_window.vadjustment();
        let scroll_sender = sender.input_sender().clone();
        vadjustment.connect_value_changed(move |_adj| {
            scroll_sender.emit(DiffViewMsg::ScrollChanged);
        });

        model.setup_context_menu(&widgets.list_view, &sender);

        ComponentParts { model, widgets }
    }

    fn update_with_view(
        &mut self,
        widgets: &mut Self::Widgets,
        msg: Self::Input,
        sender: ComponentSender<Self>,
        _root: &Self::Root,
    ) {
        match msg {
            DiffViewMsg::ShowFile(path, index_status, wt_status) => {
                self.current_file = Some(path.clone());
                self.current_wt_status = wt_status;
                self.current_file_mtime = None;
                widgets.file_label.set_label(&path);
                widgets.file_label.set_tooltip_text(Some(&path));

                // Auto-select the appropriate diff mode based on file status
                let new_mode = match (index_status, wt_status) {
                    (Some(_), None) => Some(DiffMode::Staged),
                    (None, Some(_)) => Some(DiffMode::Unstaged),
                    _ => None, // both or neither: keep current mode
                };

                if let Some(mode) = new_mode
                    && self.diff_mode != mode
                {
                    self.diff_mode = mode;

                    match mode {
                        DiffMode::Staged => widgets.staged_btn.set_active(true),
                        DiffMode::Unstaged => widgets.unstaged_btn.set_active(true),
                    }
                }

                self.load_diff(&sender);
            }

            DiffViewMsg::Clear => {
                self.current_file = None;
                self.current_file_mtime = None;
                self.diff_model.clear();
                self.sticky_header.set_visible(false);
                self.sticky_hunk_index.set(None);
                widgets.file_label.set_label("");
                widgets.file_label.set_tooltip_text(None);
                widgets.summary_label.set_label("");
                widgets.stack.set_visible_child_name("empty");
            }

            DiffViewMsg::SetMode(mode) => {
                if self.diff_mode != mode {
                    self.diff_mode = mode;
                    self.load_diff(&sender);
                }
            }

            DiffViewMsg::DiffLoaded(result) => match result {
                Ok(diff) => {
                    self.current_file_mtime = self.file_mtime();

                    if diff.is_binary {
                        widgets.stack.set_visible_child_name("binary");
                        widgets.summary_label.set_label("");
                        self.diff_model.clear();
                        return;
                    }

                    if diff.is_too_large {
                        let summary = format!(
                            "{} {}, {} {}",
                            diff.insertions,
                            ngettext("insertion", "insertions", diff.insertions as u32),
                            diff.deletions,
                            ngettext("deletion", "deletions", diff.deletions as u32),
                        );

                        widgets.summary_label.set_label(&summary);
                        widgets.stack.set_visible_child_name("too_large");
                        self.diff_model.clear();

                        return;
                    }

                    let summary = format!(
                        "{} {}, {} {}, {} {}",
                        diff.hunks.len(),
                        ngettext("chunk", "chunks", diff.hunks.len() as u32),
                        diff.insertions,
                        ngettext("insertion", "insertions", diff.insertions as u32),
                        diff.deletions,
                        ngettext("deletion", "deletions", diff.deletions as u32),
                    );
                    widgets.summary_label.set_label(&summary);

                    let is_new_file = self.current_wt_status == Some(FileStatus::New);
                    self.diff_model.set_diff(diff, self.diff_mode, is_new_file);

                    if self.diff_model.n_items() > 0 {
                        widgets.stack.set_visible_child_name("diff");
                    } else {
                        widgets.stack.set_visible_child_name("empty");
                    }
                }
                Err(e) => {
                    tracing::error!("Failed to load diff: {e}");
                    let _ = sender.output(DiffViewOutput::ShowError(e));
                    widgets.stack.set_visible_child_name("empty");
                }
            },

            DiffViewMsg::RefreshIfChanged => {
                if self.current_file.is_some() && self.file_mtime() != self.current_file_mtime {
                    self.load_diff(&sender);
                }
            }

            DiffViewMsg::StageAction(hunk_index) => {
                self.spawn_hunk_op(hunk_index, false, &sender);
            }

            DiffViewMsg::DiscardAction(hunk_index) => {
                self.spawn_hunk_op(hunk_index, true, &sender);
            }

            DiffViewMsg::OperationComplete(result) => {
                if let Err(e) = &result {
                    tracing::error!("Staging operation failed: {e}");
                    let _ = sender.output(DiffViewOutput::ShowError(e.clone()));
                }
                let _ = sender.output(DiffViewOutput::RefreshFiles);
                self.load_diff(&sender);
            }

            DiffViewMsg::ScrollChanged => {
                self.update_sticky_header(widgets);
            }

            DiffViewMsg::CopyFileLocation(fallback_pos) => {
                let Some(file_path) = &self.current_file else {
                    return;
                };

                let line_numbers = self.collect_selected_line_numbers(fallback_pos);
                let min = line_numbers.iter().min();
                let max = line_numbers.iter().max();

                let text = match (min, max) {
                    (Some(min), Some(max)) if min != max => format!("{}:{}-{}", file_path, min, max),
                    (Some(n), _) | (_, Some(n)) => format!("{}:{}", file_path, n),
                    (None, None) => file_path.clone(),
                };

                if let Some(display) = gdk::Display::default() {
                    display.clipboard().set_text(&text);
                }
            }

            DiffViewMsg::CopyCode(fallback_pos) => {
                let lines = self.collect_selected_content(fallback_pos);
                if lines.is_empty() {
                    return;
                }

                let text = lines.join("\n");
                if let Some(display) = gdk::Display::default() {
                    display.clipboard().set_text(&text);
                }
            }
        }
    }

    fn update_cmd(&mut self, msg: Self::CommandOutput, sender: ComponentSender<Self>, _root: &Self::Root) {
        sender.input(msg);
    }
}

impl DiffView {
    fn spawn_hunk_op(&self, hunk_index: usize, discard: bool, sender: &ComponentSender<Self>) {
        let Some(file_path) = self.current_file.clone() else {
            return;
        };
        let selected = selected_lines_in_hunk(&self.selection_model, &self.diff_model, hunk_index);
        let repo_path = self.repo_path.clone();
        let mode = self.diff_mode;

        sender.spawn_oneshot_command(move || {
            let lines = if selected.is_empty() { None } else { Some(selected) };

            let result = if discard {
                match lines {
                    None => git::discard_hunk(&repo_path, &file_path, hunk_index),
                    Some(l) => git::discard_lines(&repo_path, &file_path, hunk_index, &l),
                }
            } else {
                match (mode, lines) {
                    (DiffMode::Unstaged, None) => git::stage_hunk(&repo_path, &file_path, hunk_index),

                    (DiffMode::Unstaged, Some(l)) => git::stage_lines(&repo_path, &file_path, hunk_index, &l),

                    (DiffMode::Staged, None) => git::unstage_hunk(&repo_path, &file_path, hunk_index),

                    (DiffMode::Staged, Some(l)) => git::unstage_lines(&repo_path, &file_path, hunk_index, &l),
                }
            };

            DiffViewMsg::OperationComplete(result)
        });
    }

    fn collect_selected_line_numbers(&self, fallback_pos: Option<u32>) -> Vec<u32> {
        self.gather_context_items(fallback_pos)
            .iter()
            .filter_map(|d| d.new_lineno.or(d.old_lineno))
            .collect()
    }

    fn collect_selected_content(&self, fallback_pos: Option<u32>) -> Vec<String> {
        let items = self.gather_context_items(fallback_pos);
        items
            .into_iter()
            .map(|d| strip_trailing_newline(&d.content).to_string())
            .collect()
    }

    fn gather_context_items(&self, fallback_pos: Option<u32>) -> Vec<DiffLineData> {
        let sel = self.selection_model.selection();
        let n = self.diff_model.n_items();

        if !sel.is_empty() {
            let mut items = Vec::new();

            for i in 0..n {
                if !self.selection_model.is_selected(i) {
                    continue;
                }

                if let Some(data) = self.diff_model.line_data(i) {
                    items.push(data);
                }
            }

            items
        } else if let Some(pos) = fallback_pos {
            if let Some(data) = self.diff_model.line_data(pos) {
                vec![data]
            } else {
                Vec::new()
            }
        } else {
            Vec::new()
        }
    }

    fn file_mtime(&self) -> Option<SystemTime> {
        let file_path = self.current_file.as_ref()?;

        std::fs::metadata(self.repo_path.join(file_path))
            .and_then(|m| m.modified())
            .ok()
    }

    fn load_diff(&self, sender: &ComponentSender<Self>) {
        let Some(file_path) = self.current_file.clone() else {
            return;
        };
        let repo_path = self.repo_path.clone();
        let mode = self.diff_mode;
        let wt_status = self.current_wt_status;

        sender.spawn_oneshot_command(move || {
            DiffViewMsg::DiffLoaded(git::load_file_diff(&repo_path, &file_path, mode, wt_status))
        });
    }

    fn update_sticky_header(&self, widgets: &<Self as Component>::Widgets) {
        let header = &self.sticky_header;

        // Hide for new files or empty diffs
        if self.diff_model.is_new_file() || self.diff_model.n_items() == 0 {
            header.set_visible(false);
            self.sticky_hunk_index.set(None);
            return;
        }

        let adj = widgets.scrolled_window.vadjustment();
        let upper = adj.upper();
        if upper <= 0.0 {
            header.set_visible(false);
            self.sticky_hunk_index.set(None);
            return;
        }

        // Hide when scrolled to top (first section header is visible)
        if adj.value() < 1.0 {
            header.set_visible(false);
            self.sticky_hunk_index.set(None);
            return;
        }

        // Estimate which item is at the top of the viewport
        let n = self.diff_model.n_items() as f64;
        let estimated_pos = ((adj.value() / upper) * n).min(n - 1.0).max(0.0) as u32;

        let Some(info) = self.diff_model.hunk_info_for_position(estimated_pos) else {
            header.set_visible(false);
            self.sticky_hunk_index.set(None);
            return;
        };

        // Only update widgets if hunk changed
        if self.sticky_hunk_index.get() != Some(info.hunk_index) {
            self.sticky_hunk_index.set(Some(info.hunk_index));

            let (label, discard_btn, stage_btn) = get_hunk_children(header);
            label.set_label(&info.header);
            update_hunk_buttons(&stage_btn, &discard_btn, false, info.mode);
        }

        header.set_visible(true);
    }

    fn setup_context_menu(&self, list_view: &gtk::ListView, sender: &ComponentSender<Self>) {
        self.context_menu_popover.set_parent(list_view);
        self.context_menu_popover.set_has_arrow(false);
        let popover = self.context_menu_popover.clone();

        let group = gtk::gio::SimpleActionGroup::new();

        let s = sender.input_sender().clone();
        let copy_location = gtk::gio::SimpleAction::new("copy-location", Some(glib::VariantTy::UINT32));
        copy_location.connect_activate(move |_, param| {
            let pos = param.and_then(|v| v.get::<u32>());
            s.emit(DiffViewMsg::CopyFileLocation(pos));
        });
        group.add_action(&copy_location);

        let s = sender.input_sender().clone();
        let copy_code = gtk::gio::SimpleAction::new("copy-code", Some(glib::VariantTy::UINT32));
        copy_code.connect_activate(move |_, param| {
            let pos = param.and_then(|v| v.get::<u32>());
            s.emit(DiffViewMsg::CopyCode(pos));
        });
        group.add_action(&copy_code);

        list_view.insert_action_group("diff-line", Some(&group));

        let gesture = gtk::GestureClick::new();
        gesture.set_button(gdk::BUTTON_SECONDARY);

        let list_view_for_gesture = list_view.clone();
        let sel_model = self.selection_model.clone();

        gesture.connect_released(move |gesture, _, x, y| {
            gesture.set_state(gtk::EventSequenceState::Claimed);

            let fallback_pos = if sel_model.selection().is_empty() {
                find_item_position_at(&list_view_for_gesture, x, y)
            } else {
                None
            };

            let menu = menus::diff_line_context_menu(fallback_pos);
            popover.set_menu_model(Some(&menu));

            let rect = gdk::Rectangle::new(x as i32, y as i32, 1, 1);
            popover.set_pointing_to(Some(&rect));
            popover.popup();
        });

        list_view.add_controller(gesture);
    }
}

fn build_hunk_header_box() -> gtk::Box {
    let hunk_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
    hunk_box.add_css_class("diff-hunk-header");

    let label = gtk::Label::new(None);
    label.set_hexpand(true);
    label.set_halign(gtk::Align::Start);
    label.set_width_request(0);
    label.set_ellipsize(gtk::pango::EllipsizeMode::End);
    label.add_css_class("diff-content");
    hunk_box.append(&label);

    let discard_btn = gtk::Button::with_label(&gettext("Discard Chunk"));
    discard_btn.add_css_class("destructive-action");
    hunk_box.append(&discard_btn);

    let stage_btn = gtk::Button::with_label(&gettext("Stage Chunk"));
    stage_btn.add_css_class("suggested-action");
    hunk_box.append(&stage_btn);

    hunk_box
}

fn build_sticky_header() -> gtk::Box {
    let hunk_box = build_hunk_header_box();
    hunk_box.set_valign(gtk::Align::Start);
    hunk_box.set_halign(gtk::Align::Fill);
    hunk_box.set_hexpand(true);
    hunk_box.set_visible(false);

    hunk_box
}

fn get_hunk_children(hunk_box: &gtk::Box) -> (gtk::Label, gtk::Button, gtk::Button) {
    let label = hunk_box
        .first_child()
        .and_downcast::<gtk::Label>()
        .expect("hunk header must have a label as first child");

    let discard_btn = label
        .next_sibling()
        .and_downcast::<gtk::Button>()
        .expect("hunk header must have discard button after label");

    let stage_btn = discard_btn
        .next_sibling()
        .and_downcast::<gtk::Button>()
        .expect("hunk header must have stage button after discard button");

    (label, discard_btn, stage_btn)
}

fn selected_lines_in_hunk(
    selection_model: &gtk::MultiSelection,
    diff_model: &DiffListModel,
    target_hunk: usize,
) -> Vec<usize> {
    let mut result = Vec::new();
    let n = diff_model.n_items();

    for i in 0..n {
        if !selection_model.is_selected(i) {
            continue;
        }

        let Some(data) = diff_model.line_data(i) else {
            continue;
        };

        if data.hunk_index == target_hunk && data.kind != DiffLineKind::Context {
            result.push(data.line_index);
        }
    }

    result
}

fn update_hunk_buttons(stage_btn: &gtk::Button, discard_btn: &gtk::Button, has_selection: bool, mode: DiffMode) {
    match mode {
        DiffMode::Unstaged => {
            discard_btn.set_visible(true);

            if has_selection {
                stage_btn.set_label(&gettext("Stage Lines"));
                discard_btn.set_label(&gettext("Discard Lines"));
            } else {
                stage_btn.set_label(&gettext("Stage Chunk"));
                discard_btn.set_label(&gettext("Discard Chunk"));
            }
        }

        DiffMode::Staged => {
            discard_btn.set_visible(false);

            if has_selection {
                stage_btn.set_label(&gettext("Unstage Lines"));
            } else {
                stage_btn.set_label(&gettext("Unstage Chunk"));
            }
        }
    }
}

fn setup_item_factory(factory: &gtk::SignalListItemFactory) {
    factory.connect_setup(|_, list_item| {
        let list_item = list_item.downcast_ref::<gtk::ListItem>().expect("Must be ListItem");

        let line_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);

        let old_lineno = gtk::Label::new(None);
        old_lineno.add_css_class("diff-line-number");
        old_lineno.set_halign(gtk::Align::End);
        old_lineno.set_width_chars(5);
        line_box.append(&old_lineno);

        line_box.append(&gtk::Separator::new(gtk::Orientation::Vertical));

        let new_lineno = gtk::Label::new(None);
        new_lineno.add_css_class("diff-line-number");
        new_lineno.set_halign(gtk::Align::End);
        new_lineno.set_width_chars(5);
        line_box.append(&new_lineno);

        line_box.append(&gtk::Separator::new(gtk::Orientation::Vertical));

        let content = gtk::Label::new(None);
        content.add_css_class("diff-content");
        content.set_hexpand(true);
        content.set_halign(gtk::Align::Start);
        content.set_selectable(false);
        content.set_width_request(0);
        content.set_wrap(true);
        content.set_wrap_mode(gtk::pango::WrapMode::WordChar);
        content.set_natural_wrap_mode(gtk::NaturalWrapMode::Inherit);

        let attr_list = gtk::pango::AttrList::new();
        attr_list.insert(gtk::pango::AttrInt::new_insert_hyphens(false));
        content.set_attributes(Some(&attr_list));

        line_box.append(&content);

        list_item.set_child(Some(&line_box));
    });

    factory.connect_bind(|_, list_item| {
        let list_item = list_item.downcast_ref::<gtk::ListItem>().expect("Must be ListItem");

        let Some(obj) = list_item.item() else { return };
        let Some(boxed) = obj.downcast_ref::<glib::BoxedAnyObject>() else {
            return;
        };
        let data = boxed.borrow::<DiffLineData>();

        let line_box = list_item.child().and_downcast::<gtk::Box>().expect("Must be Box");
        line_box.set_widget_name(&format!("diff-line-{}", list_item.position()));

        match data.kind {
            DiffLineKind::Addition => line_box.add_css_class("diff-addition"),
            DiffLineKind::Deletion => line_box.add_css_class("diff-deletion"),
            DiffLineKind::Context => {}
        }

        // Navigate children: old_lineno, sep, new_lineno, sep, content
        let Some(old_label) = line_box.first_child().and_downcast::<gtk::Label>() else {
            return;
        };
        let Some(sep1) = old_label.next_sibling() else { return };
        let Some(new_label) = sep1.next_sibling().and_downcast::<gtk::Label>() else {
            return;
        };
        let Some(sep2) = new_label.next_sibling() else { return };
        let Some(content_label) = sep2.next_sibling().and_downcast::<gtk::Label>() else {
            return;
        };

        old_label.set_label(&data.old_lineno.map(|n| n.to_string()).unwrap_or_default());
        new_label.set_label(&data.new_lineno.map(|n| n.to_string()).unwrap_or_default());
        let content_text = strip_trailing_newline(&data.content);
        content_label.set_label(content_text);

        let attr_list = new_content_attr_list();
        let is_dark = adw::StyleManager::default().is_dark();
        apply_word_attrs(&attr_list, data.kind, data.word_ranges.as_deref(), content_text.len(), is_dark);

        content_label.set_attributes(Some(&attr_list));
    });

    factory.connect_unbind(|_, list_item| {
        let list_item = list_item.downcast_ref::<gtk::ListItem>().expect("Must be ListItem");

        if let Some(line_box) = list_item.child().and_downcast::<gtk::Box>() {
            line_box.remove_css_class("diff-addition");
            line_box.remove_css_class("diff-deletion");
        }
    });
}

fn setup_header_factory(
    factory: &gtk::SignalListItemFactory,
    sender: &relm4::Sender<DiffViewMsg>,
    selection_model: &gtk::MultiSelection,
    diff_model: &DiffListModel,
) {
    factory.connect_setup(|_, list_header| {
        let list_header = list_header
            .downcast_ref::<gtk::ListHeader>()
            .expect("Must be ListHeader");

        list_header.set_child(Some(&build_hunk_header_box()));
    });

    let handler_map: Rc<RefCell<HashMap<gtk::ListHeader, glib::SignalHandlerId>>> =
        Rc::new(RefCell::new(HashMap::new()));
    let handler_map_bind = handler_map.clone();
    let handler_map_unbind = handler_map;

    let sender_clone = sender.clone();
    let sel_model = selection_model.clone();
    let model = diff_model.clone();

    factory.connect_bind(move |_, list_header| {
        let list_header = list_header
            .downcast_ref::<gtk::ListHeader>()
            .expect("Must be ListHeader");

        bind_header(list_header, &model, &sel_model, &sender_clone, &handler_map_bind);
    });

    let sel_model_unbind = selection_model.clone();
    factory.connect_unbind(move |_, list_header| {
        let list_header = list_header
            .downcast_ref::<gtk::ListHeader>()
            .expect("Must be ListHeader");

        unbind_header(list_header, &sel_model_unbind, &handler_map_unbind);
    });
}

fn bind_header(
    list_header: &gtk::ListHeader,
    model: &DiffListModel,
    sel_model: &gtk::MultiSelection,
    sender: &relm4::Sender<DiffViewMsg>,
    handler_map: &Rc<RefCell<HashMap<gtk::ListHeader, glib::SignalHandlerId>>>,
) {
    let Some(hunk_box) = list_header.child().and_downcast::<gtk::Box>() else {
        return;
    };

    if model.is_new_file() {
        hunk_box.set_visible(false);
        return;
    }

    hunk_box.set_visible(true);

    let Some(info) = model.hunk_info_for_position(list_header.start()) else {
        return;
    };

    let (label, discard_btn, stage_btn) = get_hunk_children(&hunk_box);
    label.set_label(&info.header);

    let hunk_idx = info.hunk_index;
    let mode = info.mode;

    let has_sel = !selected_lines_in_hunk(sel_model, model, hunk_idx).is_empty();
    update_hunk_buttons(&stage_btn, &discard_btn, has_sel, mode);

    // Dynamic button label updates on selection change
    let sel_for_handler = sel_model.clone();
    let model_for_handler = model.clone();
    let stage_btn_weak = stage_btn.downgrade();
    let discard_btn_weak = discard_btn.downgrade();

    let handler_id = sel_model.connect_selection_changed(move |_, _, _| {
        let Some(sb) = stage_btn_weak.upgrade() else {
            return;
        };
        let Some(db) = discard_btn_weak.upgrade() else {
            return;
        };

        let has = !selected_lines_in_hunk(&sel_for_handler, &model_for_handler, hunk_idx).is_empty();

        update_hunk_buttons(&sb, &db, has, mode);
    });

    handler_map.borrow_mut().insert(list_header.clone(), handler_id);

    let s = sender.clone();
    let s2 = s.clone();

    let gesture_discard = gtk::GestureClick::new();
    gesture_discard.connect_released(move |_, _, _, _| {
        s2.emit(DiffViewMsg::DiscardAction(hunk_idx));
    });
    clear_gesture_controllers(&discard_btn);
    discard_btn.add_controller(gesture_discard);

    let gesture_stage = gtk::GestureClick::new();
    gesture_stage.connect_released(move |_, _, _, _| {
        s.emit(DiffViewMsg::StageAction(hunk_idx));
    });
    clear_gesture_controllers(&stage_btn);
    stage_btn.add_controller(gesture_stage);
}

fn unbind_header(
    list_header: &gtk::ListHeader,
    sel_model: &gtk::MultiSelection,
    handler_map: &Rc<RefCell<HashMap<gtk::ListHeader, glib::SignalHandlerId>>>,
) {
    if let Some(handler_id) = handler_map.borrow_mut().remove(list_header) {
        sel_model.disconnect(handler_id);
    }
}

fn clear_gesture_controllers(widget: &impl IsA<gtk::Widget>) {
    let controllers = widget.as_ref().observe_controllers();
    let mut to_remove = Vec::new();

    for i in 0..controllers.n_items() {
        if let Some(obj) = controllers.item(i)
            && let Ok(gesture) = obj.downcast::<gtk::GestureClick>()
        {
            to_remove.push(gesture);
        }
    }

    for c in to_remove {
        widget.as_ref().remove_controller(&c);
    }
}

fn find_item_position_at(list_view: &gtk::ListView, x: f64, y: f64) -> Option<u32> {
    let picked = list_view.pick(x, y, gtk::PickFlags::DEFAULT)?;

    let mut current = Some(picked);
    while let Some(ref w) = current {
        if w == list_view.upcast_ref::<gtk::Widget>() {
            break;
        }

        let name = w.widget_name();

        if let Some(pos_str) = name.strip_prefix("diff-line-") {
            return pos_str.parse::<u32>().ok();
        }

        current = w.parent();
    }

    None
}
