package org.arquillian.cube.docker.impl.client.containerobject;

import java.io.File;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;

import com.github.dockerjava.api.exception.NotFoundException;
import org.apache.commons.lang3.ArrayUtils;
import org.arquillian.cube.ContainerObjectConfiguration;
import org.arquillian.cube.CubeController;
import org.arquillian.cube.CubeIp;
import org.arquillian.cube.HostPort;
import org.arquillian.cube.containerobject.Cube;
import org.arquillian.cube.containerobject.CubeDockerFile;
import org.arquillian.cube.containerobject.Environment;
import org.arquillian.cube.containerobject.Image;
import org.arquillian.cube.containerobject.Volume;
import org.arquillian.cube.docker.impl.await.PollingAwaitStrategy;
import org.arquillian.cube.docker.impl.client.config.Await;
import org.arquillian.cube.docker.impl.client.config.BuildImage;
import org.arquillian.cube.docker.impl.client.config.CubeContainer;
import org.arquillian.cube.docker.impl.client.config.Link;
import org.arquillian.cube.docker.impl.client.config.PortBinding;
import org.arquillian.cube.docker.impl.docker.DockerClientExecutor;
import org.arquillian.cube.docker.impl.model.DockerCube;
import org.arquillian.cube.docker.impl.util.ContainerObjectUtil;
import org.arquillian.cube.docker.impl.util.DockerFileUtil;
import org.arquillian.cube.impl.client.enricher.CubeIpTestEnricher;
import org.arquillian.cube.impl.client.enricher.HostPortTestEnricher;
import org.arquillian.cube.impl.util.ReflectionUtil;
import org.arquillian.cube.spi.CubeRegistry;
import org.arquillian.cube.spi.metadata.HasPortBindings;
import org.arquillian.cube.spi.metadata.IsContainerObject;
import org.jboss.arquillian.test.spi.TestEnricher;
import org.jboss.shrinkwrap.api.Archive;
import org.jboss.shrinkwrap.api.exporter.ExplodedExporter;

/**
 * Instantiate container objects. This class is not thread safe.
 *
 * @author <a href="mailto:rivasdiaz@gmail.com">Ramon Rivas</a>
 * @see DockerContainerObjectFactory
 */
public class DockerContainerObjectBuilder<T> {

    public static final String TEMPORARY_FOLDER_PREFIX = "arquilliancube_";
    public static final String TEMPORARY_FOLDER_SUFFIX = ".build";
    private static final Logger logger = Logger.getLogger(DockerContainerObjectBuilder.class.getName());
    // parameters received
    private final DockerClientExecutor dockerClientExecutor;
    private final CubeController cubeController;
    private final CubeRegistry cubeRegistry;
    private Class<T> containerObjectClass;
    private Object containerObjectContainer;
    private CubeContainer providedConfiguration;
    private Collection<TestEnricher> enrichers = Collections.emptyList();
    private Consumer<DockerCube> cubeCreatedCallback;

    // temporary variables in the process of building the container and associated cube
    private boolean classHasMethodWithCubeDockerFile;
    private boolean classDefinesCubeDockerFile;
    private boolean classDefinesImage;
    private Method methodWithCubeDockerFile;
    private CubeDockerFile cubeDockerFileAnnotation;
    private Image cubeImageAnnotation;
    private String containerName;
    private File dockerfileLocation;
    private CubeContainer generatedConfigutation, mergedConfiguration;

    // results of the building
    private T containerObjectInstance;
    private DockerCube dockerCube;

    public DockerContainerObjectBuilder(DockerClientExecutor dockerClientExecutor, CubeController cubeController,
                                        CubeRegistry cubeRegistry) {
        this.dockerClientExecutor = dockerClientExecutor;
        this.cubeController = cubeController;
        this.cubeRegistry = cubeRegistry;
    }

    private static String findEnrichedValueForFieldWithCubeIpAnnotation(CubeIp cubeIp, HasPortBindings portBindings) {
        final boolean cubeIpInternal = cubeIp.internal();
        final String cubeIpValue = (cubeIpInternal) ? portBindings.getInternalIP() : portBindings.getContainerIP();
        return cubeIpValue;
    }

