import signal
import time
from datetime import datetime, timedelta
from os import makedirs
from pathlib import Path

import pytest
import pytz

import dockertown
from dockertown import DockerClient
from dockertown.components.compose.models import ComposeConfig
from dockertown.exceptions import NoSuchImage
from dockertown.test_utils import get_all_jsons
from dockertown.utils import PROJECT_ROOT

pytestmark = pytest.mark.skipif(
    not dockertown.docker.compose.is_installed(),
    reason="Those tests need docker compose.",
)

docker = DockerClient(
    compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
    compose_compatibility=True,
)


def mock_KeyboardInterrupt(signum, frame):
    raise KeyboardInterrupt("Time is up")


def test_build_empty_list_of_services():
    previous_images = set(docker.image.list())
    docker.compose.build([])
    assert previous_images == set(docker.image.list())


def test_create_empty_list_of_services():
    previous_containers = set(docker.ps(all=True))
    docker.compose.create([])
    assert previous_containers == set(docker.ps(all=True))


def test_kill_empty_list_of_services():
    docker.compose.up(["my_service", "busybox", "alpine"], detach=True)
    time.sleep(1)
    all_running_containers = set(docker.ps())
    docker.compose.kill([])
    assert all_running_containers == set(docker.ps())
    docker.compose.down(timeout=1)


def test_pause_empty_list_of_services():
    docker.compose.up(["my_service", "busybox", "alpine"], detach=True)
    time.sleep(1)
    number_of_paused_containers = len([x for x in docker.ps() if x.state.paused])
    docker.compose.pause([])
    assert number_of_paused_containers == len(
        [x for x in docker.ps() if x.state.paused]
    )
    docker.compose.down(timeout=1)


def test_remove_empty_list_of_services():
    docker.compose.up(["my_service", "busybox", "alpine"], detach=True)
    time.sleep(1)
    number_of_containers = len(docker.ps())
    docker.compose.rm([], stop=True)
    assert number_of_containers == len(docker.ps())
    docker.compose.down(timeout=1)


def test_pull_empty_list_of_services():
    len_list_of_images = len(docker.image.list())
    docker.compose.pull([])
    assert len_list_of_images == len(docker.image.list())


def test_push_empty_list_of_services():
    docker.compose.push([])


def test_compose_project_name():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_project_name="dudu",
        compose_compatibility=True,
    )
    docker.compose.up(["busybox", "alpine"], detach=True)
    containers = docker.compose.ps()
    container_names = set(x.name for x in containers)
    assert container_names == {"dudu_busybox_1", "dudu_alpine_1"}
    docker.compose.down(timeout=1)


def test_docker_compose_build():
    docker.compose.build()
    docker.compose.build(["my_service"])
    docker.image.remove("some_random_image")


def test_docker_compose_build_with_arguments():
    docker.compose.build(
        build_args={"PYTHON_VERSION": "3.7"},
        cache=False,
        progress="plain",
        pull=True,
        quiet=True,
    )
    docker.image.remove("some_random_image")


def test_docker_compose_up_down():
    docker = DockerClient(
        compose_files=[
            PROJECT_ROOT / "tests/dockertown/components/dummy_compose_ends_quickly.yml"
        ],
        compose_compatibility=True,
    )
    docker.compose.up(["busybox", "alpine"])
    docker.compose.down(timeout=1)


def test_no_containers():
    assert docker.compose.ps() == []


def test_docker_compose_up_detach_down():
    docker.compose.up(["my_service", "busybox", "alpine"], detach=True)
    docker.compose.down(timeout=1)


def test_docker_compose_up_detach_down_with_scales():
    docker.compose.up(
        ["my_service", "busybox", "alpine"],
        detach=True,
        scales={"busybox": 2, "alpine": 3},
    )
    assert len(docker.compose.ps()) == 6

    docker.compose.up(
        ["my_service", "busybox", "alpine"],
        detach=True,
        scales={"busybox": 2, "alpine": 5},
    )
    assert len(docker.compose.ps()) == 8

    docker.compose.down(timeout=1)


