/*
 * Copyright (c) Joachim Ansorg, [email protected]
 *
 * 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.ansorgit.plugins.bash.lang.psi.util;

import com.ansorgit.plugins.bash.lang.lexer.BashTokenTypes;
import com.ansorgit.plugins.bash.lang.psi.BashVisitor;
import com.ansorgit.plugins.bash.lang.psi.api.BashBlock;
import com.ansorgit.plugins.bash.lang.psi.api.BashFile;
import com.ansorgit.plugins.bash.lang.psi.api.BashFileReference;
import com.ansorgit.plugins.bash.lang.psi.api.command.BashCommand;
import com.ansorgit.plugins.bash.lang.psi.api.command.BashGenericCommand;
import com.ansorgit.plugins.bash.lang.psi.api.command.BashIncludeCommand;
import com.ansorgit.plugins.bash.lang.psi.api.expression.BashSubshellCommand;
import com.ansorgit.plugins.bash.lang.psi.api.function.BashFunctionDef;
import com.ansorgit.plugins.bash.lang.psi.api.loops.BashFor;
import com.ansorgit.plugins.bash.lang.psi.api.vars.BashVar;
import com.ansorgit.plugins.bash.lang.psi.api.word.BashWord;
import com.ansorgit.plugins.bash.lang.psi.eval.BashEvalBlock;
import com.ansorgit.plugins.bash.lang.psi.stubs.index.BashIncludeCommandIndex;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.intellij.lang.ASTNode;
import com.intellij.lang.injection.InjectedLanguageManager;
import com.intellij.openapi.util.TextRange;
import com.intellij.psi.*;
import com.intellij.psi.impl.source.tree.CompositeElement;
import com.intellij.psi.scope.PsiScopeProcessor;
import com.intellij.psi.search.GlobalSearchScope;
import com.intellij.psi.stubs.StubIndex;
import com.intellij.psi.tree.IElementType;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.psi.util.PsiUtilCore;
import com.intellij.util.IncorrectOperationException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;

/**
 * @author jansorg
 */
public final class BashPsiUtils {
    private BashPsiUtils() {
    }

    /**
     * Finds the file context for a given element. If element is inside of an Bash file injection host (e.g. because the element is in an eval command)
     * then the host file is returned.
     *
     * @param element
     * @return The file on disk
     */
    public static PsiFile findFileContext(PsiElement element) {
        return element.getContainingFile();
    }

    /**
     * Returns the depth in blocks this element has in the tree.
     *
     * @param element The element to lookup
     * @return The depth measured in blocks, 0 if it's at the top level
     */
    public static int blockNestingLevel(PsiElement element) {
        int depth = 0;

        PsiElement current = findEnclosingBlock(element);
        while (current != null) {
            depth++;
            current = findEnclosingBlock(current);
        }

        return depth;
    }

    /**
     * Returns the broadest scope of the variable definition.
     *
     * @param startElement The element to check
     * @return The containing block or null
     */
    @Nullable
    public static BashFunctionDef findBroadestFunctionScope(PsiElement startElement) {
        BashFunctionDef lastValidScope = null;

        PsiElement element = PsiTreeUtil.getStubOrPsiParent(startElement);
        while (element != null) {
            if (element instanceof BashFunctionDef) {
                lastValidScope = (BashFunctionDef) element;
            }

            element = PsiTreeUtil.getStubOrPsiParent(element);
            if (element == null) {
                return lastValidScope;
            }
        }

        return null;
    }

    /**
     * Returns the narrowest scope of the variable definition.
     *
     * @param varDef The element to check
     * @return The containing block or null
     */
    public static BashFunctionDef findNextVarDefFunctionDefScope(PsiElement varDef) {
        PsiElement element = PsiTreeUtil.getStubOrPsiParent(varDef);
        while (element != null) {
            if (element instanceof BashFunctionDef) {
                return (BashFunctionDef) element;
            }

            element = PsiTreeUtil.getStubOrPsiParent(element);
        }

        return null;
    }

    /**
     * Returns the next logical block which contains this element.
     *
     * @param element The element to check
     * @return The containing block or null
     */
    public static PsiElement findEnclosingBlock(PsiElement element) {
        while (element != null) {
            element = PsiTreeUtil.getStubOrPsiParent(element);

            if (isValidContainer(element)) {
                return element;
            }
        }

        return null;
    }

