/*
 * Copyright 2018 Red Hat, Inc. and/or its affiliates.
 *
 * Licensed under the Eclipse Public License version 1.0, available at
 * http://www.eclipse.org/legal/epl-v10.html
*/
package org.jboss.forge.roaster.model.impl;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ServiceLoader;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.eclipse.jdt.core.compiler.IProblem;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.AnnotationTypeDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.EnumDeclaration;
import org.eclipse.jdt.core.dom.ImportDeclaration;
import org.eclipse.jdt.core.dom.Javadoc;
import org.eclipse.jdt.core.dom.Modifier.ModifierKeyword;
import org.eclipse.jdt.core.dom.PackageDeclaration;
import org.eclipse.jdt.core.dom.RecordDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jface.text.Document;
import org.eclipse.text.edits.TextEdit;
import org.jboss.forge.roaster.ParserException;
import org.jboss.forge.roaster.model.Annotation;
import org.jboss.forge.roaster.model.JavaType;
import org.jboss.forge.roaster.model.SyntaxError;
import org.jboss.forge.roaster.model.Type;
import org.jboss.forge.roaster.model.Visibility;
import org.jboss.forge.roaster.model.ast.AnnotationAccessor;
import org.jboss.forge.roaster.model.ast.ModifierAccessor;
import org.jboss.forge.roaster.model.source.AnnotationSource;
import org.jboss.forge.roaster.model.source.Import;
import org.jboss.forge.roaster.model.source.JavaDocSource;
import org.jboss.forge.roaster.model.source.JavaSource;
import org.jboss.forge.roaster.model.util.Formatter;
import org.jboss.forge.roaster.model.util.JDTOptions;
import org.jboss.forge.roaster.model.util.Types;
import org.jboss.forge.roaster.spi.WildcardImportResolver;

/**
 * Represents a Java Source File
 *
 * @author <a href="mailto:lincolnbaxter@gmail.com">Lincoln Baxter, III</a>
 */
public abstract class JavaSourceImpl<O extends JavaSource<O>> implements JavaSource<O>
{
   private static List<WildcardImportResolver> resolvers;
   private final ModifierAccessor modifiers = new ModifierAccessor();
   private final AnnotationAccessor<O, O> annotations = new AnnotationAccessor<>();

   protected final Document document;
   protected final CompilationUnit unit;
   protected final JavaSource<?> enclosingType;

   protected JavaSourceImpl(JavaSource<?> enclosingType, final Document document, final CompilationUnit unit)
   {
      this.enclosingType = enclosingType == null ? this : enclosingType;
      this.document = document;
      this.unit = unit;
   }

   // ================================================================================
   // Location
   // ================================================================================

   @Override
   public int getColumnNumber()
   {
      return unit.getColumnNumber(getStartPosition());
   }

   @Override
   public int getLineNumber()
   {
      return unit.getLineNumber(getStartPosition());
   }

   @Override
   public int getEndPosition()
   {
      int startPosition = getStartPosition();
      return (startPosition == -1) ? -1 : startPosition + getDeclaration().getLength();
   }

   @Override
   public int getStartPosition()
   {
      return getDeclaration().getStartPosition();
   }

   // ================================================================================
   // JavaDoc
   // ================================================================================

   @Override
   public boolean hasJavaDoc()
   {
      return getJDTJavaDoc() != null;
   }

   @SuppressWarnings("unchecked")
   @Override
   public JavaDocSource<O> getJavaDoc()
   {
      Javadoc javadoc = getJDTJavaDoc();
      if (javadoc == null)
      {
         javadoc = getDeclaration().getAST().newJavadoc();
         setJDTJavaDoc(javadoc);
      }
      return new JavaDocImpl<>((O) this, javadoc);
   }

   @SuppressWarnings("unchecked")
   @Override
   public O removeJavaDoc()
   {
      setJDTJavaDoc(null);
      return (O) this;
   }

   protected abstract Javadoc getJDTJavaDoc();

   protected abstract void setJDTJavaDoc(Javadoc javaDoc);

   // ================================================================================
   // Annotation
   // ================================================================================

   @Override
   public AnnotationSource<O> addAnnotation()
   {
      return annotations.addAnnotation(this, getDeclaration());
   }