    private static int findEnrichedValueForFieldWithHostPortAnnotation(HostPort hostPort, HasPortBindings portBindings) {
        int hostPortValue = hostPort.value();
        if (hostPortValue == 0) {
            throw new IllegalArgumentException(
                String.format("%s annotation does not specify any exposed port", HostPort.class.getSimpleName()));
        }
        final HasPortBindings.PortAddress bindingForExposedPort = portBindings.getMappedAddress(hostPortValue);
        if (bindingForExposedPort == null) {
            throw new IllegalArgumentException(
                String.format("exposed port %s is not exposed on container object.", hostPortValue));
        }
        return bindingForExposedPort.getPort();
    }

    private static File createTemporalDirectoryForCopyingDockerfile(String containerName) throws IOException {
        File dir = File.createTempFile(TEMPORARY_FOLDER_PREFIX + containerName, TEMPORARY_FOLDER_SUFFIX);
        dir.delete();
        if (!dir.mkdirs()) {
            throw new IllegalArgumentException("Temp Dir for storing Dockerfile contents could not be created.");
        }
        dir.deleteOnExit();
        return dir;
    }

    private static Link linkFromCubeAnnotatedField(Field cubeField) {
        final String linkName = linkNameFromCubeAnnotatedField(cubeField);
        return Link.valueOf(linkName);
    }

    private static String linkNameFromCubeAnnotatedField(Field cubeField) {
        if (cubeField.isAnnotationPresent(org.arquillian.cube.containerobject.Link.class)) {
            return cubeField.getAnnotation(org.arquillian.cube.containerobject.Link.class).value();
        }

        final String cubeName = cubeNameFromCubeAnnotatedField(cubeField);
        return cubeName + ":" + cubeName;
    }

    private static String cubeNameFromCubeAnnotatedField(Field cubeField) {
        final org.arquillian.cube.containerobject.Cube cubeAnnotationOnField =
            cubeField.getAnnotation(org.arquillian.cube.containerobject.Cube.class);
        final Class<?> containerObjectClassFromField = cubeField.getType();
        String cubeName = null;
        final String cubeAnnotationOnFieldValue = cubeAnnotationOnField.value();
        if (cubeAnnotationOnFieldValue != null && !Cube.DEFAULT_VALUE.equals(cubeAnnotationOnFieldValue)) {
            // We have found a valid cube name
            return cubeAnnotationOnFieldValue;
        }
        // Needs to check if container object or one of his parents contains a Cube
        final String cubeAnnotationOnClassValue =
            ContainerObjectUtil.getTopCubeAttribute(containerObjectClassFromField, "value", Cube.class,
                Cube.DEFAULT_VALUE);
        if (cubeAnnotationOnClassValue != null && !Cube.DEFAULT_VALUE.equals(cubeAnnotationOnClassValue)) {
            //We got the cubeName in containerobject
            return cubeAnnotationOnClassValue;
        }

        //No override so we need to use the default logic
        return containerObjectClassFromField.getSimpleName();
    }

    /**
     * Specifies an optional object that has a strong reference to the object being created. If set, a reference to it
     * is stored as part of the metadata of the container object. This object is expected to control the lifecycle of
     * the container object
     *
     * @param containerObjectContainer the container object's container
     * @return the current builder instance
     * @see IsContainerObject
     */
    public DockerContainerObjectBuilder<T> withContainerObjectContainer(Object containerObjectContainer) {
        this.containerObjectContainer = containerObjectContainer;
        return this;
    }

