/* file_diff_row.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
 */

mod gutter;

use adw::prelude::*;
use gettextrs::gettext;
use gtk::{gdk, glib};
use gutter::attach_gutter;
use relm4::factory::FactoryComponent;
use relm4::prelude::*;

use crate::diff_styling::{DiffTags, apply_word_tags};
use crate::git::{DiffLineKind, FileDiff, strip_trailing_newline};
use crate::menus;

type LineIndex = Vec<Option<(usize, usize)>>;
type GutterLines = Vec<(Option<u32>, Option<u32>)>;

#[derive(Debug)]
pub struct FileDiffRow {
    file_diff: FileDiff,
    gutter_digits: usize,
    expanded: bool,
    tags: Option<DiffTags>,
    text_view: Option<gtk::TextView>,
    line_index: LineIndex,
    theme_notify_handler: Option<glib::SignalHandlerId>,
}

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

#[derive(Debug)]
pub struct FileDiffRowInit {
    pub file_diff: FileDiff,
    pub gutter_digits: usize,
}

pub fn gutter_digits_for(file_diffs: &[FileDiff]) -> usize {
    let max_line = file_diffs
        .iter()
        .flat_map(|f| f.hunks.iter())
        .flat_map(|h| h.lines.iter())
        .filter_map(|l| l.new_lineno.or(l.old_lineno))
        .max()
        .unwrap_or(0);

    if max_line == 0 {
        1
    } else {
        (max_line.ilog10() + 1) as usize
    }
}

#[derive(Debug)]
pub enum FileDiffRowMsg {
    ToggleExpanded,
    ThemeChanged,
    CopyFileLocation,
}

#[relm4::factory(pub)]
impl FactoryComponent for FileDiffRow {
    type Init = FileDiffRowInit;
    type Input = FileDiffRowMsg;
    type Output = ();
    type CommandOutput = ();
    type ParentWidget = gtk::Box;

    view! {
        #[root]
        gtk::Box {
            set_orientation: gtk::Orientation::Vertical,
            add_css_class: "card",

            gtk::Button {
                add_css_class: "flat",
                add_css_class: "diff-header",
                #[watch]
                set_tooltip_text: Some(&if self.expanded {
                    gettext("Collapse file diff")
                } else {
                    gettext("Expand file diff")
                }),
                #[watch]
                update_state: &[gtk::accessible::State::Expanded(Some(self.expanded))],
                connect_clicked[sender] => move |_| {
                    sender.input(FileDiffRowMsg::ToggleExpanded);
                },

                #[wrap(Some)]
                set_child = &gtk::Box {
                    set_orientation: gtk::Orientation::Horizontal,
                    add_css_class: "gap-xs",
                    add_css_class: "m-m",

                    #[name = "chevron"]
                    gtk::Image {
                        #[watch]
                        set_icon_name: Some(if self.expanded { "pan-down-symbolic" } else { "pan-end-symbolic" }),
                        set_accessible_role: gtk::AccessibleRole::Presentation,
                    },

                    gtk::Label {
                        set_label: &self.file_diff.path,
                        set_halign: gtk::Align::Start,
                        add_css_class: "title-4",
                        set_use_markup: false,
                    },
                },
            },

            #[name = "content_box"]
            gtk::Box {
                set_orientation: gtk::Orientation::Vertical,
                #[watch]
                set_visible: self.expanded,
            },
        }
    }

    fn init_model(init: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
        Self {
            file_diff: init.file_diff,
            gutter_digits: init.gutter_digits,
            expanded: true,
            tags: None,
            text_view: None,
            line_index: Vec::new(),
            theme_notify_handler: None,
        }
    }

    fn init_widgets(
        &mut self,
        _index: &DynamicIndex,
        root: Self::Root,
        _returned_widget: &<Self::ParentWidget as relm4::factory::FactoryView>::ReturnedWidget,
        sender: FactorySender<Self>,
    ) -> Self::Widgets {
        let widgets = view_output!();

        let content_box = &widgets.content_box;

        if self.file_diff.is_binary {
            Self::append_binary_placeholder(content_box);
            return widgets;
        }

        Self::append_summary(content_box, &self.file_diff);

        let (buffer, tags, line_index, gutter_lines) = self.build_buffer();
        let text_view = Self::build_text_view(&buffer);

        attach_gutter(&text_view, gutter_lines, self.gutter_digits.max(1));
        Self::install_context_menu(&text_view, &sender);
        content_box.append(&text_view);

        let handler_id = Self::connect_theme_changes(&sender);

        self.tags = Some(tags);
        self.text_view = Some(text_view);
        self.line_index = line_index;
        self.theme_notify_handler = Some(handler_id);

        widgets
    }