   @Override
   public List<AnnotationSource<O>> getAnnotations()
   {
      return annotations.getAnnotations(this, getDeclaration());
   }

   @Override
   public AnnotationSource<O> addAnnotation(final Class<? extends java.lang.annotation.Annotation> clazz)
   {
      return annotations.addAnnotation(this, getDeclaration(), clazz.getName());
   }

   @Override
   public AnnotationSource<O> addAnnotation(final String className)
   {
      return annotations.addAnnotation(this, getDeclaration(), className);
   }

   @Override
   public boolean hasAnnotation(final Class<? extends java.lang.annotation.Annotation> type)
   {
      return annotations.hasAnnotation(this, getDeclaration(), type.getName());
   }

   @Override
   public boolean hasAnnotation(final String type)
   {
      return annotations.hasAnnotation(this, getDeclaration(), type);
   }

   @SuppressWarnings("unchecked")
   @Override
   public O removeAnnotation(final Annotation<O> annotation)
   {
      return (O) annotations.removeAnnotation(this, getDeclaration(), annotation);
   }

   @Override
   public void removeAllAnnotations()
   {
      annotations.removeAllAnnotations(getDeclaration());
   }

   @Override
   public AnnotationSource<O> getAnnotation(final Class<? extends java.lang.annotation.Annotation> type)
   {
      return annotations.getAnnotation(this, getDeclaration(), type);
   }

   @Override
   public AnnotationSource<O> getAnnotation(final String type)
   {
      return annotations.getAnnotation(this, getDeclaration(), type);
   }

   // ================================================================================
   // Import
   // ================================================================================

   @Override
   public Import addImport(final Class<?> type)
   {
      return addImport(type.getCanonicalName());
   }

   @Override
   public <T extends JavaType<?>> Import addImport(final T type)
   {
      String qualifiedName = type.getQualifiedName();
      return this.addImport(qualifiedName);
   }

   @Override
   public Import addImport(final Import imprt)
   {
      return addImport(imprt.getQualifiedName()).setStatic(imprt.isStatic());
   }

   @SuppressWarnings("unchecked")
   @Override
   public Import addImport(final String className)
   {
      String strippedClassName = Types.stripArray(className);

      if (Types.isGeneric(className))
      {
         for (String genericPart : Types.splitGenerics(className))
         {
            // a type variable is not qualified, so it won't be imported by accident
            if (Types.isQualified(genericPart))
               addImport(genericPart);
         }
      }

      // test this after generics are imported
      strippedClassName = Types.stripGenerics(strippedClassName);
      if (!Types.isQualified(strippedClassName) || Types.isJavaLang(strippedClassName))
      {
         return null;
      }

      if (!hasImport(strippedClassName) && validImport(strippedClassName))
      {
         Import imprt = new ImportImpl(this).setName(strippedClassName);
         unit.imports().add(imprt.getInternal());
         return imprt;
      }

      if (hasImport(strippedClassName))
      {
         return getImport(strippedClassName);
      }

      return null;
   }

   @Override
   public Import getImport(final String className)
   {
      String strippedClassName = Types.stripArray(Types.stripGenerics(className));

      for (Import imprt : getImports())
      {
         String qualifiedName = imprt.getQualifiedName();
         if (imprt.isWildcard())
         {
            qualifiedName = qualifiedName.substring(0, qualifiedName.length() - 2); // remove .*
         }

         if (qualifiedName.equals(strippedClassName))
         {
            return imprt;
         }
      }
      return null;
   }

   @Override
   public Import addImport(final Type<?> type)
   {
      Import imprt;
      if (requiresImport(type.getQualifiedName()))
      {
         imprt = addImport(type.getQualifiedName());
      }
      else
      {
         imprt = getImport(type.getQualifiedName());
      }
      for (Type<?> arg : type.getTypeArguments())
      {
         if (!arg.isWildcard() && arg.isQualified())
         {
            addImport(arg);
         }
      }
      return imprt;
   }

   @Override
   public Import getImport(final Class<?> type)
   {
      return getImport(type.getName());
   }

   @Override
   public <T extends JavaType<?>> Import getImport(final T type)
   {
      return getImport(type.getQualifiedName());
   }

   @Override
   public Import getImport(final Import imprt)
   {
      return getImport(imprt.getQualifiedName());
   }

