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

import java.io.IOException;
import java.beans.PropertyChangeListener;
import java.io.BufferedInputStream;
import java.io.InputStream;

import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.w3c.dom.DocumentType;


import org.openide.filesystems.FileObject;
import org.openide.filesystems.Repository;
import org.openide.loaders.*;
import org.openide.cookies.InstanceCookie;
import org.openide.util.Lookup;
import org.openide.util.lookup.*;
import org.openide.xml.EntityCatalog;
import org.openide.filesystems.FileChangeListener;
import org.openide.filesystems.FileRenameEvent;
import org.openide.filesystems.FileEvent;
import org.openide.filesystems.FileAttributeEvent;
import java.net.URL;
import org.openide.ErrorManager;


/** 
 * Entity resolver which loads entities (typically DTDs) from fixed
 * locations in the system file system, according to public ID.
 * <p>
 * It expects that PUBLIC has at maximum three "//" parts 
 * (standard // vendor // entity name // language). It is basically
 * converted to <tt>"/xml/entities/{vendor}/{entity_name}"</tt> resource name.
 * <p>
 * It also attaches <tt>Environment</tt> according to registrations
 * at <tt>/xml/lookups/</tt> area. There can be registered:
 * <tt>Environment.Provider</tt> or deprecated <tt>XMLDataObject.Processor</tt>
 * and <tt>XMLDataObject.Info</tt> instances.
 * <p>
 * All above are core implementation features.
 *
 * @author  Jaroslav Tulach
 */
public final class FileEntityResolver extends EntityCatalog implements Environment.Provider {
    private static final String ENTITY_PREFIX = "/xml/entities"; // NOI18N
    private static final String LOOKUP_PREFIX = "/xml/lookups"; // NOI18N

    static final ErrorManager ERR = ErrorManager.getDefault().getInstance(FileEntityResolver.class.getName());
    static final boolean LOG = ERR.isLoggable(ErrorManager.INFORMATIONAL);
    
    /** Constructor
     */
    public FileEntityResolver() {
    }
    
    /** Tries to find the entity on system file system.
     */
    public InputSource resolveEntity(String publicID, String systemID) throws IOException, SAXException {
        if (publicID == null) {
            return null;
        }


        String id = convertPublicId (publicID);
        
        StringBuffer sb = new StringBuffer (200);
        sb.append (ENTITY_PREFIX);
        sb.append (id);
        
        FileObject fo = Repository.getDefault ().getDefaultFileSystem ().findResource (sb.toString ());
        if (fo != null) {
            
            // fill in InputSource instance
            
            InputSource in = new InputSource (fo.getInputStream ());
            try {
                Object myPublicID = fo.getAttribute("hint.originalPublicID");  //NOI18N
                if (myPublicID instanceof String) {
                    in.setPublicId((String)myPublicID);
                }                
                URL url = fo.getURL();
                in.setSystemId(url.toString());  // we get nasty nbfs: instead nbres: but it is enough                
            } catch (IOException ex) {
                // do no care just no system id
            }
            return in;
        } else {
            return null;
        }
    }
    
    /** A method that tries to find the correct lookup for given XMLDataObject.
     * @return the lookup
     */
    public Lookup getEnvironment(DataObject obj) {
        if (obj instanceof XMLDataObject) {
            XMLDataObject xml = (XMLDataObject)obj;
            
            String id = null;
            try {
                DocumentType domDTD = xml.getDocument ().getDoctype ();
                if (domDTD != null) id = domDTD.getPublicId ();
            } catch (IOException ex) {
                ErrorManager.getDefault().notify (ex);
                return null;
            } catch (org.xml.sax.SAXException ex) {
                ErrorManager.getDefault().notify (ex);
                return null;
            }

            if (id == null) {
                return null;
            }
            
            id = convertPublicId (id);
            
            return new Lkp (id, xml);
        } else if (obj instanceof InstanceDataObject) {
            return getEnvForIDO((InstanceDataObject) obj);
        }
        return null;
    }
    
    private Lookup getEnvForIDO(InstanceDataObject ido) {
        FileEntityResolver.DTDParser parser = new DTDParser(ido.getPrimaryFile());
        parser.parse();
        String id = parser.getPublicId();
        if (id == null) return null;
        id = convertPublicId (id);
        return new Lkp (id, ido);
    }
    
