package org.testcontainers.containers;

import com.github.dockerjava.api.command.InspectContainerResponse;
import io.tarantool.driver.exceptions.TarantoolConnectionException;
import org.testcontainers.images.builder.ImageFromDockerfile;

import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeoutException;

import static org.testcontainers.containers.PathUtils.normalizePath;

/**
 * Sets up a Tarantool Cartridge cluster and provides API for configuring it.
 * <p>
 * The container constructors accept the classpath resources relative path to the instances.yml file, which contents
 * may look like
 *
 * <pre>
 * <code>
 * testapp.router:
 *   workdir: ./tmp/db_dev/3301
 *   advertise_uri: localhost:3301
 *   http_port: 8081
 *
 * testapp.s1-master:
 *   workdir: ./tmp/db_dev/3302
 *   advertise_uri: localhost:3302
 *   http_port: 8082
 *
 * testapp.s1-replica:
 *   workdir: ./tmp/db_dev/3303
 *   advertise_uri: localhost:3303
 *   http_port: 8083
 *
 * testapp.s2-master:
 *   workdir: ./tmp/db_dev/3304
 *   advertise_uri: localhost:3304
 *   http_port: 8084
 *
 * testapp.s2-replica:
 *   workdir: ./tmp/db_dev/3305
 *   advertise_uri: localhost:3305
 *   http_port: 8085
 * </code>
 * </pre>
 * <p>
 * and the classpath resources relative path to a topology bootstrap script, which contents may look like
 *
 * <pre>
 * <code>
 * cartridge = require('cartridge')
 * replicasets = {{
 *     alias = 'app-router',
 *     roles = {'vshard-router', 'app.roles.custom', 'app.roles.api_router'},
 *     join_servers = {{uri = 'localhost:3301'}}
 * }, {
 *     alias = 's1-storage',
 *     roles = {'vshard-storage', 'app.roles.api_storage'},
 *     join_servers = {{uri = 'localhost:3302'}, {uri = 'localhost:3303'}}
 * }, {
 *     alias = 's2-storage',
 *     roles = {'vshard-storage', 'app.roles.api_storage'},
 *     join_servers = {{uri = 'localhost:3304'}, {uri = 'localhost:3305'}}
 * }}
 * return cartridge.admin_edit_topology({replicasets = replicasets})
 * </code>
 * </pre>
 * <p>
 * After the topology changes are applied, the vshard bootstrap command will be executed.
 * <p>
 * The instances.yml file will be analyzed and the ports, specified in advertise_uri options together with the ports,
 * specified in the http_port options, will be exposed.
 *
 * @author <NAME>
 */