def test_docker_compose_pause_unpause():
    docker.compose.up(["my_service", "busybox", "alpine"], detach=True)
    docker.compose.pause()
    for container in docker.compose.ps():
        assert container.state.paused

    docker.compose.unpause()
    for container in docker.compose.ps():
        assert not container.state.paused

    docker.compose.pause(["my_service", "busybox"])

    assert docker.container.inspect("components_my_service_1").state.paused
    assert docker.container.inspect("components_busybox_1").state.paused

    docker.compose.down(timeout=1)


def test_docker_compose_create_down():
    docker.compose.create()
    docker.compose.down()


def test_docker_compose_config():
    compose_config = docker.compose.config()
    assert compose_config.services["alpine"].image == "alpine:latest"

    compose_config = docker.compose.config(return_json=True)
    assert compose_config["services"]["alpine"]["image"] == "alpine:latest"


def test_docker_compose_create_extra_options_down():
    docker.compose.create(build=True, force_recreate=True)
    docker.compose.create(build=True, force_recreate=True)
    docker.compose.create(no_build=True, no_recreate=True)
    docker.compose.down()


def test_docker_compose_up_detach_down_extra_options():
    docker.compose.up(["my_service", "busybox", "alpine"], detach=True)
    docker.compose.down(remove_orphans=True, remove_images="all", timeout=3)


def test_docker_compose_up_build():
    docker.compose.up(["my_service", "busybox", "alpine"], build=True, detach=True)
    with docker.image.inspect("some_random_image"):
        docker.compose.down()


def test_docker_compose_up_stop_rm():
    docker.compose.up(["my_service", "busybox", "alpine"], build=True, detach=True)
    docker.compose.stop(timeout=timedelta(seconds=3))
    docker.compose.rm(volumes=True)


def test_docker_compose_up_rm():
    docker.compose.up(["my_service", "busybox", "alpine"], build=True, detach=True)
    docker.compose.rm(stop=True, volumes=True)


def test_docker_compose_up_down_some_services():
    docker.compose.up(["my_service", "busybox"], detach=True)
    docker.compose.down(timeout=1)


def test_docker_compose_ps():
    docker.compose.up(["my_service", "busybox"], detach=True)
    containers = docker.compose.ps()
    names = set(x.name for x in containers)
    assert names == {"components_my_service_1", "components_busybox_1"}
    docker.compose.down()


def test_docker_compose_start():
    docker.compose.create(["busybox"])
    assert not docker.compose.ps()[0].state.running
    docker.compose.start(["busybox"])
    assert docker.compose.ps()[0].state.running
    docker.compose.down(timeout=1)


def test_docker_compose_restart():
    docker.compose.up(["my_service"], detach=True)
    time.sleep(2)

    for container in docker.compose.ps():
        assert (datetime.now(pytz.utc) - container.state.started_at) > timedelta(
            seconds=2
        )

    docker.compose.restart(timeout=1)

    for container in docker.compose.ps():
        assert (datetime.now(pytz.utc) - container.state.started_at) < timedelta(
            seconds=2
        )

    docker.compose.down()


def test_docker_compose_restart_empty_list_of_services():
    docker.compose.up(["my_service"], detach=True)
    time.sleep(2)

    for container in docker.compose.ps():
        assert (datetime.now(pytz.utc) - container.state.started_at) > timedelta(
            seconds=2
        )

    docker.compose.restart([], timeout=1)

    for container in docker.compose.ps():
        assert (datetime.now(pytz.utc) - container.state.started_at) > timedelta(
            seconds=2
        )

    docker.compose.down()


def test_docker_compose_kill():
    docker.compose.up(["my_service", "busybox"], detach=True)

    for container in docker.compose.ps():
        assert container.state.running

    docker.compose.kill("busybox")

    assert not docker.container.inspect("components_busybox_1").state.running

    docker.compose.down()