    /** A method that extracts a listener from data object.
     * 
     * @param obj the data object that we are looking for environment of
     * @param source the obj that provides the environment
     * @return lookup provided by the obj or null if none has been found
     */
    private static Lookup findLookup (DataObject obj, DataObject source) {
        if (source == null) {
            return null;
        }
        
        try {
            InstanceCookie cookie = (InstanceCookie)source.getCookie (InstanceCookie.class);
            
            if (cookie != null) {
                Object inst = cookie.instanceCreate ();
                if (inst instanceof Environment.Provider) {
                    return ((Environment.Provider)inst).getEnvironment (obj);
                }
                
                if (!(obj instanceof XMLDataObject)) return null;

                if (inst instanceof XMLDataObject.Processor) {
                    // convert provider
                    XMLDataObject.Info info = new XMLDataObject.Info ();
                    info.addProcessorClass (inst.getClass ());
                    inst = info;
                }

                if (inst instanceof XMLDataObject.Info) {
                    return createInfoLookup ((XMLDataObject)obj, ((XMLDataObject.Info)inst));
                }

            }
        } catch (IOException ex) {
            ErrorManager.getDefault ().notify (ex);
        } catch (ClassNotFoundException ex) {
            ErrorManager.getDefault ().notify (ex);
        }
        
        return null;
    }
        
    
    /** Ugly hack to get to openide hidden functionality.
     */
    private static java.lang.reflect.Method method;
    private static Lookup createInfoLookup (XMLDataObject obj, XMLDataObject.Info info) {
        // well, it is a wormhole, but just for default compatibility
        synchronized (FileEntityResolver.class) {
            if (method == null) {
                try {
                    java.lang.reflect.Method m = XMLDataObject.class.getDeclaredMethod ("createInfoLookup", new Class[] { // NOI18N
                        XMLDataObject.class,
                        XMLDataObject.Info.class
                    });
                    m.setAccessible (true);
                    method = m;
                } catch (Exception ex) {
                    ErrorManager.getDefault ().notify (ex);
                    return null;
                }
            }
        }
        try {
            return (Lookup)method.invoke (null, new Object[] { obj, info });
        } catch (Exception ex) {
            ErrorManager.getDefault ().notify (ex);
            return null;
        }
    }

    /** Converts the publicID into filesystem friendly name.
     * <p>
     * It expects that PUBLIC has at maximum three "//" parts 
     * (standard // vendor // entity name // language). It is basically
     * converted to "vendor/entity_name" resource name.
     *
     * @see EntityCatalog
     */
    private static String convertPublicId (String publicID) {
        char[] arr = publicID.toCharArray ();


        int numberofslashes = 0;
        int state = 0;
        int write = 0;
        OUT: for (int i = 0; i < arr.length; i++) {
            char ch = arr[i];

            switch (state) {
            case 0:
                // initial state 
                if (ch == '+' || ch == '-' || ch == 'I' || ch == 'S' || ch == 'O') {
                    // do not write that char
                    continue;
                }
                // switch to regular state
                state = 1;
                // fallthru
            case 1:
                // regular state expecting any character
                if (ch == '/') {
                    state = 2;
                    if (++numberofslashes == 3) {
                        // last part of the ID, exit
                        break OUT;
                    }
                    arr[write++] = '/';
                    continue;
                }
                break;
            case 2:
                // previous character was /
                if (ch == '/') {
                    // ignore second / and write nothing
                    continue;
                }
                state = 1;
                break;
            }

            // write the char into the array
            if (ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9') {
                arr[write++] = ch;
            } else {
                arr[write++] = '_';
            }
        }

        return new String (arr, 0, write);
    }
        
    
    /** Finds a fileobject for given ID.
     * @param id string id
     * @param last[0] will be filled with last file object we should listen on
     * @return file object that should represent it
     */
    private static FileObject findObject (String id, FileObject[] last) {
        StringBuffer sb = new StringBuffer (200);
        sb.append (LOOKUP_PREFIX);
        sb.append (id);
        int len = sb.length ();
        // at least for now
        sb.append (".instance"); // NOI18N 

        FileObject root = Repository.getDefault ().getDefaultFileSystem ().getRoot ();
     
        String toSearch1 = sb.toString ();
        int indx = searchFolder (root, toSearch1, last);
        if (indx == -1) {
            // not possible to find folders
            return null;
        }

        FileObject fo = last[0].getFileObject (toSearch1.substring (indx));
        
        if (fo == null) {
            // try to find a file with xml extension
            sb.setLength (len);
            sb.append (".xml"); // NOI18N
            
            fo = last[0].getFileObject (sb.toString ().substring (indx));
        }
        
        return fo;
    }
    
