/*
 * Copyright (C) 2015-2016 Federico Tomassetti
 * Copyright (C) 2017-2024 The JavaParser Team.
 *
 * This file is part of JavaParser.
 *
 * JavaParser can be used either under the terms of
 * a) the GNU Lesser General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 * b) the terms of the Apache License
 *
 * You should have received a copy of both licenses in LICENCE.LGPL and
 * LICENCE.APACHE. Please refer to those files for details.
 *
 * JavaParser is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 */

package com.github.javaparser.symbolsolver.resolution.typesolvers;

import static com.github.javaparser.ParseStart.COMPILATION_UNIT;
import static com.github.javaparser.ParserConfiguration.LanguageLevel.BLEEDING_EDGE;
import static com.github.javaparser.Providers.provider;

import com.github.javaparser.JavaParser;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.resolution.Navigator;
import com.github.javaparser.resolution.TypeSolver;
import com.github.javaparser.resolution.cache.Cache;
import com.github.javaparser.resolution.declarations.ResolvedReferenceTypeDeclaration;
import com.github.javaparser.resolution.model.SymbolReference;
import com.github.javaparser.symbolsolver.cache.GuavaCache;
import com.github.javaparser.symbolsolver.javaparsermodel.JavaParserFacade;
import com.github.javaparser.symbolsolver.utils.FileUtils;
import com.google.common.cache.CacheBuilder;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

/**
 * Defines a directory containing source code that should be used for solving symbols.
 * The directory must correspond to the root package of the files within.
 *
 * @author Federico Tomassetti
 */
public class JavaParserTypeSolver implements TypeSolver {

    private final Path srcDir;
    private final JavaParser javaParser;

    private TypeSolver parent;

    private final Cache<Path, Optional<CompilationUnit>> parsedFiles;
    private final Cache<Path, List<CompilationUnit>> parsedDirectories;
    private final Cache<String, SymbolReference<ResolvedReferenceTypeDeclaration>> foundTypes;
    private static final int CACHE_SIZE_UNSET = -1;

    public JavaParserTypeSolver(File srcDir) {
        this(srcDir.toPath());
    }

    public JavaParserTypeSolver(String srcDir) {
        this(new File(srcDir));
    }

    public JavaParserTypeSolver(Path srcDir) {
        this(srcDir, new ParserConfiguration().setLanguageLevel(BLEEDING_EDGE));
    }

    public JavaParserTypeSolver(File srcDir, ParserConfiguration parserConfiguration) {
        this(srcDir.toPath(), parserConfiguration);
    }

    public JavaParserTypeSolver(String srcDir, ParserConfiguration parserConfiguration) {
        this(new File(srcDir), parserConfiguration);
    }

    public JavaParserTypeSolver(Path srcDir, ParserConfiguration parserConfiguration) {
        this(srcDir, parserConfiguration, CACHE_SIZE_UNSET);
    }

    private <TKey, TValue> Cache<TKey, TValue> BuildCache(long cacheSizeLimit) {
        CacheBuilder<Object, Object> cacheBuilder = CacheBuilder.newBuilder().softValues();
        if (cacheSizeLimit != CACHE_SIZE_UNSET) {
            cacheBuilder.maximumSize(cacheSizeLimit);
        }
        return new GuavaCache<>(cacheBuilder.build());
    }

    /**
     * @param srcDir is the source code directory for the type solver.
     * @param parserConfiguration is the configuration the solver should use when inspecting source code files.
     * @param cacheSizeLimit is an optional size limit to the internal caches used by this solver.
     *        Be advised that setting the size too low might lead to noticeable performance degradation.
     *        However, using a size limit is advised when solving symbols in large code sources. In such cases, internal caches might consume large amounts of heap space.
     */
    public JavaParserTypeSolver(Path srcDir, ParserConfiguration parserConfiguration, long cacheSizeLimit) {
        if (!Files.exists(srcDir) || !Files.isDirectory(srcDir)) {
            throw new IllegalStateException("SrcDir does not exist or is not a directory: " + srcDir);
        }
        this.srcDir = srcDir;
        javaParser = new JavaParser(parserConfiguration);
        parsedFiles = BuildCache(cacheSizeLimit);
        parsedDirectories = BuildCache(cacheSizeLimit);
        foundTypes = BuildCache(cacheSizeLimit);
    }

