# Copyright 2020 Red Hat
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.
from __future__ import absolute_import

import collections
import typing

import netaddr
from oslo_log import log

import tobiko
from tobiko.openstack.topology import _config
from tobiko.shell import ssh

LOG = log.getLogger(__name__)


class UreachableSSHServer(tobiko.TobikoException):
    message = ("Unable to reach SSH server through any address: {addresses}. "
               "Failures: {failures}")


class SSHConnection(object):

    def __init__(self,
                 address: netaddr.IPAddress,
                 ssh_client: typing.Optional[ssh.SSHClientFixture] = None,
                 proxy_client: typing.Optional[ssh.SSHClientFixture] = None,
                 failure: typing.Optional[Exception] = None):
        self.address = address
        self.ssh_client = ssh_client
        self.proxy_client = proxy_client
        self.failure = failure

    def __repr__(self) -> str:
        attributes = ", ".join(f"{n}={v!r}"
                               for n, v in self._iter_attributes())
        return f"{type(self).__name__}({attributes})"

    def _iter_attributes(self):
        yield 'address', self.address
        if self.ssh_client is not None:
            yield 'ssh_client', self.ssh_client
        if self.proxy_client is not None:
            yield 'proxy_client', self.proxy_client
        if self.failure is not None:
            yield 'failure', self.failure

    @property
    def is_valid(self) -> bool:
        return (self.failure is None and
                self.ssh_client is not None)


SSHConnectionKey = typing.Tuple[netaddr.IPAddress,
                                typing.Optional[ssh.SSHClientFixture]]
SSHConnectionDict = typing.Dict[SSHConnectionKey, SSHConnection]


class SSHConnectionManager(tobiko.SharedFixture):

    config = tobiko.required_fixture(_config.OpenStackTopologyConfig)

    def __init__(self):
        super(SSHConnectionManager, self).__init__()
        self._connections: SSHConnectionDict = collections.OrderedDict()

    def cleanup_fixture(self):
        connections = list(self._connections.values())
        self._connections.clear()
        for connection in connections:
            ssh_client = connection.ssh_client
            if ssh_client is not None:
                ssh_client.close()

    def connect(self,
                addresses: typing.List[netaddr.IPAddress],
                proxy_client: typing.Optional[ssh.SSHClientFixture] = None,
                **connect_parameters) \
            -> ssh.SSHClientFixture:
        if not addresses:
            raise ValueError(f"'addresses' list is empty: {addresses}")
        connections = self.list_connections(addresses,
                                            proxy_client=proxy_client)
        try:
            connection = connections.with_attributes(is_valid=True).first
        except tobiko.ObjectNotFound:
            pass
        else:
            assert isinstance(connection.ssh_client, ssh.SSHClientFixture)
            return connection.ssh_client

        for connection in connections.with_attributes(failure=None):
            # connection not tried yet
            LOG.debug("Establishing SSH connection to "
                      f"'{connection.address}' (proxy_client={proxy_client})")
            try:
                ssh_client = self.ssh_client(connection.address,
                                             proxy_client=proxy_client,
                                             **connect_parameters)
                ssh_client.connect(retry_count=1, connection_attempts=1)
            except Exception as ex:
                LOG.debug("Failed establishing SSH connect to "
                          f"'{connection.address}': {ex}")
                # avoid re-checking again later the same address
                connection.failure = ex
                continue
            else:
                # cache valid connection SSH client for later use
                connection.ssh_client = ssh_client
                assert connection.is_valid
                return ssh_client

        failures = '\n'.join(str(connection.failure)
                             for connection in connections)
        raise UreachableSSHServer(addresses=addresses,
                                  failures=failures)

    def list_connections(
            self,
            addresses: typing.List[netaddr.IPAddress],
            proxy_client: ssh.SSHClientFixture = None) \
            -> tobiko.Selection[SSHConnection]:
        # Ensure there is any address duplication
        addresses = list(collections.OrderedDict.fromkeys(addresses))
        return tobiko.Selection[SSHConnection](
            self.get_connection(address, proxy_client=proxy_client)
            for address in addresses)

    def get_connection(
            self,
            address: netaddr.IPAddress,
            proxy_client: ssh.SSHClientFixture = None) \
            -> SSHConnection:
        tobiko.check_valid_type(address, netaddr.IPAddress)
        tobiko.check_valid_type(proxy_client, ssh.SSHClientFixture,
                                type(None))
        connection = SSHConnection(address, proxy_client=proxy_client)
        return self._connections.setdefault((address, proxy_client),
                                            connection)

    def ssh_client(self, address, username=None, port=None,
                   key_filename=None, **ssh_parameters):
        username = username or self.config.conf.username
        port = port or self.config.conf.port
        key_filename = key_filename or self.config.conf.key_file
        return ssh.ssh_client(host=str(address),
                              port=port,
                              username=username,
                              key_filename=key_filename,
                              **ssh_parameters)


SSH_CONNECTIONS = SSHConnectionManager()


def ssh_connect(addresses: typing.List[netaddr.IPAddress],
                manager: typing.Optional[SSHConnectionManager] = None,
                **connect_parameters) -> ssh.SSHClientFixture:
    manager = manager or SSH_CONNECTIONS
    return manager.connect(addresses=addresses, **connect_parameters)
