/*
 * 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/gl/gskglrenderer.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->crop.width * area->scale;
    crop->height = area->crop.height * area->scale;
}

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 int
eval_radial_line (double center_x, double center_y,
                  double bounds_x, double bounds_y,
                  double user_x)
{
    double decision_slope;
    double decision_intercept;

    decision_slope = (bounds_y - center_y) / (bounds_x - center_x);
    decision_intercept = -(decision_slope * bounds_x);

    return (int) (decision_slope * user_x + decision_intercept);
}

static gboolean
on_motion (GtkEventControllerMotion *controller,
           double                    event_x,
           double                    event_y,
           void                     *user_data)
{
    CcCropArea *area = CC_CROP_AREA (user_data);

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

    update_cursor (area, event_x, event_y);

    return FALSE;
}

static void
on_drag_begin (GtkGestureDrag *gesture,
               double          start_x,
               double          start_y,
               void           *user_data)
{
    CcCropArea *area = CC_CROP_AREA (user_data);
    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 (GtkGestureDrag *gesture,
                double          offset_x,
                double          offset_y,
                void           *user_data)
{
    CcCropArea *area = CC_CROP_AREA (user_data);
    double start_x, start_y;
    int x, y, delta_x, delta_y;
    int width, height;
    int adj_width, adj_height;
    int pb_width, pb_height;
    int left, right, top, bottom;
    double new_width, new_height;
    double center_x, center_y;
    int min_width, min_height;

    pb_width = gdk_paintable_get_intrinsic_width (area->paintable);
    pb_height = gdk_paintable_get_intrinsic_height (area->paintable);

    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.0;
    center_y = (top + bottom) / 2.0;

    /* What we have to do depends on where the user started dragging */
    switch (area->active_region) {
    case INSIDE:
        width = right - left + 1;
        height = bottom - top + 1;

        left = MAX (left + delta_x, 0);
        right = MIN (right + delta_x, pb_width);
        top = MAX (top + delta_y, 0);
        bottom = MIN (bottom + delta_y, pb_height);

        adj_width = right - left + 1;
        adj_height = bottom - top + 1;
        if (adj_width != width) {
            if (delta_x < 0)
                right = left + width - 1;
            else
                left = right - width + 1;
        }
        if (adj_height != height) {
            if (delta_y < 0)
                bottom = top + height - 1;
            else
                top = bottom - height + 1;
        }

        break;

    case TOP_LEFT:
        if (y < eval_radial_line (center_x, center_y, left, top, x)) {
            top = y;
            new_width = bottom - top;
            left = right - new_width;
        } else {
            left = x;
            new_height = right - left;
            top = bottom - new_height;
        }
        break;

    case TOP:
        top = y;
        new_width = bottom - top;
        right = left + new_width;
        break;

    case TOP_RIGHT:
        if (y < eval_radial_line (center_x, center_y, right, top, x)) {
            top = y;
            new_width = bottom - top;
            right = left + new_width;
        } else {
            right = x;
            new_height = right - left;
            top = bottom - new_height;
        }
        break;

    case LEFT:
        left = x;
        new_height = right - left;
        bottom = top + new_height;
        break;

    case BOTTOM_LEFT:
        if (y < eval_radial_line (center_x, center_y, left, bottom, x)) {
            left = x;
            new_height = right - left;
            bottom = top + new_height;
        } else {
            bottom = y;
            new_width = bottom - top;
            left = right - new_width;
        }
        break;

    case RIGHT:
        right = x;
        new_height = right - left;
        bottom = top + new_height;
        break;

    case BOTTOM_RIGHT:
        if (y < eval_radial_line (center_x, center_y, right, bottom, x)) {
            right = x;
            new_height = right - left;
            bottom = top + new_height;
        } else {
            bottom = y;
            new_width = bottom - top;
            right = left + new_width;
        }
        break;

    case BOTTOM:
        bottom = y;
        new_width = bottom - top;
        right= left + new_width;
        break;

    default:
        return;
    }

    min_width = area->min_crop_width / area->scale;
    min_height = area->min_crop_height / area->scale;

    width = right - left + 1;
    height = bottom - top + 1;
    if (left < 0 || top < 0 ||
        right > pb_width || bottom > pb_height ||
        width < min_width || height < min_height) {
        left = area->crop.x;
        right = area->crop.x + area->crop.width - 1;
        top = area->crop.y;
        bottom = area->crop.y + area->crop.height - 1;
    }

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

    area->drag_offx = offset_x;
    area->drag_offy = offset_y;

    gtk_widget_queue_draw (GTK_WIDGET (area));
}

static void
on_drag_end (GtkGestureDrag *gesture,
             double          offset_x,
             double          offset_y,
             void           *user_data)
{
    CcCropArea *area = CC_CROP_AREA (user_data);

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

static void
on_drag_cancel (GtkGesture       *gesture,
                GdkEventSequence *sequence,
                void             *user_data)
{
    CcCropArea *area = CC_CROP_AREA (user_data);

    area->active_region = OUTSIDE;
    area->drag_offx = 0;
    area->drag_offy = 0;
}

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 */
    cairo_save (cr);
    cairo_arc (cr, crop.x + crop.width / 2, crop.y + crop.width / 2, crop.width / 2, 0, 2 * G_PI);
    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, 4.0);

    /* top left corner */
    cairo_move_to (cr, crop.x + 15, crop.y);
    cairo_line_to (cr, crop.x, crop.y);
    cairo_line_to (cr, crop.x, crop.y + 15);
    /* top right corner */
    cairo_move_to (cr, crop.x + crop.width - 15, crop.y);
    cairo_line_to (cr, crop.x + crop.width, crop.y);
    cairo_line_to (cr, crop.x + crop.width, crop.y + 15);
    /* bottom right corner */
    cairo_move_to (cr, crop.x + crop.width - 15, crop.y + crop.height);
    cairo_line_to (cr, crop.x + crop.width, crop.y + crop.height);
    cairo_line_to (cr, crop.x + crop.width, crop.y + crop.height - 15);
    /* bottom left corner */
    cairo_move_to (cr, crop.x + 15, crop.y + crop.height);
    cairo_line_to (cr, crop.x, crop.y + crop.height);
    cairo_line_to (cr, crop.x, crop.y + crop.height - 15);

    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 (gesture, "drag-begin", G_CALLBACK (on_drag_begin), area);
    g_signal_connect (gesture, "drag-update", G_CALLBACK (on_drag_update),
                      area);
    g_signal_connect (gesture, "drag-end", G_CALLBACK (on_drag_end), area);
    g_signal_connect (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 (controller, "motion", G_CALLBACK (on_motion), 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);
}
