/*
 * Copyright 2014 Google Inc. All rights reserved.
 *
 * Licensed 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.google.samples.apps.iosched.server.schedule.server;

import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.ShortBlob;
import com.google.appengine.api.mail.MailService.Message;
import com.google.appengine.api.mail.MailServiceFactory;
import com.google.appengine.api.utils.SystemProperty;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.stream.JsonWriter;
import com.google.samples.apps.iosched.server.schedule.Config;
import com.google.samples.apps.iosched.server.schedule.input.fetcher.EntityFetcher;
import com.google.samples.apps.iosched.server.schedule.input.fetcher.RemoteFilesEntityFetcherFactory;
import com.google.samples.apps.iosched.server.schedule.model.DataCheck;
import com.google.samples.apps.iosched.server.schedule.model.DataCheck.CheckFailure;
import com.google.samples.apps.iosched.server.schedule.model.DataCheck.CheckResult;
import com.google.samples.apps.iosched.server.schedule.model.DataExtractor;
import com.google.samples.apps.iosched.server.schedule.model.JsonDataSources;
import com.google.samples.apps.iosched.server.schedule.server.cloudstorage.CloudFileManager;
import com.google.samples.apps.iosched.server.schedule.server.input.ExtraInput;
import com.google.samples.apps.iosched.server.schedule.server.input.VendorStaticInput;
import com.google.samples.apps.iosched.server.schedule.server.input.fetcher.CloudStorageRemoteFilesEntityFetcher;

import java.io.IOException;
import java.io.OutputStream;
import java.io.Writer;
import java.nio.channels.Channels;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;

/**
 * Basic class that starts the API Updater flow.
 */
public class APIUpdater {

  public static final int ADMIN_MESSAGE_SIZE_LIMIT = 10000;

  public void run(boolean force, boolean obfuscate, OutputStream optionalOutput) throws IOException {

    RemoteFilesEntityFetcherFactory.setBuilder(new RemoteFilesEntityFetcherFactory.FetcherBuilder() {
        String[] filenames;

        @Override
        public RemoteFilesEntityFetcherFactory.FetcherBuilder setSourceFiles(String... filenames) {
            this.filenames = filenames;
            return this;
        }

        @Override
        public EntityFetcher build() {
            return new CloudStorageRemoteFilesEntityFetcher(filenames);
        }
    });

    UpdateRunLogger logger = new UpdateRunLogger();
    CloudFileManager fileManager = new CloudFileManager();

    logger.startTimer();
    JsonDataSources sources = new ExtraInput().fetchAllDataSources();
    logger.stopTimer("fetchExtraAPI");

    logger.startTimer();
    sources.putAll(new VendorStaticInput().fetchAllDataSources());
    logger.stopTimer("fetchVendorStaticAPI");

    logger.startTimer();
    JsonObject newData = new DataExtractor(obfuscate).extractFromDataSources(sources);
    logger.stopTimer("extractOurData");

    logger.startTimer();
    byte[] newHash = CloudFileManager.calulateHash(newData);
    logger.stopTimer("calculateHash");

    // compare current Vendor API log with the one from previous run:
    logger.startTimer();
    if (!force && isUpToDate(newHash, logger)) {
      logger.logNoopRun();
      return;
    }
    logger.stopTimer("compareHash");

    logger.startTimer();
    ManifestData dataProduction = extractManifestData(fileManager.readProductionManifest(), null);
    //ManifestData dataStaging = extractManifestData(fileManager.readStagingManifest(), dataProduction);

    logger.stopTimer("readManifest");

    JsonWriter optionalOutputWriter = null;

    logger.startTimer();
    // Upload a new version of the sessions file
    if (optionalOutput != null) {
      // send data to the outputstream
      Writer writer = Channels.newWriter(Channels.newChannel(optionalOutput), "UTF-8");
      optionalOutputWriter = new JsonWriter(writer);
      optionalOutputWriter.setIndent("  ");
      new Gson().toJson(newData, optionalOutputWriter);
      optionalOutputWriter.flush();
    } else {
      // save data to the CloudStorage
      fileManager.createOrUpdate(dataProduction.sessionsFilename, newData, false);
    }
    logger.stopTimer("uploadNewSessionsFile");

    // Check data consistency
    logger.startTimer();
    DataCheck checker = new DataCheck(fileManager);
    CheckResult result = checker.check(sources, newData, dataProduction);
    if (!result.failures.isEmpty()) {
      reportDataCheckFailures(result, optionalOutput);
    }
    logger.stopTimer("runDataCheck");

    if (optionalOutput == null) {
      // Only update manifest and log if saving to persistent storage

      logger.startTimer();

      // Create new manifests
      JsonObject newProductionManifest = new JsonObject();
      newProductionManifest.add("format", new JsonPrimitive(Config.MANIFEST_FORMAT_VERSION));
      newProductionManifest.add("data_files", dataProduction.dataFiles);

      JsonObject newStagingManifest = new JsonObject();
      newStagingManifest.add("format", new JsonPrimitive(Config.MANIFEST_FORMAT_VERSION));
     // newStagingManifest.add("data_files", dataStaging.dataFiles);

      // save manifests to the CloudStorage
      fileManager.createOrUpdateProductionManifest(newProductionManifest);
      fileManager.createOrUpdateStagingManifest(newStagingManifest);

      try {
        // notify production GCM server:
        new GCMPing().notifyGCMServer(Config.GCM_URL, Config.GCM_API_KEY);
      } catch (Throwable t) {
        Logger.getLogger(APIUpdater.class.getName()).log(Level.SEVERE, "Error while pinging GCM server", t);
      }

      logger.stopTimer("uploadManifest");

      logger.logUpdateRun(dataProduction.majorVersion, dataProduction.minorVersion,
          dataProduction.sessionsFilename, newHash, newData, force);
    }

  }

