/**
 * Copyright (C) 2004-2011 Jive Software. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jivesoftware.sparkimpl.plugin.emoticons;

import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Node;
import org.dom4j.io.SAXReader;
import org.jivesoftware.Spark;
import org.jivesoftware.spark.util.StringUtils;
import org.jivesoftware.spark.util.URLFileSystem;
import org.jivesoftware.spark.util.log.Log;
import org.jivesoftware.sparkimpl.settings.local.LocalPreferences;
import org.jivesoftware.sparkimpl.settings.local.SettingsManager;
import org.xml.sax.SAXException;

import javax.swing.ImageIcon;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;

import static java.util.Arrays.asList;
import static org.apache.commons.lang3.Strings.CI;

/**
 * Responsible for the handling of all Emoticon packs. Using the
 * EmoticonManager, you can specify any defined Emoticon Pack, retrieve any
 * emoticon based on its text equivalent, and retrieve its associated image url.
 *
 * @author Derek DeMoro
 */
public class EmoticonManager {
    private static EmoticonManager singleton;
    private static final Object LOCK = new Object();

    // Mapped by pack name, then by 'equivalent' key.
    private final Map<String, Map<String, Emoticon>> emoticonMap = new HashMap<>();
    private final Map<String, ImageIcon> imageMap = new HashMap<>();
    private Map<String, Emoticon> activeEmoticonMap;

    /**
     * The root emoticon directory.
     */
    private File EMOTICON_DIRECTORY;
    private final LocalPreferences pref = SettingsManager.getLocalPreferences();

    public static EmoticonManager getInstance() {
        if (singleton != null) {
            return singleton;
        }
        synchronized (LOCK) {
            singleton = new EmoticonManager();
            return singleton;
        }
    }

    private EmoticonManager() {
        EMOTICON_DIRECTORY = new File(Spark.getXtraDirectory(), "emoticons").getAbsoluteFile();
        File[] files = EMOTICON_DIRECTORY.listFiles();
        // Copy emoticon files from installation directory to the Spark User Home directory
        if (files != null) {
            // Copy over to allow for non-admins to extract.
            copyFiles();
            String emoticonPack = pref.getEmoticonPack();
            try {
                addEmoticonPack(emoticonPack);
            } catch (Exception e) {
                Log.error(e);
            }
        }
    }

    /**
     * Copy the files directly over to an accepted permissions directory.
     */
    private void copyFiles() {
        // Current Plugin directory
        File profileEmoticonsFolder = new File(Spark.getSparkUserHome(), "xtra/emoticons").getAbsoluteFile();
        //noinspection ResultOfMethodCallIgnored
        profileEmoticonsFolder.mkdirs();
        deleteOldEmoticons(profileEmoticonsFolder);

        File[] files = EMOTICON_DIRECTORY.listFiles(File::isFile);
        files = files != null ? files : new File[]{};
        for (File file : files) {
                try {
                    // Copy over
                    File newFile = new File(profileEmoticonsFolder, file.getName());
                    // Check timestamps
                    long installerFile = file.lastModified();
                    long copiedFile = newFile.lastModified();
                    if (installerFile > copiedFile) {
                        // Check if File is Zip-File
                        int endIndex = file.getName().indexOf(".zip");
                        if (endIndex > 0) {
                            String unzippedFolderName = file.getName().substring(0, endIndex);
                            File unzippedFolder = new File(profileEmoticonsFolder, unzippedFolderName);
                            if (!unzippedFolder.exists() || !checkIfSameFile(file, newFile)) {
                                Log.debug("Copying " + file.getName() + " to " + profileEmoticonsFolder + " and unpack");
                                URLFileSystem.copy(file.toURI().toURL(), newFile);
                                expandNewPack(newFile, profileEmoticonsFolder);
                            }
                        }
                    }
                } catch (IOException e) {
                    Log.error(e);
                }
        }
        EMOTICON_DIRECTORY = profileEmoticonsFolder;
    }

