/*
 * Copyright (C) 2005-2008 Jive Software. All rights reserved.
 *
 * 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 org.jivesoftware.openfire.component;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

import org.dom4j.Element;
import org.jivesoftware.openfire.PacketException;
import org.jivesoftware.openfire.PacketRouter;
import org.jivesoftware.openfire.RoutableChannelHandler;
import org.jivesoftware.openfire.RoutingTable;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.openfire.container.BasicModule;
import org.jivesoftware.openfire.disco.IQDiscoItemsHandler;
import org.jivesoftware.openfire.session.ComponentSession;
import org.jivesoftware.util.JiveGlobals;
import org.jivesoftware.util.cache.CacheFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmpp.component.Component;
import org.xmpp.component.ComponentException;
import org.xmpp.component.ComponentManager;
import org.xmpp.component.ComponentManagerFactory;
import org.xmpp.component.IQResultListener;
import org.xmpp.packet.IQ;
import org.xmpp.packet.JID;
import org.xmpp.packet.Packet;
import org.xmpp.packet.Presence;

/**
 * Manages the registration and delegation of Components. The ComponentManager
 * is responsible for managing registration and delegation of {@link Component Components},
 * as well as offering a facade around basic server functionallity such as sending and
 * receiving of packets.<p>
 *
 * This component manager will be an internal service whose JID will be component.[domain]. So the
 * component manager will be able to send packets to other internal or external components and also
 * receive packets from other components or even from trusted clients (e.g. ad-hoc commands).
 *
 * @author Derek DeMoro
 */
public class InternalComponentManager extends BasicModule implements ComponentManager, RoutableChannelHandler {

    private static final Logger Log = LoggerFactory.getLogger(InternalComponentManager.class);

    final private Map<String, RoutableComponents> routables = new ConcurrentHashMap<>();
    private Map<String, IQ> componentInfo = new ConcurrentHashMap<>();
    private Map<JID, JID> presenceMap = new ConcurrentHashMap<>();
    /**
     * Holds the list of listeners that will be notified of component events.
     */
    private List<ComponentEventListener> listeners = new CopyOnWriteArrayList<>();

    private static InternalComponentManager instance;
    /**
     * XMPP address of this internal service. The address is of the form: component.[domain]
     */
    private JID serviceAddress;
    /**
     * Holds the domain of the server. We are using an iv since we use this value many times
     * in many methods.
     */
    private String serverDomain;
    private RoutingTable routingTable;

    public InternalComponentManager() {
        super("Internal Component Manager");
        instance = this;
    }

    public static InternalComponentManager getInstance() {
        return instance;
    }

    @Override
    public void initialize(XMPPServer server) {
        super.initialize(server);
        routingTable = server.getRoutingTable();
    }

    @Override
    public void start() {
        // Set this ComponentManager as the current component manager
        ComponentManagerFactory.setComponentManager(instance);

        XMPPServer server = XMPPServer.getInstance();
        serverDomain = server.getServerInfo().getXMPPDomain();
        // Set the address of this internal service. component.[domain]
        serviceAddress = new JID(null, "component." + serverDomain, null);
        if (!server.isSetupMode()) {
            // Add a route to this service
            server.getRoutingTable().addComponentRoute(getAddress(), this);
        }
    }

    @Override
    public void stop() {
        super.stop();
        if (getAddress() != null) {
            // Remove the route to this service
            XMPPServer.getInstance().getRoutingTable().removeComponentRoute(getAddress());
        }
    }

