/* Copyright 2016 Software Freedom Conservancy Inc.
 *
 * This software is licensed under the GNU Lesser General Public License
 * (version 2.1 or later).  See the COPYING file in this distribution.
 */

public abstract class FaceShape : Object {
    public const string SHAPE_TYPE = null;
    
    protected const int FACE_WINDOW_MARGIN = 5;
    protected const int LABEL_MARGIN = 12;
    protected const int LABEL_PADDING = 9;
    
    public signal void add_me_requested(FaceShape face_shape);
    public signal void delete_me_requested();
    
    protected FacesTool.EditingFaceToolWindow face_window;
    protected string current_cursor_type = "se-resize";
    public EditingTools.PhotoCanvas canvas { get; protected set; }
    protected string serialized = null;
    protected double[] face_vec;
    
    private bool editable = true;
    private bool visible = true;
    private bool known = true;
    private double guess = 0.0;
    
    private weak FacesTool.FaceWidget face_widget = null;
    
    protected FaceShape(EditingTools.PhotoCanvas canvas, double[] vec) {
        this.canvas = canvas;
        this.canvas.new_surface.connect(prepare_ctx);
        
        prepare_ctx(this.canvas.get_default_ctx(), this.canvas.get_surface_dim());
        
        face_window = new FacesTool.EditingFaceToolWindow(this.canvas.get_container());
        face_window.key_pressed.connect(key_press_event);
        
        face_window.present();
        face_window.set_visible(false);
        
        this.face_vec = vec;
        this.canvas.set_cursor(current_cursor_type);
    }
    
    ~FaceShape() {
        if (visible) {
            erase();
        }
        
        face_window.destroy();
        
        canvas.new_surface.disconnect(prepare_ctx);
        
        // make sure the cursor isn't set to a modify indicator
        canvas.set_cursor(null);
    }
    
    public static FaceShape from_serialized(EditingTools.PhotoCanvas canvas, string serialized)
    throws FaceShapeError {
        FaceShape face_shape;
        
        string[] args = serialized.split(";");
        switch (args[0]) {
            case "Rectangle":
                face_shape = FaceRectangle.from_serialized(canvas, args);
                
                break;
            default:
                assert_not_reached();
        }
        
        face_shape.serialized = serialized;
        
        return face_shape;
    }
    
    public void set_name(string face_name) {
        face_window.entry.set_text(face_name);
    }
    
    public string? get_name() {
        string face_name = face_window.entry.get_text();
        
        return face_name == "" ? null : face_name;
    }
    
    public void set_known(bool known) {
        this.known = known;
    }
    
    public bool get_known() {
        return known;
    }

    public void set_guess(double guess) {
        this.guess = guess;
    }
    
    public double get_guess() {
        return guess;
    }

    public void set_widget(FacesTool.FaceWidget face_widget) {
        this.face_widget = face_widget;
    }
    
    public FacesTool.FaceWidget get_widget() {
        assert(face_widget != null);
        
        return face_widget;
    }
    
    public void hide() {
        visible = false;
        erase();
        
        if (editable)
            face_window.set_visible(false);
        
        // make sure the cursor isn't set to a modify indicator
        canvas.set_cursor(null);
    }
    
    public void show() {
        visible = true;
        paint();
        
        if (editable) {
            face_window.present();
            
            if (!known)
                face_window.entry.select_region(0, -1);
        }
    }
    
    public bool is_visible() {
        return visible;
    }
    
    public bool is_editable() {
        return editable;
    }
    
    public void set_editable(bool editable) {
        if (visible && editable != is_editable()) {
            hide();
            this.editable = editable;
            show();
            
            return;
        }
        
        this.editable = editable;
    }
    
    public bool key_press_event(Gtk.EventControllerKey event, uint keyval, uint keycode, Gdk.ModifierType modifiers) {
        switch (Gdk.keyval_name(keyval)) {
            case "Escape":
                delete_me_requested();
            break;
            case "Return":
            case "KP_Enter":
                add_me_requested(this);
            break;
            default:
                return false;
        }
        
        return true;
    }
    