    /**
     * Specifies the container object class to be instantiated
     *
     * @param containerObjectClass container object class to be instantiated
     * @return the current builder instance
     */
    public DockerContainerObjectBuilder<T> withContainerObjectClass(Class<T> containerObjectClass) {
        if (containerObjectClass == null) {
            throw new IllegalArgumentException("container object class cannot be null");
        }
        this.containerObjectClass = containerObjectClass;

        //First we check if this ContainerObject is defining a @CubeDockerFile in static method
        final List<Method> methodsWithCubeDockerFile =
            ReflectionUtil.getMethodsWithAnnotation(containerObjectClass, CubeDockerFile.class);

        if (methodsWithCubeDockerFile.size() > 1) {
            throw new IllegalArgumentException(
                String.format(
                    "More than one %s annotation found and only one was expected. Methods where annotation was found are: %s",
                    CubeDockerFile.class.getSimpleName(), methodsWithCubeDockerFile));
        }

        classHasMethodWithCubeDockerFile = !methodsWithCubeDockerFile.isEmpty();
        classDefinesCubeDockerFile = containerObjectClass.isAnnotationPresent(CubeDockerFile.class);
        classDefinesImage = containerObjectClass.isAnnotationPresent(Image.class);

        if (classHasMethodWithCubeDockerFile) {
            methodWithCubeDockerFile = methodsWithCubeDockerFile.get(0);
            boolean isMethodStatic = Modifier.isStatic(methodWithCubeDockerFile.getModifiers());
            boolean methodHasNoArguments = methodWithCubeDockerFile.getParameterCount() == 0;
            boolean methodReturnsAnArchive = Archive.class.isAssignableFrom(methodWithCubeDockerFile.getReturnType());
            if (!isMethodStatic || !methodHasNoArguments || !methodReturnsAnArchive) {
                throw new IllegalArgumentException(
                    String.format("Method %s annotated with %s is expected to be static, no args and return %s.",
                        methodWithCubeDockerFile, CubeDockerFile.class.getSimpleName(), Archive.class.getSimpleName()));
            }
        }

        // User has defined @CubeDockerfile on the class and a method
        if (classHasMethodWithCubeDockerFile && classDefinesCubeDockerFile) {
            throw new IllegalArgumentException(
                String.format(
                    "More than one %s annotation found and only one was expected. Both class and method %s has the annotation.",
                    CubeDockerFile.class.getSimpleName(), methodWithCubeDockerFile));
        }

        // User has defined @CubeDockerfile and @Image
        if ((classHasMethodWithCubeDockerFile || classDefinesCubeDockerFile) && classDefinesImage) {
            throw new IllegalArgumentException(
                String.format("Container Object %s has defined %s annotation and %s annotation together.",
                    containerObjectClass.getSimpleName(), Image.class.getSimpleName(),
                    CubeDockerFile.class.getSimpleName()));
        }

        // User has not defined either @CubeDockerfile or @Image
        if (!classDefinesCubeDockerFile && !classDefinesImage && !classHasMethodWithCubeDockerFile) {
            throw new IllegalArgumentException(
                String.format("Container Object %s is not annotated with either %s or %s annotations.",
                    containerObjectClass.getName(), CubeDockerFile.class.getSimpleName(), Image.class.getSimpleName()));
        }

        return this;
    }

    /**
     * Specifies a configuration (can be partial) to be used to override the default configuration set using annotations
     * on the container object. The received configuration will be merged with the configuration extracted from the
     * container object, and the resulting configuration will be used to build the docker container.
     * <p>
     * Currently only supports instances of {@link CubeContainerObjectConfiguration}
     *
     * @param configuration partial configuration to override default container object cube configuration
     * @return the current builder instance
     * @see CubeContainerObjectConfiguration
     * @see CubeContainer
     */
    public DockerContainerObjectBuilder<T> withContainerObjectConfiguration(ContainerObjectConfiguration configuration) {
        if (configuration == null) {
            throw new IllegalArgumentException("configuration cannot be null");
        }
        if (configuration != null && !(configuration instanceof CubeContainerObjectConfiguration)) {
            throw new IllegalArgumentException(
                String.format("container object configuration received of type %s, but only %s is supported",
                    configuration.getClass().getSimpleName(), CubeContainerObjectConfiguration.class.getSimpleName()));
        }
        this.providedConfiguration =
            configuration != null ? ((CubeContainerObjectConfiguration) configuration).getCubeContainerConfiguration()
                : null;

        return this;
    }

