<?php

namespace MintyPHP\Service\Auth;

/**
 * Handles LDAP authentication: connect, bind, search, and attribute retrieval.
 *
 * Supports two bind methods:
 *  - search_then_bind: service account binds first, searches for user DN, then re-binds as user
 *  - direct_bind: constructs user DN from a format string and binds directly
 *
 * All user-supplied values are escaped via ldap_escape() to prevent LDAP injection.
 */
class LdapAuthService
{
    public function __construct(
        private readonly LdapConnectionGateway $ldap
    ) {
    }

    /**
     * Test connectivity and service-account bind.
     *
     * @param array $config Decrypted LDAP config (host, port, encryption_mode, etc.)
     * @return array{ok: bool, error?: string}
     */
    public function testConnection(array $config): array
    {
        try {
            $conn = $this->createConnection($config);
        } catch (\RuntimeException $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        $bindDn = trim((string) ($config['bind_dn'] ?? ''));
        $bindPassword = (string) ($config['bind_password'] ?? '');

        if ($bindDn !== '' && $bindPassword !== '') {
            if (!$this->ldap->bind($conn, $bindDn, $bindPassword)) {
                $error = $this->ldap->error($conn);
                $this->ldap->unbind($conn);
                return ['ok' => false, 'error' => 'Service account bind failed: ' . $error];
            }
        }

        $this->ldap->unbind($conn);
        return ['ok' => true];
    }

    /**
     * Authenticate a user against the LDAP directory.
     *
     * @param array  $config   Decrypted LDAP config
     * @param string $username Username (sAMAccountName, uid, or email depending on config)
     * @param string $password User's plaintext password (never logged)
     * @return array{ok: bool, attributes?: array, unique_id?: string, dn?: string, error?: string}
     */
    public function authenticate(array $config, string $username, string $password): array
    {
        if (trim($username) === '' || $password === '') {
            return ['ok' => false, 'error' => 'credentials_empty'];
        }

        try {
            $conn = $this->createConnection($config);
        } catch (\RuntimeException $e) {
            return ['ok' => false, 'error' => 'connection_failed'];
        }

        $bindMethod = $config['bind_method'] ?? 'search_then_bind';

        if ($bindMethod === 'direct_bind') {
            return $this->authenticateDirectBind($conn, $config, $username, $password);
        }

        return $this->authenticateSearchThenBind($conn, $config, $username, $password);
    }

    private function authenticateSearchThenBind(\LDAP\Connection $conn, array $config, string $username, string $password): array
    {
        $bindDn = trim((string) ($config['bind_dn'] ?? ''));
        $bindPassword = (string) ($config['bind_password'] ?? '');

        if ($bindDn !== '') {
            if (!$this->ldap->bind($conn, $bindDn, $bindPassword)) {
                $this->ldap->unbind($conn);
                return ['ok' => false, 'error' => 'service_bind_failed'];
            }
        }

        $userEntry = $this->searchUser($conn, $config, $username);
        if ($userEntry === null) {
            $this->ldap->unbind($conn);
            return ['ok' => false, 'error' => 'user_not_found'];
        }

        $userDn = $userEntry['dn'];

        if (!$this->ldap->bind($conn, $userDn, $password)) {
            $this->ldap->unbind($conn);
            return ['ok' => false, 'error' => 'invalid_credentials'];
        }

        $attributeMap = $this->parseAttributeMap($config);
        $uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID';
        $attributes = $this->readAttributes($userEntry, $attributeMap);
        $uniqueId = $this->extractUniqueId($userEntry, $uniqueIdAttr);

        $this->ldap->unbind($conn);

        return [
            'ok' => true,
            'attributes' => $attributes,
            'unique_id' => $uniqueId,
            'dn' => $userDn,
        ];
    }

    private function authenticateDirectBind(\LDAP\Connection $conn, array $config, string $username, string $password): array
    {
        $format = $config['bind_username_format'] ?? '%s';
        $escapedUsername = $this->ldap->escape($username, '', LDAP_ESCAPE_DN);
        $userDn = str_replace('%s', $escapedUsername, $format);

        if (!$this->ldap->bind($conn, $userDn, $password)) {
            $this->ldap->unbind($conn);
            return ['ok' => false, 'error' => 'invalid_credentials'];
        }

        $userEntry = $this->searchUser($conn, $config, $username);

        $attributeMap = $this->parseAttributeMap($config);
        $uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID';
        $attributes = $userEntry !== null ? $this->readAttributes($userEntry, $attributeMap) : [];
        $uniqueId = $userEntry !== null ? $this->extractUniqueId($userEntry, $uniqueIdAttr) : '';

        $this->ldap->unbind($conn);

        return [
            'ok' => true,
            'attributes' => $attributes,
            'unique_id' => $uniqueId,
            'dn' => $userDn,
        ];
    }

    private function searchUser(\LDAP\Connection $conn, array $config, string $username): ?array
    {
        $baseDn = $config['base_dn'] ?? '';
        $filterTemplate = $config['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))';
        $searchScope = ($config['user_search_scope'] ?? 'sub') === 'one' ? 1 : 0;

        $escapedUsername = $this->ldap->escape($username, '', LDAP_ESCAPE_FILTER);
        $filter = str_replace('%s', $escapedUsername, $filterTemplate);

        $attributeMap = $this->parseAttributeMap($config);
        $uniqueIdAttr = $config['unique_id_attribute'] ?? 'objectGUID';
        $requestAttrs = array_values($attributeMap);
        $requestAttrs[] = $uniqueIdAttr;
        $requestAttrs = array_unique(array_filter($requestAttrs));

        $result = $this->ldap->search($conn, $baseDn, $filter, $requestAttrs, $searchScope);
        if ($result === false) {
            return null;
        }

        $entries = $this->ldap->getEntries($conn, $result);
        if ($entries === false || ($entries['count'] ?? 0) < 1) {
            return null;
        }

        return $entries[0];
    }

    private function readAttributes(array $entry, array $attributeMap): array
    {
        $result = [];
        foreach ($attributeMap as $appField => $ldapAttr) {
            $ldapAttrLower = strtolower($ldapAttr);
            $value = null;
            if (isset($entry[$ldapAttrLower]) && is_array($entry[$ldapAttrLower]) && ($entry[$ldapAttrLower]['count'] ?? 0) > 0) {
                $value = $entry[$ldapAttrLower][0];
            }
            $result[$appField] = $value;
        }
        return $result;
    }

    private function extractUniqueId(array $entry, string $attribute): string
    {
        $attrLower = strtolower($attribute);

        if (!isset($entry[$attrLower]) || !is_array($entry[$attrLower]) || ($entry[$attrLower]['count'] ?? 0) < 1) {
            return '';
        }

        $value = $entry[$attrLower][0];

        if ($attrLower === 'objectguid' && strlen($value) === 16) {
            return self::binaryGuidToUuid($value);
        }

        return (string) $value;
    }

    /**
     * Convert a 16-byte binary Active Directory objectGUID to UUID string format.
     */
    public static function binaryGuidToUuid(string $binary): string
    {
        if (strlen($binary) !== 16) {
            return bin2hex($binary);
        }

        $parts = unpack('Va/vb/vc/C2d/C6e', $binary);
        if ($parts === false) {
            return bin2hex($binary);
        }

        return sprintf(
            '%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x',
            $parts['a'],
            $parts['b'],
            $parts['c'],
            $parts['d1'],
            $parts['d2'],
            $parts['e1'],
            $parts['e2'],
            $parts['e3'],
            $parts['e4'],
            $parts['e5'],
            $parts['e6']
        );
    }

    private function parseAttributeMap(array $config): array
    {
        $raw = $config['attribute_map'] ?? '{}';
        if (is_string($raw)) {
            $map = json_decode($raw, true);
            if (!is_array($map)) {
                $map = [];
            }
        } elseif (is_array($raw)) {
            $map = $raw;
        } else {
            $map = [];
        }
        return $map;
    }

    /**
     * @throws \RuntimeException on connection failure
     */
    private function createConnection(array $config): \LDAP\Connection
    {
        $host = trim((string) ($config['host'] ?? ''));
        $port = (int) ($config['port'] ?? 389);
        $encryptionMode = $config['encryption_mode'] ?? 'starttls';
        $verifyTls = (bool) ($config['verify_tls_certificate'] ?? true);
        $networkTimeout = (int) ($config['network_timeout'] ?? 5);

        if ($host === '') {
            throw new \RuntimeException('LDAP host is not configured');
        }

        if ($encryptionMode === 'ldaps') {
            $uri = 'ldaps://' . $host . ':' . $port;
        } else {
            $uri = 'ldap://' . $host . ':' . $port;
        }

        $conn = $this->ldap->connect($uri);
        if ($conn === false) {
            throw new \RuntimeException('ldap_connect failed for ' . $host);
        }

        $this->ldap->setOption($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
        $this->ldap->setOption($conn, LDAP_OPT_REFERRALS, 0);
        $this->ldap->setOption($conn, LDAP_OPT_NETWORK_TIMEOUT, $networkTimeout);

        if (!$verifyTls) {
            $this->ldap->setOption($conn, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
        }

        if ($encryptionMode === 'starttls') {
            if (!$this->ldap->startTls($conn)) {
                throw new \RuntimeException('STARTTLS negotiation failed');
            }
        }

        return $conn;
    }
}
