#!/usr/bin/env python3

# Libervia plugin for OpenPGP for XMPP
# Copyright (C) 2025-2026 Jérôme Poisosn (goffi@goffi.org)
# Copyright (C) 2022-2026 Tim Henkes (me@syndace.dev)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 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 Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from typing import Any

import gpg

from .exceptions import (
    DecryptionFailed,
    GPGProviderError,
    InvalidPacket,
    UnknownKey,
    VerificationFailed,
)
from .models import GPGProviderBase, GPGPublicKeyBase, GPGSecretKeyBase


class GPGPublicKey(GPGPublicKeyBase):
    """
    GPG public key implementation based on GnuPG Made Easy (GPGME).
    """

    def __init__(self, key_obj: Any) -> None:
        """
        @param key_obj: The GPGME key object.
        """

        self.__key_obj = key_obj

    @property
    def fingerprint(self) -> str:
        return self.__key_obj.fpr

    @property
    def key_obj(self) -> Any:
        return self.__key_obj


class GPGSecretKey(GPGSecretKeyBase):
    """
    GPG secret key implementation based on GnuPG Made Easy (GPGME).
    """

    def __init__(self, public_key: GPGPublicKey) -> None:
        """
        @param public_key: The public key corresponding to this secret key.
        """

        self.__public_key = public_key

    @property
    def public_key(self) -> GPGPublicKey:
        return self.__public_key


