/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.bcel.util;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.bcel.AbstractTest;
import org.apache.bcel.HelloWorldCreator;
import org.apache.bcel.classfile.JavaClass;
import org.apache.bcel.classfile.Utility;
import org.apache.bcel.generic.BinaryOpCreator;
import org.apache.commons.lang3.SystemProperties;
import org.apache.commons.lang3.SystemUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledForJreRange;
import org.junit.jupiter.api.condition.JRE;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class BCELifierTest extends AbstractTest {

    private static Pattern CANON1 = Pattern.compile("#\\d+"); // numbers may vary in length
    private static Pattern CANON2 = Pattern.compile(" +"); // collapse spaces
    private static Pattern CANON3 = Pattern.compile("//.+");

    private static final String EOL = System.lineSeparator();
    public static final String CLASSPATH = "." + File.pathSeparator + SystemProperties.getJavaClassPath();

    // Canonicalise the javap output so it compares better
    private String canonHashRef(String input) {
        input = CANON1.matcher(input).replaceAll("#n");
        input = CANON2.matcher(input).replaceAll(" ");
        return CANON3.matcher(input).replaceAll("");
    }

    private String exec(final File workDir, final String... args) throws Exception {
        // System.out.print(workDir + ": ");
        // Stream.of(args).forEach(e -> System.out.print(e + " "));
        // System.out.println();
        final ProcessBuilder pb = new ProcessBuilder(args);
        pb.directory(workDir);
        pb.redirectErrorStream(true);
        final Process proc = pb.start();
        try (BufferedInputStream is = new BufferedInputStream(proc.getInputStream())) {
            final byte[] buff = new byte[2048];
            int len;

            final StringBuilder sb = new StringBuilder();
            while ((len = is.read(buff)) != -1) {
                sb.append(new String(buff, 0, len));
            }
            final String output = sb.toString();
            assertEquals(0, proc.waitFor(), output);
            return output;
        }
    }

    private String getAppJava() {
        return getJavaHomeApp("java");
    }

    private String getAppJavaC() {
        return getJavaHomeApp("javac");
    }

    private String getAppJavaP() {
        return getJavaHomeApp("javap");
    }

    private String getJavaHomeApp(final String app) {
        final Path path = getJavaHomeBinPath().resolve(app);
        if (Files.exists(path)) {
            return path.toAbsolutePath().toString();
        }
        if (SystemUtils.IS_OS_WINDOWS && Files.exists(getJavaHomeBinPath().resolve(app + ".exe"))) {
            return path.toAbsolutePath().toString();
        }
        return app;
    }

    private Path getJavaHomeBinPath() {
        return SystemUtils.getJavaHomePath().resolve("bin");
    }

    @ParameterizedTest
    @ValueSource(strings = {
    // @formatter:off
        "iadd 3 2 = 5",
        "isub 3 2 = 1",
        "imul 3 2 = 6",
        "idiv 3 2 = 1",
        "irem 3 2 = 1",
        "iand 3 2 = 2",
        "ior 3 2 = 3",
        "ixor 3 2 = 1",
        "ishl 4 1 = 8",
        "ishr 4 1 = 2",
        "iushr 4 1 = 2",
        "ladd 3 2 = 5",
        "lsub 3 2 = 1",
        "lmul 3 2 = 6",
        "ldiv 3 2 = 1",
        "lrem 3 2 = 1",
        "land 3 2 = 2",
        "lor 3 2 = 3",
        "lxor 3 2 = 1",
        "lshl 4 1 = 8",
        "lshr 4 1 = 2",
        "lushr 4 1 = 2",
        "fadd 3 2 = 5.0",
        "fsub 3 2 = 1.0",
        "fmul 3 2 = 6.0",
        "fdiv 3 2 = 1.5",
        "frem 3 2 = 1.0",
        "dadd 3 2 = 5.0",
        "dsub 3 2 = 1.0",
        "dmul 3 2 = 6.0",
        "ddiv 3 2 = 1.5",
        "drem 3 2 = 1.0"
    // @formatter:on
    })
    void testBinaryOp(final String exp) throws Exception {
        BinaryOpCreator.main(new String[] {});
        final File workDir = new File("target");
        final Pattern pattern = Pattern.compile("([a-z]{3,5}) ([-+]?\\d*\\.?\\d+) ([-+]?\\d*\\.?\\d+) = ([-+]?\\d*\\.?\\d+)");
        final Matcher matcher = pattern.matcher(exp);
        assertTrue(matcher.matches());
        final String op = matcher.group(1);
        final String a = matcher.group(2);
        final String b = matcher.group(3);
        final String expected = matcher.group(4);
        final String javaAgent = getJavaAgent();
        if (javaAgent == null) {
            assertEquals(expected + EOL, exec(workDir, getAppJava(), "-cp", CLASSPATH, "org.apache.bcel.generic.BinaryOp", op, a, b));
        } else {
            final String runtimeExecJavaAgent = javaAgent.replace("jacoco.exec", "jacoco_org.apache.bcel.generic.BinaryOp.exec");
            assertEquals(expected + EOL, exec(workDir, getAppJava(), runtimeExecJavaAgent, "-cp", CLASSPATH, "org.apache.bcel.generic.BinaryOp", op, a, b));
        }
    }

    private void testClassOnPath(final String javaClassFileName) throws Exception {
        final File workDir = new File("target", getClass().getSimpleName());
        Files.createDirectories(workDir.getParentFile().toPath());
        final File inFile = new File(javaClassFileName);
        final JavaClass javaClass = BCELifier.getJavaClass(inFile.getName().replace(JavaClass.EXTENSION, ""));
        assertNotNull(javaClass);
        // Gets javap output of the input class
        // System.out.println(exec(null, getJavaP(), "-version"));
        final String javaClassName = javaClass.getClassName();
        final String javapOutInital = exec(null, getAppJavaP(), "-cp", CLASSPATH, "-p", "-c", javaClassName);
        final String outCreatorFileName = javaClass.getSourceFilePath().replace(".java", "Creator.java");
        final File outCreatorFile = new File(workDir, outCreatorFileName);
        Files.createDirectories(outCreatorFile.getParentFile().toPath());
        final String javaAgent = getJavaAgent();
        final String bcelifierClassName = BCELifier.class.getName();
        final String creatorJavaSource;
        // Creates the BCEL Java app source in creatorJavaSource
        if (javaAgent == null) {
            creatorJavaSource = exec(workDir, getAppJava(), "-cp", CLASSPATH, bcelifierClassName, javaClassName);
        } else {
            final String runtimeExecJavaAgent = javaAgent.replace("jacoco.exec", "jacoco_" + inFile.getName() + ".exec");
            creatorJavaSource = exec(workDir, getAppJava(), runtimeExecJavaAgent, "-cp", CLASSPATH, bcelifierClassName, javaClassName);
        }
        // Write the BCEL Java app source to a file
        Files.write(outCreatorFile.toPath(), creatorJavaSource.getBytes(StandardCharsets.UTF_8));
        // Compiles the BCEL Java app
        assertEquals("", exec(workDir, getAppJavaC(), "-cp", CLASSPATH, outCreatorFileName.toString()));
        final String creatorClassName = javaClassName + "Creator";
        // Runs the BCEL Java app to create a class file
        if (javaAgent == null) {
            assertEquals("", exec(workDir, getAppJava(), "-cp", CLASSPATH, creatorClassName));
        } else {
            final String runtimeExecJavaAgent = javaAgent.replace("jacoco.exec", "jacoco_" + Utility.pathToPackage(outCreatorFileName) + ".exec");
            assertEquals("", exec(workDir, getAppJava(), runtimeExecJavaAgent, "-cp", CLASSPATH, creatorClassName));
        }
        // Runs javap on the BCEL generated class file
        final String javapOutput = exec(workDir, getAppJavaP(), "-p", "-c", inFile.getName());
        // Finally compares the output of the roundtrip
        assertEquals(canonHashRef(javapOutInital), canonHashRef(javapOutput));
    }

    @Test
    void testHelloWorld() throws Exception {
        HelloWorldCreator.main(new String[] {});
        final File workDir = new File("target");
        final String javaAgent = getJavaAgent();
        if (javaAgent == null) {
            assertEquals("Hello World!" + EOL, exec(workDir, getAppJava(), "-cp", CLASSPATH, "org.apache.bcel.HelloWorld"));
        } else {
            final String runtimeExecJavaAgent = javaAgent.replace("jacoco.exec", "jacoco_org.apache.bcel.HelloWorld.exec");
            assertEquals("Hello World!" + EOL, exec(workDir, getAppJava(), runtimeExecJavaAgent, "-cp", CLASSPATH, "org.apache.bcel.HelloWorld"));
        }
    }

    /*
     * Dumps a class using "javap" and compare with the same class recreated using BCELifier, "javac", "java" and dumped with "javap".
     *
     * TODO: detect if JDK present and skip test if not
     */
    @ParameterizedTest
    @ValueSource(strings = {
    // @formatter:off
        "org.apache.commons.lang.math.Fraction.class",
        "org.apache.commons.lang.exception.NestableDelegate.class",
        "org.apache.commons.lang.builder.CompareToBuilder.class",
        "org.apache.commons.lang.builder.ToStringBuilder.class",
        "org.apache.commons.lang.SerializationUtils.class",
        "org.apache.commons.lang.ArrayUtils.class",
        "target/test-classes/Java4Example.class"
    // @formatter:on
    })
    void testJavapCompare(final String pathToClass) throws Exception {
        testClassOnPath(pathToClass);
    }

    /*
     * See https://issues.apache.org/jira/browse/BCEL-378
     */
    @ParameterizedTest
    @ValueSource(strings = {
    // @formatter:off
        "target/test-classes/Java8Example.class",
        "target/test-classes/Java8Example2.class",
    // @formatter:on
    })
    @DisabledForJreRange(min = JRE.JAVA_25)
    void testJavapCompareJava25KnownBroken(final String pathToClass) throws Exception {
        testClassOnPath(pathToClass);
    }

    @Test
    void testMainNoArg() throws Exception {
        final PrintStream sysout = System.out;
        try {
            final ByteArrayOutputStream out = new ByteArrayOutputStream();
            System.setOut(new PrintStream(out));
            BCELifier.main(new String[0]);
            final String outputNoArgs = new String(out.toByteArray());
            assertEquals("Usage: BCELifier className" + EOL + "\tThe class must exist on the classpath" + EOL, outputNoArgs);
        } finally {
            System.setOut(sysout);
        }
    }

    @ParameterizedTest
    @ValueSource(strings = { "StackMapExample", "StackMapExample2" })
    void testStackMap(final String className) throws Exception {
        testJavapCompare(className);
        final File workDir = new File("target");
        assertEquals("Hello World" + EOL, exec(workDir, getAppJava(), "-cp", CLASSPATH, className, "Hello"));
    }

    @Test
    void testStart() throws Exception {
        final OutputStream os = new ByteArrayOutputStream();
        final JavaClass javaClass = BCELifier.getJavaClass("Java8Example");
        assertNotNull(javaClass);
        final BCELifier bcelifier = new BCELifier(javaClass, os);
        bcelifier.start();
    }
}