    /** Find last folder for resourceName.
     * @param fo file object to search from
     * @param resourceName name of file to find
     * @param last last[0] will be filled with the last found name
     * @return position of last / if everything has been searched, or -1 if some files are missing
     */
    private static int searchFolder (FileObject fo, String resourceName, FileObject[] last) {
        int pos = 0;
        
        for (;;) {
            int next = resourceName.indexOf('/', pos);
            if (next == -1) {
                // end of the search
                last[0] = fo;
                return pos;
            }
            
            if (next == pos) {
                pos++;
                continue;
            }
            
            FileObject nf = fo.getFileObject(resourceName.substring (pos, next));
            if (nf == null) {
                // not found a continuation
                last[0] = fo;
                return -1;
            }
            
            // proceed to next one
            pos = next + 1;
            fo = nf;
        }
    }
    
    // internally stops documet parsing when looking for public id
    private static class StopSaxException extends SAXException {
        public StopSaxException() { super("STOP"); } //NOI18N
    }

    private static final StopSaxException STOP = new StopSaxException();
    
    ////////////////////////////////////////////////////////////////////////
    // DTDParser
    ////////////////////////////////////////////////////////////////////////
    /** resolve the PUBLIC item from the xml header of .settings file */
    private static class DTDParser extends org.xml.sax.helpers.DefaultHandler
    implements org.xml.sax.ext.LexicalHandler {
        
        private String publicId = null;
        private FileObject src;
        
        public DTDParser(FileObject src) {
            this.src = src;
        }
        
        public String getPublicId() {
            return publicId;
        }
        
        public void parse() {
            InputStream in = null;
            try {
                org.xml.sax.XMLReader reader = org.openide.xml.XMLUtil.createXMLReader(false, false);
                reader.setContentHandler(this);
                reader.setEntityResolver(this);
                in = src.getInputStream();
                InputSource is = new InputSource(in);
                try {
                    reader.setFeature("http://xml.org/sax/features/validation", false);  //NOI18N
                } catch (SAXException sex) {
                    ErrorManager.getDefault().log(ErrorManager.INFORMATIONAL,
                    "Warning: XML parser does not support validation feature."); //NOI18N
                }
                try {
                    reader.setProperty("http://xml.org/sax/properties/lexical-handler", this);  //NOI18N
                } catch (SAXException sex) {
                    ErrorManager.getDefault().log(ErrorManager.EXCEPTION,
                    "Warning: XML parser does not support lexical-handler feature.");  //NOI18N
                }
                reader.parse(is);
            } catch (StopSaxException ex) {
            } catch (Exception ex) { // SAXException, FileNotFoundException, IOException
                if (ex.getClass ().getName ().equals ("org.openide.util.lookup.AbstractLookup$ISE")) { // NOI18N
                    // this is covered by the FileEntityResolverDeadlock54971Test
                    throw (IllegalStateException)ex;
                }
                
                try {
                    // #25082: do not notify an exception if the file comes
                    // from other filesystem than the system filesystem
                    if (src.getFileSystem() == Repository.getDefault().getDefaultFileSystem()) {
                        ErrorManager.getDefault().notify(ErrorManager.INFORMATIONAL, ex);
                    }
                } catch (org.openide.filesystems.FileStateInvalidException fie) {
                    // ignore
                }
            } finally {
                try {
                    if (in != null) {
                        in.close();
                    }
                } catch (IOException exc) {
                    ErrorManager.getDefault().notify(ErrorManager.INFORMATIONAL, exc);
                }
            }
        }
        
        public InputSource resolveEntity(String publicId, String systemID) {
            InputSource ret = new InputSource(new java.io.StringReader("")); // NOI18N
            ret.setSystemId("StringReader");  //NOI18N
            return ret;
        }
        
        public void endDTD() throws org.xml.sax.SAXException {
            throw STOP;
        }
        
        public void startDTD(String name, String publicId, String systemId) throws org.xml.sax.SAXException {
            this.publicId = publicId;
        }
        
        public void startEntity(String str) throws org.xml.sax.SAXException {}
        public void endEntity(String str) throws org.xml.sax.SAXException {}
        public void comment(char[] values, int param, int param2) throws org.xml.sax.SAXException {}
        public void startCDATA() throws org.xml.sax.SAXException {}
        public void endCDATA() throws org.xml.sax.SAXException {}
        
    }
    
    
    /** A special lookup associated with id.
     */
    private static final class Lkp extends ProxyLookup
    implements PropertyChangeListener, FileChangeListener {
        /** converted ID we are associated with */
        private String id;
        /** for this data object we initialized this lookup */
        private DataObject xml;
        
        /** last file folder we are listening on. Initialized lazily */
        private volatile FileObject folder;
        /** a data object that produces values Initialized lazily */
        private volatile DataObject obj;
        
        /** @param id the id to work on */
        public Lkp (String id, DataObject xml) {
            super (new Lookup[0]);
            this.id = id;
            this.xml = xml;
        }
     
        /** Check whether all necessary values are updated.
         */
        protected void beforeLookup (Template t) {
            if (LOG) {
                ERR.log("beforeLookup: " + t.getType() + " for " + xml); // NOI18N
            }
            
            if (folder == null && obj == null) {
                update ();
            }
        }
        
        /** Updates current state of the lookup.
         */
        private void update () {
            if (LOG) ERR.log("update: " + id + " for " + xml); // NOI18N
            FileObject[] last = new FileObject[1];
            FileObject fo = findObject (id, last);
            if (LOG) ERR.log("fo: " + fo + " for " + xml); // NOI18N
            DataObject o = null;
            
            if (fo != null) {
                try {
                    o = DataObject.find (fo);
                    if (LOG) ERR.log("object found: " + o + " for " + xml); // NOI18N
                } catch (org.openide.loaders.DataObjectNotFoundException ex) {
                    ERR.notify (ex);
                }
            }
        
            if (o == obj) {
                if (LOG) ERR.log("same data object" + " for " + xml); // NOI18N
                // the data object is still the same as used to be
                // 
                Lookup l = findLookup (xml, o);
                if (o != null && l != null) {
                    if (LOG) ERR.log("updating lookups" + " for " + xml); // NOI18N
                    // just update the lookups
                    setLookups (new Lookup[] { l });
                    if (LOG) ERR.log("updating lookups done" + " for " + xml); // NOI18N
                    // and exit
                    return;
                } 
            } else {
                // data object changed
                Lookup l = findLookup(xml, o);
                
                if (o != null && l != null) {
                    if (LOG) ERR.log("change the lookup"); // NOI18N
                    // add listener to changes of the data object
                    o.addPropertyChangeListener (
                        org.openide.util.WeakListeners.propertyChange (this, o)
                    );
                    // update the lookups
                    setLookups (new Lookup[] { l });
                    if (LOG) ERR.log("change in lookup done" + " for " + xml); // NOI18N
                    // and exit
                    obj = o;
                    if (LOG) ERR.log("data object updated to " + obj + " for " + xml); // NOI18N
                    return;
                } else {
                    obj = o;
                    if (LOG) ERR.log("data object updated to " + obj + " for " + xml); // NOI18N
                }
            }
            
            if (LOG) ERR.log("delegating to nobody for " + obj + " for " + xml); // NOI18N
            // object is null => there are no lookups
            setLookups (new Lookup[0]);
            
            // and start listening on latest existing folder 
            // if we did not do it yet
            if (folder != last[0]) {
                folder = last[0];
                last[0].addFileChangeListener (
                    org.openide.filesystems.FileUtil.weakFileChangeListener (this, last[0])
                );
            }
        }
        
        /** Fired when a file is deleted.
         * @param fe the event describing context where action has taken place
         */
        public void fileDeleted(FileEvent fe) {
            update ();
        }
        
        /** Fired when a new folder is created. This action can only be
         * listened to in folders containing the created folder up to the root of
         * file system.
         *
         * @param fe the event describing context where action has taken place
         */
        public void fileFolderCreated(FileEvent fe) {
            update ();
        }
        
        /** Fired when a new file is created. This action can only be
         * listened in folders containing the created file up to the root of
         * file system.
         *
         * @param fe the event describing context where action has taken place
         */
        public void fileDataCreated(FileEvent fe) {
            update ();
        }
        
        /** Fired when a file attribute is changed.
         * @param fe the event describing context where action has taken place,
         *          the name of attribute and the old and new values.
         */
        public void fileAttributeChanged(FileAttributeEvent fe) {
        }
        
        public void propertyChange(java.beans.PropertyChangeEvent ev) {
            String name = ev.getPropertyName();
            
            if (
                DataObject.PROP_COOKIE == name ||
                DataObject.PROP_NAME == name ||
                DataObject.PROP_VALID == name ||
                DataObject.PROP_PRIMARY_FILE == name
            ) {
                update ();
            }
        }
        
        /** Fired when a file is renamed.
         * @param fe the event describing context where action has taken place
         *          and the original name and extension.
         */
        public void fileRenamed(FileRenameEvent fe) {
            update ();
        }
        
        /** Fired when a file is changed.
         * @param fe the event describing context where action has taken place
         */
        public void fileChanged(FileEvent fe) {
        }
        
    }
}
