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

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

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;

import javax.swing.ImageIcon;
import javax.swing.SwingUtilities;

import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.data.coor.LatLon;
import org.openstreetmap.josm.data.osm.DataSet;
import org.openstreetmap.josm.data.osm.Node;
import org.openstreetmap.josm.data.osm.Tag;
import org.openstreetmap.josm.gui.PleaseWaitRunnable;
import org.openstreetmap.josm.gui.layer.Layer;
import org.openstreetmap.josm.gui.layer.OsmDataLayer;
import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
import org.openstreetmap.josm.gui.mappaint.styleelement.MapImage;
import org.openstreetmap.josm.gui.mappaint.styleelement.NodeElement;
import org.openstreetmap.josm.gui.mappaint.styleelement.StyleElement;
import org.openstreetmap.josm.gui.preferences.SourceEntry;
import org.openstreetmap.josm.gui.preferences.map.MapPaintPreference.MapPaintPrefHelper;
import org.openstreetmap.josm.gui.progress.ProgressMonitor;
import org.openstreetmap.josm.io.CachedFile;
import org.openstreetmap.josm.tools.ImageProvider;
import org.openstreetmap.josm.tools.ListenerList;
import org.openstreetmap.josm.tools.Logging;
import org.openstreetmap.josm.tools.Utils;

/**
 * This class manages the list of available map paint styles and gives access to
 * the ElemStyles singleton.
 *
 * On change, {@link MapPaintSylesUpdateListener#mapPaintStylesUpdated()} is fired
 * for all listeners.
 */
public final class MapPaintStyles {

    private static final Collection<String> DEPRECATED_IMAGE_NAMES = Arrays.asList(
            "presets/misc/deprecated.svg",
            "misc/deprecated.png");

    private static ElemStyles styles = new ElemStyles();

    /**
     * Returns the {@link ElemStyles} singleton instance.
     *
     * The returned object is read only, any manipulation happens via one of
     * the other wrapper methods in this class. ({@link #readFromPreferences},
     * {@link #moveStyles}, ...)
     * @return the {@code ElemStyles} singleton instance
     */
    public static ElemStyles getStyles() {
        return styles;
    }

    private MapPaintStyles() {
        // Hide default constructor for utils classes
    }

    /**
     * Value holder for a reference to a tag name. A style instruction
     * <pre>
     *    text: a_tag_name;
     * </pre>
     * results in a tag reference for the tag <tt>a_tag_name</tt> in the
     * style cascade.
     */
    public static class TagKeyReference {
        /**
         * The tag name
         */
        public final String key;

        /**
         * Create a new {@link TagKeyReference}
         * @param key The tag name
         */
        public TagKeyReference(String key) {
            this.key = key;
        }

        @Override
        public String toString() {
            return "TagKeyReference{" + "key='" + key + "'}";
        }
    }

    /**
     * IconReference is used to remember the associated style source for each icon URL.
     * This is necessary because image URLs can be paths relative
     * to the source file and we have cascading of properties from different source files.
     */
    public static class IconReference {

        /**
         * The name of the icon
         */
        public final String iconName;
        /**
         * The style source this reference occurred in
         */
        public final StyleSource source;

        /**
         * Create a new {@link IconReference}
         * @param iconName The icon name
         * @param source The current style source
         */
        public IconReference(String iconName, StyleSource source) {
            this.iconName = iconName;
            this.source = source;
        }

        @Override
        public String toString() {
            return "IconReference{" + "iconName='" + iconName + "' source='" + source.getDisplayString() + "'}";
        }

        /**
         * Determines whether this icon represents a deprecated icon
         * @return whether this icon represents a deprecated icon
         * @since 10927
         */
        public boolean isDeprecatedIcon() {
            return DEPRECATED_IMAGE_NAMES.contains(iconName);
        }
    }

    /**
     * Image provider for icon. Note that this is a provider only. A @link{ImageProvider#get()} call may still fail!
     *
     * @param ref reference to the requested icon
     * @param test if <code>true</code> than the icon is request is tested
     * @return image provider for icon (can be <code>null</code> when <code>test</code> is <code>true</code>).
     * @see #getIcon(IconReference, int,int)
     * @since 8097
     */
    public static ImageProvider getIconProvider(IconReference ref, boolean test) {
        final String namespace = ref.source.getPrefName();
        ImageProvider i = new ImageProvider(ref.iconName)
                .setDirs(getIconSourceDirs(ref.source))
                .setId("mappaint."+namespace)
                .setArchive(ref.source.zipIcons)
                .setInArchiveDir(ref.source.getZipEntryDirName())
                .setOptional(true);
        if (test && i.get() == null) {
            String msg = "Mappaint style \""+namespace+"\" ("+ref.source.getDisplayString()+") icon \"" + ref.iconName + "\" not found.";
            ref.source.logWarning(msg);
            Logging.warn(msg);
            return null;
        }
        return i;
    }

