// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.plugins.streetside.gui;

import static org.openstreetmap.josm.tools.I18n.tr;

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.awt.event.MouseWheelEvent;
import java.awt.event.MouseWheelListener;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.Serial;

import javax.swing.JComponent;

import org.openstreetmap.josm.plugins.streetside.StreetsideLayer;
import org.openstreetmap.josm.plugins.streetside.actions.StreetsideDownloadAction;
import org.openstreetmap.josm.plugins.streetside.utils.StreetsideProperties;

/**
 * This object is a responsible JComponent which lets you zoom and drag. It is
 * included in a {@link StreetsideMainDialog} object.
 *
 * @author nokutu
 * @see StreetsideImageDisplay
 * @see StreetsideMainDialog
 */
public class StreetsideImageDisplay extends JComponent {

    @Serial
    private static final long serialVersionUID = -3188274185432686201L;

    /**
     * The image currently displayed
     */
    private volatile BufferedImage image;

    /**
     * The rectangle (in image coordinates) of the image that is visible. This
     * rectangle is calculated each time the zoom is modified
     */
    private volatile Rectangle visibleRect;

    /**
     * When a selection is done, the rectangle of the selection (in image
     * coordinates)
     */
    private Rectangle selectedRect;

    /**
     * Main constructor.
     */
    public StreetsideImageDisplay() {
        ImgDisplayMouseListener mouseListener = new ImgDisplayMouseListener();
        addMouseListener(mouseListener);
        addMouseWheelListener(mouseListener);
        addMouseMotionListener(mouseListener);

        StreetsideProperties.SHOW_DETECTED_SIGNS.addListener(valChanged -> repaint());
    }

    private static Point getCenterImgCoord(Rectangle visibleRect) {
        return new Point(visibleRect.x + visibleRect.width / 2, visibleRect.y + visibleRect.height / 2);
    }

    /**
     * calculateDrawImageRectangle
     *
     * @param imgRect  the part of the image that should be drawn (in image coordinates)
     * @param compRect the part of the component where the image should be drawn (in
     *         component coordinates)
     * @return the part of compRect with the same width/height ratio as the image
     */
    private static Rectangle calculateDrawImageRectangle(Rectangle imgRect, Rectangle compRect) {
        int x = 0;
        int y = 0;
        int w = compRect.width;
        int h = compRect.height;
        int wFact = w * imgRect.height;
        int hFact = h * imgRect.width;
        if (wFact != hFact) {
            if (wFact > hFact) {
                w = hFact / imgRect.height;
                x = (compRect.width - w) / 2;
            } else {
                h = wFact / imgRect.width;
                y = (compRect.height - h) / 2;
            }
        }
        return new Rectangle(x + compRect.x, y + compRect.y, w, h);
    }

    private static void checkVisibleRectPos(Image image, Rectangle visibleRect) {
        if (visibleRect.x < 0) {
            visibleRect.x = 0;
        }
        if (visibleRect.y < 0) {
            visibleRect.y = 0;
        }
        if (visibleRect.x + visibleRect.width > image.getWidth(null)) {
            visibleRect.x = image.getWidth(null) - visibleRect.width;
        }
        if (visibleRect.y + visibleRect.height > image.getHeight(null)) {
            visibleRect.y = image.getHeight(null) - visibleRect.height;
        }
    }

    private static void checkVisibleRectSize(Image image, Rectangle visibleRect) {
        if (visibleRect.width > image.getWidth(null)) {
            visibleRect.width = image.getWidth(null);
        }
        if (visibleRect.height > image.getHeight(null)) {
            visibleRect.height = image.getHeight(null);
        }
    }

    /**
     * Sets a new picture to be displayed.
     *
     * @param image    The picture to be displayed.
     */
    public void setImage(BufferedImage image) {
        synchronized (this) {
            this.image = image;
            selectedRect = null;
            if (image != null)
                visibleRect = new Rectangle(0, 0, image.getWidth(null), image.getHeight(null));
        }
        repaint();
    }

    /**
     * Returns the picture that is being displayed
     *
     * @return The picture that is being displayed.
     */
    public BufferedImage getImage() {
        return image;
    }

