/*
 * Copyright (C) 2004-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.spi;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.*;
import java.util.concurrent.locks.Lock;

import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.DocumentHelper;
import org.jivesoftware.database.DbConnectionManager;
import org.jivesoftware.openfire.*;
import org.jivesoftware.openfire.auth.UnauthorizedException;
import org.jivesoftware.openfire.component.InternalComponentManager;
import org.jivesoftware.openfire.container.BasicModule;
import org.jivesoftware.openfire.event.UserEventDispatcher;
import org.jivesoftware.openfire.event.UserEventListener;
import org.jivesoftware.openfire.handler.PresenceUpdateHandler;
import org.jivesoftware.openfire.privacy.PrivacyList;
import org.jivesoftware.openfire.privacy.PrivacyListManager;
import org.jivesoftware.openfire.roster.Roster;
import org.jivesoftware.openfire.roster.RosterItem;
import org.jivesoftware.openfire.roster.RosterManager;
import org.jivesoftware.openfire.session.ClientSession;
import org.jivesoftware.openfire.user.User;
import org.jivesoftware.openfire.user.UserManager;
import org.jivesoftware.openfire.user.UserNotFoundException;
import org.jivesoftware.util.LocaleUtils;
import org.jivesoftware.util.StringUtils;
import org.jivesoftware.util.cache.Cache;
import org.jivesoftware.util.cache.CacheFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmpp.packet.JID;
import org.xmpp.packet.PacketError;
import org.xmpp.packet.Presence;

/**
 * Simple in memory implementation of the PresenceManager interface.
 *
 * @author Iain Shigeoka
 */
public class PresenceManagerImpl extends BasicModule implements PresenceManager, UserEventListener, XMPPServerListener {

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

    private static final String LOAD_OFFLINE_PRESENCE =
            "SELECT offlinePresence, offlineDate FROM ofPresence WHERE username=?";
    private static final String INSERT_OFFLINE_PRESENCE =
            "INSERT INTO ofPresence(username, offlinePresence, offlineDate) VALUES(?,?,?)";
    private static final String DELETE_OFFLINE_PRESENCE =
            "DELETE FROM ofPresence WHERE username=?";

    private static final String NULL_STRING = "NULL";
    private static final long NULL_LONG = -1L;

    private RoutingTable routingTable;
    private SessionManager sessionManager;
    private UserManager userManager;
    private RosterManager rosterManager;
    private XMPPServer server;
    private PacketDeliverer deliverer;
    private PresenceUpdateHandler presenceUpdateHandler;

    private InternalComponentManager componentManager;

    private Cache<String, Long> lastActivityCache;
    private Cache<String, String> offlinePresenceCache;

    public PresenceManagerImpl() {
        super("Presence manager");
    }

    @Override
    public boolean isAvailable(User user) {
        return sessionManager.getActiveSessionCount(user.getUsername()) > 0;
    }

    @Override
    public Presence getPresence(User user) {
        if (user == null) {
            return null;
        }
        Presence presence = null;

        for (ClientSession session : sessionManager.getSessions(user.getUsername())) {
            if (presence == null) {
                presence = session.getPresence();
            }
            else {
                // Get the ordinals of the presences to compare. If no ordinal is available then
                // assume a value of -1
                int o1 = presence.getShow() != null ? presence.getShow().ordinal() : -1;
                int o2 = session.getPresence().getShow() != null ?
                        session.getPresence().getShow().ordinal() : -1;
                // Compare the presences' show ordinals
                if (o1 > o2) {
                    presence = session.getPresence();
                }
            }
        }
        return presence;
    }

    @Override
    public Collection<Presence> getPresences(String username) {
        if (username == null) {
            return null;
        }
        List<Presence> presences = new ArrayList<>();

        for (ClientSession session : sessionManager.getSessions(username)) {
            presences.add(session.getPresence());
        }
        return Collections.unmodifiableCollection(presences);
    }

