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

use crate::widgets::Window;

#[derive(Debug, Default, Eq, PartialEq, Clone, Copy, glib::Enum)]
#[repr(u32)]
#[enum_type(name = "Filter")]
pub enum Filter {
    Blur = 0,
    #[default]
    Filled = 1,
}

mod imp {
    use super::*;
    use glib::subclass::Signal;
    use once_cell::sync::Lazy;
    use std::cell::{Cell, RefCell};

    #[derive(Debug, glib::Properties, gtk::CompositeTemplate)]
    #[properties(wrapper_type = super::DrawingArea)]
    #[template(string = r#"
    <interface>
      <template class="DrawingArea" parent="GtkWidget">
        <property name="hexpand">True</property>
        <property name="vexpand">True</property>
        <signal name="notify::scale-factor" handler="on_scale_factor_notify" swapped="true" />
        <child>
            <object class="GtkGestureDrag">
                <property name="exclusive">True</property>
                <signal name="drag-begin" handler="on_drag_begin" swapped="true" />
                <signal name="drag-update" handler="on_drag_update" swapped="true" />
                <signal name="drag-end" handler="on_drag_end" swapped="true" />
                <signal name="cancel" handler="on_drag_cancel" swapped="true" />
            </object>
        </child>
        <child>
            <object class="GtkEventControllerMotion">
                <signal name="enter" handler="on_motion_enter" swapped="true" />
                <signal name="motion" handler="on_motion" swapped="true" />
                <signal name="leave" handler="on_motion_leave" swapped="true" />
            </object>
        </child>
        <child>
            <object class="GtkEventControllerScroll">
                <property name="flags">vertical</property>
                <signal name="scroll" handler="on_scroll" swapped="true" />
            </object>
        </child>
        <child>
            <object class="GtkGestureZoom">
                <signal name="begin" handler="on_zoom_begin" swapped="true" />
                <signal name="scale-changed" handler="on_zoom_scale_changed" swapped="true" />
            </object>
        </child>
      </template>
    </interface>
    "#)]
    pub struct DrawingArea {
        pub start_position: Cell<Option<(f64, f64)>>,
        pub current_position: Cell<Option<(f64, f64)>>,
        pub pointer_position: Cell<Option<(f64, f64)>>,
        pub history: RefCell<Vec<gdk::Texture>>,
        pub future: RefCell<Vec<gdk::Texture>>,

        pub initial_zoom: Cell<f64>,
        pub initial_zoom_center: Cell<Option<(f64, f64)>>,
        pub queued_scroll: Cell<Option<(f64, f64)>>,
        pub scale_factor: Cell<i32>,
        #[property(get, set)]
        pub can_undo: Cell<bool>,
        #[property(get, set)]
        pub can_redo: Cell<bool>,
        #[property(get, set = Self::set_zoom, minimum = 0.0, default = 1.0, maximum = 8.0)]
        pub zoom: Cell<f64>,
        #[property(get, set, builder(Filter::default()))]
        pub filter: Cell<Filter>,
        // Scrollable properties
        #[property(get, set = Self::set_vadjustment, override_interface = gtk::Scrollable)]
        pub vadjustment: RefCell<Option<gtk::Adjustment>>,
        pub vadjustment_signal: RefCell<Option<glib::SignalHandlerId>>,
        #[property(get, set = Self::set_hadjustment, override_interface = gtk::Scrollable)]
        pub hadjustment: RefCell<Option<gtk::Adjustment>>,
        pub hadjustment_signal: RefCell<Option<glib::SignalHandlerId>>,
        #[property(get, set, override_interface = gtk::Scrollable)]
        pub hscroll_policy: Cell<gtk::ScrollablePolicy>,
        #[property(get, set, override_interface = gtk::Scrollable)]
        pub vscroll_policy: Cell<gtk::ScrollablePolicy>,
    }

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

        fn new() -> Self {
            Self {
                start_position: Default::default(),
                current_position: Default::default(),
                pointer_position: Default::default(),
                history: Default::default(),
                future: Default::default(),
                initial_zoom: Cell::new(1.0),
                initial_zoom_center: Default::default(),
                queued_scroll: Default::default(),
                scale_factor: Cell::new(1),
                can_redo: Default::default(),
                can_undo: Default::default(),
                zoom: Cell::new(1.0),
                filter: Default::default(),
                hadjustment: Default::default(),
                hadjustment_signal: Default::default(),
                vadjustment: Default::default(),
                vadjustment_signal: Default::default(),
                hscroll_policy: Cell::new(gtk::ScrollablePolicy::Minimum),
                vscroll_policy: Cell::new(gtk::ScrollablePolicy::Minimum),
            }
        }

