/*
 * Copyright (c) 2002-2010 Gargoyle Software Inc.
 *
 * Licensed 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 com.gargoylesoftware.htmlunit.javascript.host.html;

import java.util.ArrayList;
import java.util.List;

import net.sourceforge.htmlunit.corejs.javascript.Context;
import net.sourceforge.htmlunit.corejs.javascript.Function;
import net.sourceforge.htmlunit.corejs.javascript.Scriptable;
import net.sourceforge.htmlunit.corejs.javascript.ScriptableObject;

import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.NOPTransformer;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import com.gargoylesoftware.htmlunit.BrowserVersion;
import com.gargoylesoftware.htmlunit.BrowserVersionFeatures;
import com.gargoylesoftware.htmlunit.WebWindow;
import com.gargoylesoftware.htmlunit.html.DomChangeEvent;
import com.gargoylesoftware.htmlunit.html.DomChangeListener;
import com.gargoylesoftware.htmlunit.html.DomElement;
import com.gargoylesoftware.htmlunit.html.DomNode;
import com.gargoylesoftware.htmlunit.html.DomText;
import com.gargoylesoftware.htmlunit.html.FrameWindow;
import com.gargoylesoftware.htmlunit.html.HtmlAttributeChangeEvent;
import com.gargoylesoftware.htmlunit.html.HtmlAttributeChangeListener;
import com.gargoylesoftware.htmlunit.html.HtmlElement;
import com.gargoylesoftware.htmlunit.html.HtmlNoScript;
import com.gargoylesoftware.htmlunit.html.xpath.XPathUtils;
import com.gargoylesoftware.htmlunit.javascript.SimpleScriptable;
import com.gargoylesoftware.htmlunit.javascript.configuration.JavaScriptConfiguration;
import com.gargoylesoftware.htmlunit.javascript.host.Window;
import com.gargoylesoftware.htmlunit.xml.XmlPage;

/**
 * An array of elements. Used for the element arrays returned by <tt>document.all</tt>,
 * <tt>document.all.tags('x')</tt>, <tt>document.forms</tt>, <tt>window.frames</tt>, etc.
 * Note that this class must not be used for collections that can be modified, for example
 * <tt>map.areas</tt> and <tt>select.options</tt>.
 * <br>
 * This class (like all classes in this package) is specific for the JavaScript engine.
 * Users of HtmlUnit shouldn't use it directly.
 *
 * @version $Revision: 5864 $
 * @author Daniel Gredler
 * @author Marc Guillemot
 * @author Chris Erskine
 * @author Ahmed Ashour
 */
public class HTMLCollection extends SimpleScriptable implements Function, NodeList {

    private static final long serialVersionUID = 4049916048017011764L;
    private static final Log LOG = LogFactory.getLog(HTMLCollection.class);

    private String xpath_;
    private DomNode node_;
    private boolean avoidObjectDetection_ = false;

    /**
     * The transformer used to get the element to return from the HTML element.
     * It returns the HTML element itself except for frames where it returns the nested window.
     */
    private Transformer transformer_;

    /**
     * Cache collection elements when possible, so as to avoid expensive XPath expression evaluations.
     */
    private List<Object> cachedElements_;

    /**
     * IE provides a way of enumerating through some element collections; this counter supports that functionality.
     */
    private int currentIndex_ = 0;

    /**
     * Creates an instance. JavaScript objects must have a default constructor.
     * Don't call.
     */
    @Deprecated
    public HTMLCollection() {
        // Empty.
    }

    /**
     * Creates an instance.
     * @param parentScope parent scope
     */
    public HTMLCollection(final DomNode parentScope) {
        this(parentScope.getScriptObject());
    }

    /**
     * Creates an instance.
     * @param parentScope parent scope
     */
    public HTMLCollection(final ScriptableObject parentScope) {
        setParentScope(parentScope);
        setPrototype(getPrototype(getClass()));
    }

    /**
     * Constructs an instance with an initial cache value.
     * @param parentScope the parent scope, on which we listen for changes
     * @param initialElements the initial content for the cache
     */
    HTMLCollection(final DomNode parentScope, final List<?> initialElements) {
        this(parentScope);
        init(parentScope, null);
        cachedElements_ = new ArrayList<Object>(initialElements);
    }

    /**
     * Only needed to make collections like <tt>document.all</tt> available but "invisible" when simulating Firefox.
     * {@inheritDoc}
     */
    @Override
    public boolean avoidObjectDetection() {
        return avoidObjectDetection_;
    }

