/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you 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.apache.vysper.xmpp.modules.core.im.handler;

import static org.apache.vysper.compliance.SpecCompliant.ComplianceStatus.IN_PROGRESS;
import static org.apache.vysper.xmpp.stanza.PresenceStanzaType.ERROR;
import static org.apache.vysper.xmpp.stanza.PresenceStanzaType.PROBE;
import static org.apache.vysper.xmpp.stanza.PresenceStanzaType.UNAVAILABLE;
import static org.apache.vysper.xmpp.stanza.PresenceStanzaType.UNSUBSCRIBED;
import static org.apache.vysper.xmpp.stanza.PresenceStanzaType.isSubscriptionType;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.vysper.compliance.SpecCompliance;
import org.apache.vysper.compliance.SpecCompliant;
import org.apache.vysper.xml.fragment.Attribute;
import org.apache.vysper.xmpp.addressing.Entity;
import org.apache.vysper.xmpp.addressing.EntityImpl;
import org.apache.vysper.xmpp.delivery.failure.DeliveryException;
import org.apache.vysper.xmpp.delivery.failure.IgnoreFailureStrategy;
import org.apache.vysper.xmpp.modules.core.base.handler.XMPPCoreStanzaHandler;
import org.apache.vysper.xmpp.modules.extension.xep0160_offline_storage.OfflineStorageProvider;
import org.apache.vysper.xmpp.modules.roster.RosterException;
import org.apache.vysper.xmpp.modules.roster.RosterItem;
import org.apache.vysper.xmpp.modules.roster.RosterUtils;
import org.apache.vysper.xmpp.modules.roster.SubscriptionType;
import org.apache.vysper.xmpp.modules.roster.persistence.RosterManager;
import org.apache.vysper.xmpp.protocol.commandstanza.EndOfSessionCommandStanza;
import org.apache.vysper.xmpp.server.ServerRuntimeContext;
import org.apache.vysper.xmpp.server.SessionContext;
import org.apache.vysper.xmpp.server.response.ServerErrorResponses;
import org.apache.vysper.xmpp.stanza.PresenceStanza;
import org.apache.vysper.xmpp.stanza.PresenceStanzaType;
import org.apache.vysper.xmpp.stanza.Stanza;
import org.apache.vysper.xmpp.stanza.StanzaBuilder;
import org.apache.vysper.xmpp.stanza.StanzaErrorCondition;
import org.apache.vysper.xmpp.stanza.StanzaErrorType;
import org.apache.vysper.xmpp.stanza.XMPPCoreStanza;
import org.apache.vysper.xmpp.stanza.XMPPCoreStanzaVerifier;
import org.apache.vysper.xmpp.state.resourcebinding.ResourceRegistry;
import org.apache.vysper.xmpp.state.resourcebinding.ResourceState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * handling presence stanzas related to availability
 *
 * @author The Apache MINA Project (dev@mina.apache.org)
 */
public class PresenceAvailabilityHandler extends AbstractPresenceSpecializedHandler {

    protected static final String DIRECTED_PRESENCE_MAP = "DIRECTED_PRESENCE_MAP_";

    final Logger logger = LoggerFactory.getLogger(PresenceAvailabilityHandler.class);

