/*
 * Copyright 2021  Red Hat, Inc,
 *
 * Authors:
 * - Matthias Clasen <mclasen@redhat.com>
 * - Niels De Graef <nielsdg@redhat.com>
 *
 * 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 2 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 <http://www.gnu.org/licenses/>.
 */

#include "config.h"

#include <glib.h>
#include <glib/gi18n.h>
#include <gtk/gtk.h>
#include <gsk/gsk.h>

#include "cc-crop-area.h"

/**
 * CcCropArea:
 *
 * A widget that shows a [iface@Gdk.Paintable] and allows the user specify a
 * cropping rectangle to effectively crop to that given area.
 */

/* Location of the cursor relative to the cropping rectangle/circle */
typedef enum {
    OUTSIDE,
    INSIDE,
    TOP,
    TOP_LEFT,
    TOP_RIGHT,
    BOTTOM,
    BOTTOM_LEFT,
    BOTTOM_RIGHT,
    LEFT,
    RIGHT
} Location;

struct _CcCropArea {
    GtkWidget parent_instance;

    GdkPaintable *paintable;

    double scale; /* scale factor to go from paintable size to widget size */

    const char *current_cursor;
    Location active_region;
    double drag_offx;
    double drag_offy;

    /* In source coordinates. See get_scaled_crop() for widget coordinates */
    GdkRectangle crop;

    /* In widget coordinates */
    GdkRectangle image;
    int min_crop_width;
    int min_crop_height;
};

G_DEFINE_TYPE (CcCropArea, cc_crop_area, GTK_TYPE_WIDGET);

static void
update_image_and_crop (CcCropArea *area)
{
    GtkAllocation allocation;
    int width, height;
    int dest_width, dest_height;
    double scale;

    if (area->paintable == NULL)
        return;

    gtk_widget_get_allocation (GTK_WIDGET (area), &allocation);

    /* Get the size of the paintable */
    width = gdk_paintable_get_intrinsic_width (area->paintable);
    height = gdk_paintable_get_intrinsic_height (area->paintable);

    /* Find out the scale to convert to widget width/height */
    scale = allocation.height / (double) height;
    if (scale * width > allocation.width)
        scale = allocation.width / (double) width;

    dest_width = width * scale;
    dest_height = height * scale;

    if (area->scale == 0.0) {
        double scale_to_80, scale_to_image, crop_scale;

        /* Start with a crop area of 80% of the area, unless it's larger than min_size */
        scale_to_80 = MIN ((double) dest_width * 0.8, (double) dest_height * 0.8);
        scale_to_image = MIN ((double) area->min_crop_width, (double) area->min_crop_height);
        crop_scale = MAX (scale_to_80, scale_to_image);

        /* Divide by `scale` to get back to paintable coordinates */
        area->crop.width = crop_scale / scale;
        area->crop.height = crop_scale / scale;
        area->crop.x = (width - area->crop.width) / 2;
        area->crop.y = (height - area->crop.height) / 2;
    }

    area->scale = scale;
    area->image.x = (allocation.width - dest_width) / 2;
    area->image.y = (allocation.height - dest_height) / 2;
    area->image.width = dest_width;
    area->image.height = dest_height;
}

/* Returns area->crop in widget coordinates (vs paintable coordsinates) */
static void
get_scaled_crop (CcCropArea    *area,
               GdkRectangle  *crop)
{
    crop->x = area->image.x + area->crop.x * area->scale;
    crop->y = area->image.y + area->crop.y * area->scale;
    crop->width = area->image.x + (area->crop.x + area->crop.width) * area->scale - crop->x;
    crop->height = area->image.y + (area->crop.y + area->crop.height) * area->scale - crop->y;
}

typedef enum {
    BELOW,
    LOWER,
    BETWEEN,
    UPPER,
    ABOVE
} Range;

static Range
find_range (int x,
            int min,
            int max)
{
    int tolerance = 12;

    if (x < min - tolerance)
        return BELOW;
    if (x <= min + tolerance)
        return LOWER;
    if (x < max - tolerance)
        return BETWEEN;
    if (x <= max + tolerance)
        return UPPER;
    return ABOVE;
}

