/**
 * @author Tres Finocchiaro
 *
 * Copyright (C) 2019 Tres Finocchiaro, QZ Industries, LLC
 *
 * LGPL 2.1 This is free software.  This software and source code are released under
 * the "LGPL 2.1 License".  A copy of this license should be distributed with
 * this software. http://www.gnu.org/licenses/lgpl-2.1.html
 */

package qz.installer;

import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import qz.auth.Certificate;
import qz.build.provision.params.Phase;
import qz.installer.certificate.*;
import qz.installer.certificate.firefox.FirefoxCertificateInstaller;
import qz.installer.provision.ProvisionInstaller;
import qz.utils.FileUtilities;
import qz.utils.SystemUtilities;
import qz.ws.WebsocketPorts;

import java.io.*;
import java.nio.file.*;
import java.security.cert.X509Certificate;
import java.util.*;

import static qz.common.Constants.*;
import static qz.installer.certificate.KeyPairWrapper.Type.CA;
import static qz.utils.FileUtilities.*;

/**
 * Cross-platform wrapper for install steps
 * - Used by CommandParser via command line
 * - Used by PrintSocketServer at startup to ensure SSL is functioning
 */
public abstract class Installer {
    protected static final Logger log = LogManager.getLogger(Installer.class);

    // Silence prompts within our control
    public static boolean IS_SILENT =  "1".equals(System.getenv(DATA_DIR + "_silent"));
    public static String JRE_LOCATION = SystemUtilities.isMac() ? "Contents/PlugIns/Java.runtime/Contents/Home" : "runtime";

    WebsocketPorts websocketPorts;

    public enum PrivilegeLevel {
        USER,
        SYSTEM
    }

    public abstract Installer removeLegacyStartup();
    public abstract Installer addAppLauncher();
    public abstract Installer addStartupEntry();
    public abstract Installer addSystemSettings();
    public abstract Installer removeSystemSettings();
    public abstract void spawn(List<String> args) throws Exception;

    public abstract void setDestination(String destination);
    public abstract String getDestination();

    private static Installer instance;

    public static Installer getInstance() {
        if(instance == null) {
            switch(SystemUtilities.getOs()) {
                case WINDOWS:
                    instance = new WindowsInstaller();
                    break;
                case MAC:
                    instance = new MacInstaller();
                    break;
                default:
                    instance = new LinuxInstaller();
            }
        }
        return instance;
    }

    public static void install(String destination, boolean silent) throws Exception {
        IS_SILENT |= silent; // preserve environmental variable if possible
        getInstance();
        if (destination != null) {
            instance.setDestination(destination);
        }
        install();
    }

    public static boolean preinstall() {
        getInstance();
        log.info("Fixing runtime permissions...");
        instance.setJrePermissions(SystemUtilities.getAppPath().toString());
        log.info("Stopping running instances...");
        return TaskKiller.killAll();
    }

    public static void install() throws Exception {
        getInstance();
        log.info("Installing to {}", instance.getDestination());
        instance.removeLibs()
                .removeProvisioning()
                .deployApp()
                .removeLegacyStartup()
                .removeLegacyFiles()
                .addSharedDirectory()
                .addAppLauncher()
                .addStartupEntry()
                .invokeProvisioning(Phase.INSTALL)
                .addSystemSettings();
    }

    public static void uninstall() {
        log.info("Stopping running instances...");
        TaskKiller.killAll();
        getInstance();
        log.info("Uninstalling from {}", instance.getDestination());
        instance.removeSharedDirectory()
                .removeSystemSettings()
                .removeCerts()
                .invokeProvisioning(Phase.UNINSTALL);
    }

    public Installer deployApp() throws IOException {
        Path src = SystemUtilities.getAppPath();
        Path dest = Paths.get(getDestination());

        if(!Files.exists(dest)) {
            Files.createDirectories(dest);
        }

        // Delete the JDK blindly
        FileUtils.deleteDirectory(dest.resolve(JRE_LOCATION).toFile());
        // Note: preserveFileDate=false per https://github.com/qzind/tray/issues/1011
        FileUtils.copyDirectory(src.toFile(), dest.toFile(), false);
        FileUtilities.setPermissionsRecursively(dest, false);
        // Fix permissions for provisioned files
        FileUtilities.setExecutableRecursively(SystemUtilities.isMac() ?
                                                       dest.resolve("Contents/Resources").resolve(PROVISION_DIR) :
                                                       dest.resolve(PROVISION_DIR), false);
        if(!SystemUtilities.isWindows()) {
            setExecutable(SystemUtilities.isMac() ? "Contents/Resources/uninstall" : "uninstall");
            setExecutable(SystemUtilities.isMac() ? "Contents/MacOS/" + ABOUT_TITLE : PROPS_FILE);
            return setJrePermissions(getDestination());
        }
        return this;
    }

