/* bz-zoom.c
 *
 * Copyright 2025 Alexander Vanhee
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU 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 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 <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

#include "bz-zoom.h"
#include <adwaita.h>
#include <math.h>

#define OVERPAN_FACTOR 0.5

struct _BzZoom
{
  GtkWidget parent_instance;

  GtkWidget *child;

  double zoom_level;
  double min_zoom;
  double max_zoom;
  double pan_x;
  double pan_y;
  double mouse_x;
  double mouse_y;

  double gesture_start_zoom;
  double drag_start_x;
  double drag_start_y;

  gboolean is_dragging;

  AdwAnimation *zoom_animation;
  double        target_zoom;
  double        target_pan_x;
  double        target_pan_y;
  double        start_zoom;
  double        start_pan_x;
  double        start_pan_y;

  GtkGesture         *zoom_gesture;
  GtkGesture         *drag_gesture;
  GtkEventController *scroll_controller;
  GtkEventController *motion_controller;
};

G_DEFINE_FINAL_TYPE (BzZoom, bz_zoom, GTK_TYPE_WIDGET)

enum
{
  PROP_0,
  PROP_CHILD,
  PROP_ZOOM_LEVEL,
  PROP_MIN_ZOOM,
  PROP_MAX_ZOOM,
  LAST_PROP
};

static GParamSpec *props[LAST_PROP] = { 0 };

static void bz_zoom_constrain_pan (BzZoom *self);
static void bz_zoom_zoom_at_point (BzZoom *self,
                                   double  zoom_factor,
                                   double  center_x,
                                   double  center_y);

static void
bz_zoom_dispose (GObject *object)
{
  BzZoom *self;

  self = BZ_ZOOM (object);

  g_clear_pointer (&self->child, gtk_widget_unparent);
  g_clear_object (&self->zoom_animation);

  G_OBJECT_CLASS (bz_zoom_parent_class)->dispose (object);
}

static void
bz_zoom_get_property (GObject    *object,
                      guint       prop_id,
                      GValue     *value,
                      GParamSpec *pspec)
{
  BzZoom *self;

  self = BZ_ZOOM (object);

  switch (prop_id)
    {
    case PROP_CHILD:
      g_value_set_object (value, bz_zoom_get_child (self));
      break;
    case PROP_ZOOM_LEVEL:
      g_value_set_double (value, bz_zoom_get_zoom_level (self));
      break;
    case PROP_MIN_ZOOM:
      g_value_set_double (value, bz_zoom_get_min_zoom (self));
      break;
    case PROP_MAX_ZOOM:
      g_value_set_double (value, bz_zoom_get_max_zoom (self));
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static void
bz_zoom_set_property (GObject      *object,
                      guint         prop_id,
                      const GValue *value,
                      GParamSpec   *pspec)
{
  BzZoom *self;

  self = BZ_ZOOM (object);

  switch (prop_id)
    {
    case PROP_CHILD:
      bz_zoom_set_child (self, g_value_get_object (value));
      break;
    case PROP_ZOOM_LEVEL:
      bz_zoom_set_zoom_level (self, g_value_get_double (value));
      break;
    case PROP_MIN_ZOOM:
      bz_zoom_set_min_zoom (self, g_value_get_double (value));
      break;
    case PROP_MAX_ZOOM:
      bz_zoom_set_max_zoom (self, g_value_get_double (value));
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static void
on_motion (BzZoom                   *self,
           double                    x,
           double                    y,
           GtkEventControllerMotion *controller)
{
  self->mouse_x = x;
  self->mouse_y = y;
}

static void
on_zoom_begin (BzZoom           *self,
               GdkEventSequence *sequence,
               GtkGesture       *gesture)
{
  self->gesture_start_zoom = self->zoom_level;
}

static void
on_zoom_changed (BzZoom     *self,
                 double      scale,
                 GtkGesture *gesture)
{
  double target_zoom;
  double zoom_factor;
  double center_x;
  double center_y;

  target_zoom = self->gesture_start_zoom * scale;

  if (self->zoom_level > 0)
    zoom_factor = target_zoom / self->zoom_level;
  else
    zoom_factor = 1.0;

  gtk_gesture_get_bounding_box_center (gesture, &center_x, &center_y);
  bz_zoom_zoom_at_point (self, zoom_factor, center_x, center_y);
}

static gboolean
on_scroll (BzZoom                   *self,
           double                    dx,
           double                    dy,
           GtkEventControllerScroll *controller)
{
  GdkEvent      *event;
  GdkDevice     *device;
  GdkInputSource source;
  double         zoom_factor;

  event = gtk_event_controller_get_current_event (GTK_EVENT_CONTROLLER (controller));
  if (event == NULL)
    return GDK_EVENT_PROPAGATE;

  device = gdk_event_get_device (event);
  if (device == NULL)
    return GDK_EVENT_PROPAGATE;

  source = gdk_device_get_source (device);

  if (source != GDK_SOURCE_MOUSE)
    return GDK_EVENT_PROPAGATE;

  zoom_factor = dy < 0 ? 1.1 : 0.9;
  bz_zoom_zoom_at_point (self, zoom_factor, self->mouse_x, self->mouse_y);
  return GDK_EVENT_STOP;
}

static void
on_drag_begin (BzZoom     *self,
               double      start_x,
               double      start_y,
               GtkGesture *gesture)
{
  if (!bz_zoom_is_transformed(self))
    {
      gtk_gesture_set_state (gesture, GTK_EVENT_SEQUENCE_DENIED);
      return;
    }

  self->drag_start_x = self->pan_x;
  self->drag_start_y = self->pan_y;
  self->is_dragging  = TRUE;
}

static void
on_drag_update (BzZoom     *self,
                double      offset_x,
                double      offset_y,
                GtkGesture *gesture)
{
  if (!self->is_dragging)
    return;

  self->pan_x = self->drag_start_x + offset_x;
  self->pan_y = self->drag_start_y + offset_y;
  bz_zoom_constrain_pan (self);
  gtk_widget_queue_draw (GTK_WIDGET (self));
}

static void
on_drag_end (BzZoom     *self,
             double      offset_x,
             double      offset_y,
             GtkGesture *gesture)
{
  self->is_dragging = FALSE;
}

static void
bz_zoom_snapshot (GtkWidget   *widget,
                  GtkSnapshot *snapshot)
{
  BzZoom          *self;
  int              width;
  int              height;
  GskTransform    *transform;
  graphene_point_t point;

  self = BZ_ZOOM (widget);

  width  = gtk_widget_get_width (widget);
  height = gtk_widget_get_height (widget);

  if (width <= 0 || height <= 0)
    return;

  transform = NULL;

  graphene_point_init (&point, width / 2.0 + self->pan_x, height / 2.0 + self->pan_y);
  transform = gsk_transform_translate (transform, &point);

  /* See bz_zoom_size_allocate */
  // transform = gsk_transform_scale (transform, self->zoom_level, self->zoom_level);

  graphene_point_init (
      &point,
      -(width * self->zoom_level) / 2.0,
      -(height * self->zoom_level) / 2.0);
  transform = gsk_transform_translate (transform, &point);

  gtk_snapshot_save (snapshot);
  gtk_snapshot_transform (snapshot, transform);

  if (self->child)
    gtk_widget_snapshot_child (widget, self->child, snapshot);

  gtk_snapshot_restore (snapshot);

  gsk_transform_unref (transform);
}

