/*
 * 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.openjpa.enhance;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.openjpa.conf.OpenJPAConfiguration;
import org.apache.openjpa.lib.log.Log;
import org.apache.openjpa.util.asm.AsmHelper;
import org.apache.openjpa.util.asm.BytecodeWriter;
import org.apache.openjpa.lib.util.Localizer;
import org.apache.openjpa.lib.util.Localizer.Message;
import org.apache.openjpa.meta.AccessCode;
import org.apache.openjpa.meta.ClassMetaData;
import org.apache.openjpa.meta.FieldMetaData;
import org.apache.openjpa.meta.JavaTypes;
import org.apache.openjpa.util.Exceptions;
import org.apache.openjpa.util.GeneratedClasses;
import org.apache.openjpa.util.ImplHelper;
import org.apache.openjpa.util.InternalException;
import org.apache.openjpa.util.MetaDataException;
import org.apache.openjpa.util.UserException;
import org.apache.openjpa.util.asm.ClassNodeTracker;

/**
 * Redefines the method bodies of existing unenhanced classes to make them
 * notify state managers of mutations.
 *
 * @since 1.0.0
 */
public class ManagedClassSubclasser {
    private static final Localizer _loc = Localizer.forPackage(
        ManagedClassSubclasser.class);

    /**
     * For each element in <code>classes</code>, creates and registers a
     * new subclass that implements {@link PersistenceCapable}, and prepares
     * OpenJPA to handle new instances of the unenhanced type. If this is
     * invoked in a Java 6 environment, this method will redefine the methods
     * for each class in the argument list such that field accesses are
     * intercepted in-line. If invoked in a Java 5 environment or very new Java versions, this
     * redefinition is not possible; in these contexts, when using field
     * access, OpenJPA will need to do state comparisons to detect any change
     * to any instance at any time, and when using property access, OpenJPA
     * will need to do state comparisons to detect changes to newly inserted
     * instances after a flush has been called.
     *
     * @return the new subclasses, or <code>null</code> if <code>classes</code>
     * is <code>null</code>.
     * @throws UserException if <code>conf</code> requires build-time
     * enhancement and <code>classes</code> includes unenhanced types.
     *
     * @since 1.0.0
     */
    public static List<Class<?>> prepareUnenhancedClasses(
        final OpenJPAConfiguration conf,
        final Collection<? extends Class<?>> classes,
        final ClassLoader envLoader) {
        if (classes == null)
            return null;
        if (classes.size() == 0)
            return Collections.emptyList();

        Log log = conf.getLog(OpenJPAConfiguration.LOG_ENHANCE);
        if (conf.getRuntimeUnenhancedClassesConstant() != RuntimeUnenhancedClassesModes.SUPPORTED) {
            Collection<Class<?>> unenhanced = new ArrayList<>();
            for (Class<?> cls : classes)
                if (!PersistenceCapable.class.isAssignableFrom(cls))
                    unenhanced.add(cls);
            if (unenhanced.size() > 0) {
                if (PCEnhancerAgent.getLoadSuccessful()) {
                    // This means that the enhancer has been ran but we
                    // have some unenhanced classes. This can happen if an
                    // entity is loaded by the JVM before the EntityManger
                    // was created. Warn the user.
                    if (log.isWarnEnabled()) {
                        log.warn(_loc.get("entities-loaded-before-em"));
                    }
                    if (log.isTraceEnabled()) {
                        log.trace(ManagedClassSubclasser.class.getName()
                            + ".prepareUnenhancedClasses()"
                            + " - The following classes are unenhanced "
                            + unenhanced.toString());
                    }
                }
                Message msg = _loc.get("runtime-optimization-disabled", Exceptions.toClassNames(unenhanced));
                if (conf.getRuntimeUnenhancedClassesConstant() == RuntimeUnenhancedClassesModes.WARN) {
                    log.warn(msg);
                } else {
                    throw new UserException(msg);
                }
            }
            return null;
        }

        boolean redefine = ClassRedefiner.canRedefineClasses(log);
        if (redefine) {
            log.info(_loc.get("enhance-and-subclass-and-redef-start", classes));
        } else {
            log.warn(_loc.get("enhance-and-subclass-no-redef-start",  classes));
        }
        final Map<Class<?>, byte[]> map = new HashMap<>();
        final List<Class<?>> subs = new ArrayList<>(classes.size());
        final List<Class<?>> ints = new ArrayList<>(classes.size());
        Set<Class<?>> unspecified = null;
        for (Class<?> cls : classes) {
            final Class<?> c = cls;
            final PCEnhancer enhancer = new PCEnhancer(conf, cls);

            enhancer.setBytecodeWriter(new BytecodeWriter() {
                @Override
                public void write(ClassNodeTracker cnt) throws IOException {
                    ManagedClassSubclasser.write(cnt, enhancer, map, c, subs, ints);
                }
            });
            if (redefine) {
                enhancer.setRedefine(true);
            }

            // we need to create subclasses because class retransform doesn't allow to change the interfaces of a previously loaded class
            enhancer.setCreateSubclass(true);
            enhancer.setAddDefaultConstructor(true);

            // set this before enhancement as well as after since enhancement
            // uses a different metadata repository, and the metadata config
            // matters in the enhancement contract. In order to avoid a
            // NullPointerException, check for no metadata and throw an
            // exception if none exists. Otherwise, don't do any warning here,
            // since we'll issue warnings when we do the final metadata
            // reconfiguration at the end of this method.
            ClassMetaData meta = enhancer.getMetaData();
            if (meta == null) {
                throw new MetaDataException(_loc.get("no-meta", cls)).setFatal(true);
            }
            configureMetaData(meta, conf, redefine, false);

            unspecified = collectRelatedUnspecifiedTypes(enhancer.getMetaData(), classes, unspecified);

            int runResult = enhancer.run();
            if (runResult == PCEnhancer.ENHANCE_PC) {
                try {
                    enhancer.record();
                } catch (IOException e) {
                    // our impl of BytecodeWriter doesn't throw IOException
                    throw new InternalException(e);
                }
            }
        }

        if (unspecified != null && !unspecified.isEmpty()) {
            throw new UserException(_loc.get("unspecified-unenhanced-types", Exceptions.toClassNames(classes), unspecified));
        }

        ClassRedefiner.redefineClasses(conf, map);
        for (Class<?> cls : map.keySet()) {
            setIntercepting(conf, envLoader, cls);
            configureMetaData(conf, envLoader, cls, redefine);
        }
        for (Class<?> cls : subs)
            configureMetaData(conf, envLoader, cls, redefine);
        for (Class<?> cls : ints)
            setIntercepting(conf, envLoader, cls);

        return subs;
    }

