/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.sync.repositories.domain;

import java.io.UnsupportedEncodingException;

import org.mozilla.gecko.sync.CryptoRecord;
import org.mozilla.gecko.sync.ExtendedJSONObject;

/**
 * Record is the abstract base class for all entries that Sync processes:
 * bookmarks, passwords, history, and such.
 *
 * A Record can be initialized from or serialized to a CryptoRecord for
 * submission to an encrypted store.
 *
 * Records should be considered to be conventionally immutable: modifications
 * should be completed before the new record object escapes its constructing
 * scope. Note that this is a critically important part of equality. As Rich
 * Hickey notes:
 *
 *   … the only things you can really compare for equality are immutable things,
 *   because if you compare two things for equality that are mutable, and ever
 *   say true, and they're ever not the same thing, you are wrong. Or you will
 *   become wrong at some point in the future.
 *
 * Records have a layered definition of equality. Two records can be said to be
 * "equal" if:
 *
 * * They have the same GUID and collection. Two crypto/keys records are in some
 *   way "the same".
 *   This is `equalIdentifiers`.
 *
 * * Their most significant fields are the same. That is to say, they share a
 *   GUID, a collection, deletion, and domain-specific fields. Two copies of
 *   crypto/keys, neither deleted, with the same encrypted data but different
 *   modified times and sortIndex are in a stronger way "the same".
 *   This is `equalPayloads`.
 *
 * * Their most significant fields are the same, and their local fields (e.g.,
 *   the androidID to which we have decided that this record maps) are congruent.
 *   A record with the same androidID, or one whose androidID has not been set,
 *   can be considered "the same".
 *   This concept can be extended by Record subclasses. The key point is that
 *   reconciling should be applied to the contents of these records. For example,
 *   two history records with the same URI and GUID, but different visit arrays,
 *   can be said to be congruent.
 *   This is `congruentWith`.
 *
 * * They are strictly identical. Every field that is persisted, including
 *   lastModified and androidID, is equal.
 *   This is `equals`.
 *
 * Different parts of the codebase have use for different layers of this
 * comparison hierarchy. For instance, lastModified times change every time a
 * record is stored; a store followed by a retrieval will return a Record that
 * shares its most significant fields with the input, but has a later
 * lastModified time and might not yet have values set for others. Reconciling
 * will thus ignore the modification time of a record.
 *
 * @author rnewman
 *
 */
public abstract class Record {

  public String guid;
  public String collection;
  public long lastModified;
  public boolean deleted;
  public long androidID;
  /**
   * An integer indicating the relative importance of this item in the collection.
   * <p>
   * Default is 0.
   */
  public long sortIndex;
  /**
   * The number of seconds to keep this record. After that time this item will
   * no longer be returned in response to any request, and it may be pruned from
   * the database.
   * <p>
   * Negative values mean never forget this record.
   * <p>
   * Default is 1 year.
   */
  public long ttl;

  public Record(String guid, String collection, long lastModified, boolean deleted) {
    this.guid         = guid;
    this.collection   = collection;
    this.lastModified = lastModified;
    this.deleted      = deleted;
    this.sortIndex    = 0;
    this.ttl          = 365 * 24 * 60 * 60; // Seconds.
    this.androidID    = -1;
  }

  /**
   * Return true iff the input is a Record and has the same
   * collection and guid as this object.
   */
  public boolean equalIdentifiers(Object o) {
    if (!(o instanceof Record)) {
      return false;
    }

    Record other = (Record) o;
    if (this.guid == null) {
      if (other.guid != null) {
        return false;
      }
    } else {
      if (!this.guid.equals(other.guid)) {
        return false;
      }
    }
    if (this.collection == null) {
      if (other.collection != null) {
        return false;
      }
    } else {
      if (!this.collection.equals(other.collection)) {
        return false;
      }
    }
    return true;
  }

  /**
   * @param o
   *        The object to which this object should be compared.
   * @return
   *        true iff the input is a Record which is substantially the
   *        same as this object.
   */
  public boolean equalPayloads(Object o) {
    if (!this.equalIdentifiers(o)) {
      return false;
    }
    Record other = (Record) o;
    return this.deleted == other.deleted;
  }

