/*
 * Copyright (C) 2025  Tim Zimmermann <tim@aglais.org>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.aglais.zms.ui.component;

import org.aglais.zms.ui.map.RadiusWaypoint;
import org.aglais.zms.ui.util.GeoUtil;
import org.jxmapviewer.JXMapViewer;
import org.jxmapviewer.OSMTileFactoryInfo;
import org.jxmapviewer.input.CenterMapListener;
import org.jxmapviewer.input.PanKeyListener;
import org.jxmapviewer.input.PanMouseInputListener;
import org.jxmapviewer.input.ZoomMouseWheelListenerCenter;
import org.jxmapviewer.painter.AbstractPainter;
import org.jxmapviewer.painter.Painter;
import org.jxmapviewer.viewer.DefaultTileFactory;
import org.jxmapviewer.viewer.GeoPosition;
import org.jxmapviewer.viewer.TileFactoryInfo;

import javax.swing.*;
import java.awt.*;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.List;

/**
 * A custom map panel extending {@link JXMapViewer} with support for multiple overlay painters and
 * configurable attribution text position.
 * <p>
 * The map panel uses OpenStreetMap tiles by default and displays an attribution
 * label on the map which can be positioned on any of the four corners using
 * {@link AttributionPosition}.
 * </p>
 */
public class MapPanel extends JXMapViewer {

    /**
     * Enum representing the possible positions for attribution.
     */
    public enum AttributionPosition {
        /**
         * Top left position.
         */
        TOP_LEFT,
        /**
         * Top right position.
         */
        TOP_RIGHT,
        /**
         * Bottom left position.
         */
        BOTTOM_LEFT,
        /**
         * Bottom right position.
         */
        BOTTOM_RIGHT;
    }

    private AttributionPosition attributionPosition = AttributionPosition.BOTTOM_RIGHT;
    private int attributionPadding = 5;
    private final List<Painter<? super JXMapViewer>> overlayPainters = new ArrayList<>();

    /**
     * Constructs a new MapPanel configured with OSM tile factory,
     * default zoom and center position, input listeners, and an attribution painter.
     */
    public MapPanel() {
        TileFactoryInfo info = new OSMTileFactoryInfo();
        DefaultTileFactory tileFactory = new DefaultTileFactory(info);

        tileFactory.setThreadPoolSize(Runtime.getRuntime().availableProcessors());
        setTileFactory(tileFactory);

        // Add proper attribution label
        addOverlayPainter(new AbstractPainter<>() {
            @Override
            protected void doPaint(Graphics2D graphics, JXMapViewer mapViewer, int width, int height) {
                String attributionText = mapViewer.getTileFactory().getInfo().getAttribution();

                FontMetrics fontMetrics = graphics.getFontMetrics();
                Rectangle2D textBounds = fontMetrics.getStringBounds(attributionText, graphics);
                int attributionWidth = (int) textBounds.getWidth() + attributionPadding * 2;
                int attributionHeight = (int) textBounds.getHeight() + attributionPadding * 2;
                int x = attributionPosition == AttributionPosition.TOP_RIGHT
                        || attributionPosition == AttributionPosition.BOTTOM_RIGHT ? width - attributionWidth : 0;
                int y = attributionPosition == AttributionPosition.BOTTOM_LEFT
                        || attributionPosition == AttributionPosition.BOTTOM_RIGHT ? height - attributionHeight : 0;
                Point position = new Point(x, y);

                // Draw container with semi-transparent background
                Color backgroundColor = UIManager.getColor("Panel.background");
                if (backgroundColor == null) {
                    backgroundColor = Color.WHITE;
                }
                graphics.setColor(new Color(backgroundColor.getRed(), backgroundColor.getGreen(),
                        backgroundColor.getBlue(), 127));
                graphics.fillRect(position.x, position.y, attributionWidth, attributionHeight);

                // Draw attribution text
                graphics.setColor(UIManager.getColor("Label.foreground"));
                graphics.drawString(attributionText, position.x + attributionPadding, position.y + attributionPadding
                        + fontMetrics.getAscent());
            }
        });

        PanMouseInputListener mouseListener = new PanMouseInputListener(this);

        addMouseListener(mouseListener);
        addMouseMotionListener(mouseListener);
        addMouseListener(new CenterMapListener(this));
        addMouseWheelListener(new ZoomMouseWheelListenerCenter(this));
        addKeyListener(new PanKeyListener(this));

        // Set default location
        setCenterPosition(new GeoPosition(30.14512718337613, 14.765625));

        // Enable proper preview at design time
        setDesignTime(false);
    }

    /**
     * Sets the position of the attribution label.
     *
     * @param attributionPosition The position
     */
    public void setAttributionPosition(AttributionPosition attributionPosition) {
        this.attributionPosition = attributionPosition;
        repaint();
    }

    /**
     * Returns the position of the attribution label.
     *
     * @return The position enum value
     */
    public AttributionPosition getAttributionPosition() {
        return attributionPosition;
    }