def test_docker_compose_pull():
    try:
        docker.image.remove("busybox")
    except NoSuchImage:
        pass
    try:
        docker.image.remove("alpine")
    except NoSuchImage:
        pass
    docker.compose.pull("busybox")
    docker.compose.pull(["busybox", "alpine"])
    docker.image.inspect(["busybox", "alpine"])

    # pull with quiet should work too
    docker.compose.pull(["busybox", "alpine"], quiet=True)


def test_docker_compose_pull_ignore_pull_failures():
    docker = DockerClient(
        compose_files=[
            PROJECT_ROOT
            / "tests/dockertown/components/dummy_compose_non_existent_image.yml"
        ]
    )
    try:
        docker.image.remove("ghost")
    except NoSuchImage:
        pass
    docker.compose.pull(["ghost"], ignore_pull_failures=True)


def test_docker_compose_pull_include_deps():
    try:
        docker.image.remove("alpine")
    except NoSuchImage:
        pass
    docker.compose.pull(["busybox-2-electric-boogaloo"], include_deps=True)
    docker.image.inspect(["alpine"])


def test_docker_compose_up_abort_on_container_exit():
    docker = DockerClient(
        compose_files=[
            PROJECT_ROOT / "tests/dockertown/components/dummy_compose_ends_quickly.yml"
        ],
        compose_compatibility=True,
    )
    docker.compose.up("alpine", abort_on_container_exit=True)
    for container in docker.compose.ps():
        assert not container.state.running
    docker.compose.down()


def test_passing_env_files(tmp_path: Path):
    compose_env_file = tmp_path / "dodo.env"
    compose_env_file.write_text("SOME_VARIABLE_TO_INSERT=hello\n")
    docker = DockerClient(
        compose_files=[
            PROJECT_ROOT / "tests/dockertown/components/dummy_compose_ends_quickly.yml"
        ],
        compose_env_file=compose_env_file,
        compose_compatibility=True,
    )
    output = docker.compose.config()

    assert output.services["alpine"].environment["SOME_VARIABLE"] == "hello"


def test_project_directory_env_files(tmp_path: Path):
    makedirs(tmp_path / "some/path")
    makedirs(tmp_path / "some/other/path")
    project_env_file_one = tmp_path / "some/path/one.env"
    project_env_file_two = tmp_path / "some/other/path/two.env"
    project_env_file_one.write_text("TEST=one\n")
    project_env_file_two.write_text("TEST=two\n")
    docker = DockerClient(
        compose_files=[
            PROJECT_ROOT
            / "tests/dockertown/components/dummy_compose_project_directory.yml"
        ],
        compose_project_directory=tmp_path,
        compose_compatibility=True,
    )
    output = docker.compose.config()

    assert output.services["alpine_one"].environment["TEST"] == "one"
    assert output.services["alpine_two"].environment["TEST"] == "two"


def test_entrypoint_loaded_in_config():
    assert docker.compose.config().services["dodo"].entrypoint == ["/bin/sh"]


def test_config_complexe_compose():
    """Checking that the pydantic model does its job"""
    compose_file = PROJECT_ROOT / "tests/dockertown/components/complexe-compose.yml"
    docker = DockerClient(compose_files=[compose_file], compose_compatibility=True)
    config = docker.compose.config()

    assert config.services["my_service"].build.context == Path("my_service_build")
    assert config.services["my_service"].image == "some_random_image"
    assert config.services["my_service"].command == [
        "ping",
        "-c",
        "2",
        "www.google.com",
    ]

    assert config.services["my_service"].ports[0].published == 5000
    assert config.services["my_service"].ports[0].target == 5000

    assert config.services["my_service"].volumes[0].source == "/tmp"
    assert config.services["my_service"].volumes[0].target == "/tmp"
    assert config.services["my_service"].volumes[1].source == "dodo"
    assert config.services["my_service"].volumes[1].target == "/dodo"

    assert config.services["my_service"].environment == {"DATADOG_HOST": "something"}
    assert config.services["my_service"].deploy.placement.constraints == [
        "node.labels.hello-world == yes"
    ]
    assert config.services["my_service"].deploy.resources.limits.cpus == 2
    assert config.services["my_service"].deploy.resources.limits.memory == 41943040

    assert config.services["my_service"].deploy.resources.reservations.cpus == 1
    assert (
        config.services["my_service"].deploy.resources.reservations.memory == 20971520
    )

    assert config.services["my_service"].deploy.replicas == 4

    assert not config.volumes["dodo"].external


