/* See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * Esri Inc. 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 com.esri.gpt.agp.sync;
import com.esri.gpt.agp.client.AgpClient;
import com.esri.gpt.agp.client.AgpCommonRequest;
import com.esri.gpt.agp.client.AgpConnection;
import com.esri.gpt.agp.client.AgpItem;
import com.esri.gpt.agp.client.AgpItemListener;
import com.esri.gpt.agp.client.AgpItems;
import com.esri.gpt.agp.client.AgpProperties;
import com.esri.gpt.agp.client.AgpProperty;
import com.esri.gpt.agp.client.AgpSearchCriteria;
import com.esri.gpt.agp.client.AgpSearchRequest;
import com.esri.gpt.agp.client.AgpUtil;
import com.esri.gpt.agp.multipart2.MultipartProvider;
import com.esri.gpt.framework.util.Val;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.json.JSONObject;

/**
 * A push synchronizer from a source ArcGIS Portal to a 
 * destination ArcGIS Portal.
 */
public class AgpPush { 
  
/*


AgpPush
 - ** don't use if source or destination < 1.6.0.2
 - ** prevent host to same host synchronization?
 - ** prevent synchronization of items with same itemId? same title?
 - ** prevent propagation of synchronized items?
 - show group ids, owner ids from push.jsp?
 - auto share ?
 - makeDestinationTypeKeywords
 - what about deletes?
 - should there be retries?
 - determine the version?
 - /sharing vs /sharing/rest
 - issues with referenced/related items for web maps?
 - code attachments aren't updating correctly
 - special characters in description, utf issue?
 - metadata starting with <xhtml>
 - specify port
 - specify non-https
 - too many items in a folder


ok AgpClient
?  AgpCommonRequest (queryRelatedItems?)
ok AgpConnection
ok AgpContext
ok AgpCredentials
ok AgpCursor
ok AgpDPart
ok AgpError
ok AgpException
ok AgpItem
ok AgpItemListener
ok AgpItems
ok AgpProperties
ok AgpProperty
ok AgpSearchCriteria
ok AgpSearchRequest
ok AgpToken
ok AgpTokenCriteria
ok AgpTokenRequest
ok AgpUtil

ok AgpDestination
ok AgpSource
?  AgpItemHelper
?  AgpPartHelper

ok MPart
ok MultipartProvider

push.jsp
push-exec.jsp
push-working.jsp

*/

/*
  // TODO: 
  
  issues with referenced/related items for web maps?
  makeDestinationTypeKeywords
  
  should there be retries?
  determine the version
  
  try to supply an id
  Supported types?
  web maps, item property plus typeKeywords ??
  addDataPart
  
  item property?
  sort by date on the primary search?
  metadata thumbnail file attachments relationships
  auto share
  Comments Ratings Views
  what about deletes?
  typekeywords
  code attachments aren't updating correctly
  don't move if the id exists on the other side?
  registered services within a web map?
  special characters in description, utf issue?
  
*/  

  
  /** class variables ========================================================= */
  
  /** The Logger. */
  private static final Logger LOGGER = Logger.getLogger(AgpPush.class.getName());
  
  /** instance variables ====================================================== */
  private AgpItemHelper  itemHelper = new AgpItemHelper(); 
  private AgpPartHelper  partHelper = new AgpPartHelper(); 
  
  private AgpSource      source;
  private AgpDestination destination;
  private boolean        forceUpdates = false;
  private boolean        readOnly = false;
  private List<String>   webMaps = new ArrayList<String>();
  
  private long millisStart = 0;
  private long millisEnd = 0;
  private int  numItemsConsidered = 0;
  private int  numItemsInserted = 0;
  private int  numItemsUpdated = 0;
  private int  numMetadataPublished = 0;
  private int  numOriginatedFromSynchronization = 0;
  private int  numRelationshipsAdded = 0;
  private int  numUnsyncedExistingAtDestination = 0;
  private int  numWithNullId = 0;
  private int  numWithNullType = 0;
     