    fn update(&mut self, msg: Self::Input, _sender: FactorySender<Self>) {
        match msg {
            FileDiffRowMsg::ToggleExpanded => {
                self.expanded = !self.expanded;
            }

            FileDiffRowMsg::ThemeChanged => {
                if let Some(tags) = &self.tags {
                    tags.refresh_colors(adw::StyleManager::default().is_dark());
                }
            }

            FileDiffRowMsg::CopyFileLocation => {
                let Some(tv) = self.text_view.as_ref() else { return };
                let buffer = tv.buffer();
                let (start, end) = match buffer.selection_bounds() {
                    Some((s, e)) => (s, e),

                    None => {
                        let cursor = buffer.iter_at_mark(&buffer.get_insert());
                        (cursor, cursor)
                    }
                };

                let s_line = start.line() as usize;
                let mut e_line = end.line() as usize;
                if end.line_offset() == 0 && e_line > s_line {
                    e_line -= 1;
                }

                let mut nums: Vec<u32> = Vec::new();
                for line in s_line..=e_line {
                    if let Some(Some((h, l))) = self.line_index.get(line) {
                        let dl = &self.file_diff.hunks[*h].lines[*l];
                        if let Some(n) = dl.new_lineno.or(dl.old_lineno) {
                            nums.push(n);
                        }
                    }
                }

                let path = &self.file_diff.path;
                let text = match (nums.iter().min(), nums.iter().max()) {
                    (Some(min), Some(max)) if min != max => format!("{path}:{min}-{max}"),
                    (Some(n), _) | (_, Some(n)) => format!("{path}:{n}"),
                    (None, None) => path.clone(),
                };

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

impl FileDiffRow {
    fn append_binary_placeholder(content_box: &gtk::Box) {
        let binary_label = gtk::Label::new(Some(&gettext("Binary file")));
        binary_label.set_halign(gtk::Align::Start);
        binary_label.add_css_class("dim-label");
        binary_label.add_css_class("m-m");

        content_box.append(&binary_label);
    }

    fn append_summary(content_box: &gtk::Box, file_diff: &FileDiff) {
        let summary = gtk::Label::new(Some(&format!("+{} -{}", file_diff.insertions, file_diff.deletions)));
        summary.set_halign(gtk::Align::Start);
        summary.add_css_class("dim-label");
        summary.add_css_class("ms-m");
        summary.add_css_class("mb-xs");

        content_box.append(&summary);
    }

    fn build_buffer(&self) -> (gtk::TextBuffer, DiffTags, LineIndex, GutterLines) {
        let is_dark = adw::StyleManager::default().is_dark();

        let buffer = gtk::TextBuffer::new(None);
        let tags = DiffTags::install(&buffer, is_dark);

        let mut line_index: LineIndex = Vec::new();
        let mut gutter_lines: GutterLines = Vec::new();

        let mut iter = buffer.end_iter();

        for (h_idx, hunk) in self.file_diff.hunks.iter().enumerate() {
            let header_text = format!("{}\n", hunk.header);
            let start_offset = iter.offset();
            buffer.insert(&mut iter, &header_text);

            let start = buffer.iter_at_offset(start_offset);
            buffer.apply_tag(&tags.hunk_header, &start, &iter);
            line_index.push(None);

            gutter_lines.push((None, None));

            for (l_idx, line) in hunk.lines.iter().enumerate() {
                let raw_text = strip_trailing_newline(&line.content);
                let line_with_nl = format!("{}\n", raw_text);
                let line_start_offset = iter.offset();

                buffer.insert(&mut iter, &line_with_nl);

                let line_start = buffer.iter_at_offset(line_start_offset);

                let bg_tag = match line.kind {
                    DiffLineKind::Addition => Some(&tags.addition),
                    DiffLineKind::Deletion => Some(&tags.deletion),
                    DiffLineKind::Context => None,
                };

                if let Some(t) = bg_tag {
                    buffer.apply_tag(t, &line_start, &iter);
                }

                apply_word_tags(&buffer, &tags, line.kind, line.word_ranges.as_deref(), raw_text, line_start_offset);

                line_index.push(Some((h_idx, l_idx)));
                gutter_lines.push((line.old_lineno, line.new_lineno));
            }
        }

        (buffer, tags, line_index, gutter_lines)
    }

    fn build_text_view(buffer: &gtk::TextBuffer) -> gtk::TextView {
        let text_view = gtk::TextView::with_buffer(buffer);

        text_view.set_editable(false);
        text_view.set_cursor_visible(true);
        text_view.set_monospace(true);
        text_view.set_wrap_mode(gtk::WrapMode::WordChar);
        text_view.set_hexpand(true);
        text_view.set_vexpand(false);
        text_view.set_top_margin(4);
        text_view.set_bottom_margin(4);
        text_view.set_left_margin(6);
        text_view.set_right_margin(8);
        text_view.add_css_class("diff-content");

        text_view
    }

    fn install_context_menu(text_view: &gtk::TextView, sender: &FactorySender<Self>) {
        let group = gtk::gio::SimpleActionGroup::new();

        let copy_loc = gtk::gio::SimpleAction::new("copy-location", None);
        let s = sender.input_sender().clone();
        copy_loc.connect_activate(move |_, _| {
            let _ = s.send(FileDiffRowMsg::CopyFileLocation);
        });
        group.add_action(&copy_loc);

        text_view.insert_action_group("diff-line", Some(&group));
        text_view.set_extra_menu(Some(&menus::diff_line_extra_menu()));
    }

    fn connect_theme_changes(sender: &FactorySender<Self>) -> glib::SignalHandlerId {
        let input_sender = sender.input_sender().clone();

        adw::StyleManager::default().connect_notify_local(Some("dark"), move |_, _| {
            let _ = input_sender.send(FileDiffRowMsg::ThemeChanged);
        })
    }
}
