# Copyright (C) 2006-2011 Robey Pointer <robey@lag.net>
# Copyright (C) 2005, 2006, 2007 Canonical Ltd
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA

"""SSH transport implementation using Paramiko library.

This module provides SSH connection functionality using the Paramiko library
for secure transport operations. It handles SSH authentication including
agent keys, private key files, and password authentication, as well as
host key verification and management.
"""

import getpass
import logging
import os
from binascii import hexlify

import paramiko

from dromedary import _bedding, _config, _ui
from dromedary.errors import TransportError
from dromedary.osutils import pathjoin
from dromedary.ssh import SSHConnection, SSHVendor

logger = logging.getLogger("dromedary.ssh.paramiko")

SYSTEM_HOSTKEYS: dict[str, dict[str, str]] = {}
BRZ_HOSTKEYS: dict[str, dict[str, str]] = {}


def _paramiko_auth(username, password, host, port, paramiko_transport):
    """Authenticate to an SSH server using paramiko.

    Attempts authentication in the following order:
    1. SSH agent keys
    2. Private key files (id_rsa, id_dsa)
    3. Password authentication

    Args:
        username: SSH username, or None to use local username
        password: SSH password, or None for no password
        host: SSH hostname
        port: SSH port number
        paramiko_transport: Paramiko Transport object for the connection

    Raises:
        ConnectionError: If authentication fails or is not supported
    """
    # paramiko requires a username, but it might be none if nothing was
    # supplied.  If so, use the local username.
    if username is None:
        username = _config.get_auth_user(
            "ssh", host, port=port, default=getpass.getuser()
        )
    agent = paramiko.Agent()
    for key in agent.get_keys():
        logger.debug("Trying SSH agent key %s", hexlify(key.get_fingerprint()).upper())
        try:
            paramiko_transport.auth_publickey(username, key)
            return
        except paramiko.SSHException:
            pass

    # okay, try finding id_rsa or id_dss?  (posix only)
    if _try_pkey_auth(paramiko_transport, paramiko.RSAKey, username, "id_rsa"):
        return
    # DSSKey was removed in paramiko 4.0.0 as DSA keys are deprecated
    if hasattr(paramiko, "DSSKey"):
        if _try_pkey_auth(paramiko_transport, paramiko.DSSKey, username, "id_dsa"):
            return

    # If we have gotten this far, we are about to try for passwords, do an
    # auth_none check to see if it is even supported.
    supported_auth_types = []
    try:
        # Note that with paramiko <1.7.5 this logs an INFO message:
        #    Authentication type (none) not permitted.
        # So we explicitly disable the logging level for this action
        old_level = paramiko_transport.logger.level
        paramiko_transport.logger.setLevel(logging.WARNING)
        try:
            paramiko_transport.auth_none(username)
        finally:
            paramiko_transport.logger.setLevel(old_level)
    except paramiko.BadAuthenticationType as e:
        # Supported methods are in the exception
        supported_auth_types = e.allowed_types
    except paramiko.SSHException:
        # Don't know what happened, but just ignore it
        pass
    # We treat 'keyboard-interactive' and 'password' auth methods identically,
    # because Paramiko's auth_password method will automatically try
    # 'keyboard-interactive' auth (using the password as the response) if
    # 'password' auth is not available.  Apparently some Debian and Gentoo
    # OpenSSH servers require this.
    # XXX: It's possible for a server to require keyboard-interactive auth that
    # requires something other than a single password, but we currently don't
    # support that.
    if (
        "password" not in supported_auth_types
        and "keyboard-interactive" not in supported_auth_types
    ):
        raise ConnectionError(
            "Unable to authenticate to SSH host as"
            f"\n  {username}@{host}\nsupported auth types: {supported_auth_types}"
        )

    if password:
        try:
            paramiko_transport.auth_password(username, password)
            return
        except paramiko.SSHException:
            pass

    # give up and ask for a password
    password = _config.get_auth_password("ssh", host, username, port=port)
    # get_password can still return None, which means we should not prompt
    if password is not None:
        try:
            paramiko_transport.auth_password(username, password)
        except paramiko.SSHException as e:
            raise ConnectionError(
                f"Unable to authenticate to SSH host as\n  {username}@{host}\n", e
            ) from e
    else:
        raise ConnectionError(
            f"Unable to authenticate to SSH host as  {username}@{host}"
        )