    /**
     * @param newValue the new value
     */
    public void setAvoidObjectDetection(final boolean newValue) {
        avoidObjectDetection_ = newValue;
    }

    /**
     * Initializes the content of this collection. The elements will be "calculated" at each
     * access using the specified XPath expression, applied to the specified node.
     * @param node the node to serve as root for the XPath expression
     * @param xpath the XPath expression which determines the elements of the collection
     */
    public void init(final DomNode node, final String xpath) {
        init(node, xpath, NOPTransformer.INSTANCE);
    }

    /**
     * Initializes the content of this collection. The elements will be "calculated" at each
     * access using the specified XPath expression, applied to the specified node, and
     * transformed using the specified transformer.
     * @param node the node to serve as root for the XPath expression
     * @param xpath the XPath expression which determines the elements of the collection
     * @param transformer the transformer enabling the retrieval of the expected objects from
     *        the results of the XPath evaluation
     */
    public void init(final DomNode node, final String xpath, final Transformer transformer) {
        if (node != null) {
            node_ = node;
            xpath_ = xpath;
            transformer_ = transformer;
            final DomHtmlAttributeChangeListenerImpl listener = new DomHtmlAttributeChangeListenerImpl();
            node_.addDomChangeListener(listener);
            if (node_ instanceof HtmlElement) {
                ((HtmlElement) node_).addHtmlAttributeChangeListener(listener);
                cachedElements_ = null;
            }
        }
    }

    /**
     * Initializes the collection. The elements will be "calculated" as the children of the node.
     * @param node the node to grab children from
     */
    public void initFromChildren(final DomNode node) {
        if (node != null) {
            node_ = node;
            final DomHtmlAttributeChangeListenerImpl listener = new DomHtmlAttributeChangeListenerImpl();
            node_.addDomChangeListener(listener);
            if (node_ instanceof HtmlElement) {
                ((HtmlElement) node_).addHtmlAttributeChangeListener(listener);
                cachedElements_ = null;
            }
        }
        transformer_ = NOPTransformer.INSTANCE;
    }

    /**
     * {@inheritDoc}
     */
    public final Object call(final Context cx, final Scriptable scope, final Scriptable thisObj, final Object[] args) {
        if (args.length == 0) {
            throw Context.reportRuntimeError("Zero arguments; need an index or a key.");
        }
        return nullIfNotFound(getIt(args[0]));
    }

    /**
     * {@inheritDoc}
     */
    public final Scriptable construct(final Context cx, final Scriptable scope, final Object[] args) {
        return null;
    }

    /**
     * Private helper that retrieves the item or items corresponding to the specified
     * index or key.
     * @param o the index or key corresponding to the element or elements to return
     * @return the element or elements corresponding to the specified index or key
     */
    private Object getIt(final Object o) {
        if (o instanceof Number) {
            final Number n = (Number) o;
            final int i = n.intValue();
            return get(i, this);
        }
        final String key = String.valueOf(o);
        return get(key, this);
    }

    /**
     * Returns the element at the specified index, or <tt>NOT_FOUND</tt> if the index is invalid.
     * {@inheritDoc}
     */
    @Override
    public final Object get(final int index, final Scriptable start) {
        final HTMLCollection array = (HTMLCollection) start;
        final List<Object> elements = array.getElements();
        if (index >= 0 && index < elements.size()) {
            return getScriptableForElement(transformer_.transform(elements.get(index)));
        }
        return NOT_FOUND;
    }

    /**
     * Gets the HTML elements from cache or retrieve them at first call.
     * @return the list of {@link HtmlElement} contained in this collection
     */
    protected List<Object> getElements() {
        if (cachedElements_ == null) {
            cachedElements_ = computeElements();
        }
        return cachedElements_;
    }