    /**
     * handles availability presence stanzas. prepares further processing of the 
     * stanza and decides which special case of availability to transfer to. 
     */
    @Override
    /*package*/Stanza executeCorePresence(ServerRuntimeContext serverRuntimeContext, boolean isOutboundStanza,
            SessionContext sessionContext, PresenceStanza presenceStanza, RosterManager rosterManager) {

        // do not handle other cases of presence
        if (isSubscriptionType(presenceStanza.getPresenceType())) {
            throw new RuntimeException("case not handled in availability handler"
                    + presenceStanza.getPresenceType().value());
        }

        // TODO: either use the resource associated with the session
        // (initiatingEntity)
        // or in case of multiple resources, use the from attribute or return an
        // error if the from attribute is not present.
        Entity initiatingEntity = sessionContext == null ? null : sessionContext.getInitiatingEntity();
        XMPPCoreStanzaVerifier verifier = presenceStanza.getCoreVerifier();
        ResourceRegistry registry = serverRuntimeContext.getResourceRegistry();

        // check if presence reception is turned off either globally or locally
        if (!serverRuntimeContext.getServerFeatures().isRelayingPresence()
                || (sessionContext != null && sessionContext
                        .getAttribute(SessionContext.SESSION_ATTRIBUTE_PRESENCE_STANZA_NO_RECEIVE) != null)) {
            return null;
        }

        PresenceStanzaType type = presenceStanza.getPresenceType();
        boolean available = PresenceStanzaType.isAvailable(type);

        if (isOutboundStanza) {
            Entity user = XMPPCoreStanzaHandler.extractUniqueSenderJID(presenceStanza, sessionContext);
            if (user == null) {
                return ServerErrorResponses.getStanzaError(StanzaErrorCondition.UNKNOWN_SENDER,
                        presenceStanza, StanzaErrorType.MODIFY, "sender info insufficient: no from", null, null);
            }

            if (available) {
                return handleOutboundAvailable(presenceStanza, serverRuntimeContext, sessionContext, rosterManager,
                        user, registry);
            } else if (type == UNAVAILABLE) {
                return handleOutboundUnavailable(presenceStanza, serverRuntimeContext, sessionContext, rosterManager,
                        user, registry);
            } else if (type == PROBE) {
                return handleOutboundPresenceProbe(presenceStanza, serverRuntimeContext, sessionContext, registry);
            } else if (type == ERROR) {
                throw new RuntimeException("not implemented yet");
            } else {
                throw new RuntimeException("unhandled outbound presence case " + type.value());
            }
        } else /* inbound */{
            if (available) {
                return handleInboundAvailable(presenceStanza, serverRuntimeContext, sessionContext, registry);
            } else if (type == UNAVAILABLE) {
                return handleInboundUnavailable(presenceStanza, serverRuntimeContext, sessionContext, registry);
            } else if (type == PROBE) {
                return handleInboundPresenceProbe(presenceStanza, serverRuntimeContext, sessionContext, registry,
                        rosterManager);
            } else if (type == ERROR) {
                throw new RuntimeException("not implemented yet");
            } else {
                throw new RuntimeException("unhandled inbound presence case " + type.value());
            }
        }
    }

    @SpecCompliance(compliant = { @SpecCompliant(spec = "RFC3921bis-08", section = "4.5.2", status = IN_PROGRESS) })
    private Stanza handleOutboundUnavailable(PresenceStanza presenceStanza, ServerRuntimeContext serverRuntimeContext,
            SessionContext sessionContext, RosterManager rosterManager, Entity user, ResourceRegistry registry) {

        boolean hasTo = presenceStanza.getCoreVerifier().attributePresent("to");
        if (hasTo)
            return handleOutboundDirectedPresence(presenceStanza, serverRuntimeContext, sessionContext, rosterManager,
                    user, registry, true);

        if (!user.isResourceSet())
            throw new RuntimeException("resource id not available");
        boolean stateChanged = registry.setResourceState(user.getResource(), ResourceState.UNAVAILABLE);
        // avoid races from closing connections and unavail presence stanza handlings happening quasi-concurrently
        if (!stateChanged)
            return null;

        sessionContext.getServerRuntimeContext().getPresenceCache().remove(user);

        SessionContext.SessionTerminationCause terminationCause = null;
        if (presenceStanza instanceof EndOfSessionCommandStanza) {
            EndOfSessionCommandStanza commandStanza = (EndOfSessionCommandStanza) presenceStanza;
            terminationCause = commandStanza.getSessionTerminationCause();
        }

        // TODO check if we do have to do something about resource priority

        List<Entity> contacts = new ArrayList<Entity>();

        Map<SubscriptionType, List<RosterItem>> itemMap = RosterUtils.getRosterItemsByState(rosterManager, user);
        List<RosterItem> item_FROM = itemMap.get(SubscriptionType.FROM);
        List<RosterItem> item_TO = itemMap.get(SubscriptionType.TO);
        List<RosterItem> item_BOTH = itemMap.get(SubscriptionType.BOTH);

        // broadcast presence from full JID to contacts
        // in roster with 'subscription' either 'from' or 'both'
        // TODO (for pres updates): ...and last presence stanza received from the contact during the user's
        // presence session was not of type "error" or "unsubscribe".

        List<RosterItem> rosterContacts_FROM = new ArrayList<RosterItem>();
        rosterContacts_FROM.addAll(item_FROM);
        rosterContacts_FROM.addAll(item_BOTH);
        for (RosterItem rosterContact : rosterContacts_FROM) {
            contacts.add(rosterContact.getJid());
        }

        // broadcast unavailable to all directed-presence contacts
        Set<Entity> entitySet = getDirectedPresenceMap(sessionContext, user);
        if (entitySet != null) {
            contacts.addAll(entitySet);
            entitySet.clear(); // and un-record them
        }

        // broadcast presence notification to all resources of
        // current entity.
        List<String> resources = registry.getAvailableResources(user);
        if (!SessionContext.SessionTerminationCause.isClientReceivingStanzas(terminationCause)) {
            resources.remove(user.getResource());
        }
        for (String resource : resources) {
            Entity otherResource = new EntityImpl(user, resource);
            contacts.add(otherResource);
        }

        // and send them out
        relayTo(user, contacts, presenceStanza, sessionContext);

        return null;
    }

