/*
 * 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.model;

import static com.google.samples.apps.iosched.server.schedule.model.DataModelHelper.get;
import static com.google.samples.apps.iosched.server.schedule.model.DataModelHelper.getAsArray;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.samples.apps.iosched.server.schedule.Config;
import com.google.samples.apps.iosched.server.schedule.server.ManifestData;
import com.google.samples.apps.iosched.server.schedule.server.cloudstorage.CloudFileManager;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;


/**
 * Safeguard checks about the reliability and consistency of the generated and saved data.
 *
 */
public class DataCheck {

  private CloudFileManager fileManager;

  private SimpleDateFormat sessionDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
  private SimpleDateFormat blockDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");

  public DataCheck(CloudFileManager fileManager) {
    this.fileManager = fileManager;
  }

  /**
   * @param sources
   */
  public CheckResult check(JsonDataSources sources, JsonObject newSessionData, ManifestData manifest) throws IOException {
    newSessionData = clone(newSessionData);
    JsonObject newData = new JsonObject();
    merge(newSessionData, newData);
    JsonObject oldData = new JsonObject();
    for (JsonElement dataFile: manifest.dataFiles) {
      String filename = dataFile.getAsString();
      // except for session data, merge all other files:
      Matcher matcher = Config.SESSIONS_PATTERN.matcher(filename);
      if (!matcher.matches()) {
        JsonObject data = fileManager.readFileAsJsonObject(filename);
        merge(data, oldData);
        merge(data, newData);
      }
    }

    CheckResult result = new CheckResult();

    // check if array of entities is more than 80% the size of the old data:
    checkUsingPredicator(result, oldData, newData, new ArraySizeValidator());

    // Check that no existing tag was removed or had its name changed in a significant way
    checkUsingPredicator(result, oldData, newData,
        OutputJsonKeys.MainTypes.tags, OutputJsonKeys.Tags.tag,
        new EntityValidator() {
          @Override
          public void evaluate(CheckResult result, String entity, JsonObject oldData,
              JsonObject newData) {
            if (newData == null) {
              String tagName = get(oldData, OutputJsonKeys.Tags.tag).getAsString();
              String originalId = get(oldData, OutputJsonKeys.Tags.original_id).getAsString();
              result.failures.add(
                  new CheckFailure(entity, tagName,
                      "Tag could not be found or changed name. Original category ID = " + originalId)
                  );
            }
          }
        });

    // Check that no room was removed
    checkUsingPredicator(result, oldData, newData,
        OutputJsonKeys.MainTypes.rooms, OutputJsonKeys.Rooms.id,
        new EntityValidator() {
          @Override
          public void evaluate(CheckResult result, String entity, JsonObject oldData,
              JsonObject newData) {
            if (newData == null) {
              String id = get(oldData, OutputJsonKeys.Rooms.id).getAsString();
              result.failures.add(
                  new CheckFailure(entity, id,
                      "Room could not be found. Original room: " + oldData)
                  );
            }
          }
        });

    // Check if blocks start and end timestamps are valid
    JsonArray newBlocks = getAsArray(newData, OutputJsonKeys.MainTypes.blocks);
    if (newBlocks == null ) {
      StringBuilder sb= new StringBuilder();
      for (Map.Entry<String, JsonElement> entry: newData.entrySet()) {
        sb.append(entry.getKey()).append(", ");
      }
      throw new IllegalArgumentException("Could not find the blocks entities. Entities in newData are: "+sb);
    }
    for (JsonElement el: newBlocks) {
      JsonObject block = el.getAsJsonObject();
      try {
        Date start = blockDateFormat.parse(get(block, OutputJsonKeys.Blocks.start).getAsString());
        Date end = blockDateFormat.parse(get(block, OutputJsonKeys.Blocks.end).getAsString());
        if ( start.getTime() >= end.getTime() ||  // check for invalid start/end combinations
            start.getTime() < Config.CONFERENCE_DAYS[0][0] || // check for block starting before the conference
            end.getTime() > Config.CONFERENCE_DAYS[1][1]) {  // check for block ending after the conference
          result.failures.add(
              new CheckFailure(OutputJsonKeys.MainTypes.blocks.name(), null,
                  "Invalid block start or end date. Block=" + block));
        }
      } catch (ParseException ex) {
        result.failures.add(
            new CheckFailure(OutputJsonKeys.MainTypes.blocks.name(), null,
                "Could not parse block start or end date. Exception="+ex.getMessage()
                +". Block=" + block));
      }
    }


    // Check if sessions start and end timestamps are valid
    JsonArray newSessions = getAsArray(newData, OutputJsonKeys.MainTypes.sessions);
    for (JsonElement el: newSessions) {
      JsonObject session = el.getAsJsonObject();
      try {
        Date start = sessionDateFormat.parse(get(session, OutputJsonKeys.Sessions.startTimestamp).getAsString());
        Date end = sessionDateFormat.parse(get(session, OutputJsonKeys.Sessions.endTimestamp).getAsString());
        if ( start.getTime() >= end.getTime() ) {  // check for invalid start/end combinations
          result.failures.add(
              new CheckFailure(OutputJsonKeys.MainTypes.sessions.name(), get(session, OutputJsonKeys.Sessions.id).getAsString(),
                  "Session ends before or at the same time as it starts. Session=" + session));
        } else if ( end.getTime() - start.getTime() > 6 * 60 * 60 * 1000L ) { // check for session longer than 6 hours
          result.failures.add(
              new CheckFailure(OutputJsonKeys.MainTypes.sessions.name(), get(session, OutputJsonKeys.Sessions.id).getAsString(),
                  "Session is longer than 6 hours. Session=" + session));
        } else if ( start.getTime() < Config.CONFERENCE_DAYS[0][0] || // check for session starting before the conference
            end.getTime() > Config.CONFERENCE_DAYS[1][1]) {  // check for session ending after the conference
          result.failures.add(
              new CheckFailure(OutputJsonKeys.MainTypes.sessions.name(), get(session, OutputJsonKeys.Sessions.id).getAsString(),
                  "Session starts before or ends after the days of the conference. Session=" + session));
        } else {
          // Check if all sessions are covered by at least one free block (except the keynote):
          boolean valid = false;
          if (!get(session, OutputJsonKeys.Sessions.id).getAsString().equals("__keynote__")) {
            for (JsonElement bl: newBlocks) {
              JsonObject block = bl.getAsJsonObject();
              Date blockStart= blockDateFormat.parse(get(block, OutputJsonKeys.Blocks.start).getAsString());
              Date blockEnd = blockDateFormat.parse(get(block, OutputJsonKeys.Blocks.end).getAsString());
              String blockType = get(block, OutputJsonKeys.Blocks.type).getAsString();
              if ("free".equals(blockType) &&
                  start.compareTo(blockStart) >= 0 &&
                  start.compareTo(blockEnd) < 0) {
                valid = true;
                break;
              }
            }
            if (!valid) {
              result.failures.add(
                  new CheckFailure(OutputJsonKeys.MainTypes.sessions.name(), get(session, OutputJsonKeys.Sessions.id).getAsString(),
                      "There is no FREE block where this session start date lies on. Session=" + session));
            }
          }
        }
      } catch (ParseException ex) {
        result.failures.add(
            new CheckFailure(OutputJsonKeys.MainTypes.sessions.name(), get(session, OutputJsonKeys.Sessions.id).getAsString(),
                "Could not parse session start or end date. Exception="+ex.getMessage()
                +". Session=" + session));
      }
    }


    // Check if video sessions (video library) have valid video URLs
    JsonArray newVideoLibrary = getAsArray(newData, OutputJsonKeys.MainTypes.video_library);
    for (JsonElement el: newVideoLibrary) {
      JsonObject session = el.getAsJsonObject();
      JsonPrimitive videoUrl = (JsonPrimitive) get(session, OutputJsonKeys.VideoLibrary.vid);
      if (videoUrl == null || !videoUrl.isString() || videoUrl.getAsString() == null ||
          videoUrl.getAsString().isEmpty()) {
        result.failures.add(
          new CheckFailure(InputJsonKeys.VendorAPISource.MainTypes.topics.name(),
              ""+get(session, OutputJsonKeys.VideoLibrary.id),
              "Video Session has empty vid info. Session: " + session));
      }
    }

    return result;
  }