    /**
     * Returns the elements whose associated host objects are available through this collection.
     * @return the elements whose associated host objects are available through this collection
     */
    protected List<Object> computeElements() {
        final List<Object> response;
        if (node_ != null) {
            if (xpath_ != null) {
                response = XPathUtils.getByXPath(node_, xpath_);
            }
            else {
                response = new ArrayList<Object>();
                Node node = node_.getFirstChild();
                while (node != null) {
                    response.add(node);
                    node = node.getNextSibling();
                }
            }
        }
        else {
            response = new ArrayList<Object>();
        }

        final boolean isXmlPage = node_ != null && node_.getOwnerDocument() instanceof XmlPage;

        final boolean isIE = getBrowserVersion().hasFeature(BrowserVersionFeatures.GENERATED_45);

        for (int i = 0; i < response.size(); i++) {
            final DomNode element = (DomNode) response.get(i);

            //IE: XmlPage ignores all empty text nodes
            if (isIE && isXmlPage && element instanceof DomText
                && ((DomText) element).getNodeValue().trim().length() == 0) { //and 'xml:space' is 'default'
                final Boolean xmlSpaceDefault = isXMLSpaceDefault(element.getParentNode());
                if (xmlSpaceDefault != Boolean.FALSE) {
                    response.remove(i--);
                    continue;
                }
            }
            for (DomNode parent = element.getParentNode(); parent != null;
                parent = parent.getParentNode()) {
                if (parent instanceof HtmlNoScript) {
                    response.remove(i--);
                    break;
                }
            }
        }

        return response;
    }

    /**
     * Recursively checks whether "xml:space" attribute is set to "default".
     * @param node node to start checking from
     * @return {@link Boolean#TRUE} if "default" is set, {@link Boolean#FALSE} for other value,
     *         or null if nothing is set.
     */
    private static Boolean isXMLSpaceDefault(DomNode node) {
        for ( ; node instanceof DomElement; node = node.getParentNode()) {
            final String value = ((DomElement) node).getAttribute("xml:space");
            if (value.length() != 0) {
                if (value.equals("default")) {
                    return Boolean.TRUE;
                }
                return Boolean.FALSE;
            }
        }
        return null;
    }