  /** constructors ============================================================ */
  
  /** Default constructor. */
  public AgpPush() {}
  
  /**
   * Construct with a source and destination.
   * @param source the source
   * @param destination the destination
   */
  public AgpPush(AgpSource source, AgpDestination destination) {
    this.source = source;
    this.destination = destination;
    //this.forceUpdates = true; // TODO turn off
  }
  
  /** properties ============================================================== */
  
  /**
   * Gets the process summary.
   * @param forHtml true if the summary should be formatted for HTML
   * @return the process summary
   */
  public String getSummary(boolean forHtml) {
    StringBuilder msg = new StringBuilder();
    String s;
    String nl = "\n";
    if (forHtml) nl = "<br/>";
    
    msg.append("Sync:");
    
    double dSec = (this.millisEnd - this.millisStart) / 1000.0;
    String sMin = (Math.round(dSec / 60.0 * 100.0) / 100.0)+" minutes";
    String sSec = (Math.round(dSec * 100.0) / 100.0)+" seconds";
    if (dSec > 300) {
      s = sMin;
    } else if (dSec <= 60) {
      s = sSec;
    } else {
      //s = sMin +", "+sSec;
      s = sSec;
    } 
    msg.append(" ").append(s);
    
    msg.append(", considered:").append(this.numItemsConsidered);
    msg.append(" inserted:").append(this.numItemsInserted);
    msg.append(" updated:").append(this.numItemsUpdated);
    msg.append(" metadataPublished:").append(this.numMetadataPublished);
    msg.append(" relationshipsAdded:").append(this.numRelationshipsAdded);
    
    int n = this.numUnsyncedExistingAtDestination+this.numOriginatedFromSynchronization+this.numWithNullId+this.numWithNullType;
    if (n > 0) {
      msg.append(nl).append(" ignored:").append(n);
      if (this.numUnsyncedExistingAtDestination > 0) {
        msg.append(" unsyncedExistingAtDestination:").append(this.numUnsyncedExistingAtDestination);
      }
      if (this.numOriginatedFromSynchronization > 0) {
        msg.append(" originatedFromSynchronization:").append(this.numOriginatedFromSynchronization);
      }
      if (this.numWithNullId > 0) {
        msg.append(" withNullId:").append(this.numWithNullId);
      }
      if (this.numWithNullType > 0) {
        msg.append(" withNullType:").append(this.numWithNullType);
      }
    }
    return msg.toString();
  }
    
  /** methods ================================================================= */
  
  /**
   * Add a relationship between items.
   * @param connection the connection
   * @param owner the owner of the relationship
   * @param originItemId the from item for the relationship
   * @param destinationItemId the to item for the relationship
   * @param relationshipType the relationship type
   * @throws Exception if an exception occurs
   */
  private void execAddRelationship(AgpConnection connection, String owner,
      String originItemId, String destinationItemId, String relationshipType) 
    throws Exception {
    String sMsg = originItemId+" to "+destinationItemId+" ,type="+relationshipType;
    LOGGER.finer("Adding relationship "+sMsg);
        
    String sUrl = connection.makeSharingUrl();
    sUrl += "/content/users";
    sUrl += "/"+AgpUtil.encodeURIComponent(owner);
    sUrl += "/addRelationship";
    StringBuilder params = new StringBuilder("f=json");
    connection.appendToken(params);
    AgpUtil.appendURLParameter(params,"originItemId",originItemId,true);
    AgpUtil.appendURLParameter(params,"destinationItemId",destinationItemId,true);
    AgpUtil.appendURLParameter(params,"relationshipType",relationshipType,true);
    AgpProperties hdr = connection.makeRequestHeaderProperties();
    String sMimeType = "application/x-www-form-urlencoded";
    
    // TODO: sometimes this takes a retry, why?
    if (this.readOnly) return;
    AgpClient client = connection.ensureClient();
    JSONObject jso = client.executeJsonRequest(sUrl,hdr,params,sMimeType);
    if (jso.has("success") && jso.getString("success").equals("true")) { 
      this.numRelationshipsAdded++;
      LOGGER.finer("Relationship added: "+Val.stripControls(sUrl)+" "+sMsg);
    } else {
      LOGGER.finer("Add relationship failed: "+Val.stripControls(sUrl)+" "+sMsg);
      // TODO: throw exception here??
    }
  }
  