/* Finds the location of (@x, @y) relative to the crop @rect */
static Location
find_location (GdkRectangle *rect,
               int           x,
               int           y)
{
    Range x_range, y_range;
    Location location[5][5] = {
        { OUTSIDE, OUTSIDE,     OUTSIDE, OUTSIDE,      OUTSIDE },
        { OUTSIDE, TOP_LEFT,    TOP,     TOP_RIGHT,    OUTSIDE },
        { OUTSIDE, LEFT,        INSIDE,  RIGHT,        OUTSIDE },
        { OUTSIDE, BOTTOM_LEFT, BOTTOM,  BOTTOM_RIGHT, OUTSIDE },
        { OUTSIDE, OUTSIDE,     OUTSIDE, OUTSIDE,      OUTSIDE }
    };

    x_range = find_range (x, rect->x, rect->x + rect->width);
    y_range = find_range (y, rect->y, rect->y + rect->height);

    return location[y_range][x_range];
}

static void
update_cursor (CcCropArea *area,
               int         x,
               int         y)
{
    const char *cursor_type;
    GdkRectangle crop;
    int region;

    region = area->active_region;
    if (region == OUTSIDE) {
        get_scaled_crop (area, &crop);
        region = find_location (&crop, x, y);
    }

    switch (region) {
    case OUTSIDE:
        cursor_type = "default";
        break;
    case TOP_LEFT:
        cursor_type = "nw-resize";
        break;
    case TOP:
        cursor_type = "n-resize";
        break;
    case TOP_RIGHT:
        cursor_type = "ne-resize";
        break;
    case LEFT:
        cursor_type = "w-resize";
        break;
    case INSIDE:
        cursor_type = "move";
        break;
    case RIGHT:
        cursor_type = "e-resize";
        break;
    case BOTTOM_LEFT:
        cursor_type = "sw-resize";
        break;
    case BOTTOM:
        cursor_type = "s-resize";
        break;
    case BOTTOM_RIGHT:
        cursor_type = "se-resize";
        break;
    default:
        g_assert_not_reached ();
    }

    if (cursor_type != area->current_cursor) {
        GtkNative *native;
        g_autoptr (GdkCursor) cursor = NULL;

        native = gtk_widget_get_native (GTK_WIDGET (area));
        if (!native) {
            g_warning ("Can't adjust cursor: no GtkNative found");
            return;
        }
        cursor = gdk_cursor_new_from_name (cursor_type, NULL);
        gdk_surface_set_cursor (gtk_native_get_surface (native), cursor);
        area->current_cursor = cursor_type;
    }
}

static gboolean
on_motion (CcCropArea *area,
           double      event_x,
           double      event_y)
{
    if (area->paintable == NULL)
        return FALSE;

    update_cursor (area, event_x, event_y);

    return FALSE;
}

static void
on_leave (CcCropArea *area)
{
    if (area->paintable == NULL)
        return;

    /* Restore 'default' cursor */
    update_cursor (area, 0, 0);
}

static void
on_drag_begin (CcCropArea     *area,
               double          start_x,
               double          start_y)
{
    GdkRectangle crop;

    if (area->paintable == NULL)
        return;

    update_cursor (area, start_x, start_y);

    get_scaled_crop (area, &crop);

    area->active_region = find_location (&crop, start_x, start_y);

    area->drag_offx = 0.0;
    area->drag_offy = 0.0;
}