public class TarantoolCartridgeContainer extends GenericContainer<TarantoolCartridgeContainer>
        implements TarantoolContainerOperations<TarantoolCartridgeContainer> {

    private static final String ROUTER_HOST = "localhost";
    private static final int ROUTER_PORT = 3301;
    private static final String CARTRIDGE_USERNAME = "admin";
    private static final String CARTRIDGE_PASSWORD = "<PASSWORD>";
    private static final String DOCKERFILE = "Dockerfile";
    private static final int API_PORT = 8081;
    private static final String VSHARD_BOOTSTRAP_COMMAND = "return require('cartridge').admin_bootstrap_vshard()";
    private static final String SCRIPT_RESOURCE_DIRECTORY = "";
    private static final String INSTANCE_DIR = "/app";

    private static final String ENV_TARANTOOL_VERSION = "TARANTOOL_VERSION";
    private static final String ENV_TARANTOOL_SERVER_USER = "TARANTOOL_SERVER_USER";
    private static final String ENV_TARANTOOL_SERVER_UID = "TARANTOOL_SERVER_UID";
    private static final String ENV_TARANTOOL_SERVER_GROUP = "TARANTOOL_SERVER_GROUP";
    private static final String ENV_TARANTOOL_SERVER_GID = "TARANTOOL_SERVER_GID";
    private static final String ENV_TARANTOOL_WORKDIR = "TARANTOOL_WORKDIR";
    private static final String ENV_TARANTOOL_RUNDIR = "TARANTOOL_RUNDIR";
    private static final String ENV_TARANTOOL_DATADIR = "TARANTOOL_DATADIR";
    private static final String ENV_TARANTOOL_INSTANCES_FILE = "TARANTOOL_INSTANCES_FILE";
    private final CartridgeConfigParser instanceFileParser;
    private final TarantoolContainerClientHelper clientHelper;
    private boolean useFixedPorts = false;
    private String routerHost = ROUTER_HOST;
    private int routerPort = ROUTER_PORT;
    private int apiPort = API_PORT;
    private String routerUsername = CARTRIDGE_USERNAME;
    private String routerPassword = <PASSWORD>;
    private String directoryResourcePath = SCRIPT_RESOURCE_DIRECTORY;
    private String instanceDir = INSTANCE_DIR;
    private String topologyConfigurationFile;
    private String replicasetsFileName;

    /**
     * Create a container with default image and specified instances file from the classpath resources. Assumes that
     * there is a file named Dockerfile in the project resources classpath.
     *
     * @param instancesFile             path to instances.yml, relative to the classpath resources
     * @param topologyConfigurationFile path to a topology bootstrap script, relative to the classpath resources
     */
    public TarantoolCartridgeContainer(String instancesFile, String topologyConfigurationFile) {
        this(DOCKERFILE, instancesFile, topologyConfigurationFile);
    }

    /**
     * Create a container with default image and specified instances file from the classpath resources. Assumes that
     * there is a file named Dockerfile in the project resources classpath.
     *
     * @param instancesFile             path to instances.yml, relative to the classpath resources
     * @param topologyConfigurationFile path to a topology bootstrap script, relative to the classpath resources
     * @param buildArgs                 a map of arguments that will be passed to docker ARG commands on image build.
     *                                  This values can be overridden by environment.
     */
    public TarantoolCartridgeContainer(String instancesFile, String topologyConfigurationFile,
                                       Map<String, String> buildArgs) {
        this(DOCKERFILE, "", instancesFile, topologyConfigurationFile, buildArgs);
    }

    /**
     * Create a container with default image and specified instances file from the classpath resources
     *
     * @param dockerFile                path to a Dockerfile which configures Cartridge and other necessary services
     * @param instancesFile             path to instances.yml, relative to the classpath resources
     * @param topologyConfigurationFile path to a topology bootstrap script, relative to the classpath resources
     */
    public TarantoolCartridgeContainer(String dockerFile, String instancesFile, String topologyConfigurationFile) {
        this(dockerFile, "", instancesFile, topologyConfigurationFile);
    }

    /**
     * Create a container with specified image and specified instances file from the classpath resources. By providing
     * the result Cartridge container image name, you can cache the image and avoid rebuilding on each test run (the
     * image is tagged with the provided name and not deleted after tests finishing).
     *
     * @param dockerFile                URL resource path to a Dockerfile which configures Cartridge and other necessary services
     * @param buildImageName            Specify a stable image name for the test container to prevent rebuilds
     * @param instancesFile             URL resource path to instances.yml relative in the classpath
     * @param topologyConfigurationFile URL resource path to a topology bootstrap script in the classpath
     */
    public TarantoolCartridgeContainer(String dockerFile, String buildImageName,
                                       String instancesFile, String topologyConfigurationFile) {
        this(dockerFile, buildImageName, instancesFile, topologyConfigurationFile, Collections.emptyMap());
    }

    /**
     * Create a container with specified image and specified instances file from the classpath resources. By providing
     * the result Cartridge container image name, you can cache the image and avoid rebuilding on each test run (the
     * image is tagged with the provided name and not deleted after tests finishing).
     *
     * @param dockerFile                URL resource path to a Dockerfile which configures Cartridge
     *                                  and other necessary services
     * @param buildImageName            Specify a stable image name for the test container to prevent rebuilds
     * @param instancesFile             URL resource path to instances.yml relative in the classpath
     * @param topologyConfigurationFile URL resource path to a topology bootstrap script in the classpath
     * @param buildArgs                 a map of arguments that will be passed to docker ARG commands on image build.
     *                                  This values can be overridden by environment.
     */
    public TarantoolCartridgeContainer(String dockerFile, String buildImageName, String instancesFile,
                                       String topologyConfigurationFile, final Map<String, String> buildArgs) {
        this(withArguments(buildImage(dockerFile, buildImageName), instancesFile, buildArgs),
                instancesFile, topologyConfigurationFile);
    }


    private TarantoolCartridgeContainer(Future<String> image, String instancesFile, String topologyConfigurationFile) {
        super(image);
        if (instancesFile == null || instancesFile.isEmpty()) {
            throw new IllegalArgumentException("Instance file name must not be null or empty");
        }
        if (topologyConfigurationFile == null || topologyConfigurationFile.isEmpty()) {
            throw new IllegalArgumentException("Topology configuration file must not be null or empty");
        }
        String fileType = topologyConfigurationFile.substring(topologyConfigurationFile.lastIndexOf('.') + 1);
        if (fileType.equals("lua")) {
            this.topologyConfigurationFile = topologyConfigurationFile;
        }else{
            this.replicasetsFileName = topologyConfigurationFile.substring(topologyConfigurationFile.lastIndexOf('/')+1);
        }
        this.instanceFileParser = new CartridgeConfigParser(instancesFile);
        this.clientHelper = new TarantoolContainerClientHelper(this);
    }

    private static Future<String> withArguments(ImageFromDockerfile image, String instancesFile,
                                                final Map<String, String> buildArgs) {
        if (!buildArgs.isEmpty()) {
            image.withBuildArgs(buildArgs);
        }

        for (String envVariable : Arrays.asList(
                ENV_TARANTOOL_VERSION,
                ENV_TARANTOOL_SERVER_USER,
                ENV_TARANTOOL_SERVER_UID,
                ENV_TARANTOOL_SERVER_GROUP,
                ENV_TARANTOOL_SERVER_GID,
                ENV_TARANTOOL_WORKDIR,
                ENV_TARANTOOL_RUNDIR,
                ENV_TARANTOOL_DATADIR,
                ENV_TARANTOOL_INSTANCES_FILE
        )) {
            String variableValue = System.getenv(envVariable);
            if (variableValue != null) {
                image.withBuildArg(envVariable, variableValue);
            }
        }
        return image;
    }

    private static ImageFromDockerfile buildImage(String dockerFile, String buildImageName) {
        if (buildImageName != null && !buildImageName.isEmpty()) {
            return new ImageFromDockerfile(buildImageName, false)
                    .withFileFromClasspath("Dockerfile", dockerFile);
        }
        return new ImageFromDockerfile().withFileFromClasspath("Dockerfile", dockerFile);
    }

    /**
     * Get the router host
     *
     * @return router hostname
     */
    public String getRouterHost() {
        return routerHost;
    }

    /**
     * Get the router port
     *
     * @return router mapped port
     */
    public int getRouterPort() {
        if (useFixedPorts) {
            return routerPort;
        }
        return getMappedPort(routerPort);
    }

    /**
     * Get the user name for connecting to the router
     *
     * @return a user name
     */
    public String getRouterUsername() {
        return routerUsername;
    }

    /**
     * Get the user password for connecting to the router
     *
     * @return a user password
     */
    public String getRouterPassword() {
        return routerPassword;
    }

    @Override
    public String getHost() {
        return getRouterHost();
    }

    @Override
    public int getPort() {
        return getRouterPort();
    }

    @Override
    public String getUsername() {
        return getRouterUsername();
    }

    @Override
    public String getPassword() {
        return getRouterPassword();
    }

    @Override
    public String getDirectoryBinding() {
        return directoryResourcePath;
    }

    /**
     * Specify the directory inside container that the resource directory will be mounted to. The default value is
     * "/app".
     *
     * @param instanceDir valid directory path
     * @return this container instance
     */
    public TarantoolCartridgeContainer withInstanceDir(String instanceDir) {
        checkNotRunning();
        this.instanceDir = instanceDir;
        return this;
    }

    @Override
    public String getInstanceDir() {
        return instanceDir;
    }

    /**
     * Get Cartridge router HTTP API hostname
     *
     * @return HTTP API hostname
     */
    public String getAPIHost() {
        return routerHost;
    }

    /**
     * Checks if already running and if so raises an exception to prevent too-late setters.
     */
    protected void checkNotRunning() {
        if (isRunning()) {
            throw new IllegalStateException("This option can be changed only before the container is running");
        }
    }

    /**
     * Specify the root directory of a Cartridge project relative to the resource classpath.
     * The default directory is the root resource directory.
     *
     * @param directoryResourcePath a valid directory path
     * @return this container instance
     */
    public TarantoolCartridgeContainer withDirectoryBinding(String directoryResourcePath) {
        checkNotRunning();
        URL resource = getClass().getClassLoader().getResource(directoryResourcePath);
        if (resource == null) {
            throw new IllegalArgumentException(
                    String.format("No resource path found for the specified resource %s", directoryResourcePath));
        }
        this.directoryResourcePath = normalizePath(resource.getPath());
        return this;
    }

    /**
     * Get Cartridge router HTTP API port
     *
     * @return HTTP API port
     */
    public int getAPIPort() {
        if (useFixedPorts) {
            return apiPort;
        }
        return getMappedPort(apiPort);
    }

    /**
     * Use fixed ports binding.
     * Defaults to false.
     *
     * @return HTTP API port
     */
    public TarantoolCartridgeContainer withUseFixedPorts(boolean useFixedPorts) {
        this.useFixedPorts = useFixedPorts;
        return this;
    }

    /**
     * Set Cartridge router hostname
     *
     * @param routerHost a hostname, default is "localhost"
     * @return this container instance
     */
    public TarantoolCartridgeContainer withRouterHost(String routerHost) {
        checkNotRunning();
        this.routerHost = routerHost;
        return this;
    }

    /**
     * Set Cartridge router binary port
     *
     * @param routerPort router Tarantool node port, usually 3301
     * @return this container instance
     */
    public TarantoolCartridgeContainer withRouterPort(int routerPort) {
        checkNotRunning();
        this.routerPort = routerPort;
        return this;
    }

    /**
     * Set Cartridge router HTTP API port
     *
     * @param apiPort HTTP API port, usually 8081
     * @return this container instance
     */
    public TarantoolCartridgeContainer withAPIPort(int apiPort) {
        checkNotRunning();
        this.apiPort = apiPort;
        return this;
    }

    /**
     * Set the username for accessing the router node
     *
     * @param routerUsername a user name, default is "admin"
     * @return this container instance
     */
    public TarantoolCartridgeContainer withRouterUsername(String routerUsername) {
        checkNotRunning();
        this.routerUsername = routerUsername;
        return this;
    }

    /**
     * Set the user password for accessing the router node
     *
     * @param routerPassword a user password, usually is a value of the "cluster_cookie" option in cartridge.cfg({...})
     * @return this container instance
     */
    public TarantoolCartridgeContainer withRouterPassword(String routerPassword) {
        checkNotRunning();
        this.routerPassword = <PASSWORD>;
        return this;
    }

    @Override
    protected void configure() {
        if (!getDirectoryBinding().isEmpty()) {
            withFileSystemBind(getDirectoryBinding(), getInstanceDir(), BindMode.READ_WRITE);
        }
        if (useFixedPorts) {
            for (Integer port : instanceFileParser.getExposablePorts()) {
                addFixedExposedPort(port, port);
            }
        } else {
            withExposedPorts(instanceFileParser.getExposablePorts());
        }
    }

    @Override
    protected void containerIsStarting(InspectContainerResponse containerInfo) {
        logger().info("Tarantool Cartridge cluster is starting");
    }

    private boolean setupTopology() {
        if (topologyConfigurationFile == null) {
            String runDirPath = null;
            try {
                Container.ExecResult envVariablesContainer = this.execInContainer("env");
                String stdout = envVariablesContainer.getStdout();
                int exitCode = envVariablesContainer.getExitCode();
                if (exitCode != 0) {
                    logger().error("Failed to bootstrap replica sets topology: {}", stdout);
                }
                int startInd = stdout.lastIndexOf(ENV_TARANTOOL_RUNDIR + "=");
                try {
                    runDirPath = stdout.substring(startInd,
                            stdout.indexOf('\n', startInd)).split("=")[1];
                } catch (Exception e) {
                    logger().error("Missing dir-run environment variable: {}", e.getMessage());
                }

            } catch (Exception e) {
                logger().error("Failed to get environment variables: {}", e.getMessage());
            }

            try {
                Container.ExecResult lsResult = this.execInContainer("cartridge", "replicasets", "--run-dir=" + runDirPath,
                                                                    "--file=" + this.replicasetsFileName, "setup", "--bootstrap-vshard");
                String stdout = lsResult.getStdout();
                int exitCode = lsResult.getExitCode();
                if (exitCode != 0) {
                    logger().error("Failed to bootstrap replica sets topology: {}", stdout);
                }
            } catch (Exception e) {
                logger().error("Failed to bootstrap replica sets topology: {}", e.getMessage());
            }
        } else {
            try {
                executeScript(topologyConfigurationFile).get();
                // The client connection will be closed after that command
            } catch (Exception e) {
                if (e instanceof ExecutionException) {
                    if (e.getCause() instanceof TimeoutException) {
                        return true;
                        // Do nothing, the cluster is reloading
                    } else if (e.getCause() instanceof TarantoolConnectionException) {
                        // Probably cluster is not ready
                        logger().error("Failed to setup topology: {}", e.getMessage());
                        return false;
                    }
                } else {
                    throw new RuntimeException("Failed to change the app topology", e);
                }
            }
        }
        return true;
    }

    private void retryingSetupTopology() {
        if (!setupTopology()) {
            try {
                logger().info("Retrying setup topology in 10 seconds");
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            if (!setupTopology()) {
                throw new RuntimeException("Failed to change the app topology after retry");
            }
        }
    }

    private void bootstrapVshard() {
        try {
            executeCommand(VSHARD_BOOTSTRAP_COMMAND).get();
        } catch (Exception e) {
            logger().error("Failed to bootstrap vshard cluster", e);
            throw new RuntimeException(e);
        }
    }

    @Override
    protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) {
        super.containerIsStarted(containerInfo, reused);

        retryingSetupTopology();
        bootstrapVshard();

        logger().info("Tarantool Cartridge cluster is started");
        logger().info("Tarantool Cartridge router is listening at {}:{}", getRouterHost(), getRouterPort());
        logger().info("Tarantool Cartridge HTTP API is available at {}:{}", getAPIHost(), getAPIPort());
    }

    @Override
    public CompletableFuture<List<?>> executeScript(String scriptResourcePath) throws Exception {
        return clientHelper.executeScript(scriptResourcePath);
    }

    @Override
    public CompletableFuture<List<?>> executeCommand(String command, Object... arguments) throws Exception {
        return clientHelper.executeCommand(command, arguments);
    }
}