    @Override
    public void addComponent(String subdomain, Component component) throws ComponentException {
        synchronized (routables) {
            RoutableComponents routable = routables.get(subdomain);
            if (routable != null && routable.hasComponent(component)) {
                // This component has already registered with this subdomain.
                // TODO: Is this all we should do?  Should we return an error?
                return;
            }
            Log.debug("InternalComponentManager: Registering component for domain: " + subdomain);
            JID componentJID = new JID(subdomain + "." + serverDomain);
            boolean notifyListeners = false;
            if (routable != null) {
                routable.addComponent(component);
            }
            else {
                routable = new RoutableComponents(componentJID, component);
                routables.put(subdomain, routable);

                if (!routingTable.hasComponentRoute(componentJID)) {
                    notifyListeners = true;
                }
                // Add the route to the new service provided by the component
                routingTable.addComponentRoute(componentJID, routable);
            }

            // Initialize the new component
            try {
                component.initialize(componentJID, this);
                component.start();

                if (notifyListeners) {
                    // Notify listeners that a new component has been registered
                    notifyComponentRegistered(componentJID);
                    // Alert other nodes of new registered domain event
                    CacheFactory.doClusterTask(new NotifyComponentRegistered(componentJID));
                }

                // Check for potential interested users.
                checkPresences();
                // Send a disco#info request to the new component. If the component provides information
                // then it will be added to the list of discoverable server items.
                checkDiscoSupport(component, componentJID);
                Log.debug("InternalComponentManager: Component registered for domain: " + subdomain);
            }
            catch (Exception e) {
                // Unregister the component's domain
                routable.removeComponent(component);
                if (e instanceof ComponentException) {
                    // Rethrow the exception
                    throw (ComponentException)e;
                }
                // Rethrow the exception
                throw new ComponentException(e);
            }
            finally {
                if (routable.numberOfComponents() == 0) {
                    // If there are no more components associated with this subdomain, remove it.
                    routables.remove(subdomain);
                    // Remove the route
                    XMPPServer.getInstance().getRoutingTable().removeComponentRoute(componentJID);
                }
            }
        }
    }

    void notifyComponentRegistered(JID componentJID) {
        for (ComponentEventListener listener : listeners) {
            listener.componentRegistered(componentJID);
        }
    }

    /**
     * Removes a component. The {@link Component#shutdown} method will be called on the
     * component. Note that if the component was an external component that was connected
     * several times then all its connections will be terminated.
     *
     * @param subdomain the subdomain of the component's address.
     */
    @Override
    public void removeComponent(String subdomain) {
        RoutableComponents components = null;
        if (routables == null || (components = routables.get(subdomain)) == null) {
            return;
        }
        List<Component> componentsToRemove = new ArrayList<>(components.getComponents());
        for (Component component : componentsToRemove) {
            removeComponent(subdomain, component);
        }
    }

    /**
     * Removes a given component. Unlike {@link #removeComponent(String)} this method will just
     * remove a single component instead of all components associated to the subdomain. External
     * components may connect several times and register for the same subdomain. This method
     * just removes a singled connection not all of them.
     *
     * @param subdomain the subdomain of the component's address.
     * @param component specific component to remove.
     */
    public void removeComponent(String subdomain, Component component) {
        if (component == null) {
            return;
        }
        synchronized (routables) {
            Log.debug("InternalComponentManager: Unregistering component for domain: " + subdomain);
            RoutableComponents routable = routables.get(subdomain);
            routable.removeComponent(component);
            if (routable.numberOfComponents() == 0) {
                routables.remove(subdomain);

                JID componentJID = new JID(subdomain + "." + serverDomain);

                // Remove the route for the service provided by the component
                routingTable.removeComponentRoute(componentJID);

                // Ask the component to shutdown
                component.shutdown();

                if (!routingTable.hasComponentRoute(componentJID)) {
                    // Remove the disco item from the server for the component that is being removed
                    IQDiscoItemsHandler iqDiscoItemsHandler = XMPPServer.getInstance().getIQDiscoItemsHandler();
                    if (iqDiscoItemsHandler != null) {
                        iqDiscoItemsHandler.removeComponentItem(componentJID.toBareJID());
                    }
                    removeComponentInfo(componentJID);
                    // Notify listeners that an existing component has been unregistered
                    notifyComponentUnregistered(componentJID);
                    // Alert other nodes of component removed event
                    CacheFactory.doClusterTask(new NotifyComponentUnregistered(componentJID));
                }
                Log.debug("InternalComponentManager: Component unregistered for domain: " + subdomain);
            }
            else {
                Log.debug("InternalComponentManager: Other components still tied to domain: " + subdomain);
            }
        }
    }

    void notifyComponentUnregistered(JID componentJID) {
        for (ComponentEventListener listener : listeners) {
            listener.componentUnregistered(componentJID);
        }
    }

    void removeComponentInfo(JID componentJID) {
        // Remove any info stored with the component being removed
        componentInfo.remove(componentJID.getDomain());
    }

    @Override
    public void sendPacket(Component component, Packet packet) {
        if (packet != null && packet.getFrom() == null) {
            throw new IllegalArgumentException("Packet with no FROM address was received from component.");
        }
        
        PacketRouter router = XMPPServer.getInstance().getPacketRouter();
        if (router != null) {
            router.route(packet);
        }
    }