    /**
     * Sets the padding of the attribution label.
     *
     * @param attributionPadding The padding
     */
    public void setAttributionPadding(int attributionPadding) {
        this.attributionPadding = attributionPadding;
        repaint();
    }

    /**
     * Returns the padding of the attribution label.
     *
     * @return The padding
     */
    public int getAttributionPadding() {
        return attributionPadding;
    }

    /**
     * Applies all overlay painters by wrapping them into a composite painter.
     * This method recreates the overlay painter to ensure proper repainting.
     */
    private void applyOverlayPainters() {
        // JXMapViewer requires us to create a new object every time the list has changed, so that it properly repaints.
        setOverlayPainter(new AbstractPainter<>() {
            @Override
            protected void doPaint(Graphics2D g, JXMapViewer object, int width, int height) {
                for (Painter<? super JXMapViewer> painter : overlayPainters) {
                    painter.paint(g, object, width, height);
                }
            }
        });
    }

    /**
     * Adds an overlay painter to the map.
     * Multiple overlay painters can be added; they will be painted in order.
     *
     * @param painter The overlay painter to add
     */
    public void addOverlayPainter(Painter<? super JXMapViewer> painter) {
        overlayPainters.add(painter);
        applyOverlayPainters();
    }

    /**
     * Removes an overlay painter from the map.
     * Does nothing if the painter was not previously added.
     *
     * @param painter The overlay painter to remove
     */
    public void removeOverlayPainter(Painter<? super JXMapViewer> painter) {
        if (overlayPainters.remove(painter))
            applyOverlayPainters();
    }

    /**
     * Adjusts the map zoom level and center position to ensure all specified waypoints,
     * each with an associated radius in meters, are visible within the viewport.
     * <p>
     * The method calculates the bounding coordinates that encompass each waypoint's radius,
     * then determines the appropriate zoom level that fits the entire area inside the current view size.
     * The map's center is set to the midpoint of this bounding box.
     *
     * @param waypoints one or more {@link RadiusWaypoint} instances representing geographic positions
     *                  and their visibility radius in meters
     */
    public void zoomTo(RadiusWaypoint... waypoints) {
        if (waypoints == null || waypoints.length == 0) {
            return;
        }

        GeoPosition firstPos = waypoints[0].getPosition();
        double north = firstPos.getLatitude();
        double south = firstPos.getLatitude();
        double west = firstPos.getLongitude();
        double east = firstPos.getLongitude();

        // Calculate max coordinates
        for (RadiusWaypoint wp : waypoints) {
            GeoPosition pos = wp.getPosition();
            int radius = wp.getRadius();

            GeoPosition top = GeoUtil.offsetByMeters(pos, radius, 0);
            GeoPosition bottom = GeoUtil.offsetByMeters(pos, -radius, 0);
            GeoPosition left = GeoUtil.offsetByMeters(pos, 0, -radius);
            GeoPosition right = GeoUtil.offsetByMeters(pos, 0, radius);

            north = Math.max(north, top.getLatitude());
            south = Math.min(south, bottom.getLatitude());
            west = Math.min(west, left.getLongitude());
            east = Math.max(east, right.getLongitude());
        }

        GeoPosition topLeft = new GeoPosition(north, west);
        GeoPosition bottomRight = new GeoPosition(south, east);

        GeoPosition center = new GeoPosition(
                (north + south) / 2.0,
                (west + east) / 2.0
        );

        int maxZoom = getTileFactory().getInfo().getMaximumZoomLevel();
        int minZoom = getTileFactory().getInfo().getMinimumZoomLevel();
        int zoom = minZoom;

        // Get the best zoom level that fits all the coordinates
        for (int z = minZoom; z <= maxZoom; z++) {
            Point2D pt1 = getTileFactory().geoToPixel(topLeft, z);
            Point2D pt2 = getTileFactory().geoToPixel(bottomRight, z);

            double widthInPixels = Math.abs(pt2.getX() - pt1.getX());
            double heightInPixels = Math.abs(pt2.getY() - pt1.getY());

            if (widthInPixels <= getWidth() && heightInPixels <= getHeight()) {
                zoom = z;
                break;
            }
        }

        setZoom(zoom);
        setCenterPosition(center);
    }

    /**
     * Adjusts the map zoom level and center position to ensure the specified geographic position,
     * along with a visible radius in meters around it, is fully visible within the viewport.
     * <p>
     * This is a convenience method that wraps the single position and radius into a {@link RadiusWaypoint}
     * and delegates to {@link #zoomTo(RadiusWaypoint...)}.
     *
     * @param position            The central {@link GeoPosition} to be visible on the map
     * @param visibleRadiusMeters The radius in meters around the given position that should be visible
     */
    public void zoomTo(GeoPosition position, int visibleRadiusMeters) {
        zoomTo(new RadiusWaypoint(position, visibleRadiusMeters));
    }
}