"""
LDAPS authentication strategy (Active Directory flavoured by default).

Security:
- LDAPS only (TLS 1.2+ enforced via ldap3 Tls). StartTLS as alt.
- Strict server-cert validation: CERT_REQUIRED + CA bundle from env.
- Service-account bind credentials encrypted at rest (Fernet, key in env);
  bootstrap from env var on first start.
- Bind-then-search-then-rebind flow: service account locates the user DN,
  then we rebind with the user's password to verify.
- User password is NEVER stored or logged. Failed bind raises generic
  AuthError with safe_message='Invalid credentials'.
- Group lookup via `memberOf` (AD default). Configurable via env.

Configuration is read from env on init. Admin UI to live-edit lands in
Phase 5; until then a backend restart is required after env changes.
"""
from __future__ import annotations

import json
import logging
import os
import ssl
from typing import List, Optional

import ldap3
from ldap3.core.exceptions import (
    LDAPBindError,
    LDAPException,
    LDAPInvalidCredentialsResult,
    LDAPSocketOpenError,
)
from sqlalchemy.orm import Session

from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity
from app.models.setting import Setting
from app.models.user import AuthProvider

logger = logging.getLogger(__name__)

DEFAULT_USER_FILTER = "(&(objectClass=user)(sAMAccountName={username}))"
DEFAULT_USER_ATTR_USERNAME = "sAMAccountName"
DEFAULT_USER_ATTR_EMAIL = "mail"
DEFAULT_USER_ATTR_GROUPS = "memberOf"
DEFAULT_USER_ATTR_OBJECT_GUID = "objectGUID"      # AD stable identifier
DEFAULT_USER_ATTR_DISPLAY = "displayName"


# ---------- bind credential storage ----------
SETTINGS_LDAP_BIND_PW_ENCRYPTED = "ldap_bind_password_encrypted"


def _load_or_bootstrap_bind_password(db: Session) -> str:
    """
    Return decrypted bind password. On first start, encrypt the bootstrap
    value from LDAP_BIND_PASSWORD_BOOTSTRAP and store it in `settings`,
    then strongly recommend removing the env var.
    """
    from app.auth.totp import decrypt_secret, encrypt_secret  # reuse Fernet helpers

    row = db.query(Setting).filter(Setting.key == SETTINGS_LDAP_BIND_PW_ENCRYPTED).first()
    if row and row.value:
        try:
            return decrypt_secret(row.value)
        except Exception as e:
            logger.error("LDAP bind password could not be decrypted: %s", e)
            raise AuthError(
                detail="LDAP bind password decryption failed (check AUTH_PROVIDER_CRYPTO_KEY).",
                safe_message="Authentication service unavailable",
            )

    bootstrap = os.getenv("LDAP_BIND_PASSWORD_BOOTSTRAP")
    if not bootstrap:
        raise AuthError(
            detail="No LDAP bind password configured (neither in DB nor in LDAP_BIND_PASSWORD_BOOTSTRAP).",
            safe_message="Authentication service unavailable",
        )

    enc = encrypt_secret(bootstrap)
    if row:
        row.value = enc
    else:
        row = Setting(
            key=SETTINGS_LDAP_BIND_PW_ENCRYPTED,
            value=enc,
            description="LDAP service-account bind password (Fernet-encrypted)",
        )
        db.add(row)
    db.commit()
    logger.warning(
        "LDAP bind password bootstrapped from env. "
        "Remove LDAP_BIND_PASSWORD_BOOTSTRAP from your environment now."
    )
    return bootstrap


def _build_tls() -> ldap3.Tls:
    """Build TLS config with strict cert validation."""
    ca_path = os.getenv("LDAP_CA_CERT_PATH")
    validate = os.getenv("LDAP_VALIDATE_CERT", "true").lower() == "true"

    if not validate:
        # Loud warning — never default this to off.
        logger.error(
            "LDAP_VALIDATE_CERT=false: server certificate is NOT validated. "
            "This is INSECURE and must not be used in production."
        )

    return ldap3.Tls(
        validate=ssl.CERT_REQUIRED if validate else ssl.CERT_NONE,
        version=ssl.PROTOCOL_TLS_CLIENT,
        ca_certs_file=ca_path,
    )


