from __future__ import annotations

import os
import typing as t

import click

from tutor import bindmount
from tutor import config as tutor_config
from tutor import env as tutor_env
from tutor import fmt, hooks
from tutor import interactive as interactive_config
from tutor import utils
from tutor.commands import images, jobs
from tutor.commands.config import save as config_save_command
from tutor.commands.context import BaseTaskContext
from tutor.commands.upgrade import OPENEDX_RELEASE_NAMES
from tutor.commands.upgrade.compose import upgrade_from
from tutor.core.hooks import Filter  # pylint: disable=unused-import
from tutor.exceptions import TutorError
from tutor.tasks import BaseComposeTaskRunner
from tutor.types import Config


class ComposeTaskRunner(BaseComposeTaskRunner):
    def __init__(self, root: str, config: Config):
        super().__init__(root, config)
        self.project_name = ""
        self.docker_compose_files: list[str] = []
        self.docker_compose_job_files: list[str] = []

    def docker_compose(self, *command: str) -> int:
        """
        Run docker-compose with the right yml files.
        """
        if "start" in command or "up" in command or "restart" in command:
            # Note that we don't trigger the action on "run". That's because we
            # don't want to trigger the action for every initialization script.
            hooks.Actions.COMPOSE_PROJECT_STARTED.do(
                self.root, self.config, self.project_name
            )
        args = []
        for docker_compose_path in self.docker_compose_files:
            if os.path.exists(docker_compose_path):
                args += ["-f", docker_compose_path]
        return utils.docker_compose(
            *args, "--project-name", self.project_name, *command
        )

    def run_task(self, service: str, command: str) -> int:
        """
        Run the "{{ service }}-job" service from local/docker-compose.jobs.yml with the
        specified command.
        """
        run_command = []
        for docker_compose_path in self.docker_compose_job_files:
            path = tutor_env.pathjoin(self.root, docker_compose_path)
            if os.path.exists(path):
                run_command += ["-f", path]
        run_command += ["run", "--rm"]
        if not utils.is_a_tty():
            run_command += ["-T"]
        job_service_name = f"{service}-job"
        return self.docker_compose(
            *run_command,
            job_service_name,
            "sh",
            "-e",
            "-c",
            command,
        )


class BaseComposeContext(BaseTaskContext):
    NAME: t.Literal["local", "dev"]

    def job_runner(self, config: Config) -> ComposeTaskRunner:
        raise NotImplementedError


@click.command(help="Configure and run Open edX from scratch")
@click.option("-I", "--non-interactive", is_flag=True, help="Run non-interactively")
@click.option("-p", "--pullimages", is_flag=True, help="Update docker images")
@click.option("--skip-build", is_flag=True, help="Skip building Docker images")
@click.pass_context
def launch(
    context: click.Context,
    non_interactive: bool,
    pullimages: bool,
    skip_build: bool,
) -> None:
    context_name = context.obj.NAME
    run_for_prod = False if context_name == "dev" else None

    utils.warn_macos_docker_memory()

    # Upgrade has to run before configuration
    interactive_upgrade(context, not non_interactive, run_for_prod=run_for_prod)
    interactive_configuration(context, not non_interactive, run_for_prod=run_for_prod)

    config = tutor_config.load(context.obj.root)

    if not skip_build:
        click.echo(fmt.title("Building Docker images"))
        images_to_build = hooks.Filters.IMAGES_BUILD_REQUIRED.apply([], context_name)
        if not images_to_build:
            fmt.echo_info("No image to build")
        context.invoke(images.build, image_names=images_to_build)

    click.echo(fmt.title("Stopping any existing platform"))
    context.invoke(stop)

    if pullimages:
        click.echo(fmt.title("Docker image updates"))
        context.invoke(dc_command, command="pull")

    click.echo(fmt.title("Starting the platform in detached mode"))
    context.invoke(start, detach=True)

    click.echo(fmt.title("Database creation and migrations"))
    context.invoke(do.commands["init"])

    # Print the urls of the user-facing apps
    public_app_hosts = ""
    for host in hooks.Filters.APP_PUBLIC_HOSTS.iterate(context_name):
        public_app_host = tutor_env.render_str(
            config, "{% if ENABLE_HTTPS %}https{% else %}http{% endif %}://" + host
        )
        public_app_hosts += f"    {public_app_host}\n"
    if public_app_hosts:
        fmt.echo_info(
            f"""The platform is now running and can be accessed at the following urls:

{public_app_hosts}"""
        )


