# This file is part of django-ca (https://github.com/mathiasertl/django-ca).
#
# django-ca 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 3 of the License, or (at your
# option) any later version.
#
# django-ca 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 django-ca. If not, see
# <http://www.gnu.org/licenses/>.

"""Model for GeneralName subclasses."""

import binascii
import ipaddress
import typing
from datetime import datetime
from ipaddress import ip_address, ip_network
from typing import Any, List, Optional, Union

import idna
from pydantic import BeforeValidator, Discriminator, Tag, TypeAdapter, model_validator

import asn1crypto.core
from cryptography import x509

from django_ca import constants
from django_ca.pydantic import validators
from django_ca.pydantic.base import CryptographyModel
from django_ca.pydantic.name import NameModel
from django_ca.pydantic.type_aliases import OIDType
from django_ca.typehints import Annotated, GeneralNames, IPAddressType, OtherNames
from django_ca.utils import encode_dns, encode_url, validate_email

ip_address_classes = (
    ipaddress.IPv4Address,
    ipaddress.IPv6Address,
    ipaddress.IPv4Network,
    ipaddress.IPv6Network,
)


def general_name_discriminator(value: Any) -> Optional[str]:
    """Decide on the discriminated type for a GeneralName value."""
    if isinstance(value, ip_address_classes):
        return "ipaddress"
    if isinstance(value, str):
        return "str"
    if isinstance(value, (list, NameModel)):
        return "name"
    if isinstance(value, (dict, OtherNameModel)):
        return "othername"
    return "str"


def other_name_type_aliases(value: Any) -> Any:
    """Validator to convert OtherName aliases."""
    return constants.OTHER_NAME_ALIASES.get(value, value)


