/*
 * Copyright 2000-2013 JetBrains s.r.o.
 * Copyright 2014-2014 AS3Boyan
 * Copyright 2014-2014 Elias Ku
 * Copyright 2018 Ilya Malanin
 * Copyright 2018 Eric Bishton
 *
 * 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.intellij.plugins.haxe.ide.folding;

import com.intellij.codeInsight.folding.CodeFoldingSettings;
import com.intellij.lang.ASTNode;
import com.intellij.lang.folding.FoldingBuilder;
import com.intellij.lang.folding.FoldingDescriptor;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.TextRange;
import com.intellij.plugins.haxe.ide.HaxeCommenter;
import com.intellij.plugins.haxe.lang.psi.HaxeImportStatement;
import com.intellij.plugins.haxe.lang.psi.HaxeUsingStatement;
import com.intellij.plugins.haxe.util.HaxeStringUtil;
import com.intellij.plugins.haxe.util.UsefulPsiTreeUtil;
import com.intellij.psi.PsiElement;
import com.intellij.psi.tree.IElementType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.*;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static com.intellij.plugins.haxe.lang.lexer.HaxeTokenTypeSets.*;
import static com.intellij.plugins.haxe.lang.lexer.HaxeTokenTypes.*;
import static com.intellij.psi.TokenType.WHITE_SPACE;

public class HaxeFoldingBuilder implements FoldingBuilder {

  private static final Key<RegionDefinition> REGION_DEFINITION_KEY = new Key<>("HaxeRegionDefinition");

  private static final String PLACEHOLDER_TEXT = "...";

  private static final String WS = "[ \\t]"; // Better unicode space detection: "[\\s\\p{Z}]";
  private static final RegionDefinition FD_STYLE = new RegionDefinition("^\\{" + WS + "*region" + WS + "+(.*)$",
                                                                        "^}" + WS + "?end" + WS + "*region(" + WS + "*(.*))?$",
                                                                        (matcher) -> matcher.group(1));
  private static final RegionDefinition PLUGIN_STYLE = new RegionDefinition("^region" + WS + "*(.*)?$",
                                                                            "^end" + WS + "*region" + WS + "*(.*)?$",
                                                                            (matcher) -> matcher.group(1));
  private static final RegionDefinition C_SHARP_STYLE = new RegionDefinition("^#" + WS + "*region" + WS + "+(.*)$",
                                                                             "^#" + WS + "?end" + WS + "*region(" + WS + "*(.*))?$",
                                                                             (matcher) -> matcher.group(1));
  private static final RegionDefinition COMMENT_REGIONS[] = {FD_STYLE, PLUGIN_STYLE, C_SHARP_STYLE};

  private static final RegionDefinition CC_REGION = new RegionDefinition("#", "#", null);


  private static class RegionDefinition {
    final Pattern begin;
    final Pattern end;
    final Function<Matcher, String> substitutor;


    public RegionDefinition(String begin, String end, Function<Matcher, String> titleExtractor) {
      this.begin = Pattern.compile(begin);
      this.end = Pattern.compile(end);
      this.substitutor = titleExtractor;
    }

    public RegionDefinition(String begin, Function<Matcher, String> titleExtractor) {
      this.begin = Pattern.compile(begin);
      this.end = this.begin;
      this.substitutor = titleExtractor;
    }

    public Matcher startMatcher(String s) {
      return begin.matcher(s);
    }

    public Matcher endMatcher(String s) {
      return end.matcher(s);
    }

    public String getPlaceholder(Matcher m) {
      if (null != substitutor) {
        return substitutor.apply(m);
      }
      return PLACEHOLDER_TEXT;
    }
  }

  private static class RegionMarker {
    public enum Type {
      START,
      END,
      CC,
      EMPTY
    }

    public static final RegionMarker EMPTY = new RegionMarker(null, null, null, Type.EMPTY);

    public final RegionDefinition region;
    public final ASTNode node;
    public final Type type;
    public final Matcher matcher;

    public RegionMarker(RegionDefinition region, ASTNode node, Matcher matcher, Type type) {
      this.region = region;
      this.node = node;
      this.type = type;
      this.matcher = matcher;
    }
  }


  @NotNull
  @Override
  public FoldingDescriptor[] buildFoldRegions(@NotNull ASTNode node, @NotNull Document document) {
    List<FoldingDescriptor> descriptorList = new ArrayList<>();
    List<RegionMarker> regionMarkers = new ArrayList<>();
    List<RegionMarker> ccMarkers = new ArrayList<>();

    buildFolding(node, descriptorList, regionMarkers, ccMarkers);

    buildMarkerRegions(descriptorList, regionMarkers);

    buildCCMarkerRegions(descriptorList, ccMarkers, document);

    FoldingDescriptor[] descriptors = new FoldingDescriptor[descriptorList.size()];
    return descriptorList.toArray(descriptors);
  }

  private static void buildFolding(@NotNull ASTNode node, List<FoldingDescriptor> descriptors,
                                   List<RegionMarker> regionMarkers,
                                   List<RegionMarker> ccMarkers) {
    final IElementType elementType = node.getElementType();

    FoldingDescriptor descriptor = null;
    if (isImportOrUsingStatement(elementType) && isFirstImportStatement(node)) {
      descriptor = buildImportsFolding(node);
    } else if (isCodeBlock(elementType)) {
      descriptor = buildCodeBlockFolding(node);
    } else if (isBodyBlock(elementType)) {
      descriptor = buildBodyBlockFolding(node);
    } else if (isComment(elementType, node)) {
      RegionMarker matched = matchRegion(node);
      if (null != matched) {
        regionMarkers.add(matched);
      } else if (isDocComment(elementType)) {
        // If no special region were detected and comment is kind of documentation - we should create folding region
        descriptors.add(buildDocCommentFolding(node));
      }
    } else if (isCompilerConditional(elementType)) {
      RegionMarker matched = matchCCRegion(node);
      ccMarkers.add(matched);
    }

    if (descriptor != null) {
      descriptors.add(descriptor);
    }

    for (ASTNode child : node.getChildren(null)) {
      buildFolding(child, descriptors, regionMarkers, ccMarkers);
    }
  }

  private static boolean isDocComment(IElementType type) {
    return type == DOC_COMMENT;
  }

  private static boolean isDocComment(ASTNode node) {
    return isDocComment(node.getElementType());
  }

  private static RegionMarker peekUpStack(List<RegionMarker> list, int n) {
    return n >= 0 && n < list.size() ? list.get(n) : null;
  }

  private static void buildMarkerRegions(List<FoldingDescriptor> descriptors, List<RegionMarker> regionMarkers) {
    // The list should be a set of balanced nested markers.  If they are unbalanced, we simply don't
    // create the descriptor.

    LinkedList<RegionMarker> startStack = new LinkedList<>();

    for (RegionMarker current : regionMarkers) {
      if (current.type == RegionMarker.Type.START) {
        startStack.push(current);
      } else {
        // TODO: Use a 'diff' algorithm to determine the best sequence?? See Google's DiffUtils package.

        // current matches with whatever is on top of the stack.
        if (startStack.isEmpty()) {
          // Unbalanced END.  // TODO: Mark it as an error in the code??  Can we do that here?
          continue;
        }
        // TODO: Maybe check if the end has a title, and if it does, use that to match to the start as well ??
        RegionMarker start = startStack.peek();
        if (start.region != current.region) {
          // Unbalanced start/end types.

          // Until we can get a better algorithm, we'll use a simple heuristic to see if maybe it's an unbalanced start.
          RegionMarker nextStart = peekUpStack(startStack, 1);
          if (null != nextStart && nextStart.region == current.region) {
            startStack.pop(); // Unbalanced start, so pop one of those
            start = startStack.peek();
          } else {
            continue;
          }
        }

        startStack.pop();
        descriptors.add(buildRegionFolding(start, current));
      }
    }
  }

  private static int lineNumber(ASTNode node, Document document) {
    return document.getLineNumber(node.getStartOffset());
  }

  private static void buildCCRegion(List<FoldingDescriptor> descriptors, RegionMarker start, RegionMarker end, Document document) {
    if (lineNumber(start.node, document) < lineNumber(end.node, document)) {
      descriptors.add(buildCCFolding(start, end));
    }
  }

  private static void buildCCMarkerRegions(List<FoldingDescriptor> descriptors, List<RegionMarker> regionMarkers, Document document) {
    LinkedList<RegionMarker> interruptedStack = new LinkedList<>();

    RegionMarker inProcess = null;

    for (RegionMarker marker : regionMarkers) {
      IElementType type = marker.node.getElementType();

      if (PPEND == type) {
        if (null != inProcess) {
          buildCCRegion(descriptors, inProcess, marker, document);
        }
        if (!interruptedStack.isEmpty()) {
          inProcess = interruptedStack.pop();
        }
      } else {
        // No matter where we start (e.g. #else instead of #if), we just go to the next marker...
        if (null == inProcess) {
          inProcess = marker;
        } else {
          // #if introduces a new level.  Other kinds do not.
          if (PPIF == type) {
            interruptedStack.push(inProcess);
            inProcess = marker;
          } else {
            buildCCRegion(descriptors, inProcess, marker, document);
            inProcess = marker;
          }
        }
      }
    }
  }

  private static boolean isCodeBlock(IElementType elementType) {
    return elementType == BLOCK_STATEMENT;
  }

  private static boolean isBodyBlock(IElementType elementType) {
    return BODY_TYPES.contains(elementType);
  }

  private static boolean isComment(IElementType elementType, ASTNode node) {
    return ONLY_COMMENTS.contains(elementType);
  }

  private static boolean isCompilerConditional(IElementType elementType) {
    return ONLY_CC_DIRECTIVES.contains(elementType);
  }

  private static String stripComment(IElementType elementType, String text) {
    if (elementType == MSL_COMMENT) {
      return strip(text, HaxeCommenter.SINGLE_LINE_PREFIX, null);
    }
    if (elementType == MML_COMMENT) {
      text = HaxeStringUtil.terminateAt(text, '\n');
      return strip(text.trim(), HaxeCommenter.BLOCK_COMMENT_PREFIX, HaxeCommenter.BLOCK_COMMENT_SUFFIX);
    }
    if (elementType == DOC_COMMENT) {
      text = HaxeStringUtil.terminateAt(text, '\n');
      return strip(text, HaxeCommenter.DOC_COMMENT_PREFIX, HaxeCommenter.DOC_COMMENT_SUFFIX);
    }
    return text.trim();
  }

  private static String strip(@NotNull String text, @Nullable String prefix, @Nullable String suffix) {
    String stripped = text.trim();
    if (null != prefix) stripped = HaxeStringUtil.stripPrefix(stripped, prefix);
    if (null != suffix) stripped = HaxeStringUtil.stripSuffix(stripped, suffix);
    return stripped.trim();
  }

  private static RegionMarker matchRegion(ASTNode node) {
    if (null == node) {
      return null;
    }

    String text = stripComment(node.getElementType(), node.getText());
    if (null == text || text.isEmpty()) {
      return null;
    }

    // Figure out if it matches any of the region patterns.
    for (RegionDefinition region : COMMENT_REGIONS) {
      Matcher start = region.startMatcher(text);
      if (start.matches()) {
        return new RegionMarker(region, node, start, RegionMarker.Type.START);
      }
      Matcher end = region.endMatcher(text);
      if (end.matches()) {
        return new RegionMarker(region, node, end, RegionMarker.Type.END);
      }
    }
    return null;
  }

  /**
   * Gather the CC and its expression (if any) as the placeholder.
   *
   * @param node The Compiler Conditional node.
   * @return a placeholder string for code folding.
   */
  @NotNull
  private static String getCCPlaceholder(ASTNode node) {
    StringBuilder s = new StringBuilder();
    s.append(node.getText());
    ASTNode next = node.getTreeNext();
    while (null != next && next.getElementType() == PPEXPRESSION) {
      s.append(next.getText());
      next = next.getTreeNext();
    }
    if (0 != s.length()) {
      s.append(' ');
    }
    String placeholder = s.toString();
    return placeholder.isEmpty() ? "compiler conditional" : placeholder;
  }

  private static RegionMarker matchCCRegion(ASTNode node) {
    return new RegionMarker(CC_REGION, node, null, RegionMarker.Type.CC);
  }

  private static FoldingDescriptor buildCodeBlockFolding(@NotNull ASTNode node) {
    final ASTNode openBrace = node.getFirstChildNode();
    final ASTNode closeBrace = node.getLastChildNode();

    return buildBlockFolding(node, openBrace, closeBrace);
  }

  private static FoldingDescriptor buildBodyBlockFolding(@NotNull ASTNode node) {
    final ASTNode openBrace = UsefulPsiTreeUtil.getPrevSiblingSkipWhiteSpacesAndComments(node);
    final ASTNode closeBrace = UsefulPsiTreeUtil.getNextSiblingSkipWhiteSpacesAndComments(node);

    return buildBlockFolding(node, openBrace, closeBrace);
  }

  private static FoldingDescriptor buildBlockFolding(@NotNull ASTNode node, ASTNode openBrace, ASTNode closeBrace) {
    TextRange textRange;
    if (openBrace != null && closeBrace != null && openBrace.getElementType() == PLCURLY && closeBrace.getElementType() == PRCURLY) {
      textRange = new TextRange(openBrace.getTextRange().getEndOffset(), closeBrace.getStartOffset());
    } else {
      textRange = node.getTextRange();
    }

    if (isValidFoldingSize(textRange)) {
      return new FoldingDescriptor(node, textRange);
    }

    return null;
  }

  private static boolean isValidFoldingSize(TextRange textRange) {
    return textRange.getLength() > PLACEHOLDER_TEXT.length();
  }

  private static FoldingDescriptor buildDocCommentFolding(ASTNode node) {
    TextRange textRange = node.getTextRange();
    textRange = new TextRange(textRange.getStartOffset() + 2, textRange.getEndOffset() - 2);

    if (isValidFoldingSize(textRange)) {
      return new FoldingDescriptor(node, textRange);
    }

    return null;
  }

  private static FoldingDescriptor buildRegionFolding(final RegionMarker start, final RegionMarker end) {
    TextRange range = new TextRange(start.node.getStartOffset(), end.node.getTextRange().getEndOffset());
    start.node.putUserData(REGION_DEFINITION_KEY, start.region);
    return new FoldingDescriptor(start.node, range) {
      @Nullable
      @Override
      public String getPlaceholderText() {
        String s = start.region.getPlaceholder(start.matcher);
        return null == s ? PLACEHOLDER_TEXT : s;
      }
    };
  }

  private static FoldingDescriptor buildCCFolding(final RegionMarker start, final RegionMarker end) {
    TextRange range = new TextRange(start.node.getStartOffset(), end.node.getTextRange().getStartOffset());

    final String placeholder = getCCPlaceholder(start.node);
    start.node.putUserData(REGION_DEFINITION_KEY, start.region);
    return new FoldingDescriptor(start.node, range) {
      @Nullable
      @Override
      public String getPlaceholderText() {
        return placeholder;
      }
    };
  }

  private static FoldingDescriptor buildImportsFolding(@NotNull ASTNode node) {
    final ASTNode lastImportNode = findLastImportNode(node);
    if (!node.equals(lastImportNode)) {
      return new FoldingDescriptor(node, buildImportsFoldingTextRange(node, lastImportNode));
    }
    return null;
  }

  private static TextRange buildImportsFoldingTextRange(ASTNode firstNode, ASTNode lastNode) {
    ASTNode nodeStartFrom = UsefulPsiTreeUtil.getNextSiblingSkipWhiteSpacesAndComments(firstNode.getFirstChildNode());
    if (nodeStartFrom == null) {
      nodeStartFrom = firstNode;
    }
    return new TextRange(nodeStartFrom.getStartOffset(), lastNode.getTextRange().getEndOffset());
  }

  private static boolean isFirstImportStatement(@NotNull ASTNode node) {
    ASTNode prevNode = UsefulPsiTreeUtil.getPrevSiblingSkipWhiteSpacesAndComments(node);
    return prevNode == null || !isImportOrUsingStatement(prevNode.getElementType());
  }

  private static ASTNode findLastImportNode(@NotNull ASTNode node) {
    ASTNode lastImportNode = node;
    ASTNode nextNode = UsefulPsiTreeUtil.getNextSiblingSkipWhiteSpacesAndComments(node);
    while (nextNode != null && isImportOrUsingStatement(nextNode.getElementType())) {
      lastImportNode = nextNode;
      nextNode = UsefulPsiTreeUtil.getNextSiblingSkipWhiteSpacesAndComments(nextNode);
    }
    if (lastImportNode.getElementType() == WHITE_SPACE) {
      lastImportNode = lastImportNode.getTreePrev();
    }
    return lastImportNode;
  }

  private static boolean isImportOrUsingStatement(IElementType type) {
    return type == IMPORT_STATEMENT || type == USING_STATEMENT;
  }

  @Nullable
  @Override
  public String getPlaceholderText(@NotNull ASTNode node) {
    return PLACEHOLDER_TEXT;
  }

  @Override
  public boolean isCollapsedByDefault(@NotNull ASTNode node) {
    final PsiElement psiElement = node.getPsi();

    final CodeFoldingSettings settings = CodeFoldingSettings.getInstance();
    final HaxeFoldingSettings haxeSettings = HaxeFoldingSettings.getInstance();

    if (psiElement instanceof HaxeImportStatement || psiElement instanceof HaxeUsingStatement) {
      return settings.COLLAPSE_IMPORTS;
    }

    if (isDocComment(node) && !hasRegionMarker(node)) return settings.COLLAPSE_DOC_COMMENTS;

    RegionDefinition regionType = node.getUserData(REGION_DEFINITION_KEY);
    if (regionType == C_SHARP_STYLE) return haxeSettings.isCollapseCSharpStyleRegions();
    if (regionType == FD_STYLE) return haxeSettings.isCollapseFlashDevelopStyleRegions();
    if (regionType == PLUGIN_STYLE) return haxeSettings.isCollapseHaxePluginStyleRegions();
    if (regionType == CC_REGION) {
      return haxeSettings.isCollapseUnusedConditionallyCompiledCode()
          && isUnusedCCRegion(node);
    }

    return false;
  }

  private boolean hasRegionMarker(@NotNull ASTNode node) {
    return node.getUserData(REGION_DEFINITION_KEY) != null;
  }

  private boolean isUnusedCCRegion(ASTNode node) {
    // This works because unused regions only contain PPEXPRESSION and PPBODY element types.
    // There is always at least one PPBODY element in any unused section, even if it is whitespace.

    if (null == node) {
      return false;
    }
    // Sections start with a PsiComment for the marker (#if/#else/#elseif/#end),
    // followed by a set of PsiComment(PPEXPRESSION), and finally, a PsiComment(PPBODY)
    // when the section is unused.
    if (!ONLY_CC_DIRECTIVES.contains(node.getElementType())) {
      return false;
    }

    node = node.getTreeNext();
    while (PPEXPRESSION == node.getElementType()) {
      node = node.getTreeNext();
    }
    return node.getElementType() == PPBODY;

  }
}