import logging
import time
import typing
import uuid
from pathlib import Path, PurePath
from threading import Thread

import sublime

from .project_data import (
    SshSession,
    add_to_project_folders,
    remove_from_project_folders,
    update_window_status,
)
from .project_data import lock as project_data_lock
from .ssh_utils import (
    PasswordlessConnectionException,
    mount_sshfs,
    ssh_check_master,
    ssh_connect,
    ssh_disconnect,
    ssh_forward,
    umount_sshfs,
)
from .st_utils import (
    format_ip_addr,
    get_absolute_purepath_flavour,
    parse_ssh_connection,
)

_logger = logging.getLogger(__package__)


class SshConnect(Thread):
    def __init__(  # pylint: disable=too-many-arguments
        self,
        view: sublime.View,
        connection_str: str,
        identifier: typing.Optional[uuid.UUID] = None,
        mounts: typing.Optional[typing.Dict[str, str]] = None,
        forwards: typing.Optional[typing.List[dict]] = None,
    ):
        self.view = view
        self.connection_str = connection_str

        # below attributes are only used in case of re-connection
        self.identifier = identifier
        self.mounts = mounts or {}
        self.forwards = forwards or []

        super().__init__()

    def run(self):
        host, port, login, password = parse_ssh_connection(self.connection_str)

        _logger.debug(
            "SSH connection string is : %s:%s@%s:%d",
            login,
            "*" * len(password or ""),
            format_ip_addr(host),
            port,
        )

        self.view.set_status(
            "zz_connection_in_progress",
            f"Connecting to ssh://{login}@{format_ip_addr(host)}:{port}...",
        )
        try:
            try:
                identifier = ssh_connect(host, port, login, password, self.identifier)
            except PasswordlessConnectionException:
                _logger.info(
                    "authentication failed for %s@%s:%d, prompting for password before retrying...",
                    login,
                    format_ip_addr(host),
                    port,
                )

                # we simply leave here and let `ssh_connect_password` command call this action again
                schedule_ssh_connect_password_command(
                    host,
                    port,
                    login,
                    self.identifier,
                    self.mounts,
                    self.forwards,
                    self.view.window(),
                )
                return
        finally:
            self.view.erase_status("zz_connection_in_progress")

        if identifier is None:
            return

        # store SSH session metadata in project data
        # Development note : on **re-connection**, mounts and forwards are reset here and will be
        #                    directly re-populated by thread actions below
        ssh_session = SshSession(str(identifier), host, port, login)
        ssh_session.set_in_project_data(self.view.window())

        _logger.info("successfully connected to %s !", ssh_session)

        update_window_status(self.view.window())

        # re-mount and re-open previous remote folders (if any)
        for mount_path, remote_path in self.mounts.items():
            SshMountSshfs(
                self.view,
                identifier,
                # here paths are strings due to JSON serialization, infer flavour back for remote
                mount_path=Path(mount_path),
                remote_path=typing.cast(PurePath, get_absolute_purepath_flavour(remote_path)),
            ).start()

        # re-open previous forwards (if any)
        for forward in self.forwards:
            SshForward(
                self.view,
                identifier,
                forward["is_reverse"],
                forward["orig_target_1"],
                forward["orig_target_2"],
            ).start()


class SshDisconnect(Thread):
    def __init__(self, view: sublime.View, identifier: uuid.UUID):
        self.view = view
        self.identifier = identifier

        super().__init__()

    def run(self):
        ssh_session = SshSession.get_from_project_data(self.identifier, self.view.window())
        if ssh_session is not None:
            # properly unmount sshfs before disconnecting session
            for mount_path in ssh_session.mounts:
                unmount_thread = SshMountSshfs(
                    self.view, self.identifier, do_mount=False, mount_path=Path(mount_path)
                )
                unmount_thread.start()
                unmount_thread.join()

        self.view.set_status("zz_disconnection_in_progress", f"Disconnecting from {ssh_session}...")
        try:
            ssh_disconnect(self.identifier)
        finally:
            self.view.erase_status("zz_disconnection_in_progress")

        if ssh_session is not None:
            ssh_session.remove_from_project_data(self.view.window())

        update_window_status(self.view.window())


class SshForward(Thread):
    def __init__(  # pylint: disable=too-many-arguments
        self,
        view: sublime.View,
        identifier: uuid.UUID,
        is_reverse: bool,
        fwd_target_1: str,
        fwd_target_2: str,
        *,
        do_open: bool = True,
    ):
        self.view = view
        self.identifier = identifier
        self.is_reverse = is_reverse
        self.fwd_target_1 = fwd_target_1
        self.fwd_target_2 = fwd_target_2
        self.do_open = do_open

        super().__init__()

    def run(self):
        self.view.set_status(
            "zz_forward_in_progress",
            f"{'Request' if self.do_open else 'Cancel'} forwarding {self.fwd_target_1} "
            f"{'<-' if self.is_reverse else '->'} {self.fwd_target_2}...",
        )
        try:
            forward_rule = ssh_forward(
                self.identifier, self.do_open, self.is_reverse, self.fwd_target_1, self.fwd_target_2
            )
        finally:
            self.view.erase_status("zz_forward_in_progress")

        if forward_rule is None:
            return

        # store forwarding rule in SSH session metadata
        with project_data_lock:
            ssh_session = SshSession.get_from_project_data(self.identifier, self.view.window())
            if ssh_session is None:
                return

            if self.do_open:
                if forward_rule not in ssh_session.forwards:
                    ssh_session.forwards.append(forward_rule)
            else:
                # clean SSH session forwards by removing the one that has just been closed
                ssh_session.forwards = [
                    forward
                    for forward in ssh_session.forwards
                    if not ssh_session.is_same_forward(forward, forward_rule)
                ]

            ssh_session.set_in_project_data(self.view.window())

        update_window_status(self.view.window())