def interactive_upgrade(
    context: click.Context, interactive: bool, run_for_prod: t.Optional[bool]
) -> None:
    """
    Piece of code that is only used in launch.
    """
    run_upgrade_from_release = tutor_env.should_upgrade_from_release(context.obj.root)
    if run_upgrade_from_release is not None:
        click.echo(fmt.title("Upgrading from an older release"))
        if interactive:
            to_release = tutor_env.get_current_open_edx_release_name()
            question = f"""You are about to upgrade your Open edX platform from {run_upgrade_from_release.capitalize()} to {to_release.capitalize()}

It is strongly recommended to make a backup before upgrading. To do so, run:

    tutor local stop # or 'tutor dev stop' in development
    sudo rsync -avr "$(tutor config printroot)"/ /tmp/tutor-backup/

In case of problem, to restore your backup you will then have to run: sudo rsync -avr /tmp/tutor-backup/ "$(tutor config printroot)"/

Are you sure you want to continue?"""
            click.confirm(
                fmt.question(question), default=True, abort=True, prompt_suffix=" "
            )
        context.invoke(
            upgrade,
            from_release=run_upgrade_from_release,
        )

        # Update env and configuration
        # Don't run in interactive mode, otherwise users gets prompted twice.
        interactive_configuration(context, False, run_for_prod)

        # Post upgrade
        if interactive:
            question = f"""Your platform is being upgraded from {run_upgrade_from_release.capitalize()}.

    If you run custom Docker images, you must rebuild them now by running the following command in a different shell:

        tutor images build all # list your custom images here

    See the documentation for more information:

        https://docs.tutor.edly.io/install.html#upgrading-to-a-new-open-edx-release

    Press enter when you are ready to continue"""
            click.confirm(
                fmt.question(question), default=True, abort=True, prompt_suffix=" "
            )


def interactive_configuration(
    context: click.Context, interactive: bool, run_for_prod: t.Optional[bool] = None
) -> None:
    click.echo(fmt.title("Interactive platform configuration"))
    config = tutor_config.load_minimal(context.obj.root)
    if interactive:
        interactive_config.ask_questions(config, run_for_prod=run_for_prod)
    tutor_config.save_config_file(context.obj.root, config)
    config = tutor_config.load_full(context.obj.root)
    tutor_env.save(context.obj.root, config)


@click.command(
    short_help="Perform release-specific upgrade tasks",
    help="Perform release-specific upgrade tasks. To perform a full upgrade remember to run `launch`.",
)
@click.option(
    "--from",
    "from_release",
    type=click.Choice(OPENEDX_RELEASE_NAMES),
)
@click.pass_context
def upgrade(context: click.Context, from_release: t.Optional[str]) -> None:
    fmt.echo_alert(
        "This command only performs a partial upgrade of your Open edX platform. "
        "To perform a full upgrade, you should run `tutor local launch` (or `tutor dev launch` "
        "in development)."
    )
    if from_release is None:
        from_release = tutor_env.get_env_release(context.obj.root)
    if from_release is None:
        fmt.echo_info("Your environment is already up-to-date")
    else:
        upgrade_from(context, from_release)
    # We update the environment to update the version
    context.invoke(config_save_command)