class OtherNameModel(CryptographyModel[x509.OtherName]):
    """Pydantic model wrapping :py:class:`~cg:cryptography.x509.OtherName`.

    The `oid` argument may be any valid object identifier as dotted string (e.g. ``"1.2.3"``).

    The `type` argument may be any type in :py:attr:`~django_ca.constants.OTHER_NAME_TYPES` or
    :py:class:`~django_ca.constants.OTHER_NAME_ALIASES`.

    The type of the `value` argument depends on the `type` value. String variants (``UTFString``, etc.)
    require a ``str``, boolean requires a ``bool`` value and so on:

    >>> OtherNameModel(oid="1.2.3", type="BOOLEAN", value=True)
    OtherNameModel(oid='1.2.3', type='BOOLEAN', value=True)
    >>> OtherNameModel(oid="1.2.3", type="NULL", value=None)
    OtherNameModel(oid='1.2.3', type='NULL', value=None)

    For datetime variants (``UTCTIME`` and ``GENERALIZEDTIME``), you must pass a timezone-aware object:

    >>> from datetime import timezone
    >>> dt = datetime(2021, 10, 5, 22, 1, 4, tzinfo=timezone.utc)
    >>> OtherNameModel(oid="1.2.3", type="UTCTIME", value=dt)  # doctest: +NORMALIZE_WHITESPACE
    OtherNameModel(oid='1.2.3',
        type='UTCTIME',
        value=datetime.datetime(2021, 10, 5, 22, 1, 4, tzinfo=datetime.timezone.utc))
    >>> OtherNameModel(oid="1.2.3", type="GENERALIZEDTIME", value=dt)  # doctest: +NORMALIZE_WHITESPACE
    OtherNameModel(oid='1.2.3',
        type='GENERALIZEDTIME',
        value=datetime.datetime(2021, 10, 5, 22, 1, 4, tzinfo=datetime.timezone.utc))

    For ``INTEGER``, you can pass an ``int`` or a ``str`` for a base 16 integer:

    >>> OtherNameModel(oid="1.2.3", type="INTEGER", value=12)
    OtherNameModel(oid='1.2.3', type='INTEGER', value=12)
    >>> OtherNameModel(oid="1.2.3", type="INTEGER", value="0x123")  # 0x123 is 291 in decimal
    OtherNameModel(oid='1.2.3', type='INTEGER', value=291)

    Finally, for an ``OctetString``, pass the raw bytes or as a hex-encoded string:

    >>> OtherNameModel(oid="1.2.3", type="OctetString", value=b"\\x61\\x62\\x63")
    OtherNameModel(oid='1.2.3', type='OctetString', value='616263')
    >>> OtherNameModel(oid="1.2.3", type="OctetString", value="09CFF1A")
    OtherNameModel(oid='1.2.3', type='OctetString', value='09CFF1A')

    As usual, the ``cryptography`` property will return the cryptography variant of the model:

    >>> OtherNameModel(oid="1.2.3", type="IA5STRING", value="some string").cryptography
    <OtherName(type_id=<ObjectIdentifier(oid=1.2.3, name=Unknown OID)>, value=b'\\x16\\x0bsome string')>
    """

    oid: OIDType
    type: Annotated[OtherNames, BeforeValidator(other_name_type_aliases)]
    value: Optional[Union[str, bool, datetime, int]]

    @classmethod
    def _parse_bytes(cls, value: bytes) -> str:
        return binascii.hexlify(value).upper().decode("ascii")

    @model_validator(mode="before")
    @classmethod
    def parse_cryptography(cls, data: Any) -> Any:
        """Parse cryptography instances."""
        if isinstance(data, x509.OtherName):
            try:
                value = asn1crypto.core.load(data.value)
            except ValueError as ex:
                raise ValueError(f"could not parse asn1 data: {ex}") from ex

            if name_type := constants.OTHER_NAME_NAMES.get(type(value)):
                name_value = value.native

                if isinstance(value, asn1crypto.core.OctetString):
                    name_value = cls._parse_bytes(name_value)

                return {"oid": data.type_id.dotted_string, "type": name_type, "value": name_value}

            raise ValueError(f"{value.tag}: Unknown otherName type found.")

        if isinstance(data, dict) and data.get("type") == "OctetString":
            if isinstance(data.get("value"), bytes):
                data["value"] = cls._parse_bytes(data["value"])

        return data

    @model_validator(mode="after")
    def check_consistency(self) -> "OtherNameModel":
        """Validator to check that the `type` matches the type of `value`."""
        if self.type in ("UTF8String", "UNIVERSALSTRING", "IA5STRING") and not isinstance(self.value, str):
            raise ValueError(f"{self.type}: Value must be a str object.")
        if self.type == "BOOLEAN" and not isinstance(self.value, bool):
            raise ValueError(f"{self.type}: Value must be a boolean.")
        if self.type in ("UTCTIME", "GENERALIZEDTIME") and not isinstance(self.value, datetime):
            raise ValueError(f"{self.type}: Value must be a datetime object.")
        if self.type == "INTEGER":
            if isinstance(self.value, str):
                if self.value.startswith("0x"):
                    self.value = int(self.value, 16)
                else:
                    try:
                        self.value = int(self.value)
                    except ValueError:
                        pass

            if not isinstance(self.value, int):
                raise ValueError(f"{self.type}: Value must be an int.")
        if self.type == "NULL" and self.value is not None:
            raise ValueError(f"{self.type}: Value must be None.")
        if self.type == "OctetString" and not isinstance(self.value, str):
            raise ValueError(f"{self.type}: Value must be a str object.")
        return self

    @property
    def cryptography(self) -> x509.OtherName:
        """Convert to a :py:class:`~cg:cryptography.x509.OtherName` instance."""
        if self.type == "OctetString":
            hex_value = typing.cast(str, self.value)  # asserted by the validator
            value = asn1crypto.core.OctetString(bytes(bytearray.fromhex(hex_value))).dump()
        elif asn1_cls := constants.OTHER_NAME_TYPES.get(self.type):
            value = asn1_cls(self.value).dump()
        else:  # pragma: no cover  # we cover all cases
            raise ValueError(f"{self.type}: Unknown type")

        return x509.OtherName(type_id=x509.ObjectIdentifier(self.oid), value=value)


