#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import glob
import logging
import os
import random
import shlex
import shutil
import signal
import string
import subprocess
import sys
from typing import Any

from integration_test_images import get_docker_env


def random_str(length: int = 6) -> str:
    alphabet = string.ascii_lowercase + string.digits
    return "".join(random.SystemRandom().choice(alphabet) for _ in range(length))


CUR_FILE_DIR = os.path.dirname(os.path.realpath(__file__))
DEFAULT_CLICKHOUSE_ROOT = os.path.abspath(os.path.join(CUR_FILE_DIR, "../../"))
CURRENT_WORK_DIR = os.getcwd()
VOLUME_NAME = "clickhouse_integration_tests"
CONTAINER_NAME = f"{VOLUME_NAME}_{random_str()}"

CONFIG_DIR_IN_REPO = "programs/server"
INTEGRATION_DIR_IN_REPO = "tests/integration"
UTILS_DIR_IN_REPO = "utils"

DIND_INTEGRATION_TESTS_IMAGE_NAME = "clickhouse/integration-tests-runner"


def physical_memory() -> int:
    try:
        # for linux
        return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
    except ValueError:
        # for MacOS
        return int(subprocess.check_output(["sysctl", "-n", "hw.memsize"]).strip())


def check_args_and_update_paths(args: argparse.Namespace) -> str:
    if args.clickhouse_root:
        if not os.path.isabs(args.clickhouse_root):
            CLICKHOUSE_ROOT = os.path.abspath(args.clickhouse_root)
        else:
            CLICKHOUSE_ROOT = args.clickhouse_root
    else:
        logging.info("ClickHouse root is not set. Will use %s", DEFAULT_CLICKHOUSE_ROOT)
        CLICKHOUSE_ROOT = DEFAULT_CLICKHOUSE_ROOT

    if not os.path.isabs(args.binary):
        args.binary = os.path.abspath(os.path.join(CURRENT_WORK_DIR, args.binary))

    if args.base_configs_dir:
        if not os.path.isabs(args.base_configs_dir):
            args.base_configs_dir = os.path.abspath(
                os.path.join(CURRENT_WORK_DIR, args.base_configs_dir)
            )
    else:
        args.base_configs_dir = os.path.abspath(
            os.path.join(CLICKHOUSE_ROOT, CONFIG_DIR_IN_REPO)
        )
        logging.info("Base configs dir is not set. Will use %s", args.base_configs_dir)

    if args.cases_dir:
        if not os.path.isabs(args.cases_dir):
            args.cases_dir = os.path.abspath(
                os.path.join(CURRENT_WORK_DIR, args.cases_dir)
            )
    else:
        args.cases_dir = os.path.abspath(
            os.path.join(CLICKHOUSE_ROOT, INTEGRATION_DIR_IN_REPO)
        )
        logging.info("Cases dir is not set. Will use %s", args.cases_dir)

    if args.utils_dir:
        if not os.path.isabs(args.utils_dir):
            args.utils_dir = os.path.abspath(
                os.path.join(CURRENT_WORK_DIR, args.utils_dir)
            )
    else:
        args.utils_dir = os.path.abspath(
            os.path.join(CLICKHOUSE_ROOT, UTILS_DIR_IN_REPO)
        )
        logging.info("utils dir is not set. Will use %s", args.utils_dir)

    logging.info(
        "base_configs_dir: %s, binary: %s, cases_dir: %s ",
        args.base_configs_dir,
        args.binary,
        args.cases_dir,
    )

    for path in [
        args.binary,
        args.base_configs_dir,
        args.cases_dir,
        CLICKHOUSE_ROOT,
    ]:
        if not os.path.exists(path):
            raise FileNotFoundError(f"Path {path} doesn't exist")

    if args.dockerd_volume:
        if not os.path.isabs(args.dockerd_volume):
            args.dockerd_volume = os.path.abspath(
                os.path.join(CURRENT_WORK_DIR, args.dockerd_volume)
            )

    if (not os.path.exists(os.path.join(args.base_configs_dir, "config.xml"))) and (
        not os.path.exists(os.path.join(args.base_configs_dir, "config.yaml"))
    ):
        raise FileNotFoundError(
            f"No config.xml or config.yaml in {args.base_configs_dir}"
        )

    if (not os.path.exists(os.path.join(args.base_configs_dir, "users.xml"))) and (
        not os.path.exists(os.path.join(args.base_configs_dir, "users.yaml"))
    ):
        raise FileNotFoundError(
            f"No users.xml or users.yaml in {args.base_configs_dir}"
        )

    return CLICKHOUSE_ROOT