    /**
     * Create a {@link JavaParserTypeSolver} with a custom cache system.
     *
     * @param srcDir                    The source code directory for the type solver.
     * @param javaParser                The {@link JavaParser} to be used when parsing .java files.
     * @param parsedFilesCache          The cache to be used to store {@link CompilationUnit} that is associated with
     *                                  a file.
     * @param parsedDirectoriesCache    The cache to store the list of {@link CompilationUnit} in a given directory.
     * @param foundTypesCache           The cache that associated a qualified name to its {@link SymbolReference}.
     */
    public JavaParserTypeSolver(
            Path srcDir,
            JavaParser javaParser,
            Cache<Path, Optional<CompilationUnit>> parsedFilesCache,
            Cache<Path, List<CompilationUnit>> parsedDirectoriesCache,
            Cache<String, SymbolReference<ResolvedReferenceTypeDeclaration>> foundTypesCache) {
        Objects.requireNonNull(srcDir, "The srcDir can't be null.");
        Objects.requireNonNull(javaParser, "The javaParser can't be null.");
        Objects.requireNonNull(parsedFilesCache, "The parsedFilesCache can't be null.");
        Objects.requireNonNull(parsedDirectoriesCache, "The parsedDirectoriesCache can't be null.");
        Objects.requireNonNull(foundTypesCache, "The foundTypesCache can't be null.");

        if (!Files.exists(srcDir) || !Files.isDirectory(srcDir)) {
            throw new IllegalStateException("SrcDir does not exist or is not a directory: " + srcDir);
        }

        this.srcDir = srcDir;
        this.javaParser = javaParser;
        this.parsedFiles = parsedFilesCache;
        this.parsedDirectories = parsedDirectoriesCache;
        this.foundTypes = foundTypesCache;
    }

    @Override
    public String toString() {
        return "JavaParserTypeSolver{" + "srcDir=" + srcDir + ", parent=" + parent + '}';
    }

    @Override
    public TypeSolver getParent() {
        return parent;
    }

    @Override
    public void setParent(TypeSolver parent) {
        Objects.requireNonNull(parent);
        if (this.parent != null) {
            throw new IllegalStateException("This TypeSolver already has a parent.");
        }
        if (parent == this) {
            throw new IllegalStateException("The parent of this TypeSolver cannot be itself.");
        }
        this.parent = parent;
    }

    private Optional<CompilationUnit> parse(Path srcFile) {
        try {
            Optional<Optional<CompilationUnit>> cachedParsedFile = parsedFiles.get(srcFile.toAbsolutePath());
            // If the value is already cached
            if (cachedParsedFile.isPresent()) {
                return cachedParsedFile.get();
            }

            // Otherwise load it
            if (!Files.exists(srcFile) || !Files.isRegularFile(srcFile)) {
                parsedFiles.put(srcFile.toAbsolutePath(), Optional.empty());
                return Optional.empty();
            }

            // JavaParser only allow one parse at time.
            synchronized (javaParser) {
                Optional<CompilationUnit> compilationUnit = javaParser
                        .parse(
                                COMPILATION_UNIT,
                                provider(
                                        srcFile,
                                        javaParser.getParserConfiguration().getCharacterEncoding()))
                        .getResult()
                        .map(cu -> cu.setStorage(srcFile));
                parsedFiles.put(srcFile.toAbsolutePath(), compilationUnit);
                return compilationUnit;
            }
        } catch (IOException e) {
            throw new RuntimeException("Issue while parsing while type solving: " + srcFile.toAbsolutePath(), e);
        }
    }

    /**
     * Note that this parse only files directly contained in this directory.
     * It does not traverse recursively all children directory.
     */
    private List<CompilationUnit> parseDirectory(Path srcDirectory) {
        return parseDirectory(srcDirectory, false);
    }