@click.command(
    short_help="Run all or a selection of services.",
    help="Run all or a selection of services. Docker images will be rebuilt where necessary.",
)
@click.option("--build", is_flag=True, help="Build images on start")
@click.option("-d", "--detach", is_flag=True, help="Start in daemon mode")
@click.argument("services", metavar="service", nargs=-1)
@click.pass_obj
def start(
    context: BaseComposeContext,
    build: bool,
    detach: bool,
    services: list[str],
) -> None:
    command = ["up", "--remove-orphans"]
    if build:
        command.append("--build")
    if detach:
        command.append("-d")

    # Start services
    config = tutor_config.load(context.root)
    context.job_runner(config).docker_compose(*command, *services)


@click.command(help="Stop a running platform")
@click.argument("services", metavar="service", nargs=-1)
@click.pass_obj
def stop(context: BaseComposeContext, services: list[str]) -> None:
    config = tutor_config.load(context.root)
    context.job_runner(config).docker_compose("stop", *services)


@click.command(
    short_help="Reboot an existing platform",
    help="This is more than just a restart: with reboot, the platform is fully stopped before being restarted again",
)
@click.option("-d", "--detach", is_flag=True, help="Start in daemon mode")
@click.argument("services", metavar="service", nargs=-1)
@click.pass_context
def reboot(context: click.Context, detach: bool, services: list[str]) -> None:
    context.invoke(stop, services=services)
    context.invoke(start, detach=detach, services=services)


@click.command(
    short_help="Restart some components from a running platform.",
    help="""Specify 'openedx' to restart the lms, cms and workers, or 'all' to
restart all services. Note that this performs a 'docker compose restart', so new images
may not be taken into account. It is useful for reloading settings, for instance. To
fully stop the platform, use the 'reboot' command.""",
)
@click.argument("services", metavar="service", nargs=-1)
@click.pass_obj
def restart(context: BaseComposeContext, services: list[str]) -> None:
    config = tutor_config.load(context.root)
    command = ["restart"]
    if "all" in services:
        pass
    else:
        for service in services:
            if service == "openedx":
                command += ["lms", "lms-worker", "cms", "cms-worker"]
            else:
                command.append(service)
    context.job_runner(config).docker_compose(*command)


@jobs.do_group
def do() -> None:
    """
    Run a custom job in the right container(s).
    """


@click.command(
    short_help="Run a command in a new container",
    help=(
        "Run a command in a new container. This is a wrapper around `docker compose run`. Any option or argument passed"
        " to this command will be forwarded to docker compose. Thus, you may use `-v` or `-p` to mount volumes and"
        " expose ports."
    ),
    context_settings={"ignore_unknown_options": True},
)
@click.argument("args", nargs=-1, required=True)
@click.pass_context
def run(
    context: click.Context,
    args: list[str],
) -> None:
    extra_args = ["--rm"]
    if not utils.is_a_tty():
        extra_args.append("-T")
    context.invoke(dc_command, command="run", args=[*extra_args, *args])


@click.command(
    name="copyfrom",
    help="Copy files/folders from a container directory to the local filesystem.",
)
@click.argument("service")
@click.argument("container_path")
@click.argument(
    "host_path",
    type=click.Path(dir_okay=True, file_okay=False, resolve_path=True),
)
@click.pass_obj
def copyfrom(
    context: BaseComposeContext, service: str, container_path: str, host_path: str
) -> None:
    # Path management
    container_root_path = "/tmp/mount"
    container_dst_path = container_root_path
    if not os.path.exists(host_path):
        # Emulate cp semantics, where if the destination path does not exist
        # then we copy to its parent and rename to the destination folder
        container_dst_path += "/" + os.path.basename(host_path)
        host_path = os.path.dirname(host_path)
    if not os.path.exists(host_path):
        raise TutorError(
            f"Cannot create directory {host_path}. No such file or directory."
        )

    # cp/mv commands
    command = f"cp --recursive --preserve {container_path} {container_dst_path}"
    config = tutor_config.load(context.root)
    runner = context.job_runner(config)
    runner.docker_compose(
        "run",
        "--rm",
        "--no-deps",
        "--user=0",
        f"--volume={host_path}:{container_root_path}",
        service,
        "sh",
        "-e",
        "-c",
        command,
    )