  public void checkUsingPredicator(CheckResult result, JsonObject oldData, JsonObject newData,
      ArrayValidator predicate) {
    for (Map.Entry<String, JsonElement> entry: oldData.entrySet()) {
      String oldKey = entry.getKey();
      JsonArray oldValues = entry.getValue().getAsJsonArray();
      predicate.evaluate(result, oldKey, oldValues, newData.getAsJsonArray(oldKey));
    }
  }


  public void checkUsingPredicator(CheckResult result, JsonObject oldData, JsonObject newData, Enum<?> entityType, Enum<?> entityKey, EntityValidator predicate) {
    HashMap<String, JsonObject> oldMap = new HashMap<String, JsonObject>();
    HashMap<String, JsonObject> newMap = new HashMap<String, JsonObject>();
    JsonArray oldArray = getAsArray(oldData, entityType);
    JsonArray newArray = getAsArray(newData, entityType);
    if (oldArray!=null) for (JsonElement el: oldArray) {
      JsonObject obj = (JsonObject) el;
      oldMap.put(get(obj, entityKey).getAsString(), obj);
    }
    if (newArray!=null) for (JsonElement el: newArray) {
      JsonObject obj = (JsonObject) el;
      newMap.put(get(obj, entityKey).getAsString(), obj);
    }
    for (String id: oldMap.keySet()) {
      predicate.evaluate(result, entityType.name(), oldMap.get(id), newMap.get(id));
    }
  }


