// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation.
//
// 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only

use adw::{
    gio,
    glib::{self, clone},
    gtk::{
        self, cairo,
        graphene::{self, Point},
        gsk,
    },
    prelude::*,
    subclass::prelude::*,
};

use std::cmp::Ordering;

use super::{Link, Node, Port};
use crate::NodeType;

const CANVAS_SIZE: f64 = 5000.0;

mod imp {
    use super::*;

    use std::cell::{Cell, RefCell};
    use std::collections::{HashMap, HashSet};

    use adw::gtk::gdk::{self};
    use log::warn;
    use once_cell::sync::Lazy;
    use pipewire::spa::format::MediaType;
    use pipewire::spa::Direction;

    pub struct Colors {
        audio: gdk::RGBA,
        video: gdk::RGBA,
        midi: gdk::RGBA,
        unknown: gdk::RGBA,
    }

    impl Colors {
        pub fn color_for_media_type(&self, media_type: MediaType) -> &gdk::RGBA {
            match media_type {
                MediaType::Audio => &self.audio,
                MediaType::Video => &self.video,
                MediaType::Stream | MediaType::Application => &self.midi,
                _ => &self.unknown,
            }
        }
    }

    pub struct DragState {
        node: glib::WeakRef<Node>,
        /// This stores the offset of the pointer to the origin of the node,
        /// so that we can keep the pointer over the same position when moving the node
        ///
        /// The offset is normalized to the default zoom-level of 1.0.
        offset: Point,
    }

    pub struct GraphView {
        /// Stores nodes and their positions.
        pub(super) nodes: RefCell<HashMap<Node, Point>>,
        /// Stores the links and whether they are currently active.
        pub(super) links: RefCell<HashSet<Link>>,

        // Properties for zooming and scrolling the hraph
        pub hadjustment: RefCell<Option<gtk::Adjustment>>,
        pub vadjustment: RefCell<Option<gtk::Adjustment>>,
        pub zoom_factor: Cell<f64>,

        /// This keeps track of an ongoing node drag operation.
        pub dragged_node: RefCell<Option<DragState>>,

        // These keep track of an ongoing port drag operation
        pub dragged_port: glib::WeakRef<Port>,
        pub port_drag_cursor: Cell<Point>,

        // Memorized data for an in-progress zoom gesture
        pub zoom_gesture_initial_zoom: Cell<Option<f64>>,
        pub zoom_gesture_anchor: Cell<Option<(f64, f64)>>,
    }

    impl Default for GraphView {
        fn default() -> Self {
            Self {
                nodes: Default::default(),
                links: Default::default(),
                hadjustment: Default::default(),
                vadjustment: Default::default(),
                zoom_factor: Default::default(),
                dragged_node: Default::default(),
                dragged_port: Default::default(),
                port_drag_cursor: Cell::new(Point::new(0.0, 0.0)),
                zoom_gesture_initial_zoom: Default::default(),
                zoom_gesture_anchor: Default::default(),
            }
        }
    }

    #[glib::object_subclass]
    impl ObjectSubclass for GraphView {
        const NAME: &'static str = "HelvumGraphView";
        type Type = super::GraphView;
        type ParentType = gtk::Widget;
        type Interfaces = (gtk::Scrollable,);

        fn class_init(klass: &mut Self::Class) {
            klass.set_css_name("graphview");
        }
    }

    impl ObjectImpl for GraphView {
        fn constructed(&self) {
            self.parent_constructed();

            self.obj().add_css_class("view");

            self.obj().set_overflow(gtk::Overflow::Hidden);

            self.setup_node_dragging();
            self.setup_port_drag_and_drop();
            self.setup_scroll_zooming();
            self.setup_zoom_gesture();
        }

        fn dispose(&self) {
            self.nodes
                .borrow()
                .iter()
                .for_each(|(node, _)| node.unparent())
        }

