/*
 * SK's Minecraft Launcher
 * Copyright (C) 2010-2014 Albert Pham <http://www.sk89q.com> and contributors
 * Please see LICENSE.txt for license information.
 */

package com.skcraft.launcher.builder;

import com.beust.jcommander.JCommander;
import com.beust.jcommander.ParameterException;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.io.ByteStreams;
import com.google.common.io.Closer;
import com.google.common.io.Files;
import com.skcraft.launcher.Launcher;
import com.skcraft.launcher.LauncherUtils;
import com.skcraft.launcher.builder.loaders.ILoaderProcessor;
import com.skcraft.launcher.builder.loaders.LoaderResult;
import com.skcraft.launcher.builder.loaders.ModernForgeLoaderProcessor;
import com.skcraft.launcher.builder.loaders.OldForgeLoaderProcessor;
import com.skcraft.launcher.model.loader.BasicInstallProfile;
import com.skcraft.launcher.model.minecraft.Library;
import com.skcraft.launcher.model.minecraft.ReleaseList;
import com.skcraft.launcher.model.minecraft.Version;
import com.skcraft.launcher.model.minecraft.VersionManifest;
import com.skcraft.launcher.model.modpack.Manifest;
import com.skcraft.launcher.util.Environment;
import com.skcraft.launcher.util.HttpRequest;
import com.skcraft.launcher.util.SimpleLogFormatter;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import lombok.extern.java.Log;

import java.io.*;
import java.net.URL;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Properties;
import java.util.jar.JarFile;
import java.util.logging.Level;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.emptyToNull;
import static com.skcraft.launcher.util.HttpRequest.url;

/**
 * Builds packages for the launcher.
 */
@Log
public class PackageBuilder {

    private static final Pattern TWEAK_CLASS_ARG = Pattern.compile("--tweakClass\\s+([^\\s]+)");

    private final Properties properties;
    private final ObjectMapper mapper;
    private ObjectWriter writer;
    private final Manifest manifest;
    private final PropertiesApplicator applicator;
    @Getter
    private boolean prettyPrint = false;

    @Getter @Setter
    private File baseDir;

    private List<Library> loaderLibraries = Lists.newArrayList();
    private List<Library> installerLibraries = Lists.newArrayList();
    private List<String> mavenRepos;
    private List<URL> jarMavens = Lists.newArrayList();

    /**
     * Create a new package builder.
     *
     * @param mapper the mapper
     * @param manifest the manifest
     */
    public PackageBuilder(@NonNull ObjectMapper mapper, @NonNull Manifest manifest) throws IOException {
        this.properties = LauncherUtils.loadProperties(Launcher.class,
                "launcher.properties", "com.skcraft.launcher.propertiesFile");

        this.mapper = mapper;
        this.manifest = manifest;
        this.applicator = new PropertiesApplicator(manifest);
        setPrettyPrint(false); // Set writer

        Closer closer = Closer.create();
        try {
            mavenRepos = mapper.readValue(closer.register(Launcher.class.getResourceAsStream("maven_repos.json")), new TypeReference<List<String>>() {
            });
        } finally {
            closer.close();
        }
    }

    public void setPrettyPrint(boolean prettyPrint) {
        if (prettyPrint) {
            writer = mapper.writerWithDefaultPrettyPrinter();
        } else {
            writer = mapper.writer();
        }
        this.prettyPrint = prettyPrint;
    }

    public void scan(File dir) throws IOException {
        logSection("Scanning for .info.json files...");

        FileInfoScanner scanner = new FileInfoScanner(mapper);
        scanner.walk(dir);
        for (FeaturePattern pattern : scanner.getPatterns()) {
            applicator.register(pattern);
        }
    }

    public void addFiles(File dir, File destDir) throws IOException {
        logSection("Adding files to modpack...");

        ClientFileCollector collector = new ClientFileCollector(this.manifest, applicator, destDir);
        collector.walk(dir);
    }

    public void addLoaders(File dir, File librariesDir) {
        logSection("Checking for mod loaders to install...");

        LinkedHashSet<Library> collected = new LinkedHashSet<Library>();

        File[] files = dir.listFiles(new JarFileFilter());
        if (files != null) {
            for (File file : files) {
                try {
                    processLoader(collected, file, librariesDir);
                } catch (IOException e) {
                    log.log(Level.WARNING, "Failed to add the loader at " + file.getAbsolutePath(), e);
                }
            }
        }

        this.loaderLibraries.addAll(collected);

        VersionManifest version = manifest.getVersionManifest();
        collected.addAll(version.getLibraries());
        version.setLibraries(collected);
    }

