package de.geolykt.starplane;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;

public class JarStripper {

    public static class MavenId {
        @NotNull
        private final String group, artifact, version;
        public MavenId(@NotNull String g, @NotNull String a, @NotNull String v) {
            this.group = g;
            this.artifact = a;
            this.version = v;
        }

        @Override
        public int hashCode() {
            return this.group.hashCode() ^ this.artifact.hashCode() ^ this.version.hashCode();
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof MavenId) {
                MavenId other = (MavenId) obj;
                return this.group.equals(other.group)
                        && this.artifact.equals(other.artifact)
                        && this.version.equals(other.version);
            }
            return false;
        }

        @NotNull
        public String toGAVNotation() {
            return this.group + ":" + this.artifact + ":" + this.version;
        }
    }

    /**
     * Resolves the maven dependencies that are directly shaded into a jar.
     * This may include the jar itself.
     * This method is highly flawed by many aspects, especially because it relies on
     * files that are usually generated by maven to be present in the jar.
     * It also does not check whether the artifacts are actually included in the jar.
     * It may also include dependencies that are shaded in into another dependency that was ultimately
     * shaded in the jar.
     *
     * @param rawJarStream The {@link InputStream} which contains the jar's byte content.
     * @return The maven IDs that are directly shade-included in the jar.
     * @throws IOException If something unexpected occurs while reading the jar
     */
    @NotNull
    @Contract(pure = true, mutates = "null -> fail; !null -> new")
    public Set<MavenId> getShadedDependencies(InputStream rawJarStream) throws IOException {
        Set<MavenId> out = new HashSet<>();

        try (JarInputStream jarIn = new JarInputStream(rawJarStream, true)) {
            for (JarEntry entry = jarIn.getNextJarEntry(); entry != null; entry = jarIn.getNextJarEntry()) {
                if (!entry.getName().endsWith(".properties") || !entry.getName().startsWith("META-INF/maven")) {
                    continue;
                }
                Properties properties = new Properties();
                properties.load(jarIn);
                String groupId = properties.getProperty("groupId");
                String artifactId = properties.getProperty("artifactId");
                String version = properties.getProperty("version");
                if (groupId == null || artifactId == null || version == null) {
                    throw new IOException("File is incomplete: " + entry.getName());
                }
                out.add(new MavenId(groupId, artifactId, version));
            }
        }

        return out;
    }

    @NotNull
    public Set<String> aggregate(@NotNull Path cache, @NotNull Set<MavenId> artifacts) throws IOException {
        Set<String> blacklistedMembers = new HashSet<>();
        blacklistedMembers.remove("META-INF/MANIFEST.MF");
        for (MavenId dep : artifacts) {
            Path negativeCachePath = cache.resolve(dep.group.replace('.', '/')).resolve(dep.artifact).resolve(dep.artifact + "-" + dep.version + ".jar.error");
            if (Files.exists(negativeCachePath)) {
                continue;
            }
            Path relativePath = Paths.get(dep.group.replace('.', '/')).resolve(dep.artifact).resolve(dep.artifact + "-" + dep.version + ".jar");
            Path cachePath = cache.resolve(relativePath);
            if (Files.notExists(cachePath)) {
                Files.createDirectories(cachePath.getParent());
                try {
                    Files.copy(URI.create("https://repo1.maven.org/maven2/" + relativePath.toString()).toURL().openStream(), cachePath);
                } catch (IOException e) {
                    Files.createFile(negativeCachePath);
                    continue;
                }
            }
            try (ZipInputStream zipIn = new ZipInputStream(Files.newInputStream(cache))) {
                for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn.getNextEntry()) {
                    String name = entry.getName();
                    if (name.codePointAt(0) == '/') {
                        name = name.substring(0);
                    }
                    blacklistedMembers.add(name);
                }
            } catch (IOException e) {
                throw new IOException("Cannot read dependency " + cache.toAbsolutePath().toString(), e);
            }
        }
        return blacklistedMembers;
    }

    public void createStrippedJar(@NotNull Path source, @NotNull Path target, @NotNull Collection<String> removePaths) throws IOException {
        try (JarInputStream in = new JarInputStream(Files.newInputStream(source))) {
            try (OutputStream rawOut = Files.newOutputStream(target)) {
                Manifest man = in.getManifest();
                try (JarOutputStream out = man == null ? new JarOutputStream(rawOut) : new JarOutputStream(rawOut, man)) {
                    byte[] buffer = new byte[8096];
                    for (JarEntry entry = in.getNextJarEntry(); entry != null; entry = in.getNextJarEntry()) {
                        if (removePaths.contains(entry.getName())) {
                            continue;
                        }
                        out.putNextEntry(entry);
                        for (int readBytes = in.read(buffer); readBytes != -1; readBytes = in.read(buffer)) {
                            out.write(buffer, 0, readBytes);
                        }
                    }
                }
            }
        }
    }
}