class GeneralNameModel(CryptographyModel[x509.GeneralName]):
    """Pydantic model wrapping :py:class:`~cg:cryptography.x509.NameAttribute`.

    This model takes a `type` named in :py:attr:`~django_ca.constants.GENERAL_NAME_TYPES` and a `value` that
    is usually a ``str``:

    >>> GeneralNameModel(type="DNS", value="example.com")
    GeneralNameModel(type='DNS', value='example.com')

    For directory names, you have to pass a :py:class:`~django_ca.pydantic.name.NameModel` instead:

    >>> GeneralNameModel(
    ...     type="dirName", value=[{"oid": "2.5.4.3", "value": "example.com"}]
    ... )  # doctest: +NORMALIZE_WHITESPACE
    GeneralNameModel(type='dirName',
                     value=NameModel(root=[NameAttributeModel(oid='2.5.4.3', value='example.com')]))

    For :py:class:`~cg:cryptography.x509.OtherName` instances, pass a
    :py:class:`~django_ca.pydantic.general_name.OtherNameModel` instead:

    >>> GeneralNameModel(
    ...     type="otherName", value={"oid": "2.5.4.3", "type": "BOOLEAN", 'value': True}
    ... )  # doctest: +NORMALIZE_WHITESPACE
    GeneralNameModel(type='otherName',
                     value=OtherNameModel(oid='2.5.4.3', type='BOOLEAN', value=True))
    """

    type: GeneralNames

    # Use a discriminated Union so that pydantic can more efficiently determine the type. Without
    # discrimination, passing large IPv4/IPv6 networks (which are iterable, just like a list of str intended
    # for NameModel) would invoke the NameAttribute validation for every address in the network, making this
    # extremely slow for large network segments.
    value: Annotated[
        Union[
            Annotated[str, Tag("str")],
            Annotated[NameModel, Tag("name")],
            Annotated[OtherNameModel, Tag("othername")],
            Annotated[IPAddressType, Tag("ipaddress")],
        ],
        Discriminator(general_name_discriminator),
    ]

    @model_validator(mode="before")
    @classmethod
    def parse_cryptography(cls, data: Any) -> Any:
        """Validator to parse cryptography values."""
        if isinstance(data, x509.RegisteredID):
            return {"type": "RID", "value": data.value.dotted_string}
        if isinstance(data, x509.OtherName):
            return {"type": "otherName", "value": OtherNameModel.model_validate(data)}
        if isinstance(data, x509.DirectoryName):
            return {"type": "dirName", "value": list(data.value)}
        if isinstance(data, x509.GeneralName):  # email, URI, DNS, IPAddress
            type_value = constants.GENERAL_NAME_NAMES[type(data)]
            return {"type": type_value, "value": data.value}

        return data

    @model_validator(mode="after")
    def validate_value(self) -> "GeneralNameModel":  # noqa: PLR0912
        """Validator to make sure that `value` is of the right type."""
        if self.type == "URI":
            if not isinstance(self.value, str):
                raise ValueError(f"{self.value}: Must be a str for type {self.type}")

            try:
                self.value = encode_url(self.value)
            except idna.IDNAError as ex:
                raise ValueError(f"Could not parse DNS name in URL: {self.value}") from ex
        elif self.type == "email":
            if not isinstance(self.value, str):
                raise ValueError(f"{self.value}: Must be a str for type {self.type}")

            self.value = validate_email(self.value)
        elif self.type == "IP":
            if isinstance(self.value, str):
                try:
                    self.value = ip_address(self.value)
                except ValueError:
                    try:
                        self.value = ip_network(self.value)
                    except ValueError as ex:
                        raise ValueError(f"{self.value}: Could not parse IP address") from ex

            elif not isinstance(self.value, ip_address_classes):
                raise ValueError(f"{self.value}: Must be an IPAddress/IPNetwork for type {self.type}")

        elif self.type == "RID":
            if not isinstance(self.value, str):
                raise ValueError(f"{self.value}: Must be a str for type {self.type}")

            validators.oid_validator(self.value)
        elif self.type == "otherName":
            if not isinstance(self.value, OtherNameModel):
                raise ValueError(f"{self.value}: Must be OtherNameModel for type {self.type}")
        elif self.type == "DNS":
            if not isinstance(self.value, str):
                raise ValueError(f"{self.value}: Must be a str for type {self.type}")

            self.value = encode_dns(self.value)
        elif self.type == "dirName":
            pass
        else:
            raise ValueError(f"{self.type}: Unknown type")  # pragma: no cover

        return self

    @property
    def cryptography(self) -> x509.GeneralName:
        """Convert to a :py:class:`~cg:cryptography.x509.GeneralName` instance."""
        if self.type == "RID":
            if not isinstance(self.value, str):  # pragma: no cover  # just to make mypy happy
                raise ValueError(f"{self.value}: Must be a str for type {self.type}")

            return x509.RegisteredID(x509.ObjectIdentifier(self.value))
        if self.type == "dirName":
            if not isinstance(self.value, NameModel):  # pragma: no cover  # just to make mypy happy
                raise ValueError(f"{self.value}: Must be a str for type {self.type}")

            return x509.DirectoryName(self.value.cryptography)
        if self.type == "otherName":
            if not isinstance(self.value, OtherNameModel):  # pragma: no cover  # just to make mypy happy
                raise ValueError(f"{self.value}: Must be a OtherNameModel for type {self.type}")

            return self.value.cryptography

        # TYPEHINT NOTE: constant has type GeneralName, abstract constructor does not take arguments
        return constants.GENERAL_NAME_TYPES[self.type](self.value)  # type: ignore[call-arg]


GeneralNameModelList = TypeAdapter(List[GeneralNameModel])