   @SuppressWarnings("unchecked")
   @Override
   public List<Import> getImports()
   {
      List<Import> results = new ArrayList<>();

      for (ImportDeclaration i : (List<ImportDeclaration>) unit.imports())
      {
         results.add(new ImportImpl(this, i));
      }

      return Collections.unmodifiableList(results);
   }

   @Override
   public boolean hasImport(final Class<?> type)
   {
      return hasImport(type.getName());
   }

   @Override
   public <T extends JavaType<T>> boolean hasImport(final T type)
   {
      return hasImport(type.getQualifiedName());
   }

   @Override
   public boolean hasImport(final Import imprt)
   {
      return hasImport(imprt.getQualifiedName());
   }

   @Override
   public boolean hasImport(final String type)
   {
      String resultType = type;
      if (Types.isArray(type))
      {
         resultType = Types.stripArray(type);
      }
      if (Types.isGeneric(type))
      {
         resultType = Types.stripGenerics(type);
      }
      return getImport(resultType) != null;
   }

   @Override
   public boolean requiresImport(final Class<?> type)
   {
      return requiresImport(type.getName());
   }

   @Override
   public boolean requiresImport(final String type)
   {
      boolean requiresImport = false;
      String resultType = type;
      if (Types.isArray(resultType))
      {
         resultType = Types.stripArray(resultType);
      }
      if (Types.isGeneric(resultType))
      {
         for (String genericPart : Types.splitGenerics(resultType))
         {
            requiresImport |= requiresImport(genericPart);
         }
         resultType = Types.stripGenerics(resultType);
      }
      requiresImport |= !(!validImport(resultType)
               || hasImport(resultType)
               || Types.isJavaLang(resultType)
               || Objects.equals(getPackage(), Types.getPackage(resultType)));
      return requiresImport;
   }

   @Override
   public String resolveType(final String type)
   {
      String result = type;

      // Strip away any characters that might hinder the type matching process
      if (Types.isArray(result))
      {
         result = Types.stripArray(result);
      }

      if (Types.isGeneric(result))
      {
         result = Types.stripGenerics(result);
      }

      // primitive types don't need a import -> direct return
      if (Types.isPrimitive(result))
      {
         return result;
      }

      // qualified names don't need to be resolved
      if (Types.isQualified(result))
      {
         return result;
      }

      // java lang types are implicitly imported and we don't allow a duplicate simple name in another package
      if (Types.isJavaLang(result))
      {
         return "java.lang." + result;
      }

      List<Import> imports = getImports(); // fetch imports only once

      // no need to check for a import with getImport becuase than a fqn name is needed
      // search for an existing import with the same simple name
      for (Import imprt : imports)
      {
         if (imprt.getSimpleName().equals(result))
         {
            return imprt.getQualifiedName();
         }
      }

      // if we have no imports and no fqn name the following doesn't need to be executed
      if (!imports.isEmpty())
      {
         // if we have only one wildcard import we can use this
         List<Import> wildcardImports = imports.stream().filter(Import::isWildcard).collect(Collectors.toList());
         if (wildcardImports.size() == 1)
         {
            return wildcardImports.get(0).getPackage() + "." + result;
         }

         // If we didn't match any imports directly, we might have a wild-card/on-demand import.
         for (Import imprt : imports)
         {
            if (imprt.isWildcard())
            {
               // TODO warn if no wild-card resolvers are configured
               // TODO Test wild-card/on-demand import resolving
               for (WildcardImportResolver r : getImportResolvers())
               {
                  result = r.resolve(this, result);
                  if (Types.isQualified(result))
                     return result;
               }
            }
         }
      }

      // Nothing matched since here, so try to resolve it with the current package
      if (getPackage() != null)
      {
         return getPackage() + "." + result;
      }

      return result;
   }

   private List<WildcardImportResolver> getImportResolvers()
   {
      if (resolvers == null)
      {
         resolvers = new ArrayList<>();
         for (WildcardImportResolver r : ServiceLoader.load(WildcardImportResolver.class, getClass().getClassLoader()))
         {
            resolvers.add(r);
         }
      }
      if (resolvers.isEmpty())
      {
         throw new IllegalStateException("No instances of [" + WildcardImportResolver.class.getName()
                  + "] were found on the classpath.");
      }
      return resolvers;
   }