static void
on_drag_update (CcCropArea     *area,
                double          offset_x,
                double          offset_y,
                GtkGestureDrag *gesture)
{
    double start_x, start_y;
    int x, y, delta_x, delta_y;
    int clamped_delta_x, clamped_delta_y;
    int left, right, top, bottom;
    int center_x, center_y;
    int distance_left, distance_right, distance_top, distance_bottom;
    int closest_distance_x, closest_distance_y;
    int size_x, size_y;
    int min_size, max_size, wanted_size, new_size;

    gtk_gesture_drag_get_start_point (gesture, &start_x, &start_y);

    /* Get the x, y, dx, dy in paintable coords */
    x = (start_x + offset_x - area->image.x) / area->scale;
    y = (start_y + offset_y - area->image.y) / area->scale;
    delta_x = (offset_x - area->drag_offx) / area->scale;
    delta_y = (offset_y - area->drag_offy) / area->scale;

    /* Helper variables */
    left = area->crop.x;
    right = area->crop.x + area->crop.width - 1;
    top = area->crop.y;
    bottom = area->crop.y + area->crop.height - 1;

    center_x = (left + right) / 2;
    center_y = (top + bottom) / 2;

    distance_left = left;
    distance_right = gdk_paintable_get_intrinsic_width (area->paintable) - (right + 1);
    distance_top = top;
    distance_bottom = gdk_paintable_get_intrinsic_height (area->paintable) - (bottom + 1);

    closest_distance_x = MIN (distance_left, distance_right);
    closest_distance_y = MIN (distance_top, distance_bottom);

    /* All size variables are center-to-center, not edge-to-edge, hence the missing '+ 1' everywhere */
    size_x = right - left;
    size_y = bottom - top;

    min_size = MAX (area->min_crop_width / area->scale, area->min_crop_height / area->scale);

    /* What we have to do depends on where the user started dragging */
    switch (area->active_region) {
    case INSIDE:
        if (delta_x < 0)
            clamped_delta_x = MAX (delta_x, -distance_left);
        else
            clamped_delta_x = MIN (delta_x, distance_right);

        if (delta_y < 0)
            clamped_delta_y = MAX (delta_y, -distance_top);
        else
            clamped_delta_y = MIN (delta_y, distance_bottom);

        left += clamped_delta_x;
        right += clamped_delta_x;
        top += clamped_delta_y;
        bottom += clamped_delta_y;

        break;

    /* The wanted size assumes one side remains glued to the cursor */
    case TOP_LEFT:
        max_size = MIN (size_y + distance_top, size_x + distance_left);
        wanted_size = MAX (bottom - y, right - x);
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        top = bottom - new_size;
        left = right - new_size;
        break;

    case TOP:
        max_size = MIN (size_y + distance_top, size_x + 2 * closest_distance_x);
        wanted_size = bottom - y;
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        top = bottom - new_size;
        left = center_x - new_size / 2;
        right = left + new_size;
        break;

    case TOP_RIGHT:
        max_size = MIN (size_y + distance_top, size_x + distance_right);
        wanted_size = MAX (bottom - y, x - left);
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        top = bottom - new_size;
        right = left + new_size;
        break;

    case LEFT:
        max_size = MIN (size_x + distance_left, size_y + 2 * closest_distance_y);
        wanted_size = right - x;
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        left = right - new_size;
        top = center_y - new_size / 2;
        bottom = top + new_size;
        break;

    case BOTTOM_LEFT:
        max_size = MIN (size_y + distance_bottom, size_x + distance_left);
        wanted_size = MAX (y - top, right - x);
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        bottom = top + new_size;
        left = right - new_size;
        break;

    case RIGHT:
        max_size = MIN (size_x + distance_right, size_y + 2 * closest_distance_y);
        wanted_size = x - left;
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        right = left + new_size;
        top = center_y - new_size / 2;
        bottom = top + new_size;
        break;

    case BOTTOM_RIGHT:
        max_size = MIN (size_y + distance_bottom, size_x + distance_right);
        wanted_size = MAX (y - top, x - left);
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        bottom = top + new_size;
        right = left + new_size;
        break;

    case BOTTOM:
        max_size = MIN (size_y + distance_bottom, size_x + 2 * closest_distance_x);
        wanted_size = y - top;
        new_size = CLAMP (wanted_size, MIN (min_size, max_size), max_size);
        bottom = top + new_size;
        left = center_x - new_size / 2;
        right = left + new_size;
        break;

    default:
        return;
    }

    area->crop.x = left;
    area->crop.y = top;
    area->crop.width = right - left + 1;
    area->crop.height = bottom - top + 1;

    /* Only update drag_off based on the rounded deltas, otherwise rounding accumulates */
    area->drag_offx += area->scale * delta_x;
    area->drag_offy += area->scale * delta_y;

    gtk_widget_queue_draw (GTK_WIDGET (area));
}

static void
on_drag_end (CcCropArea     *area,
             double          offset_x,
             double          offset_y)
{
    area->active_region = OUTSIDE;
    area->drag_offx = 0.0;
    area->drag_offy = 0.0;
}

static void
on_drag_cancel (CcCropArea       *area,
                GdkEventSequence *sequence)
{
    area->active_region = OUTSIDE;
    area->drag_offx = 0;
    area->drag_offy = 0;
}