    private Installer setJrePermissions(String dest) {
        File jreLocation = new File(dest, JRE_LOCATION);
        File jreBin = new File(jreLocation, "bin");
        File jreLib = new File(jreLocation, "lib");

        // Set jre/bin/java and friends executable
        File[] files = jreBin.listFiles(pathname -> !pathname.isDirectory());
        if(files != null) {
            for(File file : files) {
                file.setExecutable(true, false);
            }
        }

        // Set jspawnhelper executable
        new File(jreLib, "jspawnhelper" + (SystemUtilities.isWindows() ? ".exe" : "")).setExecutable(true, false);
        return this;
    }

    private void setExecutable(String relativePath) {
        new File(getDestination(), relativePath).setExecutable(true, false);
    }

    /**
     * Explicitly purge libs to notify system cache per https://github.com/qzind/tray/issues/662
     */
    public Installer removeLibs() {
        String[] dirs = { "libs" };
        for (String dir : dirs) {
            try {
                FileUtils.deleteDirectory(new File(instance.getDestination() + File.separator + dir));
            } catch(IOException ignore) {}
        }
        return this;
    }

    public Installer cleanupLegacyLogs(int rolloverCount) {
        // Convert old < 2.2.3 log file format
        Path logLocation = USER_DIR;
        int oldIndex = 0;
        int newIndex = 0;
        File oldFile;
        do {
            // Old: debug.log.1
            oldFile = logLocation.resolve("debug.log." + ++oldIndex).toFile();
            if(oldFile.exists()) {
                // New: debug.1.log
                File newFile;
                do {
                    newFile = logLocation.resolve("debug." + ++newIndex + ".log").toFile();
                } while(newFile.exists());

                oldFile.renameTo(newFile);
                log.info("Migrated log file {} to new location {}", oldFile, newFile);
            }
        } while(oldFile.exists() || oldIndex <= rolloverCount);

        return this;
    }

    public Installer removeLegacyFiles() {
        ArrayList<String> dirs = new ArrayList<>();
        ArrayList<String> files = new ArrayList<>();
        HashMap<String, String> move = new HashMap<>();

        // QZ Tray 2.0 files
        dirs.add("demo/js/3rdparty");
        dirs.add("utils");
        dirs.add("auth");
        files.add("demo/js/qz-websocket.js");
        files.add("windows-icon.ico");

        // QZ Tray 2.2.3-SNAPSHOT accidentally wrote certs in the wrong place
        dirs.add("ssl");

        // QZ Tray 2.1 files
        if(SystemUtilities.isMac()) {
            // Moved to macOS Application Bundle standard https://developer.apple.com/go/?id=bundle-structure
            dirs.add("demo");
            dirs.add("libs");
            files.add(PROPS_FILE + ".jar");
            files.add("LICENSE.txt");
            files.add("uninstall");
            move.put(PROPS_FILE + ".properties", "Contents/Resources/" + PROPS_FILE + ".properties");
        }

        dirs.forEach(dir -> {
            try {
                FileUtils.deleteDirectory(new File(instance.getDestination() + File.separator + dir));
            } catch(IOException ignore) {}
        });

        files.forEach(file -> {
            new File(instance.getDestination() + File.separator + file).delete();
        });

        move.forEach((src, dest) -> {
            try {
                FileUtils.moveFile(new File(instance.getDestination() + File.separator + src),
                                   new File(instance.getDestination() + File.separator + dest));
            } catch(IOException ignore) {}
        });
        return this;
    }

    public Installer addSharedDirectory() {
        try {
            Files.createDirectories(SHARED_DIR);
            FileUtilities.setPermissionsRecursively(SHARED_DIR, true);
            Path ssl = Paths.get(SHARED_DIR.toString(), "ssl");
            Files.createDirectories(ssl);
            FileUtilities.setPermissionsRecursively(ssl, true);

            log.info("Created shared directory: {}", SHARED_DIR);
        } catch(IOException e) {
            log.warn("Could not create shared directory: {}", SHARED_DIR);
        }
        return this;
    }

    public Installer removeSharedDirectory() {
        try {
            FileUtils.deleteDirectory(SHARED_DIR.toFile());
            log.info("Deleted shared directory: {}", SHARED_DIR);
        } catch(IOException e) {
            log.warn("Could not delete shared directory: {}", SHARED_DIR);
        }
        return this;
    }