    private static Set<Class<?>> collectRelatedUnspecifiedTypes(ClassMetaData meta,
        Collection<? extends Class<?>> classes, Set<Class<?>> unspecified) {
        unspecified = collectUnspecifiedType(meta.getPCSuperclass(), classes,
            unspecified);

        for (FieldMetaData fmd : meta.getFields()) {
            if (fmd.isTransient())
                continue;
            if (fmd.isTypePC())
                unspecified = collectUnspecifiedType(fmd.getType(), classes,
                    unspecified);
            if (fmd.getElement() != null && fmd.getElement().isTypePC())
                unspecified = collectUnspecifiedType(fmd.getElement().getType(),
                    classes, unspecified);
            if (fmd.getKey() != null && fmd.getKey().isTypePC())
                unspecified = collectUnspecifiedType(fmd.getKey().getType(),
                    classes, unspecified);
            if (fmd.getValue() != null && fmd.getValue().isTypePC())
                unspecified = collectUnspecifiedType(fmd.getValue().getType(),
                    classes, unspecified);
        }
        return unspecified;
    }

    private static Set<Class<?>> collectUnspecifiedType(Class<?> cls,
        Collection<? extends Class<?>> classes, Set<Class<?>> unspecified) {
        if (cls != null && !classes.contains(cls)
            && !ImplHelper.isManagedType(null, cls)
            && !cls.isInterface()) {
            if (unspecified == null)
                unspecified = new HashSet<>();
            unspecified.add(cls);
        }
        return unspecified;
    }