    @Override
    public IQ query(Component component, IQ packet, long timeout) throws ComponentException {
        final LinkedBlockingQueue<IQ> answer = new LinkedBlockingQueue<>(8);
        XMPPServer.getInstance().getIQRouter().addIQResultListener(packet.getID(), new IQResultListener() {
            @Override
            public void receivedAnswer(IQ packet) {
                answer.offer(packet);
            }

            @Override
            public void answerTimeout(String packetId) {
                Log.warn("An answer to a previously sent IQ stanza was never received. Packet id: " + packetId);
            }
        });
        sendPacket(component, packet);
        IQ reply = null;
        try {
            reply = answer.poll(timeout, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            // Ignore
        }
        return reply;
    }

    @Override
    public void query(Component component, IQ packet, IQResultListener listener) throws ComponentException {
        XMPPServer.getInstance().getIQRouter().addIQResultListener(packet.getID(), listener);
        sendPacket(component, packet);
    }

    /**
     * Adds a new listener that will be notified of component events. Events being
     * notified are: 1) when a component is added to the component manager, 2) when
     * a component is deleted and 3) when disco#info is received from a component.
     *
     * @param listener the new listener to notify of component events.
     */
    public void addListener(ComponentEventListener listener) {
        listeners.add(listener);
        // Notify the new listener about existing components
        for (String domain : routingTable.getComponentsDomains()) {
            JID componentJID = new JID(domain);
            listener.componentRegistered(componentJID);
            // Check if there is disco#info stored for the component
            IQ disco = componentInfo.get(domain);
            if (disco != null) {
                listener.componentInfoReceived(disco);
            }
        }
    }

    /**
     * Removes the specified listener from the listeners being notified of component
     * events.
     *
     * @param listener the listener to remove.
     */
    public void removeListener(ComponentEventListener listener) {
        listeners.remove(listener);
    }

    @Override
    public String getProperty(String name) {
        return JiveGlobals.getProperty(name);
    }

    @Override
    public void setProperty(String name, String value) {
        //Ignore
    }

    @Override
    public String getServerName() {
        return serverDomain;
    }

    public String getHomeDirectory() {
        return JiveGlobals.getHomeDirectory();
    }

    @Override
    public boolean isExternalMode() {
        return false;
    }


    /**
     * Retrieves the <code>Component</code> which is mapped to the specified JID. The
     * look up will only be done on components that were registered with this JVM. That
     * means that components registered in other cluster nodes are not going to be
     * considered.
     *
     * @param componentJID the jid mapped to the component.
     * @return the list of components with the specified id.
     */
    private List<Component> getComponents(JID componentJID) {
        synchronized (routables) {
            if (componentJID.getNode() != null) {
                return Collections.emptyList();
            }
            RoutableComponents routable = routables.get(componentJID.getDomain());
            if (routable != null) {
                return routable.getComponents();
            }
            else {
                // Search again for those JIDs whose domain include the server name but this
                // time remove the server name from the JID's domain
                String serverName = componentJID.getDomain();
                int index = serverName.lastIndexOf("." + serverDomain);
                if (index > -1) {
                    routable = routables.get(serverName.substring(0, index));
                    if (routable != null) {
                        return routable.getComponents();
                    }
                }
            }
            return Collections.emptyList();
        }
    }

    /**
     * Returns true if a component is associated to the specified address. Components
     * registered with this JVM or other cluster nodes are going to be considered.
     *
     * @param componentJID the address of the component. This is the complete domain.
     * @return true if a component is associated to the specified address.
     */
    public boolean hasComponent(JID componentJID) {
        synchronized (routables) {
            if (componentJID.getNode() != null || componentJID.getResource() != null) {
                return false;
            }
    //        if (componentJID.getDomain().lastIndexOf("." + serverDomain) == -1) {
    //            componentJID = new JID(componentJID.getDomain() + "." + serverDomain);
    //        }
            return routingTable.hasComponentRoute(componentJID);
        }
    }

    /**
     * Registers Probeers who have not yet been serviced.
     *
     * @param prober the jid probing.
     * @param probee the presence being probed.
     */
    public void addPresenceRequest(JID prober, JID probee) {
        presenceMap.put(prober, probee);
    }

    private void checkPresences() {
        for (JID prober : presenceMap.keySet()) {
            JID probee = presenceMap.get(prober);

            if (routingTable.hasComponentRoute(probee)) {
                Presence presence = new Presence();
                presence.setFrom(prober);
                presence.setTo(probee);
                routingTable.routePacket(probee, presence, false);

                // No reason to hold onto prober reference.
                presenceMap.remove(prober);
            }
        }
    }

    /**
     *  Send a disco#info request to the new component. If the component provides information
     *  then it will be added to the list of discoverable server items.
     *
     * @param component the new component that was added to this manager.
     * @param componentJID the XMPP address of the new component.
     */
    private void checkDiscoSupport(Component component, JID componentJID) {
        // Build a disco#info request that will be sent to the component
        IQ iq = new IQ(IQ.Type.get);
        iq.setFrom(getAddress());
        iq.setTo(componentJID);
        iq.setChildElement("query", "http://jabber.org/protocol/disco#info");
        // Send the disco#info request to the component. The reply (if any) will be processed in
        // #process(Packet)
//        sendPacket(component, iq);
        component.processPacket(iq);
    }

    @Override
    public JID getAddress() {
        return serviceAddress;
    }

    /**
     * Processes packets that were sent to this service. Currently only packets that were sent from
     * registered components are being processed. In the future, we may also process packet of
     * trusted clients. Trusted clients may be able to execute ad-hoc commands such as adding or
     * removing components.
     *
     * @param packet the packet to process.
     */
    @Override
    public void process(Packet packet) throws PacketException {
        List<Component> components = getComponents(packet.getFrom());
        // Only process packets that were sent by registered components
        if (!components.isEmpty()) {
            if (packet instanceof IQ && IQ.Type.result == ((IQ) packet).getType()) {
                IQ iq = (IQ) packet;
                Element childElement = iq.getChildElement();
                if (childElement != null) {
                    String namespace = childElement.getNamespaceURI();
                    if ("http://jabber.org/protocol/disco#info".equals(namespace)) {
                        // Add a disco item to the server for the component that supports disco
                        Element identity = childElement.element("identity");
                        if (identity == null) {
                            // Do nothing since there are no identities in the disco#info packet
                            return;
                        }
                        try {
                            XMPPServer.getInstance().getIQDiscoItemsHandler().addComponentItem(packet.getFrom()
                                    .toBareJID(),
                                    identity.attributeValue("name"));
                            for (Component component : components) {
                                if (component instanceof ComponentSession.ExternalComponent) {
                                    ComponentSession.ExternalComponent externalComponent =
                                            (ComponentSession.ExternalComponent) component;
                                    externalComponent.setName(identity.attributeValue("name"));
                                    externalComponent.setType(identity.attributeValue("type"));
                                    externalComponent.setCategory(identity.attributeValue("category"));
                                }
                            }
                        }
                        catch (Exception e) {
                            Log.error("Error processing disco packet of components: " + components +
                                    " - " + packet.toXML(), e);
                        }
                        // Store the IQ disco#info returned by the component
                        addComponentInfo(iq);
                        // Notify listeners that a component answered the disco#info request
                        notifyComponentInfo(iq);
                        // Alert other cluster nodes
                        CacheFactory.doClusterTask(new NotifyComponentInfo(iq));
                    }
                }
            }
        }
    }

    void notifyComponentInfo(IQ iq) {
        for (ComponentEventListener listener : listeners) {
            listener.componentInfoReceived(iq);
        }
    }

    void addComponentInfo(IQ iq) {
        componentInfo.put(iq.getFrom().getDomain(), iq);
    }

    /**
     * Exposes a Component as a RoutableChannelHandler.
     */
    private static class RoutableComponents implements RoutableChannelHandler {

        private JID jid;
        final private List<Component> components = new ArrayList<>();

        public RoutableComponents(JID jid, Component component) {
            this.jid = jid;
            addComponent(component);
        }

        public void addComponent(Component component) {
            synchronized (components) {
                components.add(component);
            }
        }

        public void removeComponent(Component component) {
            synchronized (components) {
                components.remove(component);
            }
        }

        public void removeAllComponents() {
            synchronized (components) {
                components.clear();
            }
        }

        public Boolean hasComponent(Component component) {
            return components.contains(component);
        }

        public Integer numberOfComponents() {
            return components.size();
        }

        public List<Component> getComponents() {
            return components;
        }

        private Component getNextComponent() {
            Component component;
            synchronized (components) {
                component = components.get(0);
                Collections.rotate(components, 1);
            }
            return component;
        }

        @Override
        public JID getAddress() {
            return jid;
        }

        @Override
        public void process(Packet packet) throws PacketException {
            Component component = getNextComponent();
            component.processPacket(packet);
        }
    }
}