static void
bz_zoom_measure (GtkWidget     *widget,
                 GtkOrientation orientation,
                 int            for_size,
                 int           *minimum,
                 int           *natural,
                 int           *minimum_baseline,
                 int           *natural_baseline)
{
  BzZoom *self;

  self = BZ_ZOOM (widget);

  if (self->child)
    gtk_widget_measure (self->child, orientation, for_size, minimum, natural, minimum_baseline, natural_baseline);
  else
    {
      *minimum = 0;
      *natural = 0;
    }
}

static void
bz_zoom_size_allocate (GtkWidget *widget,
                       int        width,
                       int        height,
                       int        baseline)
{
  BzZoom *self;

  self = BZ_ZOOM (widget);

  if (self->child)
    /* TODO: maybe add a property to control whether the child is artificially
       scaled? */
    gtk_widget_allocate (
        self->child,
        self->zoom_level * width,
        self->zoom_level * height,
        baseline,
        NULL);

  bz_zoom_constrain_pan (self);
}

static void
bz_zoom_class_init (BzZoomClass *klass)
{
  GObjectClass   *object_class;
  GtkWidgetClass *widget_class;

  object_class = G_OBJECT_CLASS (klass);
  widget_class = GTK_WIDGET_CLASS (klass);

  object_class->dispose      = bz_zoom_dispose;
  object_class->get_property = bz_zoom_get_property;
  object_class->set_property = bz_zoom_set_property;

  widget_class->snapshot      = bz_zoom_snapshot;
  widget_class->measure       = bz_zoom_measure;
  widget_class->size_allocate = bz_zoom_size_allocate;

  props[PROP_CHILD] =
      g_param_spec_object (
          "child",
          NULL, NULL,
          GTK_TYPE_WIDGET,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);

  props[PROP_ZOOM_LEVEL] =
      g_param_spec_double (
          "zoom-level",
          NULL, NULL,
          0.1, 10.0, 1.0,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_MIN_ZOOM] =
      g_param_spec_double (
          "min-zoom",
          NULL, NULL,
          0.1, 1.0, 1,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);

  props[PROP_MAX_ZOOM] =
      g_param_spec_double (
          "max-zoom",
          NULL, NULL,
          1.0, 10.0, 5.0,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);

  g_object_class_install_properties (object_class, LAST_PROP, props);
}

