<?php
namespace EasyChat\Library\Messenger;

use EasyChat\Model\Messenger as MessengerModel;
use PHPMax\Api\Auth\AuthService as PhpMaxAuthService;
use PHPMax\Auth\AuthFlowInterface;
use PHPMax\Auth\AuthResult;
use PHPMax\Client;
use PHPMax\Config\ClientOptions;
use PHPMax\Domain\Auth\CheckCodeResponse;
use PHPMax\Exception\ApiException;
use PHPMax\Exception\PHPMaxException;
use PHPMax\Protocol\Opcode;
use PHPMax\WebClient;

class MaxAuthService {

    private const QR_LIFETIME_SECONDS = 180;
    private const QR_WORKER_START_TIMEOUT_SECONDS = 18;
     
     
    private const QR_WORKER_HARD_TIMEOUT_SECONDS = 210;
    private const QR_WORKER_KILL_GRACE_SECONDS = 5;
     
     
     
     
    private const QR_WORKER_LEASE_SECONDS = 90;
    private const QR_ARTIFACT_RETENTION_SECONDS = 86400;
    private const QR_WORKER_ACTIVE_STATUSES = [
        'qr_starting',
        'worker_ready',
        'qr_pending',
        'qr_wait',
        'qr_confirming',
    ];

    private $registry;
    private $model_messenger;
    private $logger;
    private $lifecycle;
    private $incidents;

    public function __construct($registry) {
        $this->registry = $registry;
        $this->model_messenger = new MessengerModel($registry);
        $this->logger = new MessengerLogger($registry, MaxProvider::PROVIDER);
        $this->lifecycle = new MaxSessionLifecycle($registry);
        $this->incidents = new MaxPersonalIncidentService($registry);
        MaxRuntime::requireAutoload();
    }

    public function authState(array $channel): array {
        $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
        $auth = is_array($settings['auth'] ?? null) ? $settings['auth'] : [];
        $expires_at = (int)($auth['expires_at'] ?? 0);
        if ($expires_at > 0 && $expires_at < time() && !in_array((string)($auth['status'] ?? ''), ['connected', 'password_required'], true)) {
            $auth = [
                'kind' => (string)($auth['kind'] ?? ''),
                'status' => 'expired',
                'message' => 'Срок действия авторизации MAX истек. Начните вход заново.',
            ];
        }

        return $this->publicState($auth, $channel);
    }

    public function startQr(array $channel): array {
        $readiness = $this->qrWorkerReadiness();
        if (empty($readiness['success'])) {
            $state = [
                'kind' => 'qr',
                'status' => 'error',
                'error_code' => (string)($readiness['error'] ?? 'qr_worker_unavailable'),
                'message' => (string)($readiness['message'] ?? 'QR-вход сейчас недоступен на этом сервере.'),
            ];
            $this->logAuth('warning', 'max_auth_qr_readiness_blocked', $channel, $state);
            return [
                'success' => false,
                'error' => $state['error_code'],
                'message' => $state['message'],
                'state' => $this->publicState($state, $channel),
            ];
        }

        try {
            $attempt_id = bin2hex(random_bytes(12));
        } catch (\Throwable $e) {
            $message = 'Сервер не смог безопасно подготовить QR-вход. Обратитесь в поддержку хостинга.';
            return ['success' => false, 'error' => 'secure_random_unavailable', 'message' => $message, 'state' => $this->publicState([
                'kind' => 'qr',
                'status' => 'error',
                'error_code' => 'secure_random_unavailable',
                'message' => $message,
            ], $channel)];
        }
        $created_at = time();
        $worker_state = [
            'kind' => 'qr',
            'status' => 'qr_starting',
            'worker_mode' => 1,
            'attempt_id' => $attempt_id,
            'created_at' => $created_at,
            'expires_at' => $created_at + self::QR_LIFETIME_SECONDS,
            'worker_started_at' => 0,
            'worker_heartbeat_at' => 0,
            'worker_lease_expires_at' => $created_at + self::QR_WORKER_START_TIMEOUT_SECONDS,
            'message' => 'Готовим QR-вход MAX.',
        ];
        try {
            $context = $this->lifecycle->beginAuthentication($channel, $worker_state);
        } catch (MaxSessionLifecycleBusyException $e) {
            $message = 'Сессия MAX сейчас обновляется. Повторите QR-вход через несколько секунд.';
            return ['success' => false, 'error' => 'max_session_busy', 'message' => $message, 'state' => $this->publicState([
                'kind' => 'qr',
                'status' => 'error',
                'error_code' => 'max_session_busy',
                'message' => $message,
            ], $channel)];
        } catch (\Throwable $e) {
            $message = 'Не удалось подготовить защищенную сессию MAX. Проверьте права на папку storage.';
            $this->logAuthException('error', 'max_auth_qr_session_prepare_failed', $channel, $e, [
                'kind' => 'qr',
                'status' => 'error',
                'error_code' => 'session_store_unavailable',
                'message' => $message,
            ]);
            return ['success' => false, 'error' => $message, 'state' => $this->publicState([
                'kind' => 'qr',
                'status' => 'error',
                'error_code' => 'session_store_unavailable',
                'message' => $message,
            ], $channel)];
        }
        $channel = $context['channel'];
        $worker_state = $context['settings']['auth'];
        $this->cleanupQrWorkerArtifacts((int)$channel['channel_id'], $attempt_id);
        $this->logAuth('info', 'max_auth_qr_worker_start_requested', $channel, $worker_state);
        $spawn = $this->spawnQrWorker($channel, $attempt_id, $readiness);
        if (!empty($spawn['success'])) {
            $state = $this->waitForWorkerQrState((int)$channel['channel_id'], $attempt_id, 4.0);
            if ($state) {
                if (in_array((string)($state['status'] ?? ''), ['error', 'expired'], true)) {
                    return [
                        'success' => false,
                        'error' => (string)($state['error_code'] ?? 'qr_worker_failed'),
                        'state' => $this->publicState($state, $channel),
                    ];
                }
                return array_merge(['success' => true], $this->publicState($state, $channel));
            }
            $this->logAuth('info', 'max_auth_qr_worker_waiting_for_state', $channel, $worker_state);
            return array_merge(['success' => true, 'pending' => true], $this->publicState($worker_state, $channel));
        }

        $state = array_merge($worker_state, [
            'status' => 'error',
            'error_code' => (string)($spawn['error'] ?? 'qr_worker_unavailable'),
            'message' => (string)($spawn['message'] ?? 'QR-вход MAX требует PHP CLI 7.4 или выше. Проверьте поддержку CLI на хостинге.'),
        ]);
        if (!$this->saveAuthState($channel, $state)) {
            return $this->replacedAuthAttempt($channel);
        }
        $this->logAuth('warning', 'max_auth_qr_worker_spawn_unavailable', $channel, $state);
        return ['success' => false, 'error' => $state['message'], 'state' => $this->publicState($state, $channel)];
    }