    /**
     * Checks, and if needed generates an SSL for the system
     */
    public CertificateManager certGen(boolean forceNew, String... hostNames) throws Exception {
        CertificateManager certificateManager = new CertificateManager(forceNew, hostNames);
        boolean needsInstall = certificateManager.needsInstall();
        try {
            // Check that the CA cert is installed
            X509Certificate caCert = certificateManager.getKeyPair(CA).getCert();
            NativeCertificateInstaller installer = NativeCertificateInstaller.getInstance();

            if (forceNew || needsInstall) {
                // Remove installed certs per request (usually the desktop installer, or failure to write properties)
                // Skip if running from IDE, this may accidentally remove sandboxed certs
                if(SystemUtilities.isJar()) {
                    List<String> matchingCerts = installer.find();
                    installer.remove(matchingCerts);
                }
                installer.install(caCert);
                FirefoxCertificateInstaller.install(caCert, hostNames);
            } else {
                // Make sure the certificate is recognized by the system
                if(caCert == null) {
                    log.info("CA cert is empty, skipping installation checks.  This is normal for trusted/3rd-party SSL certificates.");
                } else {
                    File tempCert = File.createTempFile(KeyPairWrapper.getAlias(KeyPairWrapper.Type.CA) + "-", CertificateManager.DEFAULT_CERTIFICATE_EXTENSION);
                    CertificateManager.writeCert(caCert, tempCert); // temp cert
                    if (!installer.verify(tempCert)) {
                        installer.install(caCert);
                        FirefoxCertificateInstaller.install(caCert, hostNames);
                    }
                    if(!tempCert.delete()) {
                        tempCert.deleteOnExit();
                    }
                }
            }
        }
        catch(Exception e) {
            log.error("Something went wrong obtaining the certificate.  HTTPS will fail.", e);
        }

        // Add provisioning steps that come after certgen
        if(SystemUtilities.isAdmin()) {
            invokeProvisioning(Phase.CERTGEN);
        }

        return certificateManager;
    }

    /**
     * Remove matching certs from user|system, then Firefox
     */
    public Installer removeCerts() {
        // System certs
        NativeCertificateInstaller instance = NativeCertificateInstaller.getInstance();
        instance.remove(instance.find());
        // Firefox certs
        FirefoxCertificateInstaller.uninstall();
        return this;
    }

    /**
     * Add user-specific settings
     * Note: See override usage for platform-specific tasks
     */
    public Installer addUserSettings() {
        // Check for whitelisted certificates in <install>/whitelist/
        Path whiteList = SystemUtilities.getJarParentPath().resolve(WHITELIST_CERT_DIR);
        if(Files.exists(whiteList) && Files.isDirectory(whiteList)) {
            for(File file : whiteList.toFile().listFiles()) {
                try {
                    Certificate cert = new Certificate(FileUtilities.readLocalFile(file.getPath()));
                    if (!cert.isSaved()) {
                        FileUtilities.addToCertList(ALLOW_FILE, file);
                    }
                } catch(Exception e) {
                    log.warn("Could not add {} to {}", file, ALLOW_FILE, e);
                }
            }
        }
        return instance;
    }

    public Installer invokeProvisioning(Phase phase) {
        try {
            Path provisionPath = SystemUtilities.isMac() ?
                    Paths.get(getDestination()).resolve("Contents/Resources").resolve(PROVISION_DIR) :
                    Paths.get(getDestination()).resolve(PROVISION_DIR);
            ProvisionInstaller provisionInstaller = new ProvisionInstaller(provisionPath);
            provisionInstaller.invoke(phase);

            // Special case for custom websocket ports
            if(phase == Phase.INSTALL) {
                    websocketPorts = WebsocketPorts.parseFromSteps(provisionInstaller.getSteps());
            }
        } catch(Exception e) {
            log.warn("An error occurred invoking provision \"phase\": \"{}\"", phase, e);
        }
        return this;
    }

    public Installer removeProvisioning() {
        try {
            Path provisionPath = SystemUtilities.isMac() ?
                    Paths.get(getDestination()).resolve("Contents/Resources").resolve(PROVISION_DIR) :
                    Paths.get(getDestination()).resolve(PROVISION_DIR);
            FileUtils.deleteDirectory(provisionPath.toFile());
        } catch(Exception e) {
            log.warn("An error occurred removing provision directory",  e);
        }
        return this;
    }

    public static Properties persistProperties(File oldFile, Properties newProps) {
        if(oldFile.exists()) {
            Properties oldProps = new Properties();
            try(Reader reader = new FileReader(oldFile)) {
                oldProps.load(reader);
                for(String key : PERSIST_PROPS) {
                    if (oldProps.containsKey(key)) {
                        String value = oldProps.getProperty(key);
                        log.info("Preserving {}={} for install", key, value);
                        newProps.put(key, value);
                    }
                }
            } catch(IOException e) {
                log.warn("Warning, an error occurred reading the old properties file {}", oldFile, e);
            }
        }
        return newProps;
    }

    public void spawn(String ... args) throws Exception {
        spawn(new ArrayList(Arrays.asList(args)));
    }
}
