"""authentik Kerberos Source Models"""

import os
from pathlib import Path
from tempfile import gettempdir
from typing import Any

import gssapi
import pglock
from django.db import connection, models
from django.db.models.fields import b64decode
from django.http import HttpRequest
from django.shortcuts import reverse
from django.templatetags.static import static
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from kadmin import KAdmin, KAdminApiVersion
from kadmin.exceptions import PyKAdminException
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger

from authentik.core.models import (
    GroupSourceConnection,
    PropertyMapping,
    Source,
    UserSourceConnection,
    UserTypes,
)
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.flows.challenge import RedirectChallenge

LOGGER = get_logger()


# Creating kadmin connections is expensive. As such, this global is used to reuse
# existing kadmin connections instead of creating new ones
_kadmin_connections: dict[str, Any] = {}


class KAdminType(models.TextChoices):
    MIT = "MIT"
    HEIMDAL = "Heimdal"
    OTHER = "other"


class KerberosSource(Source):
    """Federate Kerberos realm with authentik"""

    realm = models.TextField(help_text=_("Kerberos realm"), unique=True)
    krb5_conf = models.TextField(
        blank=True,
        help_text=_("Custom krb5.conf to use. Uses the system one by default"),
    )
    kadmin_type = models.TextField(
        choices=KAdminType.choices, default=KAdminType.OTHER, help_text=_("KAdmin server type")
    )

    sync_users = models.BooleanField(
        default=False, help_text=_("Sync users from Kerberos into authentik"), db_index=True
    )
    sync_users_password = models.BooleanField(
        default=True,
        help_text=_("When a user changes their password, sync it back to Kerberos"),
        db_index=True,
    )
    sync_principal = models.TextField(
        help_text=_("Principal to authenticate to kadmin for sync."), blank=True
    )
    sync_password = models.TextField(
        help_text=_("Password to authenticate to kadmin for sync"), blank=True
    )
    sync_keytab = models.TextField(
        help_text=_(
            "Keytab to authenticate to kadmin for sync. "
            "Must be base64-encoded or in the form TYPE:residual"
        ),
        blank=True,
    )
    sync_ccache = models.TextField(
        help_text=_(
            "Credentials cache to authenticate to kadmin for sync. "
            "Must be in the form TYPE:residual"
        ),
        blank=True,
    )

    spnego_server_name = models.TextField(
        help_text=_(
            "Force the use of a specific server name for SPNEGO. Must be in the form HTTP@hostname"
        ),
        blank=True,
    )
    spnego_keytab = models.TextField(
        help_text=_("SPNEGO keytab base64-encoded or path to keytab in the form FILE:path"),
        blank=True,
    )
    spnego_ccache = models.TextField(
        help_text=_("Credential cache to use for SPNEGO in form type:residual"),
        blank=True,
    )

    password_login_update_internal_password = models.BooleanField(
        default=False,
        help_text=_(
            "If enabled, the authentik-stored password will be updated upon "
            "login with the Kerberos password backend"
        ),
    )

    class Meta:
        verbose_name = _("Kerberos Source")
        verbose_name_plural = _("Kerberos Sources")

    def __str__(self):
        return f"Kerberos Source {self.name}"

    @property
    def component(self) -> str:
        return "ak-source-kerberos-form"

    @property
    def serializer(self) -> type[Serializer]:
        from authentik.sources.kerberos.api.source import KerberosSourceSerializer

        return KerberosSourceSerializer

    @property
    def property_mapping_type(self) -> type[PropertyMapping]:
        return KerberosSourcePropertyMapping

    @property
    def icon_url(self) -> str:
        icon = super().icon_url
        if not icon:
            return static("authentik/sources/kerberos.png")
        return icon

    def ui_login_button(self, request: HttpRequest) -> UILoginButton:
        return UILoginButton(
            challenge=RedirectChallenge(
                data={
                    "to": reverse(
                        "authentik_sources_kerberos:spnego-login",
                        kwargs={"source_slug": self.slug},
                    ),
                }
            ),
            name=self.name,
            icon_url=self.icon_url,
        )

    def ui_user_settings(self) -> UserSettingSerializer | None:
        return UserSettingSerializer(
            data={
                "title": self.name,
                "component": "ak-user-settings-source-kerberos",
                "configure_url": reverse(
                    "authentik_sources_kerberos:spnego-login",
                    kwargs={"source_slug": self.slug},
                ),
                "icon_url": self.icon_url,
            }
        )

    @property
    def sync_lock(self) -> pglock.advisory:
        """Redis lock for syncing Kerberos to prevent multiple parallel syncs happening"""
        return pglock.advisory(
            lock_id=f"goauthentik.io/{connection.schema_name}/sources/kerberos/sync/{self.slug}",
            timeout=0,
            side_effect=pglock.Return,
        )

    def get_base_user_properties(self, principal: str, **kwargs):
        localpart, _ = principal.rsplit("@", 1)

        properties = {
            "username": localpart,
            "type": UserTypes.INTERNAL,
            "path": self.get_user_path(),
        }

        if "principal_obj" in kwargs:
            princ_expiry = kwargs["principal_obj"].expire_time
            properties["is_active"] = princ_expiry is None or princ_expiry > now()

        return properties

    def get_base_group_properties(self, group_id: str, **kwargs):
        return {
            "name": group_id,
        }

    @property
    def tempdir(self) -> Path:
        """Get temporary storage for Kerberos files"""
        path = (
            Path(gettempdir())
            / "authentik"
            / connection.schema_name
            / "sources"
            / "kerberos"
            / str(self.pk)
        )
        path.mkdir(mode=0o700, parents=True, exist_ok=True)
        return path

    @property
    def krb5_conf_path(self) -> str | None:
        """Get krb5.conf path"""
        if not self.krb5_conf:
            return None
        conf_path = self.tempdir / "krb5.conf"
        conf_path.write_text(self.krb5_conf)
        return str(conf_path)

    def _kadmin_init(self) -> KAdmin | None:
        api_version = None
        match self.kadmin_type:
            case KAdminType.MIT:
                api_version = KAdminApiVersion.Version4
            case KAdminType.HEIMDAL:
                api_version = KAdminApiVersion.Version2
            case KAdminType.OTHER:
                api_version = KAdminApiVersion.Version2
        # kadmin doesn't use a ccache for its connection
        # as such, we don't need to create a separate ccache for each source
        if not self.sync_principal:
            return None
        if self.sync_password:
            return KAdmin.with_password(
                self.sync_principal,
                self.sync_password,
                api_version=api_version,
            )
        if self.sync_keytab:
            keytab = self.sync_keytab
            if ":" not in keytab:
                keytab_path = self.tempdir / "kadmin_keytab"
                keytab_path.touch(mode=0o600)
                keytab_path.write_bytes(b64decode(self.sync_keytab))
                keytab = f"FILE:{keytab_path}"
            return KAdmin.with_keytab(
                self.sync_principal,
                keytab,
                api_version=api_version,
            )
        if self.sync_ccache:
            return KAdmin.with_ccache(
                self.sync_principal,
                self.sync_ccache,
                api_version=api_version,
            )
        return None

    def connection(self) -> KAdmin | None:
        """Get kadmin connection"""
        if str(self.pk) not in _kadmin_connections:
            kadm = self._kadmin_init()
            if kadm is not None:
                _kadmin_connections[str(self.pk)] = self._kadmin_init()
        return _kadmin_connections.get(str(self.pk), None)

    def check_connection(self) -> dict[str, str]:
        """Check Kerberos Connection"""
        status = {"status": "ok"}
        if not self.sync_users:
            return status
        with Krb5ConfContext(self):
            try:
                kadm = self.connection()
                if kadm is None:
                    status["status"] = "no connection"
                    return status
                status["principal_exists"] = kadm.principal_exists(self.sync_principal)
            except PyKAdminException as exc:
                status["status"] = str(exc)
        return status

    def get_gssapi_store(self) -> dict[str, str]:
        """Get GSSAPI credentials store for this source"""
        ccache = self.spnego_ccache
        keytab = None

        if not ccache:
            ccache_path = self.tempdir / "spnego_ccache"
            ccache_path.touch(mode=0o600)
            ccache = f"FILE:{ccache_path}"

        if self.spnego_keytab:
            # Keytab is of the form type:residual, use as-is
            if ":" in self.spnego_keytab:
                keytab = self.spnego_keytab
            # Parse the keytab and write it in the file
            else:
                keytab_path = self.tempdir / "spnego_keytab"
                keytab_path.touch(mode=0o600)
                keytab_path.write_bytes(b64decode(self.spnego_keytab))
                keytab = f"FILE:{keytab_path}"

        store = {"ccache": ccache}
        if keytab is not None:
            store["keytab"] = keytab
        return store

    def get_gssapi_creds(self) -> gssapi.creds.Credentials | None:
        """Get GSSAPI credentials for this source"""
        try:
            name = None
            if self.spnego_server_name:
                # pylint: disable=c-extension-no-member
                name = gssapi.names.Name(
                    base=self.spnego_server_name,
                    name_type=gssapi.raw.types.NameType.hostbased_service,
                )
            return gssapi.creds.Credentials(
                usage="accept", name=name, store=self.get_gssapi_store()
            )
        except gssapi.exceptions.GSSError as exc:
            LOGGER.warning("GSSAPI credentials failure", exc=exc)
            return None