        fn class_init(klass: &mut Self::Class) {
            klass.set_accessible_role(gtk::AccessibleRole::Img);
            klass.bind_template();
            klass.bind_template_callbacks();
        }

        fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
            obj.init_template();
        }
    }

    impl ObjectImpl for DrawingArea {
        fn signals() -> &'static [Signal] {
            static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| vec![Signal::builder("loaded").build()]);
            SIGNALS.as_ref()
        }

        fn constructed(&self) {
            self.parent_constructed();
            let obj = self.obj();

            obj.set_overflow(gtk::Overflow::Hidden);
            obj.set_cursor(gdk::Cursor::from_name("crosshair", None).as_ref());

            let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
            drop_target.connect_drop(clone!(@weak obj => @default-return false, move |_, value, _, _| {
                if let Ok(file) = value.get::<gio::File>() {
                    let window = obj.root().unwrap().downcast::<Window>().unwrap();
                    window.set_open_file(&file);
                    true
                } else {
                    false
                }
            }));
            obj.add_controller(drop_target);
        }

        fn properties() -> &'static [glib::ParamSpec] {
            Self::derived_properties()
        }

        fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
            self.derived_set_property(id, value, pspec)
        }

        fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value {
            self.derived_property(id, pspec)
        }
    }

    impl WidgetImpl for DrawingArea {
        fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) {
            let widget = self.obj();
            let zoom = widget.effective_zoom();
            let (width, height) = if let Some(texture) = self.history.borrow().iter().last() {
                ((texture.width() as f64 * zoom) as i32, (texture.height() as f64 * zoom) as i32)
            } else {
                // some default (width, height)
                (300, 300)
            };

            if orientation == gtk::Orientation::Horizontal {
                (0, width, -1, -1)
            } else {
                (0, height, -1, -1)
            }
        }

        fn snapshot(&self, snapshot: &gtk::Snapshot) {
            let widget = self.obj();
            if let Some(texture) = widget.texture() {
                let hadj = widget.hadjustment().unwrap();
                let vadj = widget.vadjustment().unwrap();
                let zoom = widget.effective_zoom() as f32;
                let (translate_x, translate_y) = translate(widget.width() as f32, widget.height() as f32, &texture, zoom);

                snapshot.save();
                snapshot.translate(&graphene::Point::new((-hadj.value() as f32 + translate_x).round(), (-vadj.value() as f32 + translate_y).round()));
                snapshot.scale(zoom, zoom);
                snapshot.append_texture(&texture, &widget.bounds());
                snapshot.restore();

                if let Some(end_pos) = self.current_position.get() {
                    let start_pos = self.start_position.get().unwrap();

                    let view_start = widget.convert_image_coords(start_pos);
                    let view_end = widget.convert_image_coords(end_pos);

                    let (x, y) = top_left_corner(view_start, view_end);
                    let (width, height) = width_height(view_start, view_end);

                    let selection_rect = graphene::Rect::new(x, y, width, height);

                    snapshot.append_border(
                        &gsk::RoundedRect::from_rect(selection_rect, 0.0),
                        &[1.0, 1.0, 1.0, 1.0],
                        &[gdk::RGBA::BLACK, gdk::RGBA::BLACK, gdk::RGBA::BLACK, gdk::RGBA::BLACK],
                    );
                }
            }
        }

        fn size_allocate(&self, width: i32, height: i32, _baseline: i32) {
            let widget = self.obj();
            if let Some(texture) = widget.texture() {
                let zoom = widget.effective_zoom();

                let hadjustment = widget.hadjustment().unwrap();
                let vadjustment = widget.vadjustment().unwrap();

                let (x, y) = self.queued_scroll.take().unwrap_or((hadjustment.value(), vadjustment.value()));

                hadjustment.configure(x, 0.0, (width as f64).max(texture.width() as f64 * zoom), 0.1 * width as f64, 0.9 * width as f64, width as f64);
                vadjustment.configure(y, 0.0, (height as f64).max(texture.height() as f64 * zoom), 0.1 * height as f64, 0.9 * height as f64, height as f64);
            }
        }
    }

    impl ScrollableImpl for DrawingArea {}

    #[gtk::template_callbacks]
    impl DrawingArea {
        fn set_hadjustment(&self, adj: Option<gtk::Adjustment>) {
            let obj = self.obj();
            if let Some(signal_id) = self.hadjustment_signal.borrow_mut().take() {
                let old_adj = self.hadjustment.borrow().as_ref().unwrap().clone();
                old_adj.disconnect(signal_id);
            }

            if let Some(ref adjustment) = adj {
                let signal_id = adjustment.connect_value_changed(clone!(@weak obj => move |_| {
                    obj.queue_draw();
                }));
                self.hadjustment_signal.replace(Some(signal_id));
            }
            self.hadjustment.replace(adj);
        }

        fn set_vadjustment(&self, adj: Option<gtk::Adjustment>) {
            let obj = self.obj();
            if let Some(signal_id) = self.vadjustment_signal.borrow_mut().take() {
                let old_adj = self.vadjustment.borrow().as_ref().unwrap().clone();
                old_adj.disconnect(signal_id);
            }

            if let Some(ref adjustment) = adj {
                let signal_id = adjustment.connect_value_changed(clone!(@weak obj => move |_| {
                    obj.queue_draw();
                }));
                self.vadjustment_signal.replace(Some(signal_id));
            }
            self.vadjustment.replace(adj);
        }

        fn set_zoom(&self, new_zoom: f64) {
            let obj = self.obj();
            if let Some(pos) = self.pointer_position.get() {
                obj.begin_zoom(pos);
                obj.set_zoom_at_center(new_zoom, pos);
                return;
            }

            let width = obj.width();
            let height = obj.height();
            let pos = (width as f64 / 2.0, height as f64 / 2.0);
            obj.begin_zoom(pos);
            obj.set_zoom_at_center(new_zoom, pos);
        }

        #[template_callback]
        fn on_drag_begin(&self, start_x: f64, start_y: f64) {
            let image_coords = self.obj().to_image_coords((start_x, start_y));
            self.start_position.replace(Some(image_coords));
        }

        #[template_callback]
        fn on_drag_update(&self, offset_x: f64, offset_y: f64, gesture: &gtk::GestureDrag) {
            if let Some(start_point) = gesture.start_point() {
                let obj = self.obj();
                let (start_x, start_y) = start_point;
                let image_coords = obj.to_image_coords((start_x + offset_x, start_y + offset_y));
                self.current_position.replace(Some(image_coords));
                obj.queue_draw();
            }
        }

        #[template_callback]
        fn on_drag_end(&self, offset_x: f64, offset_y: f64, gesture: &gtk::GestureDrag) {
            if let Some(start_point) = gesture.start_point() {
                let obj = self.obj();
                let (start_x, start_y) = start_point;
                let image_coords = obj.to_image_coords((start_x + offset_x, start_y + offset_y));
                self.current_position.replace(Some(image_coords));
                obj.apply_filter();
            }
        }

        #[template_callback]
        fn on_drag_cancel(&self) {
            self.start_position.replace(None);
            self.current_position.replace(None);
        }

        #[template_callback]
        fn on_scale_factor_notify(&self) {
            let obj = self.obj();
            let change = obj.scale_factor() as f64 / self.scale_factor.get() as f64;

            let hadj = obj.hadjustment().unwrap();
            let vadj = obj.vadjustment().unwrap();
            let new_scroll = (hadj.value(), vadj.value());

            obj.set_zoom(obj.zoom() * change);
            self.queued_scroll.replace(Some(new_scroll));

            self.scale_factor.set(obj.scale_factor());
            obj.queue_allocate();
        }

        #[template_callback]
        fn on_motion_enter(&self, x: f64, y: f64) {
            self.pointer_position.replace(Some((x, y)));
        }

        #[template_callback]
        fn on_motion(&self, x: f64, y: f64) {
            self.pointer_position.replace(Some((x, y)));
        }

        #[template_callback]
        fn on_motion_leave(&self) {
            self.pointer_position.replace(None);
        }

        #[template_callback]
        fn on_scroll(&self, _delta_x: f64, delta_y: f64, event: &gtk::EventControllerScroll) -> gtk::Inhibit {
            if event.current_event_state().contains(gdk::ModifierType::CONTROL_MASK) {
                let obj = self.obj();
                let zoom = -delta_y * 0.1 + obj.zoom();
                obj.set_zoom(zoom.clamp(0f64, 8.0f64));
                gtk::Inhibit(true)
            } else {
                gtk::Inhibit(false)
            }
        }

        #[template_callback]
        fn on_zoom_begin(&self, _: &gdk::EventSequence, gesture: &gtk::GestureZoom) {
            let view_center = gesture.bounding_box_center().unwrap();
            self.obj().begin_zoom(view_center);

            gesture.set_state(gtk::EventSequenceState::Claimed);
        }

        #[template_callback]
        fn on_zoom_scale_changed(&self, scale: f64, gesture: &gtk::GestureZoom) {
            let view_center = gesture.bounding_box_center().unwrap();
            self.obj().set_zoom_at_center(self.initial_zoom.get() * scale, view_center);
        }
    }
}