    public static int getElementLineNumber(PsiElement element) {
        FileViewProvider fileViewProvider = element.getContainingFile().getViewProvider();
        if (fileViewProvider.getDocument() != null) {
            return fileViewProvider.getDocument().getLineNumber(element.getTextOffset()) + 1;
        }

        return 0;
    }

    public static int getElementEndLineNumber(PsiElement element) {
        FileViewProvider fileViewProvider = element.getContainingFile().getViewProvider();
        if (fileViewProvider.getDocument() != null) {
            return fileViewProvider.getDocument().getLineNumber(element.getTextOffset() + element.getTextLength()) + 1;
        }

        return 0;
    }

    public static PsiElement findNextSibling(PsiElement start, IElementType ignoreType) {
        if (start == null) {
            return null;
        }

        PsiElement current = start.getNextSibling();
        while (current != null) {
            if (ignoreType != PsiUtilCore.getElementType(current)) {
                return current;
            }

            current = current.getNextSibling();
        }

        return null;
    }

    public static PsiElement findPreviousSibling(PsiElement start, IElementType ignoreType) {
        if (start == null) {
            return null;
        }

        PsiElement current = start.getPrevSibling();
        while (current != null) {
            if (ignoreType != PsiUtilCore.getElementType(current)) {
                return current;
            }

            current = current.getPrevSibling();
        }

        return null;
    }

    /**
     * Replaces the priginal element with the replacement.
     *
     * @param original    The original element which should be replaced.
     * @param replacement The new element
     * @return The replaces element. Depending on the context of the original element it either the original element or the replacement element.
     */
    public static <T extends PsiElement> T replaceElement(T original, PsiElement replacement) throws IncorrectOperationException {
        try {
            return (T) original.replace(replacement);
        } catch (IncorrectOperationException e) {
            //failed, try another way
        } catch (UnsupportedOperationException e) {
            //failed, try another way
        }

        PsiElement parent = original.getParent();
        if (parent != null) {
            PsiElement inserted = parent.addBefore(replacement, original);
            original.delete();
            return (T) inserted;
        } else {
            //last try, not optimal
            original.getNode().replaceAllChildrenToChildrenOf(replacement.getNode());
            return original;
        }
    }

    @Nullable
    public static TextRange rangeInParent(PsiElement parent, PsiElement child) {
        if (!parent.getTextRange().contains(child.getTextRange())) {
            return null;
        }

        return TextRange.from(child.getTextOffset() - parent.getTextOffset(), child.getTextLength());
    }

    public static boolean isStaticWordExpr(PsiElement child) {
        while (child != null) {
            if (child instanceof BashVar || child instanceof BashSubshellCommand) {
                return false;
            }

            //a string may contain other composed elements, e.g. "$a" contains a wrapped word which contains the var
            if (!isStaticWordExpr(child.getFirstChild())) {
                return false;
            }

            child = child.getNextSibling();
        }

        return true;
    }

    /**
     * This tree walkup method does continue even if a valid definition has been found on an more-inner level.
     * Bash is different in regard to the definitions, the most outer definitions count, not the most inner / the first one found.
     *
     * @param processor
     * @param entrance
     * @param maxScope
     * @param state
     * @return
     */
    public static boolean varResolveTreeWalkUp(@NotNull final PsiScopeProcessor processor,
                                               @NotNull final BashVar entrance,
                                               @Nullable final PsiElement maxScope,
                                               @NotNull final ResolveState state) {
        PsiElement prevParent = entrance;
        PsiElement scope = entrance;

        boolean hasResult = false;

        while (scope != null) {
            hasResult |= !scope.processDeclarations(processor, state, prevParent, entrance);

            if (scope == maxScope) {
                break;
            }

            prevParent = scope;
            scope = PsiTreeUtil.getStubOrPsiParent(prevParent);
        }

        return !hasResult;
    }

    @Nullable
    public static PsiFile findIncludedFile(BashCommand bashCommand) {
        if (bashCommand instanceof BashIncludeCommand) {
            BashFileReference reference = ((BashIncludeCommand) bashCommand).getFileReference();

            if (reference != null) {
                return reference.findReferencedFile();
            }
        }

        return null;
    }