    public abstract string serialize(bool geometry_only = false);
    public abstract void prepare_ctx(Cairo.Context ctx, Dimensions dim);
    public abstract void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled);
    public abstract void on_motion(int x, int y, Gdk.ModifierType mask);
    public abstract void on_left_released(int x, int y);
    public abstract bool on_left_click(int x, int y);
    public abstract bool cursor_is_over(int x, int y);
    public abstract bool equals(FaceShape face_shape);
    public abstract double get_distance(int x, int y);
    public abstract double[] get_face_vec();
    
    protected abstract void paint();
    protected abstract void erase();
}

public class FaceRectangle : FaceShape {
    public new const string SHAPE_TYPE = "Rectangle";
    
    private const int FACE_MIN_SIZE = 8;
    public const int NULL_SIZE = 0;
    
    private Box box;
    private Box? label_box;
    private BoxLocation in_manipulation = BoxLocation.OUTSIDE;
    private Cairo.Context wide_black_ctx = null;
    private Cairo.Context wide_white_ctx = null;
    private int last_grab_x = -1;
    private int last_grab_y = -1;
    
    public FaceRectangle(EditingTools.PhotoCanvas canvas, int x, int y,
        int half_width = NULL_SIZE, int half_height = NULL_SIZE, double[] vec = {}) {
	    double[] int_vec;
	    if (vec.length == 0)
	        int_vec = create_empty_vec();
	    else
	        int_vec = vec;
        base(canvas, int_vec);
        
        Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position();
        x -= scaled_pixbuf_pos.x;
        y -= scaled_pixbuf_pos.y;
        
        // If half_width is NULL_SIZE we are creating a new FaceShape,
        // otherwise we are only showing a previously created one.
        if (half_width == NULL_SIZE) {
            box = Box(x, y, x, y);
            
            in_manipulation = BoxLocation.BOTTOM_RIGHT;
            last_grab_x = x;
            last_grab_y = y;
        } else {
            Dimensions pixbuf_dimensions = Dimensions.for_pixbuf(canvas.get_scaled_pixbuf());
            int right = (x + half_width).clamp(x, pixbuf_dimensions.width);
            int bottom = (y + half_height).clamp(y, pixbuf_dimensions.height);
        
            box = Box(x - half_width, y - half_height, right, bottom);
        }
    }
    
    ~FaceRectangle() {
        if (!is_editable())
            erase_label();
    }

    public static double[] create_empty_vec() {
        double[] empty_vec = new double[128];
        for (int i = 0; i < 128; i++) {
            empty_vec[i] = 0;
	    }
	    return empty_vec;
    }
    
