/*
 * Jitsi, the OpenSource Java VoIP and Instant Messaging client.
 *
 * Copyright @ 2015 Atlassian Pty Ltd
 *
 * 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 net.java.sip.communicator.impl.protocol.jabber;

import java.util.*;
import java.util.concurrent.*;

import net.java.sip.communicator.impl.protocol.jabber.extensions.caps.*;
import net.java.sip.communicator.service.protocol.*;
import net.java.sip.communicator.util.*;

import org.jivesoftware.smack.*;
import org.jivesoftware.smack.filter.*;
import org.jivesoftware.smack.packet.*;
import org.jivesoftware.smack.util.*;
import org.jivesoftware.smackx.*;
import org.jivesoftware.smackx.packet.*;

/**
 * An wrapper to smack's default {@link ServiceDiscoveryManager} that adds
 * support for XEP-0115 - Entity Capabilities.
 *
 * This work is based on Jonas Adahl's smack fork.
 *
 * @author Emil Ivov
 * @author Lubomir Marinov
 */
public class ScServiceDiscoveryManager
    implements PacketInterceptor,
               NodeInformationProvider
{
    /**
     * The <tt>Logger</tt> used by the <tt>ScServiceDiscoveryManager</tt>
     * class and its instances for logging output.
     */
    private static final Logger logger
        = Logger.getLogger(ScServiceDiscoveryManager.class);

    /**
     * The flag which indicates whether we are currently storing non-caps.
     */
    private final boolean cacheNonCaps;

    /**
     * The cache of non-caps. Used only if {@link #cacheNonCaps} is
     * <tt>true</tt>.
     */
    private final Map<String, DiscoverInfo> nonCapsCache
        = new ConcurrentHashMap<String, DiscoverInfo>();

    /**
     * The <tt>EntitiCapsManager</tt> used by this instance to handle entity
     * capabilities.
     */
    private final EntityCapsManager capsManager;

    /**
     * The {@link ServiceDiscoveryManager} that we are wrapping.
     */
    private final ServiceDiscoveryManager discoveryManager;

    /**
     * The parent provider
     */
    private final ProtocolProviderService parentProvider;

    /**
     * The {@link Connection} that this manager is responsible for.
     */
    private final Connection connection;

    /**
     * A local copy that we keep in sync with {@link ServiceDiscoveryManager}'s
     * feature list that as we add and remove features to it.
     */
    private final List<String> features;

    /**
     * The unmodifiable view of {@link #features} which can be exposed to the
     * public through {@link #getFeatures()}, for example.
     */
    private final List<String> unmodifiableFeatures;

    /**
     * A {@link List} of the identities we use in our disco answers.
     */
    private final List<DiscoverInfo.Identity> identities;

    /**
     * Capabilities to put in ext attribute of capabilities stanza.
     */
    private final List<String> extCapabilities = new ArrayList<String>();

    /**
     * The runnable responsible for retrieving discover info.
     */
    private DiscoveryInfoRetriever retriever = new DiscoveryInfoRetriever();

    /**
     * Creates a new <tt>ScServiceDiscoveryManager</tt> wrapping the default
     * discovery manager of the specified <tt>connection</tt>.
     *
     * @param parentProvider the parent provider that creates discovery manager.
     * @param connection Smack connection object that will be used by this
     * instance to handle XMPP connection.
     * @param featuresToRemove an array of <tt>String</tt>s representing the
     * features to be removed from the <tt>ServiceDiscoveryManager</tt> of the
     * specified <tt>connection</tt> which is to be wrapped by the new instance
     * @param featuresToAdd an array of <tt>String</tt>s representing the
     * features to be added to the new instance and to the
     * <tt>ServiceDiscoveryManager</tt> of the specified <tt>connection</tt>
     * which is to be wrapped by the new instance
     * @param cacheNonCaps <tt>true</tt> if we want to cache entity features
     *                     even though it does not support XEP-0115
     */
    public ScServiceDiscoveryManager(
            ProtocolProviderService parentProvider,
            Connection connection,
            String[] featuresToRemove,
            String[] featuresToAdd,
            boolean cacheNonCaps)
    {
        this.parentProvider = parentProvider;
        this.connection = connection;

        this.discoveryManager
            = ServiceDiscoveryManager.getInstanceFor(connection);

        this.features = new ArrayList<String>();
        this.unmodifiableFeatures = Collections.unmodifiableList(this.features);
        this.identities = new ArrayList<DiscoverInfo.Identity>();

        this.cacheNonCaps = cacheNonCaps;

        DiscoverInfo.Identity identity
            = new DiscoverInfo.Identity(
                    "client",
                    ServiceDiscoveryManager.getIdentityName());

        identity.setType(ServiceDiscoveryManager.getIdentityType());
        identities.add(identity);

        //add support for capabilities
        discoveryManager.addFeature(CapsPacketExtension.NAMESPACE);

        /*
         * Reflect featuresToRemove and featuresToAdd before
         * updateEntityCapsVersion() in order to persist only the complete
         * node#ver association with our own DiscoverInfo. Otherwise, we'd
         * persist all intermediate ones upon each addFeature() and
         * removeFeature().
         */
        // featuresToRemove
        if (featuresToRemove != null)
        {
            for (String featureToRemove : featuresToRemove)
                discoveryManager.removeFeature(featureToRemove);
        }
        // featuresToAdd
        if (featuresToAdd != null)
        {
            for (String featureToAdd : featuresToAdd)
                if (!discoveryManager.includesFeature(featureToAdd))
                    discoveryManager.addFeature(featureToAdd);
        }

        // For every XMPPConnection, add one EntityCapsManager.
        this.capsManager = new EntityCapsManager();
        capsManager.addPacketListener(connection);

        /*
         * XXX initFeatures() has to happen before updateEntityCapsVersion().
         * Otherwise, updateEntityCapsVersion() will not include the features of
         * the wrapped discoveryManager.
         */
        initFeatures();
        updateEntityCapsVersion();

        // Now, make sure we intercept presence packages and add caps data when
        // intended. XEP-0115 specifies that a client SHOULD include entity
        // capabilities with every presence notification it sends.
        connection.addPacketInterceptor(
                this,
                new PacketTypeFilter(Presence.class));
    }

    /**
     * Registers that a new feature is supported by this XMPP entity. When this
     * client is queried for its information the registered features will be
     * answered.
     * <p>
     * Since no packet is actually sent to the server it is safe to perform
     * this operation before logging to the server. In fact, you may want to
     * configure the supported features before logging to the server so that
     * the information is already available if it is required upon login.
     *
     * @param feature the feature to register as supported.
     */
    public void addFeature(String feature)
    {
        synchronized (features)
        {
            features.add(feature);
            discoveryManager.addFeature(feature);
        }
        updateEntityCapsVersion();
    }

    /**
     * Recalculates the entity capabilities caps ver string according to what's
     * currently available in our own discovery info.
     */
    private void updateEntityCapsVersion()
    {
        // If a XMPPConnection is the managed one, see that the new version is
        // updated
        if ((connection != null) && (capsManager != null))
            capsManager.calculateEntityCapsVersion(getOwnDiscoverInfo());
    }

    /**
     * Returns a reference to our local copy of the feature list supported by
     * this implementation.
     *
     * @return a reference to our local copy of the feature list supported by
     * this implementation.
     */
    public List<String> getFeatures()
    {
        return unmodifiableFeatures;
    }

    /**
     * Get a DiscoverInfo for the current entity caps node.
     *
     * @return a DiscoverInfo for the current entity caps node
     */
    public DiscoverInfo getOwnDiscoverInfo()
    {
        DiscoverInfo di = new DiscoverInfo();

        di.setType(IQ.Type.RESULT);
        di.setNode(capsManager.getNode() + "#" + getEntityCapsVersion());

        // Add discover info
        addDiscoverInfoTo(di);
        return di;
    }

    /**
     * Returns the caps version as returned by our caps manager or <tt>null</tt>
     * if we don't have a caps manager yet.
     *
     * @return the caps version as returned by our caps manager or <tt>null</tt>
     * if we don't have a caps manager yet.
     */
    private String getEntityCapsVersion()
    {
        return (capsManager == null) ? null : capsManager.getCapsVersion();
    }

    /**
     * Populates a specific <tt>DiscoverInfo</tt> with the identity and features
     * of the current entity caps node.
     *
     * @param response the discover info response packet
     */
    private void addDiscoverInfoTo(DiscoverInfo response)
    {
        // Set this client identity
        DiscoverInfo.Identity identity
            = new DiscoverInfo.Identity(
                    "client",
                    ServiceDiscoveryManager.getIdentityName());

        identity.setType(ServiceDiscoveryManager.getIdentityType());
        response.addIdentity(identity);

        // Add the registered features to the response

        // Add Entity Capabilities (XEP-0115) feature node.
        /*
         * XXX Only addFeature if !containsFeature. Otherwise, the DiscoverInfo
         * may end up with repeating features.
         */
        if (!response.containsFeature(CapsPacketExtension.NAMESPACE))
            response.addFeature(CapsPacketExtension.NAMESPACE);

        Iterable<String> features = getFeatures();

        synchronized (features)
        {
            for (String feature : features)
                if (!response.containsFeature(feature))
                    response.addFeature(feature);
        }
    }

    /**
     * Returns <tt>true</tt> if the specified feature is registered in our
     * {@link ServiceDiscoveryManager} and <tt>false</tt> otherwise.
     *
     * @param feature the feature to look for.
     *
     * @return a boolean indicating if the specified featured is registered or
     * not.
     */
    public boolean includesFeature(String feature)
    {
        return this.discoveryManager.includesFeature(feature);
    }

    /**
     * Removes the specified feature from the supported features by the
     * encapsulated ServiceDiscoveryManager.<p>
     *
     * Since no packet is actually sent to the server it is safe to perform
     * this operation before logging to the server.
     *
     * @param feature the feature to remove from the supported features.
     */
    public void removeFeature(String feature)
    {
        synchronized (features)
        {
            features.remove(feature);
            discoveryManager.removeFeature(feature);
        }
        updateEntityCapsVersion();
    }

    /**
     * Add feature to put in "ext" attribute.
     *
     * @param ext ext feature to add
     */
    public void addExtFeature(String ext)
    {
        synchronized(extCapabilities)
        {
            extCapabilities.add(ext);
        }
    }

    /**
     * Remove "ext" feature.
     *
     * @param ext ext feature to remove
     */
    public void removeExtFeature(String ext)
    {
        synchronized(extCapabilities)
        {
            extCapabilities.remove(ext);
        }
    }

    /**
     * Get "ext" value.
     *
     * @return string that represents "ext" value
     */
    public synchronized String getExtFeatures()
    {
        StringBuilder bldr = new StringBuilder("");

        for(String e : extCapabilities)
        {
            bldr.append(e);
            bldr.append(" ");
        }

        return bldr.toString();
    }

    /**
     * Intercepts outgoing presence packets and adds entity capabilities at
     * their ends.
     *
     * @param packet the (hopefully presence) packet we need to add a "c"
     * element to.
     */
    public void interceptPacket(Packet packet)
    {
        if ((packet instanceof Presence) && (capsManager != null))
        {
            String ver = getEntityCapsVersion();
            CapsPacketExtension caps
                = new CapsPacketExtension(
                        getExtFeatures(),
                        capsManager.getNode(),
                        CapsPacketExtension.HASH_METHOD,
                        ver);

            //make sure we'll be able to handle requests for the newly generated
            //node once we've used it.
            discoveryManager.setNodeInformationProvider(
                    caps.getNode() + "#" + caps.getVersion(),
                    this);

            // Remove old capabilities extension if present
            PacketExtension oldCaps
                = packet.getExtension(
                        CapsPacketExtension.ELEMENT_NAME,
                        CapsPacketExtension.NAMESPACE);
            if (oldCaps != null)
            {
                packet.removeExtension(oldCaps);
            }
            // Put new capabilities extension
            packet.addExtension(caps);
        }
    }

    /**
     * Returns a list of the Items
     * {@link org.jivesoftware.smackx.packet.DiscoverItems.Item} defined in the
     * node or in other words <tt>null</tt> since we don't support any.
     *
     * @return always <tt>null</tt> since we don't support items.
     */
    public List<DiscoverItems.Item> getNodeItems()
    {
        return null;
    }

    /**
     * Returns a list of the features defined in the node. For
     * example, the entity caps protocol specifies that an XMPP client
     * should answer with each feature supported by the client version
     * or extension.
     *
     * @return a list of the feature strings defined in the node.
     */
    public List<String> getNodeFeatures()
    {
        return getFeatures();
    }

    /**
     * Returns a list of the identities defined in the node. For example, the
     * x-command protocol must provide an identity of category automation and
     * type command-node for each command.
     *
     * @return a list of the Identities defined in the node.
     */
    public List<DiscoverInfo.Identity> getNodeIdentities()
    {
        return identities;
    }

    /**
     * Initialize our local features copy in a way that would
     */
    private void initFeatures()
    {
        Iterator<String> defaultFeatures = discoveryManager.getFeatures();

        synchronized (features)
        {
            while (defaultFeatures.hasNext())
            {
                String feature = defaultFeatures.next();

                this.features.add( feature );
            }
        }
    }

    /**
     * Returns the discovered information of a given XMPP entity addressed by
     * its JID.
     *
     * @param entityID the address of the XMPP entity.
     * @return the discovered information.
     * @throws XMPPException if the operation failed for some reason.
     */
    public DiscoverInfo discoverInfo(String entityID)
        throws XMPPException
    {
        DiscoverInfo discoverInfo = capsManager.getDiscoverInfoByUser(entityID);

        if (discoverInfo != null)
            return discoverInfo;

        EntityCapsManager.Caps caps = capsManager.getCapsByUser(entityID);

        // if caps is not valid, has empty hash
        if (cacheNonCaps && (caps == null || !caps.isValid(discoverInfo)))
        {
            discoverInfo = nonCapsCache.get(entityID);
            if (discoverInfo != null)
                return discoverInfo;
        }

        discoverInfo
            = discoverInfo(
                    entityID,
                    (caps == null) ? null : caps.getNodeVer());

        if ((caps != null) && !caps.isValid(discoverInfo))
        {
            if(!caps.hash.equals(""))
            {
                logger.error(
                        "Invalid DiscoverInfo for " + caps.getNodeVer() + ": "
                            + discoverInfo);
            }
            caps = null;
        }

        if (caps == null)
        {
            if (cacheNonCaps)
                nonCapsCache.put(entityID, discoverInfo);
        }
        else
            EntityCapsManager.addDiscoverInfoByCaps(caps, discoverInfo);
        return discoverInfo;
    }

    /**
     * Returns the discovered information of a given XMPP entity addressed by
     * its JID if locally cached, otherwise schedules for retrieval.
     *
     * @param entityID the address of the XMPP entity.
     * @return the discovered information.
     * @throws XMPPException if the operation failed for some reason.
     */
    public DiscoverInfo discoverInfoNonBlocking(String entityID)
        throws XMPPException
    {
        DiscoverInfo discoverInfo = capsManager.getDiscoverInfoByUser(entityID);

        if (discoverInfo != null)
            return discoverInfo;

        EntityCapsManager.Caps caps = capsManager.getCapsByUser(entityID);

        // if caps is not valid, has empty hash
        if (cacheNonCaps && (caps == null || !caps.isValid(discoverInfo)))
        {
            discoverInfo = nonCapsCache.get(entityID);
            if (discoverInfo != null)
                return discoverInfo;
        }

        // add to retrieve thread
        retriever.addEntityForRetrieve(
            entityID,
            caps);

        return null;
    }

    /**
     * Returns the discovered information of a given XMPP entity addressed by
     * its JID and note attribute. Use this message only when trying to query
     * information which is not directly addressable.
     *
     * @param entityID the address of the XMPP entity.
     * @param node the attribute that supplements the 'jid' attribute.
     *
     * @return the discovered information.
     *
     * @throws XMPPException if the operation failed for some reason.
     */
    public DiscoverInfo discoverInfo(String entityID, String node)
        throws XMPPException
    {
        return discoveryManager.discoverInfo(entityID, node);
    }

    /**
     * Returns the discovered items of a given XMPP entity addressed by its JID.
     *
     * @param entityID the address of the XMPP entity.
     *
     * @return the discovered information.
     *
     * @throws XMPPException if the operation failed for some reason.
     */
    public DiscoverItems discoverItems(String entityID) throws XMPPException
    {
        return discoveryManager.discoverItems(entityID);
    }

    /**
     * Returns the discovered items of a given XMPP entity addressed by its JID
     * and note attribute. Use this message only when trying to query
     * information which is not directly addressable.
     *
     * @param entityID the address of the XMPP entity.
     * @param node the attribute that supplements the 'jid' attribute.
     *
     * @return the discovered items.
     *
     * @throws XMPPException if the operation failed for some reason.
     */
    public DiscoverItems discoverItems(String entityID, String node)
        throws XMPPException
    {
        return discoveryManager.discoverItems(entityID, node);
    }

    /**
     * Returns <tt>true</tt> if <tt>jid</tt> supports the specified
     * <tt>feature</tt> and <tt>false</tt> otherwise. The method may check the
     * information locally if we've already cached this <tt>jid</tt>'s disco
     * info, or retrieve it from the network.
     *
     * @param jid the jabber ID we'd like to test for support
     * @param feature the URN feature we are interested in
     *
     * @return true if <tt>jid</tt> is discovered to support <tt>feature</tt>
     * and <tt>false</tt> otherwise.
     */
    public boolean supportsFeature(String jid, String feature)
    {
        DiscoverInfo info;

        try
        {
            info = this.discoverInfo(jid);
        }
        catch(XMPPException ex)
        {
            logger.info("failed to retrieve disco info for " + jid
                                + " feature " + feature, ex);
            return false;
        }

        return ((info != null) && info.containsFeature(feature));
    }

    /**
     * Gets the <tt>EntityCapsManager</tt> which handles the entity capabilities
     * for this <tt>ScServiceDiscoveryManager</tt>.
     *
     * @return the <tt>EntityCapsManager</tt> which handles the entity
     * capabilities for this <tt>ScServiceDiscoveryManager</tt>
     */
    public EntityCapsManager getCapsManager()
    {
        return capsManager;
    }

    /**
     * Clears/stops what's needed.
     */
    public void stop()
    {
        if(retriever != null)
            retriever.stop();
    }

    /**
     * Thread that runs the discovery info.
     */
    private class DiscoveryInfoRetriever
        implements Runnable
    {
        /**
         * start/stop.
         */
        private boolean stopped = true;

        /**
         * The thread that runs this dispatcher.
         */
        private Thread retrieverThread = null;

        /**
         * Entities to be processed and their caps.
         * HashMap so we can store null caps.
         */
        private Map<String, EntityCapsManager.Caps> entities
            = new HashMap<String, EntityCapsManager.Caps>();

        /**
         * Our capability operation set.
         */
        private OperationSetContactCapabilitiesJabberImpl capabilitiesOpSet;

        /**
         * Runs in different thread.
         */
        public void run()
        {
            try
            {
                stopped = false;

                while(!stopped)
                {
                    Map.Entry<String, EntityCapsManager.Caps>
                        entityToProcess = null;

                    synchronized(entities)
                    {
                        if(entities.size() == 0)
                        {
                            try
                            {
                                entities.wait();
                            }
                            catch (InterruptedException iex){}
                        }

                        Iterator<Map.Entry<String, EntityCapsManager.Caps>>
                            iter = entities.entrySet().iterator();
                        if(iter.hasNext())
                        {
                            entityToProcess = iter.next();
                            iter.remove();
                        }
                    }

                    if(entityToProcess != null)
                    {
                        // process
                        requestDiscoveryInfo(
                            entityToProcess.getKey(),
                            entityToProcess.getValue());
                    }

                    entityToProcess = null;
                }
            } catch(Throwable t)
            {
                logger.error("Error requesting discovery info, " +
                    "thread ended unexpectedly", t);
            }
        }

        /**
         * Requests the discovery info and fires the event if
         * retrieved.
         * @param entityID the entity to request
         * @param caps and its capability.
         */
        private void requestDiscoveryInfo(final String entityID,
                                          EntityCapsManager.Caps caps)
        {
            try
            {
                DiscoverInfo discoverInfo = discoverInfo(
                            entityID,
                            (caps == null ) ? null : caps.getNodeVer());

                if ((caps != null) && !caps.isValid(discoverInfo))
                {
                    if(!caps.hash.equals(""))
                    {
                        logger.error("Invalid DiscoverInfo for "
                            + caps.getNodeVer() + ": " + discoverInfo);
                    }
                    caps = null;
                }

                boolean fireEvent = false;

                if (caps == null)
                {
                    if (cacheNonCaps)
                    {
                        nonCapsCache.put(entityID, discoverInfo);
                        fireEvent = true;
                    }
                }
                else
                {
                    EntityCapsManager.addDiscoverInfoByCaps(caps, discoverInfo);
                    fireEvent = true;
                }

                // fire event
                if(fireEvent && capabilitiesOpSet != null)
                {
                    capabilitiesOpSet.fireContactCapabilitiesChanged(
                        entityID,
                        capsManager.getFullJidsByBareJid(
                            StringUtils.parseBareAddress(entityID))
                        );
                }
            }
            catch(XMPPException ex)
            {
                // print discovery info errors only when trace is enabled
                if(logger.isTraceEnabled())
                    logger.error("Error requesting discover info for "
                        + entityID, ex);
            }
        }

        /**
         * Queue entities for retrieval.
         * @param entityID the entity.
         * @param caps and its capability.
         */
        public void addEntityForRetrieve(String entityID,
                                         EntityCapsManager.Caps caps)
        {
            synchronized(entities)
            {

                if(!entities.containsKey(entityID))
                {
                    entities.put(entityID, caps);
                    entities.notifyAll();

                    if(retrieverThread == null)
                    {
                        start();
                    }
                }
            }
        }

        /**
         * Start thread.
         */
        private void start()
        {
            capabilitiesOpSet = (OperationSetContactCapabilitiesJabberImpl)
                parentProvider.getOperationSet(
                    OperationSetContactCapabilities.class);

            retrieverThread = new Thread(
                this,
                ScServiceDiscoveryManager.class.getName());
            retrieverThread.setDaemon(true);

            retrieverThread.start();


        }

        /**
         * Stops and clears.
         */
        void stop()
        {
            synchronized(entities)
            {
                stopped = true;
                entities.notifyAll();

                retrieverThread = null;
            }
        }
    }
}
