from io import StringIO
from pathlib import Path
from unittest.mock import call, patch

from cryptography.hazmat.primitives.asymmetric.ec import (
    EllipticCurvePrivateKey,
)
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from pytest import mark, raises

from paramiko import (
    ECDSAKey,
    Ed25519Key,
    Message,
    PKey,
    PublicBlob,
    RSAKey,
    UnknownKeyType,
)
from paramiko.pkey import OPENSSH, PEM, PrivateKey
from paramiko.ssh_exception import SSHException

from ._util import _support


class PKey_:
    # NOTE: this is incidentally tested by a number of other tests, such as the
    # agent.py test suite
    class from_type_string:
        def loads_from_type_and_bytes(self, keys):
            obj = PKey.from_type_string(keys.full_type, keys.pkey.asbytes())
            assert obj == keys.pkey

        def accepts_passphrase(self, keys):
            obj = PKey.from_type_string(
                keys.full_type, keys.pkey.asbytes(), password=keys.passphrase
            )
            assert obj == keys.pkey

        def raises_UnknownKeyType_for_unknown_type_names(self):
            key = b"not even a real key, smh"
            with raises(UnknownKeyType) as exc:
                PKey.from_type_string("wat", key)
            assert exc.value.key_type == "wat"
            assert exc.value.key_bytes == key

    class from_path:
        def loads_from_Path(self, keys):
            obj = PKey.from_path(keys.path, password=keys.passphrase)
            assert obj == keys.pkey

        def loads_from_str(self):
            key = PKey.from_path(str(_support("rsa.key")))
            assert isinstance(key, RSAKey)

        @patch("paramiko.pkey.Path")
        def expands_user(self, mPath):
            # real key for guts that want a real key format
            mykey = Path(_support("rsa.key"))
            pathy = mPath.return_value.expanduser.return_value
            # read_bytes for cryptography.io's loaders
            pathy.read_bytes.return_value = mykey.read_bytes()
            # open() for our own class loader
            pathy.open.return_value = mykey.open()
            # fake out exists() to avoid attempts to load cert
            pathy.exists.return_value = False
            PKey.from_path("whatever")  # we're not testing expanduser itself
            # Both key and cert paths
            mPath.return_value.expanduser.assert_has_calls([call(), call()])

        def raises_UnknownKeyType_for_unknown_types(self):
            # I.e. a real, becomes a useful object via cryptography.io, key
            # class that we do NOT support. Chose Ed448 randomly as OpenSSH
            # doesn't seem to support it either, going by ssh-keygen...
            keypath = _support("ed448.key")
            with raises(UnknownKeyType) as exc:
                PKey.from_path(keypath)
            assert issubclass(exc.value.key_type, Ed448PrivateKey)
            with open(keypath, "rb") as fd:
                assert exc.value.key_bytes == fd.read()

        def leaves_cryptography_exceptions_untouched(self):
            # a Python file is not a private key!
            with raises(ValueError):
                PKey.from_path(__file__)

        class automatically_loads_certificates:
            def existing_cert_loaded_when_given_key_path(self):
                key = PKey.from_path(_support("rsa.key"))
                # Public blob exists despite no .load_certificate call
                assert key.public_blob is not None
                assert (
                    key.public_blob.key_type == "ssh-rsa-cert-v01@openssh.com"
                )
                # And it's definitely the one we expected
                assert key.public_blob == PublicBlob.from_file(
                    _support("rsa.key-cert.pub")
                )

            def can_be_given_cert_path_instead(self):
                key = PKey.from_path(_support("rsa.key-cert.pub"))
                # It's still a key, not a PublicBlob
                assert isinstance(key, RSAKey)
                # Public blob exists despite no .load_certificate call
                assert key.public_blob is not None
                assert (
                    key.public_blob.key_type == "ssh-rsa-cert-v01@openssh.com"
                )
                # And it's definitely the one we expected
                assert key.public_blob == PublicBlob.from_file(
                    _support("rsa.key-cert.pub")
                )

            def no_cert_load_if_no_cert(self):
                # This key exists (it's a copy of the regular one) but has no
                # matching -cert.pub
                key = PKey.from_path(_support("rsa-lonely.key"))
                assert key.public_blob is None

            def excepts_usefully_if_no_key_only_cert(self):
                # TODO: is that truly an error condition? the cert is ~the
                # pubkey and we still require the privkey for signing, yea?
                # This cert exists (it's a copy of the regular one) but there's
                # no rsa-missing.key to load.
                with raises(FileNotFoundError) as info:
                    PKey.from_path(_support("rsa-missing.key-cert.pub"))
                assert info.value.filename.endswith("rsa-missing.key")

    class load_certificate:
        def rsa_public_cert_blobs(self):
            # Data to test signing with (arbitrary)
            data = b"ice weasels"
            # Load key w/o cert at first (so avoiding .from_path)
            key = RSAKey.from_private_key_file(_support("rsa.key"))
            assert key.public_blob is None
            # Sign regular-style (using, arbitrarily, SHA2)
            msg = key.sign_ssh_data(data, "rsa-sha2-256")
            msg.rewind()
            assert "rsa-sha2-256" == msg.get_text()
            signed = msg.get_binary()  # for comparison later

            # Load cert and inspect its internals
            key.load_certificate(_support("rsa.key-cert.pub"))
            assert key.public_blob is not None
            assert key.public_blob.key_type == "ssh-rsa-cert-v01@openssh.com"
            assert key.public_blob.comment == "test_rsa.key.pub"
            msg = Message(key.public_blob.key_blob)
            # cert type
            assert msg.get_text() == "ssh-rsa-cert-v01@openssh.com"
            # nonce
            msg.get_string()
            # public numbers
            assert msg.get_mpint() == key.public_numbers.e
            assert msg.get_mpint() == key.public_numbers.n
            # serial number
            assert msg.get_int64() == 1234
            # TODO: whoever wrote the OG tests didn't care about the remaining
            # fields from
            # https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys
            # so neither do I, for now...

            # Sign cert-style (still SHA256 - so this actually does almost
            # exactly the same thing under the hood as the previous sign)
            msg = key.sign_ssh_data(data, "rsa-sha2-256-cert-v01@openssh.com")
            msg.rewind()
            assert "rsa-sha2-256" == msg.get_text()
            assert signed == msg.get_binary()  # same signature as above
            msg.rewind()
            assert key.verify_ssh_sig(b"ice weasels", msg)  # our data verified

        def loading_cert_of_different_type_from_key_raises_ValueError(self):
            edkey = Ed25519Key.from_private_key_file(_support("ed25519.key"))
            err = "PublicBlob type ssh-rsa-cert-v01@openssh.com incompatible with key type ssh-ed25519"  # noqa
            with raises(ValueError, match=err):
                edkey.load_certificate(_support("rsa.key-cert.pub"))

    def fingerprint(self, keys):
        # NOTE: Hardcoded fingerprint expectation stored in fixture.
        assert keys.pkey.fingerprint == keys.expected_fp

    def algorithm_name(self, keys):
        key = keys.pkey
        if isinstance(key, RSAKey):
            assert key.algorithm_name == "RSA"
        elif isinstance(key, ECDSAKey):
            assert key.algorithm_name == "ECDSA"
        elif isinstance(key, Ed25519Key):
            assert key.algorithm_name == "ED25519"
        # TODO: corner case: AgentKey, whose .name can be cert-y (due to the
        # value of the name field passed via agent protocol) and thus
        # algorithm_name is eg "RSA-CERT" - keys loaded directly from disk will
        # never look this way, even if they have a .public_blob attached.

    class equality_and_hashing:
        def same_key_is_equal_to_itself(self, keys):
            assert keys.pkey == keys.pkey2

        def same_key_same_hash(self, keys):
            # NOTE: this isn't a great test due to hashseed randomization under
            # Python 3 preventing use of static values, but it does still prove
            # that __hash__ is implemented/doesn't explode & works across
            # instances
            assert hash(keys.pkey) == hash(keys.pkey2)

        def keys_are_not_equal_to_other_types(self, keys):
            for value in [None, True, ""]:
                assert keys.pkey != value

    class identifiers_classmethods:
        def default_is_class_name_attribute(self):
            # NOTE: not all classes _have_ this, only the ones that don't
            # customize identifiers().
            class MyKey(PKey):
                name = "it me"

            assert MyKey.identifiers() == ["it me"]

        def rsa_is_all_combos_of_cert_and_sha_type(self):
            assert RSAKey.identifiers() == [
                "rsa-sha2-256",
                "rsa-sha2-256-cert-v01@openssh.com",
                "rsa-sha2-512",
                "rsa-sha2-512-cert-v01@openssh.com",
                # Still required for identifying keys-not-algorithms! But now
                # they come last.
                "ssh-rsa",
                "ssh-rsa-cert-v01@openssh.com",
            ]

        def ed25519_is_protocol_name(self):
            assert Ed25519Key.identifiers() == ["ssh-ed25519"]

        def ecdsa_is_all_curve_names(self):
            assert ECDSAKey.identifiers() == [
                "ecdsa-sha2-nistp256",
                "ecdsa-sha2-nistp384",
                "ecdsa-sha2-nistp521",
            ]

    class write_private_key_and_file:
        @mark.parametrize(
            "key_path, expected_class",
            [
                ("rsa.key", RSAPrivateKey),
                ("ecdsa-256.key", EllipticCurvePrivateKey),
                # TODO: Ed25519 after we sort it out
            ],
        )
        def uses_private_key_property(
            self, key_path: str, expected_class: PrivateKey
        ) -> None:
            key_obj = PKey.from_path(_support(key_path))
            assert isinstance(key_obj.private_key, expected_class)

        # TODO: Ed25519 after we sort its writing out
        @mark.parametrize(
            "key_class, key_kwargs, key_name",
            [
                (RSAKey, dict(bits=4098), "RSA"),
                (ECDSAKey, dict(bits=384), "EC"),
            ],
        )
        @mark.parametrize("key_format", [PEM, OPENSSH])
        def can_write_multiple_formats(
            self,
            key_class: str,
            key_kwargs: dict,
            key_format: PrivateKey,
            key_name: str,
            tmp_path: Path,
        ) -> None:
            # Roundtrip-as-proof
            temp_key = tmp_path / "my.key"
            key = key_class.generate(**key_kwargs)
            key.write_private_key_file(
                filename=str(temp_key), file_format=key_format
            )
            read_key = PKey.from_path(temp_key)
            assert read_key == key
            # Paranoia seasoning
            if key_format is PEM:
                assert f"BEGIN {key_name} PRIVATE KEY" in temp_key.read_text()
            elif key_format is OPENSSH:
                assert "BEGIN OPENSSH PRIVATE KEY" in temp_key.read_text()


class Ed25519Key_:
    def has_non_excepting_repr_during_load_errors(self) -> None:
        """
        Inside baseball: don't throw scary looking AttributeErrors inside
        repr() shown during other errors (eg "normal" load errors due to common
        case of trying an unknown key input as each possible type).

        Most PKey subclasses don't have this particular problem, Ed25519Key
        does due to its original implementation where key material attributes
        had no default assignment.
        """
        with raises(
            SSHException, match="not a valid OPENSSH private key file"
        ) as info:
            Ed25519Key(file_obj=StringIO("ohai! I have lines. Technically."))
        # When bug under test is not fixed, this will blow up with
        # AttributeErrors about _signing_key/_verifying_key (as they won't have
        # been assigned).
        # When fixed, we get a normal looking repr (albeit whose fingerprint
        # will effectively be that of an 'empty' key bytes)
        assert (
            repr(info.traceback[1].locals["self"])
            == "PKey(alg=ED25519, bits=256, fp=SHA256:UsIasxMWEd9VFEwjARWWtGJ08DgHp1eib3gSBLed54U)"
        )