class SshMountSshfs(Thread):
    def __init__(  # pylint: disable=too-many-arguments
        self,
        view: sublime.View,
        identifier: uuid.UUID,
        *,
        do_mount: bool = True,
        mount_path: typing.Optional[Path] = None,
        remote_path: typing.Optional[PurePath] = None,
    ):
        self.view = view
        self.identifier = identifier
        self.do_mount = do_mount
        self.mount_path = mount_path
        self.remote_path = remote_path

        super().__init__()

    def run(self):
        ssh_session = SshSession.get_from_project_data(self.identifier, self.view.window())
        if ssh_session is None:
            _logger.error("could not retrieve SSH session information from project data")
            return

        # if `remote_path` is unknown, fetch if from session
        if not self.do_mount:
            self.remote_path = PurePath(ssh_session.mounts[str(self.mount_path)])

        self.view.set_status(
            "zz_mounting_sshfs",
            f"{'M' if self.do_mount else 'Un'}ounting ssh://{ssh_session}{self.remote_path}...",
        )
        try:
            if self.do_mount:
                mount_path = mount_sshfs(
                    self.identifier, typing.cast(PurePath, self.remote_path), self.mount_path
                )
            else:
                mount_path = typing.cast(Path, self.mount_path)
                umount_sshfs(mount_path)
        finally:
            self.view.erase_status("zz_mounting_sshfs")

        if mount_path is None:
            return

        if self.do_mount:
            add_to_project_folders(
                str(mount_path), f"{ssh_session}{self.remote_path}", self.view.window()
            )
        else:
            remove_from_project_folders(str(mount_path), self.view.window())

        # store/remove mount path in/from SSH session metadata
        with project_data_lock:
            if self.do_mount:
                ssh_session.mounts[str(mount_path)] = str(self.remote_path)
            else:
                ssh_session.mounts.pop(str(mount_path), None)

            ssh_session.set_in_project_data(self.view.window())


class SshKeepaliveThread(Thread):
    """
    This thread is responsible for periodical connections to OpenSSH control master sockets, in
    order to postpone `ControlPersist` timeout and thus keep these sessions opened.
    If master fails to answer, a re-connection attempt occurs.
    """

    __LOOP_PERIOD = 15

    def __init__(self, *args, window: sublime.Window, **kwargs):
        self.window = window

        self._keep_running = True

        super().__init__(*args, **kwargs)

    def stop(self) -> None:
        self._keep_running = False

    def run(self):
        _logger.debug(
            "keepalive thread %d for window %d is starting up...", self.ident, self.window.id()
        )

        while self._keep_running:
            start_loop = time.monotonic()

            for identifier in SshSession.get_all_from_project_data(self.window):
                session_identifier = uuid.UUID(identifier)
                with project_data_lock:
                    ssh_session = SshSession.get_from_project_data(session_identifier, self.window)

                    # skip this session as a re-connection attempt is already in progress
                    if ssh_session is None or ssh_session.is_up is None:
                        continue

                    _logger.debug(
                        "checking that master behind %s (%s) is still up...",
                        ssh_session,
                        identifier,
                    )
                    is_up = ssh_check_master(session_identifier)
                    if is_up:  # update session "up" status (if needed) and leave
                        if not ssh_session.is_up:
                            ssh_session.is_up = is_up
                            ssh_session.set_in_project_data(self.window)
                        continue

                    _logger.warning("%s's master is down : attempting to reconnect...", ssh_session)
                    SshConnect(
                        self.window.active_view(),
                        str(ssh_session),
                        session_identifier,
                        ssh_session.mounts,
                        ssh_session.forwards,
                    ).start()

                    # set "up" status to `None` so we know a re-connection attempt is in progress
                    ssh_session.is_up = None
                    ssh_session.set_in_project_data(self.window)

            end_loop = time.monotonic()

            # sleep at most `__LOOP_PERIOD` seconds
            time.sleep(
                max(min(self.__LOOP_PERIOD - (end_loop - start_loop), self.__LOOP_PERIOD), 0)
            )

        _logger.debug(
            "keepalive thread %d for window %d is shutting down...", self.ident, self.window.id()
        )


def schedule_ssh_connect_password_command(  # pylint: disable=too-many-arguments
    host: str,
    port: int,
    login: str,
    identifier: typing.Optional[uuid.UUID] = None,
    mounts: typing.Optional[typing.Dict[str, str]] = None,
    forwards: typing.Optional[typing.List[dict]] = None,
    window: typing.Optional[sublime.Window] = None,
    *,
    delay: int = 0,
) -> None:
    window = window or sublime.active_window()

    if delay != 0:
        _logger.debug(
            "scheduling password connection command for %s to be run on window %d in %d seconds...",
            f"{login}@{format_ip_addr(host)}:{port}",
            window.id(),
            delay,
        )

    sublime.set_timeout_async(
        lambda: window.run_command(
            "ssh_connect_password",
            {
                "host": host,
                "port": port,
                "login": login,
                "identifier": str(identifier) if identifier is not None else None,
                "mounts": mounts,
                "forwards": forwards,
            },
        ),
        delay,
    )