/* 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 android.support.annotation.Nullable;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.Map;

import org.json.simple.JSONArray;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.sync.ExtendedJSONObject;
import org.mozilla.gecko.sync.NonArrayJSONException;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.sync.repositories.android.RepoUtils;

/**
 * Covers the fields used by all bookmark objects.
 * @author rnewman
 *
 */
public class BookmarkRecord extends Record {
  public static final String PLACES_URI_PREFIX = "places:";

  private static final String LOG_TAG = "BookmarkRecord";

  public static final String COLLECTION_NAME = "bookmarks";
  public static final long BOOKMARKS_TTL = -1; // Never ttl bookmarks.

  public BookmarkRecord(String guid, String collection, long lastModified, boolean deleted) {
    super(guid, collection, lastModified, deleted);
    this.ttl = BOOKMARKS_TTL;
  }
  public BookmarkRecord(String guid, String collection, long lastModified) {
    this(guid, collection, lastModified, false);
  }
  public BookmarkRecord(String guid, String collection) {
    this(guid, collection, 0, false);
  }
  public BookmarkRecord(String guid) {
    this(guid, COLLECTION_NAME, 0, false);
  }
  public BookmarkRecord() {
    this(Utils.generateGuid(), COLLECTION_NAME, 0, false);
  }

  // Note: redundant accessors are evil. We're all grownups; let's just use
  // public fields.
  public String  title;
  public String  bookmarkURI;
  public String  description;
  public String  keyword;
  public String  parentID;
  public String  parentName;
  public long    androidParentID;
  public String  type;
  public long    androidPosition;

  // This field started to be synced by clients in v55, so there's a chance it will be `null`
  // if this instance is initialized from a remote payload.
  @Nullable public Long dateAdded;

  public JSONArray children;
  public JSONArray tags;

  @Override
  public String toString() {
    return "#<Bookmark " + guid + " (" + androidID + "), parent " +
           parentID + "/" + androidParentID + "/" + parentName + ">";
  }

  // Oh God, this is terribly thread-unsafe. These record objects should be immutable.
  @SuppressWarnings("unchecked")
  protected JSONArray copyChildren() {
    if (this.children == null) {
      return null;
    }
    JSONArray children = new JSONArray();
    children.addAll(this.children);
    return children;
  }

  @SuppressWarnings("unchecked")
  protected JSONArray copyTags() {
    if (this.tags == null) {
      return null;
    }
    JSONArray tags = new JSONArray();
    tags.addAll(this.tags);
    return tags;
  }

  @Override
  public Record copyWithIDs(String guid, long androidID) {
    BookmarkRecord out = new BookmarkRecord(guid, this.collection, this.lastModified, this.deleted);
    out.androidID = androidID;
    out.sortIndex = this.sortIndex;
    out.ttl       = this.ttl;

    // Copy BookmarkRecord fields.
    out.title           = this.title;
    out.bookmarkURI     = this.bookmarkURI;
    out.description     = this.description;
    out.keyword         = this.keyword;
    out.parentID        = this.parentID;
    out.parentName      = this.parentName;
    out.androidParentID = this.androidParentID;
    out.type            = this.type;
    out.androidPosition = this.androidPosition;
    out.dateAdded       = this.dateAdded;

    out.children        = this.copyChildren();
    out.tags            = this.copyTags();

    return out;
  }

  public boolean isBookmark() {
    if (type == null) {
      return false;
    }
    return type.equals("bookmark");
  }

  public boolean isFolder() {
    if (type == null) {
      return false;
    }
    return type.equals("folder");
  }

  public boolean isLivemark() {
    if (type == null) {
      return false;
    }
    return type.equals("livemark");
  }

  public boolean isSeparator() {
    if (type == null) {
      return false;
    }
    return type.equals("separator");
  }

  public boolean isMicrosummary() {
    if (type == null) {
      return false;
    }
    return type.equals("microsummary");
  }

  public boolean isQuery() {
    if (type == null) {
      return false;
    }
    return type.equals("query");
  }