static void
bz_zoom_init (BzZoom *self)
{
  self->zoom_level         = 1.0;
  self->min_zoom           = 1.0;
  self->max_zoom           = 5.0;
  self->pan_x              = 0.0;
  self->pan_y              = 0.0;
  self->mouse_x            = 0.0;
  self->mouse_y            = 0.0;
  self->gesture_start_zoom = 1.0;
  self->is_dragging        = FALSE;

  self->zoom_gesture = gtk_gesture_zoom_new ();
  g_signal_connect_swapped (self->zoom_gesture, "begin", G_CALLBACK (on_zoom_begin), self);
  g_signal_connect_swapped (self->zoom_gesture, "scale-changed", G_CALLBACK (on_zoom_changed), self);
  gtk_widget_add_controller (GTK_WIDGET (self), GTK_EVENT_CONTROLLER (self->zoom_gesture));

  self->drag_gesture = gtk_gesture_drag_new ();
  gtk_gesture_single_set_button (GTK_GESTURE_SINGLE (self->drag_gesture), GDK_BUTTON_PRIMARY);
  g_signal_connect_swapped (self->drag_gesture, "drag-begin", G_CALLBACK (on_drag_begin), self);
  g_signal_connect_swapped (self->drag_gesture, "drag-update", G_CALLBACK (on_drag_update), self);
  g_signal_connect_swapped (self->drag_gesture, "drag-end", G_CALLBACK (on_drag_end), self);
  gtk_widget_add_controller (GTK_WIDGET (self), GTK_EVENT_CONTROLLER (self->drag_gesture));

  self->scroll_controller = gtk_event_controller_scroll_new (GTK_EVENT_CONTROLLER_SCROLL_VERTICAL | GTK_EVENT_CONTROLLER_SCROLL_HORIZONTAL);
  g_signal_connect_swapped (self->scroll_controller, "scroll", G_CALLBACK (on_scroll), self);
  gtk_widget_add_controller (GTK_WIDGET (self), self->scroll_controller);

  self->motion_controller = gtk_event_controller_motion_new ();
  g_signal_connect_swapped (self->motion_controller, "motion", G_CALLBACK (on_motion), self);
  gtk_widget_add_controller (GTK_WIDGET (self), self->motion_controller);

  gtk_widget_set_focusable (GTK_WIDGET (self), TRUE);
}

GtkWidget *
bz_zoom_new (void)
{
  return g_object_new (BZ_TYPE_ZOOM, NULL);
}

GtkWidget *
bz_zoom_get_child (BzZoom *self)
{
  g_return_val_if_fail (BZ_IS_ZOOM (self), NULL);
  return self->child;
}

void
bz_zoom_set_child (BzZoom    *self,
                   GtkWidget *child)
{
  g_return_if_fail (BZ_IS_ZOOM (self));
  g_return_if_fail (child == NULL || GTK_IS_WIDGET (child));

  if (self->child == child)
    return;

  g_clear_pointer (&self->child, gtk_widget_unparent);

  self->child = child;

  if (self->child)
    gtk_widget_set_parent (self->child, GTK_WIDGET (self));

  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_CHILD]);
}

double
bz_zoom_get_zoom_level (BzZoom *self)
{
  g_return_val_if_fail (BZ_IS_ZOOM (self), 1.0);
  return self->zoom_level;
}

void
bz_zoom_set_zoom_level (BzZoom *self,
                        double  zoom_level)
{
  double new_zoom;

  g_return_if_fail (BZ_IS_ZOOM (self));

  new_zoom = CLAMP (zoom_level, self->min_zoom, self->max_zoom);

  if (fabs (new_zoom - self->zoom_level) < 0.001)
    return;

  self->zoom_level = new_zoom;
  bz_zoom_constrain_pan (self);
  gtk_widget_queue_resize (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_ZOOM_LEVEL]);
}