        fn properties() -> &'static [glib::ParamSpec] {
            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
                vec![
                    glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("hadjustment"),
                    glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vadjustment"),
                    glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("hscroll-policy"),
                    glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vscroll-policy"),
                    glib::ParamSpecDouble::builder("zoom-factor")
                        .minimum(0.3)
                        .maximum(4.0)
                        .default_value(1.0)
                        .flags(glib::ParamFlags::CONSTRUCT | glib::ParamFlags::READWRITE)
                        .build(),
                ]
            });

            PROPERTIES.as_ref()
        }

        fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
            match pspec.name() {
                "hadjustment" => self.hadjustment.borrow().to_value(),
                "vadjustment" => self.vadjustment.borrow().to_value(),
                "hscroll-policy" | "vscroll-policy" => gtk::ScrollablePolicy::Natural.to_value(),
                "zoom-factor" => self.zoom_factor.get().to_value(),
                _ => unimplemented!(),
            }
        }

        fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
            let obj = self.obj();

            match pspec.name() {
                "hadjustment" => {
                    self.set_adjustment(&obj, value.get().ok(), gtk::Orientation::Horizontal)
                }
                "vadjustment" => {
                    self.set_adjustment(&obj, value.get().ok(), gtk::Orientation::Vertical)
                }
                "hscroll-policy" | "vscroll-policy" => {}
                "zoom-factor" => {
                    self.zoom_factor.set(value.get().unwrap());
                    obj.queue_allocate();
                }
                _ => unimplemented!(),
            }
        }
    }

    impl WidgetImpl for GraphView {
        fn size_allocate(&self, _width: i32, _height: i32, baseline: i32) {
            let widget = &*self.obj();

            for (node, point) in self.nodes.borrow().iter() {
                let (_, natural_size) = node.preferred_size();

                let transform = self
                    .canvas_space_to_screen_space_transform()
                    .translate(point);

                node.allocate(
                    natural_size.width(),
                    natural_size.height(),
                    baseline,
                    Some(transform),
                );
            }

            if let Some(ref hadjustment) = *self.hadjustment.borrow() {
                self.set_adjustment_values(widget, hadjustment, gtk::Orientation::Horizontal);
            }
            if let Some(ref vadjustment) = *self.vadjustment.borrow() {
                self.set_adjustment_values(widget, vadjustment, gtk::Orientation::Vertical);
            }
        }

        fn snapshot(&self, snapshot: &gtk::Snapshot) {
            let widget = &*self.obj();
            let alloc = widget.allocation();

            // Draw all visible children
            self.nodes
                .borrow()
                .iter()
                // Cull nodes from rendering when they are outside the visible canvas area
                .filter(|(node, _)| alloc.intersect(&node.allocation()).is_some())
                .for_each(|(node, _)| widget.snapshot_child(node, snapshot));

            self.snapshot_links(widget, snapshot);
        }
    }

    impl ScrollableImpl for GraphView {}

    impl GraphView {
        /// Returns a [`gsk::Transform`] matrix that can translate from canvas space to screen space.
        ///
        /// Canvas space is non-zoomed, and (0, 0) is fixed at the middle of the graph. \
        /// Screen space is zoomed and adjusted for scrolling, (0, 0) is at the top-left corner of the window.
        ///
        /// This is the inverted form of [`Self::screen_space_to_canvas_space_transform()`].
        fn canvas_space_to_screen_space_transform(&self) -> gsk::Transform {
            let hadj = self.hadjustment.borrow().as_ref().unwrap().value();
            let vadj = self.vadjustment.borrow().as_ref().unwrap().value();
            let zoom_factor = self.zoom_factor.get();

            gsk::Transform::new()
                .translate(&Point::new(-hadj as f32, -vadj as f32))
                .scale(zoom_factor as f32, zoom_factor as f32)
        }

        /// Returns a [`gsk::Transform`] matrix that can translate from screen space to canvas space.
        ///
        /// This is the inverted form of [`Self::canvas_space_to_screen_space_transform()`], see that function for a more detailed explantion.
        fn screen_space_to_canvas_space_transform(&self) -> gsk::Transform {
            self.canvas_space_to_screen_space_transform()
                .invert()
                .unwrap()
        }

        fn setup_node_dragging(&self) {
            let drag_controller = gtk::GestureDrag::new();

            drag_controller.connect_drag_begin(|drag_controller, x, y| {
                let widget = drag_controller
                    .widget()
                    .dynamic_cast::<super::GraphView>()
                    .expect("drag-begin event is not on the GraphView");
                let mut dragged_node = widget.imp().dragged_node.borrow_mut();

                // pick() should at least return the widget itself.
                let target = widget
                    .pick(x, y, gtk::PickFlags::DEFAULT)
                    .expect("drag-begin pick() did not return a widget");
                *dragged_node = if target.ancestor(Port::static_type()).is_some() {
                    // The user targeted a port, so the dragging should be handled by the Port
                    // component instead of here.
                    None
                } else if let Some(target) = target.ancestor(Node::static_type()) {
                    // The user targeted a Node without targeting a specific Port.
                    // Drag the Node around the screen.
                    let node = target.dynamic_cast_ref::<Node>().unwrap();

                    let Some(canvas_node_pos) = widget.node_position(node) else {
                        return;
                    };
                    let canvas_cursor_pos = widget
                        .imp()
                        .screen_space_to_canvas_space_transform()
                        .transform_point(&Point::new(x as f32, y as f32));

                    Some(DragState {
                        node: node.clone().downgrade(),
                        offset: Point::new(
                            canvas_cursor_pos.x() - canvas_node_pos.x(),
                            canvas_cursor_pos.y() - canvas_node_pos.y(),
                        ),
                    })
                } else {
                    None
                }
            });
            drag_controller.connect_drag_update(|drag_controller, x, y| {
                let widget = drag_controller
                    .widget()
                    .dynamic_cast::<super::GraphView>()
                    .expect("drag-update event is not on the GraphView");
                let dragged_node = widget.imp().dragged_node.borrow();
                let Some(DragState { node, offset }) = dragged_node.as_ref() else {
                    return;
                };
                let Some(node) = node.upgrade() else { return };

                let (start_x, start_y) = drag_controller
                    .start_point()
                    .expect("Drag has no start point");

                let onscreen_node_origin = Point::new((start_x + x) as f32, (start_y + y) as f32);
                let transform = widget.imp().screen_space_to_canvas_space_transform();
                let canvas_node_origin = transform.transform_point(&onscreen_node_origin);

                widget.move_node(
                    &node,
                    &Point::new(
                        canvas_node_origin.x() - offset.x(),
                        canvas_node_origin.y() - offset.y(),
                    ),
                );
            });
            self.obj().add_controller(drag_controller);
        }

        fn setup_port_drag_and_drop(&self) {
            let controller = gtk::DropControllerMotion::new();

            controller.connect_enter(|controller, x, y| {
                let graph = controller
                    .widget()
                    .downcast::<super::GraphView>()
                    .expect("Widget should be a graphview");

                graph.imp().port_drag_enter(controller, x, y)
            });

            controller.connect_motion(|controller, x, y| {
                let graph = controller
                    .widget()
                    .downcast::<super::GraphView>()
                    .expect("Widget should be a graphview");

                graph.imp().port_drag_motion(x, y)
            });

            controller.connect_leave(|controller| {
                let graph = controller
                    .widget()
                    .downcast::<super::GraphView>()
                    .expect("Widget should be a graphview");

                graph.imp().port_drag_leave()
            });

            self.obj().add_controller(controller);
        }

        fn port_drag_enter(&self, controller: &gtk::DropControllerMotion, x: f64, y: f64) {
            let Some(drop) = controller.drop() else {
                return;
            };

            self.port_drag_cursor.set(Point::new(x as f32, y as f32));

            drop.read_value_async(
                Port::static_type(),
                glib::Priority::DEFAULT,
                Option::<&gio::Cancellable>::None,
                clone!(@weak self as imp => move|value| {
                    let Ok(value) = value else {
                        return;
                    };
                    let port: &Port = value.get().expect("Value should contain a port");

                    imp.dragged_port.set(Some(port));
                }),
            );

            self.obj().queue_draw();
        }

        fn port_drag_motion(&self, x: f64, y: f64) {
            if self.dragged_port.upgrade().is_some() {
                self.port_drag_cursor.set(Point::new(x as f32, y as f32));

                self.obj().queue_draw();
            }
        }

        fn port_drag_leave(&self) {
            if self.dragged_port.upgrade().is_some() {
                self.dragged_port.set(None);
                self.obj().queue_draw();
            }
        }

        fn setup_scroll_zooming(&self) {
            // We're only interested in the vertical axis, but for devices like touchpads,
            // not capturing a small accidental horizontal move may cause the scroll to be disrupted if a widget
            // higher up captures it instead.
            let scroll_controller =
                gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);

            scroll_controller.connect_scroll(|eventcontroller, _, delta_y| {
                let event = eventcontroller.current_event().unwrap(); // We are inside the event handler, so it must have an event

                if event
                    .modifier_state()
                    .contains(gdk::ModifierType::CONTROL_MASK)
                {
                    let widget = eventcontroller
                        .widget()
                        .downcast::<super::GraphView>()
                        .unwrap();
                    widget.set_zoom_factor(widget.zoom_factor() + (0.1 * -delta_y), None);

                    glib::Propagation::Stop
                } else {
                    glib::Propagation::Proceed
                }
            });
            self.obj().add_controller(scroll_controller);
        }

        fn setup_zoom_gesture(&self) {
            let zoom_gesture = gtk::GestureZoom::new();
            zoom_gesture.connect_begin(|gesture, _| {
                let widget = gesture.widget().downcast::<super::GraphView>().unwrap();

                widget
                    .imp()
                    .zoom_gesture_initial_zoom
                    .set(Some(widget.zoom_factor()));
                widget
                    .imp()
                    .zoom_gesture_anchor
                    .set(gesture.bounding_box_center());
            });
            zoom_gesture.connect_scale_changed(move |gesture, delta| {
                let widget = gesture.widget().downcast::<super::GraphView>().unwrap();

                let initial_zoom = widget
                    .imp()
                    .zoom_gesture_initial_zoom
                    .get()
                    .expect("Initial zoom not set during zoom gesture");

                widget.set_zoom_factor(initial_zoom * delta, gesture.bounding_box_center());
            });
            self.obj().add_controller(zoom_gesture);
        }

        fn draw_link(
            &self,
            link_cr: &cairo::Context,
            output_anchor: &Point,
            input_anchor: &Point,
            active: bool,
            color: &gdk::RGBA,
        ) {
            let output_x: f64 = output_anchor.x().into();
            let output_y: f64 = output_anchor.y().into();
            let input_x: f64 = input_anchor.x().into();
            let input_y: f64 = input_anchor.y().into();

            // Use dashed line for inactive links, full line otherwise.
            if active {
                link_cr.set_dash(&[], 0.0);
            } else {
                link_cr.set_dash(&[10.0, 5.0], 0.0);
            }

            link_cr.set_source_rgba(
                color.red().into(),
                color.green().into(),
                color.blue().into(),
                color.alpha().into(),
            );

            // If the output port is farther right than the input port and they have
            // a similar y coordinate, apply a y offset to the control points
            // so that the curve sticks out a bit.
            let y_control_offset = if output_x > input_x {
                f64::max(0.0, 25.0 - (output_y - input_y).abs())
            } else {
                0.0
            };

            // Place curve control offset by half the x distance between the two points.
            // This makes the curve scale well for varying distances between the two ports,
            // especially when the output port is farther right than the input port.
            let half_x_dist = f64::abs(output_x - input_x) / 2.0;
            link_cr.move_to(output_x, output_y);
            link_cr.curve_to(
                output_x + half_x_dist,
                output_y - y_control_offset,
                input_x - half_x_dist,
                input_y - y_control_offset,
                input_x,
                input_y,
            );

            if let Err(e) = link_cr.stroke() {
                warn!("Failed to draw graphview links: {}", e);
            };
        }

        fn draw_dragged_link(&self, port: &Port, link_cr: &cairo::Context, colors: &Colors) {
            let Some(port_anchor) = port.compute_point(&*self.obj(), &port.link_anchor()) else {
                return;
            };
            let drag_cursor = self.port_drag_cursor.get();

            /* If we can find a linkable port under the cursor, link to its anchor,
             * otherwise link to the mouse cursor */
            let picked_port = self
                .obj()
                .pick(
                    drag_cursor.x().into(),
                    drag_cursor.y().into(),
                    gtk::PickFlags::DEFAULT,
                )
                .and_then(|widget| widget.ancestor(Port::static_type()).and_downcast::<Port>())
                .filter(|picked_port| port.is_linkable_to(picked_port));
            let picked_port_anchor = picked_port.and_then(|picked_port| {
                picked_port.compute_point(&*self.obj(), &picked_port.link_anchor())
            });
            let other_anchor = picked_port_anchor.unwrap_or(drag_cursor);

            let (output_anchor, input_anchor) = match Direction::from_raw(port.direction()) {
                Direction::Output => (&port_anchor, &other_anchor),
                Direction::Input => (&other_anchor, &port_anchor),
                _ => unreachable!(),
            };

            let color = &colors.color_for_media_type(MediaType::from_raw(port.media_type()));

            self.draw_link(link_cr, output_anchor, input_anchor, false, color);
        }

        fn snapshot_links(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
            let alloc = widget.allocation();

            let link_cr = snapshot.append_cairo(&graphene::Rect::new(
                0.0,
                0.0,
                alloc.width() as f32,
                alloc.height() as f32,
            ));

            link_cr.set_line_width(2.0 * self.zoom_factor.get());

            let colors = Colors {
                audio: widget
                    .style_context()
                    .lookup_color("media-type-audio")
                    .expect("color not found"),
                video: widget
                    .style_context()
                    .lookup_color("media-type-video")
                    .expect("color not found"),
                midi: widget
                    .style_context()
                    .lookup_color("media-type-midi")
                    .expect("color not found"),
                unknown: widget
                    .style_context()
                    .lookup_color("media-type-unknown")
                    .expect("color not found"),
            };

            for link in self.links.borrow().iter() {
                let color = &colors.color_for_media_type(link.media_type());

                // TODO: Do not draw links when they are outside the view
                let Some((output_anchor, input_anchor)) = self.get_link_coordinates(link) else {
                    warn!("Could not get allocation of ports of link: {:?}", link);
                    continue;
                };

                self.draw_link(
                    &link_cr,
                    &output_anchor,
                    &input_anchor,
                    link.active(),
                    color,
                );
            }

            if let Some(port) = self.dragged_port.upgrade() {
                self.draw_dragged_link(&port, &link_cr, &colors);
            }
        }

        /// Get coordinates for the drawn link to start at and to end at.
        ///
        /// # Returns
        /// `Some((output_anchor, input_anchor))` if all objects the links refers to exist as widgets
        /// and those widgets are contained by the graph.
        ///
        /// The returned coordinates are in screen-space of the graph.
        fn get_link_coordinates(&self, link: &Link) -> Option<(graphene::Point, graphene::Point)> {
            let widget = &*self.obj();

            let output_port = link.output_port()?;
            let output_anchor = output_port.compute_point(widget, &output_port.link_anchor())?;

            let input_port = link.input_port()?;
            let input_anchor = input_port.compute_point(widget, &input_port.link_anchor())?;

            Some((output_anchor, input_anchor))
        }

        fn set_adjustment(
            &self,
            obj: &super::GraphView,
            adjustment: Option<&gtk::Adjustment>,
            orientation: gtk::Orientation,
        ) {
            match orientation {
                gtk::Orientation::Horizontal => {
                    *self.hadjustment.borrow_mut() = adjustment.cloned()
                }
                gtk::Orientation::Vertical => *self.vadjustment.borrow_mut() = adjustment.cloned(),
                _ => unimplemented!(),
            }

            if let Some(adjustment) = adjustment {
                adjustment
                    .connect_value_changed(clone!(@weak obj => move |_|  obj.queue_allocate() ));
            }
        }

        fn set_adjustment_values(
            &self,
            obj: &super::GraphView,
            adjustment: &gtk::Adjustment,
            orientation: gtk::Orientation,
        ) {
            let size = match orientation {
                gtk::Orientation::Horizontal => obj.width(),
                gtk::Orientation::Vertical => obj.height(),
                _ => unimplemented!(),
            };
            let zoom_factor = self.zoom_factor.get();

            adjustment.configure(
                adjustment.value(),
                -(CANVAS_SIZE / 2.0) * zoom_factor,
                (CANVAS_SIZE / 2.0) * zoom_factor,
                (f64::from(size) * 0.1) * zoom_factor,
                (f64::from(size) * 0.9) * zoom_factor,
                f64::from(size) * zoom_factor,
            );
        }
    }
}

