public abstract class ApplyMappings extends DefaultTask {
    private boolean javadocs = false;
    private boolean lambdas = true;

    public ApplyMappings() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(s -> s.file("output.zip")));
    }

    @TaskAction
    public void apply() throws IOException {
        McpNames names = McpNames.load(getMappings().get().getAsFile());

        try (ZipFile zin = new ZipFile(getInput().get().getAsFile())) {
            try (FileOutputStream fos = new FileOutputStream(getOutput().get().getAsFile());
                 ZipOutputStream out = new ZipOutputStream(fos)) {
                zin.stream().forEach(e -> {
                    try {
                        out.putNextEntry(Utils.getStableEntry(e.getName()));
                        if (!e.getName().endsWith(".java")) {
                            IOUtils.copy(zin.getInputStream(e), out);
                        } else {
                            out.write(names.rename(zin.getInputStream(e), javadocs, lambdas).getBytes(StandardCharsets.UTF_8));
                        }
                        out.closeEntry();
                    } catch (IOException e1) {
                        throw new RuntimeException(e1);
                    }
                });
            }
        }
    }

    @InputFile
    public abstract RegularFileProperty getInput();

    @InputFile
    public abstract RegularFileProperty getMappings();

    @OutputFile
    public abstract RegularFileProperty getOutput();

    @Input
    public boolean getJavadocs() {
        return this.javadocs;
    }

    public void setJavadocs(boolean javadocs) {
        this.javadocs = javadocs;
    }

    @Input
    public boolean getLambdas() {
        return this.lambdas;
    }

    public void setLambdas(boolean lambdas) {
        this.lambdas = lambdas;
    }
} public abstract class ApplyRangeMap extends JarExec {
    public boolean annotate = false;
    public boolean keepImports = true;

    public ApplyRangeMap() {
        getTool().set(Utils.SRG2SOURCE);
        getArgs().addAll("--apply", "--input", "{input}", "--range", "{range}", "--srg", "{srg}", "--exc", "{exc}",
                "--output", "{output}", "--keepImports", "{keepImports}");
        setMinimumRuntimeJavaVersion(11);

        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.file("output.zip")));
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        return replaceArgs(args, ImmutableMap.of(
                "{range}", getRangeMap().get().getAsFile(),
                "{output}", getOutput().get().getAsFile(),
                "{annotate}", annotate,
                "{keepImports}", keepImports
                ), ImmutableMap.of(
                "{input}", getSources().getFiles(),
                "{srg}", getSrgFiles().getFiles(),
                "{exc}", getExcFiles().getFiles()
                )
        );
    }

    @InputFiles
    public abstract ConfigurableFileCollection getSrgFiles();

    @InputFiles
    public abstract ConfigurableFileCollection getSources();

    @InputFiles
    public abstract ConfigurableFileCollection getExcFiles();

    @InputFile
    public abstract RegularFileProperty getRangeMap();

    @OutputFile
    public abstract RegularFileProperty getOutput();

    @Input
    public boolean getAnnotate() {
        return annotate;
    }

    public void setAnnotate(boolean value) {
        this.annotate = value;
    }

    @Input
    public boolean getKeepImports() {
        return keepImports;
    }

    public void setKeepImports(boolean value) {
        this.keepImports = value;
    }
} public abstract class DownloadAssets extends DefaultTask {
    public DownloadAssets() {
        getAssetRepository().convention("https://resources.download.minecraft.net/");
        getConcurrentDownloads().convention(8);
    }

    @TaskAction
    public void run() throws IOException, InterruptedException {
        AssetIndex index = Utils.loadJson(getIndex(), AssetIndex.class);
        List<String> keys = new ArrayList<>(index.objects.keySet());
        Collections.sort(keys);
        removeDuplicateRemotePaths(keys, index);

        File assetsPath = new File(Utils.getMCDir(), "/assets/objects");
        ExecutorService executorService = Executors.newFixedThreadPool(getConcurrentDownloads().get());
        CopyOnWriteArrayList<String> failedDownloads = new CopyOnWriteArrayList<>();
        String assetRepo = getAssetRepository().get();
        for (String key : keys) {
            Asset asset = index.objects.get(key);
            File target = Utils.getCache(getProject(), "assets", "objects", asset.getPath());
            if (!target.exists() || !HashFunction.SHA1.hash(target).equals(asset.hash)) {
                URL url = new URL(assetRepo + asset.getPath());
                Runnable copyURLtoFile = () -> {
                    try {
                        File localFile = FileUtils.getFile(assetsPath + File.separator + asset.getPath());
                        if (localFile.exists()) {
                            getProject().getLogger().lifecycle("Copying local object: " + asset.getPath() + " Asset: " + key);
                            FileUtils.copyFile(localFile, target);
                        } else {
                            getProject().getLogger().lifecycle("Downloading: " + url + " Asset: " + key);
                            FileUtils.copyURLToFile(url, target, 10_000, 5_000);
                        }
                        if (!HashFunction.SHA1.hash(target).equals(asset.hash)) {
                            failedDownloads.add(key);
                            Utils.delete(target);
                            getProject().getLogger().error("{} Hash failed.", key);
                        }
                    } catch (IOException e) {
                        failedDownloads.add(key);
                        getProject().getLogger().error("{} Failed.", key);
                        e.printStackTrace();
                    }
                };
                executorService.execute(copyURLtoFile);
            }
        }
        executorService.shutdown();
        executorService.awaitTermination(8, TimeUnit.HOURS);
        if (!failedDownloads.isEmpty()) {
            String errorMessage = "";
            for (String key : failedDownloads) {
                errorMessage += "Failed to get asset: " + key + "\n";
            }
            errorMessage += "Some assets failed to download or validate, try running the task again.";
            throw new RuntimeException(errorMessage);
        }
    }

    // Some keys may reference the same remote file. Remove these duplicates to prevent two threads
    // writing to the same file on disk.
    private static void removeDuplicateRemotePaths(List<String> keys, AssetIndex index) {
        Set<String> seen = new HashSet<>(keys.size());
        keys.removeIf(key -> !seen.add(index.objects.get(key).getPath()));
    }

    private File getIndex() throws IOException {
        VersionJson json = Utils.loadJson(getMeta().get().getAsFile(), VersionJson.class);
        File target = Utils.getCache(getProject(), "assets", "indexes", json.assetIndex.id + ".json");
        return Utils.updateDownload(getProject(), target, json.assetIndex);
    }

    @InputFile
    public abstract RegularFileProperty getMeta();

    /**
     * The Base URL that will be used to download Minecraft assets.
     * A trailing slash is required.
     */
    @Internal
    public abstract Property<String> getAssetRepository();

    /**
     * Defines how many threads will be used to download assets concurrently.
     */
    @Internal
    public abstract Property<Integer> getConcurrentDownloads();

    @OutputDirectory
    public File getOutput() {
        return Utils.getCache(getProject(), "assets");
    }

    private static class AssetIndex {
        Map<String, Asset> objects;
    }

    private static class Asset {
        String hash;

        public String getPath() {
            return hash.substring(0, 2) + '/' + hash;
        }
    }
} public abstract class DownloadMCMeta extends DefaultTask {
    // TODO: convert this into a property?
    private static final String MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
    private static final Gson GSON = new GsonBuilder().create();

    public DownloadMCMeta() {
        getManifest().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(s -> s.file("manifest.json")));
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(s -> s.file("version.json")));
    }

    @TaskAction
    public void downloadMCMeta() throws IOException {
        try (InputStream manin = new URL(MANIFEST_URL).openStream()) {
            URL url = GSON.fromJson(new InputStreamReader(manin), ManifestJson.class).getUrl(getMCVersion().get());
            if (url != null) {
                FileUtils.copyURLToFile(url, getOutput().get().getAsFile());
            } else {
                throw new RuntimeException("Missing version from manifest: " + getMCVersion().get());
            }
        }
    }

    @Input
    public abstract Property<String> getMCVersion();

    // TODO: check for uses, remove if not used
    @Internal
    public abstract RegularFileProperty getManifest();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class DynamicJarExec extends JarExec {
    public DynamicJarExec() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.file("output.jar")));
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        ImmutableMap.Builder<String, Object> replace = ImmutableMap.builder();
        replace.put("{input}", getInput().get().getAsFile());
        replace.put("{output}", getOutput().get().getAsFile());
        getData().get().forEach((key, value) -> replace.put('{' + key + '}', value.getAbsolutePath()));

        return replaceArgs(args, replace.build(), null);
    }

    @InputFiles
    @Optional
    public abstract MapProperty<String, File> getData();

    @InputFile
    public abstract RegularFileProperty getInput();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class ExtractExistingFiles extends DefaultTask {
    @TaskAction
    public void run() throws IOException {
        try (ZipFile zip = new ZipFile(getArchive().get().getAsFile())) {
            Enumeration<? extends ZipEntry> enu = zip.entries();
            while (enu.hasMoreElements()) {
                ZipEntry e = enu.nextElement();
                if (e.isDirectory()) continue;

                for (File target : getTargets()) {
                    File out = new File(target, e.getName());
                    if (!out.exists()) continue;
                    try (FileOutputStream fos = new FileOutputStream(out)) {
                        IOUtils.copy(zip.getInputStream(e), fos);
                    }
                }
            }
        }
    }

    @InputFile
    public abstract RegularFileProperty getArchive();

    @OutputDirectories
    public abstract ConfigurableFileCollection getTargets();
} public abstract class ExtractMCPData extends DefaultTask {
    private boolean allowEmpty = false;

    public ExtractMCPData() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(s -> s.file("output.srg")));
        getKey().convention("mappings");
    }

    @TaskAction
    public void run() throws IOException {
        MCPConfigV2 cfg = MCPConfigV2.getFromArchive(getConfig().get().getAsFile());

        String key = getKey().get();
        File output = getOutput().get().getAsFile();
        try (ZipFile zip = new ZipFile(getConfig().get().getAsFile())) {
            String path = cfg.getData(key.split("/"));
            if (path == null && "statics".equals(key))
                path = "config/static_methods.txt";

            if (path == null) {
                error("Could not find data entry for '" + key + "'");
                return;
            }

            ZipEntry entry = zip.getEntry(path);
            if (entry == null) {
                error("Invalid config zip, Missing path '" + path + "'");
                return;
            }

            try (OutputStream out = new FileOutputStream(output)) {
                IOUtils.copy(zip.getInputStream(entry), out);
            }
        }

        if (cfg.isOfficial() && output.exists() && "mappings".equals(key)) {
            IMappingFile obfToSrg = IMappingFile.load(output);
            remapSrgClasses(getProject(), cfg, obfToSrg).write(output.toPath(), IMappingFile.Format.TSRG2, false);
        }
    }

    public static IMappingFile remapSrgClasses(Project project, MCPConfigV2 config, IMappingFile obfToSrg) throws IOException {
        String minecraftVersion = MinecraftRepo.getMCVersion(config.getVersion());
        File client = MavenArtifactDownloader.generate(project, "net.minecraft:client:" + minecraftVersion + ":mappings@txt", true);

        IMappingFile obfToOfficial = IMappingFile.load(client).reverse();

        return obfToSrg.rename(new IRenamer() {
            @Override
            public String rename(IMappingFile.IClass value) {
                return obfToOfficial.remapClass(value.getOriginal());
            }
        });
    }

    private void error(String message) throws IOException {
        if (!isAllowEmpty())
            throw new IllegalStateException(message);

        File outputFile = getOutput().get().getAsFile();
        if (outputFile.exists())
            outputFile.delete();

        outputFile.createNewFile();
    }

    @Input
    public abstract Property<String> getKey();

    @InputFile
    public abstract RegularFileProperty getConfig();

    @Input
    public boolean isAllowEmpty() {
        return allowEmpty;
    }

    public void setAllowEmpty(boolean allowEmpty) {
        this.allowEmpty = allowEmpty;
    }

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class ExtractNatives extends DefaultTask {
    @TaskAction
    public void run() throws IOException {
        VersionJson json = Utils.loadJson(getMeta().get().getAsFile(), VersionJson.class);
        for (LibraryDownload lib : json.getNatives()) {
            File target = Utils.getCache(getProject(), "libraries", lib.path);
            Utils.updateDownload(getProject(), target, lib);
            Utils.extractZip(target, getOutput().get().getAsFile(), false);
        }
    }

    @InputFile
    public abstract RegularFileProperty getMeta();

    @OutputDirectory
    public abstract DirectoryProperty getOutput();
} public abstract class ExtractRangeMap extends JarExec {
    private boolean batch = true;

    public ExtractRangeMap() {
        getTool().set(Utils.SRG2SOURCE);
        getArgs().addAll("--extract", "--source-compatibility", "{compat}", "--output", "{output}", "--lib",
                "{library}", "--input", "{input}", "--batch", "{batched}");
        setMinimumRuntimeJavaVersion(11);

        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.file("output.txt")));
        final JavaPluginExtension extension = getProject().getExtensions().findByType(JavaPluginExtension.class);
        if (extension != null && extension.getToolchain().getLanguageVersion().isPresent()) {
            int version = extension.getToolchain().getLanguageVersion().get().asInt();
            getSourceCompatibility().convention((version <= 8 ? "1." : "") + version);
        } else {
            getSourceCompatibility().convention("1.8");
        }
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        return replaceArgs(args, ImmutableMap.of(
                "{compat}", getSourceCompatibility().get(),
                "{output}", getOutput().get().getAsFile(),
                "{batched}", batch
                ), ImmutableMap.of(
                "{input}", getSources().getFiles(),
                "{library}", getDependencies().getFiles()
                )
        );
    }

    @InputFiles
    public abstract ConfigurableFileCollection getSources();

    @InputFiles
    public abstract ConfigurableFileCollection getDependencies();

    @OutputFile
    public abstract RegularFileProperty getOutput();

    @Input
    public abstract Property<String> getSourceCompatibility();

    @Input
    public boolean getBatch() {
        return this.batch;
    }

    public void setBatch(boolean value) {
        this.batch = value;
    }
} public abstract class ExtractZip extends DefaultTask {
    public ExtractZip() {
        getOutputs().upToDateWhen(task -> false); //Gradle considers this up to date if the output exists at all...
    }

    @TaskAction
    public void run() throws IOException {
        Utils.extractZip(getZip().get().getAsFile(), getOutput().get().getAsFile(), true, true);
    }

    @InputFile
    public abstract RegularFileProperty getZip();

    @OutputDirectory
    public abstract DirectoryProperty getOutput();
} public abstract class JarExec extends DefaultTask {
    protected boolean hasLog = true;

    private final Provider<File> toolFile;
    private final Provider<String> resolvedVersion;

    protected final Provider<Directory> workDir = getProject().getLayout().getBuildDirectory().dir(getName());
    protected final Provider<RegularFile> logFile = workDir.map(d -> d.file("log.txt"));

    public JarExec() {
        toolFile = getTool().map(toolStr -> MavenArtifactDownloader.gradle(getProject(), toolStr, false));
        resolvedVersion = getTool().map(toolStr -> MavenArtifactDownloader.getVersion(getProject(), toolStr));

        final JavaPluginExtension extension = getProject().getExtensions().findByType(JavaPluginExtension.class);
        if (extension != null) {
            getJavaLauncher().convention(getJavaToolchainService().launcherFor(extension.getToolchain()));
        }
    }

    @Inject
    protected JavaToolchainService getJavaToolchainService() {
        throw new UnsupportedOperationException("Decorated instance, this should never be thrown unless shenanigans");
    }

    @TaskAction
    public void apply() throws IOException {
        File jar = getToolJar().get();
        File logFile = this.logFile.get().getAsFile();

        // Locate main class in jar file
        JarFile jarFile = new JarFile(jar);
        String mainClass = jarFile.getManifest().getMainAttributes().getValue(Attributes.Name.MAIN_CLASS);
        jarFile.close();

        // Create parent directory for log file
        Logger logger = getProject().getLogger();
        if (logFile.getParentFile() != null && !logFile.getParentFile().exists() && !logFile.getParentFile().mkdirs()) {
            logger.warn("Could not create parent directory '{}' for log file", logFile.getParentFile().getAbsolutePath());
        }

        final List<String> args = filterArgs(getArgs().get());
        final ConfigurableFileCollection classpath = getProject().files(getToolJar(), getClasspath());
        final File workingDirectory = workDir.get().getAsFile();

        try (PrintWriter log = new PrintWriter(hasLog ? new FileWriter(logFile) : NullWriter.DEFAULT, true)) {
            getProject().javaexec(spec -> {
                spec.setExecutable(getEffectiveExecutable());
                spec.setArgs(args);
                spec.setClasspath(classpath);
                spec.setWorkingDir(workingDirectory);
                spec.getMainClass().set(mainClass);

                log.println("Java Launcher: " + spec.getExecutable());
                log.println("Arguments: " + args.stream().collect(Collectors.joining(", ", "'", "'")));
                log.println("Classpath:");
                classpath.forEach(f -> log.println(" - " + f.getAbsolutePath()));
                log.println("Working directory: " + workingDirectory.getAbsolutePath());
                log.println("Main class: " + mainClass);
                log.println("====================================");

                spec.setStandardOutput(new OutputStream() {
                    @Override
                    public void flush() { log.flush(); }
                    @Override
                    public void close() {}
                    @Override
                    public void write(int b) { log.write(b); }
                });
            }).rethrowFailure().assertNormalExitValue();
        }

        if (hasLog) {
            postProcess(logFile);
        }

        // Delete working directory if empty
        final String[] workingDirContents = workingDirectory.list();
        if ((workingDirContents == null || workingDirContents.length == 0) && !workingDirectory.delete()) {
            logger.warn("Could not delete empty working directory '{}'", workingDirectory.getAbsolutePath());
        }
    }

    protected List<String> filterArgs(List<String> args) {
        return args;
    }

    // TODO: remove this? as this isn't used anywhere
    protected void postProcess(File log) {
    }

    // Should be used only if it can be guaranteed that there is at least one value for each key in the multiPrefixedReplacements
    // Otherwise, use replaceArgs to ensure keys without any linked values still exist
    protected List<String> replaceArgsMulti(List<String> args,
                                       @Nullable Map<String, ?> normalReplacements,
                                       @Nullable Multimap<String, ?> multiPrefixedReplacements) {
        multiPrefixedReplacements = multiPrefixedReplacements != null ? multiPrefixedReplacements : ImmutableMultimap.of();
        return replaceArgs(args, normalReplacements, multiPrefixedReplacements.asMap());
    }

    protected List<String> replaceArgs(List<String> args,
                                       @Nullable Map<String, ?> normalReplacements,
                                       @Nullable Map<String, ? extends Collection<?>> multiPrefixedReplacements) {
        // prevent nulls
        normalReplacements = normalReplacements != null ? normalReplacements : Collections.emptyMap();
        multiPrefixedReplacements = multiPrefixedReplacements != null ? multiPrefixedReplacements : Collections.emptyMap();
        if (normalReplacements.isEmpty() && multiPrefixedReplacements.isEmpty()) return args;

        ArrayList<String> newArgs = new ArrayList<>(args.size());

        // normalReplacements, it is a normal token substitution
        // multiPrefixedReplacements, it will take the previous token and prepend that to each value for the token

        for (String arg : args) {
            if (multiPrefixedReplacements.containsKey(arg)) {
                String prefix = newArgs.isEmpty() ? null : newArgs.remove(newArgs.size() - 1);
                for (Object value : multiPrefixedReplacements.get(arg)) {
                    if (prefix != null) newArgs.add(prefix);
                    newArgs.add(toString(value));
                }
            } else if (normalReplacements.containsKey(arg)) {
                newArgs.add(toString(normalReplacements.get(arg)));
            } else {
                newArgs.add(arg);
            }
        }

        return newArgs;
    }

    private String toString(Object obj) {
        if (obj instanceof File) {
            return ((File) obj).getAbsolutePath();
        } else if (obj instanceof Path) {
            return ((Path) obj).toAbsolutePath().toString();
        }
        return Objects.toString(obj);
    }

    @Internal
    public String getResolvedVersion() {
        return resolvedVersion.get();
    }

    @Input
    public boolean getHasLog() {
        return hasLog;
    }

    public void setHasLog(boolean value) {
        this.hasLog = value;
    }

    @InputFile
    public Provider<File> getToolJar() {
        return toolFile;
    }

    @Input
    public abstract Property<String> getTool();

    @Input
    public abstract ListProperty<String> getArgs();

    @Optional
    @InputFiles
    @Classpath
    public abstract ConfigurableFileCollection getClasspath();

    @Nested
    @Optional
    public abstract Property<JavaLauncher> getJavaLauncher();

    public void setMinimumRuntimeJavaVersion(int version) {
        if (!getJavaLauncher().isPresent() || !getJavaLauncher().get().getMetadata().getLanguageVersion().canCompileOrRun(version)) {
            setRuntimeJavaVersion(version);
        }
    }

    public void setRuntimeJavaVersion(int version) {
        setRuntimeJavaToolchain(tc -> tc.getLanguageVersion().set(JavaLanguageVersion.of(version)));
    }

    public void setRuntimeJavaToolchain(JavaToolchainSpec toolchain) {
        getJavaLauncher().set(getJavaToolchainService().launcherFor(toolchain));
    }

    public void setRuntimeJavaToolchain(Action<? super JavaToolchainSpec> action) {
        getJavaLauncher().set(getJavaToolchainService().launcherFor(action));
    }

    private String getEffectiveExecutable() {
        if (getJavaLauncher().isPresent()) {
            return getJavaLauncher().get().getExecutablePath().toString();
        } else {
            return Jvm.current().getJavaExecutable().getAbsolutePath();
        }
    }
} public class Artifact implements ArtifactIdentifier, Comparable<Artifact>, Serializable {
    private static final long serialVersionUID = 1L;

    // group:name:version[:classifier][@extension]
    private final String group;
    private final String name;
    private final String version;
    @Nullable
    private final String classifier;
    @Nullable
    private final String ext;

    // Cached so we don't rebuild every time we're asked.
    // Transient field so these aren't serialized
    // TODO: investigate whether this needs to be rebuilt because deserialization
    private transient final String path;
    private transient final String file;
    private transient final String fullDescriptor;
    private transient final ComparableVersion comparableVersion;
    private transient final boolean isSnapshot;

    public static Artifact from(String descriptor) {
        String group, name, version;
        String ext = null, classifier = null;

        String[] pts = Iterables.toArray(Splitter.on(':').split(descriptor), String.class);
        group = pts[0];
        name = pts[1];

        int last = pts.length - 1;
        int idx = pts[last].indexOf('@');
        if (idx != -1) { // we have an extension
            ext = pts[last].substring(idx + 1);
            pts[last] = pts[last].substring(0, idx);
        }

        version = pts[2];

        if (pts.length > 3) // We have a classifier
            classifier = pts[3];

        return new Artifact(group, name, version, classifier, ext);
    }

    public static Artifact from(ArtifactIdentifier identifier) {
        return new Artifact(identifier.getGroup(), identifier.getName(), identifier.getVersion(), identifier.getClassifier(), identifier.getExtension());
    }

    public static Artifact from(String group, String name, String version, @Nullable String classifier, @Nullable String ext) {
        return new Artifact(group, name, version, classifier, ext);
    }

    Artifact(String group, String name, String version, @Nullable String classifier, @Nullable String ext) {
        this.group = group;
        this.name = name;
        this.version = version;
        this.comparableVersion = new ComparableVersion(this.version);
        this.isSnapshot = this.version.toLowerCase(Locale.ROOT).endsWith("-snapshot");
        this.classifier = classifier;
        this.ext = ext != null ? ext : "jar";

        StringBuilder buf = new StringBuilder();
        buf.append(this.group).append(':').append(this.name).append(':').append(this.version);
        if (this.classifier != null) {
            buf.append(':').append(this.classifier);
        }
        if (ext != null && !"jar".equals(this.ext)) {
            buf.append('@').append(this.ext);
        }
        this.fullDescriptor = buf.toString();

        String file;
        file = this.name + '-' + this.version;
        if (this.classifier != null) file += '-' + this.classifier;
        file += '.' + this.ext;
        this.file = file;

        this.path = String.join("/", this.group.replace('.', '/'), this.name, this.version, this.file);
    }

    public String getLocalPath() {
        return path.replace('/', File.separatorChar);
    }

    public String getDescriptor(){ return fullDescriptor; }
    public String getPath()      { return path;       }
    @Override
    public String getGroup()     { return group;      }
    @Override
    public String getName()      { return name;       }
    @Override
    public String getVersion()   { return version;    }
    @Override
    @Nullable
    public String getClassifier(){ return classifier; }
    @Override
    @Nullable
    public String getExtension() { return ext;        }
    public String getFilename()  { return file;       }

    public boolean isSnapshot()  { return isSnapshot; }

    public Artifact withVersion(String version) {
        return Artifact.from(group, name, version, classifier, ext);
    }

    @Override
    public String toString() {
        return getDescriptor();
    }

    public Spec<Dependency> asDependencySpec() {
        return (dep) -> group.equals(dep.getGroup()) && name.equals(dep.getName()) && version.equals(dep.getVersion());
    }

    public Predicate<ResolvedArtifact> asArtifactMatcher() {
        return (art) -> {
            String theirClassifier;
            if (art.getClassifier() == null) {
                theirClassifier = "";
            } else {
                theirClassifier = art.getClassifier();
            }

            String theirExt;
            if (art.getExtension().isEmpty()) {
                theirExt = "jar";
            } else {
                theirExt = art.getExtension();
            }

            return (classifier == null || classifier.equals(theirClassifier)) && (ext == null || ext.equals(theirExt));
        };
    }

    @Override
    public int compareTo(Artifact o) {
        return ComparisonChain.start()
                .compare(group, o.group)
                .compare(name, o.name)
                .compare(comparableVersion, o.comparableVersion)
                // TODO: comparison of timestamps for snapshot versions (isSnapshot)
                .compare(classifier, o.classifier, Comparator.nullsFirst(Comparator.naturalOrder()))
                .compare(ext, o.ext, Comparator.nullsFirst(Comparator.naturalOrder()))
                .result();
    }
} public abstract class BaseRepo implements ArtifactProvider<ArtifactIdentifier> {

    public static final boolean DEBUG = Boolean.getBoolean("fg.debugRepo");

    private final File cache;
    protected final Logger log;
    protected final String REPO_NAME = getClass().getSimpleName();

    protected BaseRepo(File cache, Logger log) {
        this.cache = cache;
        this.log = log;
    }

    protected File getCacheRoot() {
        return this.cache;
    }

    protected File cache(String... path) {
        return new File(getCacheRoot(), String.join(File.separator, path));
    }

    protected String clean(ArtifactIdentifier art) {
        return art.getGroup() + ":" + art.getName() + ":" + art.getVersion() + ":" + art.getClassifier() + "@" + art.getExtension();
    }

    protected void debug(String message) {
        if (DEBUG)
            this.log.lifecycle(message);
    }
    protected void info(String message) {
        this.log.lifecycle(message);
    }

    @Override
    public final Artifact getArtifact(ArtifactIdentifier artifact) {
        try {
            debug(REPO_NAME + " Request: " + clean(artifact));
            String[] pts  = artifact.getExtension().split("\\.");

            String desc = (artifact.getGroup() + ":" + artifact.getName() + ":" + artifact.getVersion() + ":" + artifact.getClassifier() + "@" + pts[0]).intern();
            File ret;
            synchronized (desc) {
                if (pts.length == 1)
                    ret = findFile(artifact);
                else // Call without the .md5/.sha extension.
                    ret = findFile(net.minecraftforge.gradle.common.util.Artifact.from(artifact.getGroup(), artifact.getName(), artifact.getVersion(), artifact.getClassifier(), pts[0]));
            }

            if (ret != null) {
                ArtifactType type = ArtifactType.OTHER;
                if (artifact.getClassifier() != null && artifact.getClassifier().endsWith("sources"))
                    type = ArtifactType.SOURCE;
                else if ("jar".equals(artifact.getExtension()))
                    type = ArtifactType.BINARY;

                if (pts.length == 1)
                    return StreamableArtifact.ofFile(artifact, type, ret);
                else if (pts.length == 2) {
                    File hash = new File(ret.getAbsolutePath() + "." + pts[1]);
                    if (hash.exists())
                        return StreamableArtifact.ofFile(artifact, type, hash);
                }
            }
            return Artifact.none();
        } catch (Throwable e) {
            //Catch everything so we don't error up in gradle and fuck up the internals so it never asks us for anything ever again!
            log.lifecycle("Error getting artifact: " + clean(artifact) + " from  " + REPO_NAME, e);
            return Artifact.none();
        }
    }

    @Nullable
    protected abstract File findFile(ArtifactIdentifier artifact) throws IOException;

    public static class Builder {
        private List<ArtifactProvider<ArtifactIdentifier>> repos = new ArrayList<>();
        public Builder add(@Nullable ArtifactProvider<ArtifactIdentifier> repo) {
            if (repo != null)
                repos.add(repo);
            return this;
        }

        public void attach(Project project) {
            int random = new Random().nextInt();
            File cache = Utils.getCache(project, "bundeled_repo");
            // Java 8's compiler doesn't allow the lambda to be a method reference, but Java 16 allows it
            // noinspection Convert2MethodRef to prevent IDEA from warning us about it
            GradleRepositoryAdapter.add(project.getRepositories(), "BUNDELED_" + random, cache,
                    SimpleRepository.of(ArtifactProviderBuilder.begin(ArtifactIdentifier.class).provide(
                            artifact -> repos.stream()
                                    .map(repo -> repo.getArtifact(artifact))
                                    .filter(s -> s.isPresent())
                                    .findFirst()
                                    .orElse(Artifact.none())
                    ))
            );
        }
    }

} public class EnvironmentChecks {
    public static final String ENABLE_CERTIFICATE_CHECK_VARIABLE = "net.minecraftforge.gradle.check.certs";
    public static final String ENABLE_GRADLE_CHECK_VARIABLE = "net.minecraftforge.gradle.check.gradle";
    public static final String ENABLE_JAVA_CHECK_VARIABLE = "net.minecraftforge.gradle.check.java";

    private static final boolean ENABLE_CERTIFICATE_CHECK = Boolean.parseBoolean(System.getProperty(ENABLE_CERTIFICATE_CHECK_VARIABLE, "true"));
    private static final boolean ENABLE_GRADLE_CHECK = Boolean.parseBoolean(System.getProperty(ENABLE_GRADLE_CHECK_VARIABLE, "true"));
    private static final boolean ENABLE_JAVA_CHECK = Boolean.parseBoolean(System.getProperty(ENABLE_JAVA_CHECK_VARIABLE, "true"));
    private static final Marker ENV_CHECK = MarkerFactory.getMarker("forgegradle.env_check");

    public static void checkJavaRange(@Nullable JavaVersionParser.JavaVersion minVersionInclusive, @Nullable JavaVersionParser.JavaVersion maxVersionExclusive) {
        checkRange("java", JavaVersionParser.getCurrentJavaVersion(), minVersionInclusive, maxVersionExclusive, "", "");
    }

    public static void checkGradleRange(@Nullable GradleVersion minVersionInclusive, @Nullable GradleVersion maxVersionExclusive) {
        checkRange("Gradle", GradleVersion.current(), minVersionInclusive, maxVersionExclusive,
                "\nNote: Upgrade your gradle version first before trying to switch to FG5.", "");
    }

    private static <T> void checkRange(String name, Comparable<T> current, @Nullable T minVersionInclusive, @Nullable T maxVersionExclusive, String additionalMin, String additionalMax) {
        if (minVersionInclusive != null && current.compareTo(minVersionInclusive) < 0) {
            throw new EnvironmentCheckFailedException(String.format("Found %s version %s. Minimum required is %s.%s", name, current, minVersionInclusive, additionalMin));
        }

        if (maxVersionExclusive != null && current.compareTo(maxVersionExclusive) >= 0) {
            throw new EnvironmentCheckFailedException(String.format("Found %s version %s. Versions %s and newer are not supported yet.%s", name, current, maxVersionExclusive, additionalMax));
        }
    }

    /**
     * Checks the current project environment, and throws an exception if not satisfied.
     *
     * Current environment checks:
     * <ul>
     *     <li>Java version is <em>1.8.0_101</em> or above (first JDK version to include Let's Encrypt certificates)</li>
     *     <li>Gradle version is <em>7.1</em> or above (minimum version required by ForgeGradle)</li>
     *     <li>Certificates for {@link Utils#FORGE_MAVEN} and {@link Utils#MOJANG_MAVEN} are valid (required repositories)</li>
     * </ul>
     *
     * @see #checkGradleRange(GradleVersion, GradleVersion)
     * @see #checkJavaRange(JavaVersionParser.JavaVersion, JavaVersionParser.JavaVersion)
     * @see #testServerConnection(String)
     */
    public static void checkEnvironment(Project project) {
        Logger logger = project.getLogger();
        if (ENABLE_JAVA_CHECK) {
            logger.debug(ENV_CHECK, "Checking Java version");
            checkJavaRange(
                    // Minimum must be update 101 as it's the first one to include Let's Encrypt certificates.
                    JavaVersionParser.parseJavaVersion("1.8.0_101"),
                    null
            );
        } else {
            logger.debug(ENV_CHECK, "Java version check disabled by system property");
        }

        if (ENABLE_GRADLE_CHECK) {
            logger.debug(ENV_CHECK, "Checking Gradle version");
            checkGradleRange(
                    GradleVersion.version("7.1"),
                    null
            );
        } else {
            logger.debug(ENV_CHECK, "Gradle version check disabled by system property");
        }

        if (ENABLE_CERTIFICATE_CHECK) {
            logger.debug(ENV_CHECK, "Checking server connections");
            testServerConnection(Utils.FORGE_MAVEN);
            testServerConnection(Utils.MOJANG_MAVEN);
        } else {
            logger.debug(ENV_CHECK, "Server connection check disabled by system property");
        }
    }

    private static void testServerConnection(String url) {
        try {
            HttpsURLConnection conn = (HttpsURLConnection)new URL(url).openConnection();
            conn.setRequestMethod("HEAD");
            conn.connect();
            conn.getResponseCode();
        } catch (SSLException e) {
            throw new EnvironmentCheckFailedException(String.format("Failed to validate certificate for host '%s'. "
                    + "To disable this check, re-run with '-D%s=false'.", url, ENABLE_CERTIFICATE_CHECK_VARIABLE), e);
        } catch (IOException e) {
            // Normal connection failed, not the point of this test so ignore
        }
    }

    /**
     * Exception thrown when an environment check fails.
     */
    static class EnvironmentCheckFailedException extends RuntimeException {
        EnvironmentCheckFailedException(String message) {
            super(message);
        }

        EnvironmentCheckFailedException(String message, Throwable cause) {
            super(message, cause);
        }
    }
} public class MavenArtifactDownloader {
    /**
     * This tracks downloads that are <b>currently</b> active. As soon as a download has finished it will be removed
     * from this map.
     */
    private static final Map<DownloadKey, Future<File>> ACTIVE_DOWNLOADS = new HashMap<>();

    private static final Cache<String, File> CACHE = CacheBuilder.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    private static final Map<Project, Integer> COUNTER = new HashMap<>();

    private static final Map<String, String> VERSIONS = new HashMap<>();

    @Nullable
    public static File download(Project project, String artifact, boolean changing) {
        return _download(project, artifact, changing, true, true, true);
    }

    public static String getVersion(Project project, String artifact) {
        Artifact art = Artifact.from(artifact);
        if (!art.getVersion().endsWith("+") && !art.isSnapshot())
            return art.getVersion();
        _download(project, artifact, true, false, true, true);
        return VERSIONS.get(artifact);
    }

    @Nullable
    public static File gradle(Project project, String artifact, boolean changing) {
        return _download(project, artifact, changing, false, true, true);
    }

    @Nullable
    public static File generate(Project project, String artifact, boolean changing) {
        return _download(project, artifact, changing, true, false, true);
    }

    @Nullable
    public static File manual(Project project, String artifact, boolean changing) {
        return _download(project, artifact, changing, false, false, true);
    }

    @Nullable
    private static File _download(Project project, String artifact, boolean changing, boolean generated, boolean gradle, boolean manual) {
        /*
         * This somewhat convoluted code is necessary to avoid race-conditions when two Gradle worker threads simultaneously
         * try to download the same artifact.
         * The first thread registers a future that other threads can wait on.
         * Once it finishes, the future will be removed and subsequent calls will use the CACHE instead.
         * We use all parameters of the function as the key here to prevent subtle bugs where the same artifact
         * is looked up simultaneously with different resolver-options, leading only to one attempt being made.
         */
        DownloadKey downloadKey = new DownloadKey(project, artifact, changing, generated, gradle, manual);
        CompletableFuture<File> future;
        synchronized (ACTIVE_DOWNLOADS) {
            Future<File> activeDownload = ACTIVE_DOWNLOADS.get(downloadKey);
            if (activeDownload != null) {
                // Some other thread is already working downloading this exact artifact, wait for it to finish
                try {
                    project.getLogger().info("Waiting for download of {} on other thread", artifact);
                    return activeDownload.get();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } catch (ExecutionException e) {
                    if (e.getCause() instanceof RuntimeException) {
                        throw (RuntimeException) e.getCause();
                    } else {
                        throw new RuntimeException(e.getCause());
                    }
                }
            } else {
                project.getLogger().info("Downloading {}", artifact);
                // We're the first thread to download the artifact, make sure concurrent downloads just wait for us
                future = new CompletableFuture<>();
                ACTIVE_DOWNLOADS.put(downloadKey, future);
            }
        }

        File ret = null;
        try {
            Artifact art = Artifact.from(artifact);

            ret = CACHE.getIfPresent(artifact);
            if (ret != null && !ret.exists()) {
                CACHE.invalidate(artifact);
                ret = null;
            }

            List<MavenArtifactRepository> mavens = new ArrayList<>();
            List<GradleRepositoryAdapter> fakes = new ArrayList<>();
            List<ArtifactRepository> others = new ArrayList<>();

            project.getRepositories().forEach( repo -> {
                if (repo instanceof MavenArtifactRepository)
                    mavens.add((MavenArtifactRepository)repo);
                else if (repo instanceof GradleRepositoryAdapter)
                    fakes.add((GradleRepositoryAdapter)repo);
                else
                    others.add(repo);
            });

            if (ret == null && generated) {
                ret = _generate(fakes, art);
            }

            if (ret == null && manual) {
                ret = _manual(project, mavens, art, changing);
            }

            if (ret == null && gradle) {
                ret = _gradle(project, others, art, changing);
            }

            if (ret != null)
                CACHE.put(artifact, ret);

            future.complete(ret);
        } catch (RuntimeException | IOException | URISyntaxException e) {
            future.completeExceptionally(e);
            e.printStackTrace();
        } finally {
            synchronized (ACTIVE_DOWNLOADS) {
                ACTIVE_DOWNLOADS.remove(downloadKey);
            }
        }
        return ret;
    }

    @Nullable
    private static File _generate(List<GradleRepositoryAdapter> repos, Artifact artifact) {
        for (GradleRepositoryAdapter repo : repos) {
            File ret = repo.getArtifact(artifact);
            if (ret != null && ret.exists())
                return ret;
        }
        return null;
    }

    @Nullable
    private static File _manual(Project project, List<MavenArtifactRepository> repos, Artifact artifact, boolean changing) throws IOException, URISyntaxException {
        if (!artifact.getVersion().endsWith("+") && !artifact.isSnapshot()) {
            for (MavenArtifactRepository repo : repos) {
                Pair<Artifact, File> pair = _manualMaven(project, repo.getUrl(), artifact, changing);
                if (pair != null && pair.getValue().exists())
                    return pair.getValue();
            }
            return null;
        }

        List<Pair<Artifact, File>> versions = new ArrayList<>();

        // Gather list of all versions from all repos.
        for (MavenArtifactRepository repo : repos) {
            Pair<Artifact, File> pair = _manualMaven(project, repo.getUrl(), artifact, changing);
            if (pair != null && pair.getValue().exists())
                versions.add(pair);
        }

        Artifact version = null;
        File ret = null;
        for (Pair<Artifact, File> ver : versions) {
            //Select highest version
            if (version == null || version.compareTo(ver.getKey()) < 0) {
                version = ver.getKey();
                ret = ver.getValue();
            }
        }

        if (ret == null)
            return null;

        VERSIONS.put(artifact.getDescriptor(), version.getVersion());
        return ret;
    }

    @SuppressWarnings("unchecked")
    @Nullable
    private static Pair<Artifact, File> _manualMaven(Project project, URI maven, Artifact artifact, boolean changing) throws IOException, URISyntaxException {
        if (artifact.getVersion().endsWith("+")) {
            //I THINK +'s are only valid in the end version, So 1.+ and not 1.+.4 as that'd make no sense.
            //It also appears you can't do something like 1.5+ to NOT get 1.4/1.3. So.. mimic that.
            File meta = _downloadWithCache(project, maven, artifact.getGroup().replace('.', '/') + '/' + artifact.getName() + "/maven-metadata.xml", true, true);
            if (meta == null)
                return null; //Don't error, other repos might have it.
            try {
                Node xml = new XmlParser().parse(meta);
                Node versioning = getPath(xml, "versioning/versions");
                List<Node> versions = versioning == null ? null : (List<Node>)versioning.get("version");
                if (versions == null) {
                    meta.delete();
                    throw new IOException("Invalid maven-metadata.xml file, missing version list");
                }
                String prefix = artifact.getVersion().substring(0, artifact.getVersion().length() - 1); // Trim +
                ArtifactVersion minVersion = (!prefix.endsWith(".") && prefix.length() > 0) ? new DefaultArtifactVersion(prefix) : null;
                if (minVersion != null) { //Support min version like 1.5+ by saving it, and moving the prefix
                    //minVersion = new DefaultArtifactVersion(prefix);
                    int idx = prefix.lastIndexOf('.');
                    prefix = idx == -1 ? "" : prefix.substring(0, idx + 1);
                }
                final String prefix_ = prefix;
                ArtifactVersion highest = versions.stream().map(Node::text)
                    .filter(s -> s.startsWith(prefix_))
                    .map(DefaultArtifactVersion::new)
                    .filter(v -> minVersion == null || minVersion.compareTo(v) <= 0)
                    .sorted()
                    .reduce((first, second) -> second).orElse(null);
                if (highest == null)
                    return null; //We have no versions that match what we want, so move on to next repo.
                artifact = Artifact.from(artifact.getGroup(), artifact.getName(), highest.toString(), artifact.getClassifier(), artifact.getExtension());
            } catch (SAXException | ParserConfigurationException e) {
                meta.delete();
                throw new IOException("Invalid maven-metadata.xml file", e);
            }
        } else if (artifact.getVersion().contains("-SNAPSHOT")) {
            return null; //TODO
            //throw new IllegalArgumentException("Snapshot versions are not supported, yet... " + artifact.getDescriptor());
        }

        File ret = _downloadWithCache(project, maven, artifact.getPath(), changing, false);
        return ret == null ? null : ImmutablePair.of(artifact, ret);
    }

    //I'm sure there is a better way but not sure at the moment
    @SuppressWarnings("unchecked")
    @Nullable
    private static Node getPath(Node node, String path) {
        Node tmp = node;
        for (String pt : path.split("/")) {
            tmp = ((List<Node>)tmp.get(pt)).stream().findFirst().orElse(null);
            if (tmp == null)
                return null;
        }
        return tmp;
    }

    @Nullable
    private static File _gradle(Project project, List<ArtifactRepository> repos, Artifact mine, boolean changing) {
        String name = "mavenDownloader_" + mine.getDescriptor().replace(':', '_');
        synchronized(project) {
            int count = COUNTER.getOrDefault(project, 1);
            name += "_" + count++;
            COUNTER.put(project, count);
        }

        //Remove old repos, and only use the ones we're told to.
        List<ArtifactRepository> old = new ArrayList<>(project.getRepositories());
        project.getRepositories().clear();
        project.getRepositories().addAll(repos);

        Configuration cfg = project.getConfigurations().create(name);
        ExternalModuleDependency dependency = (ExternalModuleDependency)project.getDependencies().create(mine.getDescriptor());
        dependency.setChanging(changing);
        cfg.getDependencies().add(dependency);
        cfg.resolutionStrategy(strat -> {
            strat.cacheChangingModulesFor(5, TimeUnit.MINUTES);
            strat.cacheDynamicVersionsFor(5, TimeUnit.MINUTES);
        });
        Set<File> files;
        try {
            files = cfg.resolve();
        } catch (NullPointerException npe) {
            // This happens for unknown reasons deep in Gradle code... so we SHOULD find a way to fix it, but
            //honestly i'd rather deprecate this whole system and replace it with downloading things ourselves.
            project.getLogger().error("Failed to download " + mine.getDescriptor() + " gradle exploded");
            return null;
        }
        File ret = files.iterator().next(); //We only want the first, not transitive

        cfg.getResolvedConfiguration().getResolvedArtifacts().forEach(art -> {
            ModuleVersionIdentifier resolved = art.getModuleVersion().getId();
            if (resolved.getGroup().equals(mine.getGroup()) && resolved.getName().equals(mine.getName())) {
                if ((mine.getClassifier() == null && art.getClassifier() == null) || mine.getClassifier().equals(art.getClassifier()))
                    VERSIONS.put(mine.getDescriptor(), resolved.getVersion());
            }
        });

        project.getConfigurations().remove(cfg);

        project.getRepositories().clear(); //Clear the repos so we can re-add in the correct oder.
        project.getRepositories().addAll(old); //Readd all the normal repos.
        return ret;
    }

    @Nullable
    private static File _downloadWithCache(Project project, URI maven, String path, boolean changing, boolean bypassLocal) throws IOException, URISyntaxException {
        URL url = new URIBuilder(maven)
            .setPath(maven.getPath() + '/' + path)
            .build()
            .normalize()
            .toURL();
        File target = Utils.getCache(project, "maven_downloader", path);
        return DownloadUtils.downloadWithCache(url, target, changing, bypassLocal);
    }

    /**
     * Key used to track active downloads and avoid downloading the same file in two threads concurrently,
     * leading to corrupted files on disk.
     */
    private static class DownloadKey {
        private final Project project;
        private final String artifact;
        private final boolean changing;
        private final boolean generated;
        private final boolean gradle;
        private final boolean manual;

        DownloadKey(Project project, String artifact, boolean changing, boolean generated, boolean gradle, boolean manual) {
            this.project = project;
            this.artifact = artifact;
            this.changing = changing;
            this.generated = generated;
            this.gradle = gradle;
            this.manual = manual;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            DownloadKey that = (DownloadKey) o;
            return changing == that.changing && generated == that.generated && gradle == that.gradle && manual == that.manual && project.equals(that.project) && artifact.equals(that.artifact);
        }

        @Override
        public int hashCode() {
            return Objects.hash(project, artifact, changing, generated, gradle, manual);
        }

    }

} public class MinecraftRepo extends BaseRepo {
    private static MinecraftRepo INSTANCE;
    private static final String GROUP = "net.minecraft";
    public static final String MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
    public static final String CURRENT_OS = OS.getCurrent().getName();
    private static int CACHE_BUSTER = 1;
    private static final MinecraftVersion v1_14_4 = MinecraftVersion.from("1.14.4");
    private static final MinecraftVersion v1_18 = MinecraftVersion.from("1.18");
    public static final Pattern MCP_CONFIG_VERSION = Pattern.compile("\\d{8}\\.\\d{6}"); //Timestamp: YYYYMMDD.HHMMSS

    private final Project project;
    private final Repository repo;
    private final boolean offline;
    private MinecraftRepo(Project project, File cache, Logger log, boolean offline) {
        super(cache, log);
        this.project = project;
        this.repo = SimpleRepository.of(ArtifactProviderBuilder.begin(ArtifactIdentifier.class)
            .filter(ArtifactIdentifier.groupEquals(GROUP))
            .filter(ArtifactIdentifier.nameMatches("^(client|server)$"))
            .provide(this)
        );
        this.offline = offline;
    }

    private static MinecraftRepo getInstance(Project project) {
        if (INSTANCE == null)
            INSTANCE = new MinecraftRepo(project, Utils.getCache(project, "minecraft_repo"), project.getLogger(), project.getGradle().getStartParameter().isOffline());
        return INSTANCE;
    }

    public static void attach(Project project) {
        MinecraftRepo instance = getInstance(project);
        GradleRepositoryAdapter.add(project.getRepositories(), "MINECRAFT_DYNAMIC", instance.getCacheRoot(), instance.repo);
    }

    public static ArtifactProvider<ArtifactIdentifier> create(Project project) {
        return getInstance(project);
    }

    @Nullable
    protected String getMappings(String version) {
        if (!version.contains("_mapped_")) {
            return null;
        }
        return version.split("_mapped_")[0];
    }

    private HashStore commonCache(File file) throws IOException {
        HashStore ret = new HashStore(this.getCacheRoot()).load(new File(file.getAbsolutePath() + ".input"));
        ret.bust(CACHE_BUSTER);
        return ret;
    }

    @Override
    @Nullable
    public File findFile(ArtifactIdentifier artifact) throws IOException {
        String side = artifact.getName();

        if (!artifact.getGroup().equals(GROUP) || (!"client".equals(side) && !"server".equals(side)))
            return null;

        String version = artifact.getVersion();
        boolean forceStable = version.endsWith("-stable");
        if (forceStable)
            version = version.substring(0, version.length() - 7);
        String mappings = getMappings(version);
        if (mappings != null)
            return null; //We do not support mappings
        String classifier = artifact.getClassifier() == null ? "" : artifact.getClassifier();
        String ext = artifact.getExtension();

        File json = findVersion(getMCVersion(version));
        if (json == null)
            return null; //Not a vanilla version, MCP?

        debug("  " + REPO_NAME + " Request: " + artifact.getGroup() + ":" + side + ":" + version + ":" + classifier + "@" + ext);

        if ("pom".equals(ext)) {
            return null; //findPom(side, version, json); //MCP Repo will take care and add the extra libraries.
        } else if ("json".equals(ext)) {
            if ("".equals(classifier))
                return findVersion(getMCVersion(version));
        } else if ("jar".equals(ext)) {
            switch (classifier) {
                case "":         return findRaw(side, version, json);
                case "slim":     return findSlim(side, version, forceStable, json); //Deprecated - Use MCP Specific version
                case "data":     return findData(side, version, forceStable, json); //Deprecated - Use extra
                case "extra":    return findExtra(side, version, forceStable, json); //Deprecated - Use MCP Specific version
            }
        } else if ("txt".equals(ext)) {
            if ("mappings".equals(classifier))
                return findMappings(side, version, json);
        }
        return null;
    }

    private File findMcp(String version) throws IOException {
        Artifact mcp = Artifact.from("de.oceanlabs.mcp:mcp_config:" + version + "@zip");
        File zip = cache("versions", version, "mcp.zip");
        if (!zip.exists()) {
            FileUtils.copyFile(MavenArtifactDownloader.gradle(project, mcp.getDescriptor(), false), zip);
            Utils.updateHash(zip);
        }
        return zip;
    }

    @Nullable
    private File findMcpMappings(String version) throws IOException {
        File mcp = findMcp(version);
        if (mcp == null)
            return null;

        File mappings = cache("versions", version, "mcp_mappings.tsrg");
        HashStore cache = commonCache(cache("versions", version, "mcp_mappings.tsrg"));
        cache.add(mcp);

        if (!cache.isSame() || !mappings.exists()) {
            MCPWrapperSlim wrapper = new MCPWrapperSlim(mcp);
            wrapper.extractData(mappings, "mappings");
            cache.save();
            Utils.updateHash(mappings);
        }

        return mappings;
    }

    /**
     * This should ONLY ever be passed "mcversion" or "mcversion-mcpconfigversion".
     * Any other input is a MISTAKE and should NOT use this method.
     */
    public static String getMCVersion(String version) {
        int idx = version.lastIndexOf('-');
        if (idx != -1 && MCP_CONFIG_VERSION.matcher(version.substring(idx + 1)).matches()) {
            return version.substring(0, idx);
        }
        return version;
    }

    @Nullable
    private File findVersion(String version) throws IOException {
        File manifest = cache("versions/manifest.json");
        if (!DownloadUtils.downloadEtag(new URL(MANIFEST_URL), manifest, offline))
            return null;
        Utils.updateHash(manifest, HashFunction.SHA1);

        File json = cache("versions", version, "version.json");
        URL url =  Utils.loadJson(manifest, ManifestJson.class).getUrl(version);
        if (url == null)
            throw new RuntimeException("Missing version from manifest: " + version);

        if (!DownloadUtils.downloadEtag(url, json, offline))
            return null;
        Utils.updateHash(json, HashFunction.SHA1);
        return json;
    }

    @Nullable
    protected File findPom(String side, String version, File json) throws IOException {
        File pom = cache("versions", version, side + ".pom");
        HashStore cache = commonCache(cache("versions", version, side + ".pom"));

        if ("client".equals(side)) {
            cache.add(json);
        }

        //log("POM Cache: " + cache.isSame()  + " " + pom.exists());
        if (!cache.isSame() || !pom.exists()) {
            POMBuilder builder = new POMBuilder(GROUP, side, version);
            if ("client".equals(side)) {
                VersionJson meta = Utils.loadJson(json, VersionJson.class);
                for (VersionJson.Library lib : meta.libraries) {
                    if (lib.isAllowed()) {
                        if (lib.downloads.artifact != null)
                            builder.dependencies().add(lib.name, "compile");
                        if (lib.downloads.classifiers != null) {
                            if (lib.downloads.classifiers.containsKey("test")) {
                                builder.dependencies().add(lib.name, "test").withClassifier("test");
                            }
                            if (lib.natives != null && lib.natives.containsKey(CURRENT_OS) && !lib.getArtifact().getName().contains("java-objc-bridge")) {
                                builder.dependencies().add(lib.name, "runtime").withClassifier(lib.natives.get(CURRENT_OS));
                            }
                        }
                    }
                }
            }
            builder.dependencies().add(GROUP + ":" + side + ":" + version, "compile").withClassifier("extra");
            builder.dependencies().add("com.google.code.findbugs:jsr305:3.0.1", "compile"); //TODO: Pull this from MCPConfig.

            String ret = builder.tryBuild();
            if (ret == null) {
                return null;
            }
            FileUtils.writeByteArrayToFile(pom, ret.getBytes());
            cache.save();
            Utils.updateHash(pom);
        }

        return pom;
    }

    private File findMappings(String side, String version, File json_file) throws IOException {
        return findDownloadEntry(side + "_mappings", cache("versions", getMCVersion(version), side + "_mappings.txt"), getMCVersion(version), json_file);
    }

    private File findRaw(String side, String version, File json_file) throws IOException {
        String mcver = getMCVersion(version);
        File raw = findDownloadEntry(side, cache("versions", mcver, side + ".jar"), mcver, json_file);
        if (!"server".equals(side) || v1_18.compareTo(MinecraftVersion.from(mcver)) > 0)
            return raw;

        File extracted = cache("versions", mcver, side + "-extracted.jar");
        HashStore cache = commonCache(extracted)
                .add("raw", raw)
                .add("codever", "1");

        if (!cache.isSame() || !extracted.exists()) {
            BundlerUtils.extractMainJar(raw.toPath(), extracted.toPath());
            cache.save();
        }

        return extracted;
    }

    private File findDownloadEntry(String key, File target, String version, File json_file) throws IOException {
        VersionJson json = Utils.loadJson(json_file, VersionJson.class);
        if (json.downloads == null || !json.downloads.containsKey(key))
            throw new IllegalStateException(version + ".json missing download for " + key);

        Download dl = json.downloads.get(key);
        if (!target.exists() || !HashFunction.SHA1.hash(target).equals(dl.sha1)) {
            FileUtils.copyURLToFile(dl.url, target);
            Utils.updateHash(target, HashFunction.SHA1);
        }
        return target;
    }

    private File findExtra(String side, String version, boolean forceStable, File json) throws IOException {
        boolean stable = v1_14_4.compareTo(MinecraftVersion.from(getMCVersion(version))) < 0;
        File raw = findRaw(side, version, json);
        File mappings = findMcpMappings(version);
        File extra = cache("versions", version, side + "-extra" + (forceStable && !stable ? "-stable" : "") + ".jar");
        HashStore cache = commonCache(cache("versions", version, side + "-extra" + (forceStable && !stable ? "-stable" : "") + ".jar"))
                .add("raw", raw)
                .add("mappings", mappings)
                .add("codever", "1");

        if (!cache.isSame() || !extra.exists()) {
            splitJar(raw, mappings, extra, false, stable || forceStable);
            cache.save();
        }

        return extra;
    }

    private File findSlim(String side, String version, boolean forceStable, File json) throws IOException {
        boolean stable = v1_14_4.compareTo(MinecraftVersion.from(getMCVersion(version))) < 0;
        File raw = findRaw(side, version, json);
        File mappings = findMcpMappings(version);
        File extra = cache("versions", version, side + "-slim" + (forceStable && !stable ? "-stable" : "") + ".jar");
        HashStore cache = commonCache(cache("versions", version, side + "-slim" + (forceStable && !stable ? "-stable" : "") + ".jar"))
                .add("raw", raw)
                .add("mappings", mappings)
                .add("codever", "1");

        if (!cache.isSame() || !extra.exists()) {
            splitJar(raw, mappings, extra, true, stable || forceStable);
            cache.save();
        }


        return extra;
    }

    private static void splitJar(File raw, File mappings, File output, boolean slim, boolean stable) throws IOException {
        try (FileInputStream input = new FileInputStream(mappings)) {
            splitJar(raw, input, output, slim, stable);
        }
    }

    public static void splitJar(File raw, InputStream mappings, File output, boolean slim, boolean stable) throws IOException {
        try (ZipFile zin = new ZipFile(raw);
             FileOutputStream fos = new FileOutputStream(output);
             ZipOutputStream out = new ZipOutputStream(fos)) {

            Set<String> whitelist = IMappingFile.load(mappings).getClasses().stream()
                    .map(IMappingFile.IClass::getOriginal)
                    .collect(Collectors.toSet());

            for (Enumeration<? extends ZipEntry> entries = zin.entries(); entries.hasMoreElements();) {
                ZipEntry entry = entries.nextElement();
                String name = entry.getName();
                if (name.endsWith(".class")) {
                    boolean isNotch = whitelist.contains(name.substring(0, name.length() - 6 /*.class*/));
                    if (slim == isNotch) {
                        ZipEntry _new = Utils.getStableEntry(name, stable ? Utils.ZIPTIME : 0);
                        out.putNextEntry(_new);
                        try (InputStream ein = zin.getInputStream(entry)) {
                            IOUtils.copy(ein, out);
                        }
                        out.closeEntry();
                    }
                } else {
                    if (!slim) {
                        ZipEntry _new = Utils.getStableEntry(name, stable ? Utils.ZIPTIME : 0);
                        out.putNextEntry(_new);
                        try (InputStream ein = zin.getInputStream(entry)) {
                            IOUtils.copy(ein, out);
                        }
                        out.closeEntry();
                    }
                }
            }
        }
        Utils.updateHash(output);
    }

    private File findData(String side, String version, boolean forceStable, File json) throws IOException {
        boolean stable = v1_14_4.compareTo(MinecraftVersion.from(getMCVersion(version))) < 0 || forceStable;
        File raw = findRaw(side, version, json);
        File extra = cache("versions", version, side + "-data" + (forceStable && !stable ? "-stable" : "") + ".jar");
        HashStore cache = commonCache(cache("versions", version, side + "-data" + (forceStable && !stable ? "-stable" : "") + ".jar"))
                .add("raw", raw)
                .add("codever", "1");

        if (!cache.isSame() || !extra.exists()) {
            try (ZipFile zin = new ZipFile(raw);
                 FileOutputStream fos = new FileOutputStream(extra);
                 ZipOutputStream out = new ZipOutputStream(fos)) {

                for (Enumeration<? extends ZipEntry> entries = zin.entries(); entries.hasMoreElements();) {
                    ZipEntry entry = entries.nextElement();
                    String name = entry.getName();
                    if (!name.endsWith(".class")) {
                        ZipEntry _new = Utils.getStableEntry(name, stable || forceStable ? Utils.ZIPTIME : 0);
                        out.putNextEntry(_new);
                        try (InputStream ein = zin.getInputStream(entry)) {
                            IOUtils.copy(ein, out);
                        }
                        out.closeEntry();
                    }
                }
            }

            cache.save();
            Utils.updateHash(extra);
        }
        return extra;
    }


    private static class MCPWrapperSlim {
        private final File data;
        private final MCPConfigV1 config;
        public MCPWrapperSlim(File data) throws IOException {
            this.data = data;
            this.config = MCPConfigV2.getFromArchive(data);
        }

        public void extractData(File target, String... path) throws IOException {
            String name = config.getData(path);
            if (name == null)
                throw new IOException("Unknown MCP Entry: " + Joiner.on("/").join(path));

            try (ZipFile zip = new ZipFile(data)) {
                Utils.extractFile(zip, name, target);
            }
        }
    }
} public class MojangLicenseHelper {

    public static final String HIDE_LICENSE = "hideOfficialWarningUntilChanged";
    public static final String SHOW_LICENSE = "reshowOfficialWarning";

    public static void displayWarning(Project project, String channel, @Nullable String version, @Nullable String updateChannel, @Nullable String updateVersion) {
        displayWarning(project, channel, version);

        if (updateChannel == null || Objects.equals(channel, updateChannel)) return;

        displayWarning(project, updateChannel, updateVersion);
    }

    public static void displayWarning(Project project, String channel, @Nullable String version) {
        if ("official".equals(channel)) {
            Optional<String> license = version != null ? getOfficialLicense(project, version) : Optional.empty();
            Optional<String> licenseHash = license.map(HashFunction.SHA1::hash);

            if (license.isPresent() && isHidden(project, licenseHash.get())) return;

            String warning = getWarning(license.orElse(null));

            project.getLogger().warn(warning);
            license.map(s -> "WARNING: " + s).ifPresent(project.getLogger()::warn);
        }
    }

    public static void hide(Project project, String channel, String version) {
        if (!"official".equals(channel)) return;

        String hash = getOfficialLicense(project, version)
            .map(HashFunction.SHA1::hash)
            .orElseThrow(() -> new IllegalStateException("Could not get Mojang license text for " + version));

        Path accepted = getLicensePath(project, hash);

        if (Files.exists(accepted)) return;

        try {
            Utils.createEmpty(accepted.toFile());

            String warning = "WARNING: These warnings will not be shown again until the license changes "
                + "or the task `{TASK}` is run.";

            project.getLogger().warn(warning.replace("{TASK}", SHOW_LICENSE));
        } catch (IOException exception) {
            project.getLogger().error("Could not accept Mojang license", exception);
        }
    }

    public static void show(Project project, String channel, String version) {
        if (!"official".equals(channel)) return;

        String hash = getOfficialLicense(project, version)
            .map(HashFunction.SHA1::hash)
            .orElseThrow(() -> new IllegalStateException("Could not get Mojang license text for " + version));

        Path accepted = getLicensePath(project, hash);

        Utils.delete(accepted.toFile());
    }

    private static String getWarning(@Nullable String license) {
        String warning = "WARNING: "
            + "This project is configured to use the official obfuscation mappings provided by Mojang. "
            + "These mapping fall under their associated license, you should be fully aware of this license. "
            + "For the latest license text, refer {REFER}, or the reference copy here: "
            + "https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md"
            + ", You can hide this warning by running the `{TASK}` task";

        return warning
            .replace("{REFER}", license != null ? "below" : "to the mapping file itself")
            .replace("{TASK}", HIDE_LICENSE);
    }

    private static boolean isHidden(Project project, String hash) {
        return Files.exists(getLicensePath(project, hash));
    }

    private static Optional<String> getOfficialLicense(Project project, String version) {
        String minecraftVersion = MinecraftRepo.getMCVersion(version);
        String artifact = "net.minecraft:client:" + minecraftVersion + ":mappings@txt";

        File client = MavenArtifactDownloader.generate(project, artifact, true);

        if (client == null) return Optional.empty();

        try {
            return Optional.of(
                Files.lines(client.toPath())
                    .filter(line -> line.startsWith("#"))        // Only Comments
                    .map(l -> l.substring(1).trim())             // Remove initial #
                    .collect(Collectors.joining("\n"))   // Join via \n
            );
        } catch (IOException e) {
            e.printStackTrace();
        }

        return Optional.empty();
    }

    private static Path getLicensePath(Project project, String hash) {
        return new File(Utils.getCache(project, "licenses"), hash + ".marker").toPath();
    }
} public class Utils {
    private static final boolean ENABLE_FILTER_REPOS = Boolean.parseBoolean(System.getProperty("net.minecraftforge.gradle.filter_repos", "true"));

    public static final Gson GSON = new GsonBuilder()
        .registerTypeAdapter(MCPConfigV1.Step.class, new MCPConfigV1.Step.Deserializer())
        .registerTypeAdapter(VersionJson.Argument.class, new VersionJson.Argument.Deserializer())
        .setPrettyPrinting().create();
    static final int CACHE_TIMEOUT = 1000 * 60 * 60; //1 hour, Timeout used for version_manifest.json so we dont ping their server every request.
                                                          //manifest doesn't include sha1's so we use this for the per-version json as well.
    public static final String FORGE_MAVEN = "https://maven.minecraftforge.net/";
    public static final String MOJANG_MAVEN = "https://libraries.minecraft.net/";
    public static final String BINPATCHER =  "net.minecraftforge:binarypatcher:1.+:fatjar";
    public static final String ACCESSTRANSFORMER = "net.minecraftforge:accesstransformers:8.0.+:fatjar";
    public static final String SPECIALSOURCE = "net.md-5:SpecialSource:1.10.0:shaded";
    public static final String FART = "net.minecraftforge:ForgeAutoRenamingTool:0.1.+:all";
    public static final String SRG2SOURCE =  "net.minecraftforge:Srg2Source:8.+:fatjar";
    public static final String SIDESTRIPPER = "net.minecraftforge:mergetool:1.1.3:fatjar";
    public static final String INSTALLERTOOLS = "net.minecraftforge:installertools:1.2.8:fatjar";
    public static final long ZIPTIME = 628041600000L;
    public static final TimeZone GMT = TimeZone.getTimeZone("GMT");

    public static void extractFile(ZipFile zip, String name, File output) throws IOException {
        extractFile(zip, zip.getEntry(name), output);
    }

    public static void extractFile(ZipFile zip, ZipEntry entry, File output) throws IOException {
        File parent = output.getParentFile();
        if (!parent.exists())
            parent.mkdirs();

        try (InputStream stream = zip.getInputStream(entry)) {
            Files.copy(stream, output.toPath(), StandardCopyOption.REPLACE_EXISTING);
        }
    }

    public static void extractDirectory(Function<String, File> fileLocator, ZipFile zip, String directory) throws IOException {
        Enumeration<? extends ZipEntry> entries = zip.entries();
        while (entries.hasMoreElements()) {
            ZipEntry e = entries.nextElement();
            if (e.isDirectory()) continue;
            if (!e.getName().startsWith(directory)) continue;
            extractFile(zip, e, fileLocator.apply(e.getName()));
        }
    }

    public static Set<String> copyZipEntries(ZipOutputStream zout, ZipInputStream zin, Predicate<String> filter) throws IOException {
        Set<String> added = new HashSet<>();
        ZipEntry entry;
        while ((entry = zin.getNextEntry()) != null) {
            if (!filter.test(entry.getName())) continue;
            ZipEntry _new = new ZipEntry(entry.getName());
            _new.setTime(0); //SHOULD be the same time as the main entry, but NOOOO _new.setTime(entry.getTime()) throws DateTimeException, so you get 0, screw you!
            zout.putNextEntry(_new);
            IOUtils.copy(zin, zout);
            added.add(entry.getName());
        }
        return added;
    }

    public static byte[] base64DecodeStringList(List<String> strings) throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        for (String string : strings) {
            bos.write(Base64.getDecoder().decode(string));
        }
        return bos.toByteArray();
    }

    public static File delete(File file) {
        if (!file.getParentFile().exists()) file.getParentFile().mkdirs();
        if (file.exists()) file.delete();
        return file;
    }

    public static File createEmpty(File file) throws IOException {
        delete(file);
        file.createNewFile();
        return file;
    }

    public static Path getCacheBase(Project project) {
        File gradleUserHomeDir = project.getGradle().getGradleUserHomeDir();
        return Paths.get(gradleUserHomeDir.getPath(), "caches", "forge_gradle");
    }

    public static File getCache(Project project, String... tail) {
        return Paths.get(getCacheBase(project).toString(), tail).toFile();
    }

    public static void extractZip(File source, File target, boolean overwrite) throws IOException {
        extractZip(source, target, overwrite, false);
    }

    public static void extractZip(File source, File target, boolean overwrite, boolean deleteExtras) throws IOException {
        Set<File> extra = deleteExtras ? Files.walk(target.toPath()).filter(Files::isRegularFile).map(Path::toFile).collect(Collectors.toSet()) : new HashSet<>();

        try (ZipFile zip = new ZipFile(source)) {
            Enumeration<? extends ZipEntry> enu = zip.entries();
            while (enu.hasMoreElements()) {
                ZipEntry e = enu.nextElement();
                if (e.isDirectory()) continue;
                File out = new File(target, e.getName());
                File parent = out.getParentFile();
                if (!parent.exists()) {
                    parent.mkdirs();
                }
                extra.remove(out);

                if (out.exists()) {
                    if (!overwrite)
                        continue;

                    //Reading is fast, and prevents Disc wear, so check if it's equals before writing.
                    try (FileInputStream fis = new FileInputStream(out)){
                        if (IOUtils.contentEquals(zip.getInputStream(e), fis))
                            continue;
                    }
                }

                try (FileOutputStream fos = new FileOutputStream(out)) {
                    IOUtils.copy(zip.getInputStream(e), fos);
                }
            }
        }

        if (deleteExtras) {
            extra.forEach(File::delete);

            //Delete empty directories
            Files.walk(target.toPath())
            .filter(Files::isDirectory)
            .sorted(Comparator.reverseOrder())
            .map(Path::toFile)
            .filter(f -> f.list().length == 0)
            .forEach(File::delete);
        }
    }

    public static File updateDownload(Project project, File target, Download dl) throws IOException {
        if (!target.exists() || !HashFunction.SHA1.hash(target).equals(dl.sha1)) {
            project.getLogger().lifecycle("Downloading: " + dl.url);

            if (!target.getParentFile().exists()) {
                target.getParentFile().mkdirs();
            }

            FileUtils.copyURLToFile(dl.url, target);
        }
        return target;
    }

    public static <T> T loadJson(File target, Class<T> clz) throws IOException {
        try (InputStream in = new FileInputStream(target)) {
            return GSON.fromJson(new InputStreamReader(in), clz);
        }
    }
    public static <T> T loadJson(InputStream in, Class<T> clz) {
        return GSON.fromJson(new InputStreamReader(in), clz);
    }

    public static void updateHash(File target) throws IOException {
        updateHash(target, HashFunction.values());
    }
    public static void updateHash(File target, HashFunction... functions) throws IOException {
        for (HashFunction function : functions) {
            File cache = new File(target.getAbsolutePath() + "." + function.getExtension());
            if (target.exists()) {
                String hash = function.hash(target);
                Files.write(cache.toPath(), hash.getBytes());
            } else if (cache.exists()) {
                cache.delete();
            }
        }
    }

    public static void forZip(ZipFile zip, IOConsumer<ZipEntry> consumer) throws IOException {
        for (Enumeration<? extends ZipEntry> entries = zip.entries(); entries.hasMoreElements();) {
            consumer.accept(entries.nextElement());
        }
    }
    @FunctionalInterface
    public interface IOConsumer<T> {
        void accept(T value) throws IOException;
    }

    /**
     * Resolves the supplied object to a string.
     * If the input is null, this will return null.
     * Closures and Callables are called with no arguments.
     * Arrays use Arrays.toString().
     * File objects return their absolute paths.
     * All other objects have their toString run.
     * @param obj Object to resolve
     * @return resolved string
     */
    @SuppressWarnings("rawtypes")
    @Nullable
    public static String resolveString(@Nullable Object obj) {
        if (obj == null)
            return null;
        else if (obj instanceof String) // stop early if its the right type. no need to do more expensive checks
            return (String)obj;
        else if (obj instanceof Closure)
            return resolveString(((Closure)obj).call());// yes recursive.
        else if (obj instanceof Callable) {
            try {
                return resolveString(((Callable)obj).call());
            } catch (Exception e) {
                return null;
            }
        } else if (obj instanceof File)
            return ((File) obj).getAbsolutePath();
        else if (obj.getClass().isArray()) { // arrays
            if (obj instanceof Object[])
                return Arrays.toString(((Object[]) obj));
            else if (obj instanceof byte[])
                return Arrays.toString(((byte[]) obj));
            else if (obj instanceof char[])
                return Arrays.toString(((char[]) obj));
            else if (obj instanceof int[])
                return Arrays.toString(((int[]) obj));
            else if (obj instanceof float[])
                return Arrays.toString(((float[]) obj));
            else if (obj instanceof double[])
                return Arrays.toString(((double[]) obj));
            else if (obj instanceof long[])
                return Arrays.toString(((long[]) obj));
            else
                return obj.getClass().getSimpleName();
        }
        return obj.toString();
    }

    public static <T> T[] toArray(JsonArray array, Function<JsonElement, T> adapter, IntFunction<T[]> arrayFactory) {
        return StreamSupport.stream(array.spliterator(), false).map(adapter).toArray(arrayFactory);
    }

    public static byte[] getZipData(File file, String name) throws IOException {
        try (ZipFile zip = new ZipFile(file)) {
            ZipEntry entry = zip.getEntry(name);
            if (entry == null)
                throw new IOException("Zip Missing Entry: " + name + " File: " + file);

            return IOUtils.toByteArray(zip.getInputStream(entry));
        }
    }


    public static <T> T fromJson(InputStream stream, Class<T> classOfT) throws JsonSyntaxException, JsonIOException {
        return GSON.fromJson(new InputStreamReader(stream), classOfT);
    }
    public static <T> T fromJson(byte[] data, Class<T> classOfT) throws JsonSyntaxException, JsonIOException {
        return GSON.fromJson(new InputStreamReader(new ByteArrayInputStream(data)), classOfT);
    }

    @Nonnull
    public static String capitalize(@Nonnull final String toCapitalize) {
        return toCapitalize.length() > 1 ? toCapitalize.substring(0, 1).toUpperCase() + toCapitalize.substring(1) : toCapitalize;
    }

    public static Stream<String> lines(InputStream input) {
        BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        return reader.lines().onClose(() -> {
            try {
                reader.close();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        });
    }

    public static ZipEntry getStableEntry(String name) {
        return getStableEntry(name, Utils.ZIPTIME);
    }

    public static ZipEntry getStableEntry(String name, long time) {
        TimeZone _default = TimeZone.getDefault();
        TimeZone.setDefault(GMT);
        ZipEntry ret = new ZipEntry(name);
        ret.setTime(time);
        TimeZone.setDefault(_default);
        return ret;
    }

    public static void createRunConfigTasks(final MinecraftExtension extension, final TaskProvider<ExtractNatives> extractNatives, final TaskProvider<?>... setupTasks) {
        List<TaskProvider<?>> setupTasksLst = new ArrayList<>(Arrays.asList(setupTasks));

        final TaskProvider<Task> prepareRuns = extension.getProject().getTasks().register("prepareRuns", Task.class, task -> {
            task.setGroup(RunConfig.RUNS_GROUP);
            task.dependsOn(extractNatives, setupTasksLst);
        });

        final TaskProvider<Task> makeSrcDirs = extension.getProject().getTasks().register("makeSrcDirs", Task.class, task ->
                task.doFirst(t -> {
                    final JavaPluginExtension java = task.getProject().getExtensions().getByType(JavaPluginExtension.class);

                    java.getSourceSets().forEach(s -> s.getAllSource()
                            .getSrcDirs().stream().filter(f -> !f.exists()).forEach(File::mkdirs));
                }));
        setupTasksLst.add(makeSrcDirs);

        extension.getRuns().forEach(RunConfig::mergeParents);

        // Create run configurations _AFTER_ all projects have evaluated so that _ALL_ run configs exist and have been configured
        extension.getProject().getGradle().projectsEvaluated(gradle -> {
            VersionJson json = null;

            try {
                json = Utils.loadJson(extractNatives.get().getMeta().get().getAsFile(), VersionJson.class);
            } catch (IOException ignored) {
            }

            List<String> additionalClientArgs = json != null ? json.getPlatformJvmArgs() : Collections.emptyList();

            extension.getRuns().forEach(RunConfig::mergeChildren);
            extension.getRuns().forEach(run -> RunConfigGenerator.createRunTask(run, extension.getProject(), prepareRuns, additionalClientArgs));

            EclipseHacks.doEclipseFixes(extension, extractNatives, setupTasksLst);

            RunConfigGenerator.createIDEGenRunsTasks(extension, prepareRuns, makeSrcDirs, additionalClientArgs);
        });
    }

    public static void addRepoFilters(Project project) {
        if (!ENABLE_FILTER_REPOS) return;

        if (project.getGradle().getStartParameter().getTaskNames().stream().anyMatch(t -> t.endsWith("DownloadSources"))) {
            // Only modify repos already present to fix issues with IntelliJ's download sources
            project.getRepositories().forEach(Utils::addMappedFilter);
        } else {
            // Modify Repos already present and when they get added
            project.getRepositories().all(Utils::addMappedFilter);
        }
    }

    private static void addMappedFilter(ArtifactRepository repository) {
        // Skip our "Fake" Repos that actually do provide the de-obfuscated Artifacts
        if (repository instanceof GradleRepositoryAdapter) return;

        // Exclude Artifacts that are being de-obfuscated via ForgeGradle (_mapped_ in version)
        repository.content(rcd -> rcd.excludeVersionByRegex(".*", ".*", ".*_mapped_.*"));
    }

    public static File getMCDir()
    {
        switch (VersionJson.OS.getCurrent()) {
            case OSX:
                return new File(System.getProperty("user.home") + "/Library/Application Support/minecraft");
            case WINDOWS:
                return new File(System.getenv("APPDATA") + "\\.minecraft");
            case LINUX:
            default:
                return new File(System.getProperty("user.home") + "/.minecraft");
        }
    }

    public static String replaceTokens(Map<String, ?> tokens, String value) {
        StringBuilder buf = new StringBuilder();

        for (int x = 0; x < value.length(); x++) {
            char c = value.charAt(x);
            if (c == '\\') {
                if (x == value.length() - 1)
                    throw new IllegalArgumentException("Illegal pattern (Bad escape): " + value);
                buf.append(value.charAt(++x));
            } else if (c == '{' || c ==  '\'') {
                StringBuilder key = new StringBuilder();
                for (int y = x + 1; y <= value.length(); y++) {
                    if (y == value.length())
                        throw new IllegalArgumentException("Illegal pattern (Unclosed " + c + "): " + value);
                    char d = value.charAt(y);
                    if (d == '\\') {
                        if (y == value.length() - 1)
                            throw new IllegalArgumentException("Illegal pattern (Bad escape): " + value);
                        key.append(value.charAt(++y));
                    } else if (c == '{' && d == '}') {
                        x = y;
                        break;
                    } else if (c == '\'' && d == '\'') {
                        x = y;
                        break;
                    } else
                        key.append(d);
                }
                if (c == '\'')
                    buf.append(key);
                else {
                    Object v = tokens.get(key.toString());
                    if (v instanceof Supplier)
                        v = ((Supplier<?>) v).get();

                    buf.append(v == null ? "{" + key + "}" : v);
                }
            } else {
                buf.append(c);
            }
        }

        return buf.toString();
    }
} public class VersionJson {
    @Nullable
    public Arguments arguments;
    public AssetIndex assetIndex;
    public String assets;
    @Nullable
    public Map<String, Download> downloads;
    public Library[] libraries;

    private List<LibraryDownload> _natives = null;

    public List<LibraryDownload> getNatives() {
        if (_natives == null) {
            _natives = new ArrayList<>();
            OS os = OS.getCurrent();
            for (Library lib : libraries) {
                if (lib.natives != null && lib.downloads.classifiers != null && lib.natives.containsKey(os.getName())) {
                    LibraryDownload l = lib.downloads.classifiers.get(lib.natives.get(os.getName()));
                    if (l != null) {
                        _natives.add(l);
                    }
                }
            }
        }
        return _natives;
    }

    public List<String> getPlatformJvmArgs() {
        if (arguments == null || arguments.jvm == null)
            return Collections.emptyList();

        return Stream.of(arguments.jvm).filter(arg -> arg.rules != null && arg.isAllowed()).
                flatMap(arg -> arg.value.stream()).
                map(s -> {
                    if (s.indexOf(' ') != -1)
                        return "\"" + s + "\"";
                    else
                        return s;
                }).collect(Collectors.toList());
    }

    public static class Arguments {
        public Argument[] game;
        @Nullable
        public Argument[] jvm;
    }

    public static class Argument extends RuledObject {
        public List<String> value;

        public Argument(@Nullable Rule[] rules, List<String> value) {
            this.rules = rules;
            this.value = value;
        }

        public static class Deserializer implements JsonDeserializer<VersionJson.Argument> {
            @Override
            public Argument deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
                if (json.isJsonPrimitive()) {
                    return new Argument(null, Collections.singletonList(json.getAsString()));
                }

                JsonObject obj = json.getAsJsonObject();
                if (!obj.has("rules") || !obj.has("value"))
                    throw new JsonParseException("Error parsing arguments in version json. File is corrupt or its format has changed.");

                JsonElement val = obj.get("value");
                Rule[] rules = Utils.GSON.fromJson(obj.get("rules"), Rule[].class);
                @SuppressWarnings("unchecked")
                List<String> value = val.isJsonPrimitive() ? Collections.singletonList(val.getAsString()) : Utils.GSON.fromJson(val, List.class);

                return new Argument(rules, value);
            }
        }
    }

    public static class RuledObject {
        @Nullable
        public Rule[] rules;

        public boolean isAllowed() {
            if (rules != null) {
                for (Rule rule : rules) {
                    if (!rule.allowsAction()) {
                        return false;
                    }
                }
            }
            return true;
        }
    }

    public static class Rule {
        public String action;
        public OsCondition os;

        public boolean allowsAction() {
            return (os == null || os.platformMatches()) == action.equals("allow");
        }
    }

    public static class OsCondition {
        @Nullable
        public String name;
        @Nullable
        public String version;
        @Nullable
        public String arch;

        public boolean nameMatches() {
            return name == null || OS.getCurrent().getName().equals(name);
        }

        public boolean versionMatches() {
            return version == null || Pattern.compile(version).matcher(System.getProperty("os.version")).find();
        }

        public boolean archMatches() {
            return arch == null || Pattern.compile(arch).matcher(System.getProperty("os.arch")).find();
        }

        public boolean platformMatches() {
            return nameMatches() && versionMatches() && archMatches();
        }
    }

    public static class AssetIndex extends Download {
        public String id;
        public int totalSize;
    }

    public static class Download {
        public String sha1;
        public int size;
        public URL url;
    }

    public static class LibraryDownload extends Download {
        public String path;
    }

    public static class Downloads {
        @Nullable
        public Map<String, LibraryDownload> classifiers;
        @Nullable
        public LibraryDownload artifact;
    }

    public static class Library extends RuledObject {
        //Extract? rules?
        public String name;
        public Map<String, String> natives;
        public Downloads downloads;
        private Artifact _artifact;

        public Artifact getArtifact() {
            if (_artifact == null) {
                _artifact = Artifact.from(name);
            }
            return _artifact;
        }
    }

    public enum OS {
        WINDOWS("windows", "win"),
        LINUX("linux", "linux", "unix"),
        OSX("osx", "mac"),
        UNKNOWN("unknown");

        private final String name;
        private final String[] keys;

        OS(String name, String... keys) {
            this.name = name;
            this.keys = keys;
        }

        public String getName() {
            return this.name;
        }

        public static OS getCurrent() {
            String prop = System.getProperty("os.name").toLowerCase(Locale.ENGLISH);
            for (OS os : OS.values()) {
                for (String key : os.keys) {
                    if (prop.contains(key)) {
                        return os;
                    }
                }
            }
            return UNKNOWN;
        }
    }
} public class ChannelProvidersExtension {
    public static final String EXTENSION_NAME = "channelProviders";
    private final Map<String, ChannelProvider> providers = new ConcurrentHashMap<>();

    public ChannelProvidersExtension() {
        // Add the default providers
        addProvider(new OfficialChannelProvider());
        addProvider(new MCPChannelProvider());
    }

    /**
     * Returns an unmodifiable view of the provider map.
     *
     * @return an unmodifiable view of the provider map
     */
    public Map<String, ChannelProvider> getProviderMap() {
        return Collections.unmodifiableMap(providers);
    }

    /**
     * Retrieves a possibly-null {@link ChannelProvider} from the provider map using the {@code channel} key.
     *
     * @param channel the channel key to use for querying
     * @return a possibly-null {@link ChannelProvider} from the provider map
     */
    @Nullable
    public ChannelProvider getProvider(String channel) {
        return providers.get(channel);
    }

    /**
     * Adds a {@link ChannelProvider} to the provider map using all of its defined channels in {@link ChannelProvider#getChannels()}.
     *
     * @param provider The provider to add to the channel provider map
     * @throws IllegalArgumentException if a channel name from the provider is already registered to another provider
     */
    public void addProvider(ChannelProvider provider) {
        for (String channel : provider.getChannels()) {
            ChannelProvider previous = providers.get(channel);
            if (previous != null)
                throw new IllegalArgumentException(channel + " is already registered to " + previous + " containing channels " + previous.getChannels());
            providers.put(channel, provider);
        }
    }
} public abstract class MCPExtension {
    public static final String EXTENSION_NAME = "mcp";

    protected final Project project;
    private final Property<Artifact> config;

    @Inject
    public MCPExtension(final Project project) {
        this.project = project;
        this.config = project.getObjects().property(Artifact.class);
    }

    public Property<Artifact> getConfig() {
        return this.config;
    }

    public void setConfig(Provider<String> value) {
        getConfig().set(value.map(s -> {
            if (s.indexOf(':') != -1) { // Full artifact
                return Artifact.from(s);
            } else {
                return Artifact.from("de.oceanlabs.mcp:mcp_config:" + s + "@zip");
            }
        }));
    }

    public void setConfig(String value) {
        setConfig(project.provider(() -> value));
    }

    public abstract Property<String> getPipeline();
} public class MCPPlugin implements Plugin<Project> {

    @Override
    public void apply(@Nonnull Project project) {
        // Needed to gain access to the JavaToolchainService as an extension
        project.getPluginManager().apply(JavaPlugin.class);

        MCPExtension extension = project.getExtensions().create("mcp", MCPExtension.class, project);

        TaskProvider<DownloadMCPConfig> downloadConfig = project.getTasks().register("downloadConfig", DownloadMCPConfig.class);
        TaskProvider<SetupMCP> setupMCP = project.getTasks().register("setupMCP", SetupMCP.class);

        downloadConfig.configure(task -> {
            task.getConfig().set(extension.getConfig().map(Artifact::getDescriptor));
            task.getOutput().set(project.getLayout().getBuildDirectory().file("mcp_config.zip"));
        });
        setupMCP.configure(task -> {
            task.getPipeline().set(extension.getPipeline());
            task.getConfig().set(downloadConfig.flatMap(DownloadMCPConfig::getOutput));
        });

        project.afterEvaluate(p -> {
            //Add Known repos
            project.getRepositories().maven(e -> {
                e.setUrl(Utils.MOJANG_MAVEN);
                e.metadataSources(MetadataSources::artifact);
            });
            project.getRepositories().maven(e -> {
                e.setUrl(Utils.FORGE_MAVEN);
                e.metadataSources(m -> {
                    m.gradleMetadata();
                    m.mavenPom();
                    m.artifact();
                });
            });
            project.getRepositories().mavenCentral(); //Needed for MCP Deps
        });
    }
} public class MCPRepo extends BaseRepo {
    private static MCPRepo INSTANCE = null;
    private static final String GROUP_MINECRAFT = "net.minecraft";
    private static final String NAMES_MINECRAFT = "^(client|server|joined|mappings_[a-z_]+)$";
    private static final String GROUP_MCP = "de.oceanlabs.mcp";
    private static final String NAMES_MCP = "^(mcp_config)$";
    private static final String STEP_MERGE = "merge"; //TODO: Design better way to get steps output, for now hardcode
    private static final String STEP_RENAME = "rename";

    //This is the artifact we expose that is a zip containing SRG->Official fields and methods.
    public static final String MAPPING_DEP = "net.minecraft:mappings_{CHANNEL}:{VERSION}@zip";
    public static String getMappingDep(String channel, String version) {
        return MAPPING_DEP.replace("{CHANNEL}", channel).replace("{VERSION}", version);
    }

    private final Project project;
    private final Repository repo;
    private final Map<String, MCPWrapper> wrappers = Maps.newHashMap();
    private final Map<String, McpNames> mapCache = new HashMap<>();

    private MCPRepo(Project project, File cache, Logger log) {
        super(cache, log);
        this.project = project;
        this.repo = SimpleRepository.of(ArtifactProviderBuilder.begin(ArtifactIdentifier.class)
            .provide(this)
        );
    }

    private static MCPRepo getInstance(Project project) {
        if (INSTANCE == null)
            INSTANCE = new MCPRepo(project, Utils.getCache(project, "mcp_repo"), project.getLogger());
        return INSTANCE;
    }
    public static void attach(Project project) {
        MCPRepo instance = getInstance(project);
        GradleRepositoryAdapter.add(project.getRepositories(), "MCP_DYNAMIC", instance.getCacheRoot(), instance.repo);
    }

    public static ArtifactProvider<ArtifactIdentifier> create(Project project) {
        return getInstance(project);
    }

    @Override
    protected File cache(String... path) {
        return super.cache(path);
    }

    File cacheMC(String side, String version, @Nullable String classifier, String ext) {
        if (classifier != null)
            return cache("net", "minecraft", side, version, side + '-' + version + '-' + classifier + '.' + ext);
        return cache("net", "minecraft", side, version, side + '-' + version + '.' + ext);
    }

    private File cacheMCP(String version, @Nullable String classifier, String ext) {
        if (classifier != null)
            return cache("de", "oceanlabs", "mcp", "mcp_config", version, "mcp_config-" + version + '-' + classifier + '.' + ext);
        return cache("de", "oceanlabs", "mcp", "mcp_config", version, "mcp_config-" + version + '.' + ext);
    }
    private File cacheMCP(String version) {
        return cache("de", "oceanlabs", "mcp", "mcp_config", version);
    }

    @Override
    public File findFile(ArtifactIdentifier artifact) throws IOException {
        String name = artifact.getName();
        String group = artifact.getGroup();

        if (group.equals(GROUP_MCP)) {
            if (!name.matches(NAMES_MCP))
                return null;
        } else if (group.equals(GROUP_MINECRAFT)) {
            if (!name.matches(NAMES_MINECRAFT))
                return null;
        } else
            return null;

        String version = artifact.getVersion();
        String classifier = artifact.getClassifier() == null ? "" : artifact.getClassifier();
        String ext = artifact.getExtension();

        debug("  " + REPO_NAME + " Request: " + artifact.getGroup() + ":" + name + ":" + version + ":" + classifier + "@" + ext);

        if (group.equals(GROUP_MINECRAFT)) {
            if (name.startsWith("mappings_")) {
                if ("zip".equals(ext)) {
                    return findNames(name.substring(9) + '_' + version);
                } else if ("pom".equals(ext)) {
                    return findEmptyPom(name, version);
                }
            } else if ("pom".equals(ext)) {
                return findPom(name, version);
            } else if ("jar".equals(ext)) {
                switch (classifier) {
                    case "":              return findRaw(name, version);
                    case "srg":           return findSrg(name, version);
                    case "extra":         return findExtra(name, version);
                }
            }
        } else if (group.equals(GROUP_MCP)) {
            /* Gradle fucks up caching for anything that isnt a zip or a jar, this is fucking annoying we can't do this.
            MappingFile.Format format = MappingFile.Format.get(ext);
            if (format != null) {
                classifier = classifier.replace('!', '.'); //We hack around finding the extension by using a invalid path character
                switch (classifier) {
                    case "obf-to-srg": return findRenames(classifier, format, version, false);
                    case "srg-to-obf": return findRenames(classifier, format, version, true);
                }
                if (classifier.startsWith("obf-to-")) return findRenames(classifier, format, version, classifier.substring(7), true, false);
                if (classifier.startsWith("srg-to-")) return findRenames(classifier, format, version, classifier.substring(7), false,  false);
                if (classifier.endsWith  ("-to-obf")) return findRenames(classifier, format, version, classifier.substring(0, classifier.length() - 7), true, true);
                if (classifier.endsWith  ("-to-srg")) return findRenames(classifier, format, version, classifier.substring(0, classifier.length() - 7), false, true);
            }
            */
        }
        return null;
    }

    HashStore commonHash(File mcp) {
        return new HashStore(this.getCacheRoot())
            .add("mcp", mcp);
    }

    @Nullable
    File getMCP(String version) {
        return MavenArtifactDownloader.manual(project, "de.oceanlabs.mcp:mcp_config:" + version + "@zip", false);
    }

    @Nullable
    private File findVersion(String version) throws IOException {
        File manifest = cache("versions", "manifest.json");
        if (!DownloadUtils.downloadEtag(new URL(MinecraftRepo.MANIFEST_URL), manifest, project.getGradle().getStartParameter().isOffline()))
            return null;
        Utils.updateHash(manifest);
        File json = cache("versions", version, "version.json");

        URL url =  Utils.loadJson(manifest, ManifestJson.class).getUrl(version);
        if (url == null)
            throw new RuntimeException("Missing version from manifest: " + version);

        if (!DownloadUtils.downloadEtag(url, json, project.getGradle().getStartParameter().isOffline()))
            return null;
        Utils.updateHash(json);
        return json;
    }

    @Nullable
    private File findPom(String side, String version) throws IOException {
        File mcp = getMCP(version);
        if (mcp == null)
            return null;

        File pom = cacheMC(side, version, null, "pom");
        debug("    Finding pom: " + pom);
        HashStore cache = commonHash(mcp).load(cacheMC(side, version, null, "pom.input"));
        File json = null;
        if (!"server".equals(side)) {
            json = findVersion(MinecraftRepo.getMCVersion(version));
            if (json == null) {
                project.getLogger().lifecycle("Could not make Minecraft POM. Missing version json");
                return null;
            }
            cache.add("json", json);
        }

        if (!cache.isSame() || !pom.exists()) {
            POMBuilder builder = new POMBuilder(GROUP_MINECRAFT, side, version);
            if (!"server".equals(side)) {
                VersionJson meta = Utils.loadJson(json, VersionJson.class);
                for (VersionJson.Library lib : meta.libraries) {
                    if (lib.isAllowed()) {
                        if (lib.downloads.artifact != null)
                            builder.dependencies().add(lib.name, "compile");
                        if (lib.downloads.classifiers != null) {
                            if (lib.downloads.classifiers.containsKey("test")) {
                                builder.dependencies().add(lib.name, "test").withClassifier("test");
                            }
                            if (lib.natives != null && lib.natives.containsKey(MinecraftRepo.CURRENT_OS) && !lib.getArtifact().getName().contains("java-objc-bridge")) {
                                builder.dependencies().add(lib.name, "runtime").withClassifier(lib.natives.get(MinecraftRepo.CURRENT_OS));
                            }
                        }
                    }
                }
                builder.dependencies().add("net.minecraft:client:" + version, "compile").withClassifier("extra");
                //builder.dependencies().add("net.minecraft:client:" + getMCVersion(version), "compile").withClassifier("data");
            } else {
                builder.dependencies().add("net.minecraft:server:" + version, "compile").withClassifier("extra");
                //builder.dependencies().add("net.minecraft:server:" + getMCVersion(version), "compile").withClassifier("data");
            }

            MCPWrapper wrapper = getWrapper(version, mcp);
            wrapper.getConfig().getLibraries(side).forEach(e -> builder.dependencies().add(e, "compile"));

            String ret = builder.tryBuild();
            if (ret == null)
                return null;
            FileUtils.writeByteArrayToFile(pom, ret.getBytes());
            cache.save();
            Utils.updateHash(pom, HashFunction.SHA1);
        }

        return pom;
    }

    @Nullable
    private File findRaw(String side, String version) throws IOException {
        if (!"joined".equals(side))
            return null; //MinecraftRepo provides these

        return findStepOutput(side, version, null, "jar", STEP_MERGE);
    }

    @Nullable
    private File findSrg(String side, String version) throws IOException {
        return findStepOutput(side, version, "srg", "jar", STEP_RENAME);
    }

    @Nullable
    private File findStepOutput(String side, String version, @Nullable String classifier, String ext, String step) throws IOException {
        File mcp = getMCP(version);
        if (mcp == null)
            return null;
        File raw = cacheMC(side, version, classifier, ext);
        debug("  Finding " + step + ": " + raw);
        HashStore cache = commonHash(mcp).load(cacheMC(side, version, classifier, ext + ".input"));

        if (!cache.isSame() || !raw.exists()) {
            MCPWrapper wrapper = getWrapper(version, mcp);
            MCPRuntime runtime = wrapper.getRuntime(project, side);
            try {
                File output = runtime.execute(log, step);
                FileUtils.copyFile(output, raw);
                cache.save();
                Utils.updateHash(raw, HashFunction.SHA1);
            } catch (IOException e) {
                throw e;
            } catch (Exception e) {
                e.printStackTrace();
                log.lifecycle(e.getMessage());
                if (e instanceof RuntimeException) throw (RuntimeException)e;
                throw new RuntimeException(e);
            }
        }
        return raw;
    }

    private synchronized MCPWrapper getWrapper(String version, File data) throws IOException {
        String hash = HashFunction.SHA1.hash(data);
        MCPWrapper ret = wrappers.get(version);
        if (ret == null  || !hash.equals(ret.getHash())) {
            ret = new MCPWrapper(hash, data, cacheMCP(version));
            wrappers.put(version, ret);
        }
        return ret;
    }

    @Nullable
    File findRenames(String classifier, IMappingFile.Format format, String version, boolean toObf) throws IOException {
        String ext = format.name().toLowerCase();
        //File names = findNames(version));
        File mcp = getMCP(version);
        if (mcp == null)
            return null;

        File file = cacheMCP(version, classifier, ext);
        debug("    Finding Renames: " + file);
        HashStore cache = commonHash(mcp).load(cacheMCP(version, classifier, ext + ".input"));

        if (!cache.isSame() || !file.exists()) {
            MCPWrapper wrapper = getWrapper(version, mcp);
            byte[] data = wrapper.getData("mappings");
            IMappingFile obf_to_srg = IMappingFile.load(new ByteArrayInputStream(data));
            obf_to_srg.write(file.toPath(), format, toObf);
            cache.save();
            Utils.updateHash(file, HashFunction.SHA1);
        }

        return file;
    }

    @Nullable
    private File findNames(String mapping) throws IOException {
        int idx = mapping.lastIndexOf('_');
        if (idx == -1) return null; //Invalid format
        String channel = mapping.substring(0, idx);
        String version = mapping.substring(idx + 1);

        ChannelProvidersExtension channelProviders = project.getExtensions().getByType(ChannelProvidersExtension.class);
        ChannelProvider provider = channelProviders.getProvider(channel);
        if (provider == null)
            throw new IllegalArgumentException("Unknown mapping provider: " + mapping + ", currently loaded: " + channelProviders.getProviderMap().keySet());
        return provider.getMappingsFile(this, project, channel, version);
    }

    private McpNames loadMCPNames(String name, File data) throws IOException {
        McpNames map = mapCache.get(name);
        String hash = HashFunction.SHA1.hash(data);
        if (map == null || !hash.equals(map.hash)) {
            map = McpNames.load(data);
            mapCache.put(name, map);
        }
        return map;
    }

    @SuppressWarnings("unused")
    @Nullable
    private File findRenames(String classifier, IMappingFile.Format format, String version, String mapping, boolean obf, boolean reverse) throws IOException {
        String ext = format.name().toLowerCase();
        File names = findNames(version);
        File mcp = getMCP(version);
        if (mcp == null || names == null)
            return null;

        File file = cacheMCP(version, classifier, ext);
        debug("    Finding Renames: " + file);
        HashStore cache = commonHash(mcp).load(cacheMCP(version, classifier, ext + ".input"));

        if (!cache.isSame() || !file.exists()) {
            MCPWrapper wrapper = getWrapper(version, mcp);
            byte[] data = wrapper.getData("mappings");
            IMappingFile input = IMappingFile.load(new ByteArrayInputStream(data)); //SRG->OBF
            if (!obf)
                input = input.reverse().chain(input); //SRG->OBF + OBF->SRG = SRG->SRG

            McpNames map = loadMCPNames(mapping, names);
            IMappingFile ret = input.rename(new IRenamer() {
                @Override
                public String rename(IField value) {
                    return map.rename(value.getMapped());
                }

                @Override
                public String rename(IMethod value) {
                    return map.rename(value.getMapped());
                }
            });

            ret.write(file.toPath(), format, reverse);
            cache.save();
            Utils.updateHash(file, HashFunction.SHA1);
        }

        return file;
    }

    @Nullable
    private File findExtra(String side, String version) throws IOException {
        File raw = findRaw(side, version);
        File mcp = getMCP(version);
        if (raw == null || mcp == null)
            return null;

        File extra = cacheMC(side, version, "extra", "jar");
        HashStore cache = commonHash(mcp).load(cacheMC(side, version, "extra", "jar.input"))
                .add("raw", raw)
                .add("mcp", mcp)
                .add("codever", "1");

        if (!cache.isSame() || !extra.exists()) {
            MCPWrapper wrapper = getWrapper(version, mcp);
            byte[] data = wrapper.getData("mappings");
            MinecraftRepo.splitJar(raw, new ByteArrayInputStream(data), extra, false, true);
            cache.save();
        }

        return extra;
    }

    protected static void writeCsv(String name, List<String[]> mappings, ZipOutputStream out) throws IOException {
        if (mappings.size() <= 1)
            return;
        out.putNextEntry(Utils.getStableEntry(name));
        try (CsvWriter writer = CsvWriter.builder().lineDelimiter(LineDelimiter.LF).build(new UncloseableOutputStreamWriter(out))) {
            mappings.forEach(writer::writeRow);
        }
        out.closeEntry();
    }

    private static class UncloseableOutputStreamWriter extends OutputStreamWriter {
        private UncloseableOutputStreamWriter(OutputStream out) {
            super(out);
        }

        @Override
        public void close() throws IOException {
            super.flush();
        }
    }

    @Nullable
    private File findEmptyPom(String side, String version) throws IOException {
        File pom = cacheMC(side, version, null, "pom");
        debug("    Finding pom: " + pom);
        HashStore cache = new HashStore(this.getCacheRoot()).load(cacheMC(side, version, null, "pom.input"));

        if (!cache.isSame() || !pom.exists()) {
            String ret = new POMBuilder(GROUP_MINECRAFT, side, version).tryBuild();
            if (ret == null)
                return null;
            FileUtils.writeByteArrayToFile(pom, ret.getBytes());
            cache.save();
            Utils.updateHash(pom, HashFunction.SHA1);
        }

        return pom;
    }

    @Override
    protected void debug(String message) {
        super.debug(message);
    }
} public interface MCPFunction extends Serializable {

    default void loadData(Map<String, String> data) {
    }

    default void initialize(MCPEnvironment environment, ZipFile zip) throws Exception {
    }

    File execute(MCPEnvironment environment) throws Exception;

    default void cleanup(MCPEnvironment environment) {
    }

    default void addInputs(HashStore cache, String prefix) {
    }

} public class MCPFunctionFactory {
    /**
     * Internal Use Only
     * Non-Public API, Can be changed at any time.
     */
    @Deprecated
    @Nullable
    public static MCPFunction createBuiltIn(String type, int spec) {
        switch (type) {
            case "downloadManifest":
                return new DownloadFileFunction("manifest.json", "https://launchermeta.mojang.com/mc/game/version_manifest.json");
            case "downloadJson":
                return new DownloadVersionJSONFunction();
            case "downloadClient":
                return new DownloadCoreFunction("client", "jar");
            case "downloadServer":
                return new DownloadCoreFunction("server", "jar");
            case "strip":
                return new StripJarFunction();
            case "listLibraries":
                return new ListLibrariesFunction();
            case "inject":
                return new InjectFunction();
            case "patch":
                return new PatchFunction();
        }
        if (spec >= 2) {
            switch (type) {
                case "downloadClientMappings":
                    return new DownloadCoreFunction("client_mappings", "txt");
                case "downloadServerMappings":
                    return new DownloadCoreFunction("server_mappings", "txt");
            }
        }
        return null;
    }

    /**
     * Internal Use Only
     * Non-Public API, Can be changed at any time.
     */
    @Deprecated
    public static MCPFunction createAT(Project project, List<File> files, Collection<String> data) {
        AccessTransformerFunction ret = new AccessTransformerFunction(project, files);
        data.forEach(ret::addTransformer);
        return ret;
    }

    /**
     * Internal Use Only
     * Non-Public API, Can be changed at any time.
     */
    @Deprecated
    public static MCPFunction createSAS(Project project, List<File> files, Collection<String> data) {
        SideAnnotationStripperFunction ret = new SideAnnotationStripperFunction(project, files);
        data.forEach(ret::addData);
        return ret;
    }

    /**
     * Internal Use Only
     * Non-Public API, Can be changed at any time.
     */
    @Deprecated
    public static MCPFunction createExecute(File jar, List<String> jvmArgs, List<String> runArgs) {
        return new ExecuteFunction(jar,
            jvmArgs.toArray(new String[jvmArgs.size()]),
            runArgs.toArray(new String[runArgs.size()]),
            Collections.emptyMap());
    }
} public abstract class DownloadMCPConfig extends DefaultTask {
    @TaskAction
    public void downloadMCPConfig() throws IOException {
        File file = getConfigFile();
        File output = getOutput().get().getAsFile();

        if (output.exists()) {
            if (FileUtils.contentEquals(file, output)) {
                // NO-OP: The contents of both files are the same, we're up to date
                setDidWork(false);
                return;
            } else {
                output.delete();
            }
        }
        FileUtils.copyFile(file, output);
        setDidWork(true);
    }

    @Input
    public abstract Property<String> getConfig();

    @Internal
    public File getConfigFile() {
        return downloadConfigFile(getConfig().get());
    }

    @OutputFile
    public abstract RegularFileProperty getOutput();

    private File downloadConfigFile(String config) {
        return MavenArtifactDownloader.manual(getProject(), config, false);
    }
} public abstract class DownloadMCPMappings extends DefaultTask {
    public DownloadMCPMappings() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().file("mappings.zip"));
    }

    @TaskAction
    public void download() throws IOException {
        File out = getMappingFile();
        File output = getOutput().get().getAsFile();
        this.setDidWork(out.exists());
        if (FileUtils.contentEquals(out, output)) return;
        if (output.exists()) output.delete();
        if (output.getParentFile() != null && !output.getParentFile().exists()) output.getParentFile().mkdirs();
        FileUtils.copyFile(out, output);
    }

    private File getMappingFile() {
        String mappings = getMappings().get();
        int idx = mappings.lastIndexOf('_');
        if (idx == -1)
            throw new IllegalArgumentException("Invalid mapping string format, must be {channel}_{version}.");
        String channel = mappings.substring(0, idx);
        String version = mappings.substring(idx + 1);
        String artifact = MCPRepo.getMappingDep(channel, version);
        File ret = MavenArtifactDownloader.generate(getProject(), artifact, false);
        if (ret == null)
            throw new IllegalStateException("Failed to download mappings: " + artifact);
        return ret;
    }

    @Input
    public abstract Property<String> getMappings();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class GenerateSRG extends DefaultTask {
    private final Property<IMappingFile.Format> format;
    private boolean notch = false;
    private boolean reverse = false;

    public GenerateSRG() {
        this.format = getProject().getObjects().property(IMappingFile.Format.class)
                .convention(IMappingFile.Format.TSRG2);
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(f -> f.file("output.tsrg")));
    }

    @TaskAction
    public void apply() throws IOException {
        File names = findNames(getMappings().get());
        if (names == null)
            throw new IllegalStateException("Invalid mappings: " + getMappings() + " Could not find archive");

        IMappingFile input = IMappingFile.load(getSrg().get().getAsFile());
        if (!notch)
            input = input.reverse().chain(input); // Reverse makes SRG->OBF, chain makes SRG->SRG

        McpNames map = McpNames.load(names);
        IMappingFile ret = input.rename(new IRenamer() {
            @Override
            public String rename(IField value) {
                return map.rename(value.getMapped());
            }

            @Override
            public String rename(IMethod value) {
                return map.rename(value.getMapped());
            }
        });

        ret.write(getOutput().get().getAsFile().toPath(), getFormat().get(), reverse);
    }

    private File findNames(String mapping) {
        int idx = mapping.lastIndexOf('_');
        if (idx == -1) return null; //Invalid format
        String channel = mapping.substring(0, idx);
        String version = mapping.substring(idx + 1);
        String desc = MCPRepo.getMappingDep(channel, version);
        return MavenArtifactDownloader.generate(getProject(), desc, false);
    }

    @InputFile
    public abstract RegularFileProperty getSrg();

    @Input
    public abstract Property<String> getMappings();

    @Input
    public Property<IMappingFile.Format> getFormat() {
        return this.format;
    }

    public void setFormat(String value) {
        this.getFormat().set(IMappingFile.Format.valueOf(value));
    }

    @Input
    public boolean getNotch() {
        return this.notch;
    }

    public void setNotch(boolean value) {
        this.notch = value;
    }

    @Input
    public boolean getReverse() {
        return this.reverse;
    }

    public void setReverse(boolean value) {
        this.reverse = value;
    }

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class SetupMCP extends DefaultTask {
    public SetupMCP() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.file("output.zip")));

        this.getOutputs().upToDateWhen(task -> {
            HashStore cache = new HashStore(getProject());
            try {
                cache.load(getProject().file("build/" + getName() + "/inputcache.sha1"));
                cache.add("configFile", getConfig().get().getAsFile());
                getPreDecompile().get().forEach((key, func) -> func.addInputs(cache, key + "."));
                cache.save();
                return cache.isSame() && getOutput().get().getAsFile().exists();
            } catch (IOException e) {
                e.printStackTrace();
                return false;
            }
        });
    }

    @InputFile
    public abstract RegularFileProperty getConfig();

    @Input
    public abstract Property<String> getPipeline();

    @OutputFile
    public abstract RegularFileProperty getOutput();

    @Input
    public abstract MapProperty<String, MCPFunction> getPreDecompile();

    @TaskAction
    public void setupMCP() throws Exception {
        File config = getConfig().get().getAsFile();
        File output = getOutput().get().getAsFile();

        MCPConfigV2 mcpconfig = MCPConfigV2.getFromArchive(config);
        MCPRuntime runtime = new MCPRuntime(getProject(), config, mcpconfig, getPipeline().get(), getProject().file("build/mcp/"), getPreDecompile().get());
        File out = runtime.execute(getLogger());
        if (FileUtils.contentEquals(out, output)) return;
        Utils.delete(output);
        FileUtils.copyFile(out, output);
    }
} public abstract class ApplyPatches extends DefaultTask {
    private float minFuzzQuality = -1;
    private int maxFuzzOffset = -1;
    private boolean verbose = false;
    private boolean printSummary = false;
    private boolean failOnError = true;

    public ApplyPatches() {
        getPatchMode().convention(PatchMode.EXACT);
        getPatchesPrefix().convention("");
        getOriginalPrefix().convention("a/");
        getModifiedPrefix().convention("b/");
    }

    @TaskAction
    public void doTask() throws Exception {
        if (!getPatches().isPresent()) {
            FileUtils.copyFile(getBase().get(), getOutput().get().getAsFile());
            return;
        }

        Path outputPath = getOutput().get().getAsFile().toPath();
        ArchiveFormat outputFormat = getOutputFormat().getOrNull();
        if (outputFormat == null) {
            outputFormat = ArchiveFormat.findFormat(outputPath.getFileName());
        }

        Path rejectsPath = getRejects().map(File::toPath).getOrNull();
        ArchiveFormat rejectsFormat = getOutputFormat().getOrNull();
        if (rejectsFormat == null && rejectsPath != null) {
            rejectsFormat = ArchiveFormat.findFormat(rejectsPath.getFileName());
        }

        PatchOperation.Builder builder = PatchOperation.builder()
                .logTo(new LoggingOutputStream(getLogger(), LogLevel.LIFECYCLE))
                .basePath(getBase().get().toPath())
                .patchesPath(getPatches().get().getAsFile().toPath())
                .outputPath(outputPath, outputFormat)
                .rejectsPath(rejectsPath, rejectsFormat)
                .verbose(verbose)
                .summary(printSummary)
                .mode(getPatchMode().get())
                .aPrefix(getOriginalPrefix().get())
                .bPrefix(getModifiedPrefix().get())
                .patchesPrefix(getPatchesPrefix().get());
        if (minFuzzQuality != -1) {
            builder.minFuzz(minFuzzQuality);
        }
        if (maxFuzzOffset != -1) {
            builder.maxOffset(maxFuzzOffset);
        }

        CliOperation.Result<PatchOperation.PatchesSummary> result = builder.build().operate();

        int exit = result.exit;
        if (exit != 0 && exit != 1) {
            throw new RuntimeException("DiffPatch failed with exit code: " + exit);
        }
        if (exit != 0 && isFailOnError()) {
            throw new RuntimeException("Patches failed to apply.");
        }
    }

    // TODO: split into separate (exclusive) properties for directory or file?
    @InputFile
    public abstract Property<File> getBase();

    @InputDirectory
    @Optional
    public abstract DirectoryProperty getPatches();

    @OutputFile
    public abstract RegularFileProperty getOutput();

    @Internal
    public abstract Property<File> getRejects();

    @Input
    @Optional
    public abstract Property<ArchiveFormat> getOutputFormat();

    @Input
    @Optional
    public abstract Property<ArchiveFormat> getRejectsFormat();

    @Input
    @Optional
    public abstract Property<PatchMode> getPatchMode();

    @Input
    @Optional
    public abstract Property<String> getPatchesPrefix();

    @Input
    @Optional
    public abstract Property<String> getOriginalPrefix();

    @Input
    @Optional
    public abstract Property<String> getModifiedPrefix();

    @Input
    public float getMinFuzzQuality() {
        return minFuzzQuality;
    }

    public void setMinFuzzQuality(float minFuzzQuality) {
        this.minFuzzQuality = minFuzzQuality;
    }

    @Input
    public int getMaxFuzzOffset() {
        return maxFuzzOffset;
    }

    public void setMaxFuzzOffset(int maxFuzzOffset) {
        this.maxFuzzOffset = maxFuzzOffset;
    }

    @Console
    public boolean isVerbose() {
        return verbose;
    }

    public void setVerbose(boolean verbose) {
        this.verbose = verbose;
    }

    @Console
    public boolean isPrintSummary() {
        return printSummary;
    }

    public void setPrintSummary(boolean printSummary) {
        this.printSummary = printSummary;
    }

    @Input
    public boolean isFailOnError() {
        return failOnError;
    }

    public void setFailOnError(boolean failOnError) {
        this.failOnError = failOnError;
    }
} public abstract class BakePatches extends DefaultTask {

    public BakePatches() {
        getLineEnding().convention(System.lineSeparator());
    }

    @SuppressWarnings("deprecation")
    @TaskAction
    public void doTask() throws IOException {
        File output = getOutput().get().getAsFile();
        ArchiveFormat outputFormat = ArchiveFormat.findFormat(output.getName());
        if (!getInput().isPresent()) {
            try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(output))) {
                // Open and close to make empty zip
                // This is a dumb workaround so :clean:userdevJar doesn't explode
            }
            return;
        }
        PatchOperation.bakePatches(new InputPath.FilePath(getInput().get().getAsFile().toPath(), null),
                new OutputPath.FilePath(output.toPath(), outputFormat), getLineEnding().get());
    }

    @InputDirectory
    @Optional
    public abstract DirectoryProperty getInput();

    @OutputFile
    public abstract RegularFileProperty getOutput();

    @Input
    public abstract Property<String> getLineEnding();
} public abstract class CreateExc extends DefaultTask {
    private static final Pattern CLS_ENTRY = Pattern.compile("L([^;]+);");

    public CreateExc() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.file("output.exc")));
    }

    @TaskAction
    public void run() throws IOException {
        MCPConfigV2 cfg = MCPConfigV2.getFromArchive(getConfig().get().getAsFile());
        if (cfg != null && cfg.isOfficial()) {
            // Write empty file if MCPConfig is official because it means TSRGv2
            try (FileOutputStream fos = new FileOutputStream(getOutput().get().getAsFile())) {
                IOUtils.write("", fos, StandardCharsets.UTF_8);
            }
            return;
        }

        Set<String> staticMap = new HashSet<>(Files.readLines(getStatics().get().getAsFile(), StandardCharsets.UTF_8));
        Map<String, String> names = loadMappings();
        List<String> out = new ArrayList<>();

        List<String> lines = Files.readLines(getSrg().get().getAsFile(), StandardCharsets.UTF_8);
        lines = lines.stream().map(line -> line.split("#")[0]).filter(l -> !Strings.isNullOrEmpty(l.trim())).collect(Collectors.toList()); //Strip empty/comments

        Map<String, String> classes = IMappingFile.load(getSrg().get().getAsFile()).getClasses().stream()
                .collect(Collectors.toMap(IMappingFile.IClass::getOriginal, IMappingFile.IClass::getMapped));

        String currentClass = null;
        for (String line : lines) {
            if (line.startsWith("\t")) line = currentClass + " " + line.substring(1);

            String[] pts = line.split(" ");
            if (pts[0].indexOf(':') != -1) {
                if (pts[0].equals("MD:")) {
                    int idx = pts[3].lastIndexOf('/');
                    String name = pts[3].substring(idx + 1);
                    if ((name.startsWith("func_") || name.startsWith("m_")) && !pts[4].contains("()")) {
                        out.add(pts[3].substring(0, idx + 1) + "." + names.getOrDefault(name, name) + pts[4] + "=|" + String.join(",", buildArgs(name, pts[4], staticMap.contains(name))));
                    }
                }
            } else {
                if (pts.length == 2) {
                    currentClass = pts[1];
                } else if (pts.length == 4) {
                    String name = pts[3];
                    if ((name.startsWith("func_") || name.startsWith("m_")) && !pts[2].contains("()")) {
                        String desc = remapDesc(pts[2], classes);
                        out.add(currentClass + "." + names.getOrDefault(name, name) + desc + "=|" + String.join(",", buildArgs(name, desc, staticMap.contains(name))));
                    }
                }
            }
        }

        Files.readLines(getConstructors().get().getAsFile(), StandardCharsets.UTF_8).stream()
                .map(l -> l.split(" "))
                .forEach(pts -> out.add(pts[1] + ".<init>" + pts[2] + "=|" + String.join(",", buildArgs(pts[0], pts[2], false))));

        try (FileOutputStream fos = new FileOutputStream(getOutput().get().getAsFile())) {
            IOUtils.write(String.join("\n", out), fos, StandardCharsets.UTF_8);
        }
    }

    private List<String> buildArgs(String name, String desc, boolean isStatic) {
        String prefix = "p_i" + name + "_";
        if (name.startsWith("func_") || name.startsWith("m_")) {
            prefix = "p_" + name.split("_")[1] + "_";
        }
        List<String> ret = new ArrayList<>();
        int idx = isStatic ? 0 : 1;
        int x = 1;
        while (desc.charAt(x) != ')') {
            int array = 0;
            while (desc.charAt(x) == '[') {
                x++;
                array++;
            }
            int size = 1;
            char type = desc.charAt(x);
            if (array == 0 && (type == 'D' || type == 'J')) //Long/Double's are 2 wide.
                size = 2;
            if (type == 'L')
                x = desc.indexOf(';', x);
            x++;

            ret.add(prefix + idx + '_');
            idx += size;
        }

        return ret;
    }

    private String remapClass(String cls, Map<String, String> map)
    {
        String ret = map.get(cls);
        if (ret != null)
            return ret;

        int idx = cls.lastIndexOf('$');
        if (idx != -1)
            ret = remapClass(cls.substring(0, idx), map) + cls.substring(idx);
        else
            ret = cls;
        map.put(cls, ret);
        return cls;
    }

    private String remapDesc(String desc, Map<String, String> map)
    {
        StringBuffer buf = new StringBuffer();
        Matcher matcher = CLS_ENTRY.matcher(desc);
        while (matcher.find()) {
            matcher.appendReplacement(buf, Matcher.quoteReplacement("L" + remapClass(matcher.group(1), map) + ";"));
        }
        matcher.appendTail(buf);
        return buf.toString();
    }

    private Map<String, String> loadMappings() throws IOException {
        Map<String, String> names = new HashMap<>();
        try (ZipFile zip = new ZipFile(getMappings().get().getAsFile())) {
            zip.stream().filter(e -> e.getName().equals("fields.csv") || e.getName().equals("methods.csv")).forEach(e -> {
                try (NamedCsvReader reader = NamedCsvReader.builder().build(new InputStreamReader(zip.getInputStream(e)))) {
                    reader.forEach(row -> names.put(row.getField("searge"), row.getField("name")));
                } catch (IOException e1) {
                    throw new RuntimeException(e1);
                }
            });
        }
        return names;
    }

    @InputFile
    public abstract RegularFileProperty getConfig();

    @InputFile
    public abstract RegularFileProperty getSrg();

    @InputFile
    public abstract RegularFileProperty getStatics();

    @InputFile
    public abstract RegularFileProperty getConstructors();

    @InputFile
    public abstract RegularFileProperty getMappings();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class CreateFakeSASPatches extends DefaultTask {
    public CreateFakeSASPatches() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.dir("patches")));
    }

    @InputFiles
    public abstract ConfigurableFileCollection getFiles();

    @OutputDirectory
    public abstract DirectoryProperty getOutput();

    @TaskAction
    public void apply() throws IOException {
        File output = getOutput().get().getAsFile();
        if (output.exists())
            output.mkdirs();
        for (File file : getFiles()) {
            for (String line : FileUtils.readLines(file, StandardCharsets.UTF_8)) {
                int idx = line.indexOf('#');
                if (idx == 0 || line.isEmpty()) continue;
                if (idx != -1) line = line.substring(0, idx - 1);
                if (line.charAt(0) == '\t') line = line.substring(1);
                String cls = (line.trim() + "    ").split(" ", -1)[0];
                File patch = new File(output, cls + ".java.patch");
                if (!patch.getParentFile().exists())
                    patch.getParentFile().mkdirs();
                patch.createNewFile();
            }
        }
    }
} public abstract class FilterNewJar extends DefaultTask { //TODO: Copy task?
    public FilterNewJar() {
        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.file("output.jar")));
    }

    @TaskAction
    public void apply() throws IOException {
        Set<String> filter = new HashSet<>();
        for (File file : getBlacklist()) {
            try (ZipFile zip = new ZipFile(file)) {
                Utils.forZip(zip, entry -> filter.add(entry.getName()));
            }
        }

        Set<String> classes = IMappingFile.load(getSrg().get().getAsFile()).getClasses().stream()
                .map(IMappingFile.IClass::getMapped)
                .collect(Collectors.toSet());

        try (ZipFile zin = new ZipFile(getInput().get().getAsFile());
             ZipOutputStream out = new ZipOutputStream(new FileOutputStream(getOutput().get().getAsFile()))){

            Utils.forZip(zin, entry -> {
                if (entry.isDirectory() || filter.contains(entry.getName()) ||
                        (entry.getName().endsWith(".class") && isVanilla(classes, entry.getName().substring(0, entry.getName().length() - 6)))) {
                    return;
                }
                out.putNextEntry(Utils.getStableEntry(entry.getName()));
                IOUtils.copy(zin.getInputStream(entry), out);
                out.closeEntry();
            });
        }
    }

    //We pack all inner classes in binpatches. So strip anything thats a vanilla class or inner class of one.
    private boolean isVanilla(Set<String> classes, String cls) {
        int idx = cls.indexOf('$');
        if (idx != -1) {
            return isVanilla(classes, cls.substring(0, idx));
        }
        return classes.contains(cls);
    }

    @InputFile
    public abstract RegularFileProperty getInput();

    @InputFile
    public abstract RegularFileProperty getSrg();

    @InputFiles
    public abstract ConfigurableFileCollection getBlacklist();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class GenerateBinPatches extends JarExec {
    public GenerateBinPatches() {
        getTool().set(Utils.BINPATCHER);
        getArgs().addAll("--clean", "{clean}", "--create", "{dirty}", "--output", "{output}",
                "--patches", "{patches}", "--srg", "{srg}");

        getOutput().convention(getProject().getLayout().getBuildDirectory()
                .dir(getName()).map(d -> d.file(getSide().getOrElse("output") + ".lzma")));
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        final List<String> newArgs = replaceArgs(args, ImmutableMap.of(
                "{clean}", getCleanJar().get().getAsFile(),
                "{dirty}", getDirtyJar().get().getAsFile(),
                "{output}", getOutput().get().getAsFile(),
                "{srg}", getSrg().get().getAsFile()
                ), ImmutableMap.of(
                "{patches}", getPatchSets().getFiles()
                )
        );
        return newArgs;
    }

    @InputFile
    public abstract RegularFileProperty getCleanJar();

    @InputFile
    public abstract RegularFileProperty getDirtyJar();

    @InputFiles
    public abstract ConfigurableFileCollection getPatchSets();

    @InputFile
    public abstract RegularFileProperty getSrg();

    @Input
    @Optional
    public abstract Property<String> getSide();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class GeneratePatches extends DefaultTask {
    private int contextLines = -1;
    private boolean autoHeader;
    private boolean verbose;
    private boolean printSummary;

    public GeneratePatches() {
        getOriginalPrefix().convention("a/");
        getModifiedPrefix().convention("b/");
        getLineEnding().convention(System.lineSeparator());
    }

    @TaskAction
    public void doTask() throws Exception {
        Path base = getBase().get().getAsFile().toPath();
        Path modified = getModified().get().getAsFile().toPath();
        Path output = getOutput().get().getAsFile().toPath();
        getProject().getLogger().info("Base: {}", base);
        getProject().getLogger().info("Modified: {}", modified);

        ArchiveFormat outputFormat = getOutputFormat().getOrNull();
        if (outputFormat == null) {
            outputFormat = ArchiveFormat.findFormat(output.getFileName());
        }

        DiffOperation.Builder builder = DiffOperation.builder()
                .logTo(new LoggingOutputStream(getLogger(), LogLevel.LIFECYCLE))
                .aPath(base)
                .bPath(modified)
                .outputPath(output, outputFormat)
                .autoHeader(autoHeader)
                .verbose(verbose)
                .summary(printSummary)
                .aPrefix(getOriginalPrefix().get())
                .bPrefix(getModifiedPrefix().get())
                .lineEnding(getLineEnding().get());

        if (contextLines != -1) {
            builder.context(contextLines);
        }

        CliOperation.Result<DiffOperation.DiffSummary> result = builder.build().operate();

        int exit = result.exit;
        if (exit != 0 && exit != 1) {
            throw new RuntimeException("DiffPatch failed with exit code: " + exit);
        }
    }

    @InputFile
    public abstract RegularFileProperty getBase();

    @InputFile
    public abstract RegularFileProperty getModified();

    @OutputDirectory
    public abstract DirectoryProperty getOutput();

    @Input
    @Optional
    public abstract Property<ArchiveFormat> getOutputFormat();

    @Input
    @Optional
    public abstract Property<String> getOriginalPrefix();

    @Input
    @Optional
    public abstract Property<String> getModifiedPrefix();

    @Input
    public abstract Property<String> getLineEnding();

    @Input
    public boolean isAutoHeader() {
        return autoHeader;
    }

    public void setAutoHeader(boolean autoHeader) {
        this.autoHeader = autoHeader;
    }

    @Input
    public int getContextLines() {
        return contextLines;
    }

    public void setContextLines(int lines) {
        this.contextLines = lines;
    }

    @Console
    public boolean isVerbose() {
        return verbose;
    }

    public void setVerbose(boolean verbose) {
        this.verbose = verbose;
    }

    @Console
    public boolean isPrintSummary() {
        return printSummary;
    }

    public void setPrintSummary(boolean printSummary) {
        this.printSummary = printSummary;
    }
} public abstract class GenerateUserdevConfig extends DefaultTask {

    private final NamedDomainObjectContainer<RunConfig> runs;

    @Nullable
    private DataFunction processor;
    private final MapProperty<String, File> processorData;
    private final Property<String> sourceFileEncoding;

    private boolean notchObf = false;

    @Inject
    public GenerateUserdevConfig(@Nonnull final Project project) {
        this.runs = project.container(RunConfig.class, name -> new RunConfig(project, name));

        ObjectFactory objects = project.getObjects();
        getPatchesOriginalPrefix().convention("a/");
        getPatchesModifiedPrefix().convention("b/");
        sourceFileEncoding = project.getObjects().property(String.class)
                .convention(StandardCharsets.UTF_8.name());
        getInject().convention("inject/");
        getPatches().convention("patches/");

        processorData = objects.mapProperty(String.class, File.class);

        getOutput().convention(getProject().getLayout().getBuildDirectory().dir(getName()).map(d -> d.file("output.json")));
    }

    @TaskAction
    public void apply() throws IOException {
        UserdevConfigV2 json = new UserdevConfigV2(); //TODO: Move this to plugin so we can re-use the names in both tasks?
        json.spec = isV2() ? 2 : 1;
        json.binpatches = "joined.lzma";
        json.sources = getSource().get();
        json.universal = getUniversal().get();
        json.patches = getPatches().get();
        json.inject = getInject().get();
        if (json.inject.isEmpty()) // Workaround since null in properties means use the convention, which we don't want.
            json.inject = null;
        getLibraries().get().forEach(json::addLibrary);
        getModules().get().forEach(json::addModule);
        getATs().forEach(at -> json.addAT("ats/" + at.getName()));
        getSASs().forEach(at -> json.addSAS("sas/" + at.getName()));
        getSRGs().forEach(srg -> json.addSRG("srgs/" + srg.getName()));
        getSRGLines().get().forEach(json::addSRG);
        addParent(json, getProject());

        runs.getAsMap().forEach(json::addRun);

        json.binpatcher = new Function();
        json.binpatcher.setVersion(getTool().get());
        json.binpatcher.setArgs(getArguments().get());

        if (isV2()) {
            json.processor = processor;
            json.patchesOriginalPrefix = getPatchesOriginalPrefix().get();
            json.patchesModifiedPrefix = getPatchesModifiedPrefix().get();
            json.setNotchObf(notchObf);
            json.setSourceFileCharset(getSourceFileEncoding().get());
            getUniversalFilters().get().forEach(json::addUniversalFilter);
        }

        Files.write(Utils.GSON.toJson(json).getBytes(StandardCharsets.UTF_8), getOutput().get().getAsFile());
    }

    private void addParent(UserdevConfigV1 json, Project project) {
        PatcherExtension patcher = project.getExtensions().findByType(PatcherExtension.class);
        MCPExtension mcp = project.getExtensions().findByType(MCPExtension.class);

        if (patcher != null) {
            if (project != getProject() && patcher.getPatches().isPresent()) {
                // !patches.isPresent() means they don't add anything, used by Forge as a 'clean' workspace
                if (json.parent == null) {
                    json.parent = String.format("%s:%s:%s:userdev", project.getGroup(), project.getName(), project.getVersion());
                    return;
                }
            }
            if (patcher.getParent().isPresent()) {
                addParent(json, patcher.getParent().get());
            }
            //TODO: MCP/Parents without separate projects?
        } else {
            if (json.parent == null) { // Only specify mcp if we have no patcher parent.
                if (mcp == null)
                    throw new IllegalStateException("Could not determine MCP parent for userdev config");
                json.mcp = mcp.getConfig().get().toString();
            }
        }
    }

    private boolean isV2() {
        return this.notchObf || this.processor != null || this.getUniversalFilters().isPresent() ||
                !"a/".equals(getPatchesOriginalPrefix().get()) ||
                !"b/".equals(getPatchesModifiedPrefix().get());
    }

    @Input
    public abstract ListProperty<String> getLibraries();

    @Input
    public abstract ListProperty<String> getModules();

    @Input
    public abstract Property<String> getUniversal();

    @Input
    public abstract Property<String> getSource();

    @Input
    public abstract Property<String> getTool();

    @Input
    @Optional
    public abstract Property<String> getInject();

    @Input
    @Optional
    public abstract Property<String> getPatches();

    @Input
    public abstract ListProperty<String> getArguments();

    @InputFiles
    public abstract ConfigurableFileCollection getATs();

    @InputFiles
    public abstract ConfigurableFileCollection getSASs();

    @InputFiles
    public abstract ConfigurableFileCollection getSRGs();

    @Input
    @Optional
    public abstract ListProperty<String> getSRGLines();

    public NamedDomainObjectContainer<RunConfig> runs(@SuppressWarnings("rawtypes") Closure closure) {
        return runs.configure(closure);
    }

    @Input
    public NamedDomainObjectContainer<RunConfig> getRuns() {
        return runs;
    }

    public void propertyMissing(String name, Object value) {
        if (!(value instanceof Closure)) {
            throw new MissingPropertyException(name);
        }

        @SuppressWarnings("rawtypes")
        final Closure closure = (Closure) value;
        final RunConfig runConfig = getRuns().maybeCreate(name);

        closure.setResolveStrategy(Closure.DELEGATE_FIRST);
        closure.setDelegate(runConfig);
        closure.call();
    }

    private DataFunction ensureProcessor() {
        if (this.processor == null)
            this.processor = new DataFunction();
        return this.processor;
    }

    public void setProcessor(DataFunction value) {
        ensureProcessor();
        this.processor.setVersion(value.getVersion());
        this.processor.setRepo(value.getRepo());
        this.processor.setArgs(value.getArgs());
        this.processor.setJvmArgs(value.getJvmArgs());
    }

    @Input
    @Optional
    @Nullable
    public String getProcessorTool() {
        return this.processor == null ? null : this.processor.getVersion();
    }
    public void setProcessorTool(String value) {
        ensureProcessor().setVersion(value);
    }

    @Input
    @Optional
    @Nullable
    public String getProcessorRepo() {
        return this.processor == null ? null : this.processor.getRepo();
    }
    public void setProcessorRepo(String value) {
        ensureProcessor().setRepo(value);
    }

    @Input
    @Optional
    @Nullable
    public List<String> getProcessorArgs() {
        return this.processor == null ? null : this.processor.getArgs();
    }
    public void setProcessorTool(String... values) {
        ensureProcessor().setArgs(Arrays.asList(values));
    }

    @InputFiles
    @Optional
    public Provider<Collection<File>> getProcessorFiles() {
        return this.processorData.map(Map::values);
    }
    public void addProcessorData(String key, File file) {
        this.processorData.put(key, file);
        ensureProcessor().setData(key,  "processor/" + file.getName());
    }

    @Input
    @Optional
    public abstract Property<String> getPatchesOriginalPrefix();

    @Input
    @Optional
    public abstract Property<String> getPatchesModifiedPrefix();

    @Input
    public boolean getNotchObf() {
        return this.notchObf;
    }
    public void setNotchObf(boolean value) {
        this.notchObf = value;
    }

    @Input
    public Property<String> getSourceFileEncoding() {
        return this.sourceFileEncoding;
    }

    public void setSourceFileEncoding(Charset value) {
        getSourceFileEncoding().set(value.name());
    }
    public void setSourceFileEncoding(String value) {
        setSourceFileEncoding(Charset.forName(value)); // Load to ensure valid charset.
    }

    @Input
    @Optional
    public abstract ListProperty<String> getUniversalFilters();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class ReobfuscateJar extends JarExec {
    private boolean keepPackages = false;
    private boolean keepData = false;

    private final Provider<RegularFile> outputTemp = workDir.map(d -> d.file("output_temp.jar"));

    public ReobfuscateJar() {
        getTool().set(Utils.SPECIALSOURCE);
        getArgs().addAll("--in-jar", "{input}", "--out-jar", "{output}", "--srg-in", "{srg}", "--live");
        getOutput().convention(workDir.map(d -> d.file("output.jar")));
    }

    @TaskAction
    public void apply() throws IOException {
        super.apply();

        try (OutputStream log = new BufferedOutputStream(new FileOutputStream(logFile.get().getAsFile()))) {
            Set<String> packages = new HashSet<>();
            IMappingFile srgMappings = IMappingFile.load(getSrg().get().getAsFile());
            for (IMappingFile.IClass srgClass : srgMappings.getClasses()) {
                String named = srgClass.getOriginal();
                int idx = named.lastIndexOf('/');
                if (idx != -1) {
                    packages.add(named.substring(0, idx + 1) + "package-info.class");
                }
            }

            try (ZipFile zin = new ZipFile(outputTemp.get().getAsFile());
                 ZipOutputStream out = new ZipOutputStream(new FileOutputStream(getOutput().get().getAsFile()))) {
                for (Enumeration<? extends ZipEntry> enu = zin.entries(); enu.hasMoreElements(); ) {
                    ZipEntry entry = enu.nextElement();
                    boolean filter = entry.isDirectory() || entry.getName().startsWith("mcp/"); //Directories and MCP's annotations
                    if (!keepPackages) filter |= packages.contains(entry.getName());
                    if (!keepData) filter |= !entry.getName().endsWith(".class");

                    if (filter) {
                        log.write(("Filtered: " + entry.getName() + '\n').getBytes(StandardCharsets.UTF_8));
                        continue;
                    }
                    out.putNextEntry(entry);
                    IOUtils.copy(zin.getInputStream(entry), out);
                    out.closeEntry();
                }
            }

            outputTemp.get().getAsFile().delete();
        }
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        return replaceArgs(args, ImmutableMap.of(
                "{input}", getInput().get().getAsFile(),
                "{output}", outputTemp.get().getAsFile(),
                "{srg}", getSrg().get().getAsFile()), null);
    }

    @InputFile
    public abstract RegularFileProperty getInput();

    @InputFile
    public abstract RegularFileProperty getSrg();

    @OutputFile
    public abstract RegularFileProperty getOutput();

    @Input
    public boolean getKeepPackages() {
        return this.keepPackages;
    }

    public void keepPackages() {
        this.keepPackages = true;
    }

    public void filterPackages() {
        this.keepPackages = false;
    }

    @Input
    public boolean getKeepData() {
        return this.keepData;
    }

    public void keepData() {
        this.keepData = true;
    }

    public void filterData() {
        this.keepData = false;
    }
}
import net.minecraftforge.gradle.common.tasks.ApplyMappings;
import net.minecraftforge.gradle.common.tasks.ApplyRangeMap;
import net.minecraftforge.gradle.common.tasks.DownloadAssets;
import net.minecraftforge.gradle.common.tasks.DownloadMCMeta;
import net.minecraftforge.gradle.common.tasks.DynamicJarExec;
import net.minecraftforge.gradle.common.tasks.ExtractExistingFiles;
import net.minecraftforge.gradle.common.tasks.ExtractMCPData;
import net.minecraftforge.gradle.common.tasks.ExtractNatives;
import net.minecraftforge.gradle.common.tasks.ExtractRangeMap;
import net.minecraftforge.gradle.common.tasks.ExtractZip;
import net.minecraftforge.gradle.common.tasks.JarExec;
import net.minecraftforge.gradle.common.util.Artifact;
import net.minecraftforge.gradle.common.util.BaseRepo;
import net.minecraftforge.gradle.common.util.EnvironmentChecks;
import net.minecraftforge.gradle.common.util.MavenArtifactDownloader;
import net.minecraftforge.gradle.common.util.MinecraftRepo;
import net.minecraftforge.gradle.common.util.MojangLicenseHelper;
import net.minecraftforge.gradle.common.util.Utils;
import net.minecraftforge.gradle.common.util.VersionJson;
import net.minecraftforge.gradle.mcp.ChannelProvidersExtension;
import net.minecraftforge.gradle.mcp.MCPExtension;
import net.minecraftforge.gradle.mcp.MCPPlugin;
import net.minecraftforge.gradle.mcp.MCPRepo;
import net.minecraftforge.gradle.mcp.function.MCPFunction;
import net.minecraftforge.gradle.mcp.function.MCPFunctionFactory;
import net.minecraftforge.gradle.mcp.tasks.DownloadMCPConfig;
import net.minecraftforge.gradle.mcp.tasks.DownloadMCPMappings;
import net.minecraftforge.gradle.mcp.tasks.GenerateSRG;
import net.minecraftforge.gradle.mcp.tasks.SetupMCP;
import net.minecraftforge.gradle.patcher.tasks.ApplyPatches;
import net.minecraftforge.gradle.patcher.tasks.BakePatches;
import net.minecraftforge.gradle.patcher.tasks.CreateExc;
import net.minecraftforge.gradle.patcher.tasks.CreateFakeSASPatches;
import net.minecraftforge.gradle.patcher.tasks.FilterNewJar;
import net.minecraftforge.gradle.patcher.tasks.GenerateBinPatches;
import net.minecraftforge.gradle.patcher.tasks.GeneratePatches;
import net.minecraftforge.gradle.patcher.tasks.GenerateUserdevConfig;
import net.minecraftforge.gradle.patcher.tasks.ReobfuscateJar;
import org.gradle.api.DefaultTask;
import org.gradle.api.NamedDomainObjectProvider;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.repositories.MavenArtifactRepository.MetadataSources;
import org.gradle.api.file.Directory;
import org.gradle.api.file.RegularFile;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.TaskContainer;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.api.tasks.bundling.AbstractArchiveTask;
import org.gradle.api.tasks.bundling.Jar;
import org.gradle.api.tasks.bundling.Zip;
import org.gradle.api.tasks.compile.JavaCompile;
import codechicken.diffpatch.util.PatchMode;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/*
 * ForgeGradle
 * Copyright (C) 2018 Forge Development LLC
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301
 * USA
 */

package net.minecraftforge.gradle.patcher;





public class PatcherPlugin implements Plugin<Project> {
    private static final String MC_DEP_CONFIG = "minecraftImplementation";

    @Override
    public void apply(@Nonnull Project project) {
        EnvironmentChecks.checkEnvironment(project);

        final PatcherExtension extension = project.getExtensions().create(PatcherExtension.class, PatcherExtension.EXTENSION_NAME, PatcherExtension.class, project);
        project.getExtensions().create(ChannelProvidersExtension.EXTENSION_NAME, ChannelProvidersExtension.class);
        project.getPluginManager().apply(JavaPlugin.class);
        final JavaPluginExtension javaConv = project.getExtensions().getByType(JavaPluginExtension.class);

        Configuration mcImplementation = project.getConfigurations().maybeCreate(MC_DEP_CONFIG);
        mcImplementation.setCanBeResolved(true);
        project.getConfigurations().named(JavaPlugin.IMPLEMENTATION_CONFIGURATION_NAME)
                .configure(c -> c.extendsFrom(mcImplementation));

        final TaskContainer tasks = project.getTasks();
        final TaskProvider<Jar> jarTask = tasks.named(JavaPlugin.JAR_TASK_NAME, Jar.class);
        final TaskProvider<JavaCompile> javaCompileTask = tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile.class);
        final NamedDomainObjectProvider<SourceSet> mainSource = javaConv.getSourceSets().named(SourceSet.MAIN_SOURCE_SET_NAME);

        final TaskProvider<DownloadMCPMappings> dlMappingsConfig = tasks.register("downloadMappings", DownloadMCPMappings.class);
        final TaskProvider<DownloadMCMeta> dlMCMetaConfig = tasks.register("downloadMCMeta", DownloadMCMeta.class);
        final TaskProvider<ExtractNatives> extractNatives = tasks.register("extractNatives", ExtractNatives.class);
        final TaskProvider<ApplyPatches> applyPatches = tasks.register("applyPatches", ApplyPatches.class);