gboolean
bz_zoom_is_transformed (BzZoom *self)
{
  g_return_val_if_fail (BZ_IS_ZOOM (self), FALSE);
  return fabs (self->zoom_level - 1.0) >= 0.001 ||
         fabs (self->pan_x) >= 0.5 ||
         fabs (self->pan_y) >= 0.5;
}

double
bz_zoom_get_min_zoom (BzZoom *self)
{
  g_return_val_if_fail (BZ_IS_ZOOM (self), 1);
  return self->min_zoom;
}

void
bz_zoom_set_min_zoom (BzZoom *self,
                      double  min_zoom)
{
  g_return_if_fail (BZ_IS_ZOOM (self));

  self->min_zoom = min_zoom;
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_MIN_ZOOM]);
}

double
bz_zoom_get_max_zoom (BzZoom *self)
{
  g_return_val_if_fail (BZ_IS_ZOOM (self), 5.0);
  return self->max_zoom;
}

void
bz_zoom_set_max_zoom (BzZoom *self,
                      double  max_zoom)
{
  g_return_if_fail (BZ_IS_ZOOM (self));

  self->max_zoom = max_zoom;
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_MAX_ZOOM]);
}

static void
bz_zoom_constrain_pan (BzZoom *self)
{
  int    widget_width;
  int    widget_height;
  double overpan_x;
  double overpan_y;
  double range_x;
  double range_y;
  double max_pan_x;
  double max_pan_y;

  widget_width  = gtk_widget_get_width (GTK_WIDGET (self));
  widget_height = gtk_widget_get_height (GTK_WIDGET (self));

  if (widget_width <= 0 || widget_height <= 0)
    return;

  overpan_x = widget_width * OVERPAN_FACTOR;
  overpan_y = widget_height * OVERPAN_FACTOR;

  range_x = MAX (0, (widget_width * self->zoom_level - widget_width) / 2.0);
  range_y = MAX (0, (widget_height * self->zoom_level - widget_height) / 2.0);

  max_pan_x = range_x + overpan_x;
  max_pan_y = range_y + overpan_y;

  self->pan_x = CLAMP (self->pan_x, -max_pan_x, max_pan_x);
  self->pan_y = CLAMP (self->pan_y, -max_pan_y, max_pan_y);
}

static void
bz_zoom_zoom_at_point (BzZoom *self,
                       double  zoom_factor,
                       double  center_x,
                       double  center_y)
{
  double old_zoom;
  double new_zoom;
  int    widget_width;
  int    widget_height;
  double widget_center_x;
  double widget_center_y;
  double old_content_x;
  double old_content_y;
  double zoom_ratio;
  double new_content_x;
  double new_content_y;

  old_zoom = self->zoom_level;
  new_zoom = old_zoom * zoom_factor;
  new_zoom = CLAMP (new_zoom, self->min_zoom, self->max_zoom);

  if (fabs (new_zoom - old_zoom) < 0.001)
    return;

  widget_width  = gtk_widget_get_width (GTK_WIDGET (self));
  widget_height = gtk_widget_get_height (GTK_WIDGET (self));

  widget_center_x = widget_width / 2.0;
  widget_center_y = widget_height / 2.0;

  old_content_x = center_x - widget_center_x - self->pan_x;
  old_content_y = center_y - widget_center_y - self->pan_y;

  zoom_ratio    = new_zoom / old_zoom;
  new_content_x = old_content_x * zoom_ratio;
  new_content_y = old_content_y * zoom_ratio;

  self->zoom_level = new_zoom;

  self->pan_x = center_x - widget_center_x - new_content_x;
  self->pan_y = center_y - widget_center_y - new_content_y;

  bz_zoom_constrain_pan (self);
  gtk_widget_queue_resize (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_ZOOM_LEVEL]);
}

static void
on_animation_value (double  value,
                    BzZoom *self)
{
  self->zoom_level = self->start_zoom + (self->target_zoom - self->start_zoom) * value;
  self->pan_x      = self->start_pan_x + (self->target_pan_x - self->start_pan_x) * value;
  self->pan_y      = self->start_pan_y + (self->target_pan_y - self->start_pan_y) * value;

  bz_zoom_constrain_pan (self);
  gtk_widget_queue_resize (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_ZOOM_LEVEL]);
}

