/*
 * @(#)ColorPickerPanel.java
 *
 * $Date: 2014-06-06 20:04:49 +0200 (P, 06 jún. 2014) $
 *
 * Copyright (c) 2011 by Jeremy Wood.
 * All rights reserved.
 *
 * The copyright of this software is owned by Jeremy Wood. 
 * You may not use, copy or modify this software, except in  
 * accordance with the license agreement you entered into with  
 * Jeremy Wood. For details see accompanying license terms.
 * 
 * This software is probably, but not necessarily, discussed here:
 * https://javagraphics.java.net/
 * 
 * That site should also contain the most recent official version
 * of this software.  (See the SVN repository for more details.)
 */
package com.bric.swing;

import com.bric.plaf.PlafPaintUtils;

import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.MouseInputAdapter;
import javax.swing.event.MouseInputListener;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseEvent;
import java.awt.geom.Ellipse2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;

import static com.bric.swing.ColorPicker.BLUE;
import static com.bric.swing.ColorPicker.BRI;
import static com.bric.swing.ColorPicker.GREEN;
import static com.bric.swing.ColorPicker.HUE;
import static com.bric.swing.ColorPicker.RED;
import static com.bric.swing.ColorPicker.SAT;
import static java.awt.RenderingHints.KEY_ANTIALIASING;
import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON;
import static java.awt.image.BufferedImage.TYPE_INT_ARGB;
import static java.lang.Math.PI;
import static net.jafama.FastMath.cos;
import static net.jafama.FastMath.sin;

/**
 * This is the large graphic element in the <code>ColorPicker</code>
 * that depicts a wide range of colors.
 * <P>This panel can operate in 6 different modes.  In each mode a different
 * property is held constant: hue, saturation, brightness, red, green, or blue.
 * (Each property is identified with a constant in the <code>ColorPicker</code> class,
 * such as: <code>ColorPicker.HUE</code> or <code>ColorPicker.GREEN</code>.)
 * <P>In saturation and brightness mode, a wheel is used.  Although it doesn't
 * use as many pixels as a square does: it is a very aesthetic model since the hue can
 * wrap around in a complete circle.  (Also, on top of looks, this is how most
 * people learn to think the color spectrum, so it has that advantage, too).
 * In all other modes a square is used.
 * <P>The user can click in this panel to select a new color.  The selected color is
 * highlighted with a circle drawn around it.  Also once this
 * component has the keyboard focus, the user can use the arrow keys to
 * traverse the available colors.
 * <P>Note this component is public and exists independently of the
 * <code>ColorPicker</code> class.  The only way this class is dependent
 * on the <code>ColorPicker</code> class is when the constants for the modes
 * are used.
 * <P>The graphic in this panel will be based on either the width or
 * the height of this component: depending on which is smaller.
 *
 * @see com.bric.swing.ColorPicker
 * @see com.bric.swing.ColorPickerDialog
 */
public class ColorPickerPanel extends JPanel {
    private static final long serialVersionUID = 1L;

    /**
     * The maximum size the graphic will be.  No matter
     * how big the panel becomes, the graphic will not exceed
     * this length.
     * <P>(This is enforced because only 1 BufferedImage is used
     * to render the graphic.  This image is created once at a fixed
     * size and is never replaced.)
     */
    public static final int MAX_SIZE = 325;

    /**
     * This controls how the colors are displayed.
     */
    private int mode = BRI;

    /**
     * The point used to indicate the selected color.
     */
    private Point point = new Point(0, 0);

    private final List<ChangeListener> changeListeners = new ArrayList<>();

    /* Floats from [0,1].  They must be kept distinct, because
     * when you convert them to RGB coordinates HSB(0,0,0) and HSB (.5,0,0)
     * and then convert them back to HSB coordinates, the hue always shifts back to zero.
     */
    private float hue = -1;
    private float sat = -1;
    private float bri = -1;
    private int red = -1;
    private int green = -1;
    private int blue = -1;
    private int lastPressRed = -1;
    private int lastPressGreen = -1;
    private int lastPressBlue = -1;