  /**
   * Publish an item to the destination.
   * @param sourceItem the source item
   * @param destItem the destination item
   * @throws Exception is an exception occurs
   */
  private void execPublishItem(AgpItem sourceItem, AgpItem destItem) 
    throws Exception {
    AgpSource src = this.source;
    AgpDestination dest = this.destination;
    String sSrcId = sourceItem.getProperties().getValue("id");
    String sDestId = destItem.getProperties().getValue("id");
    String sTitle = sourceItem.getProperties().getValue("title");
    LOGGER.finer("Publishing item: "+Val.stripControls(sSrcId)+" "+sTitle);
    if (LOGGER.isLoggable(Level.FINEST)) {
      LOGGER.finest(sourceItem.getProperties().toString());
      LOGGER.finest(destItem.getProperties().toString());
    }
    
    // make the URL
    boolean bInsert = true;
    String sUrl = dest.getConnection().makeSharingUrl();
    sUrl += "/content/users";
    sUrl += "/"+AgpUtil.encodeURIComponent(dest.getDestinationOwner());
    sUrl += "/"+AgpUtil.encodeURIComponent(dest.getDestinationFolderID()); 
    if (sDestId == null) {
      sUrl += "/addItem";
    } else {
      bInsert = false;
      sUrl += "/items";
      sUrl += "/"+AgpUtil.encodeURIComponent(sDestId);
      sUrl += "/update";
    }
    
    // make the content provider, add thumb-nail and data parts
    MultipartProvider provider = new MultipartProvider();
    provider.add("f","json");
    provider.add("token",destination.getConnection().getToken().getTokenString());
    provider.add("overwrite","true");
    for (AgpProperty destProp: destItem.getProperties().values()) {
      provider.add(destProp.getName(),destProp.getValue());
    }
    this.partHelper.addThumbnailPart(provider,src,sourceItem,dest,destItem);
    this.partHelper.addDataPart(provider,src,sourceItem,dest,destItem);
        
    // execute
    if (this.readOnly) return;
    AgpProperties hdr = dest.getConnection().makeRequestHeaderProperties();
    AgpClient client = dest.getConnection().ensureClient();
    JSONObject jso = client.executeJsonRequest(sUrl,hdr,provider);
    if (jso.has("id") && jso.has("success") && jso.getString("success").equals("true")) {
      if (sDestId == null) {
        sDestId = jso.getString("id");
        destItem.getProperties().add(new AgpProperty("id",sDestId));
      }  
      if (bInsert) {
        this.numItemsInserted++; 
        LOGGER.finer("Item inserted: "+Val.stripControls(sUrl));
      } else {
        this.numItemsUpdated++;
        LOGGER.finer("Item updated: "+Val.stripControls(sUrl));
      }
      
    } else {
      LOGGER.finer("Publish item FAILED for: "+Val.stripControls(sUrl));
      // TODO: throw exception here??
    }
  }
  