    /**
     * Paints the visible part of the picture.
     */
    @Override
    public void paintComponent(Graphics g) {
        Image image;
        Rectangle visibleRect;
        synchronized (this) {
            image = this.image;
            visibleRect = this.visibleRect;
        }
        if (image == null) {
            g.setColor(Color.black);
            String noImageStr = StreetsideLayer.hasInstance() ? tr("No image selected")
                    : tr("Press \"{0}\" to download images", StreetsideDownloadAction.SHORTCUT.getKeyText());
            Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(noImageStr, g);
            Dimension size = getSize();
            g.drawString(noImageStr, (int) ((size.width - noImageSize.getWidth()) / 2),
                    (int) ((size.height - noImageSize.getHeight()) / 2));
        } else {
            Rectangle target = calculateDrawImageRectangle(visibleRect);
            g.drawImage(image, target.x, target.y, target.x + target.width, target.y + target.height, visibleRect.x,
                    visibleRect.y, visibleRect.x + visibleRect.width, visibleRect.y + visibleRect.height, null);
            if (selectedRect != null) {
                Point topLeft = img2compCoord(visibleRect, selectedRect.x, selectedRect.y);
                Point bottomRight = img2compCoord(visibleRect, selectedRect.x + selectedRect.width,
                        selectedRect.y + selectedRect.height);
                g.setColor(new Color(128, 128, 128, 180));
                g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
                g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
                g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
                g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
                g.setColor(Color.black);
                g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
            }

            if (Boolean.TRUE.equals(StreetsideProperties.SHOW_DETECTED_SIGNS.get())) {
                Point upperLeft = img2compCoord(visibleRect, 0, 0);
                Point lowerRight = img2compCoord(visibleRect, getImage().getWidth(), getImage().getHeight());

                // Transformation, which can convert you a Shape relative to the unit square to a Shape relative to the Component
                AffineTransform unit2compTransform = AffineTransform.getTranslateInstance(upperLeft.getX(),
                        upperLeft.getY());
                unit2compTransform.concatenate(AffineTransform.getScaleInstance(lowerRight.getX() - upperLeft.getX(),
                        lowerRight.getY() - upperLeft.getY()));

                final Graphics2D g2d = (Graphics2D) g;
                g2d.setStroke(new BasicStroke(2));
            }
        }
    }