  /**
   *
   *
   * @param o
   *        The object to which this object should be compared.
   * @return
   *        true iff the input is a Record which is substantially the
   *        same as this object, considering the ability and desire to
   *        reconcile the two objects if possible.
   */
  public boolean congruentWith(Object o) {
    if (!this.equalIdentifiers(o)) {
      return false;
    }
    Record other = (Record) o;
    return congruentAndroidIDs(other) &&
           (this.deleted == other.deleted);
  }

  public boolean congruentAndroidIDs(Record other) {
    // We treat -1 as "unset", and treat this as
    // congruent with any other value.
    if (this.androidID  != -1 &&
        other.androidID != -1 &&
        this.androidID  != other.androidID) {
      return false;
    }
    return true;
  }

  /**
   * Return true iff the input is both equal in terms of payload,
   * and also shares transient values such as timestamps.
   */
  @Override
  public boolean equals(Object o) {
    if (!(o instanceof Record)) {
      return false;
    }

    Record other = (Record) o;
    return equalTimestamps(other) &&
           equalSortIndices(other) &&
           equalAndroidIDs(other) &&
           equalPayloads(o);
  }

  public boolean equalAndroidIDs(Record other) {
    return this.androidID == other.androidID;
  }

  public boolean equalSortIndices(Record other) {
    return this.sortIndex == other.sortIndex;
  }

  public boolean equalTimestamps(Object o) {
    if (!(o instanceof Record)) {
      return false;
    }
    return ((Record) o).lastModified == this.lastModified;
  }

  protected abstract void populatePayload(ExtendedJSONObject payload);
  protected abstract void initFromPayload(ExtendedJSONObject payload);

  public void initFromEnvelope(CryptoRecord envelope) {
    ExtendedJSONObject p = envelope.payload;
    this.guid = envelope.guid;
    checkGUIDs(p);

    this.collection    = envelope.collection;
    this.lastModified  = envelope.lastModified;

    final Object del = p.get("deleted");
    if (del instanceof Boolean) {
      this.deleted = (Boolean) del;
    } else {
      this.initFromPayload(p);
    }

  }

  public CryptoRecord getEnvelope() {
    CryptoRecord rec = new CryptoRecord(this);
    ExtendedJSONObject payload = new ExtendedJSONObject();
    payload.put("id", this.guid);

    if (this.deleted) {
      payload.put("deleted", true);
    } else {
      populatePayload(payload);
    }
    rec.payload = payload;
    return rec;
  }

  @SuppressWarnings("static-method")
  public String toJSONString() {
    throw new RuntimeException("Cannot JSONify non-CryptoRecord Records.");
  }

  public byte[] toJSONBytes() {
    try {
      return this.toJSONString().getBytes("UTF-8");
    } catch (UnsupportedEncodingException e) {
      // Can't happen.
      return null;
    }
  }

  /**
   * Utility for safely populating an output CryptoRecord.
   *
   * @param rec
   * @param key
   * @param value
   */
  @SuppressWarnings("static-method")
  protected void putPayload(CryptoRecord rec, String key, String value) {
    if (value == null) {
      return;
    }
    rec.payload.put(key, value);
  }

  protected void putPayload(ExtendedJSONObject payload, String key, String value) {
    this.putPayload(payload, key, value, false);
  }

  @SuppressWarnings("static-method")
  protected void putPayload(ExtendedJSONObject payload, String key, String value, boolean excludeEmpty) {
    if (value == null) {
      return;
    }
    if (excludeEmpty && value.equals("")) {
      return;
    }
    payload.put(key, value);
  }

  protected void checkGUIDs(ExtendedJSONObject payload) {
    String payloadGUID = (String) payload.get("id");
    if (this.guid == null ||
        payloadGUID == null) {
      String detailMessage = "Inconsistency: either envelope or payload GUID missing.";
      throw new IllegalStateException(detailMessage);
    }
    if (!this.guid.equals(payloadGUID)) {
      String detailMessage = "Inconsistency: record has envelope ID " + this.guid + ", payload ID " + payloadGUID;
      throw new IllegalStateException(detailMessage);
    }
  }

  /**
   * Oh for persistent data structures.
   *
   * @param guid
   * @param androidID
   * @return
   *        An identical copy of this record with the provided two values.
   */
  public abstract Record copyWithIDs(String guid, long androidID);
}