glib::wrapper! {
    pub struct DrawingArea(ObjectSubclass<imp::DrawingArea>)
        @extends gtk::Widget,
        @implements gtk::Scrollable;
}

impl DrawingArea {
    #[allow(clippy::new_without_default)]
    pub fn new() -> Self {
        glib::Object::new()
    }

    fn effective_zoom(&self) -> f64 {
        self.zoom() / self.scale_factor() as f64
    }

    pub fn load_file(&self, file: &gio::File) -> anyhow::Result<()> {
        let texture = gdk::Texture::from_file(file)?;
        self.load_texture(texture);
        Ok(())
    }

    fn texture(&self) -> Option<gdk::Texture> {
        let history = self.imp().history.borrow().clone();
        history.into_iter().last()
    }

    fn bounds(&self) -> graphene::Rect {
        let texture = self.texture().unwrap();
        graphene::Rect::new(0.0, 0.0, texture.width() as f32, texture.height() as f32)
    }

    fn load_texture(&self, texture: gdk::Texture) {
        let imp = self.imp();
        imp.history.replace(vec![texture]);
        imp.future.replace(Vec::new());
        imp.start_position.replace(None);
        imp.current_position.replace(None);
        imp.initial_zoom_center.replace(None);
        self.set_can_undo(false);
        self.set_can_redo(false);
        self.set_zoom(1.0);
        imp.queued_scroll.replace(Some((0.0, 0.0)));
        self.queue_allocate();
    }