class Krb5ConfContext:
    """
    Context manager to set the path to the krb5.conf config file.
    """

    def __init__(self, source: KerberosSource):
        self._source = source
        self._path = self._source.krb5_conf_path
        self._previous = None

    def __enter__(self):
        if not self._path:
            return
        self._previous = os.environ.get("KRB5_CONFIG", None)
        os.environ["KRB5_CONFIG"] = self._path

    def __exit__(self, *args, **kwargs):
        if not self._path:
            return
        if self._previous:
            os.environ["KRB5_CONFIG"] = self._previous
        else:
            del os.environ["KRB5_CONFIG"]


class KerberosSourcePropertyMapping(PropertyMapping):
    """Map Kerberos Property to User object attribute"""

    @property
    def component(self) -> str:
        return "ak-property-mapping-source-kerberos-form"

    @property
    def serializer(self) -> type[Serializer]:
        from authentik.sources.kerberos.api.property_mappings import (
            KerberosSourcePropertyMappingSerializer,
        )

        return KerberosSourcePropertyMappingSerializer

    def __str__(self):
        return str(self.name)

    class Meta:
        verbose_name = _("Kerberos Source Property Mapping")
        verbose_name_plural = _("Kerberos Source Property Mappings")


class UserKerberosSourceConnection(UserSourceConnection):
    """Connection to configured Kerberos Sources."""

    @property
    def serializer(self) -> type[Serializer]:
        from authentik.sources.kerberos.api.source_connection import (
            UserKerberosSourceConnectionSerializer,
        )

        return UserKerberosSourceConnectionSerializer

    class Meta:
        verbose_name = _("User Kerberos Source Connection")
        verbose_name_plural = _("User Kerberos Source Connections")


class GroupKerberosSourceConnection(GroupSourceConnection):
    """Connection to configured Kerberos Sources."""

    @property
    def serializer(self) -> type[Serializer]:
        from authentik.sources.kerberos.api.source_connection import (
            GroupKerberosSourceConnectionSerializer,
        )

        return GroupKerberosSourceConnectionSerializer

    class Meta:
        verbose_name = _("Group Kerberos Source Connection")
        verbose_name_plural = _("Group Kerberos Source Connections")