    /**
     * Returns the commands of file which include the other file.
     *
     * @param file
     * @return The list of files which are included in the first file
     */
    public static Set<PsiFile> findIncludedFiles(PsiFile file, boolean followNestedFiles) {
        Set<PsiFile> files = Sets.newLinkedHashSet();

        collectIncludedFiles(file, files, followNestedFiles);

        return files;
    }

    public static void collectIncludedFiles(PsiFile file, Set<PsiFile> files, boolean followNestedFiles) {
        String filePath = file.getVirtualFile().getPath();

        Collection<BashIncludeCommand> commands = StubIndex.getElements(BashIncludeCommandIndex.KEY, filePath, file.getProject(), GlobalSearchScope.fileScope(file), BashIncludeCommand.class);
        for (BashIncludeCommand command : commands) {
            PsiFile includedFile = findIncludedFile(command);
            if (includedFile != null) {
                boolean followFile = followNestedFiles && !files.contains(includedFile);
                files.add(includedFile);

                if (followFile) {
                    collectIncludedFiles(includedFile, files, true);
                }
            }
        }
    }

    /**
     * Returns the commands of file which include the other file.
     *
     * @param file
     * @param filterByFile
     * @return The list of commands, may be empty but wont be null
     */
    public static List<BashIncludeCommand> findIncludeCommands(PsiFile file, @Nullable final PsiFile filterByFile) {
        String filePath = file.getVirtualFile().getPath();

        List<BashIncludeCommand> result = Lists.newLinkedList();

        Collection<BashIncludeCommand> commands = StubIndex.getElements(BashIncludeCommandIndex.KEY, filePath, file.getProject(), GlobalSearchScope.fileScope(file), BashIncludeCommand.class);
        for (BashIncludeCommand command : commands) {
            if (filterByFile == null || filterByFile.equals(findIncludedFile(command))) {
                result.add(command);
            }
        }

        return result;
    }

    public static void visitRecursively(PsiElement element, BashVisitor visitor) {
        element.accept(visitor);

        // calling element.getChildren() is expensive,
        // better iterate over the chilren
        PsiElement child = element.getFirstChild();
        while (child != null) {
            if (child.getNode() instanceof CompositeElement) {
                visitRecursively(child, visitor);
            }

            child = child.getNextSibling();
        }
    }

    public static boolean hasContext(PsiElement element, PsiElement contextCandidate) {
        for (PsiElement ref = element; ref != null; ref = ref.getContext()) {
            if (ref == contextCandidate) {
                return true;
            }
        }

        return false;
    }

    public static boolean isValidReferenceScope(PsiElement referenceToDefCandidate, PsiElement variableDefinition) {
        PsiFile definitionFile = findFileContext(variableDefinition);
        PsiFile referenceFile = findFileContext(referenceToDefCandidate);

        boolean sameFile = definitionFile.equals(referenceFile);

        if (sameFile) {
            return isValidGlobalOffset(referenceToDefCandidate, variableDefinition);
        }

        //we need to find the include command and check the offset
        //the include command must fullfil the same condition as the normal variable definition above:
        //either var use and definition are both in functions or it the use is invalid

        List<BashIncludeCommand> includeCommands = findIncludeCommands(referenceFile, definitionFile);

        //currently we only support global include commands
        for (BashCommand includeCommand : includeCommands) {
            if (!isValidGlobalOffset(referenceToDefCandidate, includeCommand)) {
                return false;
            }
        }

        return true;
    }

    public static List<PsiComment> findDocumentationElementComments(PsiElement element) {
        PsiElement command = findStubParent(element, BashCommand.class);
        if (command == null) {
            command = findStubParent(element, BashFunctionDef.class);
        }

        if (command == null) {
            return Collections.emptyList();
        }

        int previousLine = getElementLineNumber(element);

        PsiElement current = command.getPrevSibling();

        List<PsiComment> result = Lists.newLinkedList();

        while (current != null && current.getNode() != null && current.getNode().getElementType() == BashTokenTypes.LINE_FEED) {
            current = current.getPrevSibling();

            if (current instanceof PsiComment && BashPsiUtils.getElementEndLineNumber(current) + 1 == previousLine) {
                result.add(0, (PsiComment) current);
                previousLine = getElementLineNumber(current);
                current = current.getPrevSibling();
            } else {
                break;
            }
        }

        return result;
    }