    public function checkQr(array $channel): array {
        $auth = $this->privateAuthState($channel);
        if ((string)($auth['status'] ?? '') === 'connected') {
            $this->logAuth('info', 'max_auth_qr_check_already_connected', $channel, array_merge($auth, [
                'kind' => (string)($auth['kind'] ?? 'qr'),
                'status' => 'connected',
            ]));
            return ['success' => true, 'state' => $this->publicState(['kind' => (string)($auth['kind'] ?? 'qr'), 'status' => 'connected'], $channel)];
        }
        if ((int)($auth['worker_mode'] ?? 0) === 1) {
             
             
             
            if ((string)($auth['status'] ?? '') === 'expired') {
                return ['success' => false, 'error' => 'qr_expired', 'state' => $this->publicState($auth, $channel)];
            }
            if ((int)($auth['expires_at'] ?? 0) > 0 && (int)$auth['expires_at'] < time() && !in_array((string)($auth['status'] ?? ''), ['connected', 'password_required', 'error'], true)) {
                $expired = $this->transitionCurrentQrAttempt($channel, $auth, [
                    'status' => 'expired',
                    'error_code' => 'qr_expired',
                    'message' => 'Срок действия QR истек. Начните вход заново.',
                ]);
                if ($expired === []) {
                    return $this->replacedAuthAttempt($channel);
                }
                if ((string)($expired['status'] ?? '') === 'expired') {
                    $this->logAuth('warning', 'max_auth_qr_expired', $channel, $expired);
                    return ['success' => false, 'error' => 'qr_expired', 'state' => $this->publicState($expired, $channel)];
                }
                $auth = $expired;
            }

            $failure = $this->qrWorkerWatchdogFailure($channel, $auth);
            if ($failure !== []) {
                $failed = $this->transitionCurrentQrAttempt($channel, $auth, [
                    'status' => 'error',
                    'error_code' => (string)$failure['error'],
                    'message' => (string)$failure['message'],
                ]);
                if ($failed === []) {
                    return $this->replacedAuthAttempt($channel);
                }
                if ((string)($failed['status'] ?? '') === 'error') {
                    $this->logAuth('warning', (string)($failure['event'] ?? 'max_auth_qr_worker_watchdog_failed'), $channel, $failed);
                    return ['success' => false, 'error' => (string)($failed['error_code'] ?? $failure['error']), 'state' => $this->publicState($failed, $channel)];
                }
                $auth = $failed;
            }

             
             
            $observed = array_merge($auth, [
                'check_count' => (int)($auth['check_count'] ?? 0) + 1,
                'last_checked_at' => time(),
            ]);
            $this->logAuth('info', 'max_auth_qr_worker_state_check', $channel, $observed);
            if ((string)($auth['status'] ?? '') === 'error') {
                return ['success' => false, 'error' => (string)($auth['error_code'] ?? 'max_auth_failed'), 'state' => $this->publicState($auth, $channel)];
            }
            return ['success' => true, 'pending' => !in_array((string)($auth['status'] ?? ''), ['connected', 'password_required'], true), 'state' => $this->publicState($auth, $channel)];
        }
        if ((string)($channel['status'] ?? '') === 'active') {
            $this->logAuth('info', 'max_auth_qr_check_already_connected', $channel, array_merge($auth, [
                'kind' => (string)($auth['kind'] ?? 'qr'),
                'status' => 'connected',
            ]));
            return ['success' => true, 'state' => $this->publicState(['kind' => (string)($auth['kind'] ?? 'qr'), 'status' => 'connected'], $channel)];
        }
        if ((string)($auth['kind'] ?? '') !== 'qr' || (string)($auth['track_id'] ?? '') === '') {
            $this->logAuth('warning', 'max_auth_qr_check_without_state', $channel, $auth);
            return ['success' => false, 'error' => 'qr_not_started'];
        }
        $auth['status'] = 'error';
        $auth['error_code'] = 'qr_worker_required';
        $auth['message'] = 'Эта попытка QR-входа была создана старым web-режимом. Начните вход заново, чтобы EasyChat запустил безопасный CLI worker.';
        if (!$this->saveAuthState($channel, $auth)) {
            return $this->replacedAuthAttempt($channel);
        }
        $this->logAuth('warning', 'max_auth_qr_legacy_web_state_rejected', $channel, $auth);
        return ['success' => false, 'error' => 'qr_worker_required', 'state' => $this->publicState($auth, $channel)];
    }

    public function runQrWorker(int $channel_id, string $attempt_id): array {
        if (!preg_match('/^[a-f0-9]{24}$/', $attempt_id)) {
            return ['success' => false, 'error' => 'attempt_invalid'];
        }
        $channel = $this->model_messenger->getChannelById($channel_id);
        if (!$channel || (string)($channel['provider'] ?? '') !== MaxProvider::PROVIDER) {
            return ['success' => false, 'error' => 'channel_not_found'];
        }
        $auth = $this->privateAuthState($channel);
        if (!$this->isCurrentAttempt($channel, $attempt_id)) {
            return ['success' => false, 'error' => 'stale_attempt'];
        }

        $lock_context = $this->openQrWorkerLock($channel_id, $attempt_id);
        $lock = $lock_context['handle'] ?? null;
        if (!is_resource($lock)) {
            $lock_error = (string)($lock_context['error'] ?? 'worker_lock_unavailable');
            $this->logAuth('warning', 'max_auth_qr_worker_lock_busy', $channel, ['kind' => 'qr', 'status' => 'qr_starting', 'attempt_id' => $attempt_id, 'worker_mode' => 1]);
             
             
            if ($lock_error === 'worker_lock_busy') {
                return ['success' => true, 'pending' => true, 'duplicate' => true];
            }
            $failed = $this->transitionCurrentQrAttempt($channel, $auth, [
                'status' => 'error',
                'error_code' => $lock_error,
                'message' => 'EasyChat не смог создать защищенную блокировку QR-входа. Проверьте права на папку storage.',
            ]);
            return ['success' => false, 'error' => $lock_error, 'state' => $failed];
        }

        $captured = $auth;
        $capture = function (array $state) use (&$captured, $channel_id, $attempt_id): void {
            $now = time();
            $captured = array_merge($captured, $state, [
                'kind' => 'qr',
                'worker_mode' => 1,
                'attempt_id' => $attempt_id,
                'worker_heartbeat_at' => $now,
                'worker_lease_expires_at' => $now + self::QR_WORKER_LEASE_SECONDS,
            ]);
            if (!$this->saveWorkerAuthState($channel_id, $attempt_id, $captured)) {
                throw new MaxSessionGenerationChangedException('MAX auth attempt was replaced');
            }
        };
        $flow = new MaxQrWorkerFlow($capture);

        try {
             
             
             
            $capture([
                'status' => 'worker_ready',
                'worker_started_at' => time(),
                'message' => 'Подключаемся к MAX.',
            ]);
            $this->logAuth('info', 'max_auth_qr_worker_open_session_start', $channel, $captured);
             
             
             
            $generation = max(1, (int)($captured['session_generation'] ?? 0));
            $client = new WebClient($this->clientOptions($channel, $flow, true, [
                'qrPollTimeout' => 180,
            ], $generation));
            $profile = $client->withOpenSession(function (Client $client) {
                return $client->me();
            });
            $fresh_channel = $this->model_messenger->getChannelById($channel_id);
            if (!$this->isCurrentAttempt($fresh_channel, $attempt_id)) {
                $this->logAuth('warning', 'max_auth_qr_worker_stale_connected', $channel, $captured);
                return ['success' => false, 'error' => 'stale_attempt'];
            }
            $account_id = $profile && $profile->contact && isset($profile->contact->id) ? (string)$profile->contact->id : '';
            $this->markConnected($fresh_channel ?: $channel, $generation, $account_id, $attempt_id, MaxAccountProfile::fromProfile($profile));
            $this->logAuth('info', 'max_auth_connected', $channel, [
                'kind' => 'qr',
                'status' => 'connected',
                'worker_mode' => 1,
                'attempt_id' => $attempt_id,
                'account_hash' => $account_id !== '' ? MessengerLogger::hashExternalId($account_id) : '',
            ]);
            return ['success' => true, 'account_id' => $account_id];
        } catch (MaxSessionGenerationChangedException $e) {
            $this->logAuth('info', 'max_auth_qr_worker_generation_replaced', $channel, $captured);
            return ['success' => false, 'error' => 'stale_attempt'];
        } catch (MaxAuthPendingException $e) {
            $this->logAuth('info', 'max_auth_qr_worker_pending_stop', $channel, $captured);
            return ['success' => true, 'pending' => true];
        } catch (\Throwable $e) {
            $state = $this->authFailureState($e, $captured);
            if (!$this->isCurrentAttempt($this->model_messenger->getChannelById($channel_id), $attempt_id)) {
                $this->logAuth('info', 'max_auth_qr_worker_stale_error_ignored', $channel, $captured);
                return ['success' => false, 'error' => 'stale_attempt'];
            }
            if (!$this->saveWorkerAuthState($channel_id, $attempt_id, $state)) {
                return ['success' => false, 'error' => 'stale_attempt'];
            }
            $this->logAuthException('error', 'max_auth_qr_worker_failed', $channel, $e, $state);
            return ['success' => false, 'error' => (string)($state['error_code'] ?? 'max_auth_failed')];
        } finally {
            if (is_resource($lock)) {
                flock($lock, LOCK_UN);
                fclose($lock);
            }
        }
    }

