package saros.net.xmpp.contact;

import java.util.Comparator;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.jivesoftware.smack.RosterEntry;
import org.jivesoftware.smack.packet.RosterPacket;
import org.jivesoftware.smack.packet.RosterPacket.ItemType;
import saros.net.xmpp.JID;
import saros.net.xmpp.XMPPConnectionService;
import saros.net.xmpp.contact.ContactStatus.Type;

/**
 * An XMPPContact represents a contact on the XMPP roster. It contains the current presence state
 * and extended informations like Saros Support. Objects of this class are thread-safe for all
 * public methods and informations are updated by {@link XMPPConnectionService}.
 *
 * <p>All package private methods should be called single-threaded or synchronized to guarantee
 * thread-safety.
 *
 * <p>This class is likely to implement a generic Contact Interface in the future.
 */
public class XMPPContact {

  /** Bundles information about a resource of a client. */
  private static final class Resource {
    static final Comparator<Resource> BEST_RESOURCE_FIRST =
        Comparator.comparing((Resource r) -> !r.sarosSupport)
            .thenComparing(r -> r.status)
            .thenComparing(r -> r.fullJid.getRAW());

    final JID fullJid;
    final ContactStatus status;
    final boolean sarosSupport;

    Resource(JID fullJid, ContactStatus status, boolean sarosSupport) {
      this.fullJid = Objects.requireNonNull(fullJid, "fullJid is null");
      if (fullJid.isBareJID()) throw new IllegalArgumentException("fullJid is a bare JID");
      this.status = Objects.requireNonNull(status, "status is null");
      this.sarosSupport = sarosSupport;
    }

    @Override
    public String toString() {
      return String.format(
          "Resource [fullJid=%s, status=%s, sarosSupport=%s]", fullJid, status, sarosSupport);
    }
  }

  /**
   * Create a new XMPPContact from RosterEntry.
   *
   * @param entry RosterEntry
   * @return new XMPPContact
   */
  static XMPPContact from(RosterEntry entry) {
    ContactStatus baseStatus;
    if (entry.getStatus() == RosterPacket.ItemStatus.SUBSCRIPTION_PENDING) {
      baseStatus = ContactStatus.TYPE_SUBSCRIPTION_PENDING;
    } else if (entry.getType() == ItemType.none || entry.getType() == ItemType.from) {
      /* see http://xmpp.org/rfcs/rfc3921.html chapter 8.2.1, 8.3.1 and 8.6 */
      baseStatus = ContactStatus.TYPE_SUBSCRIPTION_CANCELED;
    } else {
      baseStatus = ContactStatus.TYPE_OFFLINE;
    }

    return new XMPPContact(new JID(entry.getUser()), baseStatus, entry.getName());
  }

  /** JID of this contact without resource information. */
  private final JID bareJid;
  /** Basic Status of this Contact, either offline, removed or subscription related */
  private volatile ContactStatus baseStatus;
  /** Roster provided nickname if available */
  private volatile Optional<String> nickname;

  /** Set of all known resources. Methods update bestResource after modification. */
  private final Set<Resource> resources = new HashSet<>();
  /** Best resource from resources set. */
  private volatile Optional<Resource> bestResource = Optional.empty();

  private XMPPContact(JID bareJid, ContactStatus baseStatus, String nickname) {
    this.bareJid = Objects.requireNonNull(bareJid, "bareJid is null").getBareJID();
    this.baseStatus = Objects.requireNonNull(baseStatus, "baseStatus is null");
    this.nickname = Optional.ofNullable(nickname);
  }

  /**
   * This method should be avoided in future use outside of {@link saros.net.xmpp}
   *
   * @return Bare JID of this contact
   */
  public JID getBareJid() {
    return bareJid;
  }

  /**
   * If available returns the roster provided nickname, alternatively the bare JID of this contact.
   *
   * @return String containing nickname or JID of this contact
   */
  public String getDisplayableName() {
    return nickname.orElse(bareJid.getRAW());
  }

  /**
   * Get the combination of nickname (if available) and bare JID of this contact.
   *
   * @return String if nickname available in format "nickname (bare JID)" otherwise just bare JID
   */
  public String getDisplayableNameLong() {
    return nickname
        .map(name -> String.format("%s (%s)", name, bareJid.getRAW()))
        .orElse(bareJid.getRAW());
  }