    @SpecCompliant(spec = "RFC3921bis-08", section = "4.3.1", status = IN_PROGRESS)
    private Stanza handleOutboundPresenceProbe(PresenceStanza presenceStanza,
            ServerRuntimeContext serverRuntimeContext, SessionContext sessionContext, ResourceRegistry registry) {
        // outbound presence probes are against the spec.

        // TODO return error stanza
        throw new IllegalStateException("clients might not send presence probes");
    }

    @SpecCompliant(spec = "RFC3921bis-08", section = "4.2.2", status = IN_PROGRESS)
    private PresenceStanza handleOutboundAvailable(PresenceStanza presenceStanza,
            ServerRuntimeContext serverRuntimeContext, SessionContext sessionContext, RosterManager rosterManager,
            Entity user, ResourceRegistry registry) {
        boolean hasTo = presenceStanza.getCoreVerifier().attributePresent("to");
        if (hasTo)
            return handleOutboundDirectedPresence(presenceStanza, serverRuntimeContext, sessionContext, rosterManager,
                    user, registry, false);

        if (!user.isResourceSet())
            throw new RuntimeException("resource id not available");
        String resourceId = user.getResource();
        ResourceState resourceState = registry.getResourceState(resourceId);

        boolean isPresenceUpdate = resourceState != null && ResourceState.isAvailable(resourceState);

        // TODO in case of !isPresenceUpdate, should we check for resourceState != ResourceState.AVAILABLE_INTERESTED ?
        // RFC3921bis-04#4.2.2 Initial Presence
        // RFC3921bis-04#4.4.2 Initial Presence

        updateLatestPresence(sessionContext, user, presenceStanza);
        if (!isPresenceUpdate) {
            // things to be done for initial presence

            // set resource state
            ResourceState currentState = registry.getResourceState(resourceId);
            // set to AVAILABLE, but do not override AVAILABLE_INTERESTED
            registry.setResourceState(resourceId, ResourceState.makeAvailable(currentState));
        }

        // the presence priority is optional, but if contained, it might become relevant for
        // message delivery (see RFC3921bis-05#8.3.1.1)
        registry.setResourcePriority(resourceId, presenceStanza.getPrioritySafe());

		// check for pending offline stored stanzas, and send them out 
		OfflineStorageProvider offlineProvider = (OfflineStorageProvider) serverRuntimeContext
				.getStorageProvider(OfflineStorageProvider.class);
		if (offlineProvider == null) {
			logger.warn("No Offline Storage Provider configured");
		} else {
			Collection<Stanza> offlineStanzas = offlineProvider.getStanzasForBareJID(user.getBareJID().getFullQualifiedName());
			for (Stanza stanza : offlineStanzas) {
				logger.debug("Sending out delayed offline stanza");
				relayStanza(user, stanza, sessionContext);
			}
		}

        List<Entity> contacts = new ArrayList<Entity>();

        Map<SubscriptionType, List<RosterItem>> itemMap = RosterUtils.getRosterItemsByState(rosterManager, user);
        List<RosterItem> item_FROM = itemMap.get(SubscriptionType.FROM);
        List<RosterItem> item_TO = itemMap.get(SubscriptionType.TO);
        List<RosterItem> item_BOTH = itemMap.get(SubscriptionType.BOTH);

        // broadcast presence from full JID to contacts
        // in roster with 'subscription' either 'from' or 'both'
        // TODO: ...and user is not blocking outbound presence notifications above
        // TODO (for pres updates): ...and last presence stanza received from the contact during the user's
        // presence session was not of type "error" or "unsubscribe".

        List<RosterItem> rosterContacts_FROM = new ArrayList<RosterItem>();
        rosterContacts_FROM.addAll(item_FROM);
        rosterContacts_FROM.addAll(item_BOTH);
        for (RosterItem rosterContact : rosterContacts_FROM) {
            contacts.add(rosterContact.getJid());
        }

        // broadcast presence notification to all resources of
        // current entity.
        List<String> resources = registry.getAvailableResources(user);
        for (String resource : resources) {
            Entity otherResource = new EntityImpl(user, resource);
            contacts.add(otherResource);
        }

        // and send them out
        relayTo(user, contacts, presenceStanza, sessionContext);

        if (!isPresenceUpdate) {
            // initial presence only:
            // send probes to all contacts of the current jid where
            // 'subscription' is either 'to' or 'both'
            // TODO: ...and jid is not blocking inbound presence notification
            // TODO: optimize: don't send server-local probes when contact's presence is known locally
            List<RosterItem> rosterContacts_TO = new ArrayList<RosterItem>();
            rosterContacts_TO.addAll(item_TO);
            rosterContacts_TO.addAll(item_BOTH);
            for (RosterItem rosterItem : rosterContacts_TO) {
                Entity contact_TO = rosterItem.getJid();
                Stanza probeStanza = buildPresenceStanza(user, contact_TO, PresenceStanzaType.PROBE, null);
                relayStanza(contact_TO, probeStanza, sessionContext);
            }
        }

        return null;
    }