    fn begin_zoom(&self, zoom_center: (f64, f64)) {
        let imp = self.imp();
        imp.initial_zoom.set(self.zoom());
        imp.initial_zoom_center.replace(Some(self.to_image_coords(zoom_center)));
    }

    pub fn set_zoom_at_center(&self, new_zoom: f64, zoom_center: (f64, f64)) {
        let imp = self.imp();

        let zoom = self.zoom();
        imp.zoom.replace(new_zoom.clamp((0.1f64).min(zoom), (8.0f64).max(zoom)));

        let hadj = self.hadjustment().unwrap();
        let vadj = self.vadjustment().unwrap();

        let (center_x, center_y) = zoom_center;
        let (x, y) = self.convert_image_coords(imp.initial_zoom_center.get().unwrap());

        let new_scroll = (x + hadj.value() - center_x, y + vadj.value() - center_y);
        imp.queued_scroll.replace(Some(new_scroll));

        self.queue_allocate();
        self.queue_draw();
    }

    pub fn save_to(&self, dest: &gio::File) -> anyhow::Result<()> {
        if let Some(ref texture) = self.texture() {
            let pixbuf = gdk::pixbuf_get_from_texture(texture).unwrap();
            let stream = dest.replace(None, false, gio::FileCreateFlags::REPLACE_DESTINATION, gio::Cancellable::NONE)?;
            pixbuf.save_to_streamv(&stream, "png", &[], gio::Cancellable::NONE)?;
        }
        Ok(())
    }

    pub async fn paste(&self) {
        let clipboard = self.clipboard();
        match clipboard.read_texture_future().await {
            Ok(Some(texture)) => {
                self.load_texture(texture);
                self.emit_by_name::<()>("loaded", &[]);
            }
            Ok(None) => {
                log::warn!("No texture found");
            }
            Err(err) => {
                log::error!("Failed to paste texture {err}");
            }
        }
    }

    pub fn copy(&self) {
        if let Some(ref texture) = self.texture() {
            let clipboard = self.clipboard();
            clipboard.set_texture(texture);
        }
    }

    pub fn undo(&self) {
        let imp = self.imp();
        if let Some(texture) = imp.history.borrow_mut().pop() {
            self.set_can_redo(true);
            imp.future.borrow_mut().push(texture);
        } else {
            self.set_can_redo(false);
        }
        self.set_can_undo(imp.history.borrow().len() > 1);
        self.queue_allocate();
        self.queue_draw();
    }

    pub fn redo(&self) {
        let imp = self.imp();
        if let Some(texture) = imp.future.borrow_mut().pop() {
            imp.history.borrow_mut().push(texture);
            self.set_can_undo(true);
        } else {
            self.set_can_undo(true);
        }
        self.set_can_redo(imp.future.borrow().len() != 0);
        self.queue_draw();
    }