    private boolean checkIfSameFile(File oldZip, File newZip) {
        boolean result = true;

        ZipFile oldZipFile = null;
        ZipFile newZipFile = null;
        try {
            oldZipFile = new JarFile(oldZip);
            newZipFile = new JarFile(newZip);
            if (oldZipFile.size() == newZipFile.size()) {
                for (Enumeration<?> e = newZipFile.entries(); e.hasMoreElements(); ) {
                    JarEntry entry = (JarEntry) e.nextElement();
                    if (oldZipFile.getEntry(entry.getName()) == null ||
                        entry.hashCode() != oldZipFile.getEntry(entry.getName()).hashCode()) {
                        result = false;
                        break;
                    }
                }
            } else {
                result = false;
            }
        } catch (IOException e) {
            Log.error(e);
        }
        closeFile(newZipFile);
        closeFile(oldZipFile);
        return result;
    }

    private void closeFile(ZipFile zipFile) {
        try {
            if (zipFile != null) {
                zipFile.close();
            }
        } catch (IOException e) {
            Log.error(e);
        }
    }

    /**
     * Returns the active emoticon set within Spark.
     */
    public Set<Emoticon> getActiveEmoticonSet() {
        String emoticonPack = pref.getEmoticonPack();
        // If EmoticonPack is set
        if (emoticonPack != null) {
            Map<String, Emoticon> emoticons = emoticonMap.get(emoticonPack);
            // When no emoticon set is available, return an empty list
            return emoticons != null ? new LinkedHashSet<>(emoticons.values()) : Set.of();
        }
        return Set.of();
    }

    /**
     * Sets the active emoticon set.
     *
     * @param pack the archive containing the emoticon pack.
     */
    public void setActivePack(String pack) {
        pref.setEmoticonPack(pack);
        imageMap.clear();
        activeEmoticonMap = null;
    }

    /**
     * Installs a new Adium style emoticon pack into Spark.
     *
     * @param pack the emoticon pack (contains Emotiocons.plist)
     * @return the name of the newly installed emoticon set.
     */
    public String installPack(File pack) {
        if (!containsEmoticonPList(pack)) {
            return null;
        }
        // Copy to the emoticon area
        try {
            File dst = new File(EMOTICON_DIRECTORY, pack.getName());
            URLFileSystem.copy(pack.toURI().toURL(), dst);
            File rootDirectory = unzipPack(pack, EMOTICON_DIRECTORY);
            String name = URLFileSystem.getName(rootDirectory.toURI().toURL());
            addEmoticonPack(name);
            return name;
        } catch (Exception e) {
            Log.error("Unable to install emoticon pack: " + pack.getAbsolutePath(), e);
            return null;
        }
    }

    /**
     * Loads an emoticon set.
     *
     * @param packName the name of the pack.
     */
    public void addEmoticonPack(String packName) {
        File emoticonSet = new File(EMOTICON_DIRECTORY, packName + ".adiumemoticonset");
        if (!emoticonSet.exists()) {
            emoticonSet = new File(EMOTICON_DIRECTORY, packName + ".AdiumEmoticonset");
        }
        if (!emoticonSet.exists()) {
            Log.error("The emoticons file not found in " + emoticonSet.getAbsolutePath());
        }

        final File plist = new File(emoticonSet, "Emoticons.plist");
        if (!plist.exists()) {
            Log.error("Emoticons.plist not found in " + emoticonSet.getAbsolutePath());
            return;
        }

        // Create SaxReader and set to non-validating parser.
        // This will allow for non-http problems to not break spark :)
        final SAXReader saxParser = SAXReader.createDefault();
        saxParser.setValidation(false);
        try {
            saxParser.setFeature("http://xml.org/sax/features/validation", false);
            saxParser.setFeature("http://xml.org/sax/features/namespaces", false);
            saxParser.setFeature("http://apache.org/xml/features/validation/schema", false);
            saxParser.setFeature("http://apache.org/xml/features/validation/schema-full-checking", false);
            saxParser.setFeature("http://apache.org/xml/features/validation/dynamic", false);
            saxParser.setFeature("http://apache.org/xml/features/allow-java-encodings", true);
            saxParser.setFeature("http://apache.org/xml/features/continue-after-fatal-error", true);
            saxParser.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false);
        } catch (SAXException e) {
            Log.error(e);
            return;
        }