    public function startSms(array $channel, string $phone): array {
        $phone = $this->normalizePhone($phone);
        if ($phone === '') {
            return ['success' => false, 'error' => 'phone_invalid'];
        }
        $initial_state = [
            'kind' => 'sms',
            'status' => 'sms_starting',
            'phone_mask' => $this->maskPhone($phone),
            'created_at' => time(),
            'message' => 'Запрашиваем SMS-код MAX.',
        ];
        try {
            $context = $this->lifecycle->beginAuthentication($channel, $initial_state);
        } catch (MaxSessionLifecycleBusyException $e) {
            $message = 'Сессия MAX сейчас обновляется. Повторите SMS-вход через несколько секунд.';
            return ['success' => false, 'error' => 'max_session_busy', 'message' => $message, 'state' => $this->publicState([
                'kind' => 'sms',
                'status' => 'error',
                'error_code' => 'max_session_busy',
                'message' => $message,
            ], $channel)];
        } catch (\Throwable $e) {
            $message = 'Не удалось подготовить защищенную сессию MAX. Проверьте права на папку storage.';
            $this->logAuthException('error', 'max_auth_sms_session_prepare_failed', $channel, $e, [
                'kind' => 'sms',
                'status' => 'error',
                'error_code' => 'session_store_unavailable',
                'message' => $message,
            ]);
            return ['success' => false, 'error' => $message, 'state' => $this->publicState([
                'kind' => 'sms',
                'status' => 'error',
                'error_code' => 'session_store_unavailable',
                'message' => $message,
            ], $channel)];
        }
        $channel = $context['channel'];
        $generation = (int)$context['generation'];
        $captured = [];
        $flow = new MaxSmsStartFlow($phone, function (array $state) use (&$captured): void {
            $captured = $state;
        });

        try {
            $client = new Client($this->clientOptions($channel, $flow, false, ['phone' => $phone], $generation));
            $client->withOpenSession(function () {
                return null;
            });
        } catch (MaxSessionGenerationChangedException $e) {
            return $this->replacedAuthAttempt($channel);
        } catch (MaxAuthPendingException $e) {
            $state = array_merge([
                'kind' => 'sms',
                'status' => 'sms_pending',
                'phone_mask' => $this->maskPhone($phone),
                'created_at' => time(),
                'session_generation' => $generation,
            ], $captured);
            if (!$this->saveAuthState($channel, $state)) {
                return $this->replacedAuthAttempt($channel);
            }
            $this->logAuth('info', 'max_auth_sms_start_pending', $channel, $state);
            return array_merge(['success' => true], $this->publicState($state, $channel));
        } catch (ApiException $e) {
            if (!$this->lifecycle->isCurrent((int)$channel['channel_id'], $generation)) {
                return $this->replacedAuthAttempt($channel);
            }
            $state = $this->smsApiErrorState($e);
            $state['session_generation'] = $generation;
            if (!$this->saveAuthState($channel, $state)) {
                return $this->replacedAuthAttempt($channel);
            }
            $this->logAuthException('warning', 'max_auth_sms_start_api_error', $channel, $e, $state);
            return ['success' => false, 'error' => $state['message'], 'state' => $this->publicState($state, $channel)];
        } catch (\Throwable $e) {
            if (!$this->lifecycle->isCurrent((int)$channel['channel_id'], $generation)) {
                return $this->replacedAuthAttempt($channel);
            }
            $state = $this->authFailureState($e, [
                'kind' => 'sms',
                'session_generation' => $generation,
            ]);
            if (!$this->saveAuthState($channel, $state)) {
                return $this->replacedAuthAttempt($channel);
            }
            $this->logAuthException('error', 'max_auth_sms_start_failed', $channel, $e, $state);
            return ['success' => false, 'error' => $state['message'], 'state' => $this->publicState($state, $channel)];
        }

        return ['success' => false, 'error' => 'sms_auth_unexpected'];
    }

    public function submitSms(array $channel, string $code): array {
        $auth = $this->privateAuthState($channel);
        $sms_token = (string)($auth['sms_token'] ?? '');
        $code = preg_replace('/\D+/', '', $code);
        if ((string)($auth['kind'] ?? '') !== 'sms' || $sms_token === '') {
            return ['success' => false, 'error' => 'sms_not_started'];
        }
        if ($code === '' || strlen($code) < 3 || strlen($code) > 12) {
            return ['success' => false, 'error' => 'sms_code_invalid'];
        }

        $captured = [];
        $flow = new MaxSmsCodeFlow($sms_token, $code, function (array $state) use (&$captured): void {
            $captured = $state;
        });

        return $this->finishAuthAttempt($channel, $flow, false, $auth, $captured, $this->authClientIdentity($auth));
    }

    public function submitPassword(array $channel, string $password): array {
        $auth = $this->privateAuthState($channel);
        $track_id = (string)($auth['password_track_id'] ?? '');
        if ($track_id === '') {
            return ['success' => false, 'error' => 'password_not_required'];
        }
        if ($password === '') {
            return ['success' => false, 'error' => 'password_empty'];
        }

        $captured = [];
        $flow = new MaxPasswordFlow($track_id, $password, function (array $state) use (&$captured): void {
            $captured = $state;
        });

        return $this->finishAuthAttempt($channel, $flow, (string)($auth['kind'] ?? '') === 'qr', $auth, $captured, $this->authClientIdentity($auth));
    }

    public function clearAuth(array $channel): array {
        $this->lifecycle->clearAuthentication($channel);
        return ['success' => true, 'state' => $this->publicState([], array_merge($channel, ['status' => 'disabled', 'bot_user_id' => '']))];
    }

    private function finishAuthAttempt(array $channel, AuthFlowInterface $flow, bool $web, array $previous_auth, array &$captured, array $extra = []): array {
        try {
            $this->logAuth('info', 'max_auth_open_session_start', $channel, array_merge($previous_auth, $captured, [
                'web_client' => $web ? 1 : 0,
                'has_device_id' => !empty($extra['deviceId']) ? 1 : 0,
                'has_mt_instance_id' => !empty($extra['mtInstanceId']) ? 1 : 0,
            ]));
            $generation = max(1, (int)($previous_auth['session_generation'] ?? 0));
            $options = $this->clientOptions($channel, $flow, $web, $extra, $generation);
            $client = $web ? new WebClient($options) : new Client($options);
            $profile = $client->withOpenSession(function (Client $client) {
                return $client->me();
            });
            $account_id = $profile && $profile->contact && isset($profile->contact->id) ? (string)$profile->contact->id : '';
            $this->logAuth('info', 'max_auth_profile_received', $channel, array_merge($previous_auth, $captured, [
                'status' => 'connected',
                'profile_has_contact' => $profile && $profile->contact ? 1 : 0,
                'account_hash' => $account_id !== '' ? MessengerLogger::hashExternalId($account_id) : '',
            ]));
            $this->markConnected($channel, $generation, $account_id, '', MaxAccountProfile::fromProfile($profile));
            $this->logAuth('info', 'max_auth_connected', $channel, [
                'kind' => (string)($previous_auth['kind'] ?? ''),
                'status' => 'connected',
                'account_hash' => $account_id !== '' ? MessengerLogger::hashExternalId($account_id) : '',
            ]);
            return [
                'success' => true,
                'state' => $this->publicState(['kind' => (string)($previous_auth['kind'] ?? ''), 'status' => 'connected'], array_merge($channel, ['bot_user_id' => $account_id])),
                'account_id' => $account_id,
                'message' => 'Личный MAX подключен.',
            ];
        } catch (MaxSessionGenerationChangedException $e) {
            $this->logAuth('info', 'max_auth_attempt_generation_replaced', $channel, $previous_auth);
            return $this->replacedAuthAttempt($channel);
        } catch (MaxAuthPendingException $e) {
            $state = array_merge($previous_auth, $captured);
            if (!$this->saveAuthState($channel, $state)) {
                return $this->replacedAuthAttempt($channel);
            }
            $this->logAuth('info', 'max_auth_pending', $channel, $state);
            return ['success' => true, 'pending' => true, 'state' => $this->publicState($state, $channel)];
        } catch (\Throwable $e) {
            $generation = max(1, (int)($previous_auth['session_generation'] ?? 0));
            if (!$this->lifecycle->isCurrent((int)$channel['channel_id'], $generation)) {
                $this->logAuth('info', 'max_auth_stale_error_ignored', $channel, $previous_auth);
                return $this->replacedAuthAttempt($channel);
            }
            $state = $this->authFailureState($e, array_merge($previous_auth, $captured));
            if (!$this->saveAuthState($channel, $state)) {
                return $this->replacedAuthAttempt($channel);
            }
            $this->logAuthException('error', 'max_auth_attempt_failed', $channel, $e, $state);
            return ['success' => false, 'error' => $state['message'], 'state' => $this->publicState($state, $channel)];
        }
    }

