/*
 * The contents of this file are subject to the terms of the Common Development
 * and Distribution License (the License). You may not use this file except in
 * compliance with the License.
 *
 * You can obtain a copy of the License at http://www.netbeans.org/cddl.html
 * or http://www.netbeans.org/cddl.txt.
 * 
 * When distributing Covered Code, include this CDDL Header Notice in each file
 * and include the License file at http://www.netbeans.org/cddl.txt.
 * If applicable, add the following below the CDDL Header, with the fields
 * enclosed by brackets [] replaced by your own identifying information:
 * "Portions Copyrighted [year] [name of copyright owner]"
 *
 * The Original Software is NetBeans. The Initial Developer of the Original
 * Software is Sun Microsystems, Inc. Portions Copyright 1997-2006 Sun
 * Microsystems, Inc. All Rights Reserved.
 */

package org.netbeans.core;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.StringTokenizer;
import org.openide.ErrorManager;
import org.openide.filesystems.FileObject;
import org.openide.filesystems.Repository;
import org.openide.loaders.DataFolder;
import org.openide.loaders.FolderLookup;
import org.openide.util.Lookup;
import org.openide.util.io.NbObjectInputStream;
import org.openide.util.io.NbObjectOutputStream;
import org.netbeans.Module;
import org.netbeans.ModuleManager;
import org.netbeans.core.startup.StartLog;

/**
 * Responsible for persisting the structure of folder lookup.
 * <p>A cache is kept in serialized form in $userdir/cache/folder-lookup.ser.
 * Unless the cache is invalidated due to changes in module JARs or
 * files in $userdir/system/**, it is restored after a regular startup.
 * The actual objects in lookup are not serialized - only their classes,
 * instanceof information, position in the Services folder, and so on. This
 * permits us to avoid calling the XML parser for every .settings object etc.
 * Other forms of lookup like META-INF/services/* are not persisted.
 * <p>Can be enabled or disabled with the system property netbeans.cache.lookup.
 * @author Jesse Glick, Jaroslav Tulach
 * @see "#20190"
 */
class LookupCache {

    /** whether to enable the cache for this session */
    private static final boolean ENABLED = Boolean.valueOf(System.getProperty("netbeans.cache.lookup", "true")).booleanValue(); // NOI18N
    
    /** private logging for this class */
    private static final ErrorManager err = ErrorManager.getDefault().getInstance("org.netbeans.core.LookupCache"); // NOI18N
    
    /**
     * Get the Services/ folder lookup.
     * May either do it the slow way, or might load quickly from a cache.
     * @return the folder lookup for the system
     */
    public static Lookup load() {
        err.log("enabled=" + ENABLED);
        if (ENABLED && cacheHit()) {
            try {
                return loadCache();
            } catch (Exception e) {
                err.notify(ErrorManager.INFORMATIONAL, e);
            }
        }
        return loadDirect();
    }
    
    /**
     * Load folder lookup directly from the system file system, parsing
     * as necessary (the slow way).
     */
    private static Lookup loadDirect() {
        FileObject services = Repository.getDefault().getDefaultFileSystem().findResource("Services"); // NOI18N
        if (services != null) {
            StartLog.logProgress("Got Services folder"); // NOI18N
            DataFolder servicesF;
            try {
                servicesF = DataFolder.findFolder(services);
            } catch (RuntimeException e) {
                err.notify(ErrorManager.INFORMATIONAL, e);
                return Lookup.EMPTY;
            }
            FolderLookup f = new FolderLookup(servicesF, "SL["); // NOI18N
            StartLog.logProgress("created FolderLookup"); // NOI18N
            err.log("loadDirect from Services");
            return f.getLookup();
        } else {
            err.log("loadDirect, but no Services");
            return Lookup.EMPTY;
        }
    }
    