def check_iptables_legacy() -> None:
    iptables_path = shutil.which("iptables")
    ip6tables_path = shutil.which("ip6tables")

    if iptables_path is None:
        print("Error: 'iptables' not found in PATH")
        sys.exit(1)
    if ip6tables_path is None:
        print("Error: 'ip6tables' not found in PATH, ignoring")

    try:
        file_info = os.stat(iptables_path)
        file_info_str = str(file_info)

        if "legacy" in file_info_str:
            print(
                """
                iptables on your host machine is in 'legacy' mode. This is not supported.

                Please switch to 'nftables' mode, usualy by installing `iptables-nft` or `nftables`, consult your distribution manual.
                Or, use --ignore-iptables-legacy-check.
                """
            )
            sys.exit(1)

        if not ip6tables_path:
            return

        file_info = os.stat(ip6tables_path)
        file_info_str = str(file_info)

        if "legacy" in file_info_str:
            print(
                """
                ip6tables on your host machine is in 'legacy' mode. This is not supported.

                Please switch to 'nftables' mode, usualy by installing `iptables-nft` or `nftables`, consult your distribution manual.
                Or, use --ignore-iptables-legacy-check.
                """
            )
            sys.exit(1)

    except FileNotFoundError:
        print(f"Error: '{iptables_path}' not found")
        sys.exit(1)


def docker_kill_handler_handler(signum: Any, frame: Any) -> None:
    _, _ = signum, frame
    subprocess.check_call(
        f"docker ps --all --quiet --filter name={CONTAINER_NAME}",
        shell=True,
    )
    raise KeyboardInterrupt("Killed by Ctrl+C")


signal.signal(signal.SIGINT, docker_kill_handler_handler)

# Integration tests runner should allow to run tests on several versions of ClickHouse.
# Integration tests should be portable.
# To run integration tests following artfacts should be sufficient:
#   - clickhouse binaries (env CLICKHOUSE_TESTS_SERVER_BIN_PATH or --binary arg)
#   - clickhouse default configs(config.xml, users.xml) from same version as binary (env CLICKHOUSE_TESTS_BASE_CONFIG_DIR or --base-configs-dir arg)
#   - tests/integration directory with all test cases and configs (env CLICKHOUSE_TESTS_INTEGRATION_PATH or --cases-dir)
#
# 1) --clickhouse-root is only used to determine other paths on default places
# 2) path of runner script is used to determine paths for trivial case, when we run it from repository