   private boolean validImport(final String type)
   {
      String className = Types.toSimpleName(type);
      // check if this class name is equal to the class to import
      if (className.equals(getName()))
      {
         return false;
      }
      // check if any import has the same class name
      for (final Import imprt : getImports())
      {
         String importClassName = imprt.getSimpleName();
         if (!imprt.isWildcard() && importClassName.equals(className))
         {
            return false;
         }
      }
      return !StringUtils.isEmpty(type) && !Types.isPrimitive(type) && !StringUtils.isEmpty(Types.getPackage(type));
   }

   @SuppressWarnings("unchecked")
   @Override
   public O removeImport(final String name)
   {
      for (Import i : getImports())
      {
         if (i.getQualifiedName().equals(name))
         {
            removeImport(i);
            break;
         }
      }
      return (O) this;
   }

   @Override
   public O removeImport(final Class<?> clazz)
   {
      return removeImport(clazz.getName());
   }

   @Override
   public <T extends JavaType<?>> O removeImport(final T type)
   {
      return removeImport(type.getQualifiedName());
   }

   @SuppressWarnings("unchecked")
   @Override
   public O removeImport(final Import imprt)
   {
      Object internal = imprt.getInternal();
      if (unit.imports().contains(internal))
      {
         unit.imports().remove(internal);
      }
      return (O) this;
   }

   // ================================================================================
   // Naming
   // ================================================================================

   @Override
   public String getCanonicalName()
   {
      String result = getName();

      JavaType<?> enclosingTypeLocal = this;
      while (enclosingTypeLocal != enclosingTypeLocal.getEnclosingType())
      {
         enclosingTypeLocal = enclosingTypeLocal.getEnclosingType();
         result = enclosingTypeLocal.getName() + "." + result;
      }

      if (!StringUtils.isEmpty(getPackage()))
         result = getPackage() + "." + result;

      return result;
   }

   /**
    * Call-back to allow updating of any necessary internal names with the given name.
    */
   protected O updateTypeNames(String name) {
      return (O) this;
   }

   @Override
   public String getQualifiedName()
   {
      String result = getName();

      JavaType<?> enclosingTypeLocal = this;
      while (enclosingTypeLocal != enclosingTypeLocal.getEnclosingType())
      {
         enclosingTypeLocal = enclosingTypeLocal.getEnclosingType();
         result = enclosingTypeLocal.getName() + "$" + result;
      }

      if (!StringUtils.isEmpty(getPackage()))
         result = getPackage() + "." + result;

      return result;
   }

   // ================================================================================
   // Package
   // ================================================================================

   @Override
   public String getPackage()
   {
      PackageDeclaration pkg = unit.getPackage();
      if (pkg != null)
      {
         return pkg.getName().getFullyQualifiedName();
      }
      return null;
   }

   @SuppressWarnings("unchecked")
   @Override
   public O setPackage(final String name)
   {
      if (unit.getPackage() == null)
      {
         unit.setPackage(unit.getAST().newPackageDeclaration());
      }
      unit.getPackage().setName(unit.getAST().newName(name));
      return (O) this;
   }

   @SuppressWarnings("unchecked")
   @Override
   public O setDefaultPackage()
   {
      unit.setPackage(null);
      return (O) this;
   }

   @Override
   public boolean isDefaultPackage()
   {
      return unit.getPackage() == null;
   }

   // ================================================================================
   // String methods
   // ================================================================================
   /**
    * Return this {@link JavaType} file as a String
    */
   @Override
   public String toString()
   {
      return Formatter.format(toUnformattedString());
   }

   @Override
   public String toUnformattedString()
   {
      Document documentLocal = new Document(this.document.get());

      try
      {
         Map<String, String> options = JDTOptions.getJDTOptions();
         TextEdit edit = unit.rewrite(documentLocal, options);
         edit.apply(documentLocal);
      }
      catch (Exception e)
      {
         throw new ParserException("Could not modify source: " + unit.toString(), e);
      }

      return documentLocal.get();
   }

   // ================================================================================
   // Visibility
   // ================================================================================

   @SuppressWarnings("unchecked")
   @Override
   public O setPackagePrivate()
   {
      modifiers.clearVisibility(getDeclaration());
      return (O) this;
   }