    /**
     * Specifies the list of enrichers that will be used to enrich the container object.
     *
     * @param enrichers list of enrichers that will be used to enrich the container object
     * @return the current builder instance
     */
    public DockerContainerObjectBuilder<T> withEnrichers(Collection<TestEnricher> enrichers) {
        if (enrichers == null) {
            throw new IllegalArgumentException("enrichers cannot be null");
        }
        this.enrichers = enrichers;
        return this;
    }

    /**
     * Specifies a consumer that will be executed after the cube object is created and after cube is created or started
     * by the cube controller. Callers must use this callback to register anything necesary for the controller to work
     * and also if they want to keep an instance of the created cube.
     *
     * @param cubeCreatedCallback consumer that will be called when the cube instance is created
     * @return the current builder instance
     */
    public DockerContainerObjectBuilder<T> onCubeCreated(Consumer<DockerCube> cubeCreatedCallback) {
        this.cubeCreatedCallback = cubeCreatedCallback;
        return this;
    }

    /**
     * Triggers the building process, builds, creates and starts the docker container associated with the requested
     * container object, creates the container object and returns it
     *
     * @return the created container object
     * @throws IllegalAccessException    if there is an error accessing the container object fields
     * @throws IOException               if there is an I/O error while preparing the docker build
     * @throws InvocationTargetException if there is an error while calling the DockerFile archive creation
     */
    public T build() throws IllegalAccessException, IOException, InvocationTargetException {
        generatedConfigutation = new CubeContainer();

        findContainerName();
        // if needed, prepare prepare resources required to build a docker image
        prepareImageBuild();
        // instantiate container object
        instantiateContainerObject();
        // enrich container object (without cube instance)
        enrichContainerObjectBeforeCube();
        // extract configuration from container object class
        extractConfigurationFromContainerObject();
        // merge received configuration with extracted configuration
        mergeContainerObjectConfiguration();
        // create/start/register associated cube
        initializeCube();
        // enrich container object (with cube instance)
        enrichContainerObjectWithCube();
        // return created container object
        return containerObjectInstance;
    }

    private void findContainerName() {
        // container name
        if (providedConfiguration != null) {
            final String providedContainerName = providedConfiguration.getContainerName();
            if (providedContainerName != null && !providedContainerName.isEmpty()) {
                containerName = providedConfiguration.getContainerName();
            }
        }
        if (containerName == null) {
            final String cubeValue =
                ContainerObjectUtil.getTopCubeAttribute(containerObjectClass, "value", Cube.class, Cube.DEFAULT_VALUE);
            if (cubeValue != null && !Cube.DEFAULT_VALUE.equals(cubeValue)) {
                containerName = cubeValue;
            }
        }
        if (containerName == null) {
            containerName = containerObjectClass.getSimpleName();
        }
        generatedConfigutation.setContainerName(containerName);
    }

    private void prepareImageBuild() throws InvocationTargetException, IllegalAccessException, IOException {

        // @CubeDockerfile is defined as static method
        if (classHasMethodWithCubeDockerFile) {
            cubeDockerFileAnnotation = methodWithCubeDockerFile.getAnnotation(CubeDockerFile.class);
            final Archive<?> archive = (Archive<?>) methodWithCubeDockerFile.invoke(null, new Object[0]);
            File output = createTemporalDirectoryForCopyingDockerfile(containerName);
            logger.finer(String.format("Created %s directory for storing contents of %s cube.", output, containerName));

            archive.as(ExplodedExporter.class).exportExplodedInto(output);
            dockerfileLocation = output;
        } else if (classDefinesCubeDockerFile) {
            cubeDockerFileAnnotation = containerObjectClass.getAnnotation(CubeDockerFile.class);

            //Copy Dockerfile and all contains of the same directory in a known directory.
            File output = createTemporalDirectoryForCopyingDockerfile(containerName);
            logger.finer(String.format("Created %s directory for storing contents of %s cube.", output, containerName));

            DockerFileUtil.copyDockerfileDirectory(containerObjectClass, cubeDockerFileAnnotation, output);
            dockerfileLocation = output;
        } else if (classDefinesImage) {
            cubeImageAnnotation = containerObjectClass.getAnnotation(Image.class);
        }
    }

