from dataclasses import dataclass
from typing import Iterable, Optional, Set

from asn1crypto import x509
from pyhanko.config.api import (
    ConfigurableMixin,
    process_bit_string_flags,
    process_oids,
)
from pyhanko_certvalidator.errors import InvalidCertificateError


def _match_usages(required: set, present: set, need_all: bool):
    if need_all:
        return not (required - present)
    else:
        # intersection must be non-empty
        return bool(required & present)


@dataclass(frozen=True)
class KeyUsageConstraints(ConfigurableMixin):
    """
    Convenience class to pass around key usage requirements and validate them.
    Intended to be flexible enough to handle both PKIX and ISO 32000 certificate
    seed value constraint semantics.

    .. versionchanged:: 0.6.0
        Bring extended key usage semantics in line with :rfc:`5280` (PKIX).
    """

    key_usage: Optional[Iterable[str]] = None
    """
    All or some (depending on :attr:`match_all_key_usage`) of these key usage
    extensions must be present in the signer's certificate.
    If not set or empty, all key usages are considered acceptable.
    """

    key_usage_forbidden: Optional[Iterable[str]] = None
    """
    These key usages must not be present in the signer's certificate.

    .. note::
        This behaviour is undefined in :rfc:`5280` (PKIX), but included for
        compatibility with certificate seed value settings in ISO 32000.
    """

    extd_key_usage: Optional[Iterable[str]] = None
    """
    List of acceptable key purposes that can appear in an extended key
    usage extension in the signer's certificate, if such an extension is at all
    present. If not set, all extended key usages are considered acceptable.

    If no extended key usage extension is present, or if the
    ``anyExtendedKeyUsage`` key purpose ID is present, the resulting behaviour
    depends on :attr:`explicit_extd_key_usage_required`.

    Setting this option to the empty set (as opposed to ``None``) effectively
    bans all (presumably unrecognised) extended key usages.

    .. warning::
        Note the difference in behaviour with :attr:`key_usage` for empty
        sets of valid usages.

    .. warning::
        Contrary to what some CAs seem to believe, the criticality of the
        extended key usage extension is irrelevant here.
        Even a non-critical EKU extension **must** be enforced according to
        :rfc:`5280` § 4.2.1.12.

        In practice, many certificate authorities issue non-repudiation certs
        that can also be used for TLS authentication by only including the
        TLS client authentication key purpose ID in the EKU extension.
        Interpreted strictly, :rfc:`5280` bans such certificates from being
        used to sign documents, and pyHanko will enforce these semantics
        if :attr:`extd_key_usage` is not ``None``.
    """

    explicit_extd_key_usage_required: bool = True
    """
    .. versionadded:: 0.6.0

    Require an extended key usage extension with the right key usages to be
    present if :attr:`extd_key_usage` is non-empty.

    If this flag is ``True``, at least one key purpose in :attr:`extd_key_usage`
    must appear in the certificate's extended key usage, and
    ``anyExtendedKeyUsage`` will be ignored.
    """

    match_all_key_usages: bool = False
    """
    .. versionadded:: 0.6.0

    If ``True``, all key usages indicated in :attr:`key_usage` must be present
    in the certificate. If ``False``, one match suffices.

    If :attr:`key_usage` is empty or ``None``, this option has no effect.
    """

    def validate(
        self,
        cert: x509.Certificate,
        extra_asserted_key_usages: Iterable[str] = (),
        extra_asserted_extd_key_usages: Iterable[str] = (),
    ):
        self._validate_key_usage_extension(
            cert.key_usage_value, extra=set(extra_asserted_key_usages)
        )
        self._validate_extd_key_usage_extension(
            cert.extended_key_usage_value,
            extra=set(extra_asserted_extd_key_usages),
        )

    def _validate_key_usage_extension(self, key_usage_extension_value, extra):
        # First, check the "regular" key usage extension
        cert_ku = (
            set(key_usage_extension_value.native)
            if key_usage_extension_value is not None
            else set()
        )
        self.validate_asserted_key_usage(cert_ku | extra)

    def validate_asserted_key_usage(self, asserted_key_usages: Set[str]):
        if not self.key_usage:
            return

        key_usage = set(self.key_usage or ())
        key_usage_forbidden = set(self.key_usage_forbidden or ())
        # check blacklisted key usages (ISO 32k)
        forbidden_ku = asserted_key_usages & key_usage_forbidden
        if forbidden_ku:
            rephrased = map(lambda s: s.replace('_', ' '), forbidden_ku)
            raise InvalidCertificateError(
                "The active key usage policy explicitly bans certificates "
                f"used for {', '.join(rephrased)}."
            )

        # check required key usage extension values
        need_all_ku = self.match_all_key_usages
        if not _match_usages(key_usage, asserted_key_usages, need_all_ku):
            rephrased = map(lambda s: s.replace('_', ' '), key_usage)
            raise InvalidCertificateError(
                "The active key usage policy requires "
                f"{'' if need_all_ku else 'at least one of '}the key "
                f"usage extensions {', '.join(rephrased)} to be present."
            )

    def _validate_extd_key_usage_extension(self, eku_extension_value, extra):
        # check extended key usage
        has_extd_key_usage_ext = eku_extension_value is not None
        cert_eku = (
            set(eku_extension_value.native) if has_extd_key_usage_ext else set()
        )
        self.validate_asserted_extended_key_usage(cert_eku | extra)

    def validate_asserted_extended_key_usage(
        self, asserted_key_usages: Set[str]
    ):
        if self.extd_key_usage is None:
            return

        if not asserted_key_usages:
            if self.explicit_extd_key_usage_required:
                raise InvalidCertificateError(
                    "The active key usage policy requires an extended "
                    "key usage extension."
                )
            return  # early out, cert is (presumably?) valid for all EKUs

        if (
            'any_extended_key_usage' in asserted_key_usages
            and not self.explicit_extd_key_usage_required
        ):
            return  # early out, cert is valid for all EKUs

        extd_key_usage = set(self.extd_key_usage or ())

        if not _match_usages(
            extd_key_usage, asserted_key_usages, need_all=False
        ):
            if extd_key_usage:
                rephrased = map(lambda s: s.replace('_', ' '), extd_key_usage)
                ok_list = f"Relevant key purposes are {', '.join(rephrased)}."
            else:
                ok_list = "There are no acceptable extended key usages."
            raise InvalidCertificateError(
                "The extended key usages for which this certificate is valid "
                f"do not match the active key usage policy. {ok_list}"
            )

    @classmethod
    def process_entries(cls, config_dict):
        super().process_entries(config_dict)

        # Deal with KeyUsage values first
        # might as well expose key_usage_forbidden while we're at it
        for key_usage_sett in ('key_usage', 'key_usage_forbidden'):
            affected_flags = config_dict.get(key_usage_sett, None)
            if affected_flags is not None:
                config_dict[key_usage_sett] = set(
                    process_bit_string_flags(
                        x509.KeyUsage,
                        affected_flags,
                        key_usage_sett.replace('_', '-'),
                    )
                )

        extd_key_usage = config_dict.get('extd_key_usage', None)
        if extd_key_usage is not None:
            config_dict['extd_key_usage'] = set(
                process_oids(
                    x509.KeyPurposeId, extd_key_usage, 'extd-key-usage'
                )
            )