def _decode_guid(value) -> str:
    """objectGUID is binary; convert to canonical hex string for stable identity."""
    if isinstance(value, bytes):
        return value.hex()
    if isinstance(value, list) and value and isinstance(value[0], bytes):
        return value[0].hex()
    return str(value)


def _ensure_str(value, fallback: str = "") -> str:
    if isinstance(value, list):
        return str(value[0]) if value else fallback
    if value is None:
        return fallback
    return str(value)


def _extract_groups(memberof_value) -> List[str]:
    if memberof_value is None:
        return []
    if isinstance(memberof_value, list):
        return [str(v) for v in memberof_value if v]
    return [str(memberof_value)]


class LdapStrategy(AuthStrategy):
    """
    LDAPS strategy. Wired into AuthOrchestrator and invoked when
    AUTH_LOOKUP_ORDER lists 'ldap'.
    """

    name = AuthProvider.LDAP

    def __init__(self, db: Session):
        self.db = db

        self.host = os.getenv("LDAP_HOST", "").strip()
        self.port = int(os.getenv("LDAP_PORT", "636"))
        self.use_ssl = os.getenv("LDAP_USE_SSL", "true").lower() == "true"
        self.use_starttls = os.getenv("LDAP_USE_STARTTLS", "false").lower() == "true"

        self.bind_dn = os.getenv("LDAP_BIND_DN", "").strip()
        self.search_base = os.getenv("LDAP_USER_SEARCH_BASE", "").strip()
        self.search_filter = os.getenv("LDAP_USER_SEARCH_FILTER", DEFAULT_USER_FILTER)

        self.attr_username = os.getenv("LDAP_USER_ATTR_USERNAME", DEFAULT_USER_ATTR_USERNAME)
        self.attr_email = os.getenv("LDAP_USER_ATTR_EMAIL", DEFAULT_USER_ATTR_EMAIL)
        self.attr_groups = os.getenv("LDAP_USER_ATTR_GROUPS", DEFAULT_USER_ATTR_GROUPS)
        self.attr_object_guid = os.getenv("LDAP_USER_ATTR_GUID", DEFAULT_USER_ATTR_OBJECT_GUID)
        self.attr_display = os.getenv("LDAP_USER_ATTR_DISPLAY", DEFAULT_USER_ATTR_DISPLAY)

        self._tls = _build_tls() if (self.use_ssl or self.use_starttls) else None

    # ---------- AuthStrategy API ----------

    @property
    def is_enabled(self) -> bool:
        if not self.host:
            return False
        if not self.bind_dn:
            return False
        if not self.search_base:
            return False
        return True

    def authenticate_credentials(self, username: str, password: str) -> ExternalIdentity:
        if not self.is_enabled:
            raise AuthError(detail="LDAP not configured")

        # 1) Look up user DN with service account
        user_dn, attrs = self._search_user(username)

        # 2) Re-bind with user's password to verify
        try:
            self._bind_as_user(user_dn, password)
        except (LDAPInvalidCredentialsResult, LDAPBindError):
            raise AuthError(detail=f"LDAP bind rejected for '{username}'")
        except LDAPException as e:
            logger.error("LDAP authentication error for '%s': %s", username, e)
            raise AuthError(
                detail=f"LDAP error verifying '{username}': {type(e).__name__}",
                safe_message="Authentication service unavailable",
            )

        # 3) Build ExternalIdentity from search attrs (we already have them)
        return ExternalIdentity(
            provider=AuthProvider.LDAP,
            external_id=_decode_guid(attrs.get(self.attr_object_guid)) or user_dn,
            username=_ensure_str(attrs.get(self.attr_username), fallback=username),
            email=_ensure_str(attrs.get(self.attr_email), fallback=f"{username}@unknown.local"),
            display_name=_ensure_str(attrs.get(self.attr_display), fallback=username),
            groups=_extract_groups(attrs.get(self.attr_groups)),
            extra={"dn": user_dn},
        )

    # ---------- internals ----------

    def _server(self) -> ldap3.Server:
        return ldap3.Server(
            self.host,
            port=self.port,
            use_ssl=self.use_ssl,
            tls=self._tls,
            get_info=ldap3.NONE,  # don't fetch DSE schema (faster, fewer round-trips)
            connect_timeout=10,
        )

    def _service_connection(self) -> ldap3.Connection:
        password = _load_or_bootstrap_bind_password(self.db)
        try:
            conn = ldap3.Connection(
                self._server(),
                user=self.bind_dn,
                password=password,
                authentication=ldap3.SIMPLE,
                auto_bind=False,
                receive_timeout=10,
            )
            # auto_bind=False so we can opt into StartTLS first if configured
            if self.use_starttls and not self.use_ssl:
                conn.open()
                conn.start_tls()
            if not conn.bind():
                raise AuthError(
                    detail=f"LDAP service bind failed: {conn.result}",
                    safe_message="Authentication service unavailable",
                )
            return conn
        except LDAPSocketOpenError as e:
            logger.error("LDAP server unreachable %s:%s: %s", self.host, self.port, e)
            raise AuthError(
                detail=f"LDAP server unreachable: {e}",
                safe_message="Authentication service unavailable",
            )

    def _search_user(self, username: str) -> tuple[str, dict]:
        # ldap3 escapes filter arguments via str(format) — we MUST escape ourselves.
        username_escaped = ldap3.utils.conv.escape_filter_chars(username)
        flt = self.search_filter.replace("{username}", username_escaped)

        attrs = [
            self.attr_username,
            self.attr_email,
            self.attr_groups,
            self.attr_object_guid,
            self.attr_display,
        ]

        conn = self._service_connection()
        try:
            conn.search(
                search_base=self.search_base,
                search_filter=flt,
                search_scope=ldap3.SUBTREE,
                attributes=attrs,
                size_limit=2,
            )
            entries = conn.entries
        finally:
            conn.unbind()

        if not entries:
            raise AuthError(detail=f"LDAP user '{username}' not found")
        if len(entries) > 1:
            # Filter is too loose; refuse rather than risk wrong-user auth.
            raise AuthError(
                detail=f"LDAP filter for '{username}' returned {len(entries)} entries",
                safe_message="Authentication service misconfigured",
            )
        entry = entries[0]
        user_dn = entry.entry_dn

        # Pull attributes (ldap3 returns LazyType for missing)
        attrs_dict: dict = {}
        for a in attrs:
            try:
                v = entry[a].values  # always a list
                attrs_dict[a] = v if len(v) > 1 else (v[0] if v else None)
            except LDAPException:
                attrs_dict[a] = None
        return user_dn, attrs_dict

    def _bind_as_user(self, user_dn: str, password: str) -> None:
        """Verify the user's password by attempting a bind as that DN."""
        if not password:
            raise AuthError(detail="empty password")
        conn = ldap3.Connection(
            self._server(),
            user=user_dn,
            password=password,
            authentication=ldap3.SIMPLE,
            auto_bind=False,
            receive_timeout=10,
        )
        if self.use_starttls and not self.use_ssl:
            conn.open()
            conn.start_tls()
        ok = conn.bind()
        try:
            if not ok:
                # ldap3 distinguishes invalid-creds from other errors via .result
                code = (conn.result or {}).get("result")
                description = (conn.result or {}).get("description", "")
                if code == 49 or "invalidCredentials" in description:
                    raise LDAPInvalidCredentialsResult(conn.result)
                raise LDAPBindError(f"bind failed: {conn.result}")
        finally:
            conn.unbind()