    private void processLoader(LinkedHashSet<Library> loaderLibraries, File file, File librariesDir) throws IOException {
        log.info("Installing " + file.getName() + "...");

        JarFile jarFile = new JarFile(file);
        Closer closer = Closer.create();
        ILoaderProcessor processor = null;

        try {
            ZipEntry profileEntry = BuilderUtils.getZipEntry(jarFile, "install_profile.json");

            if (profileEntry != null) {
                InputStream stream = jarFile.getInputStream(profileEntry);
                InputStreamReader reader = closer.register(new InputStreamReader(stream));

                BasicInstallProfile basicProfile = mapper.readValue(BuilderUtils.readStringFromStream(reader),
                        BasicInstallProfile.class);
                String profileName = basicProfile.resolveProfileName();

                if (profileName.equalsIgnoreCase("forge")) {
                    if (basicProfile.isLegacy()) {
                        processor = new OldForgeLoaderProcessor();
                    } else {
                        processor = new ModernForgeLoaderProcessor();
                    }
                }
            }
        } finally {
            closer.close();
            jarFile.close();
        }

        if (processor != null) {
            LoaderResult result = processor.process(file, manifest, mapper, baseDir);

            loaderLibraries.addAll(result.getLoaderLibraries());
            installerLibraries.addAll(result.getProcessorLibraries());
            jarMavens.addAll(result.getJarMavens());
        }
    }

    public void downloadLibraries(File librariesDir) throws IOException, InterruptedException {
        logSection("Downloading libraries...");

        // TODO: Download libraries for different environments -- As of writing, this is not an issue
        Environment env = Environment.getInstance();

        for (Library library : Iterables.concat(loaderLibraries, installerLibraries)) {
            Library.Artifact artifact = library.getArtifact(env);
            File outputPath = new File(librariesDir, artifact.getPath());

            if (!outputPath.exists()) {
                Files.createParentDirs(outputPath);
                boolean found = false;

                if (!artifact.getUrl().isEmpty()) {
                    found = tryDownloadLibrary(library, artifact, artifact.getUrl(), outputPath);
                }

                // Look inside the loader JARs
                if (!found) {
                    for (URL base : jarMavens) {
                        found = tryFetchLibrary(library, new URL(base, artifact.getPath()), outputPath);
                        if (found) break;
                    }
                }

                // Try each repository if not found yet
                if (!found) {
                    for (String baseUrl : mavenRepos) {
                        found = tryDownloadLibrary(library, artifact, baseUrl + artifact.getPath(), outputPath);
                        if (found) break;
                    }
                }

                if (!found) {
                    log.warning("!! Failed to download the library " + library.getName() + " -- this means your copy of the libraries will lack this file");
                }
            }
        }
    }

    private boolean tryDownloadLibrary(Library library, Library.Artifact artifact, String baseUrl, File outputPath)
            throws IOException, InterruptedException {
        URL url = new URL(baseUrl);
        File tempFile = File.createTempFile("launcherlib", null);

        // Some repositories compress their files
        List<Compressor> compressors = BuilderUtils.getCompressors(baseUrl);
        for (Compressor compressor : Lists.reverse(compressors)) {
            url = new URL(url, compressor.transformPathname(artifact.getPath()));
        }

        try {
            log.info("Downloading library " + library.getName() + " from " + url + "...");
            HttpRequest.get(url).execute().expectResponseCode(200).saveContent(tempFile);
        } catch (IOException e) {
            log.info("Could not get file from " + url + ": " + e.getMessage());
            return false;
        }

        writeLibraryToFile(outputPath, tempFile, compressors);
        return true;
    }

    private boolean tryFetchLibrary(Library library, URL url, File outputPath)
            throws IOException {
        File tempFile = File.createTempFile("launcherlib", null);

        Closer closer = Closer.create();
        try {
            log.info("Reading library " + library.getName() + " from " + url.toString());
            InputStream stream = closer.register(url.openStream());
            stream = closer.register(new BufferedInputStream(stream));

            ByteStreams.copy(stream, closer.register(new FileOutputStream(tempFile)));
        } catch (IOException e) {
            log.info("Could not get file from " + url + ": " + e.getMessage());
            return false;
        } finally {
            closer.close();
        }

        writeLibraryToFile(outputPath, tempFile, Collections.<Compressor>emptyList());
        return true;
    }

    private void writeLibraryToFile(File outputPath, File inputFile, List<Compressor> compressors) throws IOException {
        // Decompress (if needed) and write to file
        Closer closer = Closer.create();
        InputStream inputStream = closer.register(new FileInputStream(inputFile));
        inputStream = closer.register(new BufferedInputStream(inputStream));
        for (Compressor compressor : compressors) {
            inputStream = closer.register(compressor.createInputStream(inputStream));
        }
        ByteStreams.copy(inputStream, closer.register(new FileOutputStream(outputPath)));

        inputFile.delete();
        closer.close();
    }