    private List<CompilationUnit> parseDirectoryRecursively(Path srcDirectory) {
        return parseDirectory(srcDirectory, true);
    }

    private List<CompilationUnit> parseDirectory(Path srcDirectory, boolean recursively) {
        try {
            Optional<List<CompilationUnit>> cachedValue = parsedDirectories.get(srcDirectory.toAbsolutePath());
            if (cachedValue.isPresent()) {
                return cachedValue.get();
            }

            // If not cached, we need to load it
            List<CompilationUnit> units = new ArrayList<>();
            if (Files.exists(srcDirectory)) {
                try (DirectoryStream<Path> srcDirectoryStream = Files.newDirectoryStream(srcDirectory)) {
                    srcDirectoryStream.forEach(file -> {
                        if (file.getFileName().toString().toLowerCase().endsWith(".java")) {
                            parse(file).ifPresent(units::add);
                        } else if (recursively && file.toFile().isDirectory()) {
                            units.addAll(parseDirectoryRecursively(file));
                        }
                    });
                }
            }
            parsedDirectories.put(srcDirectory.toAbsolutePath(), units);
            return units;
        } catch (IOException e) {
            throw new RuntimeException(
                    "Unable to parse directory due to an exception. Directory:" + srcDirectory.toAbsolutePath(), e);
        }
    }

    @Override
    public SymbolReference<ResolvedReferenceTypeDeclaration> tryToSolveType(String name) {
        Optional<SymbolReference<ResolvedReferenceTypeDeclaration>> cachedValue = foundTypes.get(name);
        if (cachedValue.isPresent()) {
            return cachedValue.get();
        }

        // Otherwise load it
        SymbolReference<ResolvedReferenceTypeDeclaration> result = tryToSolveTypeUncached(name);
        foundTypes.put(name, result);
        return result;
    }

    private SymbolReference<ResolvedReferenceTypeDeclaration> tryToSolveTypeUncached(String name) {
        String[] nameElements = name.split("\\.");

        for (int i = nameElements.length; i > 0; i--) {
            StringBuilder filePath = new StringBuilder(srcDir.toAbsolutePath().toString());
            for (int j = 0; j < i; j++) {
                filePath.append(File.separator).append(nameElements[j]);
            }
            filePath.append(".java");

            StringBuilder typeName = new StringBuilder();
            for (int j = i - 1; j < nameElements.length; j++) {
                if (j != i - 1) {
                    typeName.append(".");
                }
                typeName.append(nameElements[j]);
            }

            String dirToParse = null;
            // As an optimization we first try to look in the canonical position where we expect to find the file
            if (FileUtils.isValidPath(filePath.toString())) {
                Path srcFile = Paths.get(filePath.toString());
                Optional<CompilationUnit> compilationUnit = parse(srcFile);
                if (compilationUnit.isPresent()) {
                    Optional<com.github.javaparser.ast.body.TypeDeclaration<?>> astTypeDeclaration =
                            Navigator.findType(compilationUnit.get(), typeName.toString());
                    if (astTypeDeclaration.isPresent()) {
                        return SymbolReference.solved(
                                JavaParserFacade.get(this).getTypeDeclaration(astTypeDeclaration.get()));
                    }
                }
                dirToParse = srcFile.getParent().normalize().toString();
            } else {
                dirToParse = FileUtils.getParentPath(filePath.toString());
            }

            // If this is not possible we parse all files
            // We try just in the same package, for classes defined in a file not named as the class itself
            if (FileUtils.isValidPath(dirToParse)) {
                List<CompilationUnit> compilationUnits = parseDirectory(Paths.get(dirToParse));
                for (CompilationUnit compilationUnit : compilationUnits) {
                    Optional<com.github.javaparser.ast.body.TypeDeclaration<?>> astTypeDeclaration =
                            Navigator.findType(compilationUnit, typeName.toString());
                    if (astTypeDeclaration.isPresent()) {
                        return SymbolReference.solved(
                                JavaParserFacade.get(this).getTypeDeclaration(astTypeDeclaration.get()));
                    }
                }
            }
        }

        return SymbolReference.unsolved();
    }
}