    /// Takes current (start_position, current_position)
    /// and based on the selected filter, create an appropriate texture
    /// and add it to the history
    fn apply_filter(&self) {
        let imp = self.imp();
        let start_pos = imp.start_position.take().unwrap();
        let end_pos = imp.current_position.take().unwrap();
        let texture = self.texture().unwrap();

        let start_pos = self.clamp_to_image(start_pos);
        let end_pos = self.clamp_to_image(end_pos);

        let (x, y) = top_left_corner(start_pos, end_pos);
        let (width, height) = width_height(start_pos, end_pos);

        let filter_bounds = graphene::Rect::new(x, y, width, height);
        let texture_bounds = self.bounds();

        let renderer = self.root().unwrap().upcast::<gtk::Native>().renderer();
        let snapshot = gtk::Snapshot::new();
        match self.filter() {
            Filter::Blur => {
                snapshot.append_texture(&texture, &texture_bounds);
                snapshot.push_clip(&filter_bounds);
                snapshot.push_blur(10.0);
                snapshot.push_clip(&filter_bounds.inset_r(-10.0, -10.0));
                snapshot.append_texture(&texture, &texture_bounds);
                snapshot.pop();
                snapshot.pop();
                snapshot.pop();
            }
            Filter::Filled => {
                snapshot.append_texture(&texture, &texture_bounds);
                snapshot.append_color(&gdk::RGBA::BLACK, &filter_bounds);
            }
        };

        let node = snapshot.to_node().unwrap();
        let texture = renderer.render_texture(&node, None);

        imp.history.borrow_mut().push(texture);
        imp.current_position.replace(None);
        self.set_can_undo(true);
        self.queue_draw();
    }

    fn to_image_coords(&self, view_coords: (f64, f64)) -> (f64, f64) {
        let hadj = self.hadjustment().unwrap();
        let vadj = self.vadjustment().unwrap();
        let texture = self.texture().unwrap();
        let zoom = self.effective_zoom();
        let (translate_x, translate_y) = translate(self.width() as f32, self.height() as f32, &texture, zoom as f32);
        let (x, y) = view_coords;

        let image_x = (x + hadj.value() - translate_x as f64) / zoom;
        let image_y = (y + vadj.value() - translate_y as f64) / zoom;

        (image_x, image_y)
    }

    fn convert_image_coords(&self, image_coords: (f64, f64)) -> (f64, f64) {
        let hadj = self.hadjustment().unwrap();
        let vadj = self.vadjustment().unwrap();
        let texture = self.texture().unwrap();
        let zoom = self.effective_zoom();
        let (translate_x, translate_y) = translate(self.width() as f32, self.height() as f32, &texture, zoom as f32);
        let (x, y) = image_coords;

        let view_x = x * zoom - hadj.value() + translate_x as f64;
        let view_y = y * zoom - vadj.value() + translate_y as f64;

        (view_x, view_y)
    }

    fn clamp_to_image(&self, pos: (f64, f64)) -> (f64, f64) {
        let texture = self.texture().unwrap();
        let (x, y) = pos;

        let x = x.clamp(0.0, texture.width() as f64);
        let y = y.clamp(0.0, texture.height() as f64);

        (x, y)
    }
}

fn top_left_corner(pos1: (f64, f64), pos2: (f64, f64)) -> (f32, f32) {
    let (x0, y0) = pos1;
    let (x1, y1) = pos2;

    let width = x1 - x0;
    let height = y1 - y0;
    // figure out the real start position of the rectangle
    let (x, y) = if height < 0.0 && width > 0.0 {
        (x0, y1)
    } else if width < 0.0 && height > 0.0 {
        (x1, y0)
    } else if width < 0.0 && height < 0.0 {
        (x1, y1)
    } else {
        (x0, y0)
    };
    (x as f32, y as f32)
}

fn width_height(pos1: (f64, f64), pos2: (f64, f64)) -> (f32, f32) {
    let (x0, y0) = pos1;
    let (x1, y1) = pos2;

    let width = (x1 - x0).abs();
    let height = (y1 - y0).abs();

    ((width) as f32, (height) as f32)
}

fn translate(width: f32, height: f32, texture: &gdk::Texture, zoom: f32) -> (f32, f32) {
    let (mut translate_x, mut translate_y) = (0.0, 0.0);
    let texture_width = texture.width() as f32 * zoom;
    let texture_height = texture.height() as f32 * zoom;

    if width > texture_width {
        translate_x = (width - texture_width) / 2.0;
    }
    if height > texture_height {
        translate_y = (height - texture_height) / 2.0;
    }
    (translate_x, translate_y)
}