class GPGProvider(GPGProviderBase):
    """
    GPG provider implementation based on GnuPG Made Easy (GPGME).
    """

    ALGORITHM = "ed25519/cert,sign+cv25519/encr"

    def __init__(self, home_dir: str | None = None) -> None:
        """
        @param home_dir: Optional GPG home directory path to use for all operations.
        """

        self.__home_dir = home_dir

    def export_public_key(self, public_key: GPGPublicKeyBase) -> bytes:
        assert isinstance(public_key, GPGPublicKey)

        pattern = public_key.fingerprint

        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                result = c.key_export_minimal(pattern)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e

            if result is None:
                raise UnknownKey(f"Public key {pattern} not found.")

            return result

    def import_public_key(self, packet: bytes) -> GPGPublicKeyBase:
        # TODO
        # - Reject packets older than version 4
        # - Check whether it's actually a public key (through packet inspection?)

        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                result = c.key_import(packet)
            except gpg.errors.GPGMEError as e:
                # From looking at the code, `key_import` never raises. The documentation
                # says it does though, so this is included for future-proofness.
                raise GPGProviderError("Internal GPGME error") from e

            if not hasattr(result, "considered"):
                raise InvalidPacket(
                    f"Data not considered for public key import: {result}"
                )

            if len(result.imports) != 1:
                raise InvalidPacket(
                    "Public key packet does not contain exactly one public key (not"
                    " counting subkeys)."
                )

            try:
                key_obj = c.get_key(result.imports[0].fpr, secret=False)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.errors.KeyError as e:
                raise GPGProviderError("Newly imported public key not found") from e

            return GPGPublicKey(key_obj)

    def backup_secret_key(self, secret_key: GPGSecretKeyBase) -> bytes:
        assert isinstance(secret_key, GPGSecretKey)
        # TODO
        # - Handle password protection/pinentry
        # - Make sure the key is exported unencrypted

        pattern = secret_key.public_key.fingerprint

        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                result = c.key_export_secret(pattern)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e

            if result is None:
                raise UnknownKey(f"Secret key {pattern} not found.")

            return result

    def restore_secret_keys(self, data: bytes) -> set[GPGSecretKeyBase]:
        # TODO
        # - Reject packets older than version 4
        # - Check whether it's actually secret keys (through packet inspection?)

        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                result = c.key_import(data)
            except gpg.errors.GPGMEError as e:
                # From looking at the code, `key_import` never raises. The documentation
                # says it does though, so this is included for future-proofness.
                raise GPGProviderError("Internal GPGME error") from e

            if not hasattr(result, "considered"):
                raise InvalidPacket(
                    f"Data not considered for secret key import: {result}"
                )

            if len(result.imports) == 0:
                raise InvalidPacket("Secret key packet does not contain a secret key.")

            secret_keys = set()
            for import_status in result.imports:
                try:
                    key_obj = c.get_key(import_status.fpr, secret=True)
                except gpg.errors.GPGMEError as e:
                    raise GPGProviderError("Internal GPGME error") from e
                except gpg.errors.KeyError as e:
                    raise GPGProviderError("Newly imported secret key not found") from e

                secret_keys.add(GPGSecretKey(GPGPublicKey(key_obj)))

            return secret_keys

    def encrypt_symmetrically(self, plaintext: bytes, password: str) -> bytes:
        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                ciphertext, __, __ = c.encrypt(plaintext, passphrase=password, sign=False)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e

            return ciphertext

    def decrypt_symmetrically(self, ciphertext: bytes, password: str) -> bytes:
        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                plaintext, __, __ = c.decrypt(
                    ciphertext, passphrase=password, verify=False
                )
            except gpg.errors.GPGMEError as e:
                # TODO: Find out what kind of error is raised if the password is wrong and
                # re-raise it as DecryptionFailed instead.
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.UnsupportedAlgorithm as e:
                raise DecryptionFailed("Unsupported algorithm") from e

            return plaintext

    def sign(self, data: bytes, secret_keys: set[GPGSecretKeyBase]) -> bytes:
        signers = []
        for secret_key in secret_keys:
            assert isinstance(secret_key, GPGSecretKey)

            signers.append(secret_key.public_key.key_obj)

        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
            try:
                signed_data, __ = c.sign(data)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.errors.InvalidSigners as e:
                raise GPGProviderError(
                    "At least one of the secret keys is invalid for signing"
                ) from e

            return signed_data

    def sign_detached(self, data: bytes, secret_keys: set[GPGSecretKeyBase]) -> bytes:
        signers = []
        for secret_key in secret_keys:
            assert isinstance(secret_key, GPGSecretKey)

            signers.append(secret_key.public_key.key_obj)

        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
            try:
                signature, __ = c.sign(data, mode=gpg.constants.sig.mode.DETACH)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.errors.InvalidSigners as e:
                raise GPGProviderError(
                    "At least one of the secret keys is invalid for signing"
                ) from e

            return signature

    def verify(self, signed_data: bytes, public_keys: set[GPGPublicKeyBase]) -> bytes:
        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                data, result = c.verify(signed_data)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.errors.BadSignatures as e:
                raise VerificationFailed("Bad signatures on signed data") from e

            valid_signature_found = False
            for public_key in public_keys:
                assert isinstance(public_key, GPGPublicKey)

                for subkey in public_key.key_obj.subkeys:
                    for sig in result.signatures:
                        if subkey.can_sign and subkey.fpr == sig.fpr:
                            valid_signature_found = True

            if not valid_signature_found:
                raise VerificationFailed(
                    "Data not signed by one of the expected public keys"
                )

            return data

    def verify_detached(
        self, data: bytes, signature: bytes, public_keys: set[GPGPublicKeyBase]
    ) -> None:
        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                __, result = c.verify(data, signature=signature)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.errors.BadSignatures as e:
                raise VerificationFailed("Bad signatures on signed data") from e

            valid_signature_found = False
            for public_key in public_keys:
                assert isinstance(public_key, GPGPublicKey)

                for subkey in public_key.key_obj.subkeys:
                    for sig in result.signatures:
                        if subkey.can_sign and subkey.fpr == sig.fpr:
                            valid_signature_found = True

            if not valid_signature_found:
                raise VerificationFailed(
                    "Data not signed by one of the expected public keys"
                )

    def encrypt(
        self,
        plaintext: bytes,
        public_keys: set[GPGPublicKeyBase],
        signing_keys: set[GPGSecretKeyBase] | None = None,
    ) -> bytes:
        recipients = []
        for public_key in public_keys:
            assert isinstance(public_key, GPGPublicKey)

            recipients.append(public_key.key_obj)

        signers = []
        if signing_keys is not None:
            for secret_key in signing_keys:
                assert isinstance(secret_key, GPGSecretKey)

                signers.append(secret_key.public_key.key_obj)

        sign = signing_keys is not None

        kwargs = {"home_dir": self.__home_dir, "signers": signers}

        with gpg.Context(**kwargs) as c:
            try:
                ciphertext, __, __ = c.encrypt(
                    plaintext,
                    recipients=recipients,
                    sign=sign,
                    always_trust=True,
                    add_encrypt_to=True,
                )
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.errors.InvalidRecipients as e:
                raise GPGProviderError(
                    "At least one of the public keys is invalid for encryption"
                ) from e
            except gpg.errors.InvalidSigners as e:
                raise GPGProviderError(
                    "At least one of the signing keys is invalid for signing"
                ) from e

            return ciphertext

    def decrypt(
        self,
        ciphertext: bytes,
        secret_keys: set[GPGSecretKeyBase],
        public_keys: set[GPGPublicKeyBase] | None = None,
    ) -> bytes:
        verify = public_keys is not None

        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                plaintext, result, verify_result = c.decrypt(ciphertext, verify=verify)
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.UnsupportedAlgorithm as e:
                raise DecryptionFailed("Unsupported algorithm") from e

            # TODO: Check whether the data was decrypted using one of the expected secret
            # keys

            if public_keys is not None:
                valid_signature_found = False
                for public_key in public_keys:
                    assert isinstance(public_key, GPGPublicKey)

                    for subkey in public_key.key_obj.subkeys:
                        for sig in verify_result.signatures:
                            if subkey.can_sign and subkey.fpr == sig.fpr:
                                valid_signature_found = True

                if not valid_signature_found:
                    raise VerificationFailed(
                        "Data not signed by one of the expected public keys"
                    )

            return plaintext

    def list_public_keys(self, user_id: str) -> set[GPGPublicKeyBase]:
        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                return {
                    GPGPublicKey(key) for key in c.keylist(pattern=user_id, secret=False)
                }
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e

    def list_secret_keys(self, user_id: str) -> set[GPGSecretKeyBase]:
        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                return {
                    GPGSecretKey(GPGPublicKey(key))
                    for key in c.keylist(pattern=user_id, secret=True)
                }
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e

    def can_sign(self, public_key: GPGPublicKeyBase) -> bool:
        assert isinstance(public_key, GPGPublicKey)

        return any(subkey.can_sign for subkey in public_key.key_obj.subkeys)

    def can_encrypt(self, public_key: GPGPublicKeyBase) -> bool:
        assert isinstance(public_key, GPGPublicKey)

        return any(subkey.can_encrypt for subkey in public_key.key_obj.subkeys)

    def create_key(self, user_id: str) -> GPGSecretKeyBase:
        with gpg.Context(home_dir=self.__home_dir) as c:
            try:
                # A single ECC (primary) key with signing and encryption capabilities is
                # not supported, a subkey is required for encryption. Create a primary key
                # only capable of certifying subkeys
                primary_create_result = c.create_key(
                    user_id,
                    algorithm=self.ALGORITHM,
                    expires=False,
                    certify=True,
                    force=True,
                )

                primary_key_obj = c.get_key(primary_create_result.fpr, secret=True)

                c.create_subkey(
                    primary_key_obj, algorithm=self.ALGORITHM, expires=False, sign=True
                )

                c.create_subkey(
                    primary_key_obj,
                    algorithm=self.ALGORITHM,
                    expires=False,
                    encrypt=True,
                )
            except gpg.errors.GPGMEError as e:
                raise GPGProviderError("Internal GPGME error") from e
            except gpg.errors.KeyNotFound as e:
                raise GPGProviderError("Newly created key not found") from e

            return GPGSecretKey(GPGPublicKey(primary_key_obj))