        Document emoticonFile;
        try {
            emoticonFile = saxParser.read(plist);
        } catch (DocumentException e) {
            Log.error(e);
            return;
        }

        Map<String, Emoticon> emoticons = new LinkedHashMap<>();
        Node root = emoticonFile.selectSingleNode("/plist/dict/dict");
        List<Node> keyList = root.selectNodes("key");
        List<Node> dictonaryList = root.selectNodes("dict");
        Iterator<Node> dicts = dictonaryList.iterator();
        for (Node keyEntry : keyList) {
            String key = keyEntry.getText();
            Node dict = dicts.next();
            String name = dict.selectSingleNode("string").getText();
            // Load equivalents
            final List<? extends Node> equivalents = dict.selectNodes("array/string");
            final List<String> equivs = new ArrayList<>(equivalents.size());
            for (Node equivalent : equivalents) {
                equivs.add(equivalent.getText());
            }
            final Emoticon emoticon = new Emoticon(key, name, equivs, emoticonSet);
            for (String equivalent : emoticon.getEquivalents()) {
                emoticons.put(equivalent, emoticon);
            }
        }
        emoticonMap.put(packName, emoticons);
    }

    /**
     * Retrieve the URL to an emoticon.
     *
     * @param emoticon the emoticon.
     * @return the URL of the image.
     */
    public URL getEmoticonURL(Emoticon emoticon) {
        final String imageName = emoticon.getImageName();
        File file = new File(emoticon.getEmoticonDirectory(), imageName);
        try {
            return file.toURI().toURL();
        } catch (MalformedURLException ignored) {
        }
        return null;
    }

    /**
     * Returns the <code>Emoticon</code> associated with the given key. Note:
     * This gets the emoticon from the active emoticon pack.
     *
     * @param key the key.
     * @return the Emoticon found. If no emoticon is found, null is returned.
     */
    public Emoticon getEmoticon(String key) {
        if (activeEmoticonMap == null) {
            String packName = pref.getEmoticonPack();
            activeEmoticonMap = emoticonMap.get(packName);
            if (activeEmoticonMap == null) {
                activeEmoticonMap = Map.of();
            }
        }
        return activeEmoticonMap.get(key);
    }

    /**
     * Returns the Icon that is mapped to a given key.
     *
     * @param key the key to search for.
     * @return the Icon representing the key.
     */
    public ImageIcon getEmoticonImage(String key) {
        final Emoticon emoticon = getEmoticon(key);
        if (emoticon == null) {
            return null;
        }
        ImageIcon icon = imageMap.computeIfAbsent(key, it -> {
            URL url = getEmoticonURL(emoticon);
            return new ImageIcon(url);
        });
        return icon;
    }

    /**
     * Returns a list of all available emoticon packs.
     */
    public Collection<String> getEmoticonPacks() {
        File[] dirs = EMOTICON_DIRECTORY.listFiles(File::isDirectory);
        // If no emoticons are available
        if (dirs == null) {
            return List.of();
        }
        List<String> emoticonList = new ArrayList<>(dirs.length);
        for (File file : dirs) {
            String fileName = file.getName();
            if (CI.endsWith(fileName, ".adiumemoticonset")) {
                String name = URLFileSystem.getName(fileName);
                emoticonList.add(name);
            }
        }
        return emoticonList;
    }

    /**
     * Expands any zipped Emoticon Packs.
     *
     * @param file File to unpack.
     * @param dist Dist file.
     */
    private void expandNewPack(File file, File dist) {
        String name = URLFileSystem.getName(file.getName());
        File directory = new File(dist, name);
        // Unzip contents into directory
        unzipPack(file, directory.getParentFile());
    }

    /**
     * Checks the zip file for the Emoticons.plist file. This is Sparks way of
     * detecting a valid file.
     *
     * @param zip the zip file to check.
     * @return true if the EmoticonPlist exists in the archive.
     */
    private boolean containsEmoticonPList(File zip) {
        ZipFile zipFile = null;
        boolean result = false;
        try {
            zipFile = new JarFile(zip);
            for (Enumeration<?> e = zipFile.entries(); e.hasMoreElements(); ) {
                JarEntry entry = (JarEntry) e.nextElement();
                if (entry.getName().contains("Emoticons.plist")) {
                    result = true;
                    break;
                }
            }
        } catch (IOException e) {
            Log.error(e);
        }
        closeFile(zipFile);
        return result;
    }

    /**
     * Unzips a theme from a ZIP file into a directory.
     *
     * @param zip the ZIP file
     * @param dir the directory to extract the plugin to.
     * @return the root directory.
     */
    private File unzipPack(File zip, File dir) {
        File rootDirectory = null;
        try {
            ZipFile zipFile = new JarFile(zip);
            dir.mkdir();
            for (Enumeration<?> e = zipFile.entries(); e.hasMoreElements(); ) {
                JarEntry entry = (JarEntry) e.nextElement();
                File entryFile = new File(dir, entry.getName());

                //Fix Zip Slip Vulnerability
                if (!entryFile.toPath().normalize().startsWith(dir.toPath().normalize())) {
                    throw new RuntimeException("Bad zip entry");
                }

                // Ignore any manifest.mf entries.
                if (entry.getName().toLowerCase().endsWith("manifest.mf")) {
                    continue;
                }

                if (entry.isDirectory() && rootDirectory == null) {
                    rootDirectory = entryFile;
                }

                // Extract non‑directory entries from ZIP to filesystem
                if (!entry.isDirectory()) {
                    entryFile.getParentFile().mkdirs();
                    FileOutputStream out = new FileOutputStream(entryFile);
                    InputStream zin = zipFile.getInputStream(entry);
                    byte[] b = new byte[512];
                    int len;
                    while ((len = zin.read(b)) != -1) {
                        out.write(b, 0, len);
                    }
                    out.flush();
                    out.close();
                    zin.close();
                }
            }
            zipFile.close();
        } catch (Exception e) {
            Log.error("Error unzipping emoticon pack", e);
        }
        return rootDirectory;
    }

    /**
     * Deletes Emoticons in pathToSearch that have a different md5-hash than its correspondent in install\spark\xtra\emoticons
     */
    private void deleteOldEmoticons(File pathToSearch) {
        final File installPath = new File(Spark.getXtraDirectory(), "emoticons");
        final File[] files = installPath.listFiles();
        final List<File> installerFiles = files == null ? List.of() : asList(files);

        final File[] installedEmoticons = pathToSearch.listFiles(File::isDirectory);
        if (installedEmoticons == null) {
            return;
        }

        for (final File file : installedEmoticons) {
            final File zipFile = new File(pathToSearch, file.getName() + ".zip");
            if (!zipFile.exists()) {
                uninstall(file);
            } else {
                try {
                    final File f = new File(installPath + zipFile.getName());
                    // Compare old and new files by checksums
                    if (installerFiles.contains(f)) {
                        final String oldfile = StringUtils.getMD5Checksum(zipFile.getAbsolutePath());
                        final String newfile = StringUtils.getMD5Checksum(f.getAbsolutePath());
                        if (!oldfile.equals(newfile)) {
                            Log.debug("deleting: " + file.getAbsolutePath() + "," + zipFile.getAbsolutePath());
                            uninstall(file);
                            zipFile.delete();
                        }
                    }
                } catch (Exception e) {
                    Log.error("No such file", e);
                }
            }
        }
    }

    private void uninstall(File emoticonDir) {
        Log.warning("Uninstall emoticons pack: " + emoticonDir.getAbsolutePath());
        try {
            Files.walkFileTree(emoticonDir.toPath(), new SimpleFileVisitor<>() {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    Files.delete(file);
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                    Files.delete(dir);
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            Log.error("An unexpected exception occurred while trying to uninstall a emoticon from: " + emoticonDir, e);
        }
    }

}