    private Point img2compCoord(Rectangle visibleRect, int xImg, int yImg) {
        Rectangle drawRect = calculateDrawImageRectangle(visibleRect);
        return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
                drawRect.y + ((yImg - visibleRect.y) * drawRect.height) / visibleRect.height);
    }

    private Point comp2imgCoord(Rectangle visibleRect, int xComp, int yComp) {
        Rectangle drawRect = calculateDrawImageRectangle(visibleRect);
        return new Point(visibleRect.x + ((xComp - drawRect.x) * visibleRect.width) / drawRect.width,
                visibleRect.y + ((yComp - drawRect.y) * visibleRect.height) / drawRect.height);
    }

    private Rectangle calculateDrawImageRectangle(Rectangle visibleRect) {
        return calculateDrawImageRectangle(visibleRect, new Rectangle(0, 0, getSize().width, getSize().height));
    }

    private class ImgDisplayMouseListener implements MouseListener, MouseWheelListener, MouseMotionListener {
        private boolean mouseIsDragging;
        private long lastTimeForMousePoint;
        private Point mousePointInImg;

        /**
         * Zoom in and out, trying to preserve the point of the image that was under
         * the mouse cursor at the same place
         */
        @Override
        public void mouseWheelMoved(MouseWheelEvent e) {
            Image image;
            Rectangle visibleRect;
            synchronized (StreetsideImageDisplay.this) {
                image = getImage();
                visibleRect = StreetsideImageDisplay.this.visibleRect;
            }
            mouseIsDragging = false;
            selectedRect = null;
            if (image != null && Math.min(getSize().getWidth(), getSize().getHeight()) > 0) {
                // Calculate the mouse cursor position in image coordinates, so that
                // we can center the zoom
                // on that mouse position.
                // To avoid issues when the user tries to zoom in on the image
                // borders, this point is not calculated
                // again if there was less than 1.5seconds since the last event.
                if (e.getWhen() - lastTimeForMousePoint > 1500 || mousePointInImg == null) {
                    lastTimeForMousePoint = e.getWhen();
                    mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY());
                }
                // Set the zoom to the visible rectangle in image coordinates
                if (e.getWheelRotation() > 0) {
                    visibleRect.width = visibleRect.width * 3 / 2;
                    visibleRect.height = visibleRect.height * 3 / 2;
                } else {
                    visibleRect.width = visibleRect.width * 2 / 3;
                    visibleRect.height = visibleRect.height * 2 / 3;
                }
                // Check that the zoom doesn't exceed 2:1
                if (visibleRect.width < getSize().width / 2) {
                    visibleRect.width = getSize().width / 2;
                }
                if (visibleRect.height < getSize().height / 2) {
                    visibleRect.height = getSize().height / 2;
                }
                // Set the same ratio for the visible rectangle and the display area
                int hFact = visibleRect.height * getSize().width;
                int wFact = visibleRect.width * getSize().height;
                if (hFact > wFact) {
                    visibleRect.width = hFact / getSize().height;
                } else {
                    visibleRect.height = wFact / getSize().width;
                }
                // The size of the visible rectangle is limited by the image size.
                checkVisibleRectSize(image, visibleRect);
                // Set the position of the visible rectangle, so that the mouse
                // cursor doesn't move on the image.
                Rectangle drawRect = calculateDrawImageRectangle(visibleRect);
                visibleRect.x = mousePointInImg.x + ((drawRect.x - e.getX()) * visibleRect.width) / drawRect.width;
                visibleRect.y = mousePointInImg.y + ((drawRect.y - e.getY()) * visibleRect.height) / drawRect.height;
                // The position is also limited by the image size
                checkVisibleRectPos(image, visibleRect);
                synchronized (StreetsideImageDisplay.this) {
                    StreetsideImageDisplay.this.visibleRect = visibleRect;
                }
                StreetsideImageDisplay.this.repaint();
            }
        }

        /**
         * Center the display on the point that has been clicked
         */
        @Override
        public void mouseClicked(MouseEvent e) {
            // Move the center to the clicked point.
            Image image;
            Rectangle visibleRect;
            synchronized (StreetsideImageDisplay.this) {
                image = getImage();
                visibleRect = StreetsideImageDisplay.this.visibleRect;
            }
            if (image != null && Math.min(getSize().getWidth(), getSize().getHeight()) > 0) {
                if (e.getButton() == StreetsideProperties.PICTURE_OPTION_BUTTON.get()) {
                    if (!StreetsideImageDisplay.this.visibleRect
                            .equals(new Rectangle(0, 0, image.getWidth(null), image.getHeight(null)))) {
                        // Zooms to 1:1
                        StreetsideImageDisplay.this.visibleRect = new Rectangle(0, 0, image.getWidth(null),
                                image.getHeight(null));
                    } else {
                        // Zooms to best fit.
                        StreetsideImageDisplay.this.visibleRect = new Rectangle(0,
                                (image.getHeight(null) - (image.getWidth(null) * getHeight()) / getWidth()) / 2,
                                image.getWidth(null), (image.getWidth(null) * getHeight()) / getWidth());
                    }
                    StreetsideImageDisplay.this.repaint();
                    return;
                } else if (e.getButton() != StreetsideProperties.PICTURE_DRAG_BUTTON.get()) {
                    return;
                }
                // Calculate the translation to set the clicked point the center of
                // the view.
                Point click = comp2imgCoord(visibleRect, e.getX(), e.getY());
                Point center = getCenterImgCoord(visibleRect);
                visibleRect.x += click.x - center.x;
                visibleRect.y += click.y - center.y;
                checkVisibleRectPos(image, visibleRect);
                synchronized (StreetsideImageDisplay.this) {
                    StreetsideImageDisplay.this.visibleRect = visibleRect;
                }
                StreetsideImageDisplay.this.repaint();
            }
        }

        /**
         * Initialize the dragging, either with button 1 (simple dragging) or button
         * 3 (selection of a picture part)
         */
        @Override
        public void mousePressed(MouseEvent e) {
            if (getImage() == null) {
                mouseIsDragging = false;
                selectedRect = null;
                return;
            }
            Image image;
            Rectangle visibleRect;
            synchronized (StreetsideImageDisplay.this) {
                image = StreetsideImageDisplay.this.image;
                visibleRect = StreetsideImageDisplay.this.visibleRect;
            }
            if (image == null)
                return;
            if (e.getButton() == StreetsideProperties.PICTURE_DRAG_BUTTON.get()) {
                mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY());
                mouseIsDragging = true;
                selectedRect = null;
            } else if (e.getButton() == StreetsideProperties.PICTURE_ZOOM_BUTTON.get()) {
                mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY());
                checkPointInVisibleRect(mousePointInImg, visibleRect);
                mouseIsDragging = false;
                selectedRect = new Rectangle(mousePointInImg.x, mousePointInImg.y, 0, 0);
                StreetsideImageDisplay.this.repaint();
            } else {
                mouseIsDragging = false;
                selectedRect = null;
            }
        }

        @Override
        public void mouseDragged(MouseEvent e) {
            if (!mouseIsDragging && selectedRect == null)
                return;
            Image image;
            Rectangle visibleRect;
            synchronized (StreetsideImageDisplay.this) {
                image = getImage();
                visibleRect = StreetsideImageDisplay.this.visibleRect;
            }
            if (image == null) {
                mouseIsDragging = false;
                selectedRect = null;
                return;
            }
            if (mouseIsDragging) {
                Point p = comp2imgCoord(visibleRect, e.getX(), e.getY());
                visibleRect.x += mousePointInImg.x - p.x;
                visibleRect.y += mousePointInImg.y - p.y;
                checkVisibleRectPos(image, visibleRect);
                synchronized (StreetsideImageDisplay.this) {
                    StreetsideImageDisplay.this.visibleRect = visibleRect;
                }
                StreetsideImageDisplay.this.repaint();
            } else if (selectedRect != null) {
                Point p = comp2imgCoord(visibleRect, e.getX(), e.getY());
                checkPointInVisibleRect(p, visibleRect);
                Rectangle rect = new Rectangle(Math.min(p.x, mousePointInImg.x), Math.min(p.y, mousePointInImg.y),
                        p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
                        p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y);
                checkVisibleRectSize(image, rect);
                checkVisibleRectPos(image, rect);
                selectedRect = rect;
                StreetsideImageDisplay.this.repaint();
            }
        }

        @Override
        public void mouseReleased(MouseEvent e) {
            if (!mouseIsDragging && selectedRect == null)
                return;
            Image image;
            synchronized (StreetsideImageDisplay.this) {
                image = getImage();
            }
            if (image == null) {
                mouseIsDragging = false;
                selectedRect = null;
                return;
            }
            if (mouseIsDragging) {
                mouseIsDragging = false;
            } else if (selectedRect != null) {
                int oldWidth = selectedRect.width;
                int oldHeight = selectedRect.height;
                // Check that the zoom doesn't exceed 2:1
                if (selectedRect.width < getSize().width / 2) {
                    selectedRect.width = getSize().width / 2;
                }
                if (selectedRect.height < getSize().height / 2) {
                    selectedRect.height = getSize().height / 2;
                }
                // Set the same ratio for the visible rectangle and the display
                // area
                int hFact = selectedRect.height * getSize().width;
                int wFact = selectedRect.width * getSize().height;
                if (hFact > wFact) {
                    selectedRect.width = hFact / getSize().height;
                } else {
                    selectedRect.height = wFact / getSize().width;
                }
                // Keep the center of the selection
                if (selectedRect.width != oldWidth) {
                    selectedRect.x -= (selectedRect.width - oldWidth) / 2;
                }
                if (selectedRect.height != oldHeight) {
                    selectedRect.y -= (selectedRect.height - oldHeight) / 2;
                }
                checkVisibleRectSize(image, selectedRect);
                checkVisibleRectPos(image, selectedRect);
                synchronized (StreetsideImageDisplay.this) {
                    visibleRect = selectedRect;
                }
                selectedRect = null;
                StreetsideImageDisplay.this.repaint();
            }
        }

        @Override
        public void mouseEntered(MouseEvent e) {
            // Do nothing, method is enforced by MouseListener
        }

        @Override
        public void mouseExited(MouseEvent e) {
            // Do nothing, method is enforced by MouseListener
        }

        @Override
        public void mouseMoved(MouseEvent e) {
            // Do nothing, method is enforced by MouseListener
        }

        private void checkPointInVisibleRect(Point p, Rectangle visibleRect) {
            if (p.x < visibleRect.x) {
                p.x = visibleRect.x;
            }
            if (p.x > visibleRect.x + visibleRect.width) {
                p.x = visibleRect.x + visibleRect.width;
            }
            if (p.y < visibleRect.y) {
                p.y = visibleRect.y;
            }
            if (p.y > visibleRect.y + visibleRect.height) {
                p.y = visibleRect.y + visibleRect.height;
            }
        }
    }
}
