import javax.swing.*;
import javax.swing.event.ChangeEvent;
import java.awt.*;
import java.awt.event.*;
import java.awt.image.BufferedImage;
import java.lang.reflect.Field;
import java.text.DecimalFormat;

public class JuliaSet {
    public JFrame frame;
    public JPanel panel;
    public JPanel controlPanel1;
    public JPanel controlPanel2;
    public JPanel imagePanel;
    public JPanel borderPanel1;
    public JPanel borderPanel2;
    public JPanel borderPanel3;

    JTextField textFieldCY;
    JTextField textFieldIterations;
    JLabel labelZoom;
    JLabel labelColorMethod;
    JLabel imageLabel;
    JLabel hueSliderLabel;
    JLabel brightnessSliderLabel;
    JLabel blackThresholdLabel;
    JSlider sliderZoom;
    JSlider sliderColorHue;
    JSlider sliderColorBrightness;
    JSlider sliderBlackThreshold;

    BufferedImage juliaSetImage;

    int sliderFactor;
    int imageRectX;
    int imageRectY;
    int colorHueSliderFactor;
    double colorBrightnessSliderFactor;
    double blackThresholdSliderFactor;

    public int juliaIterations;
    public double juliaCX;
    public double juliaCY;
    public double juliaXAxisMin;
    public double juliaXAxisMax;
    public double juliaYAxisMin;
    public double juliaYAxisMax;

    public double distanceMinMaxX;
    public double distanceMinMaxY;
    public double offsetX;
    public double offsetY;
    public double zoom;
    public double stretchFactorX;
    public double stretchFactorY;
    public double mouseOffsetX;
    public double mouseOffsetY;

    // Rounding to two decimal points
    // Used for the slider
    static final DecimalFormat df = new DecimalFormat("0.00");

    // For the color picker
    public int selectedColorMethod;

    static ButtonGroup colorMethods;
    static JRadioButton colorMethod1;
    static JRadioButton colorMethod2;

    // for the resize event
    private javax.swing.Timer waitingTimer;

    JuliaSet() {
        initWindow();
        initGuiElements();
        showGUI();
        drawJuliaSet();
    }