    public static new FaceRectangle from_serialized(EditingTools.PhotoCanvas canvas, string[] args)
        throws FaceShapeError {
        assert(args[0] == SHAPE_TYPE);
        
        Photo photo = canvas.get_photo();
        Dimensions raw_dim = photo.get_raw_dimensions();

        // 1, 2 is the center of the rectangle, 3, 4 is the half width / height of the rectangle,
        // normalized
        int x = (int) (raw_dim.width * double.parse(args[1]));
        int y = (int) (raw_dim.height * double.parse(args[2]));
        int half_width = (int) (raw_dim.width * double.parse(args[3]));
        int half_height = (int) (raw_dim.height * double.parse(args[4]));
        
        Box box = Box(x - half_width, y - half_height, x + half_width, y + half_height);
        
        Dimensions current_dim = Dimensions.for_pixbuf(canvas.get_scaled_pixbuf());
        Box raw_cropped;
        
        if (photo.get_raw_crop(out raw_cropped)) {
            box.left = box.left.clamp(raw_cropped.left, box.left) - raw_cropped.left;
            box.right = box.right.clamp(box.right, raw_cropped.right) - raw_cropped.left;
            box.top = box.top.clamp(raw_cropped.top, box.top) - raw_cropped.top;
            box.bottom = box.bottom.clamp(box.bottom, raw_cropped.bottom) - raw_cropped.top;
            
            box = photo.get_orientation().rotate_box(raw_cropped.get_dimensions(), box);
            
            Box cropped;
            photo.get_crop(out cropped);
            box = box.get_scaled_similar(cropped.get_dimensions(), current_dim);
        } else {
            box = photo.get_orientation().rotate_box(raw_dim, box);
            
            box = box.get_scaled_similar(photo.get_dimensions(), current_dim);
        }
        
        Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position();
        box.left += scaled_pixbuf_pos.x;
        box.right += scaled_pixbuf_pos.x;
        box.top += scaled_pixbuf_pos.y;
        box.bottom += scaled_pixbuf_pos.y;
        
        half_width = box.get_width() / 2;
        half_height = box.get_height() / 2;
        
        if (half_width < FACE_MIN_SIZE || half_height < FACE_MIN_SIZE)
            throw new FaceShapeError.CANT_CREATE("FaceShape is out of cropped photo area");

        string[] vec_str;
        if (args.length == 6)
            vec_str = args[5].split(",");
        else
            vec_str = {};
        double[] vec = new double[128];
        for (int i = 0; i < 128; i++) {
            if (vec_str.length > i)
                vec[i] = double.parse(vec_str[i]);
            else
                vec[i] = 0;
        }
        return new FaceRectangle(canvas, box.left + half_width, box.top + half_height,
            half_width, half_height, vec);
    }
    
    protected override void paint() {
        // The box is in image coordinates. Need to scale down to device coordinates
        canvas.draw_box(wide_black_ctx, box);
        canvas.draw_box(wide_white_ctx, box.get_reduced(1));
        canvas.draw_box(wide_white_ctx, box.get_reduced(2));
        
        canvas.invalidate_area(box);
        
        if (!is_editable())
            paint_label();
    }
    
    protected override void erase() {
        canvas.invalidate_area(box);
        
        if (!is_editable())
            erase_label();
    }
    
    private void paint_label() {
        Cairo.Context ctx = canvas.get_default_ctx();
        Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position();
        
        ctx.save();
        
        ctx.select_font_face("Sans", Cairo.FontSlant.NORMAL, Cairo.FontWeight.NORMAL);
        ctx.set_font_size(10.0 * Application.get_scale());

        Cairo.TextExtents text_extents = Cairo.TextExtents();
        ctx.text_extents(get_name(), out text_extents);
        
        int width = (int) text_extents.width + LABEL_PADDING;
        int height = (int) text_extents.height;
        int x = box.left + (box.get_width() - width) / 2;
        int y = box.bottom + LABEL_MARGIN;
        
        label_box = Box(x, y, x + width, y + height + LABEL_PADDING);
        
        x += scaled_pixbuf_pos.x;
        y += scaled_pixbuf_pos.y;
        
        ctx.rectangle(x, y, width, height + LABEL_PADDING);
        ctx.set_source_rgba(0, 0, 0, 0.6);
        ctx.fill();
        
        ctx.set_source_rgb(1, 1, 1);
        ctx.move_to(x + LABEL_PADDING / 2, y + height + LABEL_PADDING / 2);
        ctx.show_text(get_name());
        
        ctx.restore();
    }
    
    private void erase_label() {
        if (label_box == null)
            return;
        
        Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position();
        int x = scaled_pixbuf_pos.x + label_box.left;
        int y = scaled_pixbuf_pos.y + label_box.top;
        
        Cairo.Context ctx = canvas.get_default_ctx();
        ctx.save();
        
        ctx.set_operator(Cairo.Operator.OVER);
        ctx.rectangle(x, y, label_box.get_width(), label_box.get_height());
        
        ctx.set_source_rgb(0.0, 0.0, 0.0);
        ctx.fill_preserve();
        
        ctx.set_source_surface(canvas.get_scaled_surface(),
            scaled_pixbuf_pos.x, scaled_pixbuf_pos.y);
        ctx.fill();
        
        canvas.invalidate_area(label_box);
        label_box = null;
        
        ctx.restore();
    }
    