    @SpecCompliant(spec = "RFC3921bis-08", section = "4.6.2")
    private PresenceStanza handleOutboundDirectedPresence(PresenceStanza presenceStanza,
            ServerRuntimeContext serverRuntimeContext, SessionContext sessionContext, RosterManager rosterManager,
            Entity user, ResourceRegistry registry, final boolean unvailable) {
        final Entity to = presenceStanza.getTo();
        Entity from = presenceStanza.getFrom();

        Stanza redirectDirectedStanza = presenceStanza;
        if (from == null || !from.isResourceSet()) {
            from = new EntityImpl(sessionContext.getInitiatingEntity(), registry
                    .getUniqueResourceForSession(sessionContext));
            redirectDirectedStanza = StanzaBuilder.createForwardStanza(presenceStanza, from, null);
        }

        Set<Entity> dpMap = getDirectedPresenceMap(sessionContext, from);

        boolean isFromContact;
        try {
            isFromContact = rosterManager.retrieve(from.getBareJID()).getEntry(to.getBareJID()).hasFrom();
        } catch (Exception e) {
            isFromContact = false;
        }
        boolean IsTOAvailable = !ResourceState.isAvailable(registry.getResourceState(from.getResource()));

        if (unvailable) {
            dpMap.remove(to);
        } else {
            if (!isFromContact || !IsTOAvailable)
                dpMap.add(to);
        }

        try {
            serverRuntimeContext.getStanzaRelay().relay(to, redirectDirectedStanza, new IgnoreFailureStrategy());
        } catch (DeliveryException e) {
            logger.warn("relaying directed presence failed. from = " + from + ", to = " + to);
        }

        return null;
    }

    private Set<Entity> getDirectedPresenceMap(SessionContext sessionContext, Entity from) {
        String mapKey = DIRECTED_PRESENCE_MAP + from.getResource();
        Set<Entity> directedPresenceMap = (Set<Entity>) sessionContext.getAttribute(mapKey);
        if (directedPresenceMap == null) {
            directedPresenceMap = new HashSet<Entity>();
            sessionContext.putAttribute(mapKey, directedPresenceMap);
        }
        return directedPresenceMap;
    }

