package io.papermc.paper.pluginremap;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.mojang.logging.LogUtils;
import io.papermc.paper.util.Hashing;
import io.papermc.paper.util.MappingEnvironment;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.framework.qual.DefaultQualifier;
import org.slf4j.Logger;
import org.spongepowered.configurate.loader.AtomicFiles;

@DefaultQualifier(NonNull.class)
class RemappedPluginIndex {
    private static final Logger LOGGER = LogUtils.getLogger();
    private static final Gson GSON = new GsonBuilder()
        .setPrettyPrinting()
        .create();
    private static final String INDEX_FILE_NAME = "index.json";

    protected final State state;
    private final Path dir;
    private final Path indexFile;
    private final boolean handleDuplicateFileNames;

    // todo maybe hash remapped variants to ensure they haven't changed? probably unneeded
    static final class State {
        final Map<String, String> hashes = new HashMap<>();
        final Set<String> skippedHashes = new HashSet<>();
        private final String mappingsHash = MappingEnvironment.mappingsHash();
    }

    RemappedPluginIndex(final Path dir, final boolean handleDuplicateFileNames) {
        this.dir = dir;
        this.handleDuplicateFileNames = handleDuplicateFileNames;
        if (!Files.exists(this.dir)) {
            try {
                Files.createDirectories(this.dir);
            } catch (final IOException ex) {
                throw new UncheckedIOException(ex);
            }
        }

        this.indexFile = dir.resolve(INDEX_FILE_NAME);
        if (Files.isRegularFile(this.indexFile)) {
            this.state = this.readIndex();
        } else {
            this.state = new State();
        }
    }

    private State readIndex() {
        final State state;
        try (final BufferedReader reader = Files.newBufferedReader(this.indexFile)) {
            state = GSON.fromJson(reader, State.class);
        } catch (final Exception ex) {
            throw new RuntimeException("Failed to read index file '" + this.indexFile + "'", ex);
        }

        // If mappings have changed, delete all cached files and create a new index
        if (!state.mappingsHash.equals(MappingEnvironment.mappingsHash())) {
            for (final String fileName : state.hashes.values()) {
                final Path path = this.dir.resolve(fileName);
                try  {
                    Files.deleteIfExists(path);
                } catch (final IOException ex) {
                    throw new UncheckedIOException("Failed to delete no longer needed file '" + path + "'", ex);
                }
            }
            return new State();
        }
        return state;
    }

    Path dir() {
        return this.dir;
    }

    /**
     * Returns a list of cached paths if all of the input paths are present in the cache.
     * The returned list may contain paths from different directories.
     *
     * @param paths plugin jar paths to check
     * @return null if any of the paths are not present in the cache, otherwise a list of the cached paths
     */
    @Nullable List<Path> getAllIfPresent(final List<Path> paths) {
        final Map<Path, String> hashCache = new HashMap<>();
        final Function<Path, String> inputFileHash = path -> hashCache.computeIfAbsent(path, Hashing::sha256);

        // Delete cached entries we no longer need
        final Iterator<Map.Entry<String, String>> iterator = this.state.hashes.entrySet().iterator();
        while (iterator.hasNext()) {
            final Map.Entry<String, String> entry = iterator.next();
            final String inputHash = entry.getKey();
            final String fileName = entry.getValue();
            if (paths.stream().anyMatch(path -> inputFileHash.apply(path).equals(inputHash))) {
                // Hash is used, keep it
                continue;
            }

            iterator.remove();
            final Path filePath = this.dir.resolve(fileName);
            try {
                Files.deleteIfExists(filePath);
            } catch (final IOException ex) {
                throw new UncheckedIOException("Failed to delete no longer needed file '" + filePath + "'", ex);
            }
        }

        // Also clear hashes of skipped files
        this.state.skippedHashes.removeIf(hash -> paths.stream().noneMatch(path -> inputFileHash.apply(path).equals(hash)));

        final List<Path> ret = new ArrayList<>();
        for (final Path path : paths) {
            final String inputHash = inputFileHash.apply(path);
            if (this.state.skippedHashes.contains(inputHash)) {
                // Add the original path
                ret.add(path);
                continue;
            }

            final @Nullable Path cached = this.getIfPresent(inputHash);
            if (cached == null) {
                // Missing the remapped file
                return null;
            }
            ret.add(cached);
        }
        return ret;
    }

    private String createCachedFileName(final Path in) {
        if (this.handleDuplicateFileNames) {
            final String fileName = in.getFileName().toString();
            final int i = fileName.lastIndexOf(".jar");
            return fileName.substring(0, i) + "-" + System.currentTimeMillis() + ".jar";
        }
        return in.getFileName().toString();
    }

    /**
     * Returns the given path if the file was previously skipped for being remapped, otherwise the cached path or null.
     *
     * @param in input file
     * @return {@code in} if already remapped, the cached path if present, otherwise null
     */
    @Nullable Path getIfPresent(final Path in) {
        final String inHash = Hashing.sha256(in);
        if (this.state.skippedHashes.contains(inHash)) {
            return in;
        }
        return this.getIfPresent(inHash);
    }

    /**
     * Returns the cached path if a remapped file is present for the given hash, otherwise null.
     *
     * @param inHash hash of the input file
     * @return the cached path if present, otherwise null
     * @see #getIfPresent(Path)
     */
    protected @Nullable Path getIfPresent(final String inHash) {
        final @Nullable String fileName = this.state.hashes.get(inHash);
        if (fileName == null) {
            return null;
        }

        final Path path = this.dir.resolve(fileName);
        if (Files.exists(path)) {
            return path;
        }
        return null;
    }

    Path input(final Path in) {
        return this.input(in, Hashing.sha256(in));
    }

    /**
     * Marks the given file as skipped for remapping.
     *
     * @param in input file
     */
    void skip(final Path in) {
        this.state.skippedHashes.add(Hashing.sha256(in));
    }

    protected Path input(final Path in, final String hashString) {
        final String name = this.createCachedFileName(in);
        this.state.hashes.put(hashString, name);
        return this.dir.resolve(name);
    }

    void write() {
        try (final BufferedWriter writer = AtomicFiles.atomicBufferedWriter(this.indexFile, StandardCharsets.UTF_8)) {
            GSON.toJson(this.state, writer);
        } catch (final IOException ex) {
            LOGGER.warn("Failed to write index file '{}'", this.indexFile, ex);
        }
    }
}
