<?php
declare(strict_types=1);

/**
 * Bind-and-search authenticator. The flow per login:
 *   1. Connect using the configured transport (ldaps:// / ldap:// + StartTLS / cleartext).
 *   2. Bind as the configured service account.
 *   3. Search for an entry matching the user filter (email-substituted, ldap_escape'd).
 *   4. Apply the activity filter so disabled / locked AD accounts produce no hit.
 *   5. Re-bind as the found DN with the user's password.
 *   6. Optionally verify membership in the required group.
 *   7. Hand the matched attributes to LdapProvisioner.
 *
 * Any failure path returns null so Auth::login falls through to its standard
 * "wrong password / unknown user" response — no enumeration oracle.
 */
class LdapAuthAdapter
{
    public static function tryAuthenticate(string $email, string $password, ?array $existing): ?array
    {
        if ($email === '' || $password === '') return null;

        // Local-provider users always go through the local password path. The
        // takeover toggle lets the admin opt into a one-time migration to LDAP.
        if ($existing && ($existing['auth_provider'] ?? 'local') === 'local') {
            $takeover = Helper::getSetting('ldap_takeover_existing_users', '0') === '1';
            if (!$takeover) return null;
        }

        $cfg = self::config();
        if ($cfg === null) return null;

        $ds = self::connect($cfg);
        if ($ds === null) {
            self::audit('login_ldap_error', SecurityLog::SEV_WARN, $email, 'connect_failed');
            return null;
        }

        try {
            // Service-account bind. Decrypt the stored bind password lazily.
            $bindDn = (string)$cfg['bind_dn'];
            $bindPw = $cfg['bind_password_enc'] !== '' ? Crypto::decrypt($cfg['bind_password_enc']) : '';
            if ($bindDn !== '' && !@ldap_bind($ds, $bindDn, $bindPw)) {
                self::audit('login_ldap_error', SecurityLog::SEV_WARN, $email, 'service_bind_failed');
                return null;
            }

            // User search with active-filter so deactivated accounts do not match.
            $userFilter   = self::buildUserFilter($cfg, $email);
            $activeFilter = trim((string)$cfg['active_filter']);
            $filter = $activeFilter !== '' ? '(&' . $userFilter . $activeFilter . ')' : $userFilter;

            $attrs = array_values(array_filter([
                'dn',
                $cfg['attr_username'],
                $cfg['attr_email'],
                $cfg['attr_groups'],
            ]));
            $sr = @ldap_search($ds, $cfg['base_dn'], $filter, $attrs);
            if (!$sr) {
                self::audit('login_ldap_error', SecurityLog::SEV_WARN, $email, 'search_failed');
                return null;
            }
            $entry = ldap_first_entry($ds, $sr);
            if (!$entry) {
                // No hit. Could be unknown user OR disabled account — same response either way.
                self::audit('login_ldap_denied', SecurityLog::SEV_WARN, $email, 'not_found_or_disabled');
                return null;
            }
            $userDn       = (string)ldap_get_dn($ds, $entry);
            $entryAttrs   = ldap_get_attributes($ds, $entry);

            // Re-bind as the user with the supplied password — the actual auth check.
            if (!@ldap_bind($ds, $userDn, $password)) {
                self::audit('login_ldap_failure', SecurityLog::SEV_WARN, $email, 'wrong_password');
                return null;
            }

            // Optional required group: must be a member to log in at all.
            $required = trim((string)$cfg['required_group_dn']);
            if ($required !== '' && !self::isInGroup($ds, $userDn, $required, $cfg)) {
                self::audit('login_ldap_denied', SecurityLog::SEV_WARN, $email, 'not_in_required_group');
                return null;
            }

            // Determine admin role from the admin-group membership.
            $isAdmin = false;
            $adminGroup = trim((string)$cfg['admin_group_dn']);
            if ($adminGroup !== '') {
                $isAdmin = self::isInGroup($ds, $userDn, $adminGroup, $cfg);
            }

            $row = LdapProvisioner::syncUser($email, $userDn, $entryAttrs, $isAdmin, $existing, $cfg);
            if ($row !== null) {
                self::audit('login_ldap_success', SecurityLog::SEV_INFO, $email, $isAdmin ? 'admin' : 'user');
            }
            return $row;
        } finally {
            @ldap_unbind($ds);
        }
    }

