/* commit_log_view/dragging.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::RefCell;
use std::collections::HashSet;
use std::rc::Rc;

use adw::prelude::*;
use gettextrs::gettext;
use gtk::gdk;
use relm4::gtk::glib;
use relm4::prelude::*;

use super::{CommitItem, CommitListModel, CommitLogView, CommitLogViewMsg, CommitLogViewOutput};

const HIGHLIGHT_CLASSES: [&str; 2] = ["drop-target", "drop-invalid"];

#[derive(Default)]
pub(super) struct DragState {
    source_oids: Vec<String>,
    next_oid: Option<String>,
    forbidden: HashSet<String>,
    reject_drag: bool,
    highlighted: Option<glib::WeakRef<gtk::Box>>,
    no_drop_overlay: Option<glib::WeakRef<gtk::Image>>,
}

impl DragState {
    pub(super) fn clear(&mut self) {
        self.clear_highlight();
        self.source_oids.clear();
        self.next_oid = None;
        self.forbidden.clear();
        self.reject_drag = false;
        self.no_drop_overlay = None;
    }

    fn set_no_drop_overlay_visible(&self, visible: bool) {
        if let Some(weak) = self.no_drop_overlay.as_ref()
            && let Some(image) = weak.upgrade()
        {
            image.set_visible(visible);
        }
    }

    fn is_drop_noop(&self, target_oid: &str) -> bool {
        if target_oid.is_empty() {
            return true;
        }

        if self.reject_drag {
            return true;
        }

        if self.source_oids.iter().any(|s| s == target_oid) {
            return true;
        }

        if self.next_oid.as_deref() == Some(target_oid) {
            return true;
        }

        if self.forbidden.contains(target_oid) {
            return true;
        }

        false
    }

    fn set_highlight(&mut self, row: &gtk::Box, class: &'static str) {
        if let Some(prev) = self.highlighted.take().and_then(|w| w.upgrade())
            && &prev != row
        {
            for c in HIGHLIGHT_CLASSES {
                prev.remove_css_class(c);
            }
        }

        for c in HIGHLIGHT_CLASSES {
            if c != class {
                row.remove_css_class(c);
            }
        }

        row.add_css_class(class);

        let weak = glib::WeakRef::new();
        weak.set(Some(row));
        self.highlighted = Some(weak);
    }

    fn clear_highlight(&mut self) {
        if let Some(row) = self.highlighted.take().and_then(|w| w.upgrade()) {
            for c in HIGHLIGHT_CLASSES {
                row.remove_css_class(c);
            }
        }
    }
}

fn display_oids(list_view: &CommitListModel) -> Vec<String> {
    let n = list_view.len();
    let mut out = Vec::with_capacity(n as usize);

    for i in 0..n {
        if let Some(obj) = list_view.get(i) {
            out.push(obj.borrow::<CommitItem>().commit.oid.clone());
        }
    }

    out
}

fn first_merge_position(list_view: &CommitListModel) -> Option<usize> {
    let n = list_view.len();

    for i in 0..n {
        if let Some(obj) = list_view.get(i)
            && obj.borrow::<CommitItem>().commit.is_merge
        {
            return Some(i as usize);
        }
    }

    None
}

fn selected_positions(list_view: &CommitListModel) -> Vec<u32> {
    let bitset = list_view.selection_model.selection();

    let Some((iter, first)) = gtk::BitsetIter::init_first(&bitset) else {
        return Vec::new();
    };

    let mut result = vec![first];
    result.extend(iter);

    result
}

fn block_for_drag(list_view: &CommitListModel, source_oid: &str, display: &[String]) -> Result<Vec<String>, ()> {
    let positions = selected_positions(list_view);
    let Some(source_pos) = display.iter().position(|o| o == source_oid).map(|p| p as u32) else {
        return Ok(vec![source_oid.to_string()]);
    };

    if positions.len() < 2 {
        return Ok(vec![source_oid.to_string()]);
    }

    let mut sorted = positions.clone();
    sorted.sort_unstable();

    let contiguous = sorted.windows(2).all(|w| w[1] == w[0] + 1);

    if !contiguous {
        return Err(());
    }

    if !sorted.contains(&source_pos) {
        return Ok(vec![source_oid.to_string()]);
    }

    let block: Vec<String> = sorted
        .into_iter()
        .filter_map(|p| display.get(p as usize).cloned())
        .collect();

    Ok(block)
}

fn init_drag_state(state: &mut DragState, list_view: &CommitListModel, source_oid: &str) {
    state.clear();

    let display = display_oids(list_view);

    let block = match block_for_drag(list_view, source_oid, &display) {
        Ok(b) => b,

        Err(()) => {
            state.reject_drag = true;
            return;
        }
    };

    let last_pos = block.last().and_then(|oid| display.iter().position(|o| o == oid));
    let next = last_pos.and_then(|i| display.get(i + 1).cloned());

    let merge_pos = first_merge_position(list_view);

    let mut forbidden: HashSet<String> = match merge_pos {
        Some(m) => display.iter().skip(m + 1).cloned().collect(),
        None => HashSet::new(),
    };

    if let Some(m) = merge_pos {
        let any_at_or_below = block
            .iter()
            .filter_map(|oid| display.iter().position(|o| o == oid))
            .any(|p| p >= m);

        if any_at_or_below && let Some(merge_oid) = display.get(m).cloned() {
            forbidden.insert(merge_oid);
        }
    }

    state.source_oids = block;
    state.next_oid = next;
    state.forbidden = forbidden;
}

impl CommitLogView {
    pub(super) fn handle_reorder_drop(&mut self, _dragged_oid: &str, target_oid: &str, sender: &ComponentSender<Self>) {
        let block = self.drag_state.borrow().source_oids.clone();
        if block.is_empty() {
            return;
        }

        let display = self.display_oids();

        if block.iter().any(|o| o == target_oid) {
            return;
        }

        let Some(p_t) = display.iter().position(|o| o == target_oid) else {
            return;
        };

        let mut block_positions: Vec<usize> = block
            .iter()
            .filter_map(|oid| display.iter().position(|o| o == oid))
            .collect();
        if block_positions.len() != block.len() {
            return;
        }
        block_positions.sort_unstable();

        if !block_positions.windows(2).all(|w| w[1] == w[0] + 1) {
            return;
        }

        let block_start = block_positions[0];
        let block_end = block_positions[block_positions.len() - 1];

        let mut new_display = display.clone();

        for &pos in block_positions.iter().rev() {
            new_display.remove(pos);
        }

        let mut insert_pos = if p_t > block_end {
            p_t - block.len()
        } else if p_t < block_start {
            p_t
        } else {
            return;
        };

        if insert_pos > new_display.len() {
            insert_pos = new_display.len();
        }

        for (i, oid) in block.iter().enumerate() {
            new_display.insert(insert_pos + i, oid.clone());
        }

        if new_display == display {
            return;
        }

        let mut p_max = 0usize;
        for i in 0..display.len() {
            if display[i] != new_display[i] {
                p_max = i;
            }
        }

        let new_order: Vec<String> = new_display[..=p_max].iter().rev().cloned().collect();

        // Remember where the dragged block ends up so we can re-select it after the post-reorder
        // refresh repopulates the list with new OIDs.
        self.pending_select_range = Some((insert_pos as u32, block.len() as u32));

        sender.output(CommitLogViewOutput::ReorderRequested { new_order }).ok();
    }

    fn display_oids(&self) -> Vec<String> {
        display_oids(&self.list_view)
    }
}

pub(super) fn attach_drag_source(
    root: &gtk::Box,
    drag_state: Rc<RefCell<DragState>>,
    list_view: CommitListModel,
    sender: relm4::Sender<CommitLogViewMsg>,
) {
    let drag_source = gtk::DragSource::new();
    drag_source.set_actions(gdk::DragAction::MOVE);

    drag_source.connect_prepare({
        let root_weak = root.downgrade();
        let drag_state = drag_state.clone();
        let list_view = list_view.clone();

        move |_src, _x, _y| {
            let r = root_weak.upgrade()?;
            let oid = r.widget_name().to_string();

            if oid.is_empty() {
                return None;
            }

            init_drag_state(&mut drag_state.borrow_mut(), &list_view, &oid);

            Some(gdk::ContentProvider::for_value(&oid.to_value()))
        }
    });

    drag_source.connect_drag_begin({
        let root_for_begin = root.clone();
        let drag_state = drag_state.clone();
        let sender = sender.clone();

        move |_src, drag| {
            let row_paintable = gtk::WidgetPaintable::new(Some(&root_for_begin));
            let row_image = gtk::Picture::for_paintable(&row_paintable);
            row_image.set_can_shrink(false);

            let overlay = gtk::Overlay::new();
            overlay.set_child(Some(&row_image));

            let no_drop = gtk::Image::from_icon_name("action-unavailable-symbolic");
            no_drop.set_pixel_size(32);
            no_drop.set_halign(gtk::Align::Start);
            no_drop.set_valign(gtk::Align::Center);
            no_drop.add_css_class("ms-m");
            no_drop.add_css_class("drag-no-drop");
            no_drop.set_visible(false);
            overlay.add_overlay(&no_drop);

            let (block_len, reject) = {
                let s = drag_state.borrow();
                (s.source_oids.len(), s.reject_drag)
            };

            if block_len > 1 {
                let badge = gtk::Label::new(Some(&format!("+{}", block_len - 1)));
                badge.add_css_class("badge");
                badge.add_css_class("drag-block-badge");
                badge.set_halign(gtk::Align::End);
                badge.set_valign(gtk::Align::Start);
                badge.add_css_class("mt-xs");
                badge.add_css_class("me-xs");
                overlay.add_overlay(&badge);
            }

            let drag_icon = gtk::DragIcon::for_drag(drag);
            drag_icon.set_child(Some(&overlay));
            drag.set_hotspot(0, 0);

            drag_state.borrow_mut().no_drop_overlay = Some(no_drop.downgrade());

            if reject {
                sender.emit(CommitLogViewMsg::ShowToast(gettext("Cannot reorder non-consecutive commits")));
            }
        }
    });

    drag_source.connect_drag_end({
        let drag_state = drag_state.clone();

        move |_src, _drag, _delivered| {
            drag_state.borrow_mut().clear();
        }
    });

    root.add_controller(drag_source);
}

pub(super) fn attach_drop_target(
    root: &gtk::Box,
    drag_state: Rc<RefCell<DragState>>,
    sender: relm4::Sender<CommitLogViewMsg>,
) {
    let drop_target = gtk::DropTarget::new(glib::Type::STRING, gdk::DragAction::MOVE);

    drop_target.connect_motion({
        let root_weak = root.downgrade();
        let drag_state = drag_state.clone();

        move |_t, _x, _y| {
            let Some(r) = root_weak.upgrade() else {
                return gdk::DragAction::empty();
            };

            let target_oid = r.widget_name().to_string();
            let mut state = drag_state.borrow_mut();

            if state.is_drop_noop(&target_oid) {
                state.set_highlight(&r, "drop-invalid");
                state.set_no_drop_overlay_visible(true);

                return gdk::DragAction::empty();
            }

            state.set_highlight(&r, "drop-target");
            state.set_no_drop_overlay_visible(false);

            gdk::DragAction::MOVE
        }
    });

    drop_target.connect_drop({
        let root_weak = root.downgrade();
        move |_target, value, _x, _y| {
            let Some(r) = root_weak.upgrade() else {
                return false;
            };

            let Ok(dragged_oid) = value.get::<String>() else {
                return false;
            };

            let target_oid = r.widget_name().to_string();

            if drag_state.borrow().is_drop_noop(&target_oid) {
                return false;
            }

            drag_state.borrow_mut().clear_highlight();

            sender.emit(CommitLogViewMsg::ReorderDrop {
                dragged_oid,
                target_oid,
            });

            true
        }
    });

    root.add_controller(drop_target);
}