    /**
     * Determine if there is an existing lookup cache which can be used
     * now as is.
     * If there is a cache and a stamp file, and the stamp agrees with
     * a calculation of the files and timestamps currently available to
     * constitute the folder lookup, then the cache is used.
     */
    private static boolean cacheHit() {
        File f = cacheFile();
        if (f == null || !f.exists()) {
            err.log("no cache file");
            return false;
        }
        File stampFile = stampFile();
        if (stampFile == null || !stampFile.exists()) {
            err.log("no stamp file");
            return false;
        }
        StartLog.logStart("check for lookup cache hit"); // NOI18N
        List files = relevantFiles(); // List<File>
        if (err.isLoggable(ErrorManager.INFORMATIONAL)) {
            err.log("checking against " + stampFile + " for files " + files);
        }
        boolean hit;
        try {
            Stamp stamp = new Stamp(files);
            long newHash = stamp.getHash();
            BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(stampFile), "UTF-8")); // NOI18N
            try {
                String line = r.readLine();
                long oldHash;
                try {
                    oldHash = Long.parseLong(line);
                } catch (NumberFormatException nfe) {
                    throw new IOException(nfe.toString());
                }
                if (oldHash == newHash) {
                    err.log("Cache hit! with hash " + oldHash);
                    hit = true;
                } else {
                    err.log("Cache miss, " + oldHash + " -> " + newHash);
                    hit = false;
                }
            } finally {
                r.close();
            }
        } catch (IOException ioe) {
            err.notify(ErrorManager.INFORMATIONAL, ioe);
            hit = false;
        }
        StartLog.logEnd("check for lookup cache hit"); // NOI18N
        return hit;
    }
    
    /**
     * The file containing the serialized lookup cache.
     */
    private static File cacheFile() {
        String ud = System.getProperty("netbeans.user");
        if ((ud != null) && (! ud.equals("memory"))) {
            File cachedir = new File(new File (ud, "var"), "cache"); // NOI18N
            cachedir.mkdirs();
            return new File(cachedir, "folder-lookup.ser"); // NOI18N
        } else {
            return null;
        }
    }
    
    /**
     * The file containing a stamp which indicates which modules were
     * enabled, what versions of them, customized services, etc.
     */
    private static File stampFile() {
        String ud = System.getProperty("netbeans.user");
        if ((ud != null) && (! ud.equals("memory"))) {
            File cachedir = new File(new File (ud, "var"), "cache"); // NOI18N
            cachedir.mkdirs();
            return new File(cachedir, "lookup-stamp.txt"); // NOI18N
        } else {
            return null;
        }
    }
    
    /**
     * List of all files which might be relevant to the contents of folder lookup.
     * This means: all JAR files which are modules (skip their extensions and
     * variants which can be assumed not to contain layer files); and all files
     * contained in the system/Services/ subdirs (if any) of the home dir,
     * user dir, and extra installation directories (#27151).
     * For test modules, use the original JAR, not the physical JAR,
     * to prevent cache misses on every restart.
     * For fixed modules with layers (e.g. core.jar), add in the matching JAR,
     * if that can be ascertained.
     * No particular order of returned files is assumed.
     */
    private static List relevantFiles() {
        final List files = new ArrayList(250); // List<File>
        final ModuleManager mgr = org.netbeans.core.startup.Main.getModuleSystem().getManager();
        mgr.mutex().readAccess(new Runnable() {
            public void run() {
                Iterator it = mgr.getEnabledModules().iterator();
                while (it.hasNext()) {
                    Module m = (Module)it.next();
                    String layer = (String)m.getAttribute("OpenIDE-Module-Layer"); // NOI18N
                    if (layer != null) {
                        if (m.getJarFile() != null) {
                            files.add(m.getJarFile());
                        } else {
                            URL layerURL = m.getClassLoader().getResource(layer);
                            if (layerURL != null) {
                                String s = layerURL.toExternalForm();
                                if (s.startsWith("jar:")) { // NOI18N
                                    int bangSlash = s.lastIndexOf("!/"); // NOI18N
                                    if (bangSlash != -1) {
                                        // underlying URL inside jar:, generally file:
                                        try {
                                            URI layerJarURL = new URI(s.substring(4, bangSlash));
                                            if ("file".equals(layerJarURL.getScheme())) { // NOI18N
                                                files.add(new File(layerJarURL));
                                            } else {
                                                err.log(ErrorManager.WARNING, "Weird jar: URL: " + layerJarURL);
                                            }
                                        } catch (URISyntaxException e) {
                                            err.notify(ErrorManager.INFORMATIONAL, e);
                                        }
                                    } else {
                                        err.log(ErrorManager.WARNING, "Malformed jar: URL: " + s);
                                    }
                                } else {
                                    err.log(ErrorManager.WARNING, "Not a jar: URL: " + s);
                                }
                            } else {
                                err.log(ErrorManager.WARNING, "Could not find " + layer + " in " + m);
                            }
                        }
                    }
                    // else no layer, ignore
                }
            }
        });
        relevantFilesFromInst(files, System.getProperty("netbeans.home")); // NOI18N
        relevantFilesFromInst(files, System.getProperty("netbeans.user")); // NOI18N
        String nbdirs = System.getProperty("netbeans.dirs"); // NOI18N
        if (nbdirs != null) {
            // #27151
            StringTokenizer tok = new StringTokenizer(nbdirs, File.pathSeparator);
            while (tok.hasMoreTokens()) {
                relevantFilesFromInst(files, tok.nextToken());
            }
        }
        return files;
    }
    /**
     * Find relevant files from an installation directory.
     */
    private static void relevantFilesFromInst(List files, String instDir) {
        if (instDir == null) {
            return;
        }
        relevantFilesFrom(files, new File(new File(new File(instDir), "system"), "Services")); // NOI18N
    }
    /**
     * Retrieve all files in a directory, recursively.
     */
    private static void relevantFilesFrom(List files, File dir) {
        File[] kids = dir.listFiles();
        if (kids != null) {
            for (int i = 0; i < kids.length; i++) {
                File f = kids[i];
                if (f.isFile()) {
                    files.add(f);
                } else {
                    relevantFilesFrom(files, f);
                }
            }
        }
    }
    
    /**
     * Load folder lookup from the disk cache.
     */
    private static Lookup loadCache() throws Exception {
        StartLog.logStart("load lookup cache");
        File f = cacheFile();
        err.log("loading from " + f);
        InputStream is = new FileInputStream(f);
        try {
            ObjectInputStream ois = new NbObjectInputStream(new BufferedInputStream(is));
            Lookup l = (Lookup)ois.readObject();
            StartLog.logEnd("load lookup cache");
            return l;
        } finally {
            is.close();
        }
    }
    
    /**
     * Store the current contents of folder lookup to disk, hopefully to be used
     * in the next session to speed startup.
     * @param l the folder lookup
     * @throws IOException if it could not be saved
     */
    public static void store(Lookup l) throws IOException {
        if (!ENABLED) {
            return;
        }
        File f = cacheFile();
        if (f == null) {
            return;
        }
        File stampFile = stampFile();
        if (stampFile == null) {
            return;
        }
        StartLog.logStart("store lookup cache");
        err.log("storing to " + f + " with stamp in " + stampFile);
        OutputStream os = new FileOutputStream(f);
        try {
            try {
                ObjectOutputStream oos = new NbObjectOutputStream(new BufferedOutputStream(os));
                oos.writeObject(l);
                oos.flush();
            } finally {
                os.close();
            }
            Stamp stamp = new Stamp(relevantFiles());
            Writer wr = new OutputStreamWriter(new FileOutputStream(stampFile), "UTF-8"); // NOI18N
            try {
                // Would be nice to write out as zero-padded hex.
                // Unfortunately while Long.toHexString works fine,
                // Long.parseLong cannot be asked to parse unsigned longs,
                // so fails when the high bit is set.
                wr.write(Long.toString(stamp.getHash()));
                wr.write("\nLine above is identifying hash key, do not edit!\nBelow is metadata about folder lookup cache, for debugging purposes.\n"); // NOI18N
                wr.write(stamp.toString());
            } finally {
                wr.close();
            }
            StartLog.logEnd("store lookup cache");
        } catch (IOException ioe) {
            // Delete corrupted cache.
            if (f.exists()) {
                f.delete();
            }
            if (stampFile.exists()) {
                stampFile.delete();
            }
            throw ioe;
        }
    }
    
    /**
     * Represents a hash of a bunch of JAR or other files and their timestamps.
     * Compare ModuleLayeredFileSystem's similar nested class.
     * .settings files do not get their timestamps checked because generally
     * changes to them do not reflect changes in the structure of lookup, only
     * in the contents of one lookup instance. Otherwise autoupdate's settings
     * alone would trigger a cache miss every time. Generally, all files other
     * than JARs and .nbattrs (which can affect folder order) should not affect
     * lookup structure by their contents, except in the pathological case which
     * we do not consider that they supply zero instances or a recursive lookup
     * (which even then would only lead to problems if such a file were changed
     * on disk between IDE sessions, which can be expected to be very rare).
     */
    private static final class Stamp {
        private final List files; // List<File>
        private final long[] times;
        private final long hash;
        /** Create a stamp from a list of files. */
        public Stamp(List files) throws IOException {
            this.files = new ArrayList(files);
            Collections.sort(this.files);
            times = new long[this.files.size()];
            long x = 17L;
            Iterator it = this.files.iterator();
            int i = 0;
            while (it.hasNext()) {
                File f = (File)it.next();
                x ^= f.hashCode();
                x += 98679245L;
                long m;
                String name = f.getName().toLowerCase(Locale.US);
                if (name.endsWith(".jar") || name.equals(".nbattrs")) { // NOI18N
                    m = f.lastModified();
                } else {
                    m = 0L;
                }
                x ^= (times[i++] = m);
            }
            hash = x;
        }
        /** Hash of the stamp for comparison purposes. */
        public long getHash() {
            return hash;
        }
        /** Debugging information listing which files were used. */
        public String toString() {
            StringBuffer buf = new StringBuffer();
            Iterator it = files.iterator();
            int i = 0;
            while (it.hasNext()) {
                long t = times[i++];
                if (t != 0L) {
                    buf.append(new Date(t));
                } else {
                    buf.append("<ignoring file contents>"); // NOI18N
                }
                buf.append('\t');
                buf.append(it.next());
                buf.append('\n');
            }
            return buf.toString();
        }
    }
    
}