    @SpecCompliant(spec = "RFC3921bis-08", section = "4.5.3")
    private PresenceStanza handleInboundUnavailable(PresenceStanza presenceStanza,
            ServerRuntimeContext serverRuntimeContext, SessionContext sessionContext, ResourceRegistry registry) {
        String unavailableContact = "UNKNOWN";
        if (presenceStanza != null && presenceStanza.getFrom() != null) {
            unavailableContact = presenceStanza.getFrom().getFullQualifiedName();
        }
        logger.info("{} has become unavailable", unavailableContact);

        return presenceStanza;
    }

    /**
     * TODO I don't think this works particulary good.
     * @param stanza
     * @param serverRuntimeContext
     * @param sessionContext
     * @param registry
     * @param rosterManager
     * @return
     */
    @SpecCompliant(spec = "RFC3921bis-08", section = "4.3.2")
    private XMPPCoreStanza handleInboundPresenceProbe(PresenceStanza stanza, ServerRuntimeContext serverRuntimeContext,
            SessionContext sessionContext, ResourceRegistry registry, RosterManager rosterManager) {
        Entity contact = stanza.getFrom();
        Entity user = stanza.getTo();

        RosterItem contactItem;
        try {
            contactItem = rosterManager.getContact(user, contact.getBareJID());
        } catch (RosterException e) {
            contactItem = null;
        }
        if (contactItem == null || !contactItem.hasFrom()) {
            // not a contact, or not a _subscribed_ contact!
            relayStanza(contact, buildPresenceStanza(user, contact, UNSUBSCRIBED, null), sessionContext);
            return null;
        }

        if (contact.getResource() == null) {
            // presence probes must happen on resource level!
            relayStanza(contact, buildPresenceStanza(user, contact, UNSUBSCRIBED, null), sessionContext);
            return null;
        }

        PresenceStanza latestPresenceStanza = retrieveLatestPresence(sessionContext, user);
        if (latestPresenceStanza == null) {
            // we have no current presence info
            relayStanza(contact, buildPresenceStanza(user, contact, UNAVAILABLE, null), sessionContext);
            return null;
        }

        // return current presence as probing result
        relayStanza(contact, buildPresenceStanza(user, contact, null, latestPresenceStanza.getInnerElements()),
                sessionContext);

        return null;
    }

    private void updateLatestPresence(SessionContext sessionContext, Entity user, PresenceStanza stanza) {
        sessionContext.getServerRuntimeContext().getPresenceCache().put(user, stanza);
    }

    private PresenceStanza retrieveLatestPresence(SessionContext sessionContext, Entity user) {
        return sessionContext.getServerRuntimeContext().getPresenceCache().getForBareJID(user.getBareJID());
    }

    @SpecCompliant(spec = "RFC3921bis-08", section = "4.2.3")
    private PresenceStanza handleInboundAvailable(PresenceStanza stanza, ServerRuntimeContext serverRuntimeContext,
            SessionContext sessionContext, ResourceRegistry registry) {

        // TODO ?check if user has blocked contact?

        // write inbound stanza to the user
        sessionContext.getResponseWriter().write(stanza);

        logger.info("{} has become available", stanza.getFrom().getFullQualifiedName());

        return null;
    }

    private void relayTo(Entity from, List<Entity> tos, PresenceStanza original, SessionContext sessionContext) {
        List<Attribute> toFromReplacements = new ArrayList<Attribute>(2);
        toFromReplacements.add(new Attribute("from", from.getFullQualifiedName()));

        for (Entity to : tos) {
            toFromReplacements.add(new Attribute("to", to.getFullQualifiedName()));
            Stanza outgoingStanza = StanzaBuilder.createClone(original, true, toFromReplacements).build();
            relayStanza(to, outgoingStanza, sessionContext);
            toFromReplacements.remove(toFromReplacements.size() - 1); // clear space for new 'to' attribute
        }
    }

}
