/*
 * 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
 *
 *     http://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.openejb.javaagent;

import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.ReflectPermission;
import java.net.JarURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.security.Permission;
import java.security.ProtectionDomain;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public class Agent {

    private static final Permission ACCESS_PERMISSION = new ReflectPermission("suppressAccessChecks");
    private static String agentArgs;
    private static Instrumentation instrumentation;
    private static boolean initialized;

    public static void premain(final String agentArgs, final Instrumentation instrumentation) {
        if (Agent.instrumentation != null) {
            return;
        }

        Agent.agentArgs = agentArgs;
        Agent.instrumentation = instrumentation;
        initialized = true;

        instrumentation.addTransformer(new BootstrapTransformer());
    }

    public static void agentmain(final String agentArgs, final Instrumentation instrumentation) {
        if (Agent.instrumentation != null) {
            return;
        }

        Agent.agentArgs = agentArgs;
        Agent.instrumentation = instrumentation;
        initialized = true;
    }

    public static synchronized String getAgentArgs() {
        final SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkPermission(ACCESS_PERMISSION);
        }
        checkInitialization();
        return agentArgs;
    }

    /**
     * Gets the instrumentation instance.
     * You must have java.lang.ReflectPermission(suppressAccessChecks) to call this method
     *
     * @return the instrumentation instance
     */
    public static synchronized Instrumentation getInstrumentation() {
        final SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkPermission(ACCESS_PERMISSION);
        }
        checkInitialization();
        return instrumentation;
    }

    private static synchronized void checkInitialization() {
        if (!initialized) {
            try {
                checkSystemClassPath();
                dynamicLoadAgent();
            } catch (final Exception e) {
                new IllegalStateException("Unable to initialize agent", e).printStackTrace();
            } finally {
                initialized = true;
            }
        }
    }

    private static void checkSystemClassPath() throws NoSuchFieldException, IllegalAccessException {
        if (instrumentation != null) {
            return;
        }

        final Class<?> systemAgentClass;
        try {
            final ClassLoader systemCl = ClassLoader.getSystemClassLoader();
            systemAgentClass = systemCl.loadClass(Agent.class.getName());
        } catch (final ClassNotFoundException e) {
            // java-agent jar was not on the system class path
            return;
        }

        final Field instrumentationField = systemAgentClass.getDeclaredField("instrumentation");
        instrumentationField.setAccessible(true);
        instrumentation = (Instrumentation) instrumentationField.get(null);

        final Field agentArgsField = systemAgentClass.getDeclaredField("agentArgs");
        agentArgsField.setAccessible(true);
        agentArgs = (String) agentArgsField.get(null);
    }

    private static void dynamicLoadAgent() throws Exception {
        if (instrumentation != null) {
            return;
        }

        try {
            final Class<?> vmClass = Class.forName("com.sun.tools.attach.VirtualMachine");
            final Method attachMethod = vmClass.getMethod("attach", String.class);
            final Method loadAgentMethod = vmClass.getMethod("loadAgent", String.class);

            // find the agentJar
            final String agentPath = getAgentJar();

            // get the pid of the current process (for attach command)
            final String pid = getPid();

            // attach to the vm
            final Object vm = attachMethod.invoke(null, new String[]{pid});

            // load our agent
            loadAgentMethod.invoke(vm, agentPath);

            // The AgentJar is loaded into the system classpath, and this class could
            // be in a child classloader, so we need to double check the system classpath
            checkSystemClassPath();
        } catch (final ClassNotFoundException | NoSuchMethodException e) {
            // not a Sun VM
        }
    }

    private static String getPid() {
        // This relies on the undocumented convention of the
        // RuntimeMXBean's name starting with the PID, but
        // there appears to be no other way to obtain the
        // current process' id, which we need for the attach
        // process
        final RuntimeMXBean bean = ManagementFactory.getRuntimeMXBean();
        String pid = bean.getName();
        if (pid.contains("@")) {
            pid = pid.substring(0, pid.indexOf("@"));
        }
        return pid;
    }

    /**
     * Try to find the openejb-javaagent jar, and if not found create a new jar
     * file for the sole purpose of specifying an Agent-Class to load into the JVM.
     */
    private static String getAgentJar() throws IOException {
        final URL resource = Agent.class.getClassLoader().getResource(Agent.class.getName().replace('.', '/') + ".class");
        if (resource == null) {
            throw new IllegalStateException("Could not find Agent class file in class path");
        }

        final URLConnection urlConnection = resource.openConnection();
        if (urlConnection instanceof JarURLConnection) {
            final JarURLConnection jarURLConnection = (JarURLConnection) urlConnection;
            return jarURLConnection.getJarFile().getName();
        }

        final InputStream in = urlConnection.getInputStream();
        ZipOutputStream out = null;
        File file = null;
        try {
            try {
                file = File.createTempFile(Agent.class.getName(), ".jar");
            } catch (final Throwable e) {
                final File tmp = new File("tmp");
                if (!tmp.exists() && !tmp.mkdirs()) {
                    throw new IOException("Failed to create local tmp directory: " + tmp.getAbsolutePath());
                }

                file = File.createTempFile(Agent.class.getName(), ".jar", tmp);
            }

            file.deleteOnExit();

            out = new ZipOutputStream(new FileOutputStream(file));

            // write manifest
            out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
            try {
                final PrintWriter writer = new PrintWriter(new OutputStreamWriter(out));
                writer.println("Agent-Class: " + Agent.class.getName());
                writer.println("Can-Redefine-Classes: true");
                writer.println("Can-Retransform-Classes: true");
                writer.flush();
            } finally {
                out.closeEntry();
            }

            // write agent class
            out.putNextEntry(new ZipEntry(Agent.class.getName().replace('.', '/') + ".class"));
            try {
                final byte[] buffer = new byte[4096];
                for (int count = in.read(buffer); count >= 0; count = in.read(buffer)) {
                    out.write(buffer, 0, count);
                }
            } finally {
                out.closeEntry();
            }

            return file.getAbsolutePath();
        } catch (final IOException e) {
            if (file != null) {
                if (!file.delete()) {
                    file.deleteOnExit();
                }
            }
            throw e;
        } finally {
            close(in);
            close(out);
        }
    }

    private static void close(final Closeable closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (final IOException ignored) {
                // no-op
            }
        }
    }

    private static class BootstrapTransformer implements ClassFileTransformer {
        @Override
        public byte[] transform(final ClassLoader loader, final String className, final Class<?> classBeingRedefined, final ProtectionDomain protectionDomain, final byte[] classfileBuffer) throws IllegalClassFormatException {
            try {
                bootstrap(loader);
            } catch (final Throwable e) {
                removeThis();
            }
            return classfileBuffer;
        }

        private void removeThis() {
            try {
                instrumentation.removeTransformer(this);
            } catch (final Throwable th) {
                // no-op
            }
        }

        private void bootstrap(final ClassLoader loader) {
            if (loader == null) {
                return;
            }

            final String bootstrapClassName = "org.apache.openejb.persistence.PersistenceBootstrap";
            final String bootstrapClassFile = "org/apache/openejb/persistence/PersistenceBootstrap.class";

            if (loader.getResource(bootstrapClassFile) == null) {
                return;
            }

            try {
                final Class<?> bootstrapClass = loader.loadClass(bootstrapClassName);
                final Method bootstrap = bootstrapClass.getMethod("bootstrap", ClassLoader.class);
                bootstrap.invoke(null, loader);
                removeThis();
            } catch (final Throwable e) {
                removeThis();
                Logger.getLogger(Agent.class.getName()).log(Level.WARNING, "Failed to invoke bootstrap: " + e.getMessage());
            }
        }
    }
}