#define CORNER_LINE_WIDTH 4.0
#define CORNER_LINE_LENGTH 15.0
#define CORNER_SIZE (CORNER_LINE_LENGTH + CORNER_LINE_WIDTH / 2)

static void
cc_crop_area_snapshot (GtkWidget   *widget,
                       GtkSnapshot *snapshot)
{
    CcCropArea *area = CC_CROP_AREA (widget);
    cairo_t *cr;
    GdkRectangle crop;

    if (area->paintable == NULL)
        return;

    update_image_and_crop (area);


    gtk_snapshot_save (snapshot);

    /* First draw the picture */
    gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (area->image.x, area->image.y));

    gdk_paintable_snapshot (area->paintable, snapshot, area->image.width, area->image.height);

    /* Draw the cropping UI on top with cairo */
    cr = gtk_snapshot_append_cairo (snapshot, &GRAPHENE_RECT_INIT (0, 0, area->image.width, area->image.height));

    get_scaled_crop (area, &crop);
    crop.x -= area->image.x;
    crop.y -= area->image.y;

    /* Draw the circle as an ellipse, to prevent rounding from jitter of the edges */
    cairo_save (cr);
    cairo_translate (cr, crop.x + crop.width / 2.0, crop.y + crop.height / 2.0);
    cairo_scale (cr, crop.width / 2.0, crop.height / 2.0);
    cairo_arc (cr, 0, 0, 1, 0, 2 * G_PI);
    cairo_restore (cr);
    cairo_save (cr);
    cairo_rectangle (cr, 0, 0, area->image.width, area->image.height);
    cairo_set_source_rgba (cr, 0, 0, 0, 0.4);
    cairo_set_fill_rule (cr, CAIRO_FILL_RULE_EVEN_ODD);
    cairo_fill (cr);
    cairo_restore (cr);

    /* draw the four corners */
    cairo_set_source_rgb (cr, 1, 1, 1);
    cairo_set_line_width (cr, CORNER_LINE_WIDTH);

    /* top left corner */
    cairo_move_to (cr, crop.x + CORNER_LINE_WIDTH / 2, crop.y + CORNER_SIZE);
    cairo_rel_line_to (cr, 0, -CORNER_LINE_LENGTH);
    cairo_rel_line_to (cr, CORNER_LINE_LENGTH, 0);
    /* top right corner */
    cairo_rel_move_to (cr, crop.width - 2 * CORNER_SIZE, 0);
    cairo_rel_line_to (cr, CORNER_LINE_LENGTH, 0);
    cairo_rel_line_to (cr, 0, CORNER_LINE_LENGTH);
    /* bottom right corner */
    cairo_rel_move_to (cr, 0, crop.height - 2 * CORNER_SIZE);
    cairo_rel_line_to (cr, 0, CORNER_LINE_LENGTH);
    cairo_rel_line_to (cr, -CORNER_LINE_LENGTH, 0);
    /* bottom left corner */
    cairo_rel_move_to (cr, -(crop.width - 2 * CORNER_SIZE), 0);
    cairo_rel_line_to (cr, -CORNER_LINE_LENGTH, 0);
    cairo_rel_line_to (cr, 0, -CORNER_LINE_LENGTH);

    cairo_stroke (cr);

    gtk_snapshot_restore (snapshot);
}

static void
cc_crop_area_finalize (GObject *object)
{
    CcCropArea *area = CC_CROP_AREA (object);

    g_clear_object (&area->paintable);
}

static void
cc_crop_area_class_init (CcCropAreaClass *klass)
{
    GObjectClass *object_class = G_OBJECT_CLASS (klass);
    GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);

    object_class->finalize = cc_crop_area_finalize;

    widget_class->snapshot = cc_crop_area_snapshot;
}