    public override string serialize(bool geometry_only = false) {
        if (serialized != null)
            return serialized;
        
        double x;
        double y;
        double half_width;
        double half_height;
        
        get_geometry(out x, out y, out half_width, out half_height);
        serialized = "%s;%s;%s;%s;%s;".printf(SHAPE_TYPE, x.to_string(),
            y.to_string(), half_width.to_string(), half_height.to_string());
        if (!geometry_only) {
            var face_vec_str = new StringBuilder();
            foreach (var d in face_vec) {
                face_vec_str.append(d.to_string());
                face_vec_str.append_c(',');
            }
            face_vec_str.erase(face_vec_str.len - 1, 1);
            serialized += face_vec_str.str;
        }
        return serialized;
    }
    
    public void get_geometry(out double x, out double y,
        out double half_width, out double half_height) {
        Photo photo = canvas.get_photo();
        Dimensions raw_dim = photo.get_raw_dimensions();
        
        Box temp_box = box;
        
        Dimensions current_dim = Dimensions.for_pixbuf(canvas.get_scaled_pixbuf());
        Box cropped;
        
        if (photo.get_crop(out cropped)) {
            temp_box = temp_box.get_scaled_similar(current_dim, cropped.get_dimensions());
            
            Box raw_cropped;
            photo.get_raw_crop(out raw_cropped);
            
            temp_box =
                photo.get_orientation().derotate_box(raw_cropped.get_dimensions(), temp_box);
            
            temp_box.left += raw_cropped.left;
            temp_box.right += raw_cropped.left;
            temp_box.top += raw_cropped.top;
            temp_box.bottom += raw_cropped.top;
        } else {
            temp_box = temp_box.get_scaled_similar(current_dim, photo.get_dimensions());
            
            temp_box = photo.get_orientation().derotate_box(raw_dim, temp_box);
        }
        
        x = (temp_box.left + (temp_box.get_width() / 2)) / (double) raw_dim.width;
        y = (temp_box.top + (temp_box.get_height() / 2)) / (double) raw_dim.height;
        
        double width_left_end = temp_box.left / (double) raw_dim.width;
        double width_right_end = temp_box.right / (double) raw_dim.width;
        double height_top_end = temp_box.top / (double) raw_dim.height;
        double height_bottom_end = temp_box.bottom / (double) raw_dim.height;
        
        half_width = (width_right_end - width_left_end) / 2;
        half_height = (height_bottom_end - height_top_end) / 2;
    }

    public override double[] get_face_vec() {
        return face_vec;
    }
    
    public override bool equals(FaceShape face_shape) {
        return serialize(true) == face_shape.serialize(true);
    }
    
    public override void prepare_ctx(Cairo.Context ctx, Dimensions dim) {
        wide_black_ctx = new Cairo.Context(ctx.get_target());
        set_source_color_from_string(wide_black_ctx, "#000");
        wide_black_ctx.set_line_width(1 * Application.get_scale());
        
        wide_white_ctx = new Cairo.Context(ctx.get_target());
        set_source_color_from_string(wide_white_ctx, "#FFF");
        wide_white_ctx.set_line_width(1 * Application.get_scale());
    }
    