  private JsonObject clone(JsonObject source) {
    JsonObject dest = new JsonObject();
    for (Map.Entry<String, JsonElement> entry: source.entrySet()) {
      JsonArray values = entry.getValue().getAsJsonArray();
      JsonArray cloned = new JsonArray();
      cloned.addAll(values);
      dest.add(entry.getKey(), cloned);
    }
    return dest;
  }

  private void merge(JsonObject source, JsonObject dest) {
    for (Map.Entry<String, JsonElement> entry: source.entrySet()) {
      JsonArray values = entry.getValue().getAsJsonArray();
      if (dest.has(entry.getKey())) {
        dest.get(entry.getKey()).getAsJsonArray().addAll(values);
      } else {
        dest.add(entry.getKey(), values);
      }
    }
  }

  public static final class ArraySizeValidator implements ArrayValidator {
    @Override
    public void evaluate(CheckResult result, String entity, JsonArray oldData, JsonArray newData) {
      // check if collection exists in the new data:
      if (newData == null) {
        result.failures.add(new CheckFailure(entity, null, "Could not find entity in new data"));
        return;
      }
      // check if collection is 80% smaller than the old data:
      if (newData.size() < oldData.size()*0.8) {
        result.failures.add(new CheckFailure(entity, null, "80% or less entities of this type compared to previous"));
      }
    }
  }

  public static interface ArrayValidator {
    void evaluate(CheckResult result, String entity, JsonArray oldData, JsonArray newData);
  }

  public static interface EntityValidator {
    void evaluate(CheckResult result, String entity, JsonObject oldData, JsonObject newData);
  }


  public static class CheckFailure {
    String entity;
    String entityId;
    String failureReason;
    public CheckFailure(String entity, String entityId, String failureReason) {
      this.entity = entity;
      this.entityId = entityId;
      this.failureReason = failureReason;
    }

    @Override
    public String toString() {
      return
          entity==null?"":(entity+" ") +
          entityId==null?"":(entityId+" ") +
          failureReason==null?"":failureReason;
    }
  }

  public static class CheckResult {
    public ArrayList<CheckFailure> failures = new ArrayList<CheckFailure>();
  }

}