    /**
     * Return scaled icon.
     *
     * @param ref reference to the requested icon
     * @param width icon width or -1 for autoscale
     * @param height icon height or -1 for autoscale
     * @return image icon or <code>null</code>.
     * @see #getIconProvider(IconReference, boolean)
     */
    public static ImageIcon getIcon(IconReference ref, int width, int height) {
        final String namespace = ref.source.getPrefName();
        ImageIcon i = getIconProvider(ref, false).setSize(width, height).get();
        if (i == null) {
            Logging.warn("Mappaint style \""+namespace+"\" ("+ref.source.getDisplayString()+") icon \"" + ref.iconName + "\" not found.");
            return null;
        }
        return i;
    }

    /**
     * No icon with the given name was found, show a dummy icon instead
     * @param source style source
     * @return the icon misc/no_icon.png, in descending priority:
     *   - relative to source file
     *   - from user icon paths
     *   - josm's default icon
     *  can be null if the defaults are turned off by user
     */
    public static ImageIcon getNoIconIcon(StyleSource source) {
        return new ImageProvider("presets/misc/no_icon")
                .setDirs(getIconSourceDirs(source))
                .setId("mappaint."+source.getPrefName())
                .setArchive(source.zipIcons)
                .setInArchiveDir(source.getZipEntryDirName())
                .setOptional(true).get();
    }

    /**
     * Returns the node icon that would be displayed for the given tag.
     * @param tag The tag to look an icon for
     * @return {@code null} if no icon found
     */
    public static ImageIcon getNodeIcon(Tag tag) {
        return getNodeIcon(tag, true);
    }

    /**
     * Returns the node icon that would be displayed for the given tag.
     * @param tag The tag to look an icon for
     * @param includeDeprecatedIcon if {@code true}, the special deprecated icon will be returned if applicable
     * @return {@code null} if no icon found, or if the icon is deprecated and not wanted
     */
    public static ImageIcon getNodeIcon(Tag tag, boolean includeDeprecatedIcon) {
        if (tag != null) {
            DataSet ds = new DataSet();
            Node virtualNode = new Node(LatLon.ZERO);
            virtualNode.put(tag.getKey(), tag.getValue());
            StyleElementList styleList;
            MapCSSStyleSource.STYLE_SOURCE_LOCK.readLock().lock();
            try {
                // Add primitive to dataset to avoid DataIntegrityProblemException when evaluating selectors
                ds.addPrimitive(virtualNode);
                styleList = getStyles().generateStyles(virtualNode, 0.5, false).a;
                ds.removePrimitive(virtualNode);
            } finally {
                MapCSSStyleSource.STYLE_SOURCE_LOCK.readLock().unlock();
            }
            if (styleList != null) {
                for (StyleElement style : styleList) {
                    if (style instanceof NodeElement) {
                        MapImage mapImage = ((NodeElement) style).mapImage;
                        if (mapImage != null) {
                            if (includeDeprecatedIcon || mapImage.name == null || !DEPRECATED_IMAGE_NAMES.contains(mapImage.name)) {
                                return new ImageIcon(mapImage.getImage(false));
                            } else {
                                return null; // Deprecated icon found but not wanted
                            }
                        }
                    }
                }
            }
        }
        return null;
    }

    /**
     * Gets the directories that should be searched for icons
     * @param source The style source the icon is from
     * @return A list of directory names
     */
    public static List<String> getIconSourceDirs(StyleSource source) {
        List<String> dirs = new LinkedList<>();

        File sourceDir = source.getLocalSourceDir();
        if (sourceDir != null) {
            dirs.add(sourceDir.getPath());
        }

        Collection<String> prefIconDirs = Main.pref.getCollection("mappaint.icon.sources");
        for (String fileset : prefIconDirs) {
            String[] a;
            if (fileset.indexOf('=') >= 0) {
                a = fileset.split("=", 2);
            } else {
                a = new String[] {"", fileset};
            }

            /* non-prefixed path is generic path, always take it */
            if (a[0].isEmpty() || source.getPrefName().equals(a[0])) {
                dirs.add(a[1]);
            }
        }

        if (Main.pref.getBoolean("mappaint.icon.enable-defaults", true)) {
            /* don't prefix icon path, as it should be generic */
            dirs.add("resource://images/");
        }

        return dirs;
    }