def _try_pkey_auth(paramiko_transport, pkey_class, username, filename):
    """Attempt public key authentication with a private key file.

    Args:
        paramiko_transport: Paramiko Transport object for the connection
        pkey_class: Paramiko private key class (e.g., RSAKey, DSSKey)
        username: SSH username for authentication
        filename: Name of the private key file (relative to ~/.ssh/)

    Returns:
        bool: True if authentication succeeded, False otherwise
    """
    filename = os.path.expanduser("~/.ssh/" + filename)
    try:
        key = pkey_class.from_private_key_file(filename)
        paramiko_transport.auth_publickey(username, key)
        return True
    except paramiko.PasswordRequiredException:
        password = _ui.get_password(
            prompt="SSH %(filename)s password", filename=os.fsdecode(filename)
        )
        try:
            key = pkey_class.from_private_key_file(filename, password)
            paramiko_transport.auth_publickey(username, key)
            return True
        except paramiko.SSHException:
            logger.debug(
                f"SSH authentication via {os.path.basename(filename)} key failed."
            )
    except paramiko.SSHException:
        logger.debug(
            "SSH authentication via %s key failed.", os.path.basename(filename)
        )
    except OSError:
        pass
    return False


def _ssh_host_keys_config_dir():
    """Get the path to the SSH host keys configuration directory.

    Returns:
        str: Path to the directory where SSH host keys are stored
    """
    return pathjoin(_bedding.config_dir(), "ssh_host_keys")


def load_host_keys():
    """Load system host keys (probably doesn't work on windows) and any
    "discovered" keys from previous sessions.
    """
    global SYSTEM_HOSTKEYS, BRZ_HOSTKEYS
    try:
        SYSTEM_HOSTKEYS = paramiko.util.load_host_keys(
            os.path.expanduser("~/.ssh/known_hosts")
        )
    except OSError as e:
        logger.debug("failed to load system host keys: %s", e)
    brz_hostkey_path = _ssh_host_keys_config_dir()
    try:
        BRZ_HOSTKEYS = paramiko.util.load_host_keys(brz_hostkey_path)
    except OSError as e:
        logger.debug("failed to load brz host keys: %s", e)
        save_host_keys()


def save_host_keys():
    """Save "discovered" host keys in $(config)/ssh_host_keys/."""
    global SYSTEM_HOSTKEYS, BRZ_HOSTKEYS
    bzr_hostkey_path = _ssh_host_keys_config_dir()
    _bedding.ensure_config_dir_exists()

    try:
        with open(bzr_hostkey_path, "w") as f:
            f.write("# SSH host keys collected by bzr\n")
            for hostname, keys in BRZ_HOSTKEYS.items():
                for keytype, key in keys.items():
                    f.write(f"{hostname} {keytype} {key.get_base64()}\n")
    except OSError as e:
        logger.debug("failed to save bzr host keys: %s", e)