    private void instantiateContainerObject() {
        containerObjectInstance =
            ReflectionUtil.newInstance(containerObjectClass.getName(), new Class[0], new Class[0], containerObjectClass);
    }

    private void enrichContainerObjectBeforeCube() {
        for (TestEnricher enricher : enrichers) {
            boolean requiresDockerInstanceCreated =
                enricher instanceof HostPortTestEnricher || enricher instanceof CubeIpTestEnricher;

            if (!requiresDockerInstanceCreated) {
                enricher.enrich(containerObjectInstance);
            }
        }
    }

    private void extractConfigurationFromContainerObject() {
        // this method will focus on extracting the configuration from the container object class
        // most probably, the caller will also try to pass some configuration from the current instantiation point
        // (for example, annotations on a field)
        // received configuration overrides container object configuration, so in some cases the extraction is skipped

        // port bindings
        if (providedConfiguration == null || providedConfiguration.getPortBindings() == null) {
            final String[] portBindingsFromAnnotation =
                ContainerObjectUtil.getTopCubeAttribute(containerObjectClass, "portBinding", Cube.class,
                    Cube.DEFAULT_PORT_BINDING);

            if (portBindingsFromAnnotation != null && !Arrays.equals(portBindingsFromAnnotation,
                Cube.DEFAULT_PORT_BINDING)) {
                List<PortBinding> portBindings = Arrays.stream(portBindingsFromAnnotation)
                    .map(PortBinding::valueOf)
                    .collect(Collectors.toList());
                generatedConfigutation.setPortBindings(portBindings);
            }
        }
        // await
        if (providedConfiguration == null || providedConfiguration.getAwait() == null) {
            final int[] awaitPortsFromAnnotation =
                ContainerObjectUtil.getTopCubeAttribute(containerObjectClass, "awaitPorts", Cube.class,
                    Cube.DEFAULT_AWAIT_PORT_BINDING);

            if (awaitPortsFromAnnotation != null && !Arrays.equals(awaitPortsFromAnnotation,
                Cube.DEFAULT_AWAIT_PORT_BINDING)) {
                final Await await = new Await();
                await.setStrategy(PollingAwaitStrategy.TAG);
                await.setPorts(Arrays.asList(ArrayUtils.toObject(awaitPortsFromAnnotation)));
                generatedConfigutation.setAwait(await);
            }
        }

        // environment variables
        // merged instead of overridden
        if (true) {
            List<String> environmentVariables =
                ContainerObjectUtil.getAllAnnotations(containerObjectClass, Environment.class)
                    .stream()
                    .map(environment -> environment.key() + "=" + environment.value())
                    .collect(Collectors.toList());
            generatedConfigutation.setEnv(environmentVariables);
        }

        // volumes
        // merged instead of overridden
        if (true) {
            List<String> volumeBindings = ContainerObjectUtil.getAllAnnotations(containerObjectClass, Volume.class)
                .stream()
                .map(volume -> volume.hostPath() + ":" + volume.containerPath() + ":Z")
                .collect(Collectors.toList());
            generatedConfigutation.setBinds(volumeBindings);
        }

        // links
        if (providedConfiguration == null || providedConfiguration.getLinks() == null) {
            List<Link> links = ReflectionUtil.getFieldsWithAnnotation(containerObjectClass, Cube.class)
                .stream()
                .map(DockerContainerObjectBuilder::linkFromCubeAnnotatedField)
                .collect(Collectors.toList());
            generatedConfigutation.setLinks(links);
        }

        // image
        if (classDefinesCubeDockerFile || classHasMethodWithCubeDockerFile) {
            BuildImage dockerfileConfiguration = new BuildImage(
                dockerfileLocation.getAbsolutePath(),
                null,
                cubeDockerFileAnnotation.nocache(),
                cubeDockerFileAnnotation.remove());
            generatedConfigutation.setBuildImage(dockerfileConfiguration);
        } else {
            generatedConfigutation.setImage(
                org.arquillian.cube.docker.impl.client.config.Image.valueOf(cubeImageAnnotation.value()));
        }
    }