    @Nullable
    public static <T extends PsiElement> T findStubParent(@Nullable PsiElement start, Class<T> parentType) {
        if (start == null) {
            return null;
        }

        Class<? extends PsiElement> breakPoint = PsiFile.class;

        for (PsiElement current = start; current != null; current = PsiTreeUtil.getStubOrPsiParent(current)) {
            if (parentType.isInstance(current)) {
                return (T) current;
            }

            if (breakPoint.isInstance(current)) {
                return null;
            }
        }

        return null;
    }

    @Nullable
    public static <T extends PsiElement> T findParent(@Nullable PsiElement start, Class<T> parentType) {
        return findParent(start, parentType, PsiFile.class);
    }

    @Nullable
    public static <T extends PsiElement> T findParent(@Nullable PsiElement start, Class<T> parentType, Class<? extends PsiElement> breakPoint) {
        if (start == null) {
            return null;
        }

        for (PsiElement current = start; current != null; current = current.getParent()) {
            if (parentType.isInstance(current)) {
                return (T) current;
            }

            if (breakPoint != null && breakPoint.isInstance(current)) {
                return null;
            }
        }

        return null;
    }

    public static boolean hasParentOfType(PsiElement start, Class<? extends PsiElement> parentType, int maxSteps) {
        return hasParentOfType(start, parentType, maxSteps, PsiFile.class);
    }

    public static boolean isCommandParameterWord(PsiElement start) {
        BashCommand command = PsiTreeUtil.getParentOfType(start, BashCommand.class);
        if (command == null) {
            return false;
        }

        BashWord word = PsiTreeUtil.getParentOfType(start, BashWord.class);

        return word != null && PsiTreeUtil.getPrevSiblingOfType(word, BashGenericCommand.class) != null;

    }

    public static boolean hasParentOfType(PsiElement start, Class<? extends PsiElement> parentType, int maxSteps, Class<? extends PsiElement> breakPoint) {
        for (PsiElement current = start; current != null && maxSteps-- >= 0; current = current.getParent()) {
            if (parentType.isInstance(current)) {
                return true;
            }

            if (breakPoint != null && breakPoint.isInstance(current)) {
                return false;
            }
        }

        return false;
    }

    public static boolean isInjectedElement(@NotNull PsiElement element) {
        InjectedLanguageManager languageManager = InjectedLanguageManager.getInstance(element.getProject());
        return languageManager.isInjectedFragment(element.getContainingFile()) || hasInjectionHostParent(element);
    }

    /**
     * Returns the start text offset of the element in the toplevel file, i.e the PsiFile which containing the real document. If an element
     * is injected then the outer file is returned.
     *
     * @param element The element to work on
     * @return The start text offset in the physical PsiFile, injected virtual PsiFiles are not used for text offset calculation
     */
    public static int getFileTextOffset(@NotNull PsiElement element) {
        int offset = element.getTextOffset();
        if (isInjectedElement(element)) {
            //fixme languageManager is probably expensive
            InjectedLanguageManager languageManager = InjectedLanguageManager.getInstance(element.getProject());

            PsiLanguageInjectionHost injectionHost = languageManager.getInjectionHost(element);
            if (injectionHost != null) {
                offset += injectionHost.getTextOffset();
            }
        }

        return offset;
    }

    public static int getFileTextEndOffset(@NotNull PsiElement element) {
        return getFileTextOffset(element) + element.getTextLength();
    }

    public static TextRange getTextRangeInFile(PsiElement element) {
        int offset = getFileTextOffset(element);

        return TextRange.from(offset, element.getTextLength());
    }