    private function logAuth(string $level, string $event, array $channel, array $state = []): void {
        $context = [
            'channel_id' => (int)($channel['channel_id'] ?? 0),
            'kind' => (string)($state['kind'] ?? ''),
            'status' => (string)($state['status'] ?? ''),
            'error_code' => (string)($state['error_code'] ?? ''),
            'expires_at' => (int)($state['expires_at'] ?? 0),
            'retry_after_seconds' => (int)($state['retry_after_seconds'] ?? 0),
            'request_count_left' => (int)($state['request_count_left'] ?? 0),
            'polling_interval_ms' => (int)($state['polling_interval_ms'] ?? 0),
            'phone_mask' => (string)($state['phone_mask'] ?? ''),
            'check_count' => (int)($state['check_count'] ?? 0),
            'last_checked_at' => (int)($state['last_checked_at'] ?? 0),
            'age_seconds' => isset($state['created_at']) ? max(0, time() - (int)$state['created_at']) : 0,
            'expires_in_seconds' => isset($state['expires_at']) && (int)$state['expires_at'] > 0 ? max(0, (int)$state['expires_at'] - time()) : 0,
            'has_track_id' => !empty($state['track_id']) ? 1 : 0,
            'has_qr_link' => !empty($state['qr_link']) ? 1 : 0,
            'has_device_id' => !empty($state['device_id']) ? 1 : (int)($state['has_device_id'] ?? 0),
            'has_mt_instance_id' => !empty($state['mt_instance_id']) ? 1 : (int)($state['has_mt_instance_id'] ?? 0),
            'has_password_track_id' => !empty($state['password_track_id']) ? 1 : 0,
            'web_client' => (int)($state['web_client'] ?? 0),
            'qr_login_available' => (int)($state['qr_login_available'] ?? 0),
            'qr_remote_expires_at' => (int)($state['qr_remote_expires_at'] ?? 0),
            'profile_has_contact' => (int)($state['profile_has_contact'] ?? 0),
            'worker_mode' => (int)($state['worker_mode'] ?? 0),
            'session_generation' => max(0, (int)($state['session_generation'] ?? 0)),
            'worker_started' => (int)($state['worker_started_at'] ?? 0) > 0 ? 1 : 0,
            'worker_heartbeat_age_seconds' => (int)($state['worker_heartbeat_at'] ?? 0) > 0
                ? max(0, time() - (int)$state['worker_heartbeat_at'])
                : 0,
            'worker_lease_remaining_seconds' => (int)($state['worker_lease_expires_at'] ?? 0) > 0
                ? max(0, (int)$state['worker_lease_expires_at'] - time())
                : 0,
        ];
        if (isset($state['account_hash'])) {
            $context['account_hash'] = (string)$state['account_hash'];
        }
        if (isset($state['exception_class'])) {
            $context['exception_class'] = (string)$state['exception_class'];
        }
        if (isset($state['opcode'])) {
            $context['opcode'] = (int)$state['opcode'];
        }
        if (isset($state['message']) && (string)$state['message'] !== '') {
            $context['message_safe'] = substr((string)$state['message'], 0, 180);
        }

        if ($level === 'error') {
            $this->logger->error($event, $context);
        } elseif ($level === 'warning') {
            $this->logger->warning($event, $context);
        } else {
            $this->logger->info($event, $context);
        }
    }

    private function logAuthException(string $level, string $event, array $channel, \Throwable $e, array $state = []): void {
        $state['message'] = MaxRuntime::safeError($e);
        $state['exception_class'] = get_class($e);
        if ($e instanceof ApiException) {
            $state['error_code'] = (string)$e->error();
            $state['opcode'] = $e->opcode();
        }
        $this->logAuth($level, $event, $channel, $state);
    }

    private function markConnected(array $channel, int $generation, string $account_id, string $attempt_id = '', array $account_profile = []): void {
        if (!$this->lifecycle->completeAuthentication((int)$channel['channel_id'], $generation, $account_id, $attempt_id, $account_profile)) {
            throw new MaxSessionGenerationChangedException('MAX auth attempt was replaced');
        }

         
         
         
         
         
        $this->incidents->observeSuccess((int)$channel['channel_id']);
    }

    private function smsApiErrorState(ApiException $e): array {
        return $this->authFailureState($e, ['kind' => 'sms']);
    }

    



    private function authFailureState(\Throwable $e, array $base = []): array {
        $technical = strtolower(MaxRuntime::safeError($e));
        $provider_code = $e instanceof ApiException ? trim((string)$e->error()) : '';
        $error_code = preg_match('/^[a-z0-9._-]{1,80}$/i', $provider_code)
            ? $provider_code
            : 'max_auth_failed';
        $message = 'MAX не завершил вход. Повторите попытку; если ошибка повторится, откройте диагностику MAX.';

        if ($error_code === 'error.limit.violate') {
            $message = 'MAX временно ограничил частые попытки входа. Подождите и повторите позже.';
        } elseif (
            strpos($technical, 'track.not.found') !== false
            || strpos($technical, 'qr_expired') !== false
        ) {
            $error_code = 'qr_expired';
            $message = 'Срок действия этого QR-входа истек. Создайте новый QR-код и повторите сканирование.';
        } elseif (
            strpos($technical, 'wrong_password') !== false
            || strpos($technical, 'fail_wrong_password') !== false
            || strpos($technical, 'login.cred') !== false
        ) {
            $error_code = 'credentials_rejected';
            $message = 'MAX не принял код или облачный пароль. Проверьте данные и повторите вход.';
        } elseif (
            strpos($technical, 'session store') !== false
            || strpos($technical, 'not writable') !== false
            || strpos($technical, 'unable to write') !== false
        ) {
            $error_code = 'session_store_unavailable';
            $message = 'EasyChat не может сохранить защищенную сессию MAX. Проверьте права на папку storage.';
        } elseif (
            strpos($technical, 'timeout') !== false
            || strpos($technical, 'timed out') !== false
            || strpos($technical, 'failed to connect') !== false
            || strpos($technical, 'connection') !== false
        ) {
            $error_code = 'max_temporarily_unavailable';
            $message = 'MAX временно не ответил. EasyChat не изменил текущую сессию; повторите вход через несколько секунд.';
        } elseif (
            strpos($technical, 'expected array') !== false
            || strpos($technical, 'invalid response') !== false
            || strpos($technical, 'model ') !== false
        ) {
            $error_code = 'max_response_changed';
            $message = 'MAX вернул неожиданный ответ. EasyChat сохранил безопасную диагностику для проверки.';
        }

        return array_merge($base, [
            'status' => 'error',
            'message' => $message,
            'error_code' => $error_code,
        ]);
    }