    public void drawJuliaSet() {
        // Start timer
        long startTime = System.currentTimeMillis();

        // Because the JPanel with the image on it doesn't want to reveal its own size...
        int trueHeight = this.frame.getContentPane().getComponent(0).getHeight() - (this.controlPanel1.getHeight() + this.controlPanel2.getHeight() + 3 * 10);

        this.imageRectX = this.imagePanel.getBounds().width;
        this.imageRectY = trueHeight;

        // If the user switches the variables around
        double temp;
        if (juliaXAxisMin > juliaXAxisMax) {
            temp = juliaXAxisMax;
            juliaXAxisMax = juliaXAxisMin;
            juliaXAxisMin = temp;
        }
        if (juliaYAxisMin > juliaYAxisMax) {
            temp = juliaYAxisMax;
            juliaYAxisMax = juliaYAxisMin;
            juliaYAxisMin = temp;
        }

        // All the calculations are done beforehand
        // to save time. No need to calculate them
        // every single time...

        // zoom = slider value ^ 3
        zoom = Math.pow((double) sliderFactor / 10.0, 3);

        distanceMinMaxX = Math.abs(juliaXAxisMax - juliaXAxisMin);
        distanceMinMaxY = Math.abs(juliaYAxisMax - juliaYAxisMin);

        offsetX = (juliaXAxisMin + (Math.abs(distanceMinMaxX) / 2));
        offsetY = (juliaYAxisMin + (Math.abs(distanceMinMaxY) / 2));

        stretchFactorX = distanceMinMaxX / 2;
        stretchFactorY = distanceMinMaxY / 2;

        // Create image to draw on
        juliaSetImage = new BufferedImage(this.imageRectX, this.imageRectY, BufferedImage.TYPE_INT_RGB);

        int systemThreads = Runtime.getRuntime().availableProcessors();

        JuliaDrawThread[] jA = new JuliaDrawThread[systemThreads];

        for (int t = 0; t < systemThreads; t++) {
            // Make sure that the reference is empty
            jA[t] = null;
            jA[t] = new JuliaDrawThread(t, systemThreads, this);
        }

        // Create array of threads
        Thread[] tA = new Thread[systemThreads];

        // here's where we
        for (int t = 0; t < systemThreads; t++) {
            tA[t] = new Thread(jA[t]);
            tA[t].start();
        }

        BufferedImage[] partialImages = new BufferedImage[systemThreads];
        for (int t = 0; t < systemThreads; t++) {
            try {
                tA[t].join();
                partialImages[t] = jA[t].getPartialImage();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // At this point we've collected all the image data
        // Merge into one image
        int currentHeight = 0;
        Graphics2D g2d = juliaSetImage.createGraphics();
        for (BufferedImage pImage: partialImages) {
            g2d.drawImage(pImage, 0, currentHeight, null);
            currentHeight += pImage.getHeight();
        }

        g2d.dispose();

        // Draw the image here
        imageLabel.setIcon(new ImageIcon(juliaSetImage));

        // End timer
        long endTime = System.currentTimeMillis();
        System.out.printf("Time: %dms%n", endTime - startTime);
    }

    public void zoomSliderStateChanged(ChangeEvent e) {
        JSlider source = (JSlider) e.getSource();
        sliderFactor = source.getValue();

        // Rounding the number before writing it to the label
        labelZoom.setText(df.format((Math.pow((double) sliderFactor / 10, 3))));

        drawJuliaSet();
    }

    public void colorHueSliderStateChanged(ChangeEvent e) {
        JSlider source = (JSlider) e.getSource();
        colorHueSliderFactor = source.getValue();
        drawJuliaSet();
    }

    public void brightnessSliderStateChanged(ChangeEvent e) {
        JSlider source = (JSlider) e.getSource();
        colorBrightnessSliderFactor = (float) source.getValue();
        drawJuliaSet();
    }

    public void blackThresholdSliderStateChanged(ChangeEvent e) {
        JSlider source = (JSlider) e.getSource();
        blackThresholdSliderFactor = (float) source.getValue() / 250;
        drawJuliaSet();
    }

    // Universal text field changer
    // That way I don't have to re-write the float and integer parsing for every single text field
    // Using reflection we just look for a variable with a matching name

    // Help for key listener: https://kodejava.org/how-do-i-add-key-listener-event-handler-to-jtextfield/
    public void textFieldChanged(JTextField jt, String variableToBeChanged) {
        try {
            // Get variable of object by name
            Field f = this.getClass().getDeclaredField(variableToBeChanged);
            if (f.getType().toString().equals("double")) {
                try {
                    f.setDouble(this, Double.parseDouble(jt.getText()));
                    // Value changed successfully, redraw Julia set
                    drawJuliaSet();
                } catch (NumberFormatException ex) {
                    jt.setText(Double.toString(f.getDouble(this)));
                    JOptionPane.showConfirmDialog(null, "Please only enter floating point numbers", "Floats only", JOptionPane.DEFAULT_OPTION);
                } catch (IllegalAccessException e) {
                    // Just like the other exceptions these should never occur
                    // because the user doesn't have control over them
                    // Prints stack trace to the terminal, but the user can't see that *taps head*
                    e.printStackTrace();
                }
            }
            if (f.getType().toString().equals("int")) {
                try {
                    f.setInt(this, Integer.parseInt(jt.getText()));
                    drawJuliaSet();
                } catch (NumberFormatException ex) {
                    jt.setText(Integer.toString(f.getInt(this)));
                    JOptionPane.showConfirmDialog(null, "Please only enter integers", "Integers only", JOptionPane.DEFAULT_OPTION);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        }
        // These are never supposed to be hit
        // The user doesn't have control over any of this
        // They're just here so IntelliJ doesn't complain
        catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    class ColorRadioButtonActionListener implements ActionListener {
        @Override
        public void actionPerformed(ActionEvent event) {
            JRadioButton button = (JRadioButton) event.getSource();

            if (button == colorMethod1) {
                selectedColorMethod = 1;
                hueSliderLabel.setVisible(false);
                brightnessSliderLabel.setVisible(false);
                sliderColorBrightness.setVisible(false);
                sliderColorHue.setVisible(false);
                blackThresholdLabel.setVisible(false);
                sliderBlackThreshold.setVisible(false);
            } else if (button == colorMethod2) {
                selectedColorMethod = 2;
                hueSliderLabel.setVisible(true);
                brightnessSliderLabel.setVisible(true);
                sliderColorBrightness.setVisible(true);
                sliderColorHue.setVisible(true);
                blackThresholdLabel.setVisible(true);
                sliderBlackThreshold.setVisible(true);
            }

            drawJuliaSet();
        }
    }

    public void initGuiElements() {
        this.panel = new JPanel();
        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));

        // Create panels to serve as borders
        borderPanel1 = new JPanel();
        borderPanel2 = new JPanel();
        borderPanel3 = new JPanel();

        borderPanel1.setMinimumSize(new Dimension(10, 10));
        borderPanel1.setPreferredSize(new Dimension(10, 10));
        borderPanel2.setMinimumSize(new Dimension(10, 10));
        borderPanel2.setPreferredSize(new Dimension(10, 10));
        borderPanel3.setMinimumSize(new Dimension(10, 10));
        borderPanel3.setPreferredSize(new Dimension(10, 10));

        borderPanel1.setLayout(new BoxLayout(borderPanel1, BoxLayout.X_AXIS));
        borderPanel2.setLayout(new BoxLayout(borderPanel2, BoxLayout.X_AXIS));
        borderPanel3.setLayout(new BoxLayout(borderPanel3, BoxLayout.X_AXIS));

        // Create JPanels for the controls at the top
        this.controlPanel1 = new JPanel();
        this.controlPanel2 = new JPanel();


        this.controlPanel1.setLayout(new BoxLayout(this.controlPanel1, BoxLayout.X_AXIS));
        this.controlPanel2.setLayout(new BoxLayout(this.controlPanel2, BoxLayout.X_AXIS));

        // Add space in-between control elements
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));
        JLabel labelParameterC = new JLabel("Parameter C:");
        this.controlPanel1.add(labelParameterC);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));

