public class Config {
    public int spec;

    public static int getSpec(InputStream stream) throws IOException {
        return Utils.GSON.fromJson(new InputStreamReader(stream), Config.class).spec;
    }
    public static int getSpec(byte[] data) throws IOException {
        return getSpec(new ByteArrayInputStream(data));
    }
} public class MCPConfigV2 extends MCPConfigV1 {
    public static MCPConfigV2 get(InputStream stream) {
        return Utils.fromJson(stream, MCPConfigV2.class);
    }
    public static MCPConfigV2 get(byte[] data) {
        return get(new ByteArrayInputStream(data));
    }

    public static MCPConfigV2 getFromArchive(File path) throws IOException {
        try (ZipFile zip = new ZipFile(path)) {
            ZipEntry entry = zip.getEntry("config.json");
            if (entry == null)
                throw new IllegalStateException("Could not find 'config.json' in " + path.getAbsolutePath());

            byte[] data = IOUtils.toByteArray(zip.getInputStream(entry));
            int spec = Config.getSpec(data);
            if (spec == 2 || spec == 3)
                return MCPConfigV2.get(data);
            if (spec == 1)
                return new MCPConfigV2(MCPConfigV1.get(data));

            throw new IllegalStateException("Invalid MCP Config: " + path.getAbsolutePath() + " Unknown spec: " + spec);
        }
    }

    private boolean official = false;
    private int java_target = 8;
    @Nullable
    private String encoding = "UTF-8";

    public boolean isOfficial() {
        return this.official;
    }

    public int getJavaTarget() {
        return this.java_target;
    }

    public String getEncoding() {
        return this.encoding == null ? "UTF-8" : this.encoding;
    }

    public MCPConfigV2(MCPConfigV1 old) {
        this.version = old.version;
        this.data = old.data;
        this.steps = old.steps;
        this.functions = old.functions;
        this.libraries = old.libraries;
    }
} public class UserdevConfigV1 extends Config {
    public static UserdevConfigV1 get(InputStream stream) {
        return Utils.fromJson(stream, UserdevConfigV1.class);
    }
    public static UserdevConfigV1 get(byte[] data) {
        return get(new ByteArrayInputStream(data));
    }

    @Nullable
    public String mcp;    // Do not specify this unless there is no parent.
    @Nullable
    public String parent; // To fully resolve, we must walk the parents until we hit null, and that one must specify a MCP value.
    @Nullable
    public List<String> ats;
    @Nullable
    public List<String> sass;
    @Nullable
    public List<String> srgs;
    @Nullable
    public List<String> srg_lines;
    public String binpatches; //To be applied to joined.jar, remapped, and added to the classpath
    public Function binpatcher;
    public String patches;
    @Nullable
    public String sources;
    @Nullable
    public String universal; //Remapped and added to the classpath, Contains new classes and resources
    @Nullable
    public List<String> libraries; //Additional libraries.
    @Nullable
    public String inject;
    @Nullable
    public Map<String, RunConfig> runs;
    @Nullable
    public String sourceCompatibility;
    @Nullable
    public String targetCompatibility;


    public void addAT(String value) {
        if (this.ats == null) {
            this.ats = new ArrayList<>();
        }
        this.ats.add(value);
    }
    public List<String> getATs() {
        return this.ats == null ? Collections.emptyList() : this.ats;
    }
    public void addSAS(String value) {
        if (this.sass == null) {
            this.sass = new ArrayList<>();
        }
        this.sass.add(value);
    }
    public List<String> getSASs() {
        return this.sass == null ? Collections.emptyList() : this.sass;
    }
    public void addSRG(String value) {
        if (this.srgs == null) {
            this.srgs = new ArrayList<>();
        }
        this.srgs.add(value);
    }
    public void addSRGLine(String value) {
        if (this.srg_lines == null) {
            this.srg_lines = new ArrayList<>();
        }
        this.srg_lines.add(value);
    }
    public void addLibrary(String value) {
        if (this.libraries == null)
            this.libraries = new ArrayList<>();
        this.libraries.add(value);
    }
    public void addRun(String name, RunConfig value) {
        if (this.runs == null)
            this.runs = new HashMap<>();
        this.runs.put(name, value);
    }

    public String getSourceCompatibility() {
        return sourceCompatibility == null ? "1.8" : sourceCompatibility;
    }
    public String getTargetCompatibility() {
        return targetCompatibility == null ? "1.8" : targetCompatibility;
    }
} public class UserdevConfigV2 extends UserdevConfigV1 {
    public static UserdevConfigV2 get(InputStream stream) {
        return Utils.fromJson(stream, UserdevConfigV2.class);
    }
    public static UserdevConfigV2 get(byte[] data) {
        return get(new ByteArrayInputStream(data));
    }

    public DataFunction processor;
    public String patchesOriginalPrefix;
    public String patchesModifiedPrefix;
    @Nullable
    private Boolean notchObf; //This is a Boolean so we can set to null and it won't be printed in the json.
    @Nullable
    private List<String> universalFilters;
    @Nullable
    public List<String> modules; // Modules passed to --module-path
    private String sourceFileCharset = StandardCharsets.UTF_8.name();

    public void setNotchObf(boolean value) {
        this.notchObf = value ? true : null;
    }

    public boolean getNotchObf() {
        return this.notchObf != null && this.notchObf;
    }

    public void setSourceFileCharset(String value) {
        if (!Charset.isSupported(value)) {
            throw new IllegalArgumentException("Unsupported charset: " + value);
        }
        sourceFileCharset = value;
    }

    public String getSourceFileCharset() {
        return sourceFileCharset;
    }

    public void addUniversalFilter(String value) {
        if (universalFilters == null)
            universalFilters = new ArrayList<>();
        universalFilters.add(value);
    }

    public List<String> getUniversalFilters() {
        return universalFilters == null ? Collections.emptyList() : universalFilters;
    }

    public void addModule(String value) {
        if (modules == null)
            modules = new ArrayList<>();
        modules.add(value);
    }

    @Nullable
    public List<String> getModules() {
        return modules;
    }

    public static class DataFunction extends Function {
        protected Map<String, String> data;

        public Map<String, String> getData() {
            return this.data == null ? Collections.emptyMap() : data;
        }

        @Nullable
        public String setData(String name, String path) {
            if (this.data == null)
                this.data = new HashMap<>();
            return this.data.put(name, path);
        }
    }
} public static class DataFunction extends Function {
    protected Map<String, String> data;

    public Map<String, String> getData() {
        return this.data == null ? Collections.emptyMap() : data;
    }

    @Nullable
    public String setData(String name, String path) {
        if (this.data == null)
            this.data = new HashMap<>();
        return this.data.put(name, path);
    }
} public abstract class ApplyBinPatches extends JarExec {
    public ApplyBinPatches() {
        getTool().set(Utils.BINPATCHER);
        getArgs().addAll("--clean", "{clean}", "--output", "{output}", "--apply", "{patch}");

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

    @Override
    protected List<String> filterArgs(List<String> args) {
        return replaceArgs(args, ImmutableMap.of(
                "{clean}", getClean().get().getAsFile(),
                "{output}", getOutput().get().getAsFile(),
                "{patch}", getPatch().get().getAsFile()), null);
    }

    @InputFile
    public abstract RegularFileProperty getClean();

    @InputFile
    public abstract RegularFileProperty getPatch();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} 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 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 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 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 enum HashFunction {
    MD5("md5", 32),
    SHA1("SHA-1", 40),
    SHA256("SHA-256", 64),
    SHA512("SHA-512", 128);

    private final String algo;
    private final String pad;

    HashFunction(String algo, int length) {
        this.algo = algo;
        this.pad = String.format(Locale.ROOT, "%0" + length + "d", 0);
    }

    public String getExtension() {
        return this.name().toLowerCase(Locale.ROOT);
    }

    public MessageDigest get() {
        try {
            return MessageDigest.getInstance(algo);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e); // Never happens
        }
    }

    public String hash(File file) throws IOException {
        return hash(file.toPath());
    }

    public String hash(Path file) throws IOException {
        return hash(Files.readAllBytes(file));
    }

    public String hash(Iterable<File> files) throws IOException {
        MessageDigest hash = get();

        for (File file : files) {
            if (!file.exists())
                continue;
            hash.update(Files.readAllBytes(file.toPath()));
        }
        return pad(new BigInteger(1, hash.digest()).toString(16));
    }

    public String hash(@Nullable String data) {
        return hash(data == null ? new byte[0] : data.getBytes(StandardCharsets.UTF_8));
    }

    public String hash(InputStream stream) throws IOException {
        return hash(IOUtils.toByteArray(stream));
    }

    public String hash(byte[] data) {
        return pad(new BigInteger(1, get().digest(data)).toString(16));
    }

    public String pad(String hash) {
        return (pad + hash).substring(hash.length());
    }
} public class HashStore {
    private final boolean INVALIDATE_CACHE = System.getProperty("FG_INVALIDATE_CACHE", "false").equals("true");
    private final int RAND_CACHE = new Random().nextInt();

    private final String root;
    private final Map<String, String> oldHashes = new HashMap<>();
    private final Map<String, String> newHashes = new HashMap<>();
    private File target;

    public HashStore() {
        this.root = "";
    }
    public HashStore(Project project) {
        this.root = project.getRootDir().getAbsolutePath();
    }
    public HashStore(File root) {
        this.root = root.getAbsolutePath();
    }

    public boolean areSame(File... files) {
        for(File file : files) {
            if(!isSame(file)) return false;
        }
        return true;
    }

    public boolean areSame(Iterable<File> files) {
        for(File file : files) {
            if(!isSame(file)) return false;
        }
        return true;
    }

    public boolean isSame(File file) {
        try {
            String path = getPath(file);
            String hash = oldHashes.get(path);
            if (hash == null) {
                if (file.exists()) {
                    newHashes.put(path, HashFunction.SHA1.hash(file));
                    return false;
                }
                return true;
            }
            String fileHash = HashFunction.SHA1.hash(file);
            newHashes.put(path, fileHash);
            return fileHash.equals(hash);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public HashStore load(File file) throws IOException {
        this.target = file;
        oldHashes.clear();
        if(!file.exists()) return this;
        for (String line : FileUtils.readLines(file, StandardCharsets.UTF_8)) {
            String[] split = line.split("=");
            oldHashes.put(split[0], split[1]);
        }
        return this;
    }

    public boolean exists() {
        return this.target != null && this.target.exists();
    }

    public HashStore bust(int version) {
        newHashes.put("CACHE_BUSTER", Integer.toString(version));
        return this;
    }

    public HashStore add(String key, String data) {
        newHashes.put(key, HashFunction.SHA1.hash(data));
        return this;
    }

    public HashStore add(String key, byte[] data) {
        newHashes.put(key, HashFunction.SHA1.hash(data));
        return this;
    }

    public HashStore add(@Nullable String key, File file) {
        try {
            newHashes.put(key == null ? getPath(file) : key, HashFunction.SHA1.hash(file));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return this;
    }

    public HashStore add(File... files) {
        for (File file : files) {
            add(null, file);
        }
        return this;
    }
    public HashStore add(Iterable<File> files) {
        for (File file : files) {
            add(null, file);
        }
        return this;
    }
    public HashStore add(File file) {
        add(null, file);
        return this;
    }

    public boolean isSame() {
        if (INVALIDATE_CACHE)
            add("invalidate", "" + RAND_CACHE);
        return oldHashes.equals(newHashes);
    }

    public void save() throws IOException {
        if (target == null) {
            throw new RuntimeException("HashStore.save() called without load(File) so we dont know where to save it! Use load(File) or save(File)");
        }
        save(target);
    }
    public void save(File file) throws IOException {
        FileUtils.writeByteArrayToFile(file, newHashes.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("\n")).getBytes());
    }

    private String getPath(File file) {
        String path = file.getAbsolutePath();
        if (path.startsWith(root)) {
            return path.substring(root.length()).replace('\\', '/');
        } else {
            return path.replace('\\', '/');
        }
    }

} 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 McpNames {
    private static final String NEWLINE = System.getProperty("line.separator");
    private static final Pattern SRG_FINDER             = Pattern.compile("[fF]unc_\\d+_[a-zA-Z_]+|m_\\d+_|[fF]ield_\\d+_[a-zA-Z_]+|f_\\d+_|p_\\w+_\\d+_|p_\\d+_");
    private static final Pattern CONSTRUCTOR_JAVADOC_PATTERN = Pattern.compile("^(?<indent>(?: {3})+|\\t+)(public |private|protected |)(?<generic><[\\w\\W]*>\\s+)?(?<name>[\\w.]+)\\((?<parameters>.*)\\)\\s+(?:throws[\\w.,\\s]+)?\\{");
    private static final Pattern METHOD_JAVADOC_PATTERN = Pattern.compile("^(?<indent>(?: {3})+|\\t+)(?!return)(?:\\w+\\s+)*(?<generic><[\\w\\W]*>\\s+)?(?<return>\\w+[\\w$.]*(?:<[\\w\\W]*>)?[\\[\\]]*)\\s+(?<name>(?:func_|m_)[0-9]+_[a-zA-Z_]*)\\(");
    private static final Pattern FIELD_JAVADOC_PATTERN  = Pattern.compile("^(?<indent>(?: {3})+|\\t+)(?!return)(?:\\w+\\s+)*\\w+[\\w$.]*(?:<[\\w\\W]*>)?[\\[\\]]*\\s+(?<name>(?:field_|f_)[0-9]+_[a-zA-Z_]*) *[=;]");
    private static final Pattern CLASS_JAVADOC_PATTERN  = Pattern.compile("^(?<indent> *|\\t*)([\\w|@]*\\s)*(class|interface|@interface|enum) (?<name>[\\w]+)");
    private static final Pattern CLOSING_CURLY_BRACE    = Pattern.compile("^(?<indent> *|\\t*)}");
    private static final Pattern PACKAGE_DECL           = Pattern.compile("^[\\s]*package(\\s)*(?<name>[\\w|.]+);$");
    private static final Pattern LAMBDA_DECL            = Pattern.compile("\\((?<args>(?:(?:, ){0,1}p_[\\w]+_\\d+_\\b)+)\\) ->");

    public static McpNames load(File data) throws IOException {
        Map<String, String> names = new HashMap<>();
        Map<String, String> docs = new HashMap<>();
        try (ZipFile zip = new ZipFile(data)) {
            List<ZipEntry> entries = zip.stream().filter(e -> e.getName().endsWith(".csv")).collect(Collectors.toList());
            for (ZipEntry entry : entries) {
                try (NamedCsvReader reader = NamedCsvReader.builder().build(new InputStreamReader(zip.getInputStream(entry)))) {
                    String obf = reader.getHeader().contains("searge") ? "searge" : "param";
                    boolean hasDesc = reader.getHeader().contains("desc");
                    reader.forEach(row -> {
                        String searge = row.getField(obf);
                        names.put(searge, row.getField("name"));
                        if (hasDesc) {
                            String desc = row.getField("desc");
                            if (!desc.isEmpty())
                                docs.put(searge, desc);
                        }
                    });
                }
            }
        }

        return new McpNames(HashFunction.SHA1.hash(data), names, docs);
    }

    private final Map<String, String> names;
    private final Map<String, String> docs;
    public final String hash;

    private McpNames(String hash, Map<String, String> names, Map<String, String> docs) {
        this.hash = hash;
        this.names = names;
        this.docs = docs;
    }

    public String rename(InputStream stream, boolean javadocs) throws IOException {
        return rename(stream, javadocs, true, StandardCharsets.UTF_8);
    }

    public String rename(InputStream stream, boolean javadocs, boolean lambdas) throws IOException {
        return rename(stream, javadocs, lambdas, StandardCharsets.UTF_8);
    }

    public String rename(InputStream stream, boolean javadocs, boolean lambdas, Charset sourceFileCharset)
            throws IOException {

        String data = IOUtils.toString(stream, sourceFileCharset);
        List<String> input = IOUtils.readLines(new StringReader(data));

        // Return early on emtpy files
        if (data.isEmpty())
            return "";

        //Reader doesn't give us the empty line if the file ends with a newline.. so add one.
        if (data.charAt(data.length() - 1) == '\r' || data.charAt(data.length() - 1) == '\n')
            input.add("");

        List<String> lines = new ArrayList<>();
        Deque<Pair<String, Integer>> innerClasses = new LinkedList<>(); //pair of inner class name & indentation
        String _package = ""; //default package
        Set<String> blacklist = null;

        if (!lambdas) {
            blacklist = new HashSet<>();
            for (String line : input) {
                Matcher m = LAMBDA_DECL.matcher(line);
                if (!m.find())
                    continue;
                blacklist.addAll(Arrays.asList(m.group("args").split(", ")));
            }
        }

        for (String line : input) {
            Matcher m = PACKAGE_DECL.matcher(line);
            if(m.find())
                _package = m.group("name") + ".";

            if (javadocs) {
                if (!injectJavadoc(lines, line, _package, innerClasses))
                    javadocs = false;
            }
            lines.add(replaceInLine(line, blacklist));
        }
        return String.join(NEWLINE, lines);
    }

    public String rename(String entry) {
        return names.getOrDefault(entry, entry);
    }

    /**
     * Injects a javadoc into the given list of lines, if the given line is a
     * method or field declaration.
     * @param lines The current file content (to be modified by this method)
     * @param line The line that was just read (will not be in the list)
     * @param _package the name of the package this file is declared to be in, in com.example format;
     * @param innerClasses current position in inner class
     */
    private boolean injectJavadoc(List<String> lines, String line, String _package, Deque<Pair<String, Integer>> innerClasses) {
        // constructors
        Matcher matcher = CONSTRUCTOR_JAVADOC_PATTERN.matcher(line);
        boolean isConstructor = matcher.find() && !innerClasses.isEmpty() && innerClasses.peek().getLeft().contains(matcher.group("name"));
        // methods
        if (!isConstructor)
            matcher = METHOD_JAVADOC_PATTERN.matcher(line);

        if (isConstructor || matcher.find()) {
            String name = isConstructor ? "<init>" : matcher.group("name");
            String javadoc = docs.get(name);
            if (javadoc == null && !innerClasses.isEmpty() && !name.startsWith("func_") && !name.startsWith("m_")) {
                String currentClass = innerClasses.peek().getLeft();
                javadoc = docs.get(currentClass + '#' + name);
            }
            if (javadoc != null)
                insertAboveAnnotations(lines, JavadocAdder.buildJavadoc(matcher.group("indent"), javadoc, true));

            // worked, so return and don't try the fields.
            return true;
        }

        // fields
        matcher = FIELD_JAVADOC_PATTERN.matcher(line);
        if (matcher.find()) {
            String name = matcher.group("name");
            String javadoc = docs.get(name);
            if (javadoc == null && !innerClasses.isEmpty() && !name.startsWith("field_") && !name.startsWith("f_")) {
                String currentClass = innerClasses.peek().getLeft();
                javadoc = docs.get(currentClass + '#' + name);
            }
            if (javadoc != null)
                insertAboveAnnotations(lines, JavadocAdder.buildJavadoc(matcher.group("indent"), javadoc, false));

            return true;
        }

        //classes
        matcher = CLASS_JAVADOC_PATTERN.matcher(line);
        if(matcher.find()) {
            //we maintain a stack of the current (inner) class in com.example.ClassName$Inner format (along with indentation)
            //if the stack is not empty we are entering a new inner class
            String currentClass = (innerClasses.isEmpty() ? _package : innerClasses.peek().getLeft() + "$") + matcher.group("name");
            innerClasses.push(Pair.of(currentClass, matcher.group("indent").length()));
            String javadoc = docs.get(currentClass);
            if (javadoc != null) {
                insertAboveAnnotations(lines, JavadocAdder.buildJavadoc(matcher.group("indent"), javadoc, true));
            }

            return true;
        }

        //detect curly braces for inner class stacking/end identification
        matcher = CLOSING_CURLY_BRACE.matcher(line);
        if(matcher.find()){
            if(!innerClasses.isEmpty()) {
                int len = matcher.group("indent").length();
                if (len == innerClasses.peek().getRight()) {
                    innerClasses.pop();
                } else if (len < innerClasses.peek().getRight()) {
                    System.err.println("Failed to properly track class blocks around class " + innerClasses.peek().getLeft() + ":" + (lines.size() + 1));
                    return false;
                }
            }
        }

        return true;
    }

    /** Inserts the given javadoc line into the list of lines before any annotations */
    private static void insertAboveAnnotations(List<String> list, String line) {
        int back = 0;
        while (list.get(list.size() - 1 - back).trim().startsWith("@"))
            back++;
        list.add(list.size() - back, line);
    }

    /*
     * There are certain times, such as Mixin Accessors that we wish to have the name of this method with the first character upper case.
     */
    private String getMapped(String srg, @Nullable Set<String> blacklist) {
        if (blacklist != null && blacklist.contains(srg))
            return srg;

        boolean cap = srg.charAt(0) == 'F';
        if (cap)
            srg = 'f' + srg.substring(1);

        String ret = names.getOrDefault(srg, srg);
        if (cap)
            ret = ret.substring(0, 1).toUpperCase(Locale.ENGLISH) + ret.substring(1);
        return ret;
    }

    private String replaceInLine(String line, @Nullable Set<String> blacklist) {
        StringBuffer buf = new StringBuffer();
        Matcher matcher = SRG_FINDER.matcher(line);
        while (matcher.find()) {
            // Since '$' is a valid character in identifiers, but we need to NOT treat this as a regex group, escape any occurrences
            matcher.appendReplacement(buf, Matcher.quoteReplacement(getMapped(matcher.group(), blacklist)));
        }
        matcher.appendTail(buf);
        return buf.toString();
    }
} public class POMBuilder {

    private static final Pattern PATTERN_ARTIFACT = Pattern.compile(
            "^(?<group>[^:]+):(?<name>[^:]+)(?::(?<version>[^:@]+))(?::(?<classifier>[^:@]+))?(?:@(?<extension>[^:]+))?$");

    private final String group, name, version;
    private final Dependencies dependencies = new Dependencies();
    @Nullable
    private String description;

    public POMBuilder(String group, String name, String version) {
        this.group = group;
        this.name = name;
        this.version = version;
    }

    public POMBuilder description(String description) {
        this.description = description;
        return this;
    }

    public POMBuilder dependencies(Consumer<Dependencies> configurator) {
        configurator.accept(dependencies);
        return this;
    }

    public Dependencies dependencies() {
        return dependencies;
    }

    @Nullable
    public String tryBuild() {
        try {
            return build();
        } catch (Exception ex) {
            return null;
        }
    }

    public String build() throws ParserConfigurationException, TransformerException {
        DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
        DocumentBuilder docBuilder = docFactory.newDocumentBuilder();
        Document doc = docBuilder.newDocument();

        Element project = doc.createElement("project");
        project.setAttribute("xsi:schemaLocation", "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd");
        project.setAttribute("xmlns", "http://maven.apache.org/POM/4.0.0");
        project.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
        doc.appendChild(project);

        set(doc, project, "modelVersion", "4.0.0");
        set(doc, project, "groupId", group);
        set(doc, project, "artifactId", name);
        set(doc, project, "version", version);
        set(doc, project, "name", name);
        if (description != null) {
            set(doc, project, "description", description);
        }

        if (!dependencies.dependencies.isEmpty()) {
            Element dependencies = doc.createElement("dependencies");
            for (Dependencies.Dependency dependency : this.dependencies.dependencies) {
                Element dep = doc.createElement("dependency");
                set(doc, dep, "groupId", dependency.group);
                set(doc, dep, "artifactId", dependency.name);
                set(doc, dep, "version", dependency.version);
                if (dependency.classifier != null && !"jar".equals(dependency.classifier)) {
                    set(doc, dep, "classifier", dependency.classifier);
                }
                if (dependency.extension != null) {
                    set(doc, dep, "type", dependency.extension);
                }
                if (dependency.scope != null) {
                    set(doc, dep, "scope", dependency.scope);
                }
                dependencies.appendChild(dep);
            }
            project.appendChild(dependencies);
        }

        doc.normalizeDocument();

        TransformerFactory transformerFactory = TransformerFactory.newInstance();
        Transformer transformer = transformerFactory.newTransformer();
        transformer.setOutputProperty(OutputKeys.INDENT, "yes"); //Make it pretty
        transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
        DOMSource source = new DOMSource(doc);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        StreamResult result = new StreamResult(baos);
        transformer.transform(source, result);

        return baos.toString();
    }

    private static void set(Document doc, Element parent, String name, String value) {
        Element description = doc.createElement(name);
        description.appendChild(doc.createTextNode(value));
        parent.appendChild(description);
    }

    public class Dependencies {

        private final Set<Dependency> dependencies = new LinkedHashSet<>();

        private Dependencies() {
        }

        public Dependency add(String artifact, @Nullable String scope) {
            Matcher matcher = PATTERN_ARTIFACT.matcher(artifact);
            if (!matcher.matches()) throw new IllegalArgumentException("Invalid maven artifact specifier: " + artifact);
            return add(matcher.group("group"), matcher.group("name"), matcher.group("version"),
                    matcher.group("classifier"), matcher.group("extension"), scope);
        }

        public Dependency addHard(String artifact, @Nullable String scope) {
            Matcher matcher = PATTERN_ARTIFACT.matcher(artifact);
            if (!matcher.matches()) throw new IllegalArgumentException("Invalid maven artifact specifier: " + artifact);
            return add(matcher.group("group"), matcher.group("name"), '[' + matcher.group("version") + ']',
                    matcher.group("classifier"), matcher.group("extension"), scope);
        }

        public Dependency add(String group, String name, String version,
                              @Nullable String classifier, @Nullable String extension, @Nullable String scope) {
            Dependency dep = new Dependency(group, name, version, classifier, extension, scope);
            dependencies.add(dep);
            return dep;
        }

        public class Dependency {
            private final String group, name, version;
            @Nullable
            private String classifier, extension, scope;

            private Dependency(String group, String name, String version,
                               @Nullable String classifier, @Nullable String extension, @Nullable String scope) {
                this.group = group;
                this.name = name;
                this.version = version;
                this.classifier = classifier;
                this.extension = extension;
                this.scope = scope;
            }

            public void withClassifier(@Nullable String classifier) {
                this.classifier = classifier;
            }
        }

    }

} public class RunConfig extends GroovyObjectSupport implements Serializable {

    public static final String RUNS_GROUP = "ForgeGradle runs";

    private static final String MCP_CLIENT_MAIN = "mcp.client.Start";
    private static final String MC_CLIENT_MAIN = "net.minecraft.client.main.Main";

    private static final long serialVersionUID = 1L;

    private transient final Project project;
    private transient NamedDomainObjectContainer<ModConfig> mods;

    private final String name;

    private Boolean singleInstance = null;

    private String taskName, main, ideaModule, workDir;

    private List<SourceSet> sources;
    private List<RunConfig> parents, children;
    private List<String> args, jvmArgs;
    private boolean forceExit = true;
    private Boolean client; // so we can have it null
    private Boolean inheritArgs;
    private Boolean inheritJvmArgs;

    private Map<String, String> env, props, tokens;
    private Map<String, Supplier<String>> lazyTokens;

    public RunConfig(final Project project, final String name) {
        this.project = project;
        this.name = name;

        this.mods = project.container(ModConfig.class, modName -> new ModConfig(project, modName));
    }

    public final String getName() {
        return name;
    }

    public void setTaskName(String taskName) {
        this.taskName = taskName;
    }

    public void taskName(String taskName) {
        setTaskName(taskName);
    }

    public final String getTaskName() {
        if (taskName == null) {
            taskName = getName().replaceAll("[^a-zA-Z0-9\\-_]", "");

            if (!taskName.startsWith("run")) {
                taskName = "run" + Utils.capitalize(taskName);
            }
        }

        return taskName;
    }

    public final String getUniqueFileName() {
        return project.getPath().length() > 1 ? String.join("_", String.join("_", project.getPath().substring(1).split(":")), getTaskName()) : getTaskName();
    }

    public final String getUniqueName() {
        return getUniqueFileName().replaceAll("_", " ");
    }

    public void environment(Map<String, Object> map) {
        this.setEnvironment(map);
    }

    public void setEnvironment(Map<String, Object> map) {
        map.forEach((k, v) -> getEnvironment().put(k, v instanceof File ? ((File) v).getAbsolutePath() : (String) v));
    }

    public void environment(String key, String value) {
        getEnvironment().put(key, value);
    }

    public void environment(String key, File value) {
        getEnvironment().put(key, value.getAbsolutePath());
    }

    public Map<String, String> getEnvironment() {
        if (env == null) {
            env = new HashMap<>();
        }

        return env;
    }

    public void main(String value) {
        this.setMain(value);
    }

    public void setMain(String value) {
        this.main = value;
    }

    public String getMain() {
        return this.main;
    }

    public void inheritArgs(boolean value) {
        setInheritArgs(value);
    }

    public void setInheritArgs(boolean value) {
        this.inheritArgs = value;
    }

    public boolean getInheritArgs() {
        // Both null and true mean inherit the args
        return inheritArgs != Boolean.FALSE;
    }

    public void inheritJvmArgs(boolean value) {
        setInheritJvmArgs(value);
    }

    public void setInheritJvmArgs(boolean value) {
        this.inheritJvmArgs = value;
    }

    public boolean getInheritJvmArgs() {
        // Both null and true mean inherit the args
        return inheritJvmArgs != Boolean.FALSE;
    }

    public void args(List<Object> values) {
        setArgs(values);
    }

    public void args(Object... values) {
        args(Arrays.asList(values));
    }

    public void arg(Object value) {
        args(value);
    }

    public void setArgs(List<Object> values) {
        values.forEach(value -> getArgs().add(value instanceof File ? ((File) value).getAbsolutePath() : Objects.toString(value)));
    }

    public List<String> getArgs() {
        if (args == null) {
            args = new ArrayList<>();
        }

        return args;
    }

    public void jvmArgs(List<String> values) {
        setJvmArgs(values);
    }

    public void jvmArgs(String... values) {
        jvmArgs(Arrays.asList(values));
    }

    public void jvmArg(String value) {
        jvmArgs(value);
    }

    public void setJvmArgs(List<String> values) {
        getJvmArgs().addAll(values);
    }

    public List<String> getJvmArgs() {
        if (jvmArgs == null) {
            jvmArgs = new ArrayList<>();
        }

        return jvmArgs;
    }

    public void singleInstance(boolean value) {
        this.setSingleInstance(value);
    }

    public void setSingleInstance(boolean singleInstance) {
        this.singleInstance = singleInstance;
    }

    public boolean isSingleInstance() {
        return singleInstance != null && singleInstance;
    }

    public void properties(Map<String, Object> map) {
        this.setProperties(map);
    }

    public void setProperties(Map<String, Object> map) {
        map.forEach((k, v) -> getProperties().put(k, v instanceof File ? ((File) v).getAbsolutePath() : (String) v));
    }

    public void property(String key, String value) {
        getProperties().put(key, value);
    }

    public void property(String key, File value) {
        getProperties().put(key, value.getAbsolutePath());
    }

    public Map<String, String> getProperties() {
        if (props == null) {
            props = new HashMap<>();
        }

        return props;
    }

    public void ideaModule(String value) {
        this.setIdeaModule(value);
    }

    public void setIdeaModule(String value) {
        this.ideaModule = value;
    }

    public final String getIdeaModule() {
        if (ideaModule == null) {
            ideaModule = project.getName().replace(' ', '_') + ".main";
        }

        return ideaModule;
    }

    public void workingDirectory(String value) {
        setWorkingDirectory(value);
    }

    public void workingDirectory(File value) {
        setWorkingDirectory(value.getAbsolutePath());
    }

    public void setWorkingDirectory(String value) {
        this.workDir = value;
    }

    public String getWorkingDirectory() {
        if (workDir == null) {
            workDir = project.file("run").getAbsolutePath();
        }

        return workDir;
    }

    public void forceExit(boolean forceExit) {
        this.setForceExit(forceExit);
    }

    public void setForceExit(boolean forceExit) {
        this.forceExit = forceExit;
    }

    public boolean getForceExit() {
        return this.forceExit;
    }

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

    public NamedDomainObjectContainer<ModConfig> getMods() {
        return mods;
    }

    public void setSources(List<SourceSet> sources) {
        this.sources = sources;
    }

    public void sources(final List<SourceSet> sources) {
        getSources().addAll(sources);
    }

    public void sources(final SourceSet... sources) {
        sources(Arrays.asList(sources));
    }

    public void source(final SourceSet source) {
        sources(source);
    }

    public List<SourceSet> getSources() {
        if (sources == null) {
            sources = new ArrayList<>();
        }

        return sources;
    }

    private Stream<RunConfig> entriesToRuns(MapEntry... entries) {
        return Stream.of(entries).map(entry -> {
            final Project project = entry.getKey() == null
                    ? this.project : this.project.project(entry.getKey().toString());

            final MinecraftExtension minecraft = project.getExtensions().findByType(MinecraftExtension.class);

            return minecraft == null ? null : minecraft.getRuns().maybeCreate(entry.getValue().toString());
        }).filter(Objects::nonNull);
    }

    public void setParents(List<RunConfig> parents) {
        this.parents = parents;
    }

    public void setParents(MapEntry... parents) {
        setParents(entriesToRuns(parents).collect(Collectors.toList()));
    }

    public void setParents(Map<String, String> parents) {
        setParents(parents.entrySet().stream()
                .map((entry) -> new MapEntry(entry.getKey(), entry.getValue()))
                .toArray(MapEntry[]::new));
    }

    public void parents(MapEntry... parents) {
        getParents().addAll(entriesToRuns(parents).collect(Collectors.toList()));
    }

    public void parents(Map<String, String> parents) {
        parents(parents.entrySet().stream()
                .map((entry) -> new MapEntry(entry.getKey(), entry.getValue()))
                .toArray(MapEntry[]::new));
    }

    public void parent(@Nullable String project, @Nullable String parent) {
        parents(new MapEntry(project, parent));
    }

    public void parents(int index, MapEntry... parents) {
        getParents().addAll(index, entriesToRuns(parents).collect(Collectors.toList()));
    }

    public void parents(int index, Map<String, String> parents) {
        parents(index, parents.entrySet().stream()
                .map((entry) -> new MapEntry(entry.getKey(), entry.getValue()))
                .toArray(MapEntry[]::new));
    }

    public void parent(int index, @Nullable String project, @Nullable String parent) {
        parents(index, new MapEntry(project, parent));
    }

    public void parents(RunConfig... parents) {
        getParents().addAll(Arrays.asList(parents));
    }

    public void parent(RunConfig parent) {
        parents(parent);
    }

    public void parents(int index, RunConfig... parents) {
        getParents().addAll(Math.min(index, getParents().size()), Arrays.asList(parents));
    }

    public void parent(int index, RunConfig parent) {
        parents(index, parent);
    }

    public final List<RunConfig> getParents() {
        if (parents == null) {
            parents = new ArrayList<>();
        }

        return parents;
    }

    public void setChildren(List<RunConfig> children) {
        this.children = children;
    }

    public void setChildren(MapEntry... children) {
        setChildren(entriesToRuns(children).collect(Collectors.toList()));
    }

    public void setChildren(Map<String, String> children) {
        setChildren(children.entrySet().stream()
                .map((entry) -> new MapEntry(entry.getKey(), entry.getValue()))
                .toArray(MapEntry[]::new));
    }

    public void children(MapEntry... children) {
        getChildren().addAll(entriesToRuns(children).collect(Collectors.toList()));
    }

    public void children(Map<String, String> children) {
        children(children.entrySet().stream()
                .map((entry) -> new MapEntry(entry.getKey(), entry.getValue()))
                .toArray(MapEntry[]::new));
    }

    public void child(@Nullable String project, @Nullable String child) {
        children(new MapEntry(project, child));
    }

    public void children(int index, MapEntry... children) {
        getChildren().addAll(index, entriesToRuns(children).collect(Collectors.toList()));
    }

    public void children(int index, Map<String, String> children) {
        children(index, children.entrySet().stream()
                .map((entry) -> new MapEntry(entry.getKey(), entry.getValue()))
                .toArray(MapEntry[]::new));
    }

    public void child(int index, @Nullable String project, @Nullable String child) {
        children(index, new MapEntry(project, child));
    }

    public void children(RunConfig... children) {
        getChildren().addAll(Arrays.asList(children));
    }

    public void child(RunConfig child) {
        children(child);
    }

    public void children(int index, RunConfig... children) {
        getChildren().addAll(Math.min(index, getParents().size()), Arrays.asList(children));
    }

    public void child(int index, RunConfig child) {
        children(index, child);
    }

    public final List<RunConfig> getChildren() {
        if (children == null) {
            children = new ArrayList<>();
        }

        return children;
    }

    public void merge(final RunConfig other, boolean overwrite) {

        RunConfig first = overwrite ? other : this;
        RunConfig second = overwrite ? this : other;

        List<String> args = new ArrayList<>();
        if (this.getInheritArgs() && other.args != null)
            args.addAll(other.args);
        if (this.args != null)
            args.addAll(this.args);
        this.args = args;
        List<String> jvmArgs = new ArrayList<>();
        if (this.getInheritJvmArgs() && other.jvmArgs != null)
            jvmArgs.addAll(other.jvmArgs);
        if (this.jvmArgs != null)
            jvmArgs.addAll(this.jvmArgs);
        this.jvmArgs = jvmArgs;
        main = first.main == null ? second.main : first.main;
        mods = first.mods == null ? second.mods : first.mods;
        sources = first.sources == null ? second.sources : first.sources;
        workDir = first.workDir == null ? second.workDir : first.workDir;
        ideaModule = first.ideaModule == null ? second.ideaModule : first.ideaModule;
        singleInstance = first.singleInstance == null ? second.singleInstance : first.singleInstance;
        this.client = first.client == null ? second.client : first.client;

        if (other.env != null) {
            other.env.forEach(overwrite
                    ? (key, value) -> getEnvironment().put(key, value)
                    : (key, value) -> getEnvironment().putIfAbsent(key, value));
        }

        if (other.props != null) {
            other.props.forEach(overwrite
                    ? (key, value) -> getProperties().put(key, value)
                    : (key, value) -> getProperties().putIfAbsent(key, value));
        }

        if (other.mods != null) {
            other.mods.forEach(otherMod -> {
                final ModConfig thisMod = getMods().findByName(otherMod.getName());

                if (thisMod == null) {
                    getMods().add(otherMod);
                } else {
                    thisMod.merge(otherMod, false);
                }
            });
        }
    }

    public void merge(List<RunConfig> runs) {
        runs.stream().distinct().filter(run -> run != this).forEach(run -> merge(run, false));
    }

    public void mergeParents() {
        merge(getParents());
    }

    public void mergeChildren() {
        merge(getChildren());
    }

    public void setTokens(Map<String, String> tokens) {
        this.tokens = new HashMap<>(tokens);
    }

    public void token(String key, String value) {
        getTokens().put(key, value);
    }

    public void tokens(Map<String, String> tokens) {
        getTokens().putAll(tokens);
    }

    public Map<String, String> getTokens() {
        if (tokens == null) {
            tokens = new HashMap<>();
        }

        return tokens;
    }

    public void setLazyTokens(Map<String, Supplier<String>> lazyTokens) {
        this.lazyTokens = new HashMap<>(lazyTokens);
    }

    public void lazyToken(String key, Supplier<String> valueSupplier) {
        getLazyTokens().put(key, valueSupplier);
    }

    public void lazyToken(String key, Closure<String> closure) {
        lazyToken(key, closure::call);
    }

    public void lazyTokens(Map<String, Supplier<String>> lazyTokens) {
        getLazyTokens().putAll(lazyTokens);
    }

    public Map<String, Supplier<String>> getLazyTokens() {
        if (lazyTokens == null) {
            lazyTokens = new HashMap<>();
        }

        return lazyTokens;
    }

    public String replace(Map<String, ?> vars, String value) {
        if (value.length() <= 2 || value.indexOf('{') == -1) {
            return value;
        }

        return Utils.replaceTokens(vars, value);
    }

    public void client(boolean value) {
        setClient(value);
    }

    public void setClient(boolean value) {
        this.client = value;
    }

    public boolean isClient() {
        if (client == null) {
            boolean isTargetClient = getEnvironment().getOrDefault("target", "").contains("client") || getName().contains("client");

            client = isTargetClient || MCP_CLIENT_MAIN.equals(getMain()) || MC_CLIENT_MAIN.equals(getMain());
        }

        return client;
    }

    public List<SourceSet> getAllSources() {
        List<SourceSet> sources = getSources();

        getMods().stream().map(ModConfig::getSources).flatMap(Collection::stream).forEach(sources::add);

        sources = sources.stream().distinct().collect(Collectors.toList());

        if (sources.isEmpty()) {
            final JavaPluginExtension main = project.getExtensions().getByType(JavaPluginExtension.class);

            sources.add(main.getSourceSets().getByName(SourceSet.MAIN_SOURCE_SET_NAME));
        }

        return sources;
    }

    @Override
    public String toString() {
        return "RunConfig[project='" + project.getPath() + "', name='" + getName() + "']";
    }

} 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 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 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 class MCPRuntime {

    private static final Pattern OUTPUT_REPLACE_PATTERN = Pattern.compile("^\\{(\\w+)Output}$");

    final Project project;
    final MCPEnvironment environment;
    final File mcpDirectory;

    final File zipFile;

    final Map<String, Step> steps = new LinkedHashMap<>();
    Step currentStep;

    public MCPRuntime(Project project, File mcp_config, MCPConfigV2 config, String side,
            File mcpDirectory, Map<String, MCPFunction> extraPres) {
        this.project = project;
        this.environment = new MCPEnvironment(this, config.getVersion(), config.getJavaTarget(), side);
        this.mcpDirectory = mcpDirectory;

        this.zipFile = mcp_config;

        @SuppressWarnings("unchecked")
        Map<String, String> data = config.getData().entrySet().stream().collect(Collectors.toMap(Entry::getKey,
            e -> e.getValue() instanceof Map ? ((Map<String, String>)e.getValue()).get(side) : (String)e.getValue()
        ));

        List<MCPConfigV1.Step> steps = config.getSteps(side);
        if (steps.isEmpty())
            throw new IllegalArgumentException("Unknown side: " + side + " For Config: " + mcp_config);

        for (MCPConfigV1.Step step : steps) {
            if (step.getName().equals("decompile")) { //TODO: Clearly define decomp vs bin, needs MCPConfig spec bump.
                if (!extraPres.isEmpty()) {
                    String input = step.getValues().get("input"); //Decompile's input
                    String lastName = null;
                    for (Entry<String, MCPFunction> entry : extraPres.entrySet()) {
                        String name = entry.getKey();
                        Map<String, String> args = new HashMap<>();
                        args.put("input", input);
                        this.steps.put(name, new Step(name, entry.getValue(), args, new File(this.mcpDirectory, name), data));
                        input = "{" + name +"Output}";
                        lastName = name;
                    }
                    step.getValues().put("input", "{" + lastName + "Output}");
                }
            }

            @SuppressWarnings("deprecation")
            MCPFunction function = MCPFunctionFactory.createBuiltIn(step.getType(), config.spec);

            if (function == null) {
                MCPConfigV1.Function custom = config.getFunction(step.getType());
                if (custom == null)
                    throw new IllegalArgumentException("Invalid MCP Config, Unknown function step type: " + step.getType() + " File: " + mcp_config);

                File jar = MavenArtifactDownloader.manual(project, custom.getVersion(), false);
                if (jar == null || !jar.exists())
                    throw new IllegalArgumentException("Could not download MCP Config dependency: " + custom.getVersion());

                @SuppressWarnings("deprecation")
                MCPFunction tmp = MCPFunctionFactory.createExecute(jar, custom.getJvmArgs(), custom.getArgs());
                function = tmp;
            }

            File workingDir = new File(this.mcpDirectory, step.getName());
            this.steps.put(step.getName(), new Step(step.getName(), function, step.getValues(), workingDir, data));
        }
    }

    public File execute(Logger logger) throws Exception {
        return execute(logger, null);
    }

    public File executeUpTo(Logger logger, @Nullable String stop) throws Exception {
        String last = null;
        for(Step step : steps.values()) {
            if (step.name.equals(stop))
                break;
            last = step.name;
        }
        return execute(logger, last);
    }

    public File execute(Logger logger, @Nullable String stop) throws Exception {
        environment.logger = logger;

        logger.lifecycle("Setting up MCP environment");

        logger.lifecycle("Initializing steps");
        ZipFile zip = new ZipFile(zipFile);
        for (Step step : steps.values()) {
            logger.info(" > Initializing '" + step.name + "'");
            currentStep = step;
            step.initialize(zip);
        }
        zip.close();

        File ret = null;
        logger.lifecycle("Executing steps");
        for (Step step : steps.values()) {
            logger.lifecycle(" > Running '" + step.name + "'");
            currentStep = step;
            step.arguments.replaceAll((key, value) -> value instanceof String ? applyStepOutputSubstitutions((String)value) : value);
            ret = step.execute();

            if (stop != null && stop.equals(step.name)) {
                logger.lifecycle("Stopping at requested step: " + ret);
                return ret;
            }
        }

        logger.lifecycle("MCP environment setup is complete");
        return ret;
    }

    private Object applyStepOutputSubstitutions(String value) {
        Matcher matcher = OUTPUT_REPLACE_PATTERN.matcher(value);
        if (!matcher.find()) return value; // Not a replaceable string

        String stepName = matcher.group(1);
        if (stepName != null) {
            return environment.getStepOutput(stepName);
        }
        throw new IllegalStateException("The string '" + value + "' did not return a valid substitution match!");
    }

    class Step {

        private final String name;
        private final MCPFunction function;
        final Map<String, Object> arguments;
        final File workingDirectory;
        File output;

        private Step(String name, MCPFunction function, Map<String, String> arguments, File workingDirectory, Map<String, String> data) {
            this.name = name;
            this.function = function;
            this.arguments = new HashMap<>(arguments);
            this.workingDirectory = workingDirectory;
            function.loadData(data);
        }

        private void initialize(ZipFile zip) throws Exception {
            function.initialize(environment, zip);
        }

        private File execute() throws Exception {
            try {
                output = function.execute(environment);
            } finally {
                function.cleanup(environment);
            }
            return output;
        }

        boolean isOfType(Class<? extends MCPFunction> type) {
            return type.isAssignableFrom(function.getClass());
        }

    }
} public class MCPWrapper {
    private final String hash;
    private final File data;
    private final File root;
    private final MCPConfigV2 config;
    protected final Map<String, MCPRuntime> runtimes = Maps.newHashMap();

    public MCPWrapper(File data, File root) throws IOException {
        this(HashFunction.SHA1.hash(data), data, root);
    }

    public MCPWrapper(String hash, File data, File root) throws IOException {
        this.hash = hash;
        this.data = data;
        this.root = root;
        this.config = MCPConfigV2.getFromArchive(data);
    }

    public MCPRuntime getRuntime(Project project, String side) {
        MCPRuntime ret = runtimes.get(side);
        if (ret == null) {
            ret = new MCPRuntime(project, data, config, side, new File(root, side), Collections.emptyMap());
            runtimes.put(side, ret);
        }
        return ret;
    }

    public File getZip() {
        return data;
    }

    public String getHash() {
        return this.hash;
    }

    public MCPConfigV2 getConfig() {
        return this.config;
    }

    public File getRoot() {
        return this.root;
    }

    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 byte[] getData(String... path) throws IOException {
        String name = config.getData(path);
        if (name == null)
            throw new IOException("Unknown MCP Entry: " + Joiner.on("/").join(path));
        return Utils.getZipData(data, name);
    }
} public abstract class AccessTransformJar extends JarExec {
    public AccessTransformJar() {
        getTool().set(Utils.ACCESSTRANSFORMER);
        getArgs().addAll("--inJar", "{input}", "--outJar", "{output}", "--logFile", "accesstransform.log");
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        List<String> newArgs = replaceArgs(args, ImmutableMap.of(
                "{input}", getInput().get().getAsFile().getAbsolutePath(),
                "{output}", getOutput().get().getAsFile().getAbsolutePath()), null);
        getAccessTransformers().forEach(f -> {
            newArgs.add("--atFile");
            newArgs.add(f.getAbsolutePath());
        });
        return newArgs;
    }

    @InputFiles
    public abstract ConfigurableFileCollection getAccessTransformers();

    @InputFile
    public abstract RegularFileProperty getInput();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class ApplyMCPFunction extends JarExec {
    private final Map<String, Object> replacements = new HashMap<>();

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

    @TaskAction
    public void apply() throws IOException {
        File mcp = getMCP().get().getAsFile();
        MCPConfigV1 config = MCPConfigV2.getFromArchive(mcp);
        MCPConfigV1.Function function = config.getFunction(getFunctionName().get());

        getTool().set(function.getVersion());
        getArgs().set(function.getArgs());

        try (ZipFile zip = new ZipFile(mcp)) {
            function.getArgs().forEach(arg -> {
                // A token must start with {, end with }, and be at least 3 large ("{x}")
                if (arg.length() < 2 || !arg.startsWith("{") || !arg.endsWith("}")) return;
                String argName = arg.substring(1, arg.length() - 1);

                switch (argName) {
                    case "input":
                        replacements.put(arg, getInput().get().getAsFile().getAbsolutePath());
                        break;
                    case "output":
                        replacements.put(arg, getOutput().get().getAsFile().getAbsolutePath());
                        break;
                    case "log":
                        replacements.put(arg, getOutput().get().getAsFile().getAbsolutePath() + ".log");
                        break;
                    default:
                        Object referencedData = config.getData().get(argName);
                        if (referencedData instanceof String) {
                            ZipEntry entry = zip.getEntry((String) referencedData);
                            if (entry == null) return;
                            String entryName = entry.getName();

                            try {
                                File data = makeFile(entry.getName());
                                if (entry.isDirectory()) {
                                    Utils.extractDirectory(this::makeFile, zip, entryName);
                                } else {
                                    Utils.extractFile(zip, entry, data);
                                }
                                replacements.put(arg, data.getAbsolutePath());
                            } catch (IOException e) {
                                getLogger().debug("Exception while extracting referenced data for token {} in task {}", arg, getName(), e);
                            }
                        }
                        break;
                }
            });
        }

        super.apply();
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        return replaceArgs(args, replacements, null);
    }

    @InputFile
    public abstract RegularFileProperty getInput();

    @InputFile
    public abstract RegularFileProperty getMCP();

    @OutputFile
    public abstract RegularFileProperty getOutput();

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

    private File makeFile(String name) {
        return new File(getOutput().get().getAsFile().getParent(), name);
    }
} public class HackyJavaCompile extends JavaCompile {

    public void doHackyCompile() {

        // What follows is a horrible hack to allow us to call JavaCompile
        // from our dependency resolver.
        // As described in https://github.com/MinecraftForge/ForgeGradle/issues/550,
        // invoking Gradle tasks in the normal way can lead to deadlocks
        // when done from a dependency resolver.

        setCompiler();

        this.getOutputs().setPreviousOutputFiles(this.getProject().files());
        final DefaultJavaCompileSpec spec = reflectCreateSpec();
        spec.setSourceFiles(getSource());
        Compiler<JavaCompileSpec> compiler = createCompiler(spec);
        final WorkResult execute = compiler.execute(spec);
        setDidWork(execute.getDidWork());
    }

    private void setCompiler() {
        JavaToolchainService service = getProject().getExtensions().getByType(JavaToolchainService.class);
        Provider<JavaCompiler> compiler = service.compilerFor(s -> s.getLanguageVersion().set(JavaLanguageVersion.of(this.getSourceCompatibility())));
        this.getJavaCompiler().set(compiler);
    }

    private DefaultJavaCompileSpec reflectCreateSpec() {
        try {
            Method createSpec = JavaCompile.class.getDeclaredMethod("createSpec");
            createSpec.setAccessible(true);
            return (DefaultJavaCompileSpec) createSpec.invoke(this);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException("Could not find JavaCompile#createSpec method; might be on incompatible newer version of Gradle", e);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException("Exception while invoking JavaCompile#createSpec", e);
        }
    }

    @SuppressWarnings("unchecked")
    private Compiler<JavaCompileSpec> createCompiler(JavaCompileSpec spec) {
        try {
            Method createCompiler = JavaCompile.class.getDeclaredMethod("createCompiler");
            createCompiler.setAccessible(true);
            return (Compiler<JavaCompileSpec>) createCompiler.invoke(this);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException("Could not find JavaCompile#createCompiler method; might be on incompatible newer version of Gradle", e);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException("Exception while invoking JavaCompile#createCompiler", e);
        }
    }

} public abstract class RenameJar extends JarExec {
    public RenameJar() {
        getTool().set(Utils.SPECIALSOURCE);
        getArgs().addAll("--in-jar", "{input}", "--out-jar", "{output}", "--srg-in", "{mappings}");
    }

    protected List<String> filterArgs(List<String> args) {
        return replaceArgsMulti(args, ImmutableMap.of(
                "{input}", getInput().get().getAsFile(),
                "{output}", getOutput().get().getAsFile()
                ), ImmutableMultimap.<String, Object>builder()
                        .put("{mappings}", getMappings().get().getAsFile())
                        .putAll("{mappings}", getExtraMappings().getFiles()).build()
        );
    }

    // TODO: Make this a ConfigurableFileCollection? (then remove getExtraMappings())
    @InputFile
    public abstract RegularFileProperty getMappings();

    @Optional
    @InputFiles
    public abstract ConfigurableFileCollection getExtraMappings();

    @InputFile
    public abstract RegularFileProperty getInput();

    @OutputFile
    public abstract RegularFileProperty getOutput();
} public abstract class RenameJarInPlace extends JarExec {
    private final Provider<Directory> workDir = getProject().getLayout().getBuildDirectory().dir(getName());
    private final Provider<RegularFile> temp = workDir.map(s -> s.file("output.jar"));

    public RenameJarInPlace() {
        getTool().set(Utils.SPECIALSOURCE);
        getArgs().addAll("--in-jar", "{input}", "--out-jar", "{output}", "--srg-in", "{mappings}", "--live");
        this.getOutputs().upToDateWhen(task -> false);
    }

    @Override
    protected List<String> filterArgs(List<String> args) {
        return replaceArgsMulti(args, ImmutableMap.of(
                "{input}", getInput().get().getAsFile(),
                "{output}", temp.get().getAsFile()
                ), ImmutableMultimap.<String, Object>builder()
                        .put("{mappings}", getMappings().get().getAsFile())
                        .putAll("{mappings}", getExtraMappings().getFiles()).build()
        );
    }

    @Override
    @TaskAction
    public void apply() throws IOException {
        File temp = this.temp.get().getAsFile();
        if (temp.getParentFile() != null && !temp.getParentFile().exists() && !temp.getParentFile().mkdirs()) {
            getProject().getLogger().warn("Could not create parent directories for temp dir '{}'", temp.getAbsolutePath());
        }

        super.apply();

        FileUtils.copyFile(temp, getInput().get().getAsFile());
    }

    // TODO: Make this a ConfigurableFileCollection? (then remove getExtraMappings())
    @InputFile
    public abstract RegularFileProperty getMappings();

    @Optional
    @InputFiles
    public abstract ConfigurableFileCollection getExtraMappings();

    @InputFile
    public abstract RegularFileProperty getInput();
}
import com.google.common.collect.ImmutableList;
import net.minecraftforge.artifactural.api.artifact.ArtifactIdentifier;
import net.minecraftforge.artifactural.api.repository.Repository;
import net.minecraftforge.artifactural.base.repository.ArtifactProviderBuilder;
import net.minecraftforge.artifactural.base.repository.SimpleRepository;
import net.minecraftforge.gradle.common.config.Config;
import net.minecraftforge.gradle.common.config.MCPConfigV2;
import net.minecraftforge.gradle.common.config.UserdevConfigV1;
import net.minecraftforge.gradle.common.config.UserdevConfigV2;
import net.minecraftforge.gradle.common.config.UserdevConfigV2.DataFunction;
import net.minecraftforge.gradle.common.tasks.ApplyBinPatches;
import net.minecraftforge.gradle.common.tasks.DownloadAssets;
import net.minecraftforge.gradle.common.tasks.DynamicJarExec;
import net.minecraftforge.gradle.common.tasks.ExtractMCPData;
import net.minecraftforge.gradle.common.tasks.ExtractNatives;
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.HashFunction;
import net.minecraftforge.gradle.common.util.HashStore;
import net.minecraftforge.gradle.common.util.MavenArtifactDownloader;
import net.minecraftforge.gradle.common.util.McpNames;
import net.minecraftforge.gradle.common.util.POMBuilder;
import net.minecraftforge.gradle.common.util.RunConfig;
import net.minecraftforge.gradle.common.util.Utils;
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.GenerateSRG;
import net.minecraftforge.gradle.mcp.util.MCPRuntime;
import net.minecraftforge.gradle.mcp.util.MCPWrapper;
import net.minecraftforge.gradle.userdev.tasks.AccessTransformJar;
import net.minecraftforge.gradle.userdev.tasks.ApplyMCPFunction;
import net.minecraftforge.gradle.userdev.tasks.HackyJavaCompile;
import net.minecraftforge.gradle.userdev.tasks.RenameJar;
import net.minecraftforge.gradle.userdev.tasks.RenameJarInPlace;
import net.minecraftforge.srgutils.IMappingFile;
import net.minecraftforge.srgutils.IMappingFile.IField;
import net.minecraftforge.srgutils.IMappingFile.IMethod;
import net.minecraftforge.srgutils.IRenamer;
import net.minecraftforge.srgutils.MinecraftVersion;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.ExternalModuleDependency;
import org.gradle.api.logging.LogLevel;
import org.gradle.api.plugins.ExtraPropertiesExtension;
import codechicken.diffpatch.cli.CliOperation;
import codechicken.diffpatch.cli.PatchOperation;
import codechicken.diffpatch.util.LoggingOutputStream;
import codechicken.diffpatch.util.PatchMode;
import codechicken.diffpatch.util.archiver.ArchiveFormat;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.provider.Property;
import org.gradle.jvm.toolchain.JavaLanguageVersion;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import javax.annotation.Nullable;
                return null;

            debug("    Injecting MCP Inject binairies");
            File injected = cacheRaw("injected", "jar");
            //Combine mci, and our recompiled MCP injected classes.
            try (ZipInputStream zmci = new ZipInputStream(new FileInputStream(mcinject));
                 ZipOutputStream zout = new ZipOutputStream(new FileOutputStream(injected))) {
                ZipEntry entry = null;
                while ((entry = zmci.getNextEntry()) != null) {
                    zout.putNextEntry(Utils.getStableEntry(entry.getName()));
                    IOUtils.copy(zmci, zout);
                    zout.closeEntry();
                }
                Files.walkFileTree(compiled.toPath(), new SimpleFileVisitor<Path>() {
                    @Override
                    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                        try (InputStream fin = Files.newInputStream(file)) {
                            zout.putNextEntry(Utils.getStableEntry(compiled.toPath().relativize(file).toString().replace('\\', '/')));
                            IOUtils.copy(fin, zout);
                            zout.closeEntry();
                        }
                        return FileVisitResult.CONTINUE;
                    }
                });
            }

            if (hasAts) {
                if (bin.exists()) bin.delete(); // AT lib throws an exception if output file already exists

                debug("    Applying Access Transformer");
                AccessTransformJar at = createTask("atJar", AccessTransformJar.class);
                at.getInput().set(injected);
                at.getOutput().set(bin);
                at.getAccessTransformers().from(ATS);

                if (baseAT.length() != 0) {
                    File parentAT = project.file("build/" + at.getName() + "/parent_at.cfg");
                    if (!parentAT.getParentFile().exists())
                        parentAT.getParentFile().mkdirs();
                    Files.write(parentAT.toPath(), baseAT.toString().getBytes(StandardCharsets.UTF_8));
                    at.getAccessTransformers().from(parentAT);
                }

                at.apply();
            }

            debug("    Renaming/Fixing " + (hasAts ? "ATed" : "injected") + " jar");
            JarExec rename = createTask("renameJar", JarExec.class);
            rename.setHasLog(false);
            rename.getTool().set(Utils.FART);
            rename.getArgs().empty();
            rename.getArgs().addAll("--input", (hasAts ? bin : injected).getAbsolutePath());
            if (!hasAts)
                rename.getArgs().addAll("--output", bin.getAbsolutePath());
            if (mapping != null)
                rename.getArgs().addAll("--map", findSrgToMcp(mapping, names).getAbsolutePath());
            rename.getArgs().add("--src-fix"); // Set SourceFile attribute so IDEs will link decomped code on first pass, Line numbers will be screwy, but that's a todo.
            rename.apply();

            debug("    Finished: " + bin);
            Utils.updateHash(bin, HashFunction.SHA1);
            cache.save();
        }
        return bin;
    }

    @Nullable
    private File findBinpatched(final Set<String> packages) throws IOException {
        boolean notch = parent != null && parent.getConfigV2() != null && parent.getConfigV2().getNotchObf();

        String desc = "net.minecraft:" + (isPatcher ? "joined" : NAME) + ":" + (notch ? mcp.getMCVersion() : mcp.getVersion() + ":srg");
        File clean = MavenArtifactDownloader.generate(project, desc, true);

        if (clean == null || !clean.exists()) {
            debug("  Failed to find MC Vanilla Base: " + desc);
            project.getLogger().error("MinecraftUserRepo: Failed to get Minecraft Vanilla Base. Should not be possible. " + desc);
            return null;
        }
        debug("    Vanilla Base: " + clean);

        File obf2Srg = null;
        try (ZipFile tmp = new ZipFile(clean)) {
            if (notch) {
                obf2Srg = findObfToSrg(IMappingFile.Format.TSRG);
                if (obf2Srg == null) {
                    debug("  Failed to find obf to mcp mapping file. " + mcp.getVersion());
                    project.getLogger().error("MinecraftUserRepo: Failed to find obf to mcp mapping file. Should not be possible. " + mcp.getVersion());
                    return null;
                }

                Set<String> vanillaClasses = tmp.stream()
                .map(ZipEntry::getName)
                .filter(e -> e.endsWith(".class"))
                .map(e -> e.substring(0, e.length() - 6))
                .collect(Collectors.toSet());

                IMappingFile o2s = IMappingFile.load(obf2Srg);
                o2s.getClasses().stream()
                .filter(e -> vanillaClasses.contains(e.getOriginal()))
                .map(IMappingFile.INode::getMapped)
                .map(e -> e.indexOf('/') == -1 ? "" : e.substring(0, e.lastIndexOf('/')))
                .forEach(packages::add);

            } else {
                //Gather vanilla packages, so we can only inject the proper package-info classes.
                tmp.stream()
                .map(ZipEntry::getName)
                .filter(e -> e.endsWith(".class"))
                .map(e -> e.indexOf('/') == -1 ? "" : e.substring(0, e.lastIndexOf('/')))
                .forEach(packages::add);
            }
        }

        if (parent == null) { //Raw minecraft
            return clean;
        } else { // Needs binpatches
            File binpatched = cacheRaw("binpatched", "jar");

            debug("    Creating Binpatches");
            //Apply bin patches to vanilla
            ApplyBinPatches apply = createTask("applyBinpatches", ApplyBinPatches.class);