  /**
   * Publish formal metadata to the destination.
   * @param itemId the destination item ID
   * @param xml the metadata XML
   * @throws Exception if an exception occurs
   */
  private void execPublishMetadata(String itemId, String xml) throws Exception {
    LOGGER.finer("Publishing metadata for:"+itemId);
    
    AgpDestination dest = this.destination;    
    String sUrl = dest.getConnection().makeSharingUrl();
    sUrl += "/content/users";
    sUrl += "/"+AgpUtil.encodeURIComponent(dest.getDestinationOwner());
    sUrl += "/"+AgpUtil.encodeURIComponent(dest.getDestinationFolderID());
    sUrl += "/items";
    sUrl += "/"+AgpUtil.encodeURIComponent(itemId);
    sUrl += "/update";
    
    AgpProperties hdr = dest.getConnection().makeRequestHeaderProperties();
    MultipartProvider provider = new MultipartProvider();
    provider.add("f","json");
    provider.add("token",destination.getConnection().getToken().getTokenString());
    provider.add("overwrite","true");
    provider.add("metadata",xml.getBytes("UTF-8"),"metadata.xml","text/xml","UTF-8");
   
    if (this.readOnly) return;
    AgpClient client = dest.getConnection().ensureClient();
    JSONObject jso = client.executeJsonRequest(sUrl,hdr,provider);
    if (jso.has("success") && jso.getString("success").equals("true")) {  
      this.numMetadataPublished++;
      LOGGER.finer("Metadata updated for: "+Val.stripControls(sUrl));
    } else {
      LOGGER.finer("Metadata update FAILED for: "+Val.stripControls(sUrl));
      // TODO: throw exception here??
    }
  }
  
  /**
   * Process the formal metadata for an item.
   * @param sourceItem the source item
   * @param destItem the destination item
   * @throws Exception is an exception occurs
   */
  private void processMetadata(AgpItem sourceItem, AgpItem destItem) 
    throws Exception {
    String sSrcId = sourceItem.getProperties().getValue("id");
    String sDestId = destItem.getProperties().getValue("id");
    String sTitle = sourceItem.getProperties().getValue("title");
    LOGGER.finer("Processing metadata for: "+sSrcId+" "+sTitle);
    AgpCommonRequest request = new AgpCommonRequest();
    AgpConnection connection = this.source.getConnection();
    try {
      String sXml = request.readFormalMetadata(connection,sSrcId);
      if ((sXml != null) && (sXml.length() > 0)) {
        if (sXml.indexOf("<xhtml") == -1) {
          this.execPublishMetadata(sDestId,sXml);
        }
      }
    } catch (IOException ioe) {
      String s = ioe.toString();
      
      // this is what happens when there is no metadata
      // 1.6.02         HTTP Request failed: HTTP/1.1 500 Internal Server Error
      // AGOL 4/13/2012 HTTP Request failed: HTTP/1.1 400 Bad Request
      if (s.contains("HTTP Request failed")) {
        LOGGER.finest("No metadata found for item:"+Val.stripControls(sSrcId)+" "+s);
      } else {
        //TODO: throw exception here?
        ioe.printStackTrace(System.err);
      }
    }
  }
  