glib::wrapper! {
    pub struct GraphView(ObjectSubclass<imp::GraphView>)
        @extends gtk::Widget;
}

impl GraphView {
    pub const ZOOM_MIN: f64 = 0.3;
    pub const ZOOM_MAX: f64 = 4.0;

    pub fn new() -> Self {
        glib::Object::new()
    }

    pub fn zoom_factor(&self) -> f64 {
        self.property("zoom-factor")
    }

    /// Set the scale factor.
    ///
    /// A factor of 1.0 is equivalent to 100% zoom, 0.5 to 50% zoom etc.
    ///
    /// An optional anchor (in canvas-space coordinates) can be specified, which will be used as the center of the zoom,
    /// so that its position stays fixed.
    /// If no anchor is specified, the middle of the screen is used instead.
    ///
    /// Note that the zoom level is [clamped](`f64::clamp`) to between 30% and 300%.
    /// See [`Self::ZOOM_MIN`] and [`Self::ZOOM_MAX`].
    pub fn set_zoom_factor(&self, zoom_factor: f64, anchor: Option<(f64, f64)>) {
        let zoom_factor = zoom_factor.clamp(Self::ZOOM_MIN, Self::ZOOM_MAX);

        let (anchor_x_screen, anchor_y_screen) = anchor.unwrap_or_else(|| {
            (
                self.allocation().width() as f64 / 2.0,
                self.allocation().height() as f64 / 2.0,
            )
        });

        let old_zoom = self.imp().zoom_factor.get();
        let hadjustment_ref = self.imp().hadjustment.borrow();
        let vadjustment_ref = self.imp().vadjustment.borrow();
        let hadjustment = hadjustment_ref.as_ref().unwrap();
        let vadjustment = vadjustment_ref.as_ref().unwrap();

        let x_total = (anchor_x_screen + hadjustment.value()) / old_zoom;
        let y_total = (anchor_y_screen + vadjustment.value()) / old_zoom;

        let new_hadjustment = x_total * zoom_factor - anchor_x_screen;
        let new_vadjustment = y_total * zoom_factor - anchor_y_screen;

        hadjustment.set_value(new_hadjustment);
        vadjustment.set_value(new_vadjustment);

        self.set_property("zoom-factor", zoom_factor);
    }