  /**
   * Return true if this record should have the Sync fields
   * of a bookmark, microsummary, or query.
   */
  private boolean isBookmarkIsh() {
    if (type == null) {
      return false;
    }
    return type.equals("bookmark") ||
           type.equals("microsummary") ||
           type.equals("query");
  }

  @Override
  protected void initFromPayload(ExtendedJSONObject payload) {
    this.type        = payload.getString("type");
    this.title       = payload.getString("title");
    this.description = payload.getString("description");
    this.parentID    = payload.getString("parentid");
    this.parentName  = payload.getString("parentName");

    if (!payload.containsKey("dateAdded")) {
      // Missing data will be filled later on, during record insertion/reconciliation.
      this.dateAdded = null;
    } else {
      // Oddly enough, in local testing both of these exception paths turned out to be necessary.
      try {
        this.dateAdded = payload.getLong("dateAdded");
      } catch (ClassCastException e) {
        this.dateAdded = Long.parseLong(payload.getString("dateAdded"));
      } catch (Exception e) {
        Logger.error(LOG_TAG, "Cannot parse bookmark's 'dateAdded' value. Setting it to null.", e);
        this.dateAdded = null;
      }
    }

    if (isFolder()) {
      try {
        this.children = payload.getArray("children");
      } catch (NonArrayJSONException e) {
        Logger.error(LOG_TAG, "Got non-array children in bookmark record " + this.guid, e);
        // Let's see if we can recover later by using the parentid pointers.
        this.children = new JSONArray();
      }
      return;
    }

    final String bmkUri = payload.getString("bmkUri");

    // bookmark, microsummary, query.
    if (isBookmarkIsh()) {
      this.keyword = payload.getString("keyword");
      try {
        this.tags = payload.getArray("tags");
      } catch (NonArrayJSONException e) {
        Logger.warn(LOG_TAG, "Got non-array tags in bookmark record " + this.guid, e);
        this.tags = new JSONArray();
      }
    }

    if (isBookmark()) {
      this.bookmarkURI = bmkUri;
      return;
    }

    if (isLivemark()) {
      String siteUri = payload.getString("siteUri");
      String feedUri = payload.getString("feedUri");
      this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri,
                                                  "siteUri", siteUri,
                                                  "feedUri", feedUri);
      return;
    }
    if (isQuery()) {
      String queryId = payload.getString("queryId");
      String folderName = payload.getString("folderName");
      this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri,
                                                  "queryId", queryId,
                                                  "folderName", folderName);
      return;
    }
    if (isMicrosummary()) {
      String generatorUri = payload.getString("generatorUri");
      String staticTitle = payload.getString("staticTitle");
      this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri,
                                                  "generatorUri", generatorUri,
                                                  "staticTitle", staticTitle);
      return;
    }
    if (isSeparator()) {
      Object p = payload.get("pos");
      if (p instanceof Long) {
        this.androidPosition = (Long) p;
      } else if (p instanceof String) {
        try {
          this.androidPosition = Long.parseLong((String) p, 10);
        } catch (NumberFormatException e) {
          return;
        }
      } else {
        Logger.warn(LOG_TAG, "Unsupported position value " + p);
        return;
      }
      String pos = String.valueOf(this.androidPosition);
      this.bookmarkURI = encodeUnsupportedTypeURI(null, "pos", pos, null, null);
      return;
    }
  }

  @Override
  protected void populatePayload(ExtendedJSONObject payload) {
    putPayload(payload, "type", this.type);
    putPayload(payload, "title", this.title);
    putPayload(payload, "description", this.description);
    putPayload(payload, "parentid", this.parentID);
    putPayload(payload, "parentName", this.parentName);
    putPayload(payload, "keyword", this.keyword);

    // This field is marked as nullable, so we're careful when unboxing it. However, we do not expect
    // it to be null at this point, since it was constructed from our DB cursor which will always have
    // a `created` timestamp.
    // However, very old bookmarks that didn't have a corresponding history item might not have a
    // creation date. `created` is not a NOT NULL field in the bookmarks table. Either way, it pays
    // to be cautious here.
    if (this.dateAdded != null) {
      putPayload(payload, "dateAdded", Long.toString(this.dateAdded));
    }

    if (isFolder()) {
      payload.put("children", this.children);
      return;
    }

    // bookmark, microsummary, query.
    if (isBookmarkIsh()) {
      if (isBookmark()) {
        payload.put("bmkUri", bookmarkURI);
      }

      if (isQuery()) {
        Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
        putPayload(payload, "queryId", parts.get("queryId"), true);
        putPayload(payload, "folderName", parts.get("folderName"), true);
        putPayload(payload, "bmkUri", parts.get("uri"));
        return;
      }

      if (this.tags != null) {
        payload.put("tags", this.tags);
      }

      putPayload(payload, "keyword", this.keyword);
      return;
    }

    if (isLivemark()) {
      Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
      putPayload(payload, "siteUri", parts.get("siteUri"));
      putPayload(payload, "feedUri", parts.get("feedUri"));
      return;
    }
    if (isMicrosummary()) {
      Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
      putPayload(payload, "generatorUri", parts.get("generatorUri"));
      putPayload(payload, "staticTitle", parts.get("staticTitle"));
      return;
    }
    if (isSeparator()) {
      Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
      String pos = parts.get("pos");
      if (pos == null) {
        return;
      }
      try {
        payload.put("pos", Long.parseLong(pos, 10));
      } catch (NumberFormatException e) {
        return;
      }
      return;
    }
  }

  private void trace(String s) {
    Logger.trace(LOG_TAG, s);
  }

  @Override
  public boolean equalPayloads(Object o) {
    trace("Calling BookmarkRecord.equalPayloads.");
    if (!(o instanceof BookmarkRecord)) {
      return false;
    }

    BookmarkRecord other = (BookmarkRecord) o;
    if (!super.equalPayloads(other)) {
      return false;
    }

    if (!RepoUtils.stringsEqual(this.type, other.type)) {
      return false;
    }

    // Check children.
    if (isFolder() && (this.children != other.children)) {
      trace("BookmarkRecord.equals: this folder: " + this.title + ", " + this.guid);
      trace("BookmarkRecord.equals: other: " + other.title + ", " + other.guid);
      if (this.children  == null &&
          other.children != null) {
        trace("Records differ: one children array is null.");
        return false;
      }
      if (this.children  != null &&
          other.children == null) {
        trace("Records differ: one children array is null.");
        return false;
      }
      if (this.children.size() != other.children.size()) {
        trace("Records differ: children arrays differ in size (" +
              this.children.size() + " vs. " + other.children.size() + ").");
        return false;
      }

      for (int i = 0; i < this.children.size(); i++) {
        String child = (String) this.children.get(i);
        if (!other.children.contains(child)) {
          trace("Records differ: child " + child + " not found.");
          return false;
        }
      }
    }

    trace("Checking strings.");
    return RepoUtils.stringsEqual(this.title, other.title)
        && RepoUtils.stringsEqual(this.bookmarkURI, other.bookmarkURI)
        && RepoUtils.stringsEqual(this.parentID, other.parentID)
        && RepoUtils.stringsEqual(this.parentName, other.parentName)
        && RepoUtils.stringsEqual(this.description, other.description)
        && RepoUtils.stringsEqual(this.keyword, other.keyword)
        && jsonArrayStringsEqual(this.tags, other.tags);
  }

  // TODO: two records can be congruent if their child lists are different.
  @Override
  public boolean congruentWith(Object o) {
    return this.equalPayloads(o) &&
           super.congruentWith(o);
  }

  // Converts two JSONArrays to strings and checks if they are the same.
  // This is only useful for stuff like tags where we aren't actually
  // touching the data there (and therefore ordering won't change)
  private boolean jsonArrayStringsEqual(JSONArray a, JSONArray b) {
    // Check for nulls
    if (a == b) return true;
    if (a == null && b != null) return false;
    if (a != null && b == null) return false;
    return RepoUtils.stringsEqual(a.toJSONString(), b.toJSONString());
  }

  /**
   * URL-encode the provided string. If the input is null,
   * the empty string is returned.
   *
   * @param in the string to encode.
   * @return a URL-encoded version of the input.
   */
  protected static String encode(String in) {
    if (in == null) {
      return "";
    }
    try {
      return URLEncoder.encode(in, "UTF-8");
    } catch (UnsupportedEncodingException e) {
      // Will never occur.
      return null;
    }
  }

  /**
   * Take the provided URI and two parameters, constructing a URI like
   *
   *   places:uri=$uri&p1=$p1&p2=$p2
   *
   * null values in either parameter or value result in the parameter being omitted.
   */
  protected static String encodeUnsupportedTypeURI(String originalURI, String p1, String v1, String p2, String v2) {
    StringBuilder b = new StringBuilder(PLACES_URI_PREFIX);
    boolean previous = false;
    if (originalURI != null) {
      b.append("uri=");
      b.append(encode(originalURI));
      previous = true;
    }
    if (p1 != null && v1 != null) {
      if (previous) {
        b.append("&");
      }
      b.append(p1);
      b.append("=");
      b.append(encode(v1));
      previous = true;
    }
    if (p2 != null && v2 != null) {
      if (previous) {
        b.append("&");
      }
      b.append(p2);
      b.append("=");
      b.append(encode(v2));
      previous = true;
    }
    return b.toString();
  }
}