static void
cc_crop_area_init (CcCropArea *area)
{
    GtkGesture *gesture;
    GtkEventController *controller;

    /* Add handlers for dragging */
    gesture = gtk_gesture_drag_new ();
    g_signal_connect_swapped (gesture, "drag-begin", G_CALLBACK (on_drag_begin), area);
    g_signal_connect_swapped (gesture, "drag-update", G_CALLBACK (on_drag_update), area);
    g_signal_connect_swapped (gesture, "drag-end", G_CALLBACK (on_drag_end), area);
    g_signal_connect_swapped (gesture, "cancel", G_CALLBACK (on_drag_cancel), area);
    gtk_widget_add_controller (GTK_WIDGET (area), GTK_EVENT_CONTROLLER (gesture));

    /* Add handlers for motion events */
    controller = gtk_event_controller_motion_new ();
    g_signal_connect_swapped (controller, "motion", G_CALLBACK (on_motion), area);
    g_signal_connect_swapped (controller, "leave", G_CALLBACK (on_leave), area);
    gtk_widget_add_controller (GTK_WIDGET (area), GTK_EVENT_CONTROLLER (controller));

    area->scale = 0.0;
    area->image.x = 0;
    area->image.y = 0;
    area->image.width = 0;
    area->image.height = 0;
    area->active_region = OUTSIDE;
    area->min_crop_width = 48;
    area->min_crop_height = 48;

    gtk_widget_set_size_request (GTK_WIDGET (area), 48, 48);
}

GtkWidget *
cc_crop_area_new (void)
{
    return g_object_new (CC_TYPE_CROP_AREA, NULL);
}

/**
 * cc_crop_area_create_pixbuf:
 * @area: A crop area
 *
 * Renders the area's paintable, with the cropping applied by the user, into a
 * GdkPixbuf.
 *
 * Returns: (transfer full): The cropped picture
 */
GdkPixbuf *
cc_crop_area_create_pixbuf (CcCropArea *area)
{
    g_autoptr (GtkSnapshot) snapshot = NULL;
    g_autoptr (GskRenderNode) node = NULL;
    g_autoptr (GskRenderer) renderer = NULL;
    g_autoptr (GdkTexture) texture = NULL;
    g_autoptr (GError) error = NULL;
    graphene_rect_t viewport;

    g_return_val_if_fail (CC_IS_CROP_AREA (area), NULL);

    snapshot = gtk_snapshot_new ();
    gdk_paintable_snapshot (area->paintable, snapshot,
                            gdk_paintable_get_intrinsic_width (area->paintable),
                            gdk_paintable_get_intrinsic_height (area->paintable));
    node = gtk_snapshot_free_to_node (g_steal_pointer (&snapshot));

    renderer = gsk_gl_renderer_new ();
    if (!gsk_renderer_realize (renderer, NULL, &error)) {
        g_warning ("Couldn't realize GL renderer: %s", error->message);
        return NULL;
    }
    viewport = GRAPHENE_RECT_INIT (area->crop.x, area->crop.y,
                                   area->crop.width, area->crop.height);
    texture = gsk_renderer_render_texture (renderer, node, &viewport);
    gsk_renderer_unrealize (renderer);

    return gdk_pixbuf_get_from_texture (texture);
}

/**
 * cc_crop_area_get_paintable:
 * @area: A crop area
 *
 * Returns the area's paintable, unmodified.
 *
 * Returns: (transfer none) (nullable): The paintable which the user can crop
 */
GdkPaintable *
cc_crop_area_get_paintable (CcCropArea *area)
{
    g_return_val_if_fail (CC_IS_CROP_AREA (area), NULL);

    return area->paintable;
}

void
cc_crop_area_set_paintable (CcCropArea   *area,
                            GdkPaintable *paintable)
{
    g_return_if_fail (CC_IS_CROP_AREA (area));
    g_return_if_fail (GDK_IS_PAINTABLE (paintable));

    g_set_object (&area->paintable, paintable);

    area->scale = 0.0;
    area->image.x = 0;
    area->image.y = 0;
    area->image.width = 0;
    area->image.height = 0;

    gtk_widget_queue_draw (GTK_WIDGET (area));
}

/**
 * cc_crop_area_set_min_size:
 * @area: A crop widget
 * @width: The minimal width
 * @height: The minimal height
 *
 * Sets the minimal size of the crop rectangle (in paintable coordinates)
 */
void
cc_crop_area_set_min_size (CcCropArea *area,
                           int         width,
                           int         height)
{
    g_return_if_fail (CC_IS_CROP_AREA (area));

    area->min_crop_width = width;
    area->min_crop_height = height;

    gtk_widget_set_size_request (GTK_WIDGET (area),
                                 area->min_crop_width,
                                 area->min_crop_height);
}