    pub fn add_node(&self, node: Node, node_type: Option<NodeType>) {
        let imp = self.imp();
        node.set_parent(self);

        // Place widgets in colums of 3, growing down
        let x = if let Some(node_type) = node_type {
            match node_type {
                NodeType::Output => 20.0,
                NodeType::Input => 820.0,
            }
        } else {
            420.0
        };

        let y = imp
            .nodes
            .borrow()
            .iter()
            .map(|node| {
                // Map nodes to their locations
                let point = self.node_position(&node.0.clone().upcast()).unwrap();
                (point.x(), point.y())
            })
            .filter(|(x2, _)| {
                // Only look for other nodes that have a similar x coordinate
                (x - x2).abs() < 50.0
            })
            .max_by(|y1, y2| {
                // Get max in column
                y1.partial_cmp(y2).unwrap_or(Ordering::Equal)
            })
            .map_or(20_f32, |(_x, y)| y + 120.0);

        imp.nodes.borrow_mut().insert(node, Point::new(x, y));
    }

    pub fn remove_node(&self, node: &Node) {
        let mut nodes = self.imp().nodes.borrow_mut();

        if nodes.remove(node).is_some() {
            node.unparent();
        } else {
            log::warn!("Tried to remove non-existant node widget from graph");
        }
    }

