/*
 * (c) Copyright 2019 Palantir Technologies Inc. All rights reserved.
 *
 * 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.palantir.docker.compose;

import com.google.common.base.Throwables;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.palantir.docker.compose.EventEmitter.InterruptableClusterWait;
import com.palantir.docker.compose.configuration.DockerComposeFiles;
import com.palantir.docker.compose.configuration.ProjectName;
import com.palantir.docker.compose.configuration.ShutdownStrategy;
import com.palantir.docker.compose.connection.Cluster;
import com.palantir.docker.compose.connection.Container;
import com.palantir.docker.compose.connection.ContainerCache;
import com.palantir.docker.compose.connection.DockerMachine;
import com.palantir.docker.compose.connection.DockerPort;
import com.palantir.docker.compose.connection.ImmutableCluster;
import com.palantir.docker.compose.connection.waiting.ClusterHealthCheck;
import com.palantir.docker.compose.connection.waiting.ClusterWait;
import com.palantir.docker.compose.connection.waiting.HealthCheck;
import com.palantir.docker.compose.events.EventConsumer;
import com.palantir.docker.compose.execution.ConflictingContainerRemovingDockerCompose;
import com.palantir.docker.compose.execution.DefaultDockerCompose;
import com.palantir.docker.compose.execution.Docker;
import com.palantir.docker.compose.execution.DockerComposeExecArgument;
import com.palantir.docker.compose.execution.DockerComposeExecOption;
import com.palantir.docker.compose.execution.DockerComposeExecutable;
import com.palantir.docker.compose.execution.DockerComposeRunArgument;
import com.palantir.docker.compose.execution.DockerComposeRunOption;
import com.palantir.docker.compose.execution.DockerExecutable;
import com.palantir.docker.compose.execution.RetryingDockerCompose;
import com.palantir.docker.compose.logging.DoNothingLogCollector;
import com.palantir.docker.compose.logging.FileLogCollector;
import com.palantir.docker.compose.logging.LogCollector;
import com.palantir.docker.compose.logging.LogDirectory;
import com.palantir.docker.compose.report.TestDescription;
import com.palantir.docker.compose.reporting.RunRecorder;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.immutables.value.Value;
import org.joda.time.Duration;
import org.joda.time.ReadableDuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Value.Immutable
@CustomImmutablesStyle
@SuppressWarnings("DesignForExtension")
public abstract class DockerComposeManager {
    private static final Logger log = LoggerFactory.getLogger(DockerComposeManager.class);

    public static final Duration DEFAULT_TIMEOUT = Duration.standardMinutes(2);
    public static final int DEFAULT_RETRY_ATTEMPTS = 2;

    private final RunRecorder runRecorder = RunRecorder.defaults();

    public DockerPort hostNetworkedPort(int port) {
        return new DockerPort(machine().getIp(), port, port);
    }

    public abstract DockerComposeFiles files();

    protected abstract List<ClusterWait> clusterWaits();

    protected abstract List<EventConsumer> eventConsumers();

    @Value.Default
    public DockerMachine machine() {
        return DockerMachine.localMachine().build();
    }

    @Value.Default
    public ProjectName projectName() {
        return ProjectName.random();
    }

    @Value.Default
    public DockerComposeExecutable dockerComposeExecutable() {
        return DockerComposeExecutable.builder()
                .dockerComposeFiles(files())
                .dockerConfiguration(machine())
                .projectName(projectName())
                .useDockerComposeV2(useDockerComposeV2())
                .build();
    }

    @Value.Default
    public boolean useDockerComposeV2() {
        return true;
    }

    @Value.Default
    public DockerExecutable dockerExecutable() {
        return DockerExecutable.builder().dockerConfiguration(machine()).build();
    }

    @Value.Default
    public Docker docker() {
        return new Docker(dockerExecutable());
    }

    @Value.Default
    public ShutdownStrategy shutdownStrategy() {
        return ShutdownStrategy.KILL_DOWN;
    }

    @Value.Default
    public com.palantir.docker.compose.execution.DockerCompose dockerCompose() {
        com.palantir.docker.compose.execution.DockerCompose dockerCompose =
                new DefaultDockerCompose(dockerComposeExecutable(), machine());
        return new RetryingDockerCompose(retryAttempts(), dockerCompose);
    }

    @Value.Default
    public Cluster containers() {
        return ImmutableCluster.builder()
                .ip(machine().getIp())
                .containerCache(new ContainerCache(docker(), dockerCompose()))
                .build();
    }

    @Value.Default
    protected int retryAttempts() {
        return DEFAULT_RETRY_ATTEMPTS;
    }

    @Value.Default
    protected boolean removeConflictingContainersOnStartup() {
        return true;
    }

    @Value.Default
    protected boolean pullOnStartup() {
        return false;
    }

    @Value.Default
    protected ReadableDuration nativeServiceHealthCheckTimeout() {
        return DEFAULT_TIMEOUT;
    }

    @Value.Default
    protected LogCollector logCollector() {
        return new DoNothingLogCollector();
    }

    @Value.Derived
    protected EventEmitter emitEventsFor() {
        List<EventConsumer> eventConsumers =
                Stream.concat(Stream.of(runRecorder), eventConsumers().stream()).collect(Collectors.toList());

        return new EventEmitter(eventConsumers);
    }

    protected void setDescription(TestDescription testDescription) {
        runRecorder.setDescription(testDescription);
    }

    public void before() throws IOException, InterruptedException {
        log.debug("Starting docker-compose cluster");

        runRecorder.before(() -> dockerCompose().config());

        pullBuildAndUp();

        emitEventsFor().waitingForServices(this::waitForServices);
    }

    private void pullBuildAndUp() throws IOException, InterruptedException {
        if (pullOnStartup()) {
            emitEventsFor().pull(dockerCompose()::pull);
        }

        emitEventsFor().build(dockerCompose()::build);

        com.palantir.docker.compose.execution.DockerCompose upDockerCompose = dockerCompose();
        if (removeConflictingContainersOnStartup()) {
            upDockerCompose = new ConflictingContainerRemovingDockerCompose(upDockerCompose, docker());
        }

        emitEventsFor().up(upDockerCompose::up);
    }

    private void waitForServices() throws InterruptedException {
        log.debug("Waiting for services");
        InterruptableClusterWait nativeHealthCheckClusterWait = emitEventsFor()
                .nativeClusterWait(
                        new ClusterWait(ClusterHealthCheck.nativeHealthChecks(), nativeServiceHealthCheckTimeout()));

        List<InterruptableClusterWait> allClusterWaits = Stream.concat(
                        Stream.of(nativeHealthCheckClusterWait),
                        clusterWaits().stream().map(emitEventsFor()::userClusterWait))
                .collect(Collectors.toList());

        waitForAllClusterWaits(allClusterWaits);

        log.debug("docker-compose cluster started");
    }

    private void waitForAllClusterWaits(List<InterruptableClusterWait> allClusterWaits) throws InterruptedException {
        ListeningExecutorService executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(
                allClusterWaits.size(),
                new ThreadFactoryBuilder().setNameFormat("dcr-wait-%d").build()));

        try {
            ListenableFuture<?> listListenableFuture = Futures.allAsList(allClusterWaits.stream()
                    .map(clusterWait -> executorService.submit(() -> {
                        try {
                            clusterWait.waitForCluster(containers());
                        } catch (InterruptedException e) {
                            if (executorService.isShutdown()) {
                                // ignore if this InterruptedException has occurred because we shut down and
                                // terminated the executor
                                return;
                            }

                            Throwables.propagate(e);
                        }
                    }))
                    .collect(Collectors.toList()));

            listListenableFuture.get();
        } catch (ExecutionException e) {
            if (e.getCause() instanceof RuntimeException) {
                throw (RuntimeException) e.getCause();
            }
            throw new IllegalStateException("A cluster wait errored out: ", e);
        } finally {
            MoreExecutors.shutdownAndAwaitTermination(executorService, 0, TimeUnit.SECONDS);
        }
    }

    public void after() {
        try {
            emitEventsFor().shutdownStop(() -> shutdownStrategy().stop(this.dockerCompose()));

            emitEventsFor().logCollection(() -> logCollector().collectLogs(this.dockerCompose()));

            emitEventsFor().shutdown(() -> shutdownStrategy().shutdown(this.dockerCompose(), this.docker()));
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException("Error cleaning up docker compose cluster", e);
        } finally {
            runRecorder.after();
        }
    }

    public String exec(DockerComposeExecOption options, String containerName, DockerComposeExecArgument arguments)
            throws IOException, InterruptedException {
        return dockerCompose().exec(options, containerName, arguments);
    }

    public String run(DockerComposeRunOption options, String containerName, DockerComposeRunArgument arguments)
            throws IOException, InterruptedException {
        return dockerCompose().run(options, containerName, arguments);
    }

    public interface BuilderExtensions<TSelf extends BuilderExtensions<TSelf>> {
        TSelf files(DockerComposeFiles files);

        TSelf logCollector(LogCollector logCollector);

        TSelf shutdownStrategy(ShutdownStrategy shutdownStrategy);

        TSelf addClusterWait(ClusterWait element);

        TSelf addAllClusterWaits(Iterable<? extends ClusterWait> elements);

        default TSelf file(String dockerComposeYmlFile) {
            return files(DockerComposeFiles.from(dockerComposeYmlFile));
        }

        /**
         * Save the output of docker logs to files, stored in the <code>path</code> directory.
         *
         * See {@link LogDirectory} for some useful utilities, for example:
         * {@link LogDirectory#circleAwareLogDirectory}.
         *
         * @param path directory into which log files should be saved
         */
        default TSelf saveLogsTo(String path) {
            return logCollector(FileLogCollector.fromPath(path));
        }

        /**
         * Deprecated.
         * @deprecated Please use {@link DockerComposeManager#shutdownStrategy()} with
         * {@link ShutdownStrategy#SKIP} instead.
         */
        @Deprecated
        default TSelf skipShutdown(boolean skipShutdown) {
            if (skipShutdown) {
                return shutdownStrategy(ShutdownStrategy.SKIP);
            }

            return (TSelf) this;
        }

        default TSelf waitingForService(String serviceName, HealthCheck<Container> healthCheck) {
            return waitingForService(serviceName, healthCheck, DEFAULT_TIMEOUT);
        }

        default TSelf waitingForService(
                String serviceName, HealthCheck<Container> healthCheck, ReadableDuration timeout) {
            ClusterHealthCheck clusterHealthCheck = ClusterHealthCheck.serviceHealthCheck(serviceName, healthCheck);
            return addClusterWait(new ClusterWait(clusterHealthCheck, timeout));
        }

        default TSelf waitingForServices(List<String> services, HealthCheck<List<Container>> healthCheck) {
            return waitingForServices(services, healthCheck, DEFAULT_TIMEOUT);
        }

        default TSelf waitingForServices(
                List<String> services, HealthCheck<List<Container>> healthCheck, ReadableDuration timeout) {
            ClusterHealthCheck clusterHealthCheck = ClusterHealthCheck.serviceHealthCheck(services, healthCheck);
            return addClusterWait(new ClusterWait(clusterHealthCheck, timeout));
        }

        default TSelf waitingForHostNetworkedPort(int port, HealthCheck<DockerPort> healthCheck) {
            return waitingForHostNetworkedPort(port, healthCheck, DEFAULT_TIMEOUT);
        }

        default TSelf waitingForHostNetworkedPort(
                int port, HealthCheck<DockerPort> healthCheck, ReadableDuration timeout) {
            ClusterHealthCheck clusterHealthCheck = ClusterHealthCheck.transformingHealthCheck(
                    cluster -> new DockerPort(cluster.ip(), port, port), healthCheck);
            return addClusterWait(new ClusterWait(clusterHealthCheck, timeout));
        }

        default TSelf clusterWaits(Iterable<? extends ClusterWait> elements) {
            return addAllClusterWaits(elements);
        }
    }

    public static class Builder extends ImmutableDockerComposeManager.Builder implements BuilderExtensions<Builder> {
        @Override
        public DockerComposeManager build() {
            return super.build();
        }
    }
}
