/* Copyright (c) 2013-2017, Jesper Öqvist <jesper.oqvist@cs.lth.se>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 * this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its
 * contributors may be used to endorse or promote products derived from this
 * software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */
package org.extendj.ragdoc;

import org.extendj.ast.Access;
import org.extendj.ast.BodyDecl;
import org.extendj.ast.ClassDecl;
import org.extendj.ast.CompilationUnit;
import org.extendj.ast.ConstructorDecl;
import org.extendj.ast.Declarator;
import org.extendj.ast.FieldDecl;
import org.extendj.ast.InterfaceDecl;
import org.extendj.ast.MethodDecl;
import org.extendj.ast.ParameterDeclaration;
import org.extendj.ast.TypeDecl;
import org.extendj.ast.Variable;
import org.extendj.util.RelativePath;
import se.llbit.json.Json;
import se.llbit.json.JsonArray;
import se.llbit.json.JsonObject;
import se.llbit.json.JsonString;
import se.llbit.json.JsonValue;

import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class JsonBuilder {
  private Map<String, String> typeIndex = new HashMap<>();
  protected Set<String> typenames = new HashSet<>();
  protected Map<TypeDecl, JsonObject> typemap = new HashMap<>();
  private Set<String> aspects = new HashSet<>();
  final Map<String, JsonArray> packages = new HashMap<>();
  final Map<String, Collection<TypeDecl>> packageTypeMap = new HashMap<>();

  /** Ordering of the package object kinds. */
  private static final String[] TYPE_KINDS = { "ast-class", "interface", "class" };

  private static final String[] MEMBER_KINDS = { "constr", "attr", "field", "method" };

  private final File rootDir;
  private java.util.List<String> ragRoot = new java.util.LinkedList<>();
  public Map<String, File> sourceFiles = new HashMap<>();

  public JsonBuilder(File rootDir) {
    this.rootDir = rootDir;
    ragRoot = RelativePath.buildPathList(rootDir);
    typenames.add("packages"); // Reserve packages as JSON filename.
  }

  public void addConstructor(ConstructorDecl constr, JsonArray members) {
    JsonObject doc = constr.jsonDocObject();
    if (shouldDocument(constr, doc)) {
      JsonObject obj = new JsonObject();
      obj.add("name", Json.of(constr.name()));
      JsonArray modifiers = constr.getModifiers().toJson();
      if (!modifiers.isEmpty()) {
        obj.add("mods", modifiers);
      }
      if (doc != null) {
        obj.add("doc", doc);
      }
      addParameters(obj, constr.getParameterList());
      addExceptions(obj, constr.getExceptionList());
      members.add(obj);
    }
  }

  private void addParameters(JsonObject obj, Iterable<ParameterDeclaration> params) {
    Iterator<ParameterDeclaration> iter = params.iterator();
    if (iter.hasNext()) {
      JsonArray array = new JsonArray();
      do {
        ParameterDeclaration param = iter.next();
        JsonObject pobj = new JsonObject();
        pobj.add("t", typeRef(param.type()));
        pobj.add("n", param.name());
        array.add(pobj);
      } while (iter.hasNext());
      obj.add("params", array);
    }
  }

  private void addExceptions(JsonObject obj, Iterable<Access> params) {
    Iterator<Access> iter = params.iterator();
    if (iter.hasNext()) {
      JsonArray array = new JsonArray();
      do {
        array.add(typeRef(iter.next().type()));
      } while (iter.hasNext());
      obj.add("throws", array);
    }
  }

  public void addMethod(MethodDecl method, JsonArray members) {
    JsonObject doc = method.jsonDocObject();
    if (shouldDocument(method, doc)) {
      JsonObject obj = new JsonObject();
      obj.add("name", Json.of(method.name()));
      JsonArray modifiers = method.getModifiers().toJson();
      if (!modifiers.isEmpty()) {
        obj.add("mods", modifiers);
      }
      if (doc != null) {
        obj.add("doc", doc);
      }
      obj.add("type", typeRef(method.type()));
      addParameters(obj, method.getParameterList());
      addExceptions(obj, method.getExceptionList());
      members.add(obj);
    }
  }

  private JsonValue typeRef(TypeDecl type) {
    // TODO: handle wildcard types.
    JsonObject obj = new JsonObject();
    if (shouldDocument(type)) {
      // This is a user type.
      obj.add("u", type.name());
      String id = typeId(type);
      if (!id.equals("%")) {
        obj.add("i", typeId(type));
      }
    } else {
      // This is a built-in or library type.
      obj.add("n", type.name());
    }
    // Add type arguments (if any).
    Collection<TypeDecl> typeArgs = type.typeArgs();
    JsonArray args = new JsonArray();
    for (TypeDecl arg : typeArgs) {
      args.add(typeRef(arg));
    }
    if (!args.isEmpty()) {
      obj.add("a", args);
    }
    return obj;
  }

  public static boolean shouldDocument(FieldDecl field) {
    return shouldDocument(field, field.jsonDocObject());
  }

  public static boolean shouldDocument(BodyDecl member, JsonObject doc) {
    return doc == null || !doc.get("apilevel").stringValue("").equals("internal");
  }

  public static boolean isHighLevelApi(BodyDecl member, JsonObject doc) {
    if (doc == null) {
      return true;
    }
    String apilevel = doc.get("apilevel").stringValue("");
    return !(apilevel.equals("internal") || apilevel.equals("low-level"));
  }

  /**
   * Determines if documentation should be generated for a given type.
   *
   * <p>Documentation should be generated if the type is user-defined
   * and non-private.
   */
  public static boolean shouldDocument(TypeDecl typeDecl) {
    // TODO: only non-private!
    return !typeDecl.isPrivate()
        && !typeDecl.isAnonymous()
        && (!typeDecl.isArrayDecl() || shouldDocument(typeDecl.elementType()))
        && !typeDecl.isTypeVariable() && typeDecl.compilationUnit().fromSource();
  }

  // TODO: inner class names.
  String typeId(TypeDecl type) {
    String typename = type.baseTypeName();
    if (typeIndex.containsKey(typename)) {
      return typeIndex.get(typename);
    } else {
      // This handles name clashes by appending a unique index.
      int i = 1;
      String simpleName = type.name();
      while (true) {
        String id = (i == 1) ? simpleName : simpleName + i;
        if (!typenames.contains(id)) {
          typenames.add(id);
          break;
        }
        i += 1;
      }
      String id = (i == 1) ? "%" : "%" + i;
      typeIndex.put(typename, id);
      return id;
    }
  }

  public void addField(FieldDecl field, JsonArray members) {
    JsonObject doc = field.jsonDocObject();
    if (shouldDocument(field, doc)) {
      for (Declarator declarator : field.getDeclaratorList()) {
        JsonObject obj = new JsonObject();
        obj.add("name", Json.of(declarator.name()));
        obj.add("type", typeRef(declarator.type()));
        JsonArray modifiers = field.getModifiers().toJson();
        if (!modifiers.isEmpty()) {
          obj.add("mods", modifiers);
        }
        doc = field.jsonDocObject();
        if (doc != null) {
          obj.add("doc", doc);
        }
        members.add(obj);
      }
    }
  }

  public JsonObject jsonDocObject(String docComment) {
    if (!docComment.isEmpty()) {
      JavaDocParser parser = new JavaDocParser();
      String javadoc = parser.parse(docComment);
      JsonObject doc = new JsonObject();
      String aspectName = null;
      JsonArray params = new JsonArray();
      for (DocTag tag : parser.getTags()) {
        switch (tag.tag) {
          case "declaredat":
            String declaredat = tag.text;
            int sep = declaredat.lastIndexOf(':');
            String ragFile;
            String lineno;
            if (sep != -1) {
              ragFile = declaredat.substring(0, sep);
              lineno = declaredat.substring(sep + 1);
            } else {
              ragFile = declaredat;
              lineno = "0";
            }
            // Exclude source location for implicitly generated code (ragFile==ASTNode).
            if (!ragFile.equals("ASTNode")) {
              String relativePath = RelativePath.getRelativePath(ragFile, ragRoot);
              if (!sourceFiles.containsKey(relativePath)) {
                File file = new File(rootDir, relativePath);
                if (file.isFile()) {
                  sourceFiles.put(relativePath, file);
                }
              }
              doc.add("ragFile", Json.of(relativePath));
              doc.add("line", Json.of(lineno));
            }
            break;
          case "aspect":
            aspectName = tag.text;
            doc.add("aspect", Json.of(aspectName));
            break;
          case "param":
            params.add(tag.text);
            break;
          default:
            doc.add(tag.tag, Json.of(tag.text));
            break;
        }
      }
      if (!params.isEmpty()) {
        doc.add("params", params);
      }
      doc.add("description", Json.of(javadoc));
      if (aspectName != null) {
        aspects.add(aspectName);
      }
      return doc;
    }
    return null;
  }

  public void addCompilationUnit(CompilationUnit unit) {
    String packageName = unit.packageName();
    JsonArray types = packages.get(packageName);
    Collection<TypeDecl> packageTypes = packageTypeMap.get(packageName);
    if (types == null) {
      types = new JsonArray();
      packages.put(packageName, types);
      packageTypes = new ArrayList<>();
      packageTypeMap.put(packageName, packageTypes);
    }
    // Iterate all local types:
    for (TypeDecl type : unit.localTypes()) {
      if (JsonBuilder.shouldDocument(type)) {
        JsonObject typeJson = typeJson(type);
        typeJson.add("id", Json.of(typeId(type)));
        TypeDecl enclosing = type.enclosingType();
        if (enclosing != null) {
          typeJson.add("enclosing", typeRef(enclosing));
        }
        types.add(typeJson);
        packageTypes.add(type);
        typemap.put(type, typeJson);
      }
    }
  }

  private JsonObject typeJson(TypeDecl type) {
    JsonObject obj = new JsonObject();
    obj.add("kind", Json.of(type.objectKind()));
    obj.add("name", Json.of(type.nameWithTypeArgs()));
    obj.add("pkg", Json.of(type.packageName()));
    if (type instanceof ClassDecl) {
      ClassDecl klass = (ClassDecl) type;
      if (klass.hasSuperclass()) {
        obj.add("superclass", typeRef(klass.superclass()));
      }
      JsonArray ifaces = new JsonArray();
      for (Access access: klass.getImplementsList()) {
        ifaces.add(typeRef(access.type()));
      }
      if (!ifaces.isEmpty()) {
        obj.add("superinterfaces", ifaces);
      }
    } else if (type instanceof InterfaceDecl) {
      InterfaceDecl iface = (InterfaceDecl) type;
      JsonArray ifaces = new JsonArray();
      for (Access access: iface.getSuperInterfaceList()) {
        ifaces.add(typeRef(access.type()));
      }
      if (!ifaces.isEmpty()) {
        obj.add("superinterfaces", ifaces);
      }
    }
    JsonArray modifiers = type.getModifiers().toJson();
    if (!modifiers.isEmpty()) {
      obj.add("mods", modifiers);
    }
    JsonObject doc = type.jsonDocObject();
    if (doc != null) {
      obj.add("doc", doc);
    }
    addInheritedMembers(type, obj);
    Map<String, JsonArray> groupMap = new HashMap<>();
    for (BodyDecl bd: type.getBodyDeclList()) {
      if (!bd.isPrivate() && !bd.isInitializer()) {
        String kind = bd.objectKind();
        JsonArray members = groupMap.get(kind);
        if (members == null) {
          members = new JsonArray();
          groupMap.put(kind, members);
        }
        bd.addMemberJson(members);
      }
    }
    JsonArray groups = groupify(groupMap, MEMBER_KINDS);
    if (!groups.isEmpty()) {
      obj.add("groups", groups);
    }
    return obj;
  }

  /**
   * This adds inherited members from superclasses.
   */
  private void addInheritedMembers(TypeDecl type, JsonObject obj) {
    if (type instanceof ClassDecl) {
      JsonArray inheritedMethods = new JsonArray();
      JsonArray inheritedAttributes = new JsonArray();
      JsonArray inheritedFields = new JsonArray();
      // The locally declared set of methods.
      Set<String> declaredMethods = new HashSet<>();
      Set<String> declaredFields = new HashSet<>();
      declaredMethods.addAll(type.localMethodsSignatureMap().keySet());
      ClassDecl klass = (ClassDecl) type;
      while (klass.hasSuperclass()) {
        ClassDecl superclass = (ClassDecl) klass.superclass();
        JsonArray methodArray = new JsonArray();
        JsonArray attributeArray = new JsonArray();
        // Set to keep track of which method names have already been listed for
        // the current type.
        Set<String> locallyDeclared = new HashSet<>();
        for (Iterable<MethodDecl> methods : superclass.localMethodsSignatureMap().values()) {
          for (MethodDecl method : methods) {
            MethodDecl original = method.original();
            if (!declaredMethods.contains(original.signature())
                && !locallyDeclared.contains(original.name())
                && isHighLevelApi(original, original.jsonDocObject())) {
              declaredMethods.add(original.signature());
              locallyDeclared.add(original.name());
              if (isAttribute(original)) {
                // Add to attributes inherited from the current superclass.
                attributeArray.add(original.name());
              } else {
                // Add to methods inherited from the current superclass.
                methodArray.add(original.name());
              }
            }
          }
        }
        JsonArray fieldArray = new JsonArray();
        for (Iterable<Variable> vars : superclass.localFieldsMap().values()) {
          for (Variable var : vars) {
            if (!declaredFields.contains(var.name())
                && !var.isPrivate()
                && shouldDocument(var.fieldDecl())) {
              declaredFields.add(var.name());
              fieldArray.add(var.name());
            }
          }
        }
        if (!methodArray.isEmpty()) {
          JsonObject inherited = new JsonObject();
          inherited.add("superclass", typeRef(superclass));
          inherited.add("members", methodArray);
          inheritedMethods.add(inherited);
        }
        if (!attributeArray.isEmpty()) {
          JsonObject inherited = new JsonObject();
          inherited.add("superclass", typeRef(superclass));
          inherited.add("members", attributeArray);
          inheritedAttributes.add(inherited);
        }
        if (!fieldArray.isEmpty()) {
          JsonObject inherited = new JsonObject();
          inherited.add("superclass", typeRef(superclass));
          inherited.add("members", fieldArray);
          inheritedFields.add(inherited);
        }
        klass = superclass;
      }
      if (!inheritedMethods.isEmpty()) {
        obj.add("inherited_methods", inheritedMethods);
      }
      if (!inheritedAttributes.isEmpty()) {
        obj.add("inherited_attributes", inheritedAttributes);
      }
      if (!inheritedFields.isEmpty()) {
        obj.add("inherited_fields", inheritedFields);
      }
    }
  }

  /**
   * @return {@code true} if the argument is an attribute,
   * {@code false} if it is an ordinary method.
   */
  private static boolean isAttribute(MethodDecl method) {
    JsonObject doc = method.jsonDocObject();
    return !(doc == null || doc.get("attribute").stringValue("").isEmpty());
  }

  private static JsonArray groupify(Map<String, JsonArray> groupMap, String[] kinds) {
    JsonArray groups = new JsonArray();
    for (String kind : kinds) {
      if (groupMap.containsKey(kind)) {
        JsonObject group = new JsonObject();
        group.add("kind", Json.of(kind));
        JsonArray members = groupMap.get(kind);
        sortArrayBy(members, "name");
        group.add("members", members);
        groups.add(group);
      }
    }
    return groups;
  }

  public JsonArray packageIndex() {
    // TODO: split into separate arrays based on object kind.
    JsonArray packageIndex = new JsonArray();
    for (String packageName : packages.keySet()) {
      // Group members by kind.
      Map<String, JsonArray> groupMap = new HashMap<>();
      for (TypeDecl type : packageTypeMap.get(packageName)) {
        JsonObject memberEntry = new JsonObject();
        String kind = type.objectKind();
        JsonArray members = groupMap.get(kind);
        if (members == null) {
          members = new JsonArray();
          groupMap.put(kind, members);
        }
        memberEntry.add("name", type.nameWithEnclosingType());
        memberEntry.add("id", type.name() + typeId(type).substring(1));
        members.add(memberEntry);
      }

      JsonObject entry = new JsonObject();
      entry.add("name", Json.of(packageName));
      JsonArray groups = groupify(groupMap, TYPE_KINDS);
      entry.add("groups", groups);
      packageIndex.add(entry);
    }
    sortArrayBy(packageIndex, "name");
    return packageIndex;
  }

  /**
   * Sort object elements of JSON array by a particular key.
   */
  static void sortArrayBy(JsonArray array, String key) {
    Map<String, JsonObject> members = new HashMap<>();
    for (JsonValue value : array) {
      JsonObject member = value.object();
      members.put(member.get(key).stringValue(""), member);
    }
    List<String> names = new ArrayList<>(members.keySet());
    Collections.sort(names);
    int index = 0;
    for (String name : names) {
      JsonObject member = members.get(name);
      array.set(index, member);
      index += 1;
    }
  }

}