    @Override
    public String getLastPresenceStatus(User user) {
        String username = user.getUsername();
        String presenceStatus = null;
        String presenceXML = offlinePresenceCache.get(username);
        if (presenceXML == null) {
            loadOfflinePresence(username);
        }
        presenceXML = offlinePresenceCache.get(username);
        if (presenceXML != null) {
            // If the cached answer is no data, return null.
            if (presenceXML.equals(NULL_STRING)) {
                return null;
            }
            // Otherwise, parse out the status from the XML.
            try {
                // Parse the element
                Document element = DocumentHelper.parseText(presenceXML);
                presenceStatus = element.getRootElement().elementTextTrim("status");
            }
            catch (DocumentException e) {
                Log.error(LocaleUtils.getLocalizedString("admin.error"), e);
            }
        }
        return presenceStatus;
    }

    @Override
    public long getLastActivity(User user) {
        String username = user.getUsername();
        long lastActivity = NULL_LONG;
        Long offlineDate = lastActivityCache.get(username);
        if (offlineDate == null) {
            loadOfflinePresence(username);
        }
        offlineDate = lastActivityCache.get(username);
        if (offlineDate != null) {
            // If the cached answer is no data, return -1.
            if (offlineDate == NULL_LONG) {
                return NULL_LONG;
            }
            else {
                try {
                    lastActivity = (System.currentTimeMillis() - offlineDate);
                }
                catch (NumberFormatException e) {
                    Log.error(LocaleUtils.getLocalizedString("admin.error"), e);
                }
            }
        }
        return lastActivity;
    }

    @Override
    public void userAvailable(Presence presence) {
        // Delete the last unavailable presence of this user since the user is now
        // available. Only perform this operation if this is an available presence sent to
        // THE SERVER and the presence belongs to a local user.
        if (presence.getTo() == null && server.isLocal(presence.getFrom())) {
            String username = presence.getFrom().getNode();
            if (username == null || !userManager.isRegisteredUser(username)) {
                // Ignore anonymous users
                return;
            }

            // Optimization: only delete the unavailable presence information if this
            // is the first session created on the server.
            if (sessionManager.getSessionCount(username) > 1) {
                return;
            }

            deleteOfflinePresenceFromDB(username);

            // Remove data from cache.
            offlinePresenceCache.remove(username);
            lastActivityCache.remove(username);
        }
    }

    private void deleteOfflinePresenceFromDB(String username) {
        Connection con = null;
        PreparedStatement pstmt = null;
        try {
            con = DbConnectionManager.getConnection();
            pstmt = con.prepareStatement(DELETE_OFFLINE_PRESENCE);
            pstmt.setString(1, username);
            pstmt.execute();
        }
        catch (SQLException sqle) {
            Log.error(sqle.getMessage(), sqle);
        }
        finally {
            DbConnectionManager.closeConnection(pstmt, con);
        }
    }

    @Override
    public void userUnavailable(Presence presence) {
        // Only save the last presence status and keep track of the time when the user went
        // offline if this is an unavailable presence sent to THE SERVER and the presence belongs
        // to a local user.
        if (presence.getTo() == null && server.isLocal(presence.getFrom())) {
            String username = presence.getFrom().getNode();
            if (username == null || !userManager.isRegisteredUser(username)) {
                // Ignore anonymous users
                return;
            }

            // If the user has any remaining sessions, don't record the offline info.
            if (sessionManager.getActiveSessionCount(username) > 0) {
                return;
            }

            String offlinePresence = null;
            // Save the last unavailable presence of this user if the presence contains any
            // child element such as <status>.
            if (!presence.getElement().elements().isEmpty()) {
                offlinePresence = presence.toXML();
            }
            // Keep track of the time when the user went offline
            java.util.Date offlinePresenceDate = new java.util.Date();

            boolean addedToCache;
            if (offlinePresence == null) {
                addedToCache = !NULL_STRING.equals(offlinePresenceCache.put(username, NULL_STRING));
            }
            else {
                addedToCache = !offlinePresence.equals(offlinePresenceCache.put(username, offlinePresence));
            }
            if (!addedToCache) {
                return;
            }
            Log.debug( "Recording 'last activity' for user '{}'.", username);
            lastActivityCache.put(username, offlinePresenceDate.getTime());

            writeToDatabase(username, offlinePresence, offlinePresenceDate);
        }
    }