/*
// Bookmark:
{cleartext:
  {id:            "l7p2xqOTMMXw",
   type:          "bookmark",
   title:         "Your Flight Status",
   parentName:    "mobile",
   dateAdded:     1490997969974,
   bmkUri:        "http: //www.flightstats.com/go/Mobile/flightStatusByFlightProcess.do;jsessionid=13A6C8DCC9592AF141A43349040262CE.web3: 8009?utm_medium=cpc&utm_campaign=co-op&utm_source=airlineInformationAndStatus&id=212492593",
   tags:          [],
   keyword:       null,
   description:   null,
   loadInSidebar: false,
   parentid:      "mobile"},
 data: {payload: {ciphertext: null},
 id:         "l7p2xqOTMMXw",
 sortindex:  107},
 collection: "bookmarks"}

// Folder:
{cleartext:
  {id:          "mobile",
   type:        "folder",
   parentName:  "",
   title:       "mobile",
   description: null,
   dateAdded:   1490997969974,
   children:    ["1ROdlTuIoddD", "3Z_bMIHPSZQ8", "4mSDUuOo2iVB", "8aEdE9IIrJVr",
                 "9DzPTmkkZRDb", "Qwwb99HtVKsD", "s8tM36aGPKbq", "JMTi61hOO3JV",
                 "JQUDk0wSvYip", "LmVH-J1r3HLz", "NhgQlC5ykYGW", "OVanevUUaqO2",
                 "OtQVX0PMiWQj", "_GP5cF595iie", "fkRssjXSZDL3", "k7K_NwIA1Ya0",
                 "raox_QGzvqh1", "vXYL-xHjK06k", "QKHKUN6Dm-xv", "pmN2dYWT2MJ_",
                 "EVeO_J1SQiwL", "7N-qkepS7bec", "NIGa3ha-HVOE", "2Phv1I25wbuH",
                 "TTSIAH1fV0VE", "WOmZ8PfH39Da", "gDTXNg4m1AJZ", "ayI30OZslHbO",
                 "zSEs4O3n6CzQ", "oWTDR0gO2aWf", "wWHUoFaInXi9", "F7QTuVJDpsTM",
                 "FIboggegplk-", "G4HWrT5nfRYS", "MHA7y9bupDdv", "T_Ldzmj0Ttte",
                 "U9eYu3SxsE_U", "bk463Kl9IO_m", "brUfrqJjFNSR", "ccpawfWsD-bY",
                 "l7p2xqOTMMXw", "o-nSDKtXYln7"],
   parentid: "places"},
 data:        {payload: {ciphertext: null},
 id:          "mobile",
 sortindex:   1000000},
 collection: "bookmarks"}
*/