    private bool on_canvas_manipulation(int x, int y) {
        Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position();
        
        // box is maintained in coordinates non-relative to photo's position on canvas ...
        // but bound tool to photo itself
        x -= scaled_pos.x;
        if (x < 0)
            x = 0;
        else if (x >= scaled_pos.width)
            x = scaled_pos.width - 1;
        
        y -= scaled_pos.y;
        if (y < 0)
            y = 0;
        else if (y >= scaled_pos.height)
            y = scaled_pos.height - 1;
        
        // need to make manipulations outside of box structure, because its methods do sanity
        // checking
        int left = box.left;
        int top = box.top;
        int right = box.right;
        int bottom = box.bottom;

        // get extra geometric information needed to enforce constraints
        int photo_right_edge = canvas.get_scaled_pixbuf().width - 1;
        int photo_bottom_edge = canvas.get_scaled_pixbuf().height - 1;
        
        switch (in_manipulation) {
            case BoxLocation.LEFT_SIDE:
                left = x;
            break;

            case BoxLocation.TOP_SIDE:
                top = y;
            break;

            case BoxLocation.RIGHT_SIDE:
                right = x;
            break;

            case BoxLocation.BOTTOM_SIDE:
                bottom = y;
            break;

            case BoxLocation.TOP_LEFT:
                top = y;
                left = x;
            break;

            case BoxLocation.BOTTOM_LEFT:
                bottom = y;
                left = x;
            break;

            case BoxLocation.TOP_RIGHT:
                top = y;
                right = x;
            break;

            case BoxLocation.BOTTOM_RIGHT:
                bottom = y;
                right = x;
            break;

            case BoxLocation.INSIDE:
                assert(last_grab_x >= 0);
                assert(last_grab_y >= 0);
                
                int delta_x = (x - last_grab_x);
                int delta_y = (y - last_grab_y);
                
                last_grab_x = x;
                last_grab_y = y;

                int width = right - left + 1;
                int height = bottom - top + 1;
                
                left += delta_x;
                top += delta_y;
                right += delta_x;
                bottom += delta_y;
                
                // bound box inside of photo
                if (left < 0)
                    left = 0;
                
                if (top < 0)
                    top = 0;
                
                if (right >= scaled_pos.width)
                    right = scaled_pos.width - 1;
                
                if (bottom >= scaled_pos.height)
                    bottom = scaled_pos.height - 1;
                
                int adj_width = right - left + 1;
                int adj_height = bottom - top + 1;
                
                // don't let adjustments affect the size of the box
                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;
            
            default:
                // do nothing, not even a repaint
                return false;
        }

        // Check if the mouse has gone out of bounds, and if it has, make sure that the
        // face shape edges stay within the photo bounds.
        int width = right - left + 1;
        int height = bottom - top + 1;
        
        if (left < 0)
            left = 0;
        if (top < 0)
            top = 0;
        if (right > photo_right_edge)
            right = photo_right_edge;
        if (bottom > photo_bottom_edge)
            bottom = photo_bottom_edge;

        width = right - left + 1;
        height = bottom - top + 1;

        switch (in_manipulation) {
            case BoxLocation.LEFT_SIDE:
            case BoxLocation.TOP_LEFT:
            case BoxLocation.BOTTOM_LEFT:
                if (width < FACE_MIN_SIZE)
                    left = right - FACE_MIN_SIZE;
            break;
            
            case BoxLocation.RIGHT_SIDE:
            case BoxLocation.TOP_RIGHT:
            case BoxLocation.BOTTOM_RIGHT:
                if (width < FACE_MIN_SIZE)
                    right = left + FACE_MIN_SIZE;
            break;

            default:
            break;
        }

        switch (in_manipulation) {
            case BoxLocation.TOP_SIDE:
            case BoxLocation.TOP_LEFT:
            case BoxLocation.TOP_RIGHT:
                if (height < FACE_MIN_SIZE)
                    top = bottom - FACE_MIN_SIZE;
            break;

            case BoxLocation.BOTTOM_SIDE:
            case BoxLocation.BOTTOM_LEFT:
            case BoxLocation.BOTTOM_RIGHT:
                if (height < FACE_MIN_SIZE)
                    bottom = top + FACE_MIN_SIZE;
            break;
            
            default:
            break;
        }
       
        Box new_box = Box(left, top, right, bottom);
        
        if (!box.equals(new_box)) {
            canvas.invalidate_area(box);
            
            if (in_manipulation != BoxLocation.INSIDE)
                check_resized_box(new_box);
            
            box = new_box;
            paint();
            canvas.invalidate_area(new_box);
        }
        
        canvas.repaint();
        
        serialized = null;
        
        return false;
    }
    
    private void check_resized_box(Box new_box) {
        Box horizontal;
        bool horizontal_enlarged;
        Box vertical;
        bool vertical_enlarged;
        BoxComplements complements = box.resized_complements(new_box, out horizontal,
            out horizontal_enlarged, out vertical, out vertical_enlarged);
        
        // this should never happen ... this means that the operation wasn't a resize
        assert(complements != BoxComplements.NONE);
    }
    