    private void writeToDatabase(String username, String offlinePresence, Date offlinePresenceDate) {
        // delete existing offline presence (if any)
        deleteOfflinePresenceFromDB(username);

        // Insert data into the database.
        Connection con = null;
        PreparedStatement pstmt = null;
        try {
            con = DbConnectionManager.getConnection();
            pstmt = con.prepareStatement(INSERT_OFFLINE_PRESENCE);
            pstmt.setString(1, username);
            if (offlinePresence != null) {
                DbConnectionManager.setLargeTextField(pstmt, 2, offlinePresence);
            } else {
                pstmt.setNull(2, Types.VARCHAR);
            }
            pstmt.setString(3, StringUtils.dateToMillis(offlinePresenceDate));
            pstmt.execute();
        } catch (SQLException sqle) {
            Log.error("Error storing offline presence of user: " + username, sqle);
        } finally {
            DbConnectionManager.closeConnection(pstmt, con);
        }
    }

    @Override
    public void handleProbe(Presence packet) throws UnauthorizedException {
        String username = packet.getTo().getNode();
        try {
            Roster roster = rosterManager.getRoster(username);
            RosterItem item = roster.getRosterItem(packet.getFrom());
            if (item.getSubStatus() == RosterItem.SUB_FROM
                    || item.getSubStatus() == RosterItem.SUB_BOTH) {
                probePresence(packet.getFrom(),  packet.getTo());
            }
            else {
                PacketError.Condition error = PacketError.Condition.not_authorized;
                if ((item.getSubStatus() == RosterItem.SUB_NONE &&
                        item.getRecvStatus() != RosterItem.RECV_SUBSCRIBE) ||
                        (item.getSubStatus() == RosterItem.SUB_TO &&
                        item.getRecvStatus() != RosterItem.RECV_SUBSCRIBE)) {
                    error = PacketError.Condition.forbidden;
                }
                Presence presenceToSend = new Presence();
                presenceToSend.setError(error);
                presenceToSend.setTo(packet.getFrom());
                presenceToSend.setFrom(packet.getTo());
                deliverer.deliver(presenceToSend);
            }
        }
        catch (UserNotFoundException e) {
            Presence presenceToSend = new Presence();
            presenceToSend.setError(PacketError.Condition.forbidden);
            presenceToSend.setTo(packet.getFrom());
            presenceToSend.setFrom(packet.getTo());
            deliverer.deliver(presenceToSend);
        }
    }

    @Override
    public boolean canProbePresence(JID prober, String probee) throws UserNotFoundException {
        if (probee.equals(prober.getNode()) && XMPPServer.getInstance().isLocal(prober)) {
            return true;
        }
        RosterItem item = rosterManager.getRoster(probee).getRosterItem(prober);
        return item.getSubStatus() == RosterItem.SUB_FROM
                || item.getSubStatus() == RosterItem.SUB_BOTH;
    }