def test_compose_down_volumes():
    compose_file = PROJECT_ROOT / "tests/dockertown/components/complexe-compose.yml"
    docker = DockerClient(compose_files=[compose_file], compose_compatibility=True)
    docker.compose.up(
        ["my_service"], detach=True, scales=dict(my_service=1), build=True
    )
    assert docker.volume.exists("components_dodo")
    docker.compose.down()
    assert docker.volume.exists("components_dodo")
    docker.compose.down(volumes=True)
    assert not docker.volume.exists("components_dodo")


def test_compose_config_from_rc1():
    config = ComposeConfig.parse_file(
        Path(__file__).parent / "strange_compose_config_rc1.json"
    )

    assert config.services["myservice"].deploy.resources.reservations.cpus == "'0.25'"


@pytest.mark.parametrize("json_file", get_all_jsons("compose"))
def test_load_json(json_file):
    json_as_txt = json_file.read_text()
    config: ComposeConfig = ComposeConfig.parse_raw(json_as_txt)
    if json_file.name == "0.json":
        assert config.services["traefik"].labels["traefik.enable"] == "true"


def test_compose_run_simple():
    result = docker.compose.run("alpine", ["echo", "dodo"], remove=True, tty=False)
    assert result == "dodo"


def test_compose_run_detach():
    container = docker.compose.run("alpine", ["echo", "dodo"], detach=True, tty=False)

    time.sleep(0.1)
    assert not container.state.running
    assert container.logs() == "dodo\n"


def test_compose_version():
    assert "Docker Compose version v2" in docker.compose.version()


def test_compose_logs_simple_use_case():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/compose_logs.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(detach=True)
    # Wait some seconds to let the container to complete the execution of ping
    # and print the statistics
    time.sleep(15)
    full_output = docker.compose.logs()
    assert "error with my_other_service" in full_output
    assert "--- www.google.com ping statistics ---" in full_output
    docker.compose.down(timeout=1)


def test_compose_logs_stream():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/compose_logs.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(detach=True)
    time.sleep(15)
    logs = docker.compose.logs(stream=True)
    logs = list(logs)
    assert any(["error with my_other_service" in log[1].decode() for log in logs])
    assert any(
        ["--- www.google.com ping statistics ---" in log[1].decode() for log in logs]
    )

    docker.compose.down(timeout=1)


def test_compose_logs_follow():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/compose_logs.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(detach=True)

    signal.signal(signal.SIGALRM, mock_KeyboardInterrupt)
    signal.alarm(15)

    start = datetime.now()
    full_output = ""
    try:
        for output_type, current_output in docker.compose.logs(
            follow=True, stream=True
        ):
            full_output += current_output.decode()
        # interrupt the alarm in case the command ends before the timeout
        signal.alarm(0)
    # catch and ignore the exception when the command is interruped by the timeout
    except KeyboardInterrupt:
        pass

    end = datetime.now()

    # 5 seconds because the command can end before the timeout (set to 15 seconds)...
    # but it is enough to verify that the follow flag was working
    # otherwise the logs command should completed in much less than 5 seconds
    assert (end - start).seconds >= 5

    assert "error with my_other_service" in full_output
    assert "--- www.google.com ping statistics ---" in full_output

    docker.compose.down(timeout=1)


@pytest.mark.parametrize("privileged", [True, False])
def test_compose_execute_no_tty(privileged):
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(["busybox"], detach=True)
    time.sleep(2)
    full_output = docker.compose.execute(
        "busybox", ["echo", "dodo"], tty=False, privileged=privileged
    )
    assert full_output == "dodo"
    docker.compose.down(timeout=1)


# We can't test the TTY flag on execute because we can't have a true tty in pytest
# of course tty still works if dockertown is executed outside pytest.