  /**
   * Get the nickname if available.
   *
   * @return Optional with String of nickname if available
   */
  public Optional<String> getNickname() {
    return nickname;
  }

  /**
   * Provide the best resource of a contact currently online.
   *
   * @deprecated This method should be avoided in future use outside of {@link saros.net.xmpp}
   * @return Optional the JID of a resource online regardless of Saros support
   */
  @Deprecated
  public Optional<JID> getOnlineJid() {
    return bestResource.map(r -> r.fullJid);
  }

  /**
   * Provide the JID of a Saros Supporting resource if available.
   *
   * @deprecated This method should be avoided in future use outside of {@link saros.net.xmpp}
   * @return Optional with JID if available
   */
  @Deprecated
  public Optional<JID> getSarosJid() {
    return bestResource.filter(r -> r.sarosSupport).map(r -> r.fullJid);
  }

  /**
   * Get the latest available status information.
   *
   * @return current {@link ContactStatus}
   */
  public ContactStatus getStatus() {
    return bestResource.map(r -> r.status).orElse(baseStatus);
  }

  /**
   * Get Saros support of contact.
   *
   * @return true if contact has Saros support
   */
  public boolean hasSarosSupport() {
    return bestResource.map(r -> r.sarosSupport).orElse(false);
  }

  /**
   * Remove resource informations.
   *
   * @param fullJid
   * @return true if operation changed general status of contact
   */
  boolean removeResource(JID fullJid) {
    ContactStatus oldStatus = getStatus();
    boolean oldSupport = hasSarosSupport();
    resources.removeIf(res -> compareRawJid(res.fullJid, fullJid));
    updateBestResource();
    return !getStatus().equals(oldStatus) || oldSupport != hasSarosSupport();
  }

  /** Remove all resources. */
  void removeResources() {
    resources.clear();
    bestResource = Optional.empty();
  }

  /**
   * Set the general status of this contact.
   *
   * @param status
   * @return true if operation changed general status of contact
   */
  boolean setBaseStatus(ContactStatus status) {
    ContactStatus oldStatus = getStatus();
    baseStatus = status;
    return !getStatus().equals(oldStatus);
  }

  /**
   * Changes the contact nickname.
   *
   * @param newNickname
   * @return true if operation changed nickname of contact
   */
  boolean setNickname(String newNickname) {
    if (Objects.equals(nickname.orElse(null), newNickname)) return false;
    nickname = Optional.ofNullable(newNickname);
    return true;
  }

  /**
   * Adds or sets a new resource state.
   *
   * @param fullJid
   * @param status
   * @return true if operation changed general status of contact
   */
  boolean setResourceStatus(JID fullJid, ContactStatus status, boolean sarosSupport) {
    ContactStatus oldStatus = getStatus();
    boolean oldSarosSupport = hasSarosSupport();

    resources.removeIf(r -> compareRawJid(r.fullJid, fullJid));
    resources.add(new Resource(fullJid, status, sarosSupport));
    updateBestResource();

    if (oldSarosSupport != hasSarosSupport()) return true;
    return !getStatus().equals(oldStatus);
  }

  /**
   * After we received a Available presence we can assume that all pending subscriptions are done.
   *
   * @return true if operation changed general status of contact
   */
  boolean setSubscribed() {
    Type currentStatus = getStatus().getType();
    if (currentStatus == Type.SUBSCRIPTION_CANCELED || currentStatus == Type.SUBSCRIPTION_PENDING) {
      return setBaseStatus(ContactStatus.TYPE_OFFLINE);
    }
    return false;
  }

  private static boolean compareRawJid(JID jid1, JID jid2) {
    return jid1.getRAW().equals(jid2.getRAW());
  }

  private void updateBestResource() {
    bestResource = resources.stream().sorted(Resource.BEST_RESOURCE_FIRST).findFirst();
  }

  @Override
  public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null) return false;
    if (getClass() != obj.getClass()) return false;
    XMPPContact other = (XMPPContact) obj;
    return Objects.equals(bareJid, other.bareJid);
  }

  @Override
  public int hashCode() {
    return bareJid.hashCode();
  }

  @Override
  public String toString() {
    return String.format(
        "XMPPContact [bareJid=%s, baseStatus=%s, bestResource=%s]",
        bareJid, baseStatus, bestResource.orElse(null));
  }
}