    private function saveAuthState(array $channel, array $state): bool {
        $generation = max(1, (int)($state['session_generation'] ?? $this->privateAuthState($channel)['session_generation'] ?? 0));
        return $this->lifecycle->saveAuthState((int)$channel['channel_id'], $generation, $state);
    }

    private function replacedAuthAttempt(array $channel): array {
        $fresh = $this->model_messenger->getChannelById((int)($channel['channel_id'] ?? 0)) ?: $channel;
        return [
            'success' => false,
            'error' => 'auth_attempt_replaced',
            'message' => 'Эта попытка входа уже заменена новой. Продолжайте текущую авторизацию.',
            'state' => $this->authState($fresh),
        ];
    }

    private function privateAuthState(array $channel): array {
        $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
        return is_array($settings['auth'] ?? null) ? $settings['auth'] : [];
    }

    private function publicState(array $auth, array $channel): array {
        $status = (string)($auth['status'] ?? '');
        $qr_link = (string)($auth['qr_link'] ?? '');
        $state = [
            'kind' => (string)($auth['kind'] ?? ''),
            'status' => $status !== '' ? $status : ((string)($channel['status'] ?? '') === 'active' ? 'connected' : 'idle'),
            'message' => (string)($auth['message'] ?? ''),
            'expires_at' => (int)($auth['expires_at'] ?? 0),
            'code_length' => (int)($auth['code_length'] ?? 0),
            'request_count_left' => (int)($auth['request_count_left'] ?? 0),
            'retry_after_seconds' => (int)($auth['retry_after_seconds'] ?? 0),
            'polling_interval_ms' => (int)($auth['polling_interval_ms'] ?? 0),
            'phone_mask' => (string)($auth['phone_mask'] ?? ''),
            'password_hint' => (string)($auth['password_hint'] ?? ''),
            'error_code' => (string)($auth['error_code'] ?? ''),
        ];
        if ($qr_link !== '' && in_array($state['status'], ['qr_pending', 'qr_wait', 'qr_confirming'], true)) {
            $state['qr_link'] = $qr_link;
            $state['qr_svg'] = $this->qrSvgDataUri($qr_link);
        }
        return $state;
    }

    private function qrSvgDataUri(string $qr_link): string {
        try {
            require_once DIR_LIBRARY . DIRECTORY_SEPARATOR . 'qrcode' . DIRECTORY_SEPARATOR . 'loader.php';
            $svg = \splitbrain\phpQRCode\QRCode::svg($qr_link, ['ecl' => 'M']);
            return 'data:image/svg+xml;base64,' . base64_encode($svg);
        } catch (\Throwable $e) {
            return '';
        }
    }

    private function clientOptions(array $channel, AuthFlowInterface $flow, bool $web, array $extra = [], ?int $generation = null): ClientOptions {
        $options = array_merge([
            'authFlow' => $flow,
            'requestTimeout' => 12,
            'connectTimeout' => 8,
            'qrPollTimeout' => $web ? 8 : 180,
            'executionSafetyMargin' => 3,
            'telemetry' => false,
        ], $extra);
        return $this->lifecycle->clientOptions($channel, $options, $generation);
    }

    private function authClientIdentity(array $auth): array {
        $extra = [];
        if (!empty($auth['device_id'])) {
            $extra['deviceId'] = (string)$auth['device_id'];
        }
        if (!empty($auth['mt_instance_id'])) {
            $extra['mtInstanceId'] = (string)$auth['mt_instance_id'];
        }
        return $extra;
    }

    private function spawnQrWorker(array $channel, string $attempt_id, array $readiness = []): array {
        if ($readiness === []) {
            $readiness = $this->qrWorkerReadiness();
        }
        if (empty($readiness['success'])) {
            return $readiness;
        }
        $script = SITE_PATH . DIRECTORY_SEPARATOR . 'cli' . DIRECTORY_SEPARATOR . 'max_qr_auth_worker.php';
        $php_probe = (array)$readiness['php'];
        $php = (string)$php_probe['binary'];
        $supervisor = (string)($readiness['supervisor']['binary'] ?? '');
        if ($supervisor === '') {
            return [
                'success' => false,
                'error' => 'worker_supervisor_unavailable',
                'message' => 'Сервер не подтвердил ограничение времени фонового процесса. Войдите по телефону или обратитесь в поддержку хостинга.',
            ];
        }
        $worker_log = $this->qrWorkerErrorLogPath((int)($channel['channel_id'] ?? 0), $attempt_id);
        if ($worker_log === '' || !$this->resetQrWorkerErrorLog($worker_log)) {
            return [
                'success' => false,
                'error' => 'worker_log_unavailable',
                'message' => 'EasyChat не может подготовить защищенную диагностику QR-входа. Проверьте права на папку storage.',
            ];
        }
        $command = 'nohup ' . escapeshellarg($supervisor)
            . ' -s TERM'
            . ' -k ' . self::QR_WORKER_KILL_GRACE_SECONDS
            . ' ' . self::QR_WORKER_HARD_TIMEOUT_SECONDS
            . ' ' . escapeshellarg($php)
            . ' ' . escapeshellarg($script)
            . ' ' . (int)($channel['channel_id'] ?? 0)
            . ' ' . escapeshellarg($attempt_id)
            . ($worker_log !== '' ? ' ' . escapeshellarg($worker_log) : '')
            . ' < /dev/null > /dev/null '
            . ($worker_log !== '' ? '2>> ' . escapeshellarg($worker_log) : '2>&1')
            . ' &';
        $output = [];
        $code = 0;
        @exec($command, $output, $code);
        $this->logAuth($code === 0 ? 'info' : 'warning', 'max_auth_qr_worker_spawn_result', $channel, [
            'kind' => 'qr',
            'status' => 'qr_starting',
            'worker_mode' => 1,
            'error_code' => $code === 0 ? '' : 'spawn_exit_' . $code,
            'message' => 'QR worker launch ' . ($code === 0 ? 'accepted' : 'failed') . '; php=' . basename($php) . '; version=' . (string)($php_probe['version'] ?? '') . '; stderr=' . ($worker_log !== '' ? basename($worker_log) : 'disabled'),
        ]);
        if ($code !== 0) {
            return ['success' => false, 'error' => 'spawn_exit_' . $code, 'message' => 'Не удалось запустить локальный MAX QR worker. Проверьте PHP CLI и права на запуск команд.'];
        }
        return ['success' => true, 'binary' => $php, 'version' => (string)($php_probe['version'] ?? '')];
    }

    




    private function qrWorkerReadiness(): array {
        if (!function_exists('exec')) {
            return ['success' => false, 'error' => 'exec_disabled', 'message' => 'Хостинг запретил запуск фоновых команд. Войдите по телефону или попросите поддержку хостинга включить PHP exec.'];
        }

        $script = SITE_PATH . DIRECTORY_SEPARATOR . 'cli' . DIRECTORY_SEPARATOR . 'max_qr_auth_worker.php';
        if (!is_file($script)) {
            return ['success' => false, 'error' => 'qr_worker_missing', 'message' => 'Файлы EasyChat обновлены не полностью. Загрузите дистрибутив заново и повторите проверку.'];
        }

        $php_probe = MaxCliSupport::detectPhpCli($script);
        if (empty($php_probe['success'])) {
            return [
                'success' => false,
                'error' => (string)($php_probe['error'] ?? 'php_cli_unavailable'),
                'message' => (string)($php_probe['message'] ?? 'На сервере не найден подходящий PHP для QR-входа.'),
            ];
        }

        $runtime_dir = MaxRuntime::sessionRoot();
        if (!is_dir($runtime_dir)) {
            $this->bestEffortFilesystem(static function () use ($runtime_dir): void {
                mkdir($runtime_dir, 0700, true);
            });
        }
        if (!is_dir($runtime_dir) || !is_writable($runtime_dir)) {
            return ['success' => false, 'error' => 'session_store_unavailable', 'message' => 'EasyChat не может сохранить защищенный вход. Проверьте права на папку storage.'];
        }

        $supervisor = $this->detectQrWorkerSupervisor();
        if (empty($supervisor['success'])) {
            return $supervisor;
        }

        $probe = $this->probeDetachedPhpWorker(
            (string)$php_probe['binary'],
            $runtime_dir,
            (string)$supervisor['binary']
        );
        if (empty($probe['success'])) {
            return $probe;
        }

        return ['success' => true, 'php' => $php_probe, 'supervisor' => $supervisor];
    }