    // always true, except right after a mouse press or mouse release
    private boolean adjusting = true;

    private final MouseInputListener mouseListener = new MouseInputAdapter() {
        @Override
        public void mousePressed(MouseEvent e) {
            change(e, false);
            lastPressRed = red;
            lastPressGreen = green;
            lastPressBlue = blue;
            adjusting = true;
        }

        private void change(MouseEvent e, boolean adjusting) {
            ColorPickerPanel.this.adjusting = adjusting;
            requestFocus();
            Point p = e.getPoint();
            if (mode == BRI || mode == SAT ||
                    mode == HUE) {
                float[] hsb = getHSB(p);
                setHSB(hsb[0], hsb[1], hsb[2]);
            } else {
                int[] rgb = getRGB(p);
                setRGB(rgb[0], rgb[1], rgb[2]);
            }
        }

        @Override
        public void mouseDragged(MouseEvent e) {
            change(e, true);
        }

        @Override
        public void mouseReleased(MouseEvent e) {
            adjusting = false;
            // always fire, except if this was a click, and the
            // color is the same as at mousePressed
            if (lastPressRed != red || lastPressGreen != green || lastPressBlue != blue) {
                fireChangeListeners();
            }
            adjusting = true;
        }
    };

    public boolean isAdjusting() {
        return adjusting;
    }