if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [ %(process)d ] %(levelname)s : %(message)s (%(filename)s:%(lineno)s, %(funcName)s)",
    )

    parser = argparse.ArgumentParser(
        description="ClickHouse integration tests runner",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )

    parser.add_argument(
        "--binary",
        default=os.environ.get(
            "CLICKHOUSE_TESTS_SERVER_BIN_PATH",
            os.environ.get("CLICKHOUSE_TESTS_CLIENT_BIN_PATH", "/usr/bin/clickhouse"),
        ),
        help="Path to clickhouse binary. For example /usr/bin/clickhouse",
    )

    parser.add_argument(
        "--base-configs-dir",
        default=os.environ.get("CLICKHOUSE_TESTS_BASE_CONFIG_DIR"),
        help="Path to clickhouse base configs directory with config.xml/users.xml",
    )

    parser.add_argument(
        "--cases-dir",
        default=os.environ.get("CLICKHOUSE_TESTS_INTEGRATION_PATH"),
        help="Path to integration tests cases and configs directory. For example tests/integration in repository",
    )

    parser.add_argument(
        "--utils-dir",
        default=os.environ.get("CLICKHOUSE_UTILS_DIR"),
        help="Path to the 'utils' directory in repository. Used to provide Python modules for grpc protocol schemas which are located in the 'utils' directory",
    )

    parser.add_argument(
        "--clickhouse-root",
        help="Path to repository root folder. Used to take configuration from repository default paths.",
    )

    parser.add_argument(
        "--command",
        default="",
        help="Set it to run some other command in container (for example bash)",
    )

    parser.add_argument(
        "--disable-net-host",
        action="store_true",
        default=False,
        help="Don't use net host in parent docker container",
    )

    parser.add_argument(
        "--network",
        help="Set network driver for runnner container (defaults to `host`)",
    )

    parser.add_argument(
        "--memory",
        # Default is host memory - 2GiB
        default=physical_memory() - 2 * 1024**3,
        help="Set the memory limit for the docker container, "
        "accepts the same argument as `docker run --memory`",
    )

    parser.add_argument(
        "--docker-image-version",
        default="latest",
        help="Version of docker image which runner will use to run tests",
    )

    parser.add_argument(
        "--docker-compose-images-tags",
        action="append",
        help="Set non-default tags for images used in docker compose recipes(yandex/my_container:my_tag)",
    )

    parser.add_argument(
        "-n", "--parallel", action="store", dest="parallel", help="Parallelism"
    )

    parser.add_argument(
        "--count", action="store", type=int, dest="count", help="Repeat count"
    )

    parser.add_argument(
        "--random-order",
        action="store_true",
        dest="random_order",
        help="Run tests in random order using pytest-random-order",
    )

    parser.add_argument(
        "--pre-pull",
        action="store_true",
        default=False,
        dest="pre_pull",
        help="Pull images for docker_compose before all other actions",
    )

    parser.add_argument(
        "-t",
        "--tests_list",
        action="store",
        nargs="+",
        default=[],
        dest="tests_list",
        help="List of tests to run",
    )

    parser.add_argument(
        "-k",
        "--keyword_expression",
        action="store",
        dest="keyword_expression",
        help="pytest keyword expression",
    )

    parser.add_argument(
        "--tmpfs",
        action="store_true",
        default=False,
        dest="tmpfs",
        help="Use tmpfs for dockerd files",
    )

    parser.add_argument(
        "--old-analyzer",
        action="store_true",
        default=False,
        dest="old_analyzer",
        help="Use old analyzer infrastructure",
    )

    parser.add_argument(
        "--distributed-plan",
        action="store_true",
        default=False,
        dest="distributed_plan",
        help="Use serialized query plan in distributed queries",
    )

    parser.add_argument(
        "--cleanup-containers",
        action="store_true",
        default=False,
        dest="cleanup_containers",
        help="Remove all running containers on test session start",
    )

    parser.add_argument(
        "--dockerd-volume-dir",
        action="store",
        dest="dockerd_volume",
        help="Bind volume to this dir to use for dockerd files",
    )

    parser.add_argument(
        "--debug",
        action="store_true",
        dest="debug",
        default=False,
        help="Mount ClickHouse source folder from --clickhouse-root into /debug (read-only)",
    )

    parser.add_argument(
        "--ignore-iptables-legacy-check",
        action="store_true",
        default=False,
        help="Ignore iptables-legacy usage check",
    )

    parser.add_argument("pytest_args", nargs="*", help="args for pytest command")

    args = parser.parse_args()

    # Resolve and store ClickHouse root path
    CLICKHOUSE_ROOT = check_args_and_update_paths(args)

    if not args.ignore_iptables_legacy_check:
        check_iptables_legacy()
    else:
        logging.warning("Skipping iptables-legacy check")

    parallel_args = ""
    if args.parallel:
        parallel_args += "--dist=loadfile"
        parallel_args += f" -n {args.parallel}".format()

    repeat_args = (
        f" --count {args.count}" if args.count is not None and args.count > 0 else ""
    )

    rand_args = "--random-order" if args.random_order else ""

    net = ""
    if args.network:
        net = f"--net={args.network}"
    elif not args.disable_net_host:
        net = "--net=host"

    env_tags = ""

    if args.docker_compose_images_tags is not None:
        for img_tag in args.docker_compose_images_tags:
            [image, tag] = img_tag.split(":")
            env_tag = get_docker_env(image, tag)
            if env_tag:
                env_tags += env_tag
            else:
                logging.info("Unknown image %s", image)

    # create named volume which will be used inside to store images and other docker related files,
    # to avoid redownloading it every time
    #
    # should be removed manually when not needed
    dockerd_internal_volume = ""
    if args.tmpfs:
        dockerd_internal_volume = "--tmpfs /var/lib/docker -e DOCKER_RAMDISK=true"
    elif args.dockerd_volume:
        dockerd_internal_volume = (
            f"--mount type=bind,source={args.dockerd_volume},target=/var/lib/docker"
        )
    else:
        try:
            subprocess.check_call(
                f"docker volume create {VOLUME_NAME}_volume", shell=True
            )
        except Exception as ex:
            print("Volume creation failed, probably it already exists, exception", ex)
        # TODO: this part cleans out stale volumes produced by container name
        # randomizer, we should remove it after Sep 2022
        try:
            subprocess.check_call(
                f"docker volume ls -q | grep '{VOLUME_NAME}_.*_volume' | xargs --no-run-if-empty docker volume rm",
                shell=True,
            )
        except Exception as ex:
            print("Probably, some stale volumes still there, just continue:", ex)
        # TODO END
        dockerd_internal_volume = f"--volume={VOLUME_NAME}_volume:/var/lib/docker"

    # If enabled we kill and remove containers before pytest session run.
    env_cleanup = ""
    if args.cleanup_containers:
        env_cleanup = "-e PYTEST_CLEANUP_CONTAINERS=1"
    # enable tty mode & interactive for docker if we have real tty
    tty = ""
    if sys.stdout.isatty() and sys.stdin.isatty():
        tty = "-it"

    # Remove old logs.
    for old_log_path in glob.glob(args.cases_dir + "/pytest*.log"):
        os.remove(old_log_path)

    if args.keyword_expression:
        args.pytest_args += ["-k", args.keyword_expression]

    use_old_analyzer = ""
    if args.old_analyzer:
        use_old_analyzer = "-e CLICKHOUSE_USE_OLD_ANALYZER=1"

    use_distributed_plan = ""
    if args.distributed_plan:
        use_distributed_plan = "-e CLICKHOUSE_USE_DISTRIBUTED_PLAN=1"

    # NOTE: since pytest options is in the argument value already we need to additionally escape '"'
    pytest_opts = " ".join(
        map(lambda x: shlex.quote(x).replace('"', '\\"'), args.pytest_args)
    )
    tests_list = " ".join(
        map(lambda x: shlex.quote(x).replace('"', '\\"'), args.tests_list)
    )

    # Mount debug root into container if debug flag is set
    debug_mount = f"--volume={CLICKHOUSE_ROOT}:/debug:ro" if args.debug else ""

    cmd_base = (
        f"docker run {net} {tty} --rm --name {CONTAINER_NAME} "
        "--privileged --dns-search='.' "  # since recent dns search leaks from host
        f"--memory={args.memory} "
        f"--security-opt seccomp=unconfined "
        f"--cap-add=SYS_PTRACE "
        f"--volume={args.binary}:/clickhouse "
        f"--volume={args.base_configs_dir}:/clickhouse-config "
        f"{debug_mount} "
        f"--volume={args.cases_dir}:/ClickHouse/tests/integration "
        f"--volume={args.utils_dir}/backupview:/ClickHouse/utils/backupview "
        f"--volume={args.utils_dir}/grpc-client/pb2:/ClickHouse/utils/grpc-client/pb2 "
        f"--volume=/run:/run/host:ro {dockerd_internal_volume} {env_tags} {env_cleanup} "
        f"-e DOCKER_CLIENT_TIMEOUT=300 -e COMPOSE_HTTP_TIMEOUT=600 {use_old_analyzer} {use_distributed_plan} -e PYTHONUNBUFFERED=1 "
        f'-e PYTEST_ADDOPTS="{parallel_args} {repeat_args} {pytest_opts} {tests_list} {rand_args} -vvv "'
        f" {DIND_INTEGRATION_TESTS_IMAGE_NAME}:{args.docker_image_version}"
    )

    cmd = cmd_base + " " + args.command
    cmd_pre_pull = (
        f"{cmd_base} find /ClickHouse/tests/integration/compose -name docker_compose_*.yml "
        r"-exec docker compose -f '{}' pull --quiet \;"
    )

    containers = subprocess.check_output(
        f"docker ps --all --quiet --filter name={CONTAINER_NAME}",
        shell=True,
        universal_newlines=True,
    ).splitlines()
    if containers:
        print(f"Trying to kill containers name={CONTAINER_NAME} ids={containers}")
        subprocess.check_call(f"docker kill {' '.join(containers)}", shell=True)
        print(f"Containers {containers} killed")

    if args.pre_pull:
        print(("Running pre pull as: '" + cmd_pre_pull + "'."))
        subprocess.check_call(cmd_pre_pull, shell=True)

    if args.debug:
        print(
            f"""
    # =====> DEBUG MODE <=====
    # ClickHouse root folder will be read-only mounted into /debug in all containers.
    # Tip: place a `breakpoint()` somewhere in your integration test python code before `runner`.
    # Open another shell and include:
        source {CLICKHOUSE_ROOT}/tests/integration/runner-env.sh
    # =====> DEBUG MODE <=====
"""
        )

    print(("Running pytest container as: '" + cmd + "'."))
    subprocess.check_call(cmd, shell=True, bufsize=0)