def test_compose_execute_detach():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(["busybox"], detach=True)
    t1 = datetime.now()
    output = docker.compose.execute("busybox", ["sleep", "20"], detach=True, tty=False)
    execution_time = datetime.now() - t1
    assert execution_time.seconds < 20
    assert output is None
    docker.compose.down(timeout=1)


def test_compose_execute_envs():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(["busybox"], detach=True)
    output = docker.compose.execute(
        "busybox",
        ["sh", "-c", "echo $VAR1,$VAR2"],
        envs={"VAR1": "hello", "VAR2": "world"},
        tty=False,
    )
    assert output == "hello,world"
    docker.compose.down(timeout=1)


def test_compose_execute_user():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(["busybox"], detach=True)
    output = docker.compose.execute("busybox", ["whoami"], tty=False, user="sync")
    assert output == "sync"
    docker.compose.down(timeout=1)


def test_compose_execute_workdir():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_compatibility=True,
    )
    docker.compose.up(["busybox"], detach=True)
    assert (
        docker.compose.execute("busybox", ["pwd"], tty=False, workdir="/tmp") == "/tmp"
    )
    assert (
        docker.compose.execute("busybox", ["pwd"], tty=False, workdir="/proc")
        == "/proc"
    )
    docker.compose.down(timeout=1)


def test_compose_single_profile():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_profiles=["my_test_profile"],
        compose_compatibility=True,
    )
    docker.compose.up(detach=True)

    container_names = [x.name for x in docker.compose.ps()]
    assert "components_profile_test_service_1" in container_names

    docker.compose.down(timeout=1)


def test_compose_multiple_profiles():
    docker = DockerClient(
        compose_files=[PROJECT_ROOT / "tests/dockertown/components/dummy_compose.yml"],
        compose_profiles=["my_test_profile", "my_test_profile2"],
        compose_compatibility=True,
    )
    docker.compose.up(detach=True)

    container_names = [x.name for x in docker.compose.ps()]
    assert "components_profile_test_service_1" in container_names
    assert "components_second_profile_test_service_1" in container_names

    docker.compose.down(timeout=1)


def test_compose_port():
    d = DockerClient(
        compose_files=[
            PROJECT_ROOT
            / "tests/dockertown/components/dummy_compose_non_existent_image.yml"
        ]
    )
    service = "busybox"
    d.compose.up(services=[service], detach=True)

    expected_tcp_host, expected_tcp_port, expected_udp_host, expected_udp_port = (
        None,
        None,
        None,
        None,
    )
    for container in d.compose.ps():
        if service in container.name:
            tcp_cfg = container.network_settings.ports["3000/tcp"][0]
            expected_tcp_host, expected_tcp_port = tcp_cfg["HostIp"], int(
                tcp_cfg["HostPort"]
            )
            udp_cfg = container.network_settings.ports["4000/udp"][0]
            expected_udp_host, expected_udp_port = udp_cfg["HostIp"], int(
                udp_cfg["HostPort"]
            )
            break

    tcp_host, tcp_port = d.compose.port(service, "3000")
    assert expected_tcp_host == tcp_host
    assert expected_tcp_port == tcp_port

    udp_host, udp_port = d.compose.port(service, "4000", protocol="udp")
    assert expected_udp_host == udp_host
    assert expected_udp_port == udp_port

    invalid_protocol_host, invalid_protocol_type = d.compose.port(
        service, "4000", protocol="tcp"
    )
    assert invalid_protocol_host is None
    assert invalid_protocol_type is None

    unknown_host, unknown_port = d.compose.port(service, "1111")
    assert unknown_host is None
    assert unknown_port is None

    try:
        _ = d.compose.port("", "123")
        assert False, "error should be raised for empty service"
    except ValueError as e:
        assert e.args == ValueError("Service cannot be empty").args

    try:
        _ = d.compose.port(service, "")
        assert False, "error should be raised for empty port"
    except ValueError as e:
        assert e.args == ValueError("Private port cannot be empty").args

    d.compose.down(timeout=1)