    pub fn add_link(&self, link: Link) {
        link.connect_notify_local(
            Some("active"),
            glib::clone!(@weak self as graph => move |_, _| {
                graph.queue_draw();
            }),
        );
        link.connect_notify_local(
            Some("media-type"),
            glib::clone!(@weak self as graph => move |_, _| {
                graph.queue_draw();
            }),
        );
        self.imp().links.borrow_mut().insert(link);
        self.queue_draw();
    }

    pub fn remove_link(&self, link: &Link) {
        let mut links = self.imp().links.borrow_mut();
        links.remove(link);

        self.queue_draw();
    }

    pub fn clear(&mut self) {
        self.imp().links.borrow_mut().clear();
        for (node, _) in self.imp().nodes.borrow_mut().drain() {
            node.unparent();
        }
        self.queue_draw();
    }

    /// Get the position of the specified node inside the graphview.
    ///
    /// The returned position is in canvas-space (non-zoomed, (0, 0) fixed in the middle of the canvas).
    pub(super) fn node_position(&self, node: &Node) -> Option<Point> {
        self.imp().nodes.borrow().get(node).copied()
    }

    pub(super) fn move_node(&self, widget: &Node, point: &Point) {
        let mut nodes = self.imp().nodes.borrow_mut();
        let node_point = nodes.get_mut(widget).expect("Node is not on the graph");

        // Clamp the new position to within the graph, so a node can't be moved outside it and be lost.
        node_point.set_x(point.x().clamp(
            -(CANVAS_SIZE / 2.0) as f32,
            (CANVAS_SIZE / 2.0) as f32 - widget.width() as f32,
        ));
        node_point.set_y(point.y().clamp(
            -(CANVAS_SIZE / 2.0) as f32,
            (CANVAS_SIZE / 2.0) as f32 - widget.height() as f32,
        ));

        self.queue_allocate();
    }
}

impl Default for GraphView {
    fn default() -> Self {
        Self::new()
    }
}