    public void validateManifest() {
        checkNotNull(emptyToNull(manifest.getName()), "Package name is not defined");
        checkNotNull(emptyToNull(manifest.getGameVersion()), "Game version is not defined");
    }

    public void readConfig(File path) throws IOException {
        if (path != null) {
            BuilderConfig config = read(path, BuilderConfig.class);
            config.update(manifest);
            config.registerProperties(applicator);
        }
    }

    public void readVersionManifest(File path) throws IOException, InterruptedException {
        logSection("Reading version manifest...");

        if (path.exists()) {
            VersionManifest versionManifest = read(path, VersionManifest.class);
            manifest.setVersionManifest(versionManifest);

            log.info("Loaded version manifest from " + path.getAbsolutePath());
        } else {
            URL url = url(properties.getProperty("versionManifestUrl"));

            log.info("Fetching version manifest from " + url + "...");

            ReleaseList releases = HttpRequest.get(url)
                    .execute()
                    .expectResponseCode(200)
                    .returnContent()
                    .asJson(ReleaseList.class);

            Version version = releases.find(manifest.getGameVersion());
            VersionManifest versionManifest = HttpRequest.get(url(version.getUrl()))
                .execute()
                .expectResponseCode(200)
                .returnContent()
                .asJson(VersionManifest.class);

            manifest.setVersionManifest(versionManifest);
        }
    }

    public void writeManifest(@NonNull File path) throws IOException {
        logSection("Writing manifest...");

        manifest.setFeatures(applicator.getFeaturesInUse());
        VersionManifest versionManifest = manifest.getVersionManifest();
        if (versionManifest != null) {
            versionManifest.setId(manifest.getGameVersion());
        }
        validateManifest();
        path.getAbsoluteFile().getParentFile().mkdirs();
        writer.writeValue(path, manifest);

        log.info("Wrote manifest to " + path.getAbsolutePath());
    }

    private static BuilderOptions parseArgs(String[] args) {
        BuilderOptions options = new BuilderOptions();
        new JCommander(options, args);
        options.choosePaths();
        return options;
    }

    private <V> V read(File path, Class<V> clazz) throws IOException {
        try {
            if (path == null) {
                return clazz.newInstance();
            } else {
                return mapper.readValue(path, clazz);
            }
        } catch (InstantiationException e) {
            throw new IOException("Failed to create " + clazz.getCanonicalName(), e);
        } catch (IllegalAccessException e) {
            throw new IOException("Failed to create " + clazz.getCanonicalName(), e);
        }
    }

    /**
     * Build a package given the arguments.
     *
     * @param args arguments
     * @throws IOException thrown on I/O error
     * @throws InterruptedException on interruption
     */
    public static void main(String[] args) throws IOException, InterruptedException {
        BuilderOptions options;
        try {
            options = parseArgs(args);
        } catch (ParameterException e) {
            new JCommander().usage();
            System.err.println("error: " + e.getMessage());
            System.exit(1);
            return;
        }

        // Initialize
        SimpleLogFormatter.configureGlobalLogger();
        ObjectMapper mapper = new ObjectMapper();
        mapper.setSerializationInclusion(JsonInclude.Include.NON_DEFAULT);

        Manifest manifest = new Manifest();
        manifest.setMinimumVersion(Manifest.MIN_PROTOCOL_VERSION);
        PackageBuilder builder = new PackageBuilder(mapper, manifest);
        builder.setPrettyPrint(options.isPrettyPrinting());

        // From config
        builder.readConfig(options.getConfigPath());
        builder.readVersionManifest(options.getVersionManifestPath());
        builder.setBaseDir(options.getOutputPath());

        // From options
        manifest.updateName(options.getName());
        manifest.updateTitle(options.getTitle());
        manifest.updateGameVersion(options.getGameVersion());
        manifest.setVersion(options.getVersion());
        manifest.setLibrariesLocation(options.getLibrariesLocation());
        manifest.setObjectsLocation(options.getObjectsLocation());

        builder.scan(options.getFilesDir());
        builder.addFiles(options.getFilesDir(), options.getObjectsDir());
        builder.addLoaders(options.getLoadersDir(), options.getLibrariesDir());
        builder.downloadLibraries(options.getLibrariesDir());
        builder.writeManifest(options.getManifestPath());

        logSection("Done");

        log.info("Now upload the contents of " + options.getOutputPath() + " to your web server or CDN!");
    }

    private static void logSection(String name) {
        log.info("");
        log.info("--- " + name + " ---");
    }

}
