# -*- coding: utf-8 -*-
"""LDAP 新模式的独立执行逻辑；不导入应用、不改写共享配置，不记录认证数据。"""
import base64
import copy
import json
import os
import ssl
from urllib.parse import urlsplit

import requests
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from ldap3 import Connection, ObjectDef, Reader, SAFE_SYNC, SIMPLE, Server, Tls
from ldap3.core.exceptions import LDAPException, LDAPCommunicationError, LDAPCertificateError
from ldap3.utils.conv import escape_filter_chars


MAX_BODY_BYTES = 256 * 1024
MAX_RESPONSE_BYTES = 1024 * 1024
# 协议参数与品牌无关；变更时同步升级 Func 同源执行器与帧版本。
_AAD = b"ldap-forward/v2/request"
_SENSITIVE_FIELDS = frozenset(name.casefold() for name in (
    "password", "userPassword", "bindPassword", "unicodePwd", "authPassword",
    "access_token", "refresh_token", "authorization", "client_secret", "privateKey", "secret",
))


class LDAPAuthFailure(Exception):
    """仅携带稳定分类，不保留下游原异常或认证材料。"""

    def __init__(self, kind, field=""):
        super().__init__(kind)
        self.kind = kind
        self.field = field


def _invalid(field):
    raise LDAPAuthFailure("config", field)


def auth_path(config):
    """未显式启用新能力时，不校验未使用的新配置。"""
    if config.get("enable", False) != True:
        return "builtin", []
    mode = config.get("authMode", "builtin")
    if mode not in ("builtin", "builtin_transform", "external"):
        _invalid("authMode")
    if mode == "external":
        return mode, []
    server = config.get("server", {})
    if not isinstance(server, dict):
        _invalid("server")
    hosts = server.get("hosts")
    if isinstance(hosts, str):
        failed = False
        try:
            hosts = json.loads(hosts)
        except (ValueError, TypeError):
            failed = True
        if failed:
            _invalid("server.hosts")
    if hosts is None:
        hosts = []
    if not isinstance(hosts, list) or len(hosts) > 8:
        _invalid("server.hosts")
    unique = []
    for host in hosts:
        if not isinstance(host, str) or not host or host != host.strip():
            _invalid("server.hosts")
        parsed = None
        try:
            parsed = urlsplit(host if "://" in host else "ldap://" + host)
            parsed.port
        except ValueError:
            parsed = None
        if parsed is None:
            _invalid("server.hosts")
        if parsed.scheme not in ("ldap", "ldaps") or not parsed.hostname or parsed.username or parsed.password:
            _invalid("server.hosts")
        if parsed.path or parsed.query or parsed.fragment:
            _invalid("server.hosts")
        if host not in unique:
            unique.append(host)
    return mode, unique


def auth_timeout(config):
    value = config.get("authTimeoutSeconds", 20)
    if type(value) is not int or not 1 <= value <= 60:
        _invalid("authTimeoutSeconds")
    return value