    private function detectQrWorkerSupervisor(): array {
        foreach (['/usr/bin/timeout', '/bin/timeout'] as $candidate) {
            $real = realpath($candidate);
            if ($real === false
                || !is_file($real)
                || !is_executable($real)
                || preg_match('#^/(?:usr/)?bin/timeout$#', $real) !== 1
            ) {
                continue;
            }
            $stat = stat($real);
            if (!is_array($stat)
                || (int)($stat['uid'] ?? -1) !== 0
                || (((int)($stat['mode'] ?? 0)) & 0022) !== 0
            ) {
                continue;
            }
            return ['success' => true, 'binary' => $real];
        }

        return [
            'success' => false,
            'error' => 'worker_supervisor_unavailable',
            'message' => 'На сервере нет системного ограничителя времени для фонового QR-входа. Войдите по телефону или обратитесь в поддержку хостинга.',
        ];
    }

    private function probeDetachedPhpWorker(string $php, string $runtime_dir, string $supervisor): array {
        try {
            $nonce = bin2hex(random_bytes(16));
            $marker = rtrim($runtime_dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.max-qr-probe-' . bin2hex(random_bytes(8));
        } catch (\Throwable $e) {
            return ['success' => false, 'error' => 'secure_random_unavailable', 'message' => 'Сервер не смог безопасно подготовить QR-вход. Обратитесь в поддержку хостинга.'];
        }

        $probe_code = 'umask(0077);file_put_contents($argv[1],$argv[2],LOCK_EX);';
        $command = 'nohup ' . escapeshellarg($supervisor)
            . ' -s TERM -k 2 5 '
            . escapeshellarg($php)
            . ' -r ' . escapeshellarg($probe_code)
            . ' -- ' . escapeshellarg($marker)
            . ' ' . escapeshellarg($nonce)
            . ' < /dev/null > /dev/null 2>&1 &';
        $output = [];
        $code = 0;
        @exec($command, $output, $code);
        if ($code === 0) {
            $deadline = microtime(true) + 1.5;
            do {
                usleep(50000);
                $value = is_file($marker) ? $this->bestEffortReadFile($marker) : false;
                if (is_string($value) && hash_equals($nonce, $value)) {
                    $this->bestEffortRemoveFile($marker);
                    return ['success' => true];
                }
            } while (microtime(true) < $deadline);
        }
        $this->bestEffortRemoveFile($marker);

        return [
            'success' => false,
            'error' => $code === 0 ? 'detached_worker_timeout' : 'detached_worker_rejected',
            'message' => 'Сервер не разрешил фоновый запуск QR-входа. Войдите по телефону или передайте проверку поддержке хостинга.',
        ];
    }

    private function qrWorkerErrorLogPath(int $channel_id, string $attempt_id): string {
        if ($channel_id <= 0 || !preg_match('/^[a-f0-9]{24}$/', $attempt_id)) {
            return '';
        }
        $dir = defined('DIR_LOGS')
            ? rtrim((string)DIR_LOGS, '/\\')
            : SITE_PATH . DIRECTORY_SEPARATOR . 'log';
        if (!is_dir($dir)) {
            $this->bestEffortFilesystem(static function () use ($dir): void {
                mkdir($dir, 0700, true);
            });
        }
        if (!is_dir($dir) || !is_writable($dir)) {
            return '';
        }
        return rtrim($dir, DIRECTORY_SEPARATOR)
            . DIRECTORY_SEPARATOR
            . $this->qrWorkerArtifactStem($channel_id, $attempt_id)
            . '.err.log';
    }

    private function qrWorkerFailureFromLog(int $channel_id, string $attempt_id): array {
        $path = $this->qrWorkerErrorLogPath($channel_id, $attempt_id);
        if ($path === '' || !is_file($path)) {
            return [];
        }
        $contents = $this->readPrivateControlFile($path, 16384);
        if (!is_string($contents) || !preg_match('/EASYCHAT_MAX_QR_ERROR=([a-z0-9_]+)/', $contents, $match)) {
            return [];
        }

        $code = (string)$match[1];
        $messages = [
            'database_driver_missing' => 'В PHP для фоновых задач не подключен драйвер базы данных. Включите mysqli для этой версии PHP или войдите по телефону.',
            'storage_unavailable' => 'EasyChat не может записать защищенную сессию. Проверьте права на папку storage.',
            'config_missing' => 'Фоновый PHP не нашел конфигурацию EasyChat. Проверьте целостность установки или обновите модуль.',
            'invalid_arguments' => 'EasyChat отклонил поврежденные параметры запуска QR-входа. Начните вход заново.',
            'attempt_invalid' => 'EasyChat отклонил поврежденный идентификатор QR-попытки. Начните вход заново.',
            'channel_not_found' => 'Канал личного MAX больше не найден. Обновите страницу интеграций.',
            'stale_attempt' => 'Эта попытка входа уже заменена новой. Продолжайте текущую авторизацию.',
            'worker_lock_unavailable' => 'EasyChat не смог создать защищенную блокировку QR-входа. Проверьте права на папку storage.',
            'worker_log_unavailable' => 'EasyChat не смог подготовить защищенную диагностику QR-входа. Проверьте права на папку storage.',
            'worker_terminated_unexpectedly' => 'Фоновый процесс QR-входа был преждевременно остановлен хостингом. Начните вход заново.',
            'bootstrap_failed' => 'Фоновый PHP не смог загрузить EasyChat. Проверьте его версию и расширения либо войдите по телефону.',
        ];
        return [
            'error' => 'qr_worker_' . $code,
            'message' => $messages[$code] ?? 'Сервер не смог запустить QR-вход. Повторите проверку или войдите по телефону.',
        ];
    }

    private function waitForWorkerQrState(int $channel_id, string $attempt_id, float $timeout_seconds): array {
        $deadline = microtime(true) + max(0.1, $timeout_seconds);
        do {
            usleep(200000);
            $channel = $this->model_messenger->getChannelById($channel_id);
            $auth = $channel ? $this->privateAuthState($channel) : [];
            if ($this->isSameQrAttempt($channel, $attempt_id) && (string)($auth['status'] ?? '') !== 'qr_starting') {
                return $auth;
            }
        } while (microtime(true) < $deadline);

        return [];
    }

    private function saveWorkerAuthState(int $channel_id, string $attempt_id, array $state): bool {
        $generation = max(1, (int)($state['session_generation'] ?? 0));
        $result = $this->lifecycle->updateQrAuthAttempt(
            $channel_id,
            $generation,
            $attempt_id,
            static function (array $current) use ($state): array {
                return array_merge($current, $state);
            }
        );
        if (empty($result['updated'])) {
            return false;
        }
        $channel = $this->model_messenger->getChannelById($channel_id) ?: ['channel_id' => $channel_id];
        $this->logAuth('info', 'max_auth_qr_worker_state_saved', $channel, (array)$result['state']);
        return true;
    }

    private function isCurrentAttempt($channel, string $attempt_id): bool {
        if (!$this->isSameQrAttempt($channel, $attempt_id)) {
            return false;
        }
        $auth = $this->privateAuthState($channel);
        return in_array((string)($auth['status'] ?? ''), self::QR_WORKER_ACTIVE_STATUSES, true);
    }

    private function isSameQrAttempt($channel, string $attempt_id): bool {
        if (!$channel) {
            return false;
        }
        $auth = $this->privateAuthState($channel);
        $generation = max(1, (int)($auth['session_generation'] ?? 0));
        return (string)($auth['attempt_id'] ?? '') === $attempt_id
            && (int)($auth['worker_mode'] ?? 0) === 1
            && $this->lifecycle->isCurrent((int)$channel['channel_id'], $generation);
    }

    private function openQrWorkerLock(int $channel_id, string $attempt_id): array {
        $dir = MaxRuntime::sessionRoot();
        if (!is_dir($dir)) {
            $this->bestEffortFilesystem(static function () use ($dir): void {
                mkdir($dir, 0700, true);
            });
        }
        if (!is_dir($dir) || !is_writable($dir)) {
            return ['handle' => null, 'error' => 'worker_lock_unavailable'];
        }
        $path = rtrim($dir, DIRECTORY_SEPARATOR)
            . DIRECTORY_SEPARATOR
            . $this->qrWorkerArtifactStem($channel_id, $attempt_id)
            . '.lock';
        $handle = MaxPrivateFileLock::open($path);
        if (!is_resource($handle)) {
            return ['handle' => null, 'error' => 'worker_lock_unavailable'];
        }
        if (!flock($handle, LOCK_EX | LOCK_NB)) {
            fclose($handle);
            return ['handle' => null, 'error' => 'worker_lock_busy'];
        }
        $metadata = json_encode([
            'format' => 1,
            'channel_id' => max(0, $channel_id),
            'attempt_hash' => substr(hash('sha256', $attempt_id), 0, 24),
            'started_at' => time(),
        ], JSON_UNESCAPED_SLASHES);
        $this->bestEffortFilesystem(static function () use ($handle, $metadata): void {
            ftruncate($handle, 0);
            rewind($handle);
            fwrite($handle, (string)$metadata);
            fflush($handle);
        });
        return ['handle' => $handle, 'path' => $path, 'error' => ''];
    }

    private function qrWorkerWatchdogFailure(array $channel, array $auth): array {
        $status = (string)($auth['status'] ?? '');
        if (!in_array($status, self::QR_WORKER_ACTIVE_STATUSES, true)) {
            return [];
        }

        $channel_id = (int)($channel['channel_id'] ?? 0);
        $attempt_id = (string)($auth['attempt_id'] ?? '');
        if ($status === 'qr_starting') {
            $logged = $this->qrWorkerFailureFromLog($channel_id, $attempt_id);
            if ($logged !== []) {
                $logged['event'] = 'max_auth_qr_worker_boot_failed';
                return $logged;
            }
        }

        $now = time();
        $created_at = (int)($auth['created_at'] ?? 0);
        $heartbeat_at = (int)($auth['worker_heartbeat_at'] ?? 0);
        $lease_expires_at = (int)($auth['worker_lease_expires_at'] ?? 0);
        if (
            $status === 'qr_starting'
            && $heartbeat_at <= 0
            && $created_at > 0
            && $now - $created_at > self::QR_WORKER_START_TIMEOUT_SECONDS
        ) {
            return [
                'error' => 'qr_worker_start_timeout',
                'message' => 'Сервер не смог запустить QR-вход. Начните попытку заново или войдите по телефону.',
                'event' => 'max_auth_qr_worker_start_timeout',
            ];
        }
        if (
            ($heartbeat_at > 0 && $lease_expires_at > 0 && $now > $lease_expires_at)
            || ($status !== 'qr_starting' && $heartbeat_at <= 0 && $created_at > 0 && $now - $created_at > self::QR_WORKER_LEASE_SECONDS)
        ) {
            return [
                'error' => 'qr_worker_heartbeat_timeout',
                'message' => 'Хостинг преждевременно остановил фоновый QR-вход. Начните новую попытку — старый процесс больше ее не заблокирует.',
                'event' => 'max_auth_qr_worker_heartbeat_timeout',
            ];
        }

        return [];
    }

    



    private function transitionCurrentQrAttempt(array $channel, array $observed, array $changes): array {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        $generation = max(1, (int)($observed['session_generation'] ?? 0));
        $attempt_id = (string)($observed['attempt_id'] ?? '');
        $result = $this->lifecycle->updateQrAuthAttempt(
            $channel_id,
            $generation,
            $attempt_id,
            static function (array $current) use ($observed, $changes) {
                foreach (['status', 'worker_heartbeat_at', 'worker_lease_expires_at', 'expires_at'] as $key) {
                    if ((string)($current[$key] ?? '') !== (string)($observed[$key] ?? '')) {
                        return null;
                    }
                }
                return array_merge($current, $changes);
            }
        );
        return is_array($result['state'] ?? null) ? $result['state'] : [];
    }

    private function qrWorkerArtifactStem(int $channel_id, string $attempt_id): string {
        return 'max-qr-worker-'
            . max(0, $channel_id)
            . '-'
            . substr(hash('sha256', $attempt_id), 0, 24);
    }

    private function resetQrWorkerErrorLog(string $path): bool {
        $handle = MaxPrivateFileLock::open($path);
        if (!is_resource($handle)) {
            return false;
        }
        $locked = flock($handle, LOCK_EX | LOCK_NB);
        if (!$locked) {
            fclose($handle);
            return false;
        }

        $ok = false;
        $this->bestEffortFilesystem(static function () use ($handle, &$ok): void {
            $ok = ftruncate($handle, 0) && rewind($handle) && fflush($handle);
        });
        flock($handle, LOCK_UN);
        fclose($handle);
        return $ok;
    }

    






    private function readPrivateControlFile(string $path, int $max_bytes) {
        if (!is_file($path)) {
            return false;
        }
        $handle = MaxPrivateFileLock::open($path);
        if (!is_resource($handle)) {
            return false;
        }
        if (!flock($handle, LOCK_SH | LOCK_NB)) {
            fclose($handle);
            return false;
        }

        $contents = false;
        $this->bestEffortFilesystem(static function () use ($handle, $max_bytes, &$contents): void {
            rewind($handle);
            $contents = fread($handle, max(1, $max_bytes));
        });
        flock($handle, LOCK_UN);
        fclose($handle);
        return $contents;
    }

    




    private function cleanupQrWorkerArtifacts(int $channel_id, string $current_attempt_id): void {
        $current_stem = $this->qrWorkerArtifactStem($channel_id, $current_attempt_id);
        $pattern = '/^max-qr-worker-' . preg_quote((string)max(0, $channel_id), '/') . '-[a-f0-9]{24}\\.(?:lock|err\\.log)$/';
        $cutoff = time() - self::QR_ARTIFACT_RETENTION_SECONDS;
        $dirs = [MaxRuntime::sessionRoot()];
        $log_dir = defined('DIR_LOGS')
            ? rtrim((string)DIR_LOGS, '/\\')
            : SITE_PATH . DIRECTORY_SEPARATOR . 'log';
        if (!in_array($log_dir, $dirs, true)) {
            $dirs[] = $log_dir;
        }

        foreach ($dirs as $dir) {
            if (!is_dir($dir) || !is_readable($dir) || !is_writable($dir)) {
                continue;
            }
            try {
                foreach (new \DirectoryIterator($dir) as $file) {
                    if ($file->isDot() || $file->isLink() || !$file->isFile()) {
                        continue;
                    }
                    $name = $file->getFilename();
                    if (
                        strpos($name, $current_stem . '.') === 0
                        || !preg_match($pattern, $name)
                        || $file->getMTime() >= $cutoff
                    ) {
                        continue;
                    }
                    $path = $file->getPathname();
                    $stat = lstat($path);
                    if (is_array($stat) && (int)($stat['nlink'] ?? 0) === 1) {
                        $this->bestEffortRemoveFile($path);
                    }
                }
            } catch (\Throwable $e) {
                 
                 
            }
        }
    }

    private function bestEffortFilesystem(callable $callback): void {
        set_error_handler(static function (): bool {
            return true;
        });
        try {
            $callback();
        } finally {
            restore_error_handler();
        }
    }

    






    private function bestEffortReadFile(string $path, ?int $max_bytes = null) {
        set_error_handler(static function (): bool {
            return true;
        });
        try {
            return $max_bytes === null
                ? file_get_contents($path)
                : file_get_contents($path, false, null, 0, $max_bytes);
        } catch (\Throwable $e) {
            return false;
        } finally {
            restore_error_handler();
        }
    }

    private function bestEffortRemoveFile(string $path): void {
        if (!is_file($path)) {
            return;
        }
        $this->bestEffortFilesystem(static function () use ($path): void {
            unlink($path);
        });
    }

    private function normalizePhone(string $phone): string {
        $phone = trim($phone);
        if ($phone === '') {
            return '';
        }
        $phone = preg_replace('/[\s().-]+/', '', $phone);
        return preg_match('/^\+?\d{7,18}$/', $phone) ? $phone : '';
    }

    private function maskPhone(string $phone): string {
        $digits = preg_replace('/\D+/', '', $phone);
        if (strlen($digits) <= 4) {
            return '***';
        }
        return '+' . substr($digits, 0, 1) . '***' . substr($digits, -4);
    }

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

    public static function maxDurationSeconds(int $duration): int {
        if ($duration <= 0) {
            return 0;
        }
         
         
        return $duration > 3600 ? (int)ceil($duration / 1000) : $duration;
    }
}

class MaxAuthPendingException extends \RuntimeException {}

class MaxQrWorkerFlow implements AuthFlowInterface {
    private $capture;