@click.command(
    short_help="Run a command in a running container",
    help=(
        "Run a command in a running container. This is a wrapper around `docker compose exec`. Any option or argument"
        " passed to this command will be forwarded to docker-compose. Thus, you may use `-e` to manually define"
        " environment variables."
    ),
    context_settings={"ignore_unknown_options": True},
    name="exec",
)
@click.argument("args", nargs=-1, required=True)
@click.pass_context
def execute(context: click.Context, args: list[str]) -> None:
    context.invoke(dc_command, command="exec", args=args)


@click.command(
    short_help="View output from containers",
    help="View output from containers. This is a wrapper around `docker compose logs`.",
)
@click.option("-f", "--follow", is_flag=True, help="Follow log output")
@click.option("--tail", type=int, help="Number of lines to show from each container")
@click.argument("service", nargs=-1)
@click.pass_context
def logs(context: click.Context, follow: bool, tail: bool, service: str) -> None:
    args = []
    if follow:
        args.append("--follow")
    if tail is not None:
        args += ["--tail", str(tail)]
    args += service
    context.invoke(dc_command, command="logs", args=args)


@click.command(help="Print status information for containers")
@click.pass_context
def status(context: click.Context) -> None:
    context.invoke(dc_command, command="ps")


@click.command(
    short_help="Direct interface to docker compose.",
    help=(
        "Direct interface to docker compose. This is a wrapper around `docker compose`. Most commands, options and"
        " arguments passed to this command will be forwarded as-is to docker compose."
    ),
    context_settings={"ignore_unknown_options": True},
    name="dc",
)
@click.argument("command")
@click.argument("args", nargs=-1)
@click.pass_obj
def dc_command(
    context: BaseComposeContext,
    command: str,
    args: list[str],
) -> None:
    config = tutor_config.load(context.root)
    context.job_runner(config).docker_compose(command, *args)


@hooks.Filters.COMPOSE_MOUNTS.add()
def _mount_edx_platform(
    volumes: list[tuple[str, str]], name: str
) -> list[tuple[str, str]]:
    """
    When mounting edx-platform with `tutor mounts add /path/to/edx-platform`,
    bind-mount the host repo in the lms/cms containers.
    """
    if name == "edx-platform":
        path = "/openedx/edx-platform"
        volumes += [
            ("lms", path),
            ("cms", path),
            ("lms-worker", path),
            ("cms-worker", path),
            ("lms-job", path),
            ("cms-job", path),
        ]
    return volumes


@hooks.Filters.APP_PUBLIC_HOSTS.add()
def _edx_platform_public_hosts(
    hosts: list[str], context_name: t.Literal["local", "dev"]
) -> list[str]:
    if context_name == "dev":
        hosts += ["{{ LMS_HOST }}:8000", "{{ CMS_HOST }}:8001"]
    else:
        hosts += ["{{ LMS_HOST }}", "{{ CMS_HOST }}"]
    return hosts


hooks.Filters.ENV_TEMPLATE_VARIABLES.add_item(("iter_mounts", bindmount.iter_mounts))


def add_commands(command_group: click.Group) -> None:
    command_group.add_command(launch)
    command_group.add_command(upgrade)
    command_group.add_command(start)
    command_group.add_command(stop)
    command_group.add_command(restart)
    command_group.add_command(reboot)
    command_group.add_command(dc_command)
    command_group.add_command(run)
    command_group.add_command(copyfrom)
    command_group.add_command(execute)
    command_group.add_command(logs)
    command_group.add_command(status)

    @hooks.Actions.PLUGINS_LOADED.add()
    def _add_do_commands() -> None:
        jobs.add_job_commands(do)
        command_group.add_command(do)