def _json_bytes(data):
    result = None
    try:
        result = json.dumps(data, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
    except (ValueError, TypeError, RecursionError, UnicodeError):
        pass
    if result is None or len(result) > MAX_BODY_BYTES:
        raise LDAPAuthFailure("protocol_invalid")
    return result


def _reject_constant(value):
    raise ValueError("invalid_json_constant")


def _parse_json(data):
    result = None
    failed = False
    try:
        result = json.loads(data, parse_constant=_reject_constant)
    except (ValueError, TypeError, RecursionError, UnicodeError):
        failed = True
    if failed:
        raise LDAPAuthFailure("protocol_invalid")
    return result


def normalize_secret(secret):
    if secret is None:
        return ""
    size = None
    if isinstance(secret, str):
        try:
            size = len(secret.encode("utf-8"))
        except UnicodeError:
            pass
    if size is None or size > 1024:
        _invalid("external.secret")
    return secret


def _payload_key(secret):
    return HKDF(algorithm=hashes.SHA256(), length=32, salt=None,
                info=b"ldap-forward/v2").derive(secret.encode("utf-8"))


def encode_body(data, secret=""):
    """Func 的 body 始终是字符串；空 secret 只序列化一次。"""
    secret = normalize_secret(secret)
    raw = _json_bytes(data)
    if not secret:
        return raw.decode("utf-8")
    nonce = os.urandom(12)
    cipher = AESGCM(_payload_key(secret)).encrypt(nonce, raw, _AAD)
    return "v2:" + base64.b64encode(nonce + cipher).decode("ascii")


def decode_body(body, secret=""):
    """供配套 Func 使用；不猜测模式、不回退明文。"""
    secret = normalize_secret(secret)
    encoded = None
    if isinstance(body, str):
        try:
            encoded = body.encode("utf-8")
        except UnicodeError:
            pass
    if encoded is None or len(encoded) > MAX_RESPONSE_BYTES:
        raise LDAPAuthFailure("protocol_invalid")
    raw = None
    failed = False
    if secret:
        if not body.startswith("v2:"):
            raise LDAPAuthFailure("protocol_invalid")
        try:
            packed = base64.b64decode(body[3:], validate=True)
            if len(packed) < 28:
                failed = True
            else:
                raw = AESGCM(_payload_key(secret)).decrypt(packed[:12], packed[12:], _AAD)
        except Exception:
            failed = True
        if failed:
            raise LDAPAuthFailure("protocol_invalid")
    else:
        raw = encoded
    if len(raw) > MAX_BODY_BYTES:
        raise LDAPAuthFailure("protocol_invalid")
    data = _parse_json(raw)
    if not isinstance(data, dict):
        raise LDAPAuthFailure("protocol_invalid")
    return data


def _clean_attributes(data, reject=False, depth=0):
    if depth > 20:
        raise LDAPAuthFailure("invalid_userinfo")
    if isinstance(data, dict):
        cleaned = {}
        for key, value in data.items():
            if not isinstance(key, str):
                raise LDAPAuthFailure("invalid_userinfo")
            if key.casefold() in _SENSITIVE_FIELDS:
                if reject:
                    raise LDAPAuthFailure("invalid_userinfo")
                continue
            cleaned[key] = _clean_attributes(value, reject, depth + 1)
        return cleaned
    if isinstance(data, list):
        return [_clean_attributes(value, reject, depth + 1) for value in data]
    return copy.deepcopy(data)


def mapped_value(userinfo, mapping, name):
    field = mapping.get(name)
    if field is None:
        return ""
    if not isinstance(field, str):
        raise LDAPAuthFailure("invalid_userinfo")
    value = userinfo.get(field, "") or ""
    if isinstance(value, list):
        if not all(isinstance(item, str) for item in value):
            raise LDAPAuthFailure("invalid_userinfo")
        if name in ("exterId", "username", "email") and len(value) > 1:
            raise LDAPAuthFailure("invalid_userinfo")
        value = value[0] if value else ""
    if not isinstance(value, str):
        raise LDAPAuthFailure("invalid_userinfo")
    return value


def validate_userinfo(userinfo, mapping):
    if not isinstance(userinfo, dict) or not isinstance(mapping, dict):
        raise LDAPAuthFailure("invalid_userinfo")
    cleaned = _clean_attributes(userinfo, reject=True)
    _json_bytes(cleaned)
    for name in mapping:
        mapped_value(cleaned, mapping, name)
    if not mapped_value(cleaned, mapping, "exterId"):
        raise LDAPAuthFailure("invalid_userinfo")
    if not (mapped_value(cleaned, mapping, "username") or mapped_value(cleaned, mapping, "email")):
        raise LDAPAuthFailure("invalid_userinfo")
    return cleaned


def merge_transform(userinfo, patch, mapping):
    original = validate_userinfo(userinfo, mapping)
    if not isinstance(patch, dict):
        raise LDAPAuthFailure("invalid_userinfo")
    merged = copy.deepcopy(original)
    merged.update(_clean_attributes(patch, reject=True))
    merged = validate_userinfo(merged, mapping)
    if mapped_value(merged, mapping, "exterId") != mapped_value(original, mapping, "exterId"):
        raise LDAPAuthFailure("identity_conflict")
    return merged


def call_external(external, data, remaining):
    if not isinstance(external, dict):
        _invalid("external")
    url = external.get("url")
    invalid = False
    try:
        parsed = urlsplit(url) if isinstance(url, str) else None
        invalid = (not parsed or parsed.scheme not in ("http", "https") or not parsed.hostname
                   or parsed.username is not None or parsed.password is not None or parsed.fragment)
        if parsed:
            parsed.port
    except ValueError:
        invalid = True
    if invalid:
        _invalid("external.url")
    body = encode_body(data, external.get("secret"))
    ca_file = external.get("caCertsFile")
    if ca_file is not None and not isinstance(ca_file, str):
        _invalid("external.caCertsFile")
    failure = None
    raw = None
    try:
        with requests.post(url, json={"kwargs": {"body": body}}, stream=True,
                           allow_redirects=False, timeout=(min(3, remaining()), remaining()),
                           verify=ca_file or True) as response:
            if response.status_code != 200:
                failure = LDAPAuthFailure("service_unavailable")
            else:
                chunks = []
                size = 0
                for chunk in response.iter_content(chunk_size=16384):
                    size += len(chunk)
                    if size > MAX_RESPONSE_BYTES:
                        failure = LDAPAuthFailure("protocol_invalid")
                        break
                    chunks.append(chunk)
                if failure is None:
                    raw = b"".join(chunks)
    except (requests.RequestException, OSError, ValueError):
        failure = LDAPAuthFailure("service_unavailable")
    if failure is not None:
        raise failure
    response = _parse_json(raw)
    if not isinstance(response, dict):
        raise LDAPAuthFailure("service_unavailable")
    # 原生 Func API 直接返回业务对象；旧入口仍可使用 ok/data/result 包装。
    # 出现 ok 字段时只按包装协议校验，不把失败或畸形包装降级成原生成功。
    if "ok" in response:
        if response.get("ok") is not True:
            raise LDAPAuthFailure("service_unavailable")
        data = response.get("data")
        result = data.get("result") if isinstance(data, dict) else None
    else:
        result = response
    if not isinstance(result, dict):
        raise LDAPAuthFailure("protocol_invalid")
    _json_bytes(result)
    status = result.get("status")
    if status == "denied":
        raise LDAPAuthFailure("denied")
    if status == "error":
        raise LDAPAuthFailure("service_unavailable")
    if status != "success" or not isinstance(result.get("userinfo"), dict):
        raise LDAPAuthFailure("protocol_invalid")
    return result["userinfo"]


def _success(result):
    return result[0] is True if isinstance(result, tuple) else result is True


def _certificate_failure(error):
    pending = [error]
    seen = set()
    for _ in range(32):
        if not pending:
            break
        error = pending.pop()
        if id(error) in seen:
            continue
        seen.add(id(error))
        if isinstance(error, (ssl.SSLCertVerificationError, LDAPCertificateError)):
            return True
        if isinstance(error, BaseException):
            if "CERTIFICATE_VERIFY_FAILED" in str(error).upper():
                return True
            pending.extend(error.args)
            pending.extend(value for value in (error.__cause__, error.__context__) if value is not None)
        elif isinstance(error, (list, tuple)):
            pending.extend(error)
    return False


def _server_kwargs(config, host, remaining):
    server = config.get("server", {})
    tls = {}
    for name, value in server.get("tls", {}).items():
        if name in ("validate", "version"):
            if not isinstance(value, str) or not hasattr(ssl, value):
                _invalid("server.tls")
            tls[name] = getattr(ssl, value)
        elif value:
            tls[name] = value
    connect_timeout = server.get("connect_timeout", 6)
    if isinstance(connect_timeout, bool) or not isinstance(connect_timeout, (int, float)) or connect_timeout <= 0:
        _invalid("server.connect_timeout")
    kwargs = dict(host=host, use_ssl=server.get("use_ssl", False), tls=Tls(**tls),
                  connect_timeout=min(connect_timeout, remaining()))
    if server.get("get_info"):
        kwargs["get_info"] = server["get_info"]
    return kwargs


def _connection_kwargs(config, remaining):
    kwargs = copy.deepcopy(config.get("connection", {}))
    strategy = kwargs.pop("client_strategy", "SAFE_SYNC") or "SAFE_SYNC"
    if strategy != "SAFE_SYNC":
        _invalid("connection.client_strategy")
    start_tls = config.get("startTls", False)
    auto_bind = kwargs.pop("auto_bind", None)
    allowed = (None, "", "NONE", "DEFAULT", "TLS_BEFORE_BIND") if start_tls else (None, "", "NONE", "DEFAULT", "NO_TLS")
    if auto_bind not in allowed or kwargs.get("lazy"):
        _invalid("connection.auto_bind")
    kwargs = {key: value for key, value in kwargs.items() if value is not None and value != ""}
    receive_timeout = kwargs.get("receive_timeout", 5)
    if isinstance(receive_timeout, bool) or not isinstance(receive_timeout, (int, float)) or receive_timeout <= 0:
        _invalid("connection.receive_timeout")
    kwargs["receive_timeout"] = max(1, int(min(receive_timeout, remaining())))
    credentials = kwargs.get("sasl_credentials")
    if credentials:
        if not isinstance(credentials, (list, tuple)) or len(credentials) != 5:
            _invalid("connection.sasl_credentials")
        credentials = [None if item == "none" else item for item in credentials]
        credentials[1] = credentials[1] or config.get("bindDN", "")
        credentials[2] = credentials[2] or config.get("bindPassword", "")
        kwargs["sasl_credentials"] = credentials
    kwargs.update(user=config.get("bindDN", ""), password=config.get("bindPassword", ""),
                  client_strategy=SAFE_SYNC, raise_exceptions=True, auto_bind=False, lazy=False)
    return kwargs


def _fetch_node(config, host, username, password, remaining):
    connection = None
    failure = None
    info = None
    phase = "config"
    try:
        server = Server(**_server_kwargs(config, host, remaining))
        if server.ssl and config.get("startTls"):
            _invalid("startTls")
        connection = Connection(server, **_connection_kwargs(config, remaining))
        phase = "open"
        connection.open(read_server_info=False)
        if config.get("startTls"):
            phase = "tls"
            if not _success(connection.start_tls(read_server_info=False)):
                raise LDAPAuthFailure("service_unavailable")
        phase = "service_bind"
        if not _success(connection.bind()):
            code = (connection.result or {}).get("result")
            raise LDAPAuthFailure("retryable" if code in (51, 52) else "config", "bindDN")
        phase = "search"
        person = ObjectDef(config.get("personObjectClass", "inetOrgPerson"), connection)
        attribute = config.get("searchAttribute", "")
        if not isinstance(attribute, str) or not attribute:
            _invalid("searchAttribute")
        escaped = escape_filter_chars(username)
        query = attribute.format(username=escaped) if "(" in attribute else "({}={})".format(attribute, escaped)
        reader = Reader(connection, person, config.get("baseDN", ""), query)
        reader.search()
        if (connection.result or {}).get("result") in (51, 52):
            raise LDAPAuthFailure("retryable")
        if not reader.entries:
            raise LDAPAuthFailure("missing")
        if len(reader.entries) != 1:
            raise LDAPAuthFailure("invalid_userinfo")
        entry = reader[0]
        if not isinstance(entry.entry_dn, str) or not entry.entry_dn:
            raise LDAPAuthFailure("invalid_userinfo")
        info = {}
        for key, value in entry.entry_attributes_as_dict.items():
            if key.casefold() in _SENSITIVE_FIELDS:
                continue
            serializable = True
            try:
                json.dumps(value, allow_nan=False)
            except (ValueError, TypeError, RecursionError):
                serializable = False
            if serializable:
                info[key] = value
        info = _clean_attributes(info)
        phase = "user_bind"
        # 显式使用搜索到的用户 DN 与本次密码，不让服务身份参与最终用户认证。
        if not _success(connection.rebind(entry.entry_dn, password, authentication=SIMPLE)):
            code = (connection.result or {}).get("result")
            raise LDAPAuthFailure("denied" if code == 49 else "service_unavailable")
    except LDAPAuthFailure as error:
        failure = error
    except (LDAPException, OSError) as error:
        code = getattr(error, "result", None)
        if _certificate_failure(error):
            failure = LDAPAuthFailure("config", "server.tls")
        elif phase == "user_bind":
            failure = LDAPAuthFailure("denied" if code == 49 else "service_unavailable")
        elif phase != "config" and code in (51, 52):
            failure = LDAPAuthFailure("retryable")
        elif phase != "config" and isinstance(error, (LDAPCommunicationError, OSError)):
            failure = LDAPAuthFailure("retryable")
        else:
            failure = LDAPAuthFailure("config" if phase == "config" or code == 49 else "service_unavailable", phase)
    except Exception:
        failure = LDAPAuthFailure("config" if phase == "config" else "service_unavailable", phase)
    finally:
        if connection is not None:
            # 直接关闭连接，不在退出/超时后再等待网络 unbind 响应。
            try:
                connection.strategy.close()
            except Exception:
                pass
            finally:
                # ldap3 建连/握手失败时可能仍标记 closed=True，strategy.close 会跳过 socket。
                raw_socket = getattr(connection, "socket", None)
                if raw_socket is not None:
                    try:
                        raw_socket.close()
                    except Exception:
                        pass
    if failure is not None:
        raise failure
    return info


def fetch_ldap_userinfo(config, hosts, username, password, remaining):
    config = copy.deepcopy(config)
    hosts = hosts or [config.get("server", {}).get("host", "")]
    failure = None
    for host in hosts:
        remaining()
        try:
            return _fetch_node(config, host, username, password, remaining)
        except LDAPAuthFailure as error:
            failure = error
        if failure.kind != "retryable":
            raise failure
    raise LDAPAuthFailure("service_unavailable")


def authenticate(config, username, password, remaining):
    config = copy.deepcopy(config)
    mode, hosts = auth_path(config)
    if not isinstance(username, str) or not username or not isinstance(password, str) or not password:
        raise LDAPAuthFailure("denied")
    mapping = copy.deepcopy(config.get("mapping", {}))
    if mode == "external":
        info = call_external(config.get("external", {}), {"username": username, "password": password}, remaining)
    else:
        info = fetch_ldap_userinfo(config, hosts, username, password, remaining)
        if mode == "builtin_transform":
            info = validate_userinfo(info, mapping)
            patch = call_external(config.get("external", {}), {"username": username, "userinfo": info}, remaining)
            info = merge_transform(info, patch, mapping)
    remaining()
    return validate_userinfo(info, mapping), mapping