    public function __construct(callable $capture) {
        $this->capture = $capture;
    }

    public function authenticate(PhpMaxAuthService $authService, ClientOptions $options): AuthResult {
        $qr = $authService->requestQr();
        $expires_at = $qr->expiresAt !== null ? (int)floor($qr->expiresAt / 1000) : (time() + 180);
        $interval_ms = $qr->pollingInterval !== null && (int)$qr->pollingInterval > 0 ? (int)$qr->pollingInterval : 1000;
        call_user_func($this->capture, [
            'status' => 'qr_pending',
            'track_id' => (string)$qr->trackId,
            'qr_link' => (string)$qr->qrLink,
            'expires_at' => $expires_at,
            'polling_interval_ms' => $interval_ms,
            'device_id' => (string)$options->deviceId,
            'mt_instance_id' => (string)$options->mtInstanceId,
        ]);

        $deadline = min((float)$expires_at, microtime(true) + max(1.0, (float)$options->qrPollTimeout));
        while (microtime(true) < $deadline) {
            if (function_exists('easychat_runtime_reader_lock_checkpoint')) {
                 
                 
                 
                \easychat_runtime_reader_lock_checkpoint(function (): void {
                    call_user_func($this->capture, [
                        'status' => 'error',
                        'error_code' => 'runtime_update_in_progress',
                        'message' => 'EasyChat завершает безопасное обновление. После обновления начните QR-вход заново.',
                    ]);
                });
            }
            try {
                $status = $authService->checkQr((string)$qr->trackId);
            } catch (ApiException $e) {
                if ($e->opcode() === Opcode::GET_QR_STATUS && $e->error() === 'track.not.found') {
                    call_user_func($this->capture, [
                        'status' => 'qr_wait',
                        'error_code' => 'track.not.found',
                        'opcode' => Opcode::GET_QR_STATUS,
                    ]);
                    $this->sleepUntilNextPoll($interval_ms, $deadline);
                    continue;
                }
                throw $e;
            }

            $remote_expires_at = $status->status && $status->status->expiresAt !== null
                ? (int)floor(((int)$status->status->expiresAt) / 1000)
                : 0;
            $login_available = $status->status && $status->status->loginAvailable === true;
            call_user_func($this->capture, [
                'status' => $login_available ? 'qr_confirming' : 'qr_wait',
                'error_code' => '',
                'qr_login_available' => $login_available ? 1 : 0,
                'qr_remote_expires_at' => $remote_expires_at,
                'opcode' => Opcode::GET_QR_STATUS,
            ]);
            if ($login_available) {
                call_user_func($this->capture, [
                    'status' => 'qr_confirming',
                    'qr_login_available' => 1,
                    'opcode' => Opcode::LOGIN_BY_QR,
                ]);
                return MaxAuthFlowResult::fromCheckCode($authService->confirmQr((string)$qr->trackId), $this->capture);
            }

            $this->sleepUntilNextPoll($interval_ms, $deadline);
        }

        call_user_func($this->capture, [
            'status' => 'expired',
            'message' => 'Срок действия QR истек. Начните вход заново.',
        ]);
        throw new MaxAuthPendingException('qr_expired');
    }