    @Override
    public void probePresence(JID prober, JID probee) {
        try {
            if (server.isLocal(probee)) {
                // Local probers should receive presences of probee in all connected resources
                Collection<JID> proberFullJIDs = new ArrayList<>();
                if (prober.getResource() == null && server.isLocal(prober)) {
                    for (ClientSession session : sessionManager.getSessions(prober.getNode())) {
                        proberFullJIDs.add(session.getAddress());
                    }
                }
                else {
                    proberFullJIDs.add(prober);
                }
                // If the probee is a local user then don't send a probe to the contact's server.
                // But instead just send the contact's presence to the prober
                Collection<ClientSession> sessions = sessionManager.getSessions(probee.getNode());
                if (sessions.isEmpty()) {
                    // If the probee is not online then try to retrieve his last unavailable
                    // presence which may contain particular information and send it to the
                    // prober
                    String presenceXML = offlinePresenceCache.get(probee.getNode());
                    if (presenceXML == null) {
                        loadOfflinePresence(probee.getNode());
                    }
                    presenceXML = offlinePresenceCache.get(probee.getNode());
                    if (presenceXML != null && !NULL_STRING.equals(presenceXML)) {
                        try {
                            // Parse the element
                            Document element = DocumentHelper.parseText(presenceXML);
                            // Create the presence from the parsed element
                            Presence presencePacket = new Presence(element.getRootElement());
                            presencePacket.setFrom(probee.toBareJID());
                            // Check if default privacy list of the probee blocks the
                            // outgoing presence
                            PrivacyList list = PrivacyListManager.getInstance()
                                    .getDefaultPrivacyList(probee.getNode());
                            // Send presence to all prober's resources
                            for (JID receipient : proberFullJIDs) {
                                presencePacket.setTo(receipient);
                                if (list == null || !list.shouldBlockPacket(presencePacket)) {
                                    // Send the presence to the prober
                                    deliverer.deliver(presencePacket);
                                }
                            }
                        }
                        catch (Exception e) {
                            Log.error(LocaleUtils.getLocalizedString("admin.error"), e);
                        }
                    }
                }
                else {
                    // The contact is online so send to the prober all the resources where the
                    // probee is connected
                    for (ClientSession session : sessions) {
                        // Create presence to send from probee to prober
                        Presence presencePacket = session.getPresence().createCopy();
                        presencePacket.setFrom(session.getAddress());
                        // Check if a privacy list of the probee blocks the outgoing presence
                        PrivacyList list = session.getActiveList();
                        list = list == null ? session.getDefaultList() : list;
                        // Send presence to all prober's resources
                        for (JID receipient : proberFullJIDs) {
                            presencePacket.setTo(receipient);
                            if (list != null) {
                                if (list.shouldBlockPacket(presencePacket)) {
                                    // Default list blocked outgoing presence so skip this session
                                    continue;
                                }
                            }
                            try {
                                deliverer.deliver(presencePacket);
                            }
                            catch (Exception e) {
                                Log.error(LocaleUtils.getLocalizedString("admin.error"), e);
                            }
                        }
                    }
                }
            }
            else {
                if (routingTable.hasComponentRoute(probee)) {
                    // If the probee belongs to a component then ask the component to process the
                    // probe presence
                    Presence presence = new Presence();
                    presence.setType(Presence.Type.probe);
                    presence.setFrom(prober);
                    presence.setTo(probee);
                    routingTable.routePacket(probee, presence, true);
                }
                else {
                    // Check if the probee may be hosted by this server
                    /*String serverDomain = server.getServerInfo().getName();
                    if (!probee.getDomain().contains(serverDomain)) {*/
                    if (server.isRemote(probee)) {
                        // Send the probe presence to the remote server
                        Presence probePresence = new Presence();
                        probePresence.setType(Presence.Type.probe);
                        probePresence.setFrom(prober);
                        probePresence.setTo(probee.toBareJID());
                        // Send the probe presence
                        deliverer.deliver(probePresence);
                    }
                    else {
                        // The probee may be related to a component that has not yet been connected so
                        // we will keep a registry of this presence probe. The component will answer
                        // this presence probe when he becomes online
                        componentManager.addPresenceRequest(prober, probee);
                    }
                }
            }
        }
        catch (Exception e) {
            Log.error(LocaleUtils.getLocalizedString("admin.error"), e);
        }
    }

