// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0

package cc.tweaked.web.builder;

import org.jspecify.annotations.Nullable;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.commons.ClassRemapper;
import org.objectweb.asm.commons.Remapper;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.Stream;

/**
 * A class loader which can {@linkplain #addTransformer(BiFunction) transform} and {@linkplain #remapClass(String, String)
 * remap/rename} classes.
 * <p>
 * When loading classes, this behaves much like {@link java.net.URLClassLoader}, loading files from a list of paths.
 * However, when the TeaVM compiler requests the class bytes for compilation, we run the class through a list of
 * transformers first, patching the classes to function in a Javascript environment.
 */
public class TransformingClassLoader extends ClassLoader {
    private final Map<String, String> remappedClasses = new HashMap<>();
    private final Map<String, Path> remappedResources = new HashMap<>();
    private final List<BiFunction<String, ClassVisitor, ClassVisitor>> transformers = new ArrayList<>();

    private final Remapper remapper = new Remapper(Opcodes.ASM9) {
        @Override
        public String map(String internalName) {
            return remappedClasses.getOrDefault(internalName, internalName);
        }
    };

    private final List<Path> classpath;

    // Cache of the last transformed file - TeaVM tends to call getResourceAsStream multiple times.
    private @Nullable TransformedClass lastFile;

    public TransformingClassLoader(List<Path> classpath) {
        this.classpath = classpath;

        addTransformer((name, cv) -> new ClassRemapper(cv, remapper));
    }

    public void addTransformer(BiFunction<String, ClassVisitor, ClassVisitor> transform) {
        transformers.add(transform);
    }

    public void remapClass(String from, String to, Path fromLocation) {
        remappedClasses.put(from, to);
        remappedResources.put(to + ".class", fromLocation);
    }

    public void remapClass(String from, String to) {
        remappedClasses.put(from, to);
    }

    private @Nullable Path findUnmappedFile(String name) {
        // For some odd reason, we try to resolve the generated lambda classes. This includes classes called
        // things like <linit>lambda, which is an invalid path on Windows. Detect those, and abort early.
        if (name.indexOf("<") >= 0) return null;

        return classpath.stream().map(x -> x.resolve(name)).filter(Files::exists).findFirst().orElse(null);
    }

    private @Nullable Path findFile(String name) {
        var path = remappedResources.get(name);
        return path != null ? path : findUnmappedFile(name);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // findClass is only called at compile time, and so we load the original class, not the remapped one.
        // Yes, this is super cursed.
        var path = findUnmappedFile(name.replace('.', '/') + ".class");
        if (path == null) throw new ClassNotFoundException();

        byte[] bytes;
        try {
            bytes = Files.readAllBytes(path);
        } catch (IOException e) {
            throw new ClassNotFoundException("Failed reading " + name, e);
        }

        return defineClass(name, bytes, 0, bytes.length);
    }

    @Override
    public @Nullable InputStream getResourceAsStream(String name) {
        if (!name.endsWith(".class") || name.startsWith("java/") || name.startsWith("javax/")) {
            return super.getResourceAsStream(name);
        }

        var lastFile = this.lastFile;
        if (lastFile != null && lastFile.name().equals(name)) return new ByteArrayInputStream(lastFile.contents());

        var path = findFile(name);
        if (path == null) {
            System.out.printf("Cannot find %s. Falling back to system class loader.\n", name);
            return super.getResourceAsStream(name);
        }

        var bytes = getTransformed(path);
        this.lastFile = new TransformedClass(name, bytes);
        return new ByteArrayInputStream(bytes);
    }

    private byte[] getTransformed(Path path) {
        ClassReader reader;
        try (var stream = Files.newInputStream(path)) {
            reader = new ClassReader(stream);
        } catch (IOException e) {
            throw new UncheckedIOException("Failed reading " + path, e);
        }

        var writer = new ClassWriter(reader, 0);
        var className = reader.getClassName();
        ClassVisitor sink = writer;
        for (var transformer : transformers) sink = transformer.apply(className, sink);
        reader.accept(sink, 0);

        return writer.toByteArray();
    }

    @Override
    protected @Nullable URL findResource(String name) {
        var path = findFile(name);
        return path == null ? null : toURL(path);
    }

    @Override
    protected Enumeration<URL> findResources(String name) {
        var path = remappedResources.get(name);
        return new IteratorEnumeration<>(
            (path == null ? classpath.stream().map(x -> x.resolve(name)) : Stream.of(path))
                .filter(Files::exists)
                .map(this::toURL)
                .iterator()
        );
    }

    @SuppressWarnings("JdkObsolete")
    private record IteratorEnumeration<T>(Iterator<T> iterator) implements Enumeration<T> {
        @Override
        public boolean hasMoreElements() {
            return iterator.hasNext();
        }

        @Override
        public T nextElement() {
            return iterator.next();
        }
    }

    private URL toURL(Path path) {
        try {
            var url = path.toUri().toURL();

            if (!path.toString().endsWith(".class")) return url;
            return new URL(url.getProtocol(), url.getHost(), url.getPort(), url.getFile(), new TransformingHandler(path));
        } catch (MalformedURLException e) {
            throw new IllegalStateException("Cannot convert " + path + " to a URL", e);
        }
    }

    @SuppressWarnings("ArrayRecordComponent")
    private record TransformedClass(String name, byte[] contents) {
    }

    private final class TransformingHandler extends URLStreamHandler {
        private final Path path;

        private TransformingHandler(Path path) {
            this.path = path;
        }

        @Override
        protected URLConnection openConnection(URL u) {
            return new URLConnection(u) {
                private @Nullable InputStream stream;

                @Override
                public void connect() {
                    if (stream == null) stream = new ByteArrayInputStream(getTransformed(path));
                }

                @Override
                public InputStream getInputStream() {
                    connect();
                    if (stream == null) throw new NullPointerException("Stream cannot be null");
                    return stream;
                }
            };
        }
    }
}