        JLabel labelCX = new JLabel("X");
        this.controlPanel1.add(labelCX);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));

        JTextField textFieldCX = new JTextField("0.0");
        textFieldCX.setMaximumSize(new Dimension(50, 30));
        juliaCX = 0.0 f;
        textFieldCX.addKeyListener(new KeyAdapter() {
            public void keyReleased(KeyEvent e) {
                textFieldChanged(textFieldCX, "juliaCX");
            }
        });
        textFieldCX.setPreferredSize(new Dimension(40, 30));
        textFieldCX.setMaximumSize(new Dimension(90, 30));
        this.controlPanel1.add(textFieldCX);

        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));

        JLabel labelCY = new JLabel("Y");
        this.controlPanel1.add(labelCY);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));


        textFieldCY = new JTextField("0.0");
        textFieldCY.setPreferredSize(new Dimension(40, 30));
        textFieldCY.setMaximumSize(new Dimension(90, 30));
        juliaCY = 0.0 f;
        textFieldCY.addKeyListener(new KeyAdapter() {
            public void keyReleased(KeyEvent e) {
                textFieldChanged(textFieldCY, "juliaCY");
            }
        });

        this.controlPanel1.add(textFieldCY);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));

        JLabel labelIterations = new JLabel("Iterations");
        this.controlPanel1.add(labelIterations);

        textFieldIterations = new JTextField("300");
        textFieldIterations.setPreferredSize(new Dimension(40, 30));
        textFieldIterations.setMaximumSize(new Dimension(90, 30));
        juliaIterations = 300;
        textFieldIterations.addKeyListener(new KeyAdapter() {
            public void keyReleased(KeyEvent e) {
                textFieldChanged(textFieldIterations, "juliaIterations");
            }
        });
        this.controlPanel1.add(textFieldIterations);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));

        // Initial values to constrain the first drawn julia set
        juliaXAxisMin = -2.0 f;
        juliaXAxisMax = 2.0 f;
        juliaYAxisMax = 2.0 f;
        juliaYAxisMin = -2.0 f;

        // Zoom label
        labelZoom = new JLabel("Zoom");
        this.controlPanel1.add(labelZoom);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));

        // Zoom slider
        // unfortunately it only takes integers, so we'll have to divide by ten
        // to get the number we actually want
        sliderZoom = new JSlider(1, 1000, 10);

        // + 2 because the slider feels slightly off-center otherwise
        sliderZoom.addChangeListener(this::zoomSliderStateChanged);
        this.controlPanel1.add(sliderZoom);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));

        // Text field right next to the slider
        // shows the zoom factor, is not writable
        labelZoom = new JLabel("1,0");
        sliderFactor = 10;
        this.controlPanel1.add(labelZoom);
        this.controlPanel1.add(Box.createRigidArea(new Dimension(7, 0)));
        labelZoom.setMinimumSize(new Dimension(50, 30));
        labelZoom.setPreferredSize(new Dimension(50, 30));

        labelColorMethod = new JLabel("View:");
        this.controlPanel2.add(Box.createRigidArea(new Dimension(7, 0)));
        this.controlPanel2.add(labelColorMethod);

        // Create buttons
        colorMethod1 = new JRadioButton();
        this.controlPanel2.add(colorMethod1);

        // Set this one to the default
        // We do this before we add the item listener
        // No need to draw the set twice just because
        // we clicked a button
        colorMethod1.doClick();
        selectedColorMethod = 1;

        colorMethod2 = new JRadioButton();
        this.controlPanel2.add(colorMethod2);

        // Create group for the radio buttons
        colorMethods = new ButtonGroup();
        colorMethods.add(colorMethod1);
        colorMethods.add(colorMethod2);

        // Add listener for the buttons
        ColorRadioButtonActionListener colorActionListener = new ColorRadioButtonActionListener();
        colorMethod1.addActionListener(colorActionListener);
        colorMethod2.addActionListener(colorActionListener);

        // "H" for the slider
        hueSliderLabel = new JLabel("H  ");
        this.controlPanel2.add(hueSliderLabel);
        hueSliderLabel.setVisible(false);

        // Hue slider
        sliderColorHue = new JSlider(0, 100, 0);
        // + 2 because the slider feels slightly off-center otherwise
        sliderColorHue.addChangeListener(this::colorHueSliderStateChanged);
        sliderColorHue.setVisible(false);
        this.controlPanel2.add(sliderColorHue);

        brightnessSliderLabel = new JLabel("B  ");
        this.controlPanel2.add(brightnessSliderLabel);
        brightnessSliderLabel.setVisible(false);

        // Brightness slider
        sliderColorBrightness = new JSlider(1, 100, 1);
        // + 2 because the slider feels slightly off-center otherwise
        sliderColorBrightness.addChangeListener(this::brightnessSliderStateChanged);
        sliderColorBrightness.setVisible(false);
        this.controlPanel2.add(sliderColorBrightness);
        colorBrightnessSliderFactor = 1;

        // Black threshold label
        blackThresholdLabel = new JLabel("Threshold  ");
        blackThresholdLabel.setVisible(false);
        this.controlPanel2.add(blackThresholdLabel);

        // Black threshold label
        sliderBlackThreshold = new JSlider(0, 100, 0);
        sliderBlackThreshold.addChangeListener(this::blackThresholdSliderStateChanged);
        sliderBlackThreshold.setVisible(false);
        this.controlPanel2.add(sliderBlackThreshold);

        // Draw image onto label
        // Every time setIcon() is called the image updates
        imagePanel = new JPanel();
        imageLabel = new JLabel();
        this.imagePanel.add(imageLabel);

        System.gc();
    }

    public void initWindow() {
        frame = new JFrame();
        frame.setTitle("Julia Set");

        // Windows UI
        try {
            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
            ex.printStackTrace();
        }

        frame.setMinimumSize(new Dimension(800, 800));

        frame.setLocationRelativeTo(null);

        // Actually make sure that the window gets closed when we press the X button
        frame.addWindowListener(new WindowAdapter() {
            public void windowClosing(WindowEvent we) {
                we.getWindow().dispose(); // Throw away the window
                System.exit(0); // Exit JVM
            }
        });
    }

    public void actionPerformed(ActionEvent ae) {
        if (ae.getSource() == this.waitingTimer) {
            //Stop timer, event has ended
            this.waitingTimer.stop();
            this.waitingTimer = null;

            this.imagePanel.setPreferredSize(new Dimension(this.imagePanel.getBounds().width, this.imagePanel.getBounds().height));
            drawJuliaSet();
        }
    }

    public void showGUI() {
        this.panel.add(borderPanel1);
        this.panel.add(controlPanel1);
        this.panel.add(borderPanel2);
        this.panel.add(controlPanel2);
        this.panel.add(borderPanel3);
        this.panel.add(imagePanel);
        this.frame.add(this.panel);
        this.frame.setVisible(true);

        // Implement timer to wait until the resize event is over
        this.frame.addComponentListener(new ComponentAdapter() {
            public void componentResized(ComponentEvent componentEvent) {
                if (waitingTimer == null) {
                    waitingTimer = new Timer(50, JuliaSet.this::actionPerformed);
                    waitingTimer.start();
                } else {
                    waitingTimer.restart();
                }
            }
        });

        MouseAdapter ma = new MouseAdapter() {
            private Point startPoint;

            @Override
            public void mousePressed(MouseEvent e) {
                startPoint = e.getPoint();
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                Point p = e.getPoint();
                mouseOffsetX += (p.x - startPoint.x) / zoom;
                mouseOffsetY += (p.y - startPoint.y) / zoom;
                startPoint = null;
                drawJuliaSet();
            }

            @Override
            public void mouseWheelMoved(MouseWheelEvent e) {
                sliderZoom.setValue(sliderZoom.getValue() - e.getWheelRotation());
            }
        };

        frame.addMouseListener(ma);
        frame.addMouseMotionListener(ma);
        frame.addMouseWheelListener(ma);
    }

    public static void main(String[] args) {
        new JuliaSet();
    }
}