    @Override
    public void sendUnavailableFromSessions(JID recipientJID, JID userJID) {
        if (XMPPServer.getInstance().isLocal(userJID) && userManager.isRegisteredUser(userJID.getNode())) {
            for (ClientSession session : sessionManager.getSessions(userJID.getNode())) {
                // Do not send an unavailable presence if the user sent a direct available presence
                if (presenceUpdateHandler.hasDirectPresence(session.getAddress(), recipientJID)) {
                    continue;
                }
                Presence presencePacket = new Presence();
                presencePacket.setType(Presence.Type.unavailable);
                presencePacket.setFrom(session.getAddress());
                // Ensure that unavailable presence is sent to all receipient's resources
                Collection<JID> recipientFullJIDs = new ArrayList<>();
                if (server.isLocal(recipientJID)) {
                    for (ClientSession targetSession : sessionManager
                            .getSessions(recipientJID.getNode())) {
                        recipientFullJIDs.add(targetSession.getAddress());
                    }
                }
                else {
                    recipientFullJIDs.add(recipientJID);
                }
                for (JID jid : recipientFullJIDs) {
                    presencePacket.setTo(jid);
                    try {
                        deliverer.deliver(presencePacket);
                    }
                    catch (Exception e) {
                        Log.error(LocaleUtils.getLocalizedString("admin.error"), e);
                    }
                }
            }
        }
    }

    @Override
    public void userCreated(User user, Map<String, Object> params) {
        // Do nothing
    }

    @Override
    public void userDeleting(User user, Map<String, Object> params) {
        // Delete user information
        deleteOfflinePresenceFromDB(user.getUsername());
        offlinePresenceCache.remove(user.getUsername());
        lastActivityCache.remove(user.getUsername());
    }

    @Override
    public void userModified(User user, Map<String, Object> params) {
        // Do nothing
    }

    // #####################################################################
    // Module management
    // #####################################################################

    @Override
    public void initialize(XMPPServer server) {
        super.initialize(server);
        this.server = server;

        offlinePresenceCache = CacheFactory.createCache("Offline Presence Cache");
        lastActivityCache = CacheFactory.createCache("Last Activity Cache");

        deliverer = server.getPacketDeliverer();
        sessionManager = server.getSessionManager();
        userManager = server.getUserManager();
        presenceUpdateHandler = server.getPresenceUpdateHandler();
        rosterManager = server.getRosterManager();
        routingTable = server.getRoutingTable();
    }

    @Override
    public void start() throws IllegalStateException {
        super.start();
        // Use component manager for Presence Updates.
        componentManager = InternalComponentManager.getInstance();
        // Listen for user deletion events
        UserEventDispatcher.addListener(this);

    }

    @Override
    public void stop() {
        // Clear the caches when stopping the module.
        offlinePresenceCache.clear();
        lastActivityCache.clear();
        // Stop listening for user deletion events
        UserEventDispatcher.removeListener(this);
    }

    /**
     * Loads offline presence data for the user into cache.
     *
     * @param username the username.
     */
    private void loadOfflinePresence(String username) {
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        Lock lock = CacheFactory.getLock(username, offlinePresenceCache);
        try {
            lock.lock();
            if (!offlinePresenceCache.containsKey(username) || !lastActivityCache.containsKey(username)) {
                con = DbConnectionManager.getConnection();
                pstmt = con.prepareStatement(LOAD_OFFLINE_PRESENCE);
                pstmt.setString(1, username);
                rs = pstmt.executeQuery();
                if (rs.next()) {
                    String offlinePresence = DbConnectionManager.getLargeTextField(rs, 1);
                    if (rs.wasNull()) {
                        offlinePresence = NULL_STRING;
                    }
                    long offlineDate = Long.parseLong(rs.getString(2).trim());
                    offlinePresenceCache.put(username, offlinePresence);
                    lastActivityCache.put(username, offlineDate);
                }
                else {
                    offlinePresenceCache.put(username, NULL_STRING);
                    lastActivityCache.put(username, NULL_LONG);
                }
            }
        }
        catch (SQLException sqle) {
            Log.error(sqle.getMessage(), sqle);
        }
        finally {
            DbConnectionManager.closeConnection(rs, pstmt, con);
            lock.unlock();
        }
    }

    @Override
    public void serverStarted() {
    }

    @Override
    public void serverStopping() {
        for (ClientSession session : XMPPServer.getInstance().getSessionManager().getSessions()) {
            if (!session.isAnonymousUser()) {
                try {
                    writeToDatabase(session.getUsername(), null, new Date());
                } catch (UserNotFoundException e) {
                    Log.error(e.getMessage(), e);
                }
            }
        }
    }
}