static void
bz_zoom_animate_to (BzZoom *self,
                    double  target_zoom,
                    double  target_pan_x,
                    double  target_pan_y)
{
  AdwAnimationTarget *target;

  if (self->zoom_animation)
    adw_animation_skip (self->zoom_animation);

  self->start_zoom   = self->zoom_level;
  self->start_pan_x  = self->pan_x;
  self->start_pan_y  = self->pan_y;
  self->target_zoom  = target_zoom;
  self->target_pan_x = target_pan_x;
  self->target_pan_y = target_pan_y;

  target               = adw_callback_animation_target_new ((AdwAnimationTargetFunc) on_animation_value, self, NULL);
  self->zoom_animation = adw_timed_animation_new (GTK_WIDGET (self), 0, 1, 150, target);
  adw_timed_animation_set_easing (ADW_TIMED_ANIMATION (self->zoom_animation), ADW_EASE_OUT_CUBIC);
  adw_animation_play (self->zoom_animation);
}

void
bz_zoom_zoom_in (BzZoom *self)
{
  int    width;
  int    height;
  double old_zoom;
  double new_zoom;
  double widget_center_x;
  double widget_center_y;
  double center_x;
  double center_y;
  double old_content_x;
  double old_content_y;
  double zoom_ratio;
  double new_content_x;
  double new_content_y;
  double new_pan_x;
  double new_pan_y;

  g_return_if_fail (BZ_IS_ZOOM (self));

  width  = gtk_widget_get_width (GTK_WIDGET (self));
  height = gtk_widget_get_height (GTK_WIDGET (self));

  center_x = width / 2.0;
  center_y = height / 2.0;

  old_zoom = self->zoom_level;
  new_zoom = old_zoom * 1.2;
  new_zoom = CLAMP (new_zoom, self->min_zoom, self->max_zoom);

  if (fabs (new_zoom - old_zoom) < 0.001)
    return;

  widget_center_x = width / 2.0;
  widget_center_y = height / 2.0;

  old_content_x = center_x - widget_center_x - self->pan_x;
  old_content_y = center_y - widget_center_y - self->pan_y;

  zoom_ratio    = new_zoom / old_zoom;
  new_content_x = old_content_x * zoom_ratio;
  new_content_y = old_content_y * zoom_ratio;

  new_pan_x = center_x - widget_center_x - new_content_x;
  new_pan_y = center_y - widget_center_y - new_content_y;

  bz_zoom_animate_to (self, new_zoom, new_pan_x, new_pan_y);
}

void
bz_zoom_zoom_out (BzZoom *self)
{
  int    width;
  int    height;
  double old_zoom;
  double new_zoom;
  double widget_center_x;
  double widget_center_y;
  double center_x;
  double center_y;
  double old_content_x;
  double old_content_y;
  double zoom_ratio;
  double new_content_x;
  double new_content_y;
  double new_pan_x;
  double new_pan_y;

  g_return_if_fail (BZ_IS_ZOOM (self));

  width  = gtk_widget_get_width (GTK_WIDGET (self));
  height = gtk_widget_get_height (GTK_WIDGET (self));

  center_x = width / 2.0;
  center_y = height / 2.0;

  old_zoom = self->zoom_level;
  new_zoom = old_zoom * 0.8;
  new_zoom = CLAMP (new_zoom, self->min_zoom, self->max_zoom);

  if (fabs (new_zoom - old_zoom) < 0.001)
    return;

  widget_center_x = width / 2.0;
  widget_center_y = height / 2.0;

  old_content_x = center_x - widget_center_x - self->pan_x;
  old_content_y = center_y - widget_center_y - self->pan_y;

  zoom_ratio    = new_zoom / old_zoom;
  new_content_x = old_content_x * zoom_ratio;
  new_content_y = old_content_y * zoom_ratio;

  new_pan_x = center_x - widget_center_x - new_content_x;
  new_pan_y = center_y - widget_center_y - new_content_y;

  bz_zoom_animate_to (self, new_zoom, new_pan_x, new_pan_y);
}

void
bz_zoom_reset (BzZoom *self)
{
  g_return_if_fail (BZ_IS_ZOOM (self));

  bz_zoom_animate_to (self, 1.0, 0.0, 0.0);
}

void
bz_zoom_fit_to_window (BzZoom *self)
{
  g_return_if_fail (BZ_IS_ZOOM (self));

  bz_zoom_animate_to (self, 1.0, 0.0, 0.0);
}

/* End of bz-zoom.c */