  private void reportDataCheckFailures(CheckResult result, OutputStream optionalOutput) throws IOException {
    StringBuilder errorMessage = new StringBuilder();
    errorMessage.append(
        "\nHey,\n\n"
        + "(this message is autogenerated)\n"
        + "\n** UPDATE: ignore the part of the message below that says the updater is halted. Halting the updating process is not implemented yet. **\n\n"
        + "The IOSched 2014 data updater is halted because of inconsistent data.\n"
        + "Please, check the messages below and fix the sources. "
        /*+ "If you are ok with the data "
        + "even in an inconsistent state, you or other app admin will need to force an update by "
        + "clicking on the \"Force update\" button at https://iosched-updater-dev.appspot.com/admin/\n\n"*/
        + "\n\n" + result.failures.size() + " data non-compliances:\n");
    for (CheckFailure f: result.failures) {
      errorMessage.append(f).append("\n\n");
    }

    // Log error message to syslog, so that it's available even if the log is truncated.
    Logger syslog = Logger.getLogger(APIUpdater.class.getName());
    syslog.log(Level.SEVERE, errorMessage.toString());

    // Send email with error message to project admins.
    if (SystemProperty.environment.value() != SystemProperty.Environment.Value.Development ||
        optionalOutput == null) {
      // send email if user is not running in dev or interactive mode (show=true)
      Message message = new Message();
      message.setSender(Config.EMAIL_FROM);
      message.setSubject("[iosched-data-error] Updater - Inconsistent data");

      String errorMessageStr = errorMessage.toString();
      if (errorMessageStr.length() > ADMIN_MESSAGE_SIZE_LIMIT) {
        int truncatedChars = errorMessage.length() - ADMIN_MESSAGE_SIZE_LIMIT;
        errorMessageStr = errorMessageStr.substring(0, ADMIN_MESSAGE_SIZE_LIMIT);
        errorMessageStr +=
                "\n\n--- MESSAGE TRUNCATED, " + truncatedChars + " CHARS REMAINING (CHECK LOG) ---";
      }
      message.setTextBody(errorMessageStr);
      MailServiceFactory.getMailService().sendToAdmins(message);
    } else {
      // dump errors to optionalOutput
      optionalOutput.write(errorMessage.toString().getBytes());
    }
  }


  private ManifestData extractManifestData(JsonObject currentManifest, ManifestData copyFrom) {
    ManifestData data = new ManifestData();
    data.majorVersion = copyFrom == null ? Config.MANIFEST_VERSION : copyFrom.majorVersion;
    data.minorVersion = copyFrom == null ? 0 : copyFrom.minorVersion;
    data.dataFiles = new JsonArray();

    if (currentManifest != null) {
      try {
        JsonArray files = currentManifest.get("data_files").getAsJsonArray();
        for (JsonElement file: files) {
          String filename = file.getAsString();
          Matcher matcher = Config.SESSIONS_PATTERN.matcher(filename);
          if (matcher.matches()) {
            // versions numbers are only extracted from the existing session filename if copyFrom is null.
            if (copyFrom == null) {
              data.majorVersion = Integer.parseInt(matcher.group(1));
              data.minorVersion = Integer.parseInt(matcher.group(2));
            }
          } else {
            data.dataFiles.add(file);
          }
        }
      } catch (NullPointerException ex) {
        Logger.getLogger(getClass().getName()).warning("Ignoring existing manifest, as it seems "
            + "to be badly formatted.");
      } catch (NumberFormatException ex) {
        Logger.getLogger(getClass().getName()).warning("Ignoring existing manifest, as it seems "
            + "to be badly formatted.");
      }
    }

    if (copyFrom == null) {
      // Increment the minor version
      data.minorVersion++;
      data.sessionsFilename = MessageFormat.format(Config.SESSIONS_FORMAT, data.majorVersion, data.minorVersion);
    } else {
      data.sessionsFilename = copyFrom.sessionsFilename;
    }

    data.dataFiles.add(new JsonPrimitive( data.sessionsFilename ));
    return data;
  }


  private boolean isUpToDate(byte[] newHash, UpdateRunLogger logger) {
    Entity lastUpdate = logger.getLastRun();
    byte[] currentHash = null;
    if (lastUpdate != null) {
      ShortBlob hash = (ShortBlob) lastUpdate.getProperty("hash");
      if (hash != null) {
        currentHash = hash.getBytes();
      }
    }

    return Arrays.equals(currentHash, newHash);
  }

}
