package org.web3j.mavenplugin;


import com.fasterxml.jackson.databind.ObjectMapper;

import org.apache.commons.lang3.StringUtils;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.shared.model.fileset.FileSet;
import org.apache.maven.shared.model.fileset.util.FileSetManager;
import org.web3j.abi.datatypes.Address;
import org.web3j.codegen.SolidityFunctionWrapper;
import org.web3j.mavenplugin.solidity.CompilerResult;
import org.web3j.mavenplugin.solidity.SolidityCompiler;
import org.web3j.mavenplugin.solidity.VersionMismatchException;
import org.web3j.protocol.ObjectMapperFactory;
import org.web3j.protocol.core.methods.response.AbiDefinition;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Maven Plugin to generate the java classes out of the solidity contract files.
 */
@Mojo(name = "generate-sources",
        defaultPhase = LifecyclePhase.PROCESS_RESOURCES)
public class JavaClassGeneratorMojo extends AbstractMojo {

    private static final String DEFAULT_INCLUDE = "**/*.sol";
    private static final String DEFAULT_PACKAGE = "org.web3j.model";
    private static final String DEFAULT_SOURCE_DESTINATION = "src/main/java";
    private static final String DEFAULT_SOLIDITY_SOURCES = "src/main/resources";
    private static final String DEFAULT_OUTPUT_FORMAT = "java";

    @Parameter(property = "packageName", defaultValue = DEFAULT_PACKAGE)
    protected String packageName;

    @Parameter(property = "sourceDestination", defaultValue = DEFAULT_SOURCE_DESTINATION)
    protected String sourceDestination;

    @Parameter(property = "outputDirectory")
    protected SourceDestination outputDirectory = new SourceDestination();

    @Parameter(property = "soliditySourceFiles")
    protected FileSet soliditySourceFiles = new FileSet();

    @Parameter(property = "contract")
    protected Contract contract;

    @Parameter(property = "nativeJavaType", defaultValue = "true")
    protected boolean nativeJavaType;

    @Parameter(property = "pathPrefixes")
    protected String[] pathPrefixes = new String[0];

    @Parameter(property = "outputFormat", defaultValue = DEFAULT_OUTPUT_FORMAT)
    protected String outputFormat;

    private Path createPath(String destinationPath) throws IOException {
        Path path = Paths.get(destinationPath, packageName);

        if (!path.toFile().exists()) {
            Files.createDirectories(path);
        }
        return path;
    }

    private Map<String, Map<String, String>> extractContracts(String result) throws MojoExecutionException {
        JsonParser jsonParser = new JsonParser();
        Map<String, Object> json = jsonParser.parseJson(result);
        Map<String, Map<String, String>> contracts = (Map<String, Map<String, String>>) json.get("contracts");
        if (contracts == null) {
            getLog().warn("no contracts found");
            return null;
        }
        Map<String, String> contractRemap = new HashMap<>();

        HashSet<String> contractsKeys = new HashSet<>(contracts.keySet());
        for (String contractFilename : contractsKeys) {
            Map<String, String> contractMetadata = contracts.get(contractFilename);
            String metadata = contractMetadata.get("metadata");
            if (metadata == null || metadata.length() == 0) {
                contracts.remove(contractFilename);
                continue;
            }
            getLog().debug("metadata:" + metadata);
            Map<String, Object> metadataJson = jsonParser.parseJson(metadata);
            Object settingsMap = metadataJson.get("settings");
            // FIXME this generates java files for interfaces with >org.ethereum:solcJ-all:0.5.2 , because the compiler generates now metadata.
            if (settingsMap != null) {
                Map<String, String> compilationTarget = ((Map<String, Map<String, String>>) settingsMap).get("compilationTarget");
                if (compilationTarget != null) {
                    for (Map.Entry<String, String> entry : compilationTarget.entrySet()) {
                        String value = entry.getValue();
                        contractRemap.put(contractFilename, value);
                    }
                }
            }
            Map<String, String> compiledContract = contracts.remove(contractFilename);
            String contractName = contractRemap.get(contractFilename);
            contracts.put(contractName, compiledContract);
        }
        return contracts;
    }

    private void generatedJavaClass(Map<String, String> results, String contractName) throws IOException, ClassNotFoundException {
        if (!StringUtils.containsIgnoreCase(outputFormat, "java")) {
            return;
        }

        int addressLength = Address.DEFAULT_LENGTH / Byte.SIZE;
        boolean primitiveTypes = false;

        List<AbiDefinition> functionDefinitions = loadContractDefinition(results.get(SolidityCompiler.Options.ABI.getName()));


        if (functionDefinitions.isEmpty()) {
            getLog().warn("Unable to parse input ABI file");
            return;
        }

        new SolidityFunctionWrapper(
                nativeJavaType,
                primitiveTypes,
                false, //generateSendTxForCalls
                addressLength)
                .generateJavaFiles(
                        org.web3j.tx.Contract.class,
                        contractName,
                        results.get(SolidityCompiler.Options.BIN.getName()),
                        functionDefinitions,
                        StringUtils.defaultString(outputDirectory.getJava(), sourceDestination),
                        packageName,
                        null

                );
    }