class ParamikoVendor(SSHVendor):
    """Vendor that uses paramiko."""

    def _hexify(self, s):
        """Convert a byte string to uppercase hexadecimal representation.

        Args:
            s: Byte string to convert

        Returns:
            str: Uppercase hexadecimal representation of the input
        """
        return hexlify(s).upper()

    def _connect(self, username, password, host, port):
        """Establish a low-level SSH connection using paramiko.

        Handles host key verification by checking against system known_hosts
        and breezy's stored host keys. New host keys are automatically stored.

        Args:
            username: SSH username
            password: SSH password or None
            host: SSH hostname
            port: SSH port number or None for default

        Returns:
            paramiko.Transport: Authenticated paramiko Transport object

        Raises:
            TransportError: If host key verification fails
            ConnectionError: If connection or authentication fails
        """
        global SYSTEM_HOSTKEYS, BRZ_HOSTKEYS

        from dromedary.ssh.paramiko import (
            _paramiko_auth,
            _ssh_host_keys_config_dir,
            load_host_keys,
            save_host_keys,
        )

        load_host_keys()

        try:
            t = paramiko.Transport((host, port or 22))
            t.set_log_channel("bzr.paramiko")
            t.start_client()
        except (paramiko.SSHException, OSError) as e:
            self._raise_connection_error(host, port=port, orig_error=e)

        server_key = t.get_remote_server_key()
        server_key_hex = self._hexify(server_key.get_fingerprint())
        keytype = server_key.get_name()
        if host in SYSTEM_HOSTKEYS and keytype in SYSTEM_HOSTKEYS[host]:
            our_server_key = SYSTEM_HOSTKEYS[host][keytype]
            our_server_key_hex = self._hexify(our_server_key.get_fingerprint())
        elif host in BRZ_HOSTKEYS and keytype in BRZ_HOSTKEYS[host]:
            our_server_key = BRZ_HOSTKEYS[host][keytype]
            our_server_key_hex = self._hexify(our_server_key.get_fingerprint())
        else:
            logger.warning(
                "Adding %s host key for %s: %s", keytype, host, server_key_hex
            )
            add = getattr(BRZ_HOSTKEYS, "add", None)
            if add is not None:  # paramiko >= 1.X.X
                BRZ_HOSTKEYS.add(host, keytype, server_key)
            else:
                BRZ_HOSTKEYS.setdefault(host, {})[keytype] = server_key
            our_server_key = server_key
            our_server_key_hex = self._hexify(our_server_key.get_fingerprint())
            save_host_keys()
        if server_key != our_server_key:
            filename1 = os.path.expanduser("~/.ssh/known_hosts")
            filename2 = _ssh_host_keys_config_dir()
            raise TransportError(
                f"Host keys for {host} do not match!  {our_server_key_hex} != {server_key_hex}",
                [f"Try editing {filename1} or {filename2}"],
            )

        _paramiko_auth(username, password, host, port, t)
        return t

    def connect_sftp(self, username, password, host, port):
        """Connect to an SFTP server using paramiko.

        Args:
            username: SSH username
            password: SSH password or None
            host: SSH hostname
            port: SSH port number or None for default

        Returns:
            paramiko.SFTPClient: Connected SFTP client object

        Raises:
            ConnectionError: If connection or SFTP client creation fails
        """
        t = self._connect(username, password, host, port)
        try:
            return t.open_sftp_client()
        except paramiko.SSHException as e:
            self._raise_connection_error(
                host, port=port, orig_error=e, msg="Unable to start sftp client"
            )

    def connect_ssh(self, username, password, host, port, command):
        """Connect to SSH server and execute a command.

        Args:
            username: SSH username
            password: SSH password or None
            host: SSH hostname
            port: SSH port number or None for default
            command: List of command arguments to execute

        Returns:
            _ParamikoSSHConnection: SSH connection object for command execution

        Raises:
            ConnectionError: If connection or command execution fails
        """
        t = self._connect(username, password, host, port)
        try:
            channel = t.open_session()
            cmdline = " ".join(command)
            channel.exec_command(cmdline)
            return _ParamikoSSHConnection(channel)
        except paramiko.SSHException as e:
            self._raise_connection_error(
                host, port=port, orig_error=e, msg="Unable to invoke remote bzr"
            )


class _ParamikoSSHConnection(SSHConnection):
    """An SSH connection via paramiko."""

    def __init__(self, channel):
        """Initialize a paramiko SSH connection wrapper.

        Args:
            channel: Paramiko Channel object for the SSH session
        """
        self.channel = channel

    def get_sock_or_pipes(self):
        """Get socket or pipe information for the SSH connection.

        Returns:
            tuple: A tuple containing ("socket", channel) where channel
                   is the paramiko Channel object
        """
        return ("socket", self.channel)

    def close(self):
        """Close the SSH connection channel.

        Returns:
            The result of closing the paramiko channel
        """
        return self.channel.close()


paramiko_vendor = ParamikoVendor()