  /**
   * Process the related items for a source item
   * @param sourceItem the source item
   * @param destItem the destination item
   * @throws Exception is an exception occurs
   */
  private void processRelatedItems(AgpItem sourceItem, AgpItem destItem) 
    throws Exception {
    AgpSource src = this.source;
    AgpDestination dest = this.destination;
    String sSrcId = sourceItem.getProperties().getValue("id");
    String sDestId = destItem.getProperties().getValue("id");
    String sTitle = sourceItem.getProperties().getValue("title");
    String sType = sourceItem.getProperties().getValue("type");
    LOGGER.finer("Processing related items for: "+sSrcId+" "+sTitle);

    // TODO: there are more relationships to consider
    // TODO: currently only considering rel types WMA2Code and MobileApp2Code
    
    String sRelType = null;
    String sRelDir = "forward";
    if (sType.equalsIgnoreCase("Web Mapping Application")) {
      sRelType = "WMA2Code";
    } else if (sType.equalsIgnoreCase("Mobile Application")) {
      sRelType = "MobileApp2Code";
    } else if (sType.equalsIgnoreCase("Web Map")) {
      //sRelType = "Map2Service";           // related services
      //sRelType = "Map2FeatureCollection"; // related features
    } else if (sType.equalsIgnoreCase("Service Item")) {
      //sRelType = "Service2Data";
    } else if (sType.equalsIgnoreCase("Map Service")) {
      //sRelType = "Service2Data";
    } else if (sType.equalsIgnoreCase("Feature Service")) {
      //sRelType = "Service2Data";
    }
    
    AgpItems destRelItems = null;
    AgpItems srcRelItems = null;
    AgpCommonRequest request = new AgpCommonRequest();
    if (sRelType != null) {
      
      srcRelItems = request.queryRelatedItems(src.getConnection(),
          sSrcId,sRelType,sRelDir);
      destRelItems = request.queryRelatedItems(dest.getConnection(),
          sDestId,sRelType,sRelDir);
      
      // TODO: consider when to delete destination related items
      
      if ((srcRelItems != null) && (srcRelItems.size() > 0)) {
        for (AgpItem srcRelItem: srcRelItems.values()) {
          
          AgpItem destRelItem = this.itemHelper.makeDestinationItem(src,srcRelItem);
          boolean bRequiresUpdate = this.itemHelper.requiresUpdate(srcRelItem,dest,destRelItem);
          if (this.forceUpdates || bRequiresUpdate) {
            this.execPublishItem(srcRelItem,destRelItem);
          }
          
          boolean bAddRel = true;
          if ((destRelItems != null) && (destRelItems.size() > 0)) {
            // TODO: this isn't enough
            bAddRel = false; 
          }
          
          if (bAddRel) {
            String sRelOwner = dest.getDestinationOwner();
            String sRelDestId = destRelItem.getProperties().getValue("id");
            this.execAddRelationship(dest.getConnection(),
                sRelOwner,sDestId,sRelDestId,sRelType);
          }
        }
      }
    }
  }
  
  /**
   * Process a web map.
   * @param sourceItem the source item
   * @throws Exception if an exception occurs
   */
  private void processWebMap(AgpItem sourceItem) throws Exception {
    AgpDestination dest = this.destination;
    String sId = sourceItem.getProperties().getValue("id");
    LOGGER.finer("Publishing web map: "+sId);
    AgpItem destItem = this.itemHelper.makeDestinationItem(this.source,sourceItem);
    boolean bRequiresUpdate = this.itemHelper.requiresUpdate(sourceItem,dest,destItem);
    if (this.forceUpdates || bRequiresUpdate) {
      this.execPublishItem(sourceItem,destItem);
      this.processMetadata(sourceItem,destItem);
      this.processRelatedItems(sourceItem,destItem);
    }
  }
  