    private final KeyListener keyListener = new KeyAdapter() {
        @Override
        public void keyPressed(KeyEvent e) {
            int dx = 0;
            int dy = 0;
            if (e.getKeyCode() == KeyEvent.VK_LEFT) {
                dx = -1;
            } else if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
                dx = 1;
            } else if (e.getKeyCode() == KeyEvent.VK_UP) {
                dy = -1;
            } else if (e.getKeyCode() == KeyEvent.VK_DOWN) {
                dy = 1;
            }
            int multiplier = 1;
            if (e.isShiftDown() && e.isAltDown()) {
                multiplier = 10;
            } else if (e.isShiftDown() || e.isAltDown()) {
                multiplier = 5;
            }
            if (dx != 0 || dy != 0) {
                int size = Math.min(MAX_SIZE, Math
                        .min(getWidth() - imagePadding.left - imagePadding.right, getHeight() - imagePadding.top - imagePadding.bottom));

                int offsetX = getWidth() / 2 - size / 2;
                int offsetY = getHeight() / 2 - size / 2;
                mouseListener.mousePressed(new MouseEvent(ColorPickerPanel.this,
                        MouseEvent.MOUSE_PRESSED,
                        System.currentTimeMillis(), 0,
                        point.x + multiplier * dx + offsetX,
                        point.y + multiplier * dy + offsetY,
                        1, false
                ));
            }
        }
    };

    private final FocusListener focusListener = new FocusListener() {
        @Override
        public void focusGained(FocusEvent e) {
            repaint();
        }

        @Override
        public void focusLost(FocusEvent e) {
            repaint();
        }
    };

    private final BufferedImage image = new BufferedImage(MAX_SIZE, MAX_SIZE, TYPE_INT_ARGB);

    /**
     * Creates a new <code>ColorPickerPanel</code>
     */
    public ColorPickerPanel() {
        setMaximumSize(new Dimension(MAX_SIZE + imagePadding.left + imagePadding.right,
                MAX_SIZE + imagePadding.top + imagePadding.bottom));
        setPreferredSize(new Dimension((int) (MAX_SIZE * 0.75), (int) (MAX_SIZE * 0.75)));

        setRGB(0, 0, 0);
        addMouseListener(mouseListener);
        addMouseMotionListener(mouseListener);

        setFocusable(true);
        addKeyListener(keyListener);
        addFocusListener(focusListener);

        setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));
        addComponentListener(new ComponentAdapter() {
                @Override
                public void componentResized(ComponentEvent e) {
                    regeneratePoint();
                    regenerateImage();
                }
            });
    }

    /**
     * This listener will be notified when the current HSB or RGB values
     * change.
     */
    public void addChangeListener(ChangeListener l) {
        if (changeListeners.contains(l)) {
            return;
        }
        changeListeners.add(l);
    }

    /**
     * Remove a <code>ChangeListener</code> so it is no longer
     * notified when the selected color changes.
     */
    public void removeChangeListener(ChangeListener l) {
        changeListeners.remove(l);
    }

    private void fireChangeListeners() {
        if (changeListeners == null) {
            return;
        }
        for (ChangeListener l : changeListeners) {
            try {
                l.stateChanged(new ChangeEvent(this));
            } catch (RuntimeException e) {
                e.printStackTrace();
            }
        }
    }

    private final Insets imagePadding = new Insets(6, 6, 6, 6);

    @Override
    public void paint(Graphics g) {
        super.paint(g);

        Graphics2D g2 = (Graphics2D) g;
        int size = Math.min(MAX_SIZE,
                Math.min(getWidth() - imagePadding.left - imagePadding.right,
                        getHeight() - imagePadding.top - imagePadding.bottom));

        g2.translate(
                getWidth() / 2 - size / 2,
                getHeight() / 2 - size / 2);
        g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);

        Shape shape;

        if (mode == SAT || mode == BRI) {
            shape = new Ellipse2D.Float(0, 0, size, size);
        } else {
            Rectangle r = new Rectangle(0, 0, size, size);
            shape = r;
        }

        if (hasFocus()) {
            PlafPaintUtils.paintFocus(g2, shape, 3);
        }

        if (!(shape instanceof Rectangle)) {
            //paint a circular shadow
            g2.translate(2, 2);
            g2.setColor(new Color(0, 0, 0, 20));
            g2.fill(new Ellipse2D.Float(-2, -2, size + 4, size + 4));
            g2.setColor(new Color(0, 0, 0, 40));
            g2.fill(new Ellipse2D.Float(-1, -1, size + 2, size + 2));
            g2.setColor(new Color(0, 0, 0, 80));
            g2.fill(new Ellipse2D.Float(0, 0, size, size));
            g2.translate(-2, -2);
        }

        g2.drawImage(image, 0, 0, size, size, 0, 0, size, size, null);

        g2.setStroke(new BasicStroke(1));
        if (shape instanceof Rectangle) {
            Rectangle r = (Rectangle) shape;
            PlafPaintUtils.drawBevel(g2, r);
        } else {
            g2.setColor(new Color(0, 0, 0, 120));
            g2.draw(shape);
        }

        g2.setColor(Color.white);
        g2.setStroke(new BasicStroke(1));
        g2.draw(new Ellipse2D.Float(point.x - 3, point.y - 3, 6, 6));
        g2.setColor(Color.black);
        g2.draw(new Ellipse2D.Float(point.x - 4, point.y - 4, 8, 8));

        g.translate(-imagePadding.left, -imagePadding.top);
    }

    /**
     * Set the mode of this panel.
     *
     * @param mode This must be one of the following constants from the <code>ColorPicker</code> class:
     *             <code>HUE</code>, <code>SAT</code>, <code>BRI</code>, <code>RED</code>, <code>GREEN</code>, or <code>BLUE</code>
     */
    public void setMode(int mode) {
        if (!(mode == HUE || mode == SAT || mode == BRI ||
                mode == RED || mode == GREEN || mode == BLUE)) {
            throw new IllegalArgumentException("The mode must be HUE, SAT, BRI, RED, GREEN, or BLUE.");
        }

        if (this.mode == mode) {
            return;
        }
        this.mode = mode;
        regenerateImage();
        regeneratePoint();
    }

    /**
     * Sets the selected color of this panel.
     * <P>If this panel is in HUE, SAT, or BRI mode, then
     * this method converts these values to HSB coordinates
     * and calls <code>setHSB</code>.
     * <P>This method may regenerate the graphic if necessary.
     *
     * @param r the red value of the selected color.
     * @param g the green value of the selected color.
     * @param b the blue value of the selected color.
     */
    public void setRGB(int r, int g, int b) {
        if (r < 0 || r > 255) {
            throw new IllegalArgumentException("The red value (" + r + ") must be between [0,255].");
        }
        if (g < 0 || g > 255) {
            throw new IllegalArgumentException("The green value (" + g + ") must be between [0,255].");
        }
        if (b < 0 || b > 255) {
            throw new IllegalArgumentException("The blue value (" + b + ") must be between [0,255].");
        }

        if (red != r || green != g || blue != b) {
            if (mode == RED ||
                    mode == GREEN ||
                    mode == BLUE) {
                int lastR = red;
                int lastG = green;
                int lastB = blue;
                red = r;
                green = g;
                blue = b;

                if (mode == RED) {
                    if (lastR != r) {
                        regenerateImage();
                    }
                } else if (mode == GREEN) {
                    if (lastG != g) {
                        regenerateImage();
                    }
                } else if (mode == BLUE) {
                    if (lastB != b) {
                        regenerateImage();
                    }
                }
            } else {
                float[] hsb = new float[3];
                Color.RGBtoHSB(r, g, b, hsb);
                setHSB(hsb[0], hsb[1], hsb[2]);
                return;
            }
            regeneratePoint();
            repaint();
            fireChangeListeners();
        }
    }

    /**
     * @return the HSB values of the selected color.
     * Each value is between [0,1].
     */
    public float[] getHSB() {
        return new float[]{hue, sat, bri};
    }

    /**
     * @return the RGB values of the selected color.
     * Each value is between [0,255].
     */
    public int[] getRGB() {
        return new int[]{red, green, blue};
    }

    /**
     * Returns the color at the indicated point in HSB values.
     *
     * @param p a point relative to this panel.
     * @return the HSB values at the point provided.
     */
    public float[] getHSB(Point p) {
        if (mode == RED || mode == GREEN ||
                mode == BLUE) {
            int[] rgb = getRGB(p);
            float[] hsb = Color.RGBtoHSB(rgb[0], rgb[1], rgb[2], null);
            return hsb;
        }

        int size = Math.min(MAX_SIZE, Math
                .min(getWidth() - imagePadding.left - imagePadding.right, getHeight() - imagePadding.top - imagePadding.bottom));
        p.translate(-(getWidth() / 2 - size / 2), -(getHeight() / 2 - size / 2));
        if (mode == BRI || mode == SAT) {
            //the two circular views:
            double radius = size / 2.0;
            double x = p.getX() - size / 2.0;
            double y = p.getY() - size / 2.0;
            double r = Math.sqrt(x * x + y * y) / radius;
            double theta = Math.atan2(y, x) / (PI * 2.0);

            if (r > 1) {
                r = 1;
            }

            if (mode == BRI) {
                return new float[]{
                        (float) (theta + 0.25f),
                        (float) r,
                        bri};
            } else {
                return new float[]{
                        (float) (theta + 0.25f),
                        sat,
                        (float) r
                };
            }
        } else {
            float s = ((float) p.x) / ((float) size);
            float b = ((float) p.y) / ((float) size);
            if (s < 0) {
                s = 0;
            }
            if (s > 1) {
                s = 1;
            }
            if (b < 0) {
                b = 0;
            }
            if (b > 1) {
                b = 1;
            }
            return new float[]{hue, s, b};
        }
    }

    /**
     * Returns the color at the indicated point in RGB values.
     *
     * @param p a point relative to this panel.
     * @return the RGB values at the point provided.
     */
    public int[] getRGB(Point p) {
        if (mode == BRI || mode == SAT ||
                mode == HUE) {
            float[] hsb = getHSB(p);
            int rgb = Color.HSBtoRGB(hsb[0], hsb[1], hsb[2]);
            int r = (rgb & 0xff0000) >> 16;
            int g = (rgb & 0xff00) >> 8;
            int b = (rgb & 0xff);
            return new int[]{r, g, b};
        }

        int size = Math.min(MAX_SIZE, Math
                .min(getWidth() - imagePadding.left - imagePadding.right, getHeight() - imagePadding.top - imagePadding.bottom));
        p.translate(-(getWidth() / 2 - size / 2), -(getHeight() / 2 - size / 2));

        int x2 = p.x * 255 / size;
        int y2 = p.y * 255 / size;
        if (x2 < 0) {
            x2 = 0;
        }
        if (x2 > 255) {
            x2 = 255;
        }
        if (y2 < 0) {
            y2 = 0;
        }
        if (y2 > 255) {
            y2 = 255;
        }

        if (mode == RED) {
            return new int[]{red, x2, y2};
        } else if (mode == GREEN) {
            return new int[]{x2, green, y2};
        } else {
            return new int[]{x2, y2, blue};
        }
    }

    /**
     * Sets the selected color of this panel.
     * <P>If this panel is in RED, GREEN, or BLUE mode, then
     * this method converts these values to RGB coordinates
     * and calls <code>setRGB</code>.
     * <P>This method may regenerate the graphic if necessary.
     *
     * @param h the hue value of the selected color.
     * @param s the saturation value of the selected color.
     * @param b the brightness value of the selected color.
     */
    public void setHSB(float h, float s, float b) {
        //hue is cyclic: it can be any value
        h = (float) (h - Math.floor(h));

        if (s < 0 || s > 1) {
            throw new IllegalArgumentException("The saturation value (" + s + ") must be between [0,1]");
        }
        if (b < 0 || b > 1) {
            throw new IllegalArgumentException("The brightness value (" + b + ") must be between [0,1]");
        }

        if (hue != h || sat != s || bri != b) {
            if (mode == HUE ||
                    mode == BRI ||
                    mode == SAT) {
                float lastHue = hue;
                float lastBri = bri;
                float lastSat = sat;
                hue = h;
                sat = s;
                bri = b;
                if (mode == HUE) {
                    if (lastHue != hue) {
                        regenerateImage();
                    }
                } else if (mode == SAT) {
                    if (lastSat != sat) {
                        regenerateImage();
                    }
                } else if (mode == BRI) {
                    if (lastBri != bri) {
                        regenerateImage();
                    }
                }
            } else {

                Color c = new Color(Color.HSBtoRGB(h, s, b));
                setRGB(c.getRed(), c.getGreen(), c.getBlue());
                return;
            }


            Color c = new Color(Color.HSBtoRGB(hue, sat, bri));
            red = c.getRed();
            green = c.getGreen();
            blue = c.getBlue();

            regeneratePoint();
            repaint();
            fireChangeListeners();
        }
    }

    /**
     * Recalculates the (x,y) point used to indicate the selected color.
     */
    private void regeneratePoint() {
        int size = Math.min(MAX_SIZE,
                Math.min(getWidth() - imagePadding.left - imagePadding.right,
                        getHeight() - imagePadding.top - imagePadding.bottom));
        if (mode == HUE || mode == SAT || mode == BRI) {
            if (mode == HUE) {
                point = new Point(
                        (int) (sat * size + 0.5),
                        (int) (bri * size + 0.5));
            } else if (mode == SAT) {
                double theta = hue * 2 * PI - PI / 2;
                if (theta < 0) {
                    theta += 2 * PI;
                }

                double r = bri * size / 2;
                point = new Point(
                        (int) (r * cos(theta) + 0.5 + size / 2.0),
                        (int) (r * sin(theta) + 0.5 + size / 2.0));
            } else if (mode == BRI) {
                double theta = hue * 2 * PI - PI / 2;
                if (theta < 0) {
                    theta += 2 * PI;
                }
                double r = sat * size / 2;
                point = new Point(
                        (int) (r * cos(theta) + 0.5 + size / 2.0),
                        (int) (r * sin(theta) + 0.5 + size / 2.0));
            }
        } else if (mode == RED) {
            point = new Point(
                    (int) (green * size / 255.0f + 0.49f),
                    (int) (blue * size / 255.0f + 0.49f));
        } else if (mode == GREEN) {
            point = new Point(
                    (int) (red * size / 255.0f + 0.49f),
                    (int) (blue * size / 255.0f + 0.49f));
        } else if (mode == BLUE) {
            point = new Point(
                    (int) (red * size / 255.0f + 0.49f),
                    (int) (green * size / 255.0f + 0.49f));
        }
    }

    /**
     * A row of pixel data we recycle every time we regenerate this image.
     */
    private final int[] row = new int[MAX_SIZE];

    /**
     * Regenerates the image.
     */
    private synchronized void regenerateImage() {
        int size = Math.min(MAX_SIZE,
                Math.min(getWidth() - imagePadding.left - imagePadding.right,
                        getHeight() - imagePadding.top - imagePadding.bottom));

        if (mode == BRI || mode == SAT) {
            float bri2 = this.bri;
            float sat2 = this.sat;
            float radius = size / 2.0f;
            float hue2;
            float k = 1.2f; //the number of pixels to antialias
            for (int y = 0; y < size; y++) {
                float y2 = (y - size / 2.0f);
                for (int x = 0; x < size; x++) {
                    float x2 = (x - size / 2.0f);
                    double theta = Math.atan2(y2, x2) - 3 * PI / 2.0;
                    if (theta < 0) {
                        theta += 2 * PI;
                    }

                    double r = Math.sqrt(x2 * x2 + y2 * y2);
                    if (r <= radius) {
                        if (mode == BRI) {
                            hue2 = (float) (theta / (2 * PI));
                            sat2 = (float) (r / radius);
                        } else { //SAT
                            hue2 = (float) (theta / (2 * PI));
                            bri2 = (float) (r / radius);
                        }
                        row[x] = Color.HSBtoRGB(hue2, sat2, bri2);
                        if (r > radius - k) {
                            int alpha = (int) (255 - 255 * (r - radius + k) / k);
                            if (alpha < 0) {
                                alpha = 0;
                            }
                            if (alpha > 255) {
                                alpha = 255;
                            }
                            row[x] = row[x] & 0xffffff + (alpha << 24);
                        }
                    } else {
                        row[x] = 0x00000000;
                    }
                }
                image.getRaster().setDataElements(0, y, size, 1, row);
            }
        } else if (mode == HUE) {
            float hue2 = this.hue;
            for (int y = 0; y < size; y++) {
                float y2 = ((float) y) / ((float) size);
                for (int x = 0; x < size; x++) {
                    float x2 = ((float) x) / ((float) size);
                    row[x] = Color.HSBtoRGB(hue2, x2, y2);
                }
                image.getRaster().setDataElements(0, y, image.getWidth(), 1, row);
            }
        } else { //mode is RED, GREEN, or BLUE
            int red2 = red;
            int green2 = green;
            int blue2 = blue;
            for (int y = 0; y < size; y++) {
                float y2 = ((float) y) / ((float) size);
                for (int x = 0; x < size; x++) {
                    float x2 = ((float) x) / ((float) size);
                    if (mode == RED) {
                        green2 = (int) (x2 * 255 + 0.49);
                        blue2 = (int) (y2 * 255 + 0.49);
                    } else if (mode == GREEN) {
                        red2 = (int) (x2 * 255 + 0.49);
                        blue2 = (int) (y2 * 255 + 0.49);
                    } else {
                        red2 = (int) (x2 * 255 + 0.49);
                        green2 = (int) (y2 * 255 + 0.49);
                    }
                    row[x] = 0xFF000000 + (red2 << 16) + (green2 << 8) + blue2;
                }
                image.getRaster().setDataElements(0, y, size, 1, row);
            }
        }
        repaint();
    }
}