<?php
namespace EasyChat\Library\Messenger;

use EasyChat\Model\Messenger as MessengerModel;
use PHPMax\Config\ClientOptions;
use PHPMax\Api\Session\DeviceType;
use PHPMax\Api\Session\MobileUserAgentPayload;
use PHPMax\Session\JsonFileSessionStore;
use PHPMax\Session\SessionInfo;









class MaxSessionLifecycle {

    private const GENERATION_KEY = 'session_generation';
    private const LOCK_TIMEOUT_MS = 5000;
    private const CLIENT_IDENTITY_KEY = 'phpmax_mobile_identity';

    private $registry;
    private $model_messenger;

    public function __construct($registry) {
        $this->registry = $registry;
        $this->model_messenger = new MessengerModel($registry);
        MaxRuntime::requireAutoload();
    }

    


    public function prepare(array $channel): array {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        if ($channel_id <= 0) {
            throw new \InvalidArgumentException('MAX channel id is required');
        }

        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $channel): array {
            return $this->prepareLocked($channel_id, $channel);
        });
    }

    




    public function snapshot(array $channel): array {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        if ($channel_id <= 0) {
            throw new \InvalidArgumentException('MAX channel id is required');
        }

        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $channel): array {
            $context = $this->prepareLocked($channel_id, $channel);
            $context['session'] = $this->inspectSessionFile($channel_id, (int)$context['generation']);
            return $context;
        });
    }

    




    public function beginAuthentication(array $channel, array $auth_state): array {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $channel, $auth_state): array {
            $fresh = $this->model_messenger->getChannelById($channel_id) ?: $channel;
            $settings = $this->decodeJson((string)($fresh['settings_json'] ?? '{}'));
            $credentials = $this->normalizeCredentials($fresh);
            $generation = $this->generationFromSettings($settings) + 1;

            $settings[self::GENERATION_KEY] = $generation;
            $settings['session_generation_started_at'] = time();
            $settings['session_generation_reason'] = 'authentication';
            $settings['session_store_mode'] = 'encrypted-v2';
            $settings['session_reset_pending'] = 1;
            unset($settings[self::CLIENT_IDENTITY_KEY]);
            $auth_state['session_generation'] = $generation;
            $settings['auth'] = $auth_state;
            $settings = $this->clearRuntimeErrors($settings, $generation);

            $update = [
                'credentials_json' => json_encode($credentials, JSON_UNESCAPED_UNICODE),
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
                'last_error' => '',
            ];
            $this->model_messenger->updateChannel($channel_id, $update);
            $fresh = array_merge($fresh, $update);

            try {
                $this->rawStore($channel_id, $generation)->deleteAllSessions();
            } catch (\Throwable $e) {
                $auth_state['status'] = 'error';
                $auth_state['error_code'] = 'session_store_unavailable';
                $auth_state['message'] = 'Не удалось подготовить защищенное хранилище MAX. Проверьте права на папку storage.';
                $auth_state['session_generation'] = $generation;
                $settings['auth'] = $auth_state;
                $this->model_messenger->updateChannel($channel_id, [
                    'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
                    'last_error' => 'max_session_store_unavailable',
                ]);
                throw $e;
            }
            $settings['session_reset_pending'] = 0;
            $update['settings_json'] = json_encode($settings, JSON_UNESCAPED_UNICODE);
            $this->model_messenger->updateChannel($channel_id, [
                'settings_json' => $update['settings_json'],
            ]);
            $fresh = array_merge($fresh, $update);

            return [
                'channel' => $fresh,
                'generation' => $generation,
                'credentials' => $credentials,
                'settings' => $settings,
            ];
        });
    }

    public function saveAuthState(int $channel_id, int $generation, array $state): bool {
        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $generation, $state): bool {
            $channel = $this->model_messenger->getChannelById($channel_id);
            if (!$channel) {
                return false;
            }
            $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
            if ($this->generationFromSettings($settings) !== $generation) {
                return false;
            }
            $state['session_generation'] = $generation;
            $settings['auth'] = $state;
            $this->model_messenger->updateChannel($channel_id, [
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
            ]);
            return true;
        });
    }

    public function completeAuthentication(int $channel_id, int $generation, string $account_id, string $attempt_id = '', array $account_profile = []): bool {
        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $generation, $account_id, $attempt_id, $account_profile): bool {
            $channel = $this->model_messenger->getChannelById($channel_id);
            if (!$channel) {
                return false;
            }
            $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
            if ($this->generationFromSettings($settings) !== $generation) {
                return false;
            }
            $current_auth = is_array($settings['auth'] ?? null) ? $settings['auth'] : [];
            if (
                $attempt_id !== ''
                && (
                    !hash_equals((string)($current_auth['attempt_id'] ?? ''), $attempt_id)
                    || in_array((string)($current_auth['status'] ?? ''), ['error', 'expired', 'connected', 'password_required'], true)
                )
            ) {
                return false;
            }
            $credentials = $this->normalizeCredentials($channel);
            $session = $this->rawStore($channel_id, $generation)->loadSession();
            if (!$session instanceof SessionInfo) {
                throw new \RuntimeException('MAX authentication completed without a stored session');
            }

            $previous_auth = $current_auth;
            $settings['auth'] = [
                'kind' => (string)($previous_auth['kind'] ?? ''),
                'status' => 'connected',
                'connected_at' => time(),
                'session_generation' => $generation,
            ];
            $settings['session_reset_pending'] = 0;
            $settings = $this->clearRuntimeErrors($settings, $generation);
            if ($account_profile) {
                $settings['account_profile'] = MaxAccountProfile::normalize($account_profile);
            }
            $credentials['account_id'] = $account_id;
            unset($credentials['token']);

            $this->model_messenger->updateChannel($channel_id, [
                'status' => 'active',
                'credentials_json' => json_encode($credentials, JSON_UNESCAPED_UNICODE),
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
                'bot_user_id' => $account_id,
                'last_error' => '',
            ]);
            return true;
        });
    }

    public function clearAuthentication(array $channel): array {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $channel): array {
            $fresh = $this->model_messenger->getChannelById($channel_id) ?: $channel;
            $settings = $this->decodeJson((string)($fresh['settings_json'] ?? '{}'));
            $credentials = $this->normalizeCredentials($fresh);
            $generation = $this->generationFromSettings($settings) + 1;
            $settings[self::GENERATION_KEY] = $generation;
            $settings['session_generation_started_at'] = time();
            $settings['session_generation_reason'] = 'reset';
            $settings['session_store_mode'] = 'encrypted-v2';
            $settings['session_reset_pending'] = 1;
            unset($settings['auth']);
            unset($settings['account_profile']);
            unset($settings[self::CLIENT_IDENTITY_KEY]);
            $settings = $this->clearRuntimeErrors($settings, $generation);
            unset($credentials['token'], $credentials['account_id']);

             
             
             
             
            $this->model_messenger->updateChannel($channel_id, [
                'status' => 'disabled',
                'credentials_json' => json_encode($credentials, JSON_UNESCAPED_UNICODE),
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
                'bot_user_id' => '',
                'last_error' => '',
            ]);

            $this->rawStore($channel_id, $generation)->deleteAllSessions();
            $settings['session_reset_pending'] = 0;
            $this->model_messenger->updateChannel($channel_id, [
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
            ]);

            return [
                'generation' => $generation,
                'credentials' => $credentials,
                'settings' => $settings,
            ];
        });
    }

    








    public function saveConfiguration(
        array $channel,
        string $status,
        array $credential_updates,
        array $setting_updates,
        bool $force_generation_rotation = false,
        ?int $expected_settings_revision = null
    ): array {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        if ($channel_id <= 0) {
            throw new \InvalidArgumentException('MAX channel id is required');
        }
        $status = $status === 'active' ? 'active' : 'disabled';

        return $this->withExclusiveLock($channel_id, function () use (
            $channel_id,
            $channel,
            $status,
            $credential_updates,
            $setting_updates,
            $force_generation_rotation,
            $expected_settings_revision
        ): array {
            $context = $this->prepareLocked($channel_id, $channel);
            $fresh = $context['channel'];
            $credentials = $context['credentials'];
            $settings = $context['settings'];
            $generation = (int)$context['generation'];
            $current_settings_revision = max(0, (int)($settings['personal_settings_revision'] ?? 0));
            if ($expected_settings_revision !== null && $current_settings_revision !== max(0, $expected_settings_revision)) {
                throw new MaxPersonalSettingsConflictException('MAX personal settings were changed in another request');
            }

            foreach ($credential_updates as $key => $value) {
                $credentials[(string)$key] = $value;
            }
            foreach ($setting_updates as $key => $value) {
                $settings[(string)$key] = $value;
            }
            if ($expected_settings_revision !== null) {
                $settings['personal_settings_revision'] = $current_settings_revision + 1;
            }

            $session = $this->inspectSessionFile($channel_id, $generation);
            if (
                $status === 'active'
                && trim((string)($credentials['token'] ?? '')) === ''
                && trim((string)($credentials['account_id'] ?? '')) === ''
                && (string)($session['status'] ?? '') !== 'ready'
            ) {
                throw new MaxSessionAuthorizationRequiredException('MAX authorization is required');
            }

            $rotated = $force_generation_rotation || (string)($fresh['status'] ?? 'disabled') !== $status;
            $previous_generation = $generation;
            $active_session = $rotated ? $this->rawStore($channel_id, $previous_generation)->loadSession() : null;
            if ($rotated) {
                $generation++;
                $settings[self::GENERATION_KEY] = $generation;
                $settings['session_generation_started_at'] = time();
                $settings['session_generation_reason'] = $force_generation_rotation ? 'credentials' : 'channel_status';
                $settings['session_store_mode'] = 'encrypted-v2';
                $auth = is_array($settings['auth'] ?? null) ? $settings['auth'] : [];
                if ((string)($auth['status'] ?? '') === 'connected') {
                    $auth['session_generation'] = $generation;
                    $settings['auth'] = $auth;
                } elseif ($auth) {
                     
                     
                     
                    unset($settings['auth']);
                }
                $settings = $this->clearRuntimeErrors($settings, $generation);
                if ($active_session instanceof SessionInfo) {
                     
                     
                     
                     
                    $this->rawStore($channel_id, $generation)->replaceSession($active_session);
                }
            }

            $update = [
                'status' => $status,
                'credentials_json' => json_encode($credentials, JSON_UNESCAPED_UNICODE),
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
                'webhook_url' => '',
            ];
            if ($rotated) {
                $update['last_error'] = '';
            }
            try {
                $this->model_messenger->updateChannel($channel_id, $update);
            } catch (\Throwable $e) {
                if ($rotated && $active_session instanceof SessionInfo) {
                    $this->rawStore($channel_id, $previous_generation)->replaceSession($active_session);
                }
                throw $e;
            }
            $fresh = array_merge($fresh, $update);

            return [
                'channel' => $fresh,
                'generation' => $generation,
                'credentials' => $credentials,
                'settings' => $settings,
                'rotated' => $rotated,
                'settings_revision' => max(0, (int)($settings['personal_settings_revision'] ?? 0)),
            ];
        });
    }

    public function clientOptions(array $channel, array $options = [], ?int $generation = null): ClientOptions {
        $context = $this->prepare($channel);
        $channel = $context['channel'];
        $credentials = $context['credentials'];
        $generation = $generation !== null ? max(1, $generation) : (int)$context['generation'];
        $channel_id = (int)$channel['channel_id'];

        $inner = $this->rawStore($channel_id, $generation);
        $store = new MaxGenerationSessionStore(
            $inner,
            $this->lockPath($channel_id),
            $generation,
            function () use ($channel_id): int {
                return $this->currentGeneration($channel_id);
            }
        );

        $options['workDir'] = (string)$credentials['work_dir'];
        $options['sessionName'] = (string)$credentials['session_name'];
        $options['store'] = $store;
        if (!isset($options['userAgent'])) {
            $options['userAgent'] = $this->stableMobileUserAgent($channel_id, $generation);
        }
        return new ClientOptions($options);
    }

    





    private function stableMobileUserAgent(int $channel_id, int $generation): MobileUserAgentPayload {
        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $generation): MobileUserAgentPayload {
            $channel = $this->model_messenger->getChannelById($channel_id);
            if (!$channel) {
                throw new \RuntimeException('MAX channel is missing');
            }
            $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
            if ($this->generationFromSettings($settings) !== max(1, $generation)) {
                throw new MaxSessionGenerationChangedException('MAX session generation changed');
            }

            $stored = is_array($settings[self::CLIENT_IDENTITY_KEY] ?? null)
                ? $settings[self::CLIENT_IDENTITY_KEY]
                : [];
            try {
                $user_agent = $stored ? MobileUserAgentPayload::fromArray($stored) : null;
                if ($user_agent instanceof MobileUserAgentPayload && $user_agent->deviceType === DeviceType::ANDROID) {
                    return $user_agent;
                }
            } catch (\Throwable $e) {
                 
            }

            $user_agent = MobileUserAgentPayload::defaultAndroid();
            $settings[self::CLIENT_IDENTITY_KEY] = $user_agent->toArray();
            $this->model_messenger->updateChannel($channel_id, [
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
            ]);
            return $user_agent;
        });
    }

    




    public function invalidateRejectedSession(array $channel, int $generation): bool {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $generation): bool {
            $fresh = $this->model_messenger->getChannelById($channel_id);
            if (!$fresh) {
                return false;
            }
            $settings = $this->decodeJson((string)($fresh['settings_json'] ?? '{}'));
            if ($this->generationFromSettings($settings) !== max(1, $generation)) {
                return false;
            }

            $next_generation = max(1, $generation) + 1;
            $settings[self::GENERATION_KEY] = $next_generation;
            $settings['session_generation_started_at'] = time();
            $settings['session_generation_reason'] = 'authorization_rejected';
            $settings['session_reset_pending'] = 1;
            unset($settings[self::CLIENT_IDENTITY_KEY]);
            $settings['auth'] = [
                'kind' => (string)((is_array($settings['auth'] ?? null) ? $settings['auth'] : [])['kind'] ?? ''),
                'status' => 'error',
                'error_code' => 'session_revoked',
                'session_generation' => $next_generation,
                'failed_at' => time(),
            ];
            $settings['polling_api_last_error_at'] = date('Y-m-d H:i:s');
            $settings['polling_api_last_error_ts'] = time();
            $settings['polling_api_last_error'] = 'max_session_revoked';
            $settings['polling_api_last_error_category'] = 'authorization';
            $settings['polling_api_last_error_generation'] = $next_generation;

            $this->model_messenger->updateChannel($channel_id, [
                'status' => 'disabled',
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
                'last_error' => 'max_session_revoked',
            ]);

             
             
            $this->rawStore($channel_id, max(1, $generation))->deleteAllSessions();
            $settings['session_reset_pending'] = 0;
            $this->model_messenger->updateChannel($channel_id, [
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
            ]);
            return true;
        });
    }

    public function currentGeneration(int $channel_id): int {
        $channel = $this->model_messenger->getChannelById($channel_id);
        if (!$channel) {
            return 1;
        }
        return $this->generationFromSettings($this->decodeJson((string)($channel['settings_json'] ?? '{}')));
    }

    public function isCurrent(int $channel_id, int $generation): bool {
        return $this->currentGeneration($channel_id) === max(1, $generation);
    }

    





    public function updateIfCurrent(int $channel_id, int $generation, callable $builder): bool {
        return $this->withExclusiveLock($channel_id, function () use ($channel_id, $generation, $builder): bool {
            $channel = $this->model_messenger->getChannelById($channel_id);
            if (!$channel) {
                return false;
            }
            $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
            if ($this->generationFromSettings($settings) !== max(1, $generation)) {
                return false;
            }
            $update = call_user_func($builder, $channel);
            if (is_array($update) && $update) {
                $this->model_messenger->updateChannel($channel_id, $update);
            }
            return true;
        });
    }

    









    public function updateQrAuthAttempt(
        int $channel_id,
        int $generation,
        string $attempt_id,
        callable $builder,
        bool $allow_terminal_state = false
    ): array {
        return $this->withExclusiveLock($channel_id, function () use (
            $channel_id,
            $generation,
            $attempt_id,
            $builder,
            $allow_terminal_state
        ): array {
            $channel = $this->model_messenger->getChannelById($channel_id);
            if (!$channel) {
                return ['current' => false, 'updated' => false, 'state' => []];
            }
            $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
            $auth = is_array($settings['auth'] ?? null) ? $settings['auth'] : [];
            $status = (string)($auth['status'] ?? '');
            if (
                $this->generationFromSettings($settings) !== max(1, $generation)
                || $attempt_id === ''
                || !hash_equals((string)($auth['attempt_id'] ?? ''), $attempt_id)
                || (int)($auth['worker_mode'] ?? 0) !== 1
                || (!$allow_terminal_state && in_array($status, ['error', 'expired', 'connected', 'password_required'], true))
            ) {
                return ['current' => false, 'updated' => false, 'state' => $auth];
            }

            $next = call_user_func($builder, $auth, $channel);
            if (!is_array($next)) {
                return ['current' => true, 'updated' => false, 'state' => $auth];
            }
            $next['session_generation'] = max(1, $generation);
            $settings['auth'] = $next;
            $this->model_messenger->updateChannel($channel_id, [
                'settings_json' => json_encode($settings, JSON_UNESCAPED_UNICODE),
            ]);
            return ['current' => true, 'updated' => true, 'state' => $next];
        });
    }

    public function generationFromChannel(array $channel): int {
        return $this->generationFromSettings($this->decodeJson((string)($channel['settings_json'] ?? '{}')));
    }

    public function isAuthenticationInProgress(array $settings): bool {
        $auth = is_array($settings['auth'] ?? null) ? $settings['auth'] : [];
        return in_array((string)($auth['status'] ?? ''), [
            'qr_starting',
            'worker_ready',
            'qr_pending',
            'qr_wait',
            'qr_confirming',
            'sms_pending',
            'sms_starting',
            'password_required',
        ], true);
    }

    




    public function inspectSession(array $channel): array {
        $context = $this->snapshot($channel);
        return $context['session'];
    }

    private function prepareLocked(int $channel_id, array $channel): array {
        $fresh = $this->model_messenger->getChannelById($channel_id) ?: $channel;
        $settings = $this->decodeJson((string)($fresh['settings_json'] ?? '{}'));
        $credentials = $this->normalizeCredentials($fresh);
        $generation = $this->generationFromSettings($settings);
        $update = [];

        if (!empty($settings['session_reset_pending'])) {
             
             
             
            $this->rawStore($channel_id, $generation)->deleteAllSessions();
            $settings['session_reset_pending'] = 0;
            $update['settings_json'] = json_encode($settings, JSON_UNESCAPED_UNICODE);
        }
        if ((string)($settings['session_store_mode'] ?? '') !== 'encrypted-v2') {
             
             
            $store = $this->rawStore($channel_id, $generation);
            $active = $store->loadSession();
            if (!$active instanceof SessionInfo) {
                $legacy = $this->legacyStore($credentials);
                $active = $legacy->loadSession();
                if ($active instanceof SessionInfo) {
                    $store->saveSession($active);
                    if (!$store->loadSession() instanceof SessionInfo) {
                        throw new \RuntimeException('MAX encrypted session migration verification failed');
                    }
                    $legacy->deleteAllSessions();
                }
            }
            $settings['session_store_mode'] = 'encrypted-v2';
            $update['settings_json'] = json_encode($settings, JSON_UNESCAPED_UNICODE);
        }
        if (!isset($settings[self::GENERATION_KEY]) || (int)$settings[self::GENERATION_KEY] < 1) {
            $settings[self::GENERATION_KEY] = $generation;
            $update['settings_json'] = json_encode($settings, JSON_UNESCAPED_UNICODE);
        }
        $encoded_credentials = json_encode($credentials, JSON_UNESCAPED_UNICODE);
        if ((string)($fresh['credentials_json'] ?? '') !== $encoded_credentials) {
            $update['credentials_json'] = $encoded_credentials;
        }
        if ($update) {
            $this->model_messenger->updateChannel($channel_id, $update);
            $fresh = array_merge($fresh, $update);
        }

        return [
            'channel' => $fresh,
            'generation' => $generation,
            'credentials' => $credentials,
            'settings' => $settings,
        ];
    }

    


    private function inspectSessionFile(int $channel_id, int $generation): array {
        return $this->rawStore($channel_id, $generation)->inspect();
    }

    private function clearRuntimeErrors(array $settings, int $generation): array {
        $settings['polling_error_count'] = 0;
        $settings['polling_backoff_until'] = '';
        $settings['polling_backoff_until_ts'] = 0;
        $settings['polling_last_error_at'] = '';
        $settings['polling_last_error_ts'] = 0;
        $settings['polling_session_generation'] = $generation;
        foreach (['api', 'processing'] as $scope) {
            $settings['polling_' . $scope . '_last_error_at'] = '';
            $settings['polling_' . $scope . '_last_error_ts'] = 0;
            $settings['polling_' . $scope . '_last_error'] = '';
            $settings['polling_' . $scope . '_last_error_category'] = '';
            $settings['polling_' . $scope . '_last_error_generation'] = 0;
        }
        $settings['dedicated_cron_last_error_at'] = '';
        $settings['dedicated_cron_last_error_ts'] = 0;
        $settings['dedicated_cron_last_error'] = '';
        $settings['dedicated_cron_last_error_generation'] = 0;
        $settings['dedicated_cron_last_skipped_reason'] = '';
        $settings['dedicated_cron_last_skipped_generation'] = 0;
        $settings['dedicated_cron_last_run_generation'] = 0;
        return $settings;
    }

    private function normalizeCredentials(array $channel): array {
        $credentials = $this->decodeJson((string)($channel['credentials_json'] ?? '{}'));
        if (empty($credentials['work_dir'])) {
            $credentials['work_dir'] = MaxRuntime::sessionRoot();
        }
        if (empty($credentials['session_name'])) {
            $credentials['session_name'] = MaxRuntime::channelSessionName((int)($channel['channel_id'] ?? 0));
        }
        return $credentials;
    }

    private function rawStore(int $channel_id, int $generation): MaxEncryptedSessionStore {
        return MaxPrivateRuntime::sessionStore($channel_id, $generation);
    }

    private function legacyStore(array $credentials): JsonFileSessionStore {
        return new JsonFileSessionStore((string)$credentials['work_dir'], (string)$credentials['session_name'], true);
    }

    private function generationFromSettings(array $settings): int {
        return max(1, (int)($settings[self::GENERATION_KEY] ?? 1));
    }

    private function withExclusiveLock(int $channel_id, callable $callback) {
        if ($channel_id <= 0) {
            throw new \InvalidArgumentException('MAX channel id is required');
        }
        $handle = $this->openLock($channel_id);
        $deadline = microtime(true) + (self::LOCK_TIMEOUT_MS / 1000);
        $locked = false;
        do {
            $locked = flock($handle, LOCK_EX | LOCK_NB);
            if (!$locked) {
                usleep(50000);
            }
        } while (!$locked && microtime(true) < $deadline);

        if (!$locked) {
            fclose($handle);
            throw new MaxSessionLifecycleBusyException('MAX session lifecycle is busy');
        }

        try {
            return call_user_func($callback);
        } finally {
            flock($handle, LOCK_UN);
            fclose($handle);
        }
    }

    private function openLock(int $channel_id) {
        $path = $this->lockPath($channel_id);
        $dir = dirname($path);
        if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
            throw new MaxSessionLifecycleBusyException('MAX lifecycle directory is unavailable');
        }
        $handle = MaxPrivateFileLock::open($path);
        if (!is_resource($handle)) {
            throw new MaxSessionLifecycleBusyException('MAX lifecycle lock cannot be opened');
        }
        return $handle;
    }

    private function lockPath(int $channel_id): string {
        $dir = defined('DIR_LOGS') && is_dir((string)DIR_LOGS) && is_writable((string)DIR_LOGS)
            ? (string)DIR_LOGS
            : MaxRuntime::sessionRoot();
        return rtrim($dir, DIRECTORY_SEPARATOR)
            . DIRECTORY_SEPARATOR
            . 'easychat_max_session_' . max(0, $channel_id) . '.lock';
    }

    private function decodeJson(string $json): array {
        $data = json_decode($json, true);
        return is_array($data) ? $data : [];
    }
}