    private function sleepUntilNextPoll(int $interval_ms, float $deadline): void {
        $remaining = $deadline - microtime(true);
        if ($remaining <= 0.0) {
            return;
        }
        $sleep = min(max(0.2, $interval_ms / 1000.0), $remaining, 5.0);
        usleep((int)floor($sleep * 1000000));
    }
}

class MaxSmsStartFlow implements AuthFlowInterface {
    private $phone;
    private $capture;

    public function __construct(string $phone, callable $capture) {
        $this->phone = $phone;
        $this->capture = $capture;
    }

    public function authenticate(PhpMaxAuthService $authService, ClientOptions $options): AuthResult {
        $start = $authService->requestCode($this->phone);
        $ttl_seconds = MaxAuthService::maxDurationSeconds((int)$start->requestMaxDuration);
        call_user_func($this->capture, [
            'sms_token' => (string)$start->token,
            'code_length' => (int)$start->codeLength,
            'expires_at' => time() + max(60, $ttl_seconds),
            'request_count_left' => (int)$start->requestCountLeft,
            'retry_after_seconds' => MaxAuthService::maxDurationSeconds((int)$start->altActionDuration),
            'device_id' => (string)$options->deviceId,
            'mt_instance_id' => (string)$options->mtInstanceId,
        ]);
        throw new MaxAuthPendingException('sms_pending');
    }
}

class MaxSmsCodeFlow implements AuthFlowInterface {
    private $sms_token;
    private $code;
    private $capture;

    public function __construct(string $sms_token, string $code, callable $capture) {
        $this->sms_token = $sms_token;
        $this->code = $code;
        $this->capture = $capture;
    }

    public function authenticate(PhpMaxAuthService $authService, ClientOptions $options): AuthResult {
        return MaxAuthFlowResult::fromCheckCode($authService->sendCode($this->sms_token, $this->code), $this->capture);
    }
}

class MaxPasswordFlow implements AuthFlowInterface {
    private $track_id;
    private $password;
    private $capture;

    public function __construct(string $track_id, string $password, callable $capture) {
        $this->track_id = $track_id;
        $this->password = $password;
        $this->capture = $capture;
    }

    public function authenticate(PhpMaxAuthService $authService, ClientOptions $options): AuthResult {
        $response = $authService->checkPassword($this->track_id, $this->password);
        if ($response->error !== null && $response->error !== false && $response->error !== '') {
            call_user_func($this->capture, ['status' => 'password_required']);
            throw new PHPMaxException('2fa_password_invalid');
        }
        $token = $response->loginToken();
        if ($token === null || $token === '') {
            throw new PHPMaxException('2fa_password_token_missing');
        }
        return new AuthResult($token);
    }
}

class MaxAuthFlowResult {
    public static function fromCheckCode(CheckCodeResponse $response, callable $capture): AuthResult {
        $token = $response->loginToken();
        if ($token !== null && $token !== '') {
            return new AuthResult($token);
        }
        if ($response->passwordChallenge !== null) {
            call_user_func($capture, [
                'status' => 'password_required',
                'password_track_id' => (string)$response->passwordChallenge->trackId,
                'password_hint' => (string)$response->passwordChallenge->hint,
            ]);
            throw new MaxAuthPendingException('password_required');
        }
        if ($response->registerToken() !== null) {
            throw new PHPMaxException('registration_required');
        }
        throw new PHPMaxException('auth_token_missing');
    }
}