    /** Returns null if mandatory fields are missing. */
    public static function config(): ?array
    {
        $host       = trim((string)Helper::getSetting('ldap_host', ''));
        $baseDn     = trim((string)Helper::getSetting('ldap_base_dn', ''));
        if ($host === '' || $baseDn === '') return null;

        $transport = (string)Helper::getSetting('ldap_transport', 'ldaps');
        $port      = (int)Helper::getSetting('ldap_port', $transport === 'ldaps' ? '636' : '389');
        $scheme    = $transport === 'ldaps' ? 'ldaps' : 'ldap';

        return [
            'transport'         => $transport,
            'uri'               => $scheme . '://' . $host . ':' . $port,
            'host'              => $host,
            'port'              => $port,
            'base_dn'           => $baseDn,
            'bind_dn'           => (string)Helper::getSetting('ldap_bind_dn', ''),
            'bind_password_enc' => (string)Helper::getSetting('ldap_bind_password_enc', ''),
            'user_filter'       => (string)Helper::getSetting('ldap_user_filter', '(mail=%s)'),
            'active_filter'     => (string)Helper::getSetting('ldap_active_filter', ''),
            'attr_username'     => (string)Helper::getSetting('ldap_attr_username', 'sAMAccountName'),
            'attr_email'        => (string)Helper::getSetting('ldap_attr_email', 'mail'),
            'attr_groups'       => (string)Helper::getSetting('ldap_attr_groups', 'memberOf'),
            'admin_group_dn'    => (string)Helper::getSetting('ldap_admin_group_dn', ''),
            'required_group_dn' => (string)Helper::getSetting('ldap_required_group_dn', ''),
            'ca_path'           => (string)Helper::getSetting('ldap_ca_path', ''),
            'tls_require_cert'  => (string)Helper::getSetting('ldap_tls_require_cert', 'demand'),
        ];
    }

    /**
     * Resource-typed return for PHP 8.1+ (\LDAP\Connection|null).
     * Sets TLS options BEFORE connect for cert validation to take effect.
     */
    public static function connect(array $cfg)
    {
        // TLS options must be set on the global ldap context before connect/bind.
        if ($cfg['ca_path'] !== '' && is_file($cfg['ca_path'])) {
            @ldap_set_option(null, LDAP_OPT_X_TLS_CACERTFILE, $cfg['ca_path']);
        }
        $require = match ((string)$cfg['tls_require_cert']) {
            'never'  => LDAP_OPT_X_TLS_NEVER,
            'allow'  => LDAP_OPT_X_TLS_ALLOW,
            'try'    => LDAP_OPT_X_TLS_TRY,
            default  => LDAP_OPT_X_TLS_DEMAND,
        };
        @ldap_set_option(null, LDAP_OPT_X_TLS_REQUIRE_CERT, $require);

        $ds = @ldap_connect($cfg['uri']);
        if (!$ds) return null;

        @ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
        @ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);
        @ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, 5);

        if ($cfg['transport'] === 'starttls') {
            if (!@ldap_start_tls($ds)) {
                @ldap_unbind($ds);
                return null;
            }
        }
        return $ds;
    }

    /** Substitutes the email into the user-filter template, escape-safe. */
    private static function buildUserFilter(array $cfg, string $email): string
    {
        $template = (string)$cfg['user_filter'] ?: '(mail=%s)';
        $safe     = ldap_escape($email, '', LDAP_ESCAPE_FILTER);
        return str_replace('%s', $safe, $template);
    }

    /** Membership check: does $userDn appear in the group's member attribute? */
    private static function isInGroup($ds, string $userDn, string $groupDn, array $cfg): bool
    {
        $safe = ldap_escape($userDn, '', LDAP_ESCAPE_FILTER);
        // Two common conventions: groupOfNames(member) and AD primary memberOf.
        // Try a `member=` lookup against the group itself; falls back to nothing
        // if the group is not visible to the bind account.
        $sr = @ldap_search($ds, $groupDn, '(member=' . $safe . ')', ['dn']);
        if (!$sr) return false;
        return ldap_count_entries($ds, $sr) > 0;
    }

    /** @internal */
    private static function audit(string $event, string $sev, string $email, string $reason): void
    {
        SecurityLog::log($event, $sev, [
            'email_hint' => Helper::maskEmail($email),
            'reason'     => $reason,
        ]);
    }
}