    private void processContractFile(Collection<String> files) throws MojoExecutionException {
        String result = parseSoliditySources(files);
        processResult(result, "\tNo Contract found in files '" + files + "'");
    }

    public void execute() throws MojoExecutionException {

        if (soliditySourceFiles.getDirectory() == null) {
            getLog().info("No solidity directory specified, using default directory [" + DEFAULT_SOLIDITY_SOURCES + "]");
            soliditySourceFiles.setDirectory(DEFAULT_SOLIDITY_SOURCES);
        }
        if (soliditySourceFiles.getIncludes().isEmpty()) {
            getLog().info("No solidity contracts specified, using the default [" + DEFAULT_INCLUDE + "]");
            soliditySourceFiles.setIncludes(Collections.singletonList(DEFAULT_INCLUDE));
        }

        String[] files = new FileSetManager().getIncludedFiles(soliditySourceFiles);
        if (files != null) {
            processContractFile(Stream.of(files)
                    .filter(f -> {
                        getLog().info("Adding to process '" + f + "'");
                        return true;
                    })
                    .collect(Collectors.toList()));
        }
    }

    private void generatedAbi(Map<String, String> contractResult, String contractName) {
        if (!StringUtils.containsIgnoreCase(outputFormat, "abi")) {
            return;
        }

        String abiJson = contractResult.get(SolidityCompiler.Options.ABI.getName());
        try {
            String filename = contractName + ".json";
            Path path = createPath(StringUtils.defaultString(outputDirectory.getAbi(), sourceDestination));
            Files.write(Paths.get(path.toString(), filename), abiJson.getBytes());
        } catch (IOException e) {
            getLog().error("Could not build abi file for contract '" + contractName + "'", e);
        }
    }

    private void generatedBin(Map<String, String> contractResult, String contractName) {
        if (!StringUtils.containsIgnoreCase(outputFormat, "bin")) {
            return;
        }

        String binJson = contractResult.get(SolidityCompiler.Options.BIN.getName());
        try {
            String filename = contractName + ".bin";
            Path path = createPath(StringUtils.defaultString(outputDirectory.getBin(), sourceDestination));

            Files.write(Paths.get(path.toString(), filename), binJson.getBytes());
        } catch (IOException e) {
            getLog().error("Could not build bin file for contract '" + contractName + "'", e);
        }
    }

    protected List<AbiDefinition> loadContractDefinition(String absFile) throws IOException {
        ObjectMapper objectMapper = ObjectMapperFactory.getObjectMapper();
        AbiDefinition[] abiDefinition = objectMapper.readValue(absFile, AbiDefinition[].class);
        return Arrays.asList(abiDefinition);
    }

    private String parseSoliditySources(Collection<String> includedFiles) throws MojoExecutionException {
        if (includedFiles.isEmpty()) {
            return "{}";
        }
        CompilerResult result = SolidityCompiler.getInstance(getLog()).compileSrc(
                soliditySourceFiles.getDirectory(),
                includedFiles,
                pathPrefixes,
                SolidityCompiler.Options.ABI,
                SolidityCompiler.Options.BIN,
                SolidityCompiler.Options.INTERFACE,
                SolidityCompiler.Options.METADATA
        );
        if (result.isFailed()) {
            if (result.errors.contains("Source file requires different compiler version")) {
                throw new VersionMismatchException(SolidityCompiler.getInstance(getLog()).getUsedSolCVersion(), result.errors);
            }
            throw new MojoExecutionException("Could not compile solidity files\n" + result.errors);
        }

        getLog().debug("\t\tResult:\t" + result.output);
        if (result.errors.contains("Warning:")) {
            getLog().info("\tCompile Warning:\n" + result.errors);
        } else {
            getLog().debug("\t\tError: \t" + result.errors);
        }
        return result.output;
    }

    private void processResult(String result, String warnMsg) throws MojoExecutionException {
        Map<String, Map<String, String>> contracts = extractContracts(result);
        if (contracts == null) {
            getLog().warn(warnMsg);
            return;
        }
        for (Map.Entry<String, Map<String, String>> entry : contracts.entrySet()) {
            String contractName = entry.getKey();
            if (isFiltered(contractName)) {
                getLog().debug("\tContract '" + contractName + "' is filtered");
                continue;
            }
            try {
                Map<String, String> contractResult = entry.getValue();
                generatedJavaClass(contractResult, contractName);
                generatedAbi(contractResult, contractName);
                generatedBin(contractResult, contractName);
                getLog().info("\tBuilt Class for contract '" + contractName + "'");
            } catch (ClassNotFoundException | IOException ioException) {
                getLog().error("Could not build java class for contract '" + contractName + "'", ioException);
            }
        }
    }

    private boolean isFiltered(String contractName) {
        if (contract == null) {
            return false;
        }

        if (contract.getExcludes() != null && !contract.getExcludes().isEmpty()) {
            return contract.getExcludes().contains(contractName);
        }

        if (contract.getIncludes() == null || contract.getIncludes().isEmpty()) {
            return false;
        } else {
            return !contract.getIncludes().contains(contractName);
        }
    }
}