  /**
   * Publish the web maps to the destination.
   * <br/>Web maps are collected then published last, this is
   * to ensure that items referenced by the web map have 
   * been previously pushed.
   * @throws Exception if an exception occurs
   */
  private void processWebMaps() throws Exception {
    LOGGER.finer("Publishing "+this.webMaps.size()+" web map(s).");
    for (String sId: this.webMaps) {
      AgpSearchRequest request = new AgpSearchRequest();
      AgpSearchCriteria criteria = new AgpSearchCriteria();
      criteria.setQ("id:"+sId);
      criteria.setNum(1);
      request.search(this.source.getConnection(),criteria, 
        new AgpItemListener() {
          @Override
          public void onItemLoaded(AgpConnection connection, AgpItem item)
            throws Exception {
            processWebMap(item);
          }
        }
      );
    }
  }
  
  
  /**
   * Synchronized an item.
   * @param sourceItem the source item
   * @return <code>true</code> if item has been updated
   * @throws Exception if an exception occurs
   */
  protected boolean syncItem(AgpItem sourceItem) throws Exception {
    this.numItemsConsidered++;
    AgpSource src = this.source;
    AgpDestination dest = this.destination; 
    String sId = sourceItem.getProperties().getValue("id");
    String sType = sourceItem.getProperties().getValue("type");
    String sTitle = sourceItem.getProperties().getValue("title");
    String sMsg = "Processing item ("+this.numItemsConsidered+")";
    sMsg += ", id:"+Val.stripControls(sId)+", type:"+Val.stripControls(sType)+", title:"+Val.stripControls(sTitle);
    LOGGER.info(sMsg);

    // check the id and type
    if (sId == null) {
      this.numWithNullId++;
      LOGGER.finer("Ignoring item with null id: "+Val.stripControls(sTitle));
      return false;
    } else if (sType == null) {
      this.numWithNullType++;
      LOGGER.finer("Ignoring item with null type: "+Val.stripControls(sId)+" "+Val.stripControls(sTitle));
      return false;
    } else if (sType.equalsIgnoreCase("Code Attachment")) {
      // don't publish Code Attachments now, publish within processRelatedItems
      return false;
    /* NOTE! Uncomment if working with Portal of version < 10.4
    } else if (sType.equalsIgnoreCase("Web Mapping Application")) {
      // don't publish Web Mapping Application
      return false;
    */
    }
    
    // TODO sync items within the same portal
    // TODO don't propagate synced items from portal to portal
    
    // don't re-publish items when the exact itemId exists at the destination
    boolean bUnsyncedItemExists = this.itemHelper.doesUnsyncedItemExist(sourceItem,dest);
    if (bUnsyncedItemExists) {
      this.numUnsyncedExistingAtDestination++;
      String s = "Ignoring unsynced item existing at destination: ";
      LOGGER.finer(s+Val.stripControls(sId)+" "+Val.stripControls(sTitle));
      return false;
    }
    
    // don't propagate synced items from portal to portal
    boolean bIsSyncedItem = this.itemHelper.isSyncedItem(sourceItem);
    if (bIsSyncedItem) {
      this.numOriginatedFromSynchronization++;
      String s = "Ignoring, an item that originated from synchronization will not be repropagated: ";
      LOGGER.finer(s+Val.stripControls(sId)+" "+Val.stripControls(sTitle));
      return false;
    }
    
       
    // determine if the item requires an update
    // process web maps at the end of the job
    // TODO: there will be problems if the item is no longer visible to this user
    AgpItem destItem = this.itemHelper.makeDestinationItem(src,sourceItem);
    boolean bRequiresUpdate = this.itemHelper.requiresUpdate(sourceItem,dest,destItem);
    if (this.forceUpdates || bRequiresUpdate) {
      if (sType.equalsIgnoreCase("Web Map")) {
        this.webMaps.add(sId);
      } else {
        this.execPublishItem(sourceItem,destItem);
        this.processMetadata(sourceItem,destItem);
        this.processRelatedItems(sourceItem,destItem);
      }
      return true;
    }
    
    return false;
  }
  
  /**
   * Execute the synchronization process.
   * @throws Exception if an exception occurs
   */
  public void synchronize() throws Exception {
    this.millisStart = System.currentTimeMillis();
    LOGGER.info("AgpPush: Starting synchronization...");
    try {
      this.source.getConnection().generateToken();
      this.destination.getConnection().generateToken();
      AgpSearchRequest sourceRequest = new AgpSearchRequest() {
        @Override
        protected boolean doContinue() {
          return AgpPush.this.doContinue();
        }
      };
      sourceRequest.search(this.source.getConnection(),this.source.getSearchCriteria(), 
        new AgpItemListener() {
          @Override
          public void onItemLoaded(AgpConnection connection, AgpItem item) throws Exception {
            AgpPush.this.syncItem(item);
          }
        }
      );
      this.processWebMaps();
      
    } catch (Exception e) {
      e.printStackTrace(System.err);
      throw e;
      
    } finally {
      try {
        this.source.getConnection().close();
      } catch (Throwable t) {
        t.printStackTrace(System.err);
      }
      try {
        this.destination.getConnection().close();
      } catch (Throwable t) {
        t.printStackTrace(System.err);
      }
      this.millisEnd = System.currentTimeMillis();
      LOGGER.info(this.getSummary(false));
    }
  }
  
  protected boolean doContinue() {
    return true;
  }
  
}