    private void mergeContainerObjectConfiguration() {
        mergedConfiguration = new CubeContainer();
        if (providedConfiguration != null) {
            mergedConfiguration.merge(providedConfiguration);
        }
        mergedConfiguration.merge(generatedConfigutation);
        // TODO if both provided and generated configurations have environment variables or volumes, they must be merged instead.
        // TODO should this be handled in CubeContainer::merge() instead?
        if (providedConfiguration != null) {
            // environment variables
            if (providedConfiguration.getEnv() != null && generatedConfigutation.getEnv() != null) {
                Collection<String> env = new ArrayList<>();
                env.addAll(mergedConfiguration.getEnv());
                env.addAll(generatedConfigutation.getEnv());
                mergedConfiguration.setEnv(env);
            }
            // volumes
            if (providedConfiguration.getBinds() != null && generatedConfigutation.getBinds() != null) {
                Collection<String> binds = new ArrayList<>();
                binds.addAll(mergedConfiguration.getBinds());
                binds.addAll(generatedConfigutation.getBinds());
                mergedConfiguration.setBinds(binds);
            }
        }
    }

    private void initializeCube() {

        if (isNotInitialized()) {

            dockerCube = new DockerCube(containerName, mergedConfiguration, dockerClientExecutor);
            Class<?> containerObjectContainerClass =
                containerObjectContainer != null ? containerObjectContainer.getClass() : null;
            dockerCube.addMetadata(IsContainerObject.class, new IsContainerObject(containerObjectContainerClass));
            logger.finer(String.format("Created Cube with name %s and configuration %s", containerName,
                dockerCube.configuration()));
            if (cubeCreatedCallback != null) {
                cubeCreatedCallback.accept(dockerCube);
            }
            cubeController.create(containerName);
            cubeController.start(containerName);
            logger.log(Level.INFO, "Started container: " + getContainerInfo(containerName));
        }
    }

    private String getContainerInfo(final String containerName) {
        try {
            final String detail = dockerClientExecutor.getDockerClient().inspectContainerCmd(containerName).exec().toString();
            return null != detail ? detail : containerName;
        } catch (Exception e) {
            return containerName;
        }
    }

    private boolean isNotInitialized() {
        return cubeRegistry.getCube(containerName) == null;
    }

    private void enrichContainerObjectWithCube() throws IllegalAccessException {
        enrichAnnotatedPortBuildingFields(CubeIp.class,
            DockerContainerObjectBuilder::findEnrichedValueForFieldWithCubeIpAnnotation);
        enrichAnnotatedPortBuildingFields(HostPort.class,
            DockerContainerObjectBuilder::findEnrichedValueForFieldWithHostPortAnnotation);
    }

    private <T extends Annotation> void enrichAnnotatedPortBuildingFields(Class<T> annotationType,
                                                                          BiFunction<T, HasPortBindings, ?> fieldEnricher) throws IllegalAccessException {
        final List<Field> annotatedFields = ReflectionUtil.getFieldsWithAnnotation(containerObjectClass, annotationType);
        if (annotatedFields.isEmpty()) return;

        final HasPortBindings portBindings = dockerCube.getMetadata(HasPortBindings.class);
        if (portBindings == null) {
            throw new IllegalArgumentException(String.format(
                "Container Object %s contains fields annotated with %s but no ports are exposed by the container",
                containerObjectClass.getSimpleName(), annotationType.getSimpleName()));
        }

        for (Field annotatedField : annotatedFields) {
            final T annotation = annotatedField.getAnnotation(annotationType);
            try {
                annotatedField.set(containerObjectInstance, fieldEnricher.apply(annotation, portBindings));
            } catch (IllegalArgumentException ex) {
                throw new IllegalArgumentException(
                    String.format("Container Object %s contains field %s annotated with %s, with error: %s",
                        containerObjectClass.getSimpleName(), annotatedField.getName(), annotationType.getSimpleName(),
                        ex.getLocalizedMessage()),
                    ex);
            }
        }
    }
}