    /**
     * Reloads all styles from the preferences.
     */
    public static void readFromPreferences() {
        styles.clear();

        Collection<? extends SourceEntry> sourceEntries = MapPaintPrefHelper.INSTANCE.get();

        for (SourceEntry entry : sourceEntries) {
            styles.add(fromSourceEntry(entry));
        }
        for (StyleSource source : styles.getStyleSources()) {
            loadStyleForFirstTime(source);
        }
        fireMapPaintSylesUpdated();
    }

    private static void loadStyleForFirstTime(StyleSource source) {
        final long startTime = System.currentTimeMillis();
        source.loadStyleSource();
        if (Main.pref.getBoolean("mappaint.auto_reload_local_styles", true) && source.isLocal()) {
            try {
                Main.fileWatcher.registerStyleSource(source);
            } catch (IOException | IllegalStateException | IllegalArgumentException e) {
                Logging.error(e);
            }
        }
        if (Logging.isDebugEnabled() || !source.isValid()) {
            final long elapsedTime = System.currentTimeMillis() - startTime;
            String message = "Initializing map style " + source.url + " completed in " + Utils.getDurationString(elapsedTime);
            if (!source.isValid()) {
                Logging.warn(message + " (" + source.getErrors().size() + " errors, " + source.getWarnings().size() + " warnings)");
            } else {
                Logging.debug(message);
            }
        }
    }

    private static StyleSource fromSourceEntry(SourceEntry entry) {
        if (entry.url == null && entry instanceof MapCSSStyleSource) {
            return (MapCSSStyleSource) entry;
        }
        Set<String> mimes = new HashSet<>(Arrays.asList(MapCSSStyleSource.MAPCSS_STYLE_MIME_TYPES.split(", ")));
        try (CachedFile cf = new CachedFile(entry.url).setHttpAccept(Utils.join(", ", mimes))) {
            String zipEntryPath = cf.findZipEntryPath("mapcss", "style");
            if (zipEntryPath != null) {
                entry.isZip = true;
                entry.zipEntryPath = zipEntryPath;
            }
            return new MapCSSStyleSource(entry);
        }
    }

    /**
     * reload styles
     * preferences are the same, but the file source may have changed
     * @param sel the indices of styles to reload
     */
    public static void reloadStyles(final int... sel) {
        List<StyleSource> toReload = new ArrayList<>();
        List<StyleSource> data = styles.getStyleSources();
        for (int i : sel) {
            toReload.add(data.get(i));
        }
        Main.worker.submit(new MapPaintStyleLoader(toReload));
    }

    /**
     * This class loads the map paint styles
     */
    public static class MapPaintStyleLoader extends PleaseWaitRunnable {
        private boolean canceled;
        private final Collection<StyleSource> sources;

        /**
         * Create a new {@link MapPaintStyleLoader}
         * @param sources The styles to load
         */
        public MapPaintStyleLoader(Collection<StyleSource> sources) {
            super(tr("Reloading style sources"));
            this.sources = sources;
        }

        @Override
        protected void cancel() {
            canceled = true;
        }

        @Override
        protected void finish() {
            fireMapPaintSylesUpdated();
            afterStyleUpdate();
        }

        @Override
        protected void realRun() {
            ProgressMonitor monitor = getProgressMonitor();
            monitor.setTicksCount(sources.size());
            for (StyleSource s : sources) {
                if (canceled)
                    return;
                monitor.subTask(tr("loading style ''{0}''...", s.getDisplayString()));
                s.loadStyleSource();
                monitor.worked(1);
            }
        }
    }