    private static void configureMetaData(OpenJPAConfiguration conf,
        ClassLoader envLoader, Class<?> cls, boolean redefineAvailable) {
        ClassMetaData meta = conf.getMetaDataRepositoryInstance()
            .getMetaData(cls, envLoader, true);
        configureMetaData(meta, conf, redefineAvailable, true);
    }

    private static void configureMetaData(ClassMetaData meta,
        OpenJPAConfiguration conf, boolean redefineAvailable, boolean warn) {

        setDetachedState(meta);

        // If warn & (implicit field access | mixed access) & noredef
        if (warn && ((AccessCode.isField(meta.getAccessType())
            && !meta.isMixedAccess()) ||  meta.isMixedAccess())
            && !redefineAvailable) {
            // only warn about declared fields; superclass fields will be
            // warned about when the superclass is handled
            for (FieldMetaData fmd : meta.getDeclaredFields()) {
                if (AccessCode.isProperty(fmd.getAccessType()))
                    continue;
                switch (fmd.getTypeCode()) {
                    case JavaTypes.COLLECTION:
                    case JavaTypes.MAP:
                        // we can lazily load these, since we own the
                        // relationship container
                        break;
                    default:
                        if (!fmd.isInDefaultFetchGroup()
                            && !(fmd.isVersion() || fmd.isPrimaryKey())) {
                            Log log = conf.getLog(
                                OpenJPAConfiguration.LOG_ENHANCE);
                            log.warn(_loc.get("subclasser-fetch-group-override",
                                meta.getDescribedType().getName(),
                                fmd.getName()));
                            fmd.setInDefaultFetchGroup(true);
                        }
                }
            }
        }
    }

    private static void write(ClassNodeTracker cnt, PCEnhancer enhancer, Map<Class<?>, byte[]> map,
                              Class<?> cls, List<Class<?>> subs, List<Class<?>> ints)
        throws IOException {

        if (cnt == enhancer.getManagedTypeBytecode()) {
            // if it was already defined, don't put it in the map,
            // but do set the metadata accordingly.
            if (enhancer.isAlreadyRedefined())
                ints.add(cls);
            else {
                final byte[] byteArray = AsmHelper.toByteArray(cnt);
                map.put(cls, byteArray);
                debugBytecodes(cnt, byteArray);
            }
        } else {
            if (!enhancer.isAlreadySubclassed()) {
                final byte[] byteArray = AsmHelper.toByteArray(cnt);
                debugBytecodes(cnt, byteArray);

                // this is the new subclass
                ClassLoader loader = GeneratedClasses.getMostDerivedLoader(cls, PersistenceCapable.class);
                String className = cnt.getClassNode().name.replace("/", ".");
                subs.add(GeneratedClasses.loadAsmClass(className, byteArray, cls, loader));
            }
        }
    }

    public static void debugBytecodes(ClassNodeTracker cnt, byte[] classBytes) throws IOException {
        // Write the bytecodes to disk for debugging purposes.
        if ("true".equals(System.getProperty(ManagedClassSubclasser.class.getName() + ".dumpBytecodes")))
        {
            File tmp = new File(System.getProperty("java.io.tmpdir"));
            File dir = new File(tmp, "openjpa");
            dir = new File(dir, "pcsubclasses");
            dir.mkdirs();
            File f = new File(dir, cnt.getClassNode().name + ".class");

            // START - ALLOW PRINT STATEMENTS
            System.err.println("Writing to " + f);
            // STOP - ALLOW PRINT STATEMENTS

            Files.write(f.toPath(), classBytes, StandardOpenOption.WRITE);
        }
    }

    private static void setIntercepting(OpenJPAConfiguration conf, ClassLoader envLoader, Class<?> cls) {
        ClassMetaData meta = conf.getMetaDataRepositoryInstance().getMetaData(cls, envLoader, true);
        meta.setIntercepting(true);
    }

    /**
     * If the metadata is configured to use a synthetic
     * detached state, reset it to not use a detached
     * state field, since we can't add fields when redefining.
     */
    private static void setDetachedState(ClassMetaData meta) {
        if (ClassMetaData.SYNTHETIC.equals(meta.getDetachedState()))
            meta.setDetachedState(null);
    }
}