    /**
     * Returns the element or elements that match the specified key. If it is the name
     * of a property, the property value is returned. If it is the id of an element in
     * the array, that element is returned. Finally, if it is the name of an element or
     * elements in the array, then all those elements are returned. Otherwise,
     * {@link #NOT_FOUND} is returned.
     * {@inheritDoc}
     */
    @Override
    protected Object getWithPreemption(final String name) {
        // Test to see if we are trying to get the length of this collection?
        // If so return NOT_FOUND here to let the property be retrieved using the prototype
        if (xpath_ == null || "length".equals(name)) {
            return NOT_FOUND;
        }

        final List<Object> elements = getElements();
        CollectionUtils.transform(elements, transformer_);

        // See if there is an element in the element array with the specified id.
        for (final Object next : elements) {
            if (next instanceof DomElement) {
                final String id = ((DomElement) next).getAttribute("id");
                if (id != null && id.equals(name)) {
                    if (getBrowserVersion().hasFeature(BrowserVersionFeatures.HTMLCOLLECTION_IDENTICAL_IDS)) {
                        int totalIDs = 0;
                        for (final Object o : elements) {
                            if (o instanceof DomElement && name.equals(((DomElement) o).getAttribute("id"))) {
                                totalIDs++;
                            }
                        }
                        if (totalIDs > 1) {
                            final HTMLCollectionTags collection =
                                new HTMLCollectionTags((SimpleScriptable) getParentScope());
                            collection.setAvoidObjectDetection(
                                    !getBrowserVersion().hasFeature(BrowserVersionFeatures.GENERATED_46));
                            collection.init(node_, ".//*[@id='" + id + "']");
                            return collection;
                        }
                    }
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Property \"" + name + "\" evaluated (by id) to " + next);
                    }
                    return getScriptableForElement(next);
                }
            }
            else if (next instanceof WebWindow) {
                final WebWindow window = (WebWindow) next;
                final String windowName = window.getName();
                if (windowName != null && windowName.equals(name)) {
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Property \"" + name + "\" evaluated (by name) to " + window);
                    }
                    return getScriptableForElement(window);
                }
                if (getBrowserVersion().hasFeature(BrowserVersionFeatures.GENERATED_47) && window instanceof FrameWindow
                        && ((FrameWindow) window).getFrameElement().getAttribute("id").equals(name)) {
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Property \"" + name + "\" evaluated (by id) to " + window);
                    }
                    return getScriptableForElement(window);
                }
            }
            else {
                LOG.warn("Unrecognized type in collection: " + next + " (" + next.getClass().getName() + ")");
            }
        }

        // See if there are any elements in the element array with the specified name.
        final HTMLCollection array = new HTMLCollection(this);
        final String newCondition = "@name = '" + name.replaceAll("\\$", "\\\\\\$") + "'";
        final String xpathExpr;
        if (xpath_.endsWith("]")) {
            xpathExpr = xpath_.replaceAll("\\[([^\\]]*)\\]$", "[($1) and " + newCondition + "]");
        }
        else {
            xpathExpr = xpath_ + "[" + newCondition + "]";
        }
        array.init(node_, xpathExpr);

        final List<Object> subElements = array.getElements();
        if (subElements.size() > 1) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Property \"" + name + "\" evaluated (by name) to " + array + " with "
                    + subElements.size() + " elements");
            }
            return array;
        }
        else if (subElements.size() == 1) {
            final Scriptable singleResult = getScriptableForElement(subElements.get(0));
            if (LOG.isDebugEnabled()) {
                LOG.debug("Property \"" + name + "\" evaluated (by name) to " + singleResult);
            }
            return singleResult;
        }

        // Nothing was found.
        return NOT_FOUND;
    }

    /**
     * Returns the length of this element array.
     * @return the length of this element array
     * @see <a href="http://msdn.microsoft.com/en-us/library/ms534101.aspx">MSDN doc</a>
     */
    public final int jsxGet_length() {
        return getElements().size();
    }

    /**
     * Returns the item or items corresponding to the specified index or key.
     * @param index the index or key corresponding to the element or elements to return
     * @return the element or elements corresponding to the specified index or key
     * @see <a href="http://msdn.microsoft.com/en-us/library/ms536460.aspx">MSDN doc</a>
     */
    public final Object jsxFunction_item(final Object index) {
        return nullIfNotFound(getIt(index));
    }

    /**
     * Returns the specified object, unless it is the <tt>NOT_FOUND</tt> constant, in which case <tt>null</tt>
     * is returned for IE.
     * @param object the object to return
     * @return the specified object, unless it is the <tt>NOT_FOUND</tt> constant, in which case <tt>null</tt>
     *         is returned for IE.
     */
    private Object nullIfNotFound(final Object object) {
        if (object == NOT_FOUND) {
            if (getBrowserVersion().hasFeature(BrowserVersionFeatures.GENERATED_48)) {
                return null;
            }
            return Context.getUndefinedValue();
        }
        return object;
    }

    /**
     * Retrieves the item or items corresponding to the specified name (checks ids, and if
     * that does not work, then names).
     * @param name the name or id the element or elements to return
     * @return the element or elements corresponding to the specified name or id
     * @see <a href="http://msdn.microsoft.com/en-us/library/ms536634.aspx">MSDN doc</a>
     */
    public final Object jsxFunction_namedItem(final String name) {
        return nullIfNotFound(getIt(name));
    }

    /**
     * Returns the next node in the collection (supporting iteration in IE only).
     * @return the next node in the collection
     */
    public Object jsxFunction_nextNode() {
        Object nextNode;
        final List<Object> elements = getElements();
        if (currentIndex_ >= 0 && currentIndex_ < elements.size()) {
            nextNode = elements.get(currentIndex_);
        }
        else {
            nextNode = null;
        }
        currentIndex_++;
        return nextNode;
    }

    /**
     * Resets the node iterator accessed via {@link #jsxFunction_nextNode()}.
     */
    public void jsxFunction_reset() {
        currentIndex_ = 0;
    }

    /**
     * Returns all the elements in this element array that have the specified tag name.
     * This method returns an empty element array if there are no elements with the
     * specified tag name.
     * @param tagName the name of the tag of the elements to return
     * @return all the elements in this element array that have the specified tag name
     * @see <a href="http://msdn.microsoft.com/en-us/library/ms536776.aspx">MSDN doc</a>
     */
    public Object jsxFunction_tags(final String tagName) {
        final HTMLCollection array = new HTMLCollection(this);
        array.init(node_, xpath_ + "[name() = '" + tagName.toLowerCase() + "']");
        return array;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        if (xpath_ != null) {
            return super.toString() + '<' + xpath_ + '>';
        }
        return super.toString();
    }

    /**
     * Called for the js "==".
     * {@inheritDoc}
     */
    @Override
    protected Object equivalentValues(final Object other) {
        if (other == this) {
            return Boolean.TRUE;
        }
        else if (other instanceof HTMLCollection) {
            final HTMLCollection otherArray = (HTMLCollection) other;
            if (node_ == otherArray.node_
                    && xpath_.toString().equals(otherArray.xpath_.toString())
                    && transformer_.equals(otherArray.transformer_)) {
                return Boolean.TRUE;
            }
            return NOT_FOUND;
        }

        return super.equivalentValues(other);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean has(final String name, final Scriptable start) {
        try {
            final int index = Integer.parseInt(name);
            final List<Object> elements = getElements();
            CollectionUtils.transform(elements, transformer_);
            if (index >= 0 && index < elements.size()) {
                return true;
            }
        }
        catch (final NumberFormatException e) {
            // Ignore.
        }

        if (name.equals("length")) {
            return true;
        }
        if (!getBrowserVersion().hasFeature(BrowserVersionFeatures.GENERATED_49)) {
            final JavaScriptConfiguration jsConfig = JavaScriptConfiguration.getInstance(BrowserVersion.FIREFOX_3);
            for (final String functionName : jsConfig.getClassConfiguration(getClassName()).functionKeys()) {
                if (name.equals(functionName)) {
                    return true;
                }
            }
            return false;
        }
        return getWithPreemption(name) != NOT_FOUND;
    }

    /**
     * {@inheritDoc}.
     */
    @Override
    public Object[] getIds() {
        final List<String> idList = new ArrayList<String>();

        final List<Object> elements = getElements();
        CollectionUtils.transform(elements, transformer_);

        if (!getBrowserVersion().hasFeature(BrowserVersionFeatures.GENERATED_50)) {
            final int length = getElements().size();
            for (int i = 0; i < length; i++) {
                idList.add(Integer.toString(i));
            }

            idList.add("length");
            final JavaScriptConfiguration jsConfig = JavaScriptConfiguration.getInstance(BrowserVersion.FIREFOX_3);
            for (final String name : jsConfig.getClassConfiguration(getClassName()).functionKeys()) {
                idList.add(name);
            }
        }
        else {
            idList.add("length");

            int index = 0;
            for (final Object next : elements) {
                if (next instanceof HtmlElement) {
                    final HtmlElement element = (HtmlElement) next;
                    final String name = element.getAttribute("name");
                    if (name != HtmlElement.ATTRIBUTE_NOT_DEFINED) {
                        idList.add(name);
                    }
                    else {
                        final String id = element.getId();
                        if (id != HtmlElement.ATTRIBUTE_NOT_DEFINED) {
                            idList.add(id);
                        }
                        else {
                            idList.add(Integer.toString(index));
                        }
                    }
                    index++;
                }
                else if (next instanceof WebWindow) {
                    final WebWindow window = (WebWindow) next;
                    final String windowName = window.getName();
                    if (windowName != null) {
                        idList.add(windowName);
                    }
                }
                else if (LOG.isDebugEnabled()) {
                    LOG.debug("Unrecognized type in array: \"" + next.getClass().getName() + "\"");
                }
            }
        }
        return idList.toArray();
    }

    private class DomHtmlAttributeChangeListenerImpl implements DomChangeListener, HtmlAttributeChangeListener {

        private static final long serialVersionUID = -6690451155079053212L;

        /**
         * {@inheritDoc}
         */
        public void nodeAdded(final DomChangeEvent event) {
            cachedElements_ = null;
        }

        /**
         * {@inheritDoc}
         */
        public void nodeDeleted(final DomChangeEvent event) {
            cachedElements_ = null;
        }

        /**
         * {@inheritDoc}
         */
        public void attributeAdded(final HtmlAttributeChangeEvent event) {
            cachedElements_ = null;
        }

        /**
         * {@inheritDoc}
         */
        public void attributeRemoved(final HtmlAttributeChangeEvent event) {
            cachedElements_ = null;
        }

        /**
         * {@inheritDoc}
         */
        public void attributeReplaced(final HtmlAttributeChangeEvent event) {
            cachedElements_ = null;
        }
    }

    /**
     * {@inheritDoc}
     */
    public int getLength() {
        return jsxGet_length();
    }

    /**
     * {@inheritDoc}
     */
    public Node item(final int index) {
        return (DomNode) transformer_.transform(getElements().get(index));
    }

    /**
     * Gets the scriptable for the provided element that may already be the right scriptable.
     * @param object the object for which to get the scriptable
     * @return the scriptable
     */
    protected Scriptable getScriptableForElement(final Object object) {
        if (object instanceof Scriptable) {
            return (Scriptable) object;
        }
        else if (object instanceof WebWindow) {
            return Window.getProxy((WebWindow) object);
        }
        return getScriptableFor(object);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String getClassName() {
        return "HTMLCollection";
    }
}