    /**
     * Move position of entries in the current list of StyleSources
     * @param sel The indices of styles to be moved.
     * @param delta The number of lines it should move. positive int moves
     *      down and negative moves up.
     */
    public static void moveStyles(int[] sel, int delta) {
        if (!canMoveStyles(sel, delta))
            return;
        int[] selSorted = Utils.copyArray(sel);
        Arrays.sort(selSorted);
        List<StyleSource> data = new ArrayList<>(styles.getStyleSources());
        for (int row: selSorted) {
            StyleSource t1 = data.get(row);
            StyleSource t2 = data.get(row + delta);
            data.set(row, t2);
            data.set(row + delta, t1);
        }
        styles.setStyleSources(data);
        MapPaintPrefHelper.INSTANCE.put(data);
        fireMapPaintSylesUpdated();
        afterStyleUpdate();
    }

    /**
     * Manually trigger for now. TODO: Move this to a listener
     */
    private static void afterStyleUpdate() {
        SwingUtilities.invokeLater(() -> {
            styles.clearCached();

            // Trigger a repaint of all data layers
            Main.getLayerManager().getLayers()
                .stream()
                .filter(layer -> layer instanceof OsmDataLayer)
                .forEach(Layer::invalidate);
        });
    }

    /**
     * Check if the styles can be moved
     * @param sel The indexes of the selected styles
     * @param i The number of places to move the styles
     * @return <code>true</code> if that movement is possible
     */
    public static boolean canMoveStyles(int[] sel, int i) {
        if (sel.length == 0)
            return false;
        int[] selSorted = Utils.copyArray(sel);
        Arrays.sort(selSorted);

        if (i < 0) // Up
            return selSorted[0] >= -i;
        else if (i > 0) // Down
            return selSorted[selSorted.length-1] <= styles.getStyleSources().size() - 1 - i;
        else
            return true;
    }

    /**
     * Toggles the active state of several styles
     * @param sel The style indexes
     */
    public static void toggleStyleActive(int... sel) {
        List<StyleSource> data = styles.getStyleSources();
        for (int p : sel) {
            StyleSource s = data.get(p);
            s.active = !s.active;
        }
        MapPaintPrefHelper.INSTANCE.put(data);
        if (sel.length == 1) {
            fireMapPaintStyleEntryUpdated(sel[0]);
        } else {
            fireMapPaintSylesUpdated();
        }
        afterStyleUpdate();
    }

    /**
     * Add a new map paint style.
     * @param entry map paint style
     * @return loaded style source, or {@code null}
     */
    public static StyleSource addStyle(SourceEntry entry) {
        StyleSource source = fromSourceEntry(entry);
        styles.add(source);
        loadStyleForFirstTime(source);
        refreshStyles();
        return source;
    }

    /**
     * Remove a map paint style.
     * @param entry map paint style
     * @since 11493
     */
    public static void removeStyle(SourceEntry entry) {
        StyleSource source = fromSourceEntry(entry);
        if (styles.remove(source)) {
            refreshStyles();
        }
    }

    private static void refreshStyles() {
        MapPaintPrefHelper.INSTANCE.put(styles.getStyleSources());
        fireMapPaintSylesUpdated();
        afterStyleUpdate();
    }

    /***********************************
     * MapPaintSylesUpdateListener &amp; related code
     *  (get informed when the list of MapPaint StyleSources changes)
     */
    public interface MapPaintSylesUpdateListener {
        /**
         * Called on any style source changes that are not handled by {@link #mapPaintStyleEntryUpdated(int)}
         */
        void mapPaintStylesUpdated();

        /**
         * Called whenever a single style source entry was changed.
         * @param index The index of the entry.
         */
        void mapPaintStyleEntryUpdated(int index);
    }

    private static final ListenerList<MapPaintSylesUpdateListener> listeners = ListenerList.createUnchecked();

    /**
     * Add a listener that listens to global style changes.
     * @param listener The listener
     */
    public static void addMapPaintSylesUpdateListener(MapPaintSylesUpdateListener listener) {
        listeners.addListener(listener);
    }

    /**
     * Removes a listener that listens to global style changes.
     * @param listener The listener
     */
    public static void removeMapPaintSylesUpdateListener(MapPaintSylesUpdateListener listener) {
        listeners.removeListener(listener);
    }

    /**
     * Notifies all listeners that there was any update to the map paint styles
     */
    public static void fireMapPaintSylesUpdated() {
        listeners.fireEvent(MapPaintSylesUpdateListener::mapPaintStylesUpdated);
    }

    /**
     * Notifies all listeners that there was an update to a specific map paint style
     * @param index The style index
     */
    public static void fireMapPaintStyleEntryUpdated(int index) {
        listeners.fireEvent(l -> l.mapPaintStyleEntryUpdated(index));
    }
}