    /**
     * Returns the deepest nested ast node which still covers the same part of the file as the parent node. Happens if a single leaf node is
     * contained in several composite parent nodes of the same range, e.g. a var in a combined word.
     *
     * @param parent The element to use as the startin point
     * @return The deepest node inside of parent which covers the same range or (if none exists) the input element
     */
    @NotNull
    public static ASTNode getDeepestEquivalent(ASTNode parent) {
        ASTNode element = parent;
        while (element.getFirstChildNode() != null && element.getFirstChildNode() == element.getLastChildNode() && element.getTextRange().equals(parent.getTextRange())) {
            element = element.getFirstChildNode();
        }

        return element;
    }

    @Nullable
    public static ASTNode findEquivalentParent(@NotNull ASTNode node, @Nullable IElementType stopAt) {
        TextRange sourceRange = node.getTextRange();

        ASTNode current = node;
        while (true) {
            ASTNode parent = current.getTreeParent();
            if (parent == null || !parent.getTextRange().equals(sourceRange)) {
                return stopAt != null && current.getElementType() != stopAt ? null : current;
            }

            current = parent;
            if (stopAt == null || stopAt.equals(current.getElementType())) {
                return current;
            }
        }
    }

    @Nullable
    public static PsiElement findEquivalentParent(@NotNull PsiElement node, @Nullable IElementType stopAt) {
        ASTNode parent = findEquivalentParent(node.getNode(), stopAt);
        if (parent != null) {
            return parent.getPsi();
        }
        return null;
    }

    @Nullable
    public static PsiReference selfReference(PsiElement element) {
        ElementManipulator<PsiElement> manipulator = ElementManipulators.getManipulator(element);
        if (manipulator == null) {
            return null;
        }

        return new PsiReferenceBase.Immediate<PsiElement>(element, manipulator.getRangeInElement(element), true, element);
    }

    public static boolean isSingleChildParent(PsiElement psi) {
        if (psi == null) {
            return false;
        }

        ASTNode child = psi.getNode();
        return child.getTreePrev() == null && child.getTreeNext() == null;
    }

    public static boolean isSingleChildParent(PsiElement psi, @NotNull IElementType childType) {
        if (psi == null) {
            return false;
        }

        ASTNode child = getDeepestEquivalent(psi.getNode());
        return child.getTreePrev() == null && child.getTreeNext() == null && (child.getElementType() == childType);
    }

    public static boolean isSingleChildParent(PsiElement psi, @NotNull Class<? extends PsiElement> childType) {
        if (psi == null) {
            return false;
        }

        ASTNode child = getDeepestEquivalent(psi.getNode());
        PsiElement childPsi = child.getPsi();

        return childType.isInstance(childPsi);
    }

    public static boolean isInEvalBlock(PsiElement element) {
        return findParent(element, BashEvalBlock.class) != null;
    }

    private static boolean isValidContainer(PsiElement element) {
        if (element instanceof BashFor) {
            // variables defined in a for loop are also visiable afterwards
            return false;
        }

        return element instanceof BashBlock || element instanceof BashFunctionDef || element instanceof BashFile;
    }

    /**
     * Checks whether the given definition is a valid definition for the referenceElement.
     * If both have the same definition context, then the text offsets are compared.
     * If the definition contexts are different functions then the definition is valid.
     * A global reference to a definition in a function is checked by text offset.
     * A reference in a function to a global definition is also checked by reference.
     *
     * @param referenceElement The element checked
     * @param definition       The definition checked
     * @return True if definition may be a valid definition for the reference
     */
    private static boolean isValidGlobalOffset(PsiElement referenceElement, PsiElement definition) {
        BashFunctionDef refScope = findNextVarDefFunctionDefScope(referenceElement);
        BashFunctionDef defScope = findNextVarDefFunctionDefScope(definition);

        int refOffset = referenceElement.getTextOffset();
        int defOffset = definition.getTextOffset();

        //both global or both in the same function
        if (refScope == defScope || (refScope != null && refScope.isEquivalentTo(defScope))) {
            //both may be null or a function scope
            return refOffset > defOffset;
        }

        //ref and def are in different functions
        if (refScope != null && defScope != null) {
            return true;
        }

        //def global: ref function must be after the definition context
        //ref global: ref must be after the definition context

        return refOffset > defOffset;
    }

    private static boolean hasInjectionHostParent(PsiElement element) {
        return hasParentOfType(element, PsiLanguageInjectionHost.class, 10);
    }
}