   @Override
   public boolean isPublic()
   {
      return modifiers.hasModifier(getDeclaration(), ModifierKeyword.PUBLIC_KEYWORD);
   }

   @SuppressWarnings("unchecked")
   @Override
   public O setPublic()
   {
      modifiers.clearVisibility(getDeclaration());
      modifiers.addModifier(getDeclaration(), ModifierKeyword.PUBLIC_KEYWORD);
      return (O) this;
   }

   @Override
   public boolean isPrivate()
   {
      return modifiers.hasModifier(getDeclaration(), ModifierKeyword.PRIVATE_KEYWORD);
   }

   @SuppressWarnings("unchecked")
   @Override
   public O setPrivate()
   {
      modifiers.clearVisibility(getDeclaration());
      modifiers.addModifier(getDeclaration(), ModifierKeyword.PRIVATE_KEYWORD);
      return (O) this;
   }

   @Override
   public boolean isProtected()
   {
      return modifiers.hasModifier(getDeclaration(), ModifierKeyword.PROTECTED_KEYWORD);
   }

   @SuppressWarnings("unchecked")
   @Override
   public O setProtected()
   {
      modifiers.clearVisibility(getDeclaration());
      modifiers.addModifier(getDeclaration(), ModifierKeyword.PROTECTED_KEYWORD);
      return (O) this;
   }

   @Override
   public boolean isPackagePrivate()
   {
      return !isPublic() && !isPrivate() && !isProtected();
   }

   @Override
   public Visibility getVisibility()
   {
      return Visibility.getFrom(this);
   }

   @SuppressWarnings("unchecked")
   @Override
   public O setVisibility(final Visibility scope)
   {
      return (O) Visibility.set(this, scope);
   }

   // ================================================================================
   // Types
   // ================================================================================

   @Override
   public boolean isClass()
   {
      ASTNode declaration = getDeclaration();
      return (declaration instanceof TypeDeclaration)
               && !((TypeDeclaration) declaration).isInterface();

   }

   @Override
   public boolean isEnum()
   {
      ASTNode declaration = getDeclaration();
      return declaration instanceof EnumDeclaration;
   }

   @Override
   public boolean isInterface()
   {
      ASTNode declaration = getDeclaration();
      return (declaration instanceof TypeDeclaration)
               && ((TypeDeclaration) declaration).isInterface();
   }

   @Override
   public boolean isAnnotation()
   {
      return getDeclaration() instanceof AnnotationTypeDeclaration;
   }

   @Override
   public boolean isRecord()
   {
      return getDeclaration() instanceof RecordDeclaration;
   }

   // ================================================================================
   // other
   // ================================================================================

   @Override
   public JavaSource<?> getEnclosingType()
   {
      return enclosingType;
   }

   @Override
   public int hashCode()
   {
      if (enclosingType == this)
      {
         return Objects.hash(document, unit);
      }
      return Objects.hash(document, enclosingType, unit);
   }

   @Override
   public boolean equals(Object obj)
   {
      if (this == obj)
         return true;
      if (obj == null)
         return false;
      if (getClass() != obj.getClass())
         return false;
      AbstractJavaSource<?> other = (AbstractJavaSource<?>) obj;
      if (document == null)
      {
         if (other.document != null)
            return false;
      }
      else if (!document.equals(other.document))
         return false;
      if (enclosingType == null)
      {
         if (other.enclosingType != null)
            return false;
      }
      else if (!enclosingType.equals(other.enclosingType))
         return false;
      if (unit == null)
      {
         if (other.unit != null)
            return false;
      }
      else if (!unit.equals(other.unit))
         return false;
      return true;
   }

   @Override
   public Object getInternal()
   {
      return unit;
   }

   @SuppressWarnings("unchecked")
   @Override
   public O getOrigin()
   {
      return (O) this;
   }

   @Override
   public List<SyntaxError> getSyntaxErrors()
   {
      List<SyntaxError> result = new ArrayList<>();

      IProblem[] problems = unit.getProblems();
      if (problems != null)
      {
         for (IProblem problem : problems)
         {
            result.add(new SyntaxErrorImpl(this, problem));
         }
      }
      return result;
   }

   @Override
   public boolean hasSyntaxErrors()
   {
      return !getSyntaxErrors().isEmpty();
   }

   protected abstract ASTNode getDeclaration();
}