/**
 * 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.aries.spifly.statictool;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Properties;
import java.util.Set;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;

import org.apache.aries.spifly.ConsumerHeaderProcessor;
import org.apache.aries.spifly.SpiFlyConstants;
import org.apache.aries.spifly.Streams;
import org.apache.aries.spifly.Util;
import org.apache.aries.spifly.WeavingData;
import org.apache.aries.spifly.weaver.TCCLSetterVisitor;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.osgi.framework.Constants;
import org.osgi.framework.Version;

public class Main {
    private static final String MODIFIED_BUNDLE_SUFFIX = "_spifly.jar";
    private static final String IMPORT_PACKAGE = "Import-Package";

    public static void usage() {
        System.err.println("This tool processes OSGi Bundles that use java.util.ServiceLoader.load() to");
        System.err.println("obtain implementations via META-INF/services. The byte code in the bundles is");
        System.err.println("modified so that the ThreadContextClassLoader is set appropriately for the ");
        System.err.println("duration of the java.util.ServiceLoader.load() call.");
        System.err.println("To opt-in to this process, bundles need to have the following MANIFEST.MF");
        System.err.println("header set:");
        System.err.println("    " + SpiFlyConstants.SPI_CONSUMER_HEADER + ": *");
        System.err.println("Modified bundles are written out under the following name:");
        System.err.println("    <original-bundle-name>" + MODIFIED_BUNDLE_SUFFIX);
        System.err.println();
        System.err.println("Usage: java " + Main.class.getName() + " bundle1.jar bundle2.jar ...");
        System.exit(-1);
    }

    public static void main(String ... args) throws Exception {
        if (args.length < 1)
            usage();

        for (String arg : args) {
            weaveJar(arg);
        }
    }

    private static void weaveJar(String jarPath) throws Exception {
        System.out.println("[SPI Fly Static Tool] Processing: " + jarPath);

        File jarFile = new File(jarPath);
        File tempDir = new File(System.getProperty("java.io.tmpdir") + File.separator + jarFile.getName() + "_" + System.currentTimeMillis());
        Manifest manifest = unJar(jarFile, tempDir);
        String consumerHeaderVal = manifest.getMainAttributes().getValue(SpiFlyConstants.SPI_CONSUMER_HEADER);
        String consumerHeaderKey = null;
        if (consumerHeaderVal != null) {
            consumerHeaderKey = SpiFlyConstants.SPI_CONSUMER_HEADER;
        } else {
            consumerHeaderVal = manifest.getMainAttributes().getValue(SpiFlyConstants.REQUIRE_CAPABILITY);
            if (consumerHeaderVal != null) {
                consumerHeaderKey = SpiFlyConstants.REQUIRE_CAPABILITY;
            }
        }

        if (consumerHeaderVal != null) {
            String bcp = manifest.getMainAttributes().getValue(Constants.BUNDLE_CLASSPATH);
            weaveDir(tempDir, consumerHeaderKey, consumerHeaderVal, bcp);

            if (SpiFlyConstants.SPI_CONSUMER_HEADER.equals(consumerHeaderKey)) {
                manifest.getMainAttributes().remove(new Attributes.Name(SpiFlyConstants.SPI_CONSUMER_HEADER));
                manifest.getMainAttributes().putValue(SpiFlyConstants.PROCESSED_SPI_CONSUMER_HEADER, consumerHeaderVal);
            } else {
                // It's SpiFlyConstants.REQUIRE_CAPABILITY

                // Take out the processor requirement, this probably needs to be improved a little bit
                String newConsumerHeaderVal = consumerHeaderVal.replaceAll(
                        "osgi[.]extender;\\s*filter[:][=][\"]?[(]osgi[.]extender[=]osgi[.]serviceloader[.]processor[)][\"]?", "").
                        trim();
                if (newConsumerHeaderVal.startsWith(","))
                    newConsumerHeaderVal = newConsumerHeaderVal.substring(1);

                if (newConsumerHeaderVal.endsWith(","))
                    newConsumerHeaderVal = newConsumerHeaderVal.substring(0, newConsumerHeaderVal.length()-1);
                manifest.getMainAttributes().putValue(SpiFlyConstants.REQUIRE_CAPABILITY, newConsumerHeaderVal);
                manifest.getMainAttributes().putValue("X-SpiFly-Processed-Require-Capability", consumerHeaderVal);
            }

            // TODO if new packages needed then...
            extendImportPackage(manifest);

            File newJar = getNewJarFile(jarFile);
            jar(newJar, tempDir, manifest);
        } else {
            System.out.println("[SPI Fly Static Tool] This file is not marked as an SPI Consumer.");
        }
        delTree(tempDir);
    }

    private static void extendImportPackage(Manifest manifest) throws IOException {
        String utilPkgVersion = getPackageVersion(Util.class);

        Version osgiVersion = Version.parseVersion(utilPkgVersion);

        Version minVersion = new Version(osgiVersion.getMajor(), osgiVersion.getMinor(), osgiVersion.getMicro());
        Version maxVersion = new Version(osgiVersion.getMajor(), osgiVersion.getMinor() + 1, 0);

        String ip = manifest.getMainAttributes().getValue(IMPORT_PACKAGE);
        if (ip == null)
            ip = "";

        StringBuilder sb = new StringBuilder(ip);
        if (ip.length() > 0)
            sb.append(",");
        sb.append(Util.class.getPackage().getName());
        sb.append(";version=\"[");
        sb.append(minVersion);
        sb.append(",");
        sb.append(maxVersion);
        sb.append(")\"");
        manifest.getMainAttributes().putValue(IMPORT_PACKAGE, sb.toString());
    }

    private static String getPackageVersion(Class<?> clazz) throws IOException {
        URL url = clazz.getResource("packageinfo");
        if (url == null) {
            throw new RuntimeException("'packageinfo' file with version information not found for package: "
                    + clazz.getPackage().getName());
        }

        byte[] bytes = Streams.suck(url.openStream());
        Properties p = new Properties();
        p.load(new ByteArrayInputStream(bytes));
        return p.getProperty("version");
    }

    private static File getNewJarFile(File jarFile) {
        String s = jarFile.getAbsolutePath();
        int idx = s.lastIndexOf('.');
        s = s.substring(0, idx);
        s += MODIFIED_BUNDLE_SUFFIX;
        return new File(s);
    }

    private static void weaveDir(File dir, String consumerHeaderKey, String consumerHeaderValue, String bundleClassPath) throws Exception {
        Set<WeavingData> wd = ConsumerHeaderProcessor.processHeader(consumerHeaderKey, consumerHeaderValue);

        URLClassLoader cl = new URLClassLoader(new URL [] {dir.toURI().toURL()}, Main.class.getClassLoader());
        String dirName = dir.getAbsolutePath();

        DirTree dt = new DirTree(dir);
        for (File f : dt.getFiles()) {
            if (!f.getName().endsWith(".class"))
                continue;

            String className = f.getAbsolutePath().substring(dirName.length());
            if (className.startsWith(File.separator))
                className = className.substring(1);
            className = className.substring(0, className.length() - ".class".length());
            className = className.replace(File.separator, ".");

            InputStream is = new FileInputStream(f);
            byte[] b;
            try {
                ClassReader cr = new ClassReader(is);
                ClassWriter cw = new StaticToolClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, cl);
                TCCLSetterVisitor cv = new TCCLSetterVisitor(cw, className, wd);
                cr.accept(cv, ClassReader.SKIP_FRAMES);
                if (cv.isWoven()) {
                    b = cw.toByteArray();
                } else {
                    // if not woven, store the original bytes
                    b = Streams.suck(new FileInputStream(f));
                }
            } finally {
                is.close();
            }

            OutputStream os = new FileOutputStream(f);
            try {
                os.write(b);
            } finally {
                os.close();
            }
        }

        if (bundleClassPath != null) {
            for (String entry : bundleClassPath.split(",")) {
                File jarFile = new File(dir, entry.trim());
                if (jarFile.isFile()) {
                    weaveBCPJar(jarFile, consumerHeaderKey, consumerHeaderValue);
                }
            }
        }
    }

    private static void weaveBCPJar(File jarFile, String consumerHeaderKey, String consumerHeaderVal) throws Exception {
        File tempDir = new File(System.getProperty("java.io.tmpdir") + File.separator + jarFile.getName() + "_" + System.currentTimeMillis());
        try {
            Manifest manifest = unJar(jarFile, tempDir);
            weaveDir(tempDir, consumerHeaderKey, consumerHeaderVal, null);
            if (!jarFile.delete()) {
                throw new IOException("Could not replace file: " + jarFile);
            }

            jar(jarFile, tempDir, manifest);
        } finally {
            delTree(tempDir);
        }
    }

    static Manifest unJar(File jarFile, File tempDir) throws IOException {
        ensureDirectory(tempDir);

        JarInputStream jis = new JarInputStream(new FileInputStream(jarFile));
        JarEntry je = null;
        while((je = jis.getNextJarEntry()) != null) {
            File outFile = new File(tempDir, je.getName());
            String canonicalizedTargetDir = tempDir.getCanonicalPath();
            if (!canonicalizedTargetDir.endsWith(File.separator)) {
                canonicalizedTargetDir += File.separator;
            }
            if (!outFile.getCanonicalPath().startsWith(canonicalizedTargetDir)) {
                throw new IOException("The output file is not contained in the destination directory");
            }

            if (je.isDirectory()) {
                ensureDirectory(outFile);

                continue;
            }

            File outDir = outFile.getParentFile();
            ensureDirectory(outDir);

            OutputStream out = new FileOutputStream(outFile);
            try {
                Streams.pump(jis, out);
            } finally {
                out.flush();
                out.close();
                jis.closeEntry();
            }
            outFile.setLastModified(je.getTime());
        }

        Manifest manifest = jis.getManifest();
        if (manifest != null) {
            File mf = new File(tempDir, "META-INF/MANIFEST.MF");
            File mfDir = mf.getParentFile();
            ensureDirectory(mfDir);

            OutputStream out = new FileOutputStream(mf);
            try {
                manifest.write(out);
            } finally {
                out.flush();
                out.close();
            }
        }

        jis.close();
        return manifest;
    }

    static void jar(File jarFile, File rootFile, Manifest manifest) throws IOException {
        JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile), manifest);
        try {
            addToJarRecursively(jos, rootFile.getAbsoluteFile(), rootFile.getAbsolutePath());
        } finally {
            jos.close();
        }
    }

    static void addToJarRecursively(JarOutputStream jar, File source, String rootDirectory) throws IOException {
        String sourceName = source.getAbsolutePath().replace("\\", "/");
        sourceName = sourceName.substring(rootDirectory.length());

        if (sourceName.startsWith("/")) {
            sourceName = sourceName.substring(1);
        }

        if ("META-INF/MANIFEST.MF".equals(sourceName))
            return;

        if (source.isDirectory()) {
            /* Is there any point in adding a directory beyond just taking up space?
            if (!sourceName.isEmpty()) {
                if (!sourceName.endsWith("/")) {
                    sourceName += "/";
                }
                JarEntry entry = new JarEntry(sourceName);
                jar.putNextEntry(entry);
                jar.closeEntry();
            }
            */
            for (File nested : source.listFiles()) {
                addToJarRecursively(jar, nested, rootDirectory);
            }
            return;
        }

        JarEntry entry = new JarEntry(sourceName);
        jar.putNextEntry(entry);
        InputStream is = new FileInputStream(source);
        try {
            Streams.pump(is, jar);
        } finally {
            jar.closeEntry();
            is.close();
        }
    }

    static void delTree(File tempDir) throws IOException {
        for (File f : new DirTree(tempDir).getFiles()) {
            if (!f.delete())
                throw new IOException("Problem deleting file: " + tempDir.getAbsolutePath());
        }
    }

    private static void ensureDirectory(File outDir) throws IOException {
        if (!outDir.isDirectory())
            if (!outDir.mkdirs())
                throw new IOException("Unable to create directory " + outDir.getAbsolutePath());
    }
}