    private void update_cursor(int x, int y) {
        // box is not maintained relative to photo's position on canvas
        Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position();
        Box offset_scaled_box = box.get_offset(scaled_pos.x, scaled_pos.y);
        
        string? cursor_type = null;
        switch (offset_scaled_box.approx_location(x, y)) {
            case BoxLocation.LEFT_SIDE:
                cursor_type = "w-resize";
            break;

            case BoxLocation.TOP_SIDE:
                cursor_type = "n-resize";
            break;

            case BoxLocation.RIGHT_SIDE:
                cursor_type = "e-resize";
            break;

            case BoxLocation.BOTTOM_SIDE:
                cursor_type = "s-resize";
            break;

            case BoxLocation.TOP_LEFT:
                cursor_type = "nw-resize";
            break;

            case BoxLocation.BOTTOM_LEFT:
                cursor_type = "sw-resize";
            break;

            case BoxLocation.TOP_RIGHT:
                cursor_type = "ne-resize";
            break;

            case BoxLocation.BOTTOM_RIGHT:
                cursor_type = "se-resize";
            break;

            case BoxLocation.INSIDE:
                cursor_type = "move";
            break;

            default:
                // use Gdk.CursorType.LEFT_PTR
            break;
        }

        if (cursor_type != current_cursor_type) {
            canvas.set_cursor(cursor_type);
            current_cursor_type = cursor_type;
        }
    }
    
    public override void on_motion(int x, int y, Gdk.ModifierType mask) {
        // only deal with manipulating the box when click-and-dragging one of the edges
        // or the interior
        if (in_manipulation != BoxLocation.OUTSIDE)
            on_canvas_manipulation(x, y);
        
        update_cursor(x, y);
    }
    
    public override bool on_left_click(int x, int y) {
        Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position();
        
        // box is not maintained relative to photo's position on canvas
        Box offset_scaled_box = box.get_offset(scaled_pixbuf_pos.x, scaled_pixbuf_pos.y);
        
        // determine where the mouse down landed and store for future events
        in_manipulation = offset_scaled_box.approx_location(x, y);
        last_grab_x = x -= scaled_pixbuf_pos.x;
        last_grab_y = y -= scaled_pixbuf_pos.y;
        
        return box.approx_location(x, y) != BoxLocation.OUTSIDE;
    }
    
    public override void on_left_released(int x, int y) {
        if (box.get_width() < FACE_MIN_SIZE) {
            delete_me_requested();
            
            return;
        }
        
        if (is_editable()) {
            face_window.present();
        }
        
        // nothing to do if released outside of the face box
        if (in_manipulation == BoxLocation.OUTSIDE)
            return;
        
        // end manipulation
        in_manipulation = BoxLocation.OUTSIDE;
        last_grab_x = -1;
        last_grab_y = -1;
        
        update_cursor(x, y);
    }
    
    public override void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled) {
        Dimensions new_dim = Dimensions.for_pixbuf(scaled);
        Dimensions uncropped_dim = canvas.get_photo().get_original_dimensions();
        
        Box new_box = box.get_scaled_similar(old_dim, uncropped_dim);
        
        // rescale back to new size
        box = new_box.get_scaled_similar(uncropped_dim, new_dim);
    }
    
    public override bool cursor_is_over(int x, int y) {
        // box is not maintained relative to photo's position on canvas
        Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position();
        Box offset_scaled_box = box.get_offset(scaled_pos.x, scaled_pos.y);
        
        return offset_scaled_box.approx_location(x, y) != BoxLocation.OUTSIDE;
    }
    
    public override double get_distance(int x, int y) {
        double center_x = box.left + box.get_width() / 2.0;
        double center_y = box.top + box.get_height() / 2.0;
        
        return Math.sqrt((center_x - x) * (center_x - x) + (center_y - y) * (center_y - y));
    }
}
