<?php
namespace EasyChat\Library\Messenger;

use EasyChat\Library\AppClock;
use EasyChat\Library\AttachmentPolicy;
use EasyChat\Library\CustomerContactService;
use EasyChat\Model\Message as MessageModel;
use EasyChat\Model\MessageContext;
use EasyChat\Model\Messenger as MessengerModel;
use EasyChat\Model\Session as SessionModel;
use EasyChat\Model\Setting;
use PHPMax\Client;
use PHPMax\Api\Messages\ItemType;
use PHPMax\Domain\Chat as PHPMaxChat;
use PHPMax\Domain\Message as PHPMaxMessage;
use PHPMax\Domain\Events\MessageReadEvent as PHPMaxMessageReadEvent;
use PHPMax\Domain\SyncOverrides;
use PHPMax\Domain\User as PHPMaxUser;
use PHPMax\Exception\SessionRevokedException;

class MaxPollingService {

    private const FULL_SYNC_INTERVAL_SECONDS = 600;
    private const BACKLOG_HISTORY_WINDOW = 40;
    private const BACKLOG_HISTORY_PAGE_SIZE = 30;
    private const BACKLOG_HISTORY_MAX_PAGES = 2;
    private const MESSAGE_CONTEXT_RECONCILIATION_CHATS_PER_RUN = 2;
    private const MESSAGE_CONTEXT_RECONCILIATION_LOOKUP_CANDIDATES = 8;
    private const MESSAGE_CONTEXT_SINGLE_RETRY_LIMIT = 10;
    private const BACKOFF_STEPS_SECONDS = [60, 120, 240, 480, 600];
    private const RUNNER_GENERAL_CRON = 'general_cron';
    private const RUNNER_DEDICATED_CRON = 'dedicated_cron';
    private const DEDICATED_FALLBACK_AFTER_SECONDS = 300;
    private const GENERAL_CRON_SECONDS = 3;
    private const DEDICATED_JITTER_MAX_SECONDS = 8;
    private const DEDICATED_MODES = [
        'conservative' => [
            'seconds' => 20,
            'label' => 'Бережный',
            'description' => '20 секунд соединения. Оптимально для shared-hosting.',
        ],
        'fast' => [
            'seconds' => 35,
            'label' => 'Быстрый',
            'description' => '35 секунд соединения. Сообщения появляются быстрее, нагрузка выше.',
        ],
        'realtime' => [
            'seconds' => 45,
            'label' => 'Почти сразу',
            'description' => '45 секунд соединения. Максимально близко к realtime в минутном Cron.',
        ],
    ];

    private $registry;
    private $logger;
    private $lifecycle;
    private $incidents;
    private $inbox;
    private $inbound_service_factory;
    private $user_name_cache = [];
    private $chat_name_cache = [];
    private $user_profile_cache = [];
    private $chat_profile_cache = [];

    public function __construct($registry, ?callable $inbound_service_factory = null) {
        $this->registry = $registry;
        $this->logger = new MessengerLogger($registry, MaxProvider::PROVIDER);
        $this->lifecycle = new MaxSessionLifecycle($registry);
        $this->incidents = new MaxPersonalIncidentService($registry);
        $this->inbox = new MaxDurableInbox();
        $this->inbound_service_factory = $inbound_service_factory;
        MaxRuntime::requireAutoload();
    }

    public function run(array $options = []): array {
        $model_messenger = new MessengerModel($this->registry);
        $channel = $model_messenger->getChannelByProvider(MaxProvider::PROVIDER);
        if (!$channel || (string)($channel['status'] ?? '') !== 'active') {
            return ['success' => true, 'skipped' => true, 'reason' => 'inactive'];
        }

        try {
            $context = $this->lifecycle->prepare($channel);
            $channel = $context['channel'];
            $generation = (int)$context['generation'];
        } catch (\Throwable $e) {
            $error = MaxRuntime::safeError($e);
            $this->incidents->observeFailure($channel, 'storage', $this->decodeJson((string)($channel['settings_json'] ?? '{}')));
            $this->logger->warning('max_polling_lifecycle_prepare_failed', [
                'channel_id' => (int)($channel['channel_id'] ?? 0),
                'error' => $error,
            ]);
            return ['success' => false, 'error' => $error];
        }

        $runner = $this->normalizeRunner((string)($options['source'] ?? $options['runner'] ?? self::RUNNER_GENERAL_CRON));
        $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
         
         
        $drain = $this->drainDurableInbox($channel);
        $settings = $this->applyInboxStats($settings, (int)$channel['channel_id'], $drain);
        $now = time();
        if ($this->lifecycle->isAuthenticationInProgress($settings)) {
            if ($runner === self::RUNNER_DEDICATED_CRON) {
                $settings = $this->applyDedicatedCronSkipped($settings, 'auth_in_progress', $generation);
                $this->saveRuntimeSettings($model_messenger, $channel, $settings, $generation);
            }
            $this->logger->info('max_polling_skipped_auth_in_progress', [
                'channel_id' => (int)$channel['channel_id'],
                'generation' => $generation,
                'runner' => $runner,
            ]);
            return ['success' => true, 'skipped' => true, 'reason' => 'auth_in_progress'];
        }
        $fallback_after = max(60, min(1800, (int)($options['fallback_after_seconds'] ?? self::DEDICATED_FALLBACK_AFTER_SECONDS)));
        if ($runner === self::RUNNER_GENERAL_CRON && $this->isDedicatedCronFresh($settings, $now, $fallback_after, $generation)) {
            $this->logger->info('max_polling_general_skipped_dedicated_recent', [
                'channel_id' => (int)$channel['channel_id'],
                'dedicated_last_ok_at' => (string)($settings['dedicated_cron_last_ok_at'] ?? ''),
                'fallback_after_seconds' => $fallback_after,
                'generation' => $generation,
            ]);
            return ['success' => true, 'skipped' => true, 'reason' => 'dedicated_recent'];
        }
        if ($runner === self::RUNNER_GENERAL_CRON && !$this->isGeneralCronFallbackEnabled($settings)) {
            $this->logger->info('max_polling_general_skipped_fallback_disabled', [
                'channel_id' => (int)$channel['channel_id'],
                'dedicated_last_ok_at' => (string)($settings['dedicated_cron_last_ok_at'] ?? ''),
                'fallback_setting' => (string)($settings['general_cron_fallback_enabled'] ?? ''),
            ]);
            return ['success' => true, 'skipped' => true, 'reason' => 'general_fallback_disabled'];
        }

        $lock = $this->acquireLock((int)$channel['channel_id']);
        if (!$lock) {
            return ['success' => true, 'skipped' => true, 'reason' => 'locked'];
        }

        try {
            return $this->runChannel($model_messenger, $channel, $generation, $options);
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    private function runChannel(MessengerModel $model_messenger, array $channel, int $generation, array $options): array {
        $credentials = $this->decodeJson((string)($channel['credentials_json'] ?? '{}'));
        $settings = $this->decodeJson((string)($channel['settings_json'] ?? '{}'));
        $session_name = (string)($credentials['session_name'] ?? MaxRuntime::channelSessionName((int)$channel['channel_id']));
        $work_dir = (string)($credentials['work_dir'] ?? MaxRuntime::sessionRoot());
        $token = $this->decryptSecretOrPlain((string)($credentials['token'] ?? ''));
        if ($session_name === '') {
            $settings = $this->applyScopedFailure($settings, 'api', 'max_session_missing', 'storage', $generation);
            $this->incidents->observeFailure($channel, 'storage', $settings);
            $this->saveRuntimeSettings($model_messenger, $channel, $settings, $generation, [
                'last_error' => 'max_session_missing',
            ]);
            return ['success' => false, 'error' => 'session_missing'];
        }

        $runner = $this->normalizeRunner((string)($options['source'] ?? $options['runner'] ?? self::RUNNER_GENERAL_CRON));
        $account_id = (string)($credentials['account_id'] ?? '');
        $processed = 0;
        $skipped_by_scope = 0;
        $backlog_processed = 0;
        $backlog_skipped_by_scope = 0;
        $backlog_deferred = false;
        $listen_seconds = 0;
        $service = $this->inboundService();
        $inbound_scope = MaxInboundScope::fromSettings($settings);
        $now = time();
        $dedicated_mode = $this->normalizeDedicatedMode((string)($settings['dedicated_cron_mode'] ?? ''));
        $seconds = $runner === self::RUNNER_DEDICATED_CRON
            ? $this->dedicatedModeDuration($dedicated_mode)
            : max(1, min(25, (int)($options['seconds'] ?? 8)));

        if ($this->isBackoffActive($settings, $now, $generation)) {
            if ($runner === self::RUNNER_DEDICATED_CRON) {
                $settings = $this->applyDedicatedCronSkipped($settings, 'backoff', $generation);
                $this->saveRuntimeSettings($model_messenger, $channel, $settings, $generation);
            }
            $this->logger->info('max_polling_backoff_skip', [
                'channel_id' => (int)$channel['channel_id'],
                'backoff_until' => (string)($settings['polling_backoff_until'] ?? ''),
                'error_count' => max(0, (int)($settings['polling_error_count'] ?? 0)),
                'runner' => $runner,
            ]);
            return ['success' => true, 'skipped' => true, 'reason' => 'backoff'];
        }

         
         
        $run_budget = new MaxPollingRunBudget($seconds);
        if ($runner === self::RUNNER_DEDICATED_CRON) {
            $jitter = max(0, min(self::DEDICATED_JITTER_MAX_SECONDS, (int)($settings['dedicated_cron_jitter_seconds'] ?? self::DEDICATED_JITTER_MAX_SECONDS)));
            if ($jitter > 0) {
                $sleep = random_int(0, $jitter);
                $this->logger->info('max_polling_dedicated_jitter', [
                    'channel_id' => (int)$channel['channel_id'],
                    'sleep_seconds' => $sleep,
                    'jitter_max_seconds' => $jitter,
                ]);
                if ($sleep > 0) {
                     
                     
                     
                     
                    $this->releaseIdleDatabase();
                    sleep($sleep);
                }
            }
            $now = time();
        }

        if (function_exists('easychat_runtime_reader_lock_checkpoint')) {
            \easychat_runtime_reader_lock_checkpoint();
        }

        if (!$this->lifecycle->isCurrent((int)$channel['channel_id'], $generation)) {
            return ['success' => true, 'skipped' => true, 'reason' => 'stale_generation'];
        }

        $polling_mode = $this->selectPollingMode($settings, $now, $generation);
        $client_options = [
            'workDir' => $work_dir,
            'sessionName' => $session_name,
            'token' => $token !== '' ? $token : null,
            'requestTimeout' => 10,
            'connectTimeout' => 8,
            'executionSafetyMargin' => 3,
            'telemetry' => false,
            'debugLogger' => function (string $event, array $context) use ($channel, $runner, $polling_mode): void {
                $context['channel_id'] = (int)$channel['channel_id'];
                $context['runner'] = $runner;
                $context['polling_mode'] = $polling_mode;
                $this->logger->info('max_polling_runtime_debug', $context);
            },
        ];
        if ($polling_mode === 'full_sync') {
             
             
             
            $client_options['sync'] = new SyncOverrides([
                'chatsSync' => -1,
                'contactsSync' => -1,
                'draftsSync' => -1,
                'presenceSync' => -1,
            ]);
        }

        $phase = 'api';
        $api_ok = false;
        $processing_ok = false;
        try {
            $client = new Client($this->lifecycle->clientOptions($channel, $client_options, $generation));

            $this->logger->info('max_polling_started', [
                'channel_id' => (int)$channel['channel_id'],
                'seconds' => $seconds,
                'session_name' => $session_name,
                'runner' => $runner,
                'dedicated_mode' => $runner === self::RUNNER_DEDICATED_CRON ? $dedicated_mode : '',
                'total_budget_seconds' => $run_budget->totalLimitSeconds(),
                'backlog_limit_seconds' => $run_budget->backlogLimitSeconds(),
            ]);
            $this->logger->info('max_polling_mode_selected', [
                'channel_id' => (int)$channel['channel_id'],
                'mode' => $polling_mode,
                'runner' => $runner,
                'full_sync_interval_seconds' => self::FULL_SYNC_INTERVAL_SECONDS,
                'last_full_sync_at' => (string)($settings['polling_full_sync_last_run_at'] ?? ''),
                'error_count' => max(0, (int)($settings['polling_error_count'] ?? 0)),
            ]);

            $client->onStart(function (Client $client) use (&$account_id, &$credentials, &$backlog_processed, &$backlog_skipped_by_scope, &$backlog_deferred, &$settings, &$phase, &$api_ok, &$processing_ok, $model_messenger, $channel, $generation, $service, $inbound_scope, $run_budget): void {
                $this->assertCurrentGeneration($client, (int)$channel['channel_id'], $generation);
                if (function_exists('easychat_runtime_reader_lock_checkpoint')) {
                    \easychat_runtime_reader_lock_checkpoint();
                }
                $profile = $client->me();
                $account_profile = MaxAccountProfile::fromProfile($profile);
                $settings = $this->applyApiSuccess($settings, $generation);
                $api_ok = true;
                $phase = 'processing';
                $id = $profile && $profile->contact && isset($profile->contact->id) ? (string)$profile->contact->id : '';
                if ($id !== '' && ($id !== $account_id || $account_profile)) {
                    $account_id = $id;
                    $credentials['account_id'] = $id;
                    $this->saveAccountIdentityIfCurrent((int)$channel['channel_id'], $generation, $id, $account_profile);
                }

                 
                 
                 
                 
                 
                 
                if ($run_budget->canProcessBacklog()) {
                    $this->repairConversationIdentities($client, $model_messenger, new SessionModel($this->registry), $channel, $run_budget);
                }
                $backlog_result = $this->processUnreadBacklog($client, $channel, $generation, $account_id, $service, $inbound_scope, $run_budget);
                $backlog_processed += (int)($backlog_result['processed'] ?? 0);
                $backlog_skipped_by_scope += (int)($backlog_result['skipped_by_scope'] ?? 0);
                $backlog_deferred = !empty($backlog_result['deferred']);
                $settings = $this->applyProcessingSuccess($settings, $generation);
                $processing_ok = true;
                $phase = 'listen';
            });

            $client->onMessage(function (PHPMaxMessage $message, Client $client) use (&$processed, &$skipped_by_scope, &$account_id, &$settings, &$phase, &$processing_ok, $channel, $generation, $service, $inbound_scope): void {
                $this->assertCurrentGeneration($client, (int)$channel['channel_id'], $generation);
                if (function_exists('easychat_runtime_reader_lock_checkpoint')) {
                    \easychat_runtime_reader_lock_checkpoint();
                }
                $phase = 'processing';
                $event = $this->eventFromMessage($client, $message, $account_id);
                $chat_type = $this->eventChatType($event);
                if (!MaxInboundScope::isAcceptedChatType($chat_type, $inbound_scope)) {
                    $skipped_by_scope++;
                    $this->logger->info('max_polling_live_message_skipped_scope', [
                        'channel_id' => (int)$channel['channel_id'],
                        'chat_hash' => MessengerLogger::hashExternalId((string)($event['external_chat_id'] ?? '')),
                        'message_hash' => MessengerLogger::hashExternalId((string)($event['external_message_id'] ?? '')),
                        'chat_type' => $chat_type,
                        'scope' => implode(',', $inbound_scope),
                    ]);
                    $settings = $this->applyProcessingSuccess($settings, $generation);
                    $processing_ok = true;
                    $phase = 'listen';
                    return;
                }
                try {
                    $result = $this->acceptDurableEvent($channel, $generation, $event, $service);
                } finally {
                    $this->releaseIdleDatabase();
                }
                if ($this->isIgnoredBacklogImport($result)) {
                    $this->logger->info('max_polling_live_message_ignored', [
                        'channel_id' => (int)$channel['channel_id'],
                        'chat_hash' => MessengerLogger::hashExternalId((string)($event['external_chat_id'] ?? '')),
                        'message_hash' => MessengerLogger::hashExternalId((string)($event['external_message_id'] ?? '')),
                        'ignored' => (string)($result['ignored'] ?? 'unknown'),
                    ]);
                    $settings = $this->applyProcessingSuccess($settings, $generation);
                    $processing_ok = true;
                    $phase = 'listen';
                    return;
                }
                $processed++;
                $settings = $this->applyProcessingSuccess($settings, $generation);
                $processing_ok = true;
                $phase = 'listen';
                $this->logger->info('max_polling_live_message_processed', [
                    'channel_id' => (int)$channel['channel_id'],
                    'chat_hash' => MessengerLogger::hashExternalId((string)($event['external_chat_id'] ?? '')),
                    'message_hash' => MessengerLogger::hashExternalId((string)($event['external_message_id'] ?? '')),
                    'direction' => (string)($event['direction'] ?? ''),
                    'chat_type' => $chat_type,
                    'attachment_count' => count((array)($event['attachments'] ?? [])),
                ]);
            });

            $client->onMessageRead(function (PHPMaxMessageReadEvent $event, Client $client) use (&$account_id, &$settings, &$phase, &$processing_ok, $channel, $generation): void {
                $this->assertCurrentGeneration($client, (int)$channel['channel_id'], $generation);
                if (function_exists('easychat_runtime_reader_lock_checkpoint')) {
                    \easychat_runtime_reader_lock_checkpoint();
                }

                $chat_id = isset($event->chatId) ? (string)$event->chatId : '';
                $reader_id = isset($event->userId) ? (string)$event->userId : '';
                $mark = isset($event->mark) ? max(0, (int)$event->mark) : 0;
                if (!empty($event->setAsUnread)
                    || $chat_id === ''
                    || $reader_id === ''
                    || $mark <= 0
                    || ($account_id !== '' && hash_equals($account_id, $reader_id))
                ) {
                    return;
                }

                $phase = 'processing';
                try {
                    $result = (new MessageReadReceiptService($this->registry))->observeMax(
                        (int)$channel['channel_id'],
                        $chat_id,
                        $reader_id,
                        $mark
                    );
                } finally {
                    $this->releaseIdleDatabase();
                }
                if (empty($result['success'])) {
                    throw new \RuntimeException((string)($result['error'] ?? 'max_read_receipt_failed'));
                }
                $settings = $this->applyProcessingSuccess($settings, $generation);
                $processing_ok = true;
                $phase = 'listen';
                $this->logger->info('max_polling_read_receipt_processed', [
                    'channel_id' => (int)$channel['channel_id'],
                    'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                    'changed' => (int)($result['changed'] ?? 0),
                    'ignored' => !empty($result['ignored']) ? 1 : 0,
                ]);
            });

            $client->withOpenSession(function (Client $client) use (&$listen_seconds, $run_budget): void {
                $listen_seconds = $run_budget->remainingListenerSeconds();
                if ($listen_seconds > 0) {
                     
                     
                    $this->releaseIdleDatabase();
                    $client->runFor($listen_seconds);
                }
            });
            if (function_exists('easychat_runtime_reader_lock_checkpoint')) {
                \easychat_runtime_reader_lock_checkpoint();
            }
        } catch (MaxSessionGenerationChangedException $e) {
            $this->logger->info('max_polling_stale_generation_ignored', [
                'channel_id' => (int)$channel['channel_id'],
                'generation' => $generation,
                'runner' => $runner,
            ]);
            return ['success' => true, 'skipped' => true, 'reason' => 'stale_generation', 'processed' => $processed];
        } catch (\Throwable $e) {
            $error = MaxRuntime::safeError($e);
            $session_revoked = $e instanceof SessionRevokedException;
            if (!$this->lifecycle->isCurrent((int)$channel['channel_id'], $generation)) {
                $this->logger->info('max_polling_stale_error_ignored', [
                    'channel_id' => (int)$channel['channel_id'],
                    'generation' => $generation,
                    'runner' => $runner,
                    'error_category' => $this->classifyPollingError($error, $phase),
                ]);
                return ['success' => true, 'skipped' => true, 'reason' => 'stale_generation', 'processed' => $processed];
            }
            $settings = $this->applyPollingFailure($settings, $now, $generation);
            $scope = $phase === 'processing' ? 'processing' : 'api';
             
             
             
            $category = $session_revoked ? 'authorization' : $this->classifyPollingError($error, $phase);
            $incident_settings = $settings;
            $settings = $this->applyScopedFailure($settings, $scope, $error, $category, $generation);
            if ($runner === self::RUNNER_DEDICATED_CRON) {
                $settings = $this->applyDedicatedCronFailure($settings, $error, $generation);
            }
            $saved = $this->saveRuntimeSettings($model_messenger, $channel, $settings, $generation, [
                'last_error' => $error,
            ]);
            if (!$saved) {
                return ['success' => true, 'skipped' => true, 'reason' => 'stale_generation', 'processed' => $processed];
            }
            if ($session_revoked || in_array($category, ['database', 'storage'], true)) {
                $this->incidents->observeFailure($channel, $category, $incident_settings);
            }
            if ($session_revoked) {
                $invalidated = $this->lifecycle->invalidateRejectedSession(
                    $channel,
                    $generation
                );
                $this->logger->warning('max_polling_authorization_rejected', [
                    'channel_id' => (int)$channel['channel_id'],
                    'generation' => $generation,
                    'invalidated' => $invalidated ? 1 : 0,
                    'reason' => $e->reason(),
                ]);
                return [
                    'success' => false,
                    'error' => 'authorization_required',
                    'processed' => $processed,
                ];
            }
            $this->logger->warning('max_polling_failed', [
                'channel_id' => (int)$channel['channel_id'],
                'error' => $error,
                'scope' => $scope,
                'category' => $category,
                'generation' => $generation,
            ]);
            $this->logger->warning('max_polling_backoff_set', [
                'channel_id' => (int)$channel['channel_id'],
                'backoff_until' => (string)($settings['polling_backoff_until'] ?? ''),
                'error_count' => max(0, (int)($settings['polling_error_count'] ?? 0)),
            ]);
            return ['success' => false, 'error' => $error, 'processed' => $processed];
        }

        if ($api_ok) {
            $settings = $this->applyApiSuccess($settings, $generation);
        }
        if ($processing_ok) {
            $settings = $this->applyProcessingSuccess($settings, $generation);
        }
        $settings = $this->applyInboxStats($settings, (int)$channel['channel_id']);
        $settings = $this->applyPollingSuccess(
            $settings,
            $polling_mode,
            $processed,
            $backlog_processed,
            $backlog_deferred,
            $generation
        );
        if ($runner === self::RUNNER_DEDICATED_CRON) {
            $settings = $this->applyDedicatedCronSuccess($settings, $dedicated_mode, $seconds, $processed, $backlog_processed, $generation);
        }
        $settings['polling_last_run_at'] = (new AppClock($this->registry))->nowSql();
        if (!$this->saveRuntimeSettings($model_messenger, $channel, $settings, $generation, [
            'last_error' => '',
        ])) {
            return ['success' => true, 'skipped' => true, 'reason' => 'stale_generation', 'processed' => $processed];
        }
        $this->incidents->observeSuccess((int)$channel['channel_id']);

        $this->logger->info('max_polling_completed', [
            'channel_id' => (int)$channel['channel_id'],
            'live_processed' => $processed,
            'live_skipped_by_scope' => $skipped_by_scope,
            'backlog_processed' => $backlog_processed,
            'backlog_skipped_by_scope' => $backlog_skipped_by_scope,
            'backlog_deferred' => $backlog_deferred ? 1 : 0,
            'listen_seconds' => $listen_seconds,
            'elapsed_ms' => $run_budget->elapsedMilliseconds(),
            'scope' => implode(',', $inbound_scope),
            'runner' => $runner,
            'generation' => $generation,
        ]);

        return ['success' => true, 'processed' => $processed + $backlog_processed, 'live_processed' => $processed, 'backlog_processed' => $backlog_processed, 'backlog_deferred' => $backlog_deferred, 'skipped_by_scope' => $skipped_by_scope + $backlog_skipped_by_scope];
    }

    private function saveRuntimeSettings(MessengerModel $model_messenger, array $channel, array $settings, int $generation, array $fields = []): bool {
        $channel_id = (int)($channel['channel_id'] ?? 0);
        if ($channel_id <= 0) {
            return false;
        }

        return $this->lifecycle->updateIfCurrent($channel_id, $generation, function (array $fresh_channel) use ($settings, $fields): array {
            $merged = $this->decodeJson((string)($fresh_channel['settings_json'] ?? '{}'));
            foreach ($settings as $key => $value) {
                if ($this->isPollingRuntimeSettingKey((string)$key)) {
                    $merged[$key] = $value;
                }
            }

             
             
             
            $update = $fields;
            $update['settings_json'] = json_encode($merged, JSON_UNESCAPED_UNICODE);
            return $update;
        });
    }

    private function isPollingRuntimeSettingKey(string $key): bool {
        return strpos($key, 'polling_') === 0 || strpos($key, 'dedicated_cron_') === 0;
    }

    private function acceptDurableEvent(array $channel, int $generation, array $event, InboundService $service): array {
        return $this->inbox->acceptAndProcess(
            (int)$channel['channel_id'],
            $generation,
            $event,
            function (array $queued) use ($channel, $service): array {
                $raw = json_encode($queued, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                $result = $service->handleWebhook(MaxProvider::PROVIDER, $channel, $queued, (string)$raw, '');
                if (empty($result['success'])) {
                    throw new \RuntimeException((string)($result['error'] ?? 'max_polling_inbound_failed'));
                }
                $this->recordProviderPhoneEvidence($queued, $result);
                return $result;
            }
        );
    }

    private function drainDurableInbox(array $channel): array {
        $service = $this->inboundService();
        $result = $this->inbox->drain((int)$channel['channel_id'], function (array $event) use ($channel, $service): array {
            $raw = json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            $processed = $service->handleWebhook(MaxProvider::PROVIDER, $channel, $event, (string)$raw, '');
            if (empty($processed['success'])) {
                throw new \RuntimeException((string)($processed['error'] ?? 'max_inbox_recovery_failed'));
            }
            $this->recordProviderPhoneEvidence($event, $processed);
            return $processed;
        });
        if ((int)($result['processed'] ?? 0) > 0 || (int)($result['failed'] ?? 0) > 0) {
            $log_context = [
                'channel_id' => (int)$channel['channel_id'],
                'processed' => (int)($result['processed'] ?? 0),
                'remaining' => (int)($result['remaining'] ?? 0),
                'failed' => (int)($result['failed'] ?? 0),
            ];
            if ((int)($result['failed'] ?? 0) > 0) {
                $this->logger->warning('max_durable_inbox_drained', $log_context);
            } else {
                $this->logger->info('max_durable_inbox_drained', $log_context);
            }
        }
        $this->releaseIdleDatabase();
        return $result;
    }

    private function applyInboxStats(array $settings, int $channel_id, array $drain = []): array {
        $stats = $this->inbox->stats($channel_id);
        $settings['polling_inbox_pending_count'] = max(0, (int)($stats['count'] ?? 0));
        $settings['polling_inbox_pending_bytes'] = max(0, (int)($stats['bytes'] ?? 0));
        $settings['polling_inbox_oldest_at'] = max(0, (int)($stats['oldest_at'] ?? 0));
        $settings['polling_inbox_last_drain_failed'] = max(0, (int)($drain['failed'] ?? 0));
        return $settings;
    }

    private function releaseIdleDatabase(): void {
        $db = $this->registry->get('db');
        if (is_object($db) && method_exists($db, 'close')) {
            try {
                $db->close();
            } catch (\Throwable $e) {
                 
                 
                $this->logger->warning('max_polling_database_release_failed', []);
            }
        }
    }

    private function assertCurrentGeneration(Client $client, int $channel_id, int $generation): void {
        if ($this->lifecycle->isCurrent($channel_id, $generation)) {
            return;
        }
        $client->stop();
        throw new MaxSessionGenerationChangedException('MAX session generation changed');
    }

    private function saveAccountIdentityIfCurrent(int $channel_id, int $generation, string $account_id, array $account_profile = []): void {
        $this->lifecycle->updateIfCurrent($channel_id, $generation, function (array $fresh_channel) use ($account_id, $account_profile): array {
            $credentials = $this->decodeJson((string)($fresh_channel['credentials_json'] ?? '{}'));
            $update = [];
            if ((string)($credentials['account_id'] ?? '') !== $account_id) {
                $credentials['account_id'] = $account_id;
                $update['credentials_json'] = json_encode($credentials, JSON_UNESCAPED_UNICODE);
            }
            if ((string)($fresh_channel['bot_user_id'] ?? '') !== $account_id) {
                $update['bot_user_id'] = $account_id;
            }
            if ($account_profile) {
                $settings = $this->decodeJson((string)($fresh_channel['settings_json'] ?? '{}'));
                $current_profile = MaxAccountProfile::normalize(is_array($settings['account_profile'] ?? null) ? $settings['account_profile'] : []);
                $next_profile = MaxAccountProfile::normalize($account_profile);
                if (
                    (string)$current_profile['display_name'] !== (string)$next_profile['display_name']
                    || (string)$current_profile['phone_mask'] !== (string)$next_profile['phone_mask']
                ) {
                    $settings['account_profile'] = $next_profile;
                    $update['settings_json'] = json_encode($settings, JSON_UNESCAPED_UNICODE);
                }
            }
            return $update;
        });
    }

    private function classifyPollingError(string $error, string $phase): string {
        $normalized = strtolower($error);
        if (
            strpos($normalized, 'too many connections') !== false
            || strpos($normalized, 'server has gone away') !== false
            || strpos($normalized, 'lost connection') !== false
            || strpos($normalized, 'error no: 1040') !== false
            || strpos($normalized, 'error no: 2002') !== false
            || strpos($normalized, 'error no: 2006') !== false
            || strpos($normalized, 'error no: 2013') !== false
            || strpos($normalized, 'sqlstate[hy000] [1040]') !== false
        ) {
            return 'database';
        }
        if (
            strpos($normalized, 'session store') !== false
            || strpos($normalized, 'not writable') !== false
            || strpos($normalized, 'invalid session store json') !== false
            || strpos($normalized, 'unable to write') !== false
        ) {
            return 'storage';
        }
        if (
            strpos($normalized, 'wrong_password') !== false
            || strpos($normalized, 'login.cred') !== false
            || strpos($normalized, 'auth') !== false
            || strpos($normalized, 'session') !== false
            || strpos($normalized, 'no session') !== false
        ) {
            return 'authorization';
        }
        if (
            strpos($normalized, 'timeout') !== false
            || strpos($normalized, 'timed out') !== false
            || strpos($normalized, 'failed to connect') !== false
            || strpos($normalized, 'tcp transport') !== false
            || strpos($normalized, 'connection') !== false
            || strpos($normalized, 'tls') !== false
        ) {
            return 'transport';
        }
        return $phase === 'processing' ? 'message_processing' : 'max_api';
    }

    private function applyApiSuccess(array $settings, int $generation): array {
        $now_sql = (new AppClock($this->registry))->nowSql();
        $now_ts = time();
        $settings['polling_api_last_ok_at'] = $now_sql;
        $settings['polling_api_last_ok_ts'] = $now_ts;
        $settings['polling_api_last_ok_generation'] = $generation;
        $settings['polling_api_last_error_at'] = '';
        $settings['polling_api_last_error_ts'] = 0;
        $settings['polling_api_last_error'] = '';
        $settings['polling_api_last_error_category'] = '';
        $settings['polling_api_last_error_generation'] = 0;
        return $settings;
    }

    private function applyProcessingSuccess(array $settings, int $generation): array {
        $now_sql = (new AppClock($this->registry))->nowSql();
        $now_ts = time();
        $settings['polling_processing_last_ok_at'] = $now_sql;
        $settings['polling_processing_last_ok_ts'] = $now_ts;
        $settings['polling_processing_last_ok_generation'] = $generation;
        $settings['polling_processing_last_error_at'] = '';
        $settings['polling_processing_last_error_ts'] = 0;
        $settings['polling_processing_last_error'] = '';
        $settings['polling_processing_last_error_category'] = '';
        $settings['polling_processing_last_error_generation'] = 0;
        return $settings;
    }

    private function applyScopedFailure(
        array $settings,
        string $scope,
        string $error,
        string $category,
        int $generation
    ): array {
        $scope = $scope === 'processing' ? 'processing' : 'api';
        $now_sql = (new AppClock($this->registry))->nowSql();
        $now_ts = time();
        $settings['polling_' . $scope . '_last_error_at'] = $now_sql;
        $settings['polling_' . $scope . '_last_error_ts'] = $now_ts;
        $settings['polling_' . $scope . '_last_error'] = substr($error, 0, 180);
        $settings['polling_' . $scope . '_last_error_category'] = $category;
        $settings['polling_' . $scope . '_last_error_generation'] = $generation;
        return $settings;
    }

    public static function dedicatedCronModeOptions(): array {
        return self::DEDICATED_MODES;
    }

    public static function normalizeDedicatedCronMode(string $mode): string {
        $mode = trim($mode);
        return isset(self::DEDICATED_MODES[$mode]) ? $mode : 'conservative';
    }

    public static function dedicatedCronModeSeconds(string $mode): int {
        $mode = self::normalizeDedicatedCronMode($mode);
        return (int)self::DEDICATED_MODES[$mode]['seconds'];
    }

    public static function dedicatedCronFallbackAfterSeconds(): int {
        return self::DEDICATED_FALLBACK_AFTER_SECONDS;
    }

    public static function generalCronSeconds(): int {
        return self::GENERAL_CRON_SECONDS;
    }

    private function isGeneralCronFallbackEnabled(array $settings): bool {
        $value = $settings['general_cron_fallback_enabled'] ?? '';
        if (is_bool($value)) {
            return $value;
        }
        return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
    }

    private function selectPollingMode(array $settings, int $now, int $generation): string {
        if ((int)($settings['polling_full_sync_generation'] ?? 0) !== $generation) {
            return 'full_sync';
        }
        $last_full_sync = max(0, (int)($settings['polling_full_sync_last_run_ts'] ?? 0));
        if ($last_full_sync <= 0) {
            $last_full_sync = $this->settingTimestamp((string)($settings['polling_full_sync_last_run_at'] ?? ''));
        }
        $error_count = max(0, (int)($settings['polling_error_count'] ?? 0));

        if ($last_full_sync <= 0) {
            return 'full_sync';
        }
        if ($error_count > 0) {
            return 'full_sync';
        }
        if (($now - $last_full_sync) >= self::FULL_SYNC_INTERVAL_SECONDS) {
            return 'full_sync';
        }

        return 'incremental';
    }

    private function isBackoffActive(array $settings, int $now, int $generation): bool {
        if ((int)($settings['polling_session_generation'] ?? 0) !== $generation) {
            return false;
        }
        $backoff_until = max(0, (int)($settings['polling_backoff_until_ts'] ?? 0));
        if ($backoff_until <= 0) {
            $backoff_until = $this->settingTimestamp((string)($settings['polling_backoff_until'] ?? ''));
        }
        return $backoff_until > $now;
    }

    private function normalizeRunner(string $runner): string {
        return $runner === self::RUNNER_DEDICATED_CRON ? self::RUNNER_DEDICATED_CRON : self::RUNNER_GENERAL_CRON;
    }

    private function normalizeDedicatedMode(string $mode): string {
        return self::normalizeDedicatedCronMode($mode);
    }

    private function dedicatedModeDuration(string $mode): int {
        return self::dedicatedCronModeSeconds($mode);
    }

    private function isDedicatedCronFresh(array $settings, int $now, int $fallback_after, int $generation): bool {
        if ((int)($settings['dedicated_cron_last_ok_generation'] ?? 0) !== $generation) {
            return false;
        }
        $last_ok = max(0, (int)($settings['dedicated_cron_last_ok_ts'] ?? 0));
        if ($last_ok <= 0) {
            $last_ok = $this->settingTimestamp((string)($settings['dedicated_cron_last_ok_at'] ?? ''));
        }
        return $last_ok > 0 && ($now - $last_ok) < $fallback_after;
    }

    private function applyDedicatedCronSuccess(array $settings, string $mode, int $seconds, int $live_processed, int $backlog_processed, int $generation): array {
        $now_sql = (new AppClock($this->registry))->nowSql();
        $now_ts = time();
        $settings['dedicated_cron_mode'] = $this->normalizeDedicatedMode($mode);
        $settings['dedicated_cron_seconds'] = $seconds;
        $settings['dedicated_cron_last_run_at'] = $now_sql;
        $settings['dedicated_cron_last_run_ts'] = $now_ts;
        $settings['dedicated_cron_last_run_generation'] = $generation;
        $settings['dedicated_cron_last_ok_at'] = $now_sql;
        $settings['dedicated_cron_last_ok_ts'] = $now_ts;
        $settings['dedicated_cron_last_ok_generation'] = $generation;
        $settings['dedicated_cron_last_processed'] = $live_processed + $backlog_processed;
        $settings['dedicated_cron_last_live_processed'] = $live_processed;
        $settings['dedicated_cron_last_backlog_processed'] = $backlog_processed;
        $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;
        return $settings;
    }

    private function applyDedicatedCronFailure(array $settings, string $error, int $generation): array {
        $now_sql = (new AppClock($this->registry))->nowSql();
        $now_ts = time();
        $settings['dedicated_cron_last_run_at'] = $now_sql;
        $settings['dedicated_cron_last_run_ts'] = $now_ts;
        $settings['dedicated_cron_last_run_generation'] = $generation;
        $settings['dedicated_cron_last_error_at'] = $now_sql;
        $settings['dedicated_cron_last_error_ts'] = $now_ts;
        $settings['dedicated_cron_last_error'] = $error;
        $settings['dedicated_cron_last_error_generation'] = $generation;
        $settings['dedicated_cron_last_skipped_reason'] = '';
        $settings['dedicated_cron_last_skipped_generation'] = 0;
        return $settings;
    }

    private function applyDedicatedCronSkipped(array $settings, string $reason, int $generation): array {
        $now_sql = (new AppClock($this->registry))->nowSql();
        $now_ts = time();
        $settings['dedicated_cron_last_run_at'] = $now_sql;
        $settings['dedicated_cron_last_run_ts'] = $now_ts;
        $settings['dedicated_cron_last_run_generation'] = $generation;
        $settings['dedicated_cron_last_skipped_reason'] = $reason;
        $settings['dedicated_cron_last_skipped_generation'] = $generation;
        return $settings;
    }

    private function applyPollingFailure(array $settings, int $now, int $generation): array {
        $error_count = max(0, (int)($settings['polling_error_count'] ?? 0)) + 1;
        $step_index = min($error_count - 1, count(self::BACKOFF_STEPS_SECONDS) - 1);
        $backoff_until = $now + self::BACKOFF_STEPS_SECONDS[$step_index];
        $settings['polling_error_count'] = $error_count;
        $settings['polling_backoff_until_ts'] = $backoff_until;
        $settings['polling_backoff_until'] = date('Y-m-d H:i:s', $backoff_until);
        $settings['polling_last_error_ts'] = $now;
        $settings['polling_last_error_at'] = date('Y-m-d H:i:s', $now);
        $settings['polling_session_generation'] = $generation;
        return $settings;
    }

    private function applyPollingSuccess(array $settings, string $mode, int $live_processed, int $backlog_processed, bool $backlog_deferred, int $generation): array {
        $now_sql = (new AppClock($this->registry))->nowSql();
        $now_ts = time();
        $settings['polling_last_mode'] = $mode;
        $settings['polling_last_processed'] = $live_processed + $backlog_processed;
        $settings['polling_last_live_processed'] = $live_processed;
        $settings['polling_last_backlog_processed'] = $backlog_processed;
        $settings['polling_backlog_deferred'] = $backlog_deferred ? 1 : 0;
        $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;
        if ($mode === 'full_sync') {
            $settings['polling_full_sync_last_run_at'] = $now_sql;
            $settings['polling_full_sync_last_run_ts'] = $now_ts;
            $settings['polling_full_sync_generation'] = $generation;
        }
        return $settings;
    }

    private function settingTimestamp(string $value): int {
        $value = trim($value);
        if ($value === '') {
            return 0;
        }
        $timestamp = strtotime($value);
        return $timestamp !== false ? (int)$timestamp : 0;
    }

    private function processUnreadBacklog(Client $client, array $channel, int $generation, string $account_id, InboundService $service, array $inbound_scope, MaxPollingRunBudget $run_budget): array {
        if (!$run_budget->canProcessBacklog()) {
            $this->logger->info('max_polling_backlog_budget_exhausted', [
                'channel_id' => (int)$channel['channel_id'],
                'processed' => 0,
                'elapsed_ms' => $run_budget->elapsedMilliseconds(),
                'phase' => 'before_chat_refresh',
            ]);
            return ['processed' => 0, 'skipped_by_scope' => 0, 'deferred' => true];
        }

        $chats = $this->freshBacklogChats($client, $channel);
        $model_message = new MessageModel($this->registry);
        $model_message_context = new MessageContext($this->registry);
        $model_messenger = new MessengerModel($this->registry);
        $chats = is_array($chats) ? $chats : [];
        if ($this->countUnreadChats($chats) === 0 && $run_budget->canProcessBacklog()) {
            $chats = $this->appendMessageContextRepairChats(
                $client,
                $chats,
                (int)$channel['channel_id'],
                $model_message_context,
                $run_budget
            );
        }
        $chat_count = is_array($chats) ? count($chats) : 0;
        $unread_chats = 0;
        $processed = 0;
        $skipped_by_scope = 0;
        $context_reconciliation_chats = 0;
        $deferred = false;

        if (!is_array($chats) || $chats === []) {
            $this->logger->info('max_polling_login_summary', [
                'channel_id' => (int)$channel['channel_id'],
                'chat_count' => $chat_count,
                'unread_chat_count' => 0,
                'scope' => implode(',', $inbound_scope),
            ]);
            return ['processed' => 0, 'skipped_by_scope' => 0, 'deferred' => false];
        }

        foreach ($chats as $chat) {
            if (!$chat instanceof PHPMaxChat) {
                continue;
            }
            $new_messages = max(0, (int)($chat->newMessages ?? 0));
            if ($new_messages <= 0) {
                continue;
            }
            $unread_chats++;
        }

        $this->logger->info('max_polling_login_summary', [
            'channel_id' => (int)$channel['channel_id'],
            'chat_count' => $chat_count,
            'unread_chat_count' => $unread_chats,
            'scope' => implode(',', $inbound_scope),
        ]);

        $unread_external_chat_ids = [];
        $candidate_last_message_ids = [];
        foreach ($chats as $chat) {
            if ($chat instanceof PHPMaxChat && max(0, (int)($chat->newMessages ?? 0)) > 0 && $chat->id !== null) {
                $unread_external_chat_ids[] = (string)$chat->id;
            }
            if ($chat instanceof PHPMaxChat
                && $chat->lastMessage instanceof PHPMaxMessage
                && isset($chat->lastMessage->id)
                && (string)$chat->lastMessage->id !== '') {
                $candidate_last_message_ids[] = (string)$chat->lastMessage->id;
            }
        }
        $existing_last_messages = $model_message->findExistingExternalMessageIds(
            MaxProvider::PROVIDER,
            (int)$channel['channel_id'],
            $candidate_last_message_ids
        );
        $history_external_chat_ids = $unread_external_chat_ids;
        foreach ($chats as $chat) {
            if (!$chat instanceof PHPMaxChat || $chat->id === null) {
                continue;
            }
            $chat_type = $this->chatType($chat);
            if (!in_array($chat_type, ['DIALOG', 'CHAT', 'CHANNEL'], true)) {
                continue;
            }
            $last_message_id = $chat->lastMessage instanceof PHPMaxMessage && isset($chat->lastMessage->id)
                ? (string)$chat->lastMessage->id
                : '';
            $existing_last = $last_message_id !== '' ? ($existing_last_messages[$last_message_id] ?? []) : [];
            $needs_dialog_history = $chat_type === 'DIALOG'
                && $last_message_id !== ''
                && (!$existing_last || $this->isRetryableAttachmentPlaceholder($existing_last));
            $needs_group_system_event_repair = in_array($chat_type, ['CHAT', 'CHANNEL'], true)
                && $existing_last
                && $this->isRetryableAttachmentPlaceholder($existing_last);
            if ($needs_dialog_history || $needs_group_system_event_repair) {
                $history_external_chat_ids[] = (string)$chat->id;
            }
        }
        $deletion_boundaries = $model_messenger->getExternalChatDeletionBoundaries(
            (int)$channel['channel_id'],
            array_values(array_unique($history_external_chat_ids))
        );

        foreach ($chats as $chat) {
            if (!$run_budget->canProcessBacklog()) {
                $deferred = true;
                break;
            }
            if (!$chat instanceof PHPMaxChat) {
                continue;
            }
            $new_messages = max(0, (int)($chat->newMessages ?? 0));
            $last_message_id = $chat->lastMessage instanceof PHPMaxMessage && isset($chat->lastMessage->id)
                ? (string)$chat->lastMessage->id
                : '';
            $existing_last = $last_message_id !== '' ? ($existing_last_messages[$last_message_id] ?? []) : [];
            $chat_type = $this->chatType($chat);
            $contact = in_array($chat_type, ['CHAT', 'CHANNEL'], true) && $chat->id !== null
                ? $model_messenger->findContactByExternalChat((int)$channel['channel_id'], (string)$chat->id)
                : [];
            $local_chat_id = (int)($contact['chat_id'] ?? 0);
            $needs_message_context_reconciliation = $context_reconciliation_chats < self::MESSAGE_CONTEXT_RECONCILIATION_CHATS_PER_RUN
                && in_array($chat_type, ['CHAT', 'CHANNEL'], true)
                && $local_chat_id > 0
                && $model_message_context->hasRecentMessagesWithoutContext($local_chat_id, 'max_message', self::BACKLOG_HISTORY_WINDOW);
            $needs_dialog_history_reconciliation = $new_messages <= 0
                && $chat_type === 'DIALOG'
                && $last_message_id !== ''
                && (!$existing_last || $this->isRetryableAttachmentPlaceholder($existing_last));
             
             
             
             
            $needs_group_system_event_reconciliation = $new_messages <= 0
                && in_array($chat_type, ['CHAT', 'CHANNEL'], true)
                && $last_message_id !== ''
                && $existing_last
                && $this->isRetryableAttachmentPlaceholder($existing_last);
            $needs_history_reconciliation = $needs_dialog_history_reconciliation
                || $needs_group_system_event_reconciliation;
            $history_reconciliation_existing_only = $needs_group_system_event_reconciliation;
            if ($new_messages <= 0 && !$needs_history_reconciliation && !$needs_message_context_reconciliation) {
                continue;
            }
            $scope_accepted = MaxInboundScope::isAcceptedChatType($chat_type, $inbound_scope);
            if (!$scope_accepted
                && !$needs_message_context_reconciliation
                && !$needs_group_system_event_reconciliation
            ) {
                $skipped_by_scope++;
                $this->logger->info('max_polling_unread_chat_skipped_scope', [
                    'channel_id' => (int)$channel['channel_id'],
                    'chat_hash' => MessengerLogger::hashExternalId((string)($chat->id ?? '')),
                    'chat_type' => $chat_type,
                    'new_messages' => $new_messages,
                    'scope' => implode(',', $inbound_scope),
                ]);
                continue;
            }
             
             
             
             
            $context_reconciliation_only = !$scope_accepted && $needs_message_context_reconciliation;
            if ($needs_message_context_reconciliation) {
                $context_reconciliation_chats++;
            }
            if ($chat->id === null) {
                continue;
            }

            $chat_id = (int)$chat->id;
            if ($needs_history_reconciliation) {
                $this->logger->info('max_polling_history_reconciliation_detected', [
                    'channel_id' => (int)$channel['channel_id'],
                    'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                    'last_message_hash' => MessengerLogger::hashExternalId($last_message_id),
                    'reported_new_messages' => $new_messages,
                ]);
            }
            $deleted_at = (string)($deletion_boundaries[(string)$chat_id] ?? '');
            $deleted_ts = $this->sqlTimestamp($deleted_at);
            $last_message_ts = $chat->lastMessage instanceof PHPMaxMessage
                ? $this->providerTimestamp((int)($chat->lastMessage->time ?? 0))
                : 0;
            if ($deleted_ts > 0 && $last_message_ts > 0 && $last_message_ts <= $deleted_ts) {
                 
                 
                $this->logger->info('max_polling_deleted_chat_backlog_skipped', [
                    'channel_id' => (int)$channel['channel_id'],
                    'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                    'new_messages' => $new_messages,
                ]);
                continue;
            }
             
             
             
             
            $limit = self::BACKLOG_HISTORY_WINDOW;
            $this->logger->info('max_polling_unread_chat_detected', [
                'channel_id' => (int)$channel['channel_id'],
                'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                'chat_type' => $chat_type,
                'new_messages' => $new_messages,
                'last_message_hash' => $chat->lastMessage && isset($chat->lastMessage->id) ? MessengerLogger::hashExternalId((string)$chat->lastMessage->id) : '',
            ]);

            $history = $this->fetchBacklogHistoryWindow($client, $chat_id, $limit, $run_budget);
            if ($history === [] && !$run_budget->canProcessBacklog()) {
                 
                 
                $deferred = true;
                break;
            }
            if ($needs_message_context_reconciliation) {
                $history = $this->appendMissingContextMessages(
                    $client,
                    $chat_id,
                    $local_chat_id,
                    (int)$channel['channel_id'],
                    $chat_type,
                    $this->displayNameFromChat($chat),
                    $history,
                    $model_message_context,
                    $run_budget
                );
            }
            $history_message_ids = array_map(static function (PHPMaxMessage $message): string {
                return (string)($message->id ?? '');
            }, $history);
            $existing_messages = $model_message->findExistingExternalMessageIds(
                MaxProvider::PROVIDER,
                (int)$channel['channel_id'],
                $history_message_ids
            );
            $existing_local_message_ids = [];
            foreach ($existing_messages as $existing_message) {
                if (!empty($existing_message['message_id'])) {
                    $existing_local_message_ids[] = (int)$existing_message['message_id'];
                }
            }
            $existing_max_contexts = $model_message_context->getByMessageIds($existing_local_message_ids, ['max_message']);

            $this->logger->info('max_polling_backlog_history_fetched', [
                'channel_id' => (int)$channel['channel_id'],
                'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                'requested' => $limit,
                'history_count' => count($history),
                'existing_count' => count($existing_messages),
                'reported_new_messages' => $new_messages,
            ]);

            $read_candidate = null;
            $imports_started_in_chat = 0;
            foreach ($history as $message) {
                if (!$message instanceof PHPMaxMessage) {
                    continue;
                }
                $external_message_id = (string)($message->id ?? '');
                $is_self_message = $this->isSelfMessage($message, $account_id);
                $existing_message = $external_message_id !== ''
                    ? ($existing_messages[$external_message_id] ?? [])
                    : [];
                $retryable_attachment_placeholder = $existing_message
                    && $this->isRetryableAttachmentPlaceholder($existing_message);
                $existing_local_message_id = (int)($existing_message['message_id'] ?? 0);
                $context_reconciliation_handled = false;
                if ($existing_message
                    && $needs_message_context_reconciliation
                    && $existing_local_message_id > 0
                    && !isset($existing_max_contexts[$existing_local_message_id])
                    && $run_budget->canProcessBacklog()) {
                    $event = $this->eventFromMessage($client, $message, $account_id, [
                        'chat_id' => (string)$chat_id,
                        'chat_type' => $chat_type,
                    ]);
                    $event['metadata']['sync_origin'] = 'message_context_reconciliation';
                    $result = $this->acceptDurableEvent($channel, $generation, $event, $service);
                    $context_reconciliation_handled = true;
                    $this->logger->info('max_polling_message_context_reconciled', [
                        'channel_id' => (int)$channel['channel_id'],
                        'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                        'message_hash' => MessengerLogger::hashExternalId($external_message_id),
                        'chat_type' => $chat_type,
                    ]);
                }
                if ($existing_message && (!$retryable_attachment_placeholder || $context_reconciliation_handled)) {
                    if (!$is_self_message
                        && !$needs_history_reconciliation
                        && !$context_reconciliation_only
                        && $new_messages > 0
                    ) {
                        $read_candidate = $message;
                    }
                    $this->logger->info('max_polling_backlog_message_already_imported', [
                        'channel_id' => (int)$channel['channel_id'],
                        'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                        'message_hash' => MessengerLogger::hashExternalId($external_message_id),
                        'direction' => $is_self_message ? 'out' : 'in',
                    ]);
                    continue;
                }
                if ($context_reconciliation_only && !$existing_message) {
                     
                     
                    continue;
                }
                if ($needs_message_context_reconciliation && $new_messages <= 0 && !$existing_message) {
                     
                     
                    continue;
                }
                if ($history_reconciliation_existing_only && !$existing_message) {
                     
                     
                     
                    continue;
                }
                if ($existing_message) {
                    $this->logger->info('max_polling_backlog_attachment_retry', [
                        'channel_id' => (int)$channel['channel_id'],
                        'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                        'message_hash' => MessengerLogger::hashExternalId($external_message_id),
                    ]);
                }
                if (!$run_budget->canProcessBacklog() && $imports_started_in_chat > 0) {
                    $deferred = true;
                    break;
                }
                $imports_started_in_chat++;
                $event = $this->eventFromMessage($client, $message, $account_id, [
                    'chat_id' => (string)$chat_id,
                    'chat_type' => $chat_type,
                ]);
                if ($needs_history_reconciliation) {
                     
                     
                     
                    $event['metadata']['sync_origin'] = 'history_reconciliation';
                }
                $event_chat_type = $this->eventChatType($event);
                if (!MaxInboundScope::isAcceptedChatType($event_chat_type, $inbound_scope)
                    && !$history_reconciliation_existing_only
                ) {
                    $skipped_by_scope++;
                    $this->logger->info('max_polling_backlog_message_skipped_scope', [
                        'channel_id' => (int)$channel['channel_id'],
                        'chat_hash' => MessengerLogger::hashExternalId((string)($event['external_chat_id'] ?? '')),
                        'message_hash' => MessengerLogger::hashExternalId((string)($event['external_message_id'] ?? '')),
                        'chat_type' => $event_chat_type,
                        'scope' => implode(',', $inbound_scope),
                    ]);
                    continue;
                }
                $result = $this->acceptDurableEvent($channel, $generation, $event, $service);
                if ($this->isIgnoredBacklogImport($result)) {
                    $this->logger->warning('max_polling_backlog_message_not_imported', [
                        'channel_id' => (int)$channel['channel_id'],
                        'chat_hash' => MessengerLogger::hashExternalId((string)($event['external_chat_id'] ?? '')),
                        'message_hash' => MessengerLogger::hashExternalId((string)($event['external_message_id'] ?? '')),
                        'chat_type' => $event_chat_type,
                        'ignored' => (string)($result['ignored'] ?? 'unknown'),
                    ]);
                    continue;
                }
                $processed++;
                $this->logger->info('max_polling_backlog_message_processed', [
                    'channel_id' => (int)$channel['channel_id'],
                    'chat_hash' => MessengerLogger::hashExternalId((string)($event['external_chat_id'] ?? '')),
                    'message_hash' => MessengerLogger::hashExternalId((string)($event['external_message_id'] ?? '')),
                    'chat_type' => $event_chat_type,
                    'duplicate' => !empty($result['duplicate']),
                    'updated' => !empty($result['updated']),
                    'local_chat_id' => (int)($result['chat_id'] ?? 0),
                    'local_message_id' => (int)($result['message_id'] ?? 0),
                    'attachment_count' => count((array)($event['attachments'] ?? [])),
                ]);
                if (!$is_self_message && !$needs_history_reconciliation && !$context_reconciliation_only) {
                    $read_candidate = $message;
                }
            }
            if ($read_candidate instanceof PHPMaxMessage) {
                 
                 
                 
                $this->markBacklogMessageRead($client, $chat_id, $read_candidate);
            }
            if ($deferred) {
                break;
            }
        }

        if ($deferred) {
            $this->logger->info('max_polling_backlog_budget_exhausted', [
                'channel_id' => (int)$channel['channel_id'],
                'processed' => $processed,
                'skipped_by_scope' => $skipped_by_scope,
                'elapsed_ms' => $run_budget->elapsedMilliseconds(),
                'phase' => 'message_import',
            ]);
        }

        return ['processed' => $processed, 'skipped_by_scope' => $skipped_by_scope, 'deferred' => $deferred];
    }

    








    private function appendMessageContextRepairChats(
        Client $client,
        array $chats,
        int $channel_id,
        MessageContext $model_message_context,
        ?MaxPollingRunBudget $run_budget = null
    ): array {
        $known_chat_ids = [];
        $recovered_chats = [];
        foreach ($chats as $chat) {
            if ($chat instanceof PHPMaxChat && $chat->id !== null) {
                $known_chat_ids[(string)$chat->id] = true;
            }
        }

        $candidates = $model_message_context->listRecentChatsWithoutContext(
            MaxProvider::PROVIDER,
            $channel_id,
            'max_message',
            self::BACKLOG_HISTORY_WINDOW,
            self::MESSAGE_CONTEXT_RECONCILIATION_LOOKUP_CANDIDATES
        );
        $added = 0;
        foreach ($candidates as $candidate) {
            if ($run_budget !== null && !$run_budget->canProcessBacklog()) {
                break;
            }
            if ($added >= self::MESSAGE_CONTEXT_RECONCILIATION_CHATS_PER_RUN) {
                break;
            }
            $external_chat_id = trim((string)($candidate['external_chat_id'] ?? ''));
            if ($external_chat_id === ''
                || !preg_match('/^-?\d+$/', $external_chat_id)
                || isset($known_chat_ids[$external_chat_id])) {
                continue;
            }

            try {
                $chat = $client->getChat((int)$external_chat_id);
                if (!$chat instanceof PHPMaxChat || $chat->id === null) {
                    continue;
                }
                $resolved_chat_id = (string)$chat->id;
                if (isset($known_chat_ids[$resolved_chat_id])) {
                    continue;
                }
                $recovered_chats[] = $chat;
                $known_chat_ids[$resolved_chat_id] = true;
                $added++;
                $this->logger->info('max_polling_message_context_chat_recovered', [
                    'channel_id' => $channel_id,
                    'chat_hash' => MessengerLogger::hashExternalId($resolved_chat_id),
                    'local_chat_id' => (int)($candidate['chat_id'] ?? 0),
                ]);
            } catch (\Throwable $e) {
                $this->logger->warning('max_polling_message_context_chat_recovery_failed', [
                    'channel_id' => $channel_id,
                    'chat_hash' => MessengerLogger::hashExternalId($external_chat_id),
                    'local_chat_id' => (int)($candidate['chat_id'] ?? 0),
                    'error' => substr(MaxRuntime::safeError($e), 0, 180),
                ]);
            }
        }

         
         
         
        return array_merge($recovered_chats, $chats);
    }

    








    private function appendMissingContextMessages(
        Client $client,
        int $external_chat_id,
        int $local_chat_id,
        int $channel_id,
        string $chat_type,
        string $chat_display_name,
        array $history,
        MessageContext $model_message_context,
        ?MaxPollingRunBudget $run_budget = null
    ): array {
        $missing_rows = $model_message_context->getRecentMessagesWithoutContext(
            $local_chat_id,
            'max_message',
            self::BACKLOG_HISTORY_WINDOW
        );
        if ($missing_rows === []) {
            return $history;
        }

        $known_ids = [];
        foreach ($history as $message) {
            if ($message instanceof PHPMaxMessage && isset($message->id) && (string)$message->id !== '') {
                $known_ids[(string)$message->id] = true;
            }
        }
        $requested_ids = [];
        $missing_rows_by_external_id = [];
        foreach ($missing_rows as $row) {
            $message_id = trim((string)($row['external_message_id'] ?? ''));
            if ($message_id === '' || !preg_match('/^\d+$/', $message_id) || isset($known_ids[$message_id])) {
                continue;
            }
            $requested_ids[$message_id] = (int)$message_id;
            $missing_rows_by_external_id[$message_id] = $row;
        }
        if ($requested_ids === []) {
            return $history;
        }
        if ($run_budget !== null && !$run_budget->canProcessBacklog()) {
            return $history;
        }

        try {
            $resolved = $client->getMessages($external_chat_id, array_values($requested_ids));
        } catch (\Throwable $e) {
            $this->logger->warning('max_polling_message_context_messages_recovery_failed', [
                'chat_hash' => MessengerLogger::hashExternalId((string)$external_chat_id),
                'local_chat_id' => $local_chat_id,
                'requested_count' => count($requested_ids),
                'error' => substr(MaxRuntime::safeError($e), 0, 180),
            ]);
            return $history;
        }

        $resolved_messages = [];
        foreach (is_array($resolved) ? $resolved : [] as $message) {
            if (!$message instanceof PHPMaxMessage || !isset($message->id) || (string)$message->id === '') {
                continue;
            }
            $resolved_id = (string)$message->id;
            if (!isset($requested_ids[$resolved_id])) {
                 
                 
                continue;
            }
            $resolved_messages[$resolved_id] = $message;
        }

        $empty_response_ids = [];
        $single_retry_errors = 0;
        $single_retries = 0;
        foreach ($requested_ids as $requested_id => $requested_id_int) {
            if ($run_budget !== null && !$run_budget->canProcessBacklog()) {
                break;
            }
            $requested_id = (string)$requested_id;
            if (isset($resolved_messages[$requested_id])) {
                continue;
            }
            if ($single_retries >= self::MESSAGE_CONTEXT_SINGLE_RETRY_LIMIT) {
                break;
            }
            $single_retries++;
            try {
                $single_response = $client->getMessages($external_chat_id, [$requested_id_int]);
            } catch (\Throwable $e) {
                 
                 
                $single_retry_errors++;
                continue;
            }

            $matched = null;
            foreach (is_array($single_response) ? $single_response : [] as $message) {
                if ($message instanceof PHPMaxMessage
                    && isset($message->id)
                    && (string)$message->id === $requested_id
                ) {
                    $matched = $message;
                    break;
                }
            }
            if ($matched instanceof PHPMaxMessage) {
                $resolved_messages[$requested_id] = $matched;
                continue;
            }
            $empty_response_ids[] = $requested_id;
        }

        $chat_type = $this->normalizeMaxChatType($chat_type);
         
         
         
        $chat_display_name = trim($chat_display_name);
        $fallback_contexts = 0;
        if (in_array($chat_type, ['CHAT', 'CHANNEL'], true) && $chat_display_name !== '') {
            foreach ($empty_response_ids as $unavailable_id) {
                $row = $missing_rows_by_external_id[$unavailable_id] ?? [];
                $local_message_id = (int)($row['message_id'] ?? 0);
                if ($local_message_id <= 0) {
                    continue;
                }
                 
                 
                 
                $stored = $model_message_context->upsertMaxMessageContext([
                    'channel_id' => $channel_id,
                    'chat_id' => $local_chat_id,
                    'message_id' => $local_message_id,
                    'external_chat_id' => (string)$external_chat_id,
                    'external_message_id' => $unavailable_id,
                    'chat_type' => $chat_type,
                    'chat_display_name' => $chat_display_name,
                    'sender_resolution_status' => 'provider_unavailable',
                ]);
                if ($stored) {
                    $fallback_contexts++;
                }
            }
        }

        $merged = [];
        foreach (array_merge($history, array_values($resolved_messages)) as $message) {
            if (!$message instanceof PHPMaxMessage || !isset($message->id) || (string)$message->id === '') {
                continue;
            }
            $merged[(string)$message->id] = $message;
        }
        $merged = array_values($merged);
        usort($merged, static function (PHPMaxMessage $left, PHPMaxMessage $right): int {
            $time_order = ((int)($left->time ?? 0)) <=> ((int)($right->time ?? 0));
            return $time_order !== 0
                ? $time_order
                : strcmp((string)($left->id ?? ''), (string)($right->id ?? ''));
        });
        $this->logger->info('max_polling_message_context_messages_recovered', [
            'chat_hash' => MessengerLogger::hashExternalId((string)$external_chat_id),
            'local_chat_id' => $local_chat_id,
            'requested_count' => count($requested_ids),
            'resolved_count' => count($resolved_messages),
            'single_retry_count' => $single_retries,
            'single_retry_error_count' => $single_retry_errors,
            'provider_unavailable_count' => $fallback_contexts,
        ]);

        return $merged;
    }

    







    private function fetchBacklogHistoryWindow(Client $client, int $chat_id, int $limit, ?MaxPollingRunBudget $run_budget = null): array {
        $limit = max(1, min(self::BACKLOG_HISTORY_WINDOW, $limit));
        $messages = [];
        $from_time = null;

        for ($page = 0; $page < self::BACKLOG_HISTORY_MAX_PAGES && count($messages) < $limit; $page++) {
            if ($run_budget !== null && !$run_budget->canProcessBacklog()) {
                break;
            }
            $remaining = $limit - count($messages);
            $request_limit = $page === 0
                ? $remaining
                : min(self::BACKLOG_HISTORY_PAGE_SIZE, $remaining);
            $history = $client->fetchHistory(
                $chat_id,
                0,
                $request_limit,
                0,
                0,
                $from_time,
                ItemType::REGULAR,
                false,
                true,
                false
            );
            $page_messages = $this->normalizeBacklogHistory($history, $request_limit);
            if ($page_messages === []) {
                break;
            }

            $oldest_time = null;
            $added = 0;
            foreach ($page_messages as $message) {
                $message_id = (string)($message->id ?? '');
                if ($message_id === '') {
                    continue;
                }
                if (!isset($messages[$message_id])) {
                    $messages[$message_id] = $message;
                    $added++;
                }
                $message_time = (int)($message->time ?? 0);
                if ($message_time > 0 && ($oldest_time === null || $message_time < $oldest_time)) {
                    $oldest_time = $message_time;
                }
            }

            if ($added === 0 || count($messages) >= $limit || $oldest_time === null) {
                break;
            }

             
             
            if (count($page_messages) < min(self::BACKLOG_HISTORY_PAGE_SIZE, $request_limit)) {
                break;
            }
            $from_time = max(1, $oldest_time - 1);
        }

        return $this->normalizeBacklogHistory(array_values($messages), $limit);
    }

    



    private function normalizeBacklogHistory($history, int $limit): array {
        if (!is_array($history) || $limit <= 0) {
            return [];
        }

        $messages = [];
        foreach ($history as $message) {
            if (!$message instanceof PHPMaxMessage || !isset($message->id) || (string)$message->id === '') {
                continue;
            }
            $messages[(string)$message->id] = $message;
        }

        usort($messages, static function (PHPMaxMessage $left, PHPMaxMessage $right): int {
            $time_order = ((int)($left->time ?? 0)) <=> ((int)($right->time ?? 0));
            if ($time_order !== 0) {
                return $time_order;
            }
            return strcmp((string)($left->id ?? ''), (string)($right->id ?? ''));
        });

        if (count($messages) > $limit) {
            $messages = array_slice($messages, -$limit);
        }

        return array_values($messages);
    }

    private function isRetryableAttachmentPlaceholder(array $message): bool {
        $text = urldecode((string)($message['text'] ?? ''));
        $classification = (new ServiceNoticeClassifier())->classify(
            MaxProvider::PROVIDER,
            $text,
            ['author' => (int)($message['author'] ?? 1)]
        );
        return (string)($classification['reason'] ?? '') === 'attachment_download_placeholder';
    }

    private function freshBacklogChats(Client $client, array $channel): ?array {
        $cached_chats = $client->chats();
        try {
             
             
             
             
             
            $fresh_chats = $client->fetchChats();
            $this->logger->info('max_polling_backlog_chats_refreshed', [
                'channel_id' => (int)$channel['channel_id'],
                'cached_chat_count' => is_array($cached_chats) ? count($cached_chats) : 0,
                'cached_unread_chat_count' => $this->countUnreadChats($cached_chats),
                'fresh_chat_count' => count($fresh_chats),
                'fresh_unread_chat_count' => $this->countUnreadChats($fresh_chats),
            ]);
            return $fresh_chats !== [] ? $fresh_chats : $cached_chats;
        } catch (\Throwable $e) {
            $this->logger->warning('max_polling_backlog_chats_refresh_failed', [
                'channel_id' => (int)$channel['channel_id'],
                'cached_chat_count' => is_array($cached_chats) ? count($cached_chats) : 0,
                'cached_unread_chat_count' => $this->countUnreadChats($cached_chats),
                'error' => substr(MaxRuntime::safeError($e), 0, 180),
            ]);
            return $cached_chats;
        }
    }

    private function countUnreadChats($chats): int {
        if (!is_array($chats) || $chats === []) {
            return 0;
        }

        $count = 0;
        foreach ($chats as $chat) {
            if ($chat instanceof PHPMaxChat && max(0, (int)($chat->newMessages ?? 0)) > 0) {
                $count++;
            }
        }

        return $count;
    }

    private function markBacklogMessageRead(Client $client, int $chat_id, PHPMaxMessage $message): void {
        if (!isset($message->id) || (string)$message->id === '') {
            return;
        }

        try {
            $state = $client->readMessage($message->id, $chat_id);
            $this->logger->info('max_polling_backlog_message_marked_read', [
                'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                'message_hash' => MessengerLogger::hashExternalId((string)$message->id),
                'unread' => isset($state->unread) ? (int)$state->unread : null,
                'mark' => isset($state->mark) ? (int)$state->mark : null,
            ]);
        } catch (\Throwable $e) {
            $this->logger->warning('max_polling_backlog_mark_read_failed', [
                'chat_hash' => MessengerLogger::hashExternalId((string)$chat_id),
                'message_hash' => MessengerLogger::hashExternalId((string)$message->id),
                'error' => substr($e->getMessage(), 0, 180),
            ]);
        }
    }

    private function isIgnoredBacklogImport(array $result): bool {
        if (empty($result['ignored'])) {
            return false;
        }
        if (!empty($result['duplicate']) || !empty($result['updated'])) {
            return false;
        }
        return (int)($result['message_id'] ?? 0) <= 0;
    }

    private function providerTimestamp(int $value): int {
        if ($value <= 0) {
            return 0;
        }
        $clock = new AppClock($this->registry);
        $sql = $clock->fromProviderTimestampSql($value, '');
        $timestamp = $clock->sqlToTimestamp($sql);
        return $timestamp === false ? 0 : (int)$timestamp;
    }

    private function sqlTimestamp(string $value): int {
        $value = trim($value);
        if ($value === '') {
            return 0;
        }
        $timestamp = (new AppClock($this->registry))->sqlToTimestamp($value);
        return $timestamp === false ? 0 : (int)$timestamp;
    }

    private function chatType(PHPMaxChat $chat): string {
        $last_sender_id = $chat->lastMessage instanceof PHPMaxMessage
            ? (string)($chat->lastMessage->sender ?? '')
            : '';
        return MaxConversationIdentity::resolveTransportChatType(
            [(string)($chat->type ?? '')],
            (string)($chat->id ?? ''),
            $last_sender_id
        );
    }

    private function eventChatType(array $event): string {
        $metadata = is_array($event['metadata'] ?? null) ? $event['metadata'] : [];
        return $this->normalizeMaxChatType((string)($metadata['chat_type'] ?? ''));
    }

    private function isSelfMessage(PHPMaxMessage $message, string $account_id): bool {
        return $account_id !== '' && isset($message->sender) && hash_equals($account_id, (string)$message->sender);
    }

    private function eventFromMessage(Client $client, PHPMaxMessage $message, string $account_id, array $context = []): array {
        $event = MaxEventNormalizer::fromMessage($message, $account_id, $context);
        $sender_id = isset($message->sender) ? (string)$message->sender : '';
        if ($sender_id === '') {
            $sender_id = (string)($context['sender_id'] ?? '');
        }
        $chat_id = isset($message->chatId) ? (string)$message->chatId : '';
        if ($chat_id === '') {
            $chat_id = (string)($context['chat_id'] ?? '');
        }

        $chat_profile = $this->chatProfile($client, $chat_id);
         
         
         
        $chat_type = MaxConversationIdentity::resolveTransportChatType([
            $context['chat_type'] ?? '',
            $event['metadata']['chat_type'] ?? '',
            $chat_profile instanceof PHPMaxChat ? (string)($chat_profile->type ?? '') : '',
        ], $chat_id, $sender_id);
        $chat_display_name = $chat_profile instanceof PHPMaxChat
            ? $this->displayNameFromChat($chat_profile)
            : $this->displayNameForChat($client, $chat_id);
        $sender_display_name = $this->displayNameForSender($client, $sender_id);
        $is_shared_conversation = in_array($chat_type, ['CHAT', 'CHANNEL'], true);
        $is_outgoing = (string)($event['direction'] ?? '') === 'out';
        $counterparty_id = '';
        if ($chat_type === 'DIALOG') {
            $counterparty_id = MaxConversationIdentity::firstCounterpartyUserId([
                $context['counterparty_sender_id'] ?? '',
                !$is_outgoing ? $sender_id : '',
                $this->counterpartyUserIdFromChat($chat_profile, $account_id),
            ], $account_id, [$chat_id]);
        }
        $counterparty_display_name = $counterparty_id !== ''
            ? $this->displayNameForSender($client, $counterparty_id)
            : '';
        $display_name_source = '';
        if ($is_shared_conversation && $chat_display_name !== '') {
             
             
            $event['display_name'] = $chat_display_name;
            $event['metadata']['display_name_authority'] = 'chat';
            $display_name_source = 'chat';
        } elseif ($counterparty_display_name !== '') {
             
             
            $event['display_name'] = $counterparty_display_name;
            $event['metadata']['display_name_authority'] = 'user';
            $display_name_source = 'sender';
        }

        $event['metadata']['chat_type'] = $chat_type;
        $event['metadata']['chat_display_name'] = $chat_display_name;
        $event['metadata']['sender_display_name'] = $sender_display_name;
        $event['metadata']['account_id'] = $account_id;
        if ($counterparty_id !== '') {
            $event['metadata']['counterparty_sender_id'] = $counterparty_id;
        }
        $event['metadata'] = MaxConversationIdentity::enrichMetadata(
            $event['metadata'],
            $chat_id,
            (string)($event['external_user_id'] ?? ''),
            $account_id
        );

        $avatar_owner_id = $is_shared_conversation ? $sender_id : $counterparty_id;
        if ($avatar_owner_id !== '') {
            $avatar_lookup = $this->avatarUrlForMessage($client, $avatar_owner_id, $chat_id, $display_name_source);
            if ($avatar_lookup['url'] !== '') {
                $event['avatar_url'] = $avatar_lookup['url'];
                $event['metadata']['avatar_source'] = $avatar_lookup['source'];
            }
            $avatar_chat_type = MaxConversationIdentity::normalizeChatType((string)($avatar_lookup['chat_type'] ?? ''));
            if (($event['metadata']['chat_type'] ?? 'unknown') === 'unknown' && $avatar_chat_type !== 'unknown') {
                $event['metadata']['chat_type'] = $avatar_chat_type;
                $event['metadata'] = MaxConversationIdentity::enrichMetadata(
                    $event['metadata'],
                    $chat_id,
                    (string)($event['external_user_id'] ?? ''),
                    $account_id
                );
            }
        }
        $forward_reference = MaxEventNormalizer::forwardReference($message);
        $attachment_items = array_values((array)($message->attaches ?? []));
        $embedded_forward_message = MaxEventNormalizer::forwardedMessage($message);
        if (!$attachment_items && $embedded_forward_message instanceof PHPMaxMessage) {
            $attachment_items = array_values((array)($embedded_forward_message->attaches ?? []));
            if ($attachment_items) {
                $this->logger->info('max_polling_forward_embedded_resolved', [
                    'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                    'message_hash' => MessengerLogger::hashExternalId((string)($message->id ?? '')),
                    'attachment_count' => count($attachment_items),
                ]);
            }
        }
        if (!$attachment_items && $forward_reference) {
            try {
                $source_message = $client->getMessage(
                    (int)$forward_reference['chat_id'],
                    (int)$forward_reference['message_id']
                );
                if ($source_message instanceof PHPMaxMessage) {
                    $attachment_items = array_values((array)($source_message->attaches ?? []));
                    $this->logger->info('max_polling_forward_source_resolved', [
                        'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                        'message_hash' => MessengerLogger::hashExternalId((string)($message->id ?? '')),
                        'source_chat_hash' => MessengerLogger::hashExternalId((string)$forward_reference['chat_id']),
                        'source_message_hash' => MessengerLogger::hashExternalId((string)$forward_reference['message_id']),
                        'attachment_count' => count($attachment_items),
                    ]);
                }
            } catch (\Throwable $e) {
                $this->logger->warning('max_polling_forward_source_failed', [
                    'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                    'message_hash' => MessengerLogger::hashExternalId((string)($message->id ?? '')),
                    'source_chat_hash' => MessengerLogger::hashExternalId((string)$forward_reference['chat_id']),
                    'source_message_hash' => MessengerLogger::hashExternalId((string)$forward_reference['message_id']),
                    'exception_class' => get_class($e),
                ]);
            }
        }

        $control_system_event = MaxEventNormalizer::controlSystemEvent($message);
        if ($control_system_event) {
             
             
             
            $attachment_items = array_values(array_filter(
                $attachment_items,
                static function ($attach): bool {
                    return !MaxEventNormalizer::isControlAttachment($attach);
                }
            ));

            if (trim((string)($event['text'] ?? '')) === '' && !$attachment_items) {
                $system_event_names = $this->displayNamesForSystemEvent(
                    $client,
                    (array)($control_system_event['user_ids'] ?? []),
                    $sender_id,
                    $sender_display_name
                );
                $event['text'] = $this->systemEventText(
                    (string)($control_system_event['type'] ?? ''),
                    $system_event_names,
                    (int)($control_system_event['user_count'] ?? 0)
                );
                $event['direction'] = 'in';
                $event['metadata']['system_event'] = 1;
                $event['metadata']['system_event_type'] = (string)($control_system_event['type'] ?? '');
                $event['metadata']['system_event_actor_names'] = $system_event_names;
                $event['metadata']['system_event_user_count'] = (int)($control_system_event['user_count'] ?? 0);
            }
        }

        $attachment_types = [];
        foreach ($attachment_items as $attachment_item) {
            $attachment_type = strtolower(trim((string)($attachment_item->type ?? '')));
            if ($attachment_type !== '') {
                $attachment_types[$attachment_type] = true;
            }
        }

        $this->logger->info('max_polling_message_shape', [
            'chat_hash' => MessengerLogger::hashExternalId($chat_id),
            'message_hash' => MessengerLogger::hashExternalId((string)($message->id ?? '')),
            'sender_hash' => MessengerLogger::hashExternalId($sender_id),
            'direction' => (string)($event['direction'] ?? ''),
            'display_name_present' => trim((string)($event['display_name'] ?? '')) !== '' ? 1 : 0,
            'display_name_source' => $display_name_source,
            'avatar_url_present' => trim((string)($event['avatar_url'] ?? '')) !== '' ? 1 : 0,
            'avatar_source' => (string)($event['metadata']['avatar_source'] ?? ''),
            'attachment_count' => count($attachment_items),
            'attachment_types' => implode(',', array_keys($attachment_types)),
            'attachment_fields' => $this->safeAttachmentFieldSummary($attachment_items),
            'forward_reference_present' => $forward_reference ? 1 : 0,
            'message_fields' => $this->safeObjectFieldSummary($message),
            'message_extra_fields' => $this->safeMessageExtraFieldSummary($message),
            'message_link_fields' => $this->safeMessageLinkFieldSummary($message),
            'message_link_message_fields' => $this->safeMessageLinkMessageFieldSummary($message),
            'metadata_fields' => $this->safeArrayFieldSummary((array)($event['metadata'] ?? [])),
            'system_event_type' => (string)($event['metadata']['system_event_type'] ?? ''),
        ]);
        $attachments = [];
        foreach ($attachment_items as $attach) {
            $type = strtolower((string)($attach->type ?? ''));
            if ($type === 'audio' || $type === 'voice') {
                $audio_url = trim((string)($attach->url ?? $attach->baseUrl ?? ''));
                $audio_id = (string)($attach->audioId ?? $attach->id ?? '');
                $attachments[] = [
                    'type' => 'audio',
                    'url' => $audio_url,
                    'file_name' => trim((string)($attach->name ?? $attach->filename ?? '')) ?: 'max-voice.m4a',
                    'mime_type' => (string)($attach->mimeType ?? $attach->mime ?? ''),
                    'duration' => MaxEventNormalizer::audioDurationSeconds((int)($attach->duration ?? 0)),
                    'file_size' => max(0, (int)($attach->size ?? 0)),
                    'waveform' => (string)($attach->wave ?? ''),
                    'payload_hash' => hash('sha256', 'audio:' . $audio_id . ':' . $audio_url),
                    'unsupported' => $audio_url === '',
                ];
                continue;
            }
            if (($type === 'photo' || $type === 'image') && !empty($attach->baseUrl)) {
                $attachments[] = [
                    'type' => 'image',
                    'url' => (string)$attach->baseUrl,
                    'payload_hash' => substr(hash('sha256', (string)$attach->baseUrl), 0, 16),
                ];
                continue;
            }
            if (($type === 'file' || !empty($attach->fileId)) && !empty($attach->fileId)) {
                $file_lookup = $this->resolveMaxFile(
                    $client,
                    $message,
                    $attach,
                    $chat_id,
                    $forward_reference
                );
                $file = $file_lookup['file'] ?? null;
                if (!$file) {
                    $this->logger->warning('max_polling_file_lookup_failed', [
                        'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                        'message_hash' => MessengerLogger::hashExternalId((string)($message->id ?? '')),
                        'file_id_present' => 1,
                        'forward_reference_present' => $forward_reference ? 1 : 0,
                        'attempt_count' => (int)($file_lookup['attempt_count'] ?? 0),
                        'exception_class' => (string)($file_lookup['exception_class'] ?? ''),
                    ]);
                    $attachments[] = ['type' => 'document', 'unsupported' => true];
                    continue;
                }
                $unsafe = $file && !empty($file->unsafe);
                $url = $file && !empty($file->url) && !$unsafe ? (string)$file->url : '';
                $file_name = trim((string)($attach->name ?? ''));
                $extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
                $this->logger->info('max_polling_file_attachment_shape', [
                    'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                    'message_hash' => MessengerLogger::hashExternalId((string)($message->id ?? '')),
                    'name_present' => $file_name !== '' ? 1 : 0,
                    'extension' => AttachmentPolicy::isDocumentExtension($extension) ? $extension : '',
                    'download_url_present' => $url !== '' ? 1 : 0,
                    'unsafe' => $unsafe ? 1 : 0,
                    'size' => max(0, (int)($attach->size ?? 0)),
                    'lookup_source' => (string)($file_lookup['source'] ?? ''),
                ]);
                $attachments[] = [
                    'type' => 'document',
                    'url' => $url,
                    'file_name' => $file_name !== '' ? $file_name : 'max-file',
                    'payload_hash' => substr(hash('sha256', (string)$attach->fileId . ':' . $url), 0, 16),
                    'unsupported' => $unsafe,
                ];
                continue;
            }
            $attachments[] = ['type' => $type !== '' ? $type : 'unknown', 'unsupported' => true];
        }
        $event['attachments'] = $attachments;
        return $event;
    }

    








    private function resolveMaxFile(Client $client, PHPMaxMessage $message, $attach, string $chat_id, array $forward_reference): array {
        $coordinates = [];
        if ($forward_reference) {
            $coordinates[] = [
                'chat_id' => (int)$forward_reference['chat_id'],
                'message_id' => (int)$forward_reference['message_id'],
                'source' => 'forward',
            ];
        }

        $current_chat_id = preg_match('/^-?\d+$/', $chat_id) ? (int)$chat_id : 0;
        $current_message_id = isset($message->id) && preg_match('/^\d+$/', (string)$message->id)
            ? (int)$message->id
            : 0;
        if ($current_chat_id !== 0 && $current_message_id > 0) {
            $coordinates[] = [
                'chat_id' => $current_chat_id,
                'message_id' => $current_message_id,
                'source' => 'message',
            ];
        }

        $seen = [];
        $attempt_count = 0;
        $exception_class = '';
        foreach ($coordinates as $coordinate) {
            $key = (string)$coordinate['chat_id'] . ':' . (string)$coordinate['message_id'];
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $attempt_count++;
            try {
                $file = $client->getFileById(
                    (int)$coordinate['chat_id'],
                    (int)$coordinate['message_id'],
                    (int)$attach->fileId
                );
                if ($file) {
                    if ((string)$coordinate['source'] === 'forward') {
                        $this->logger->info('max_polling_forward_file_resolved', [
                            'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                            'message_hash' => MessengerLogger::hashExternalId((string)($message->id ?? '')),
                            'source_chat_hash' => MessengerLogger::hashExternalId((string)$coordinate['chat_id']),
                            'source_message_hash' => MessengerLogger::hashExternalId((string)$coordinate['message_id']),
                            'attempt_count' => $attempt_count,
                        ]);
                    }
                    return [
                        'file' => $file,
                        'source' => (string)$coordinate['source'],
                        'attempt_count' => $attempt_count,
                        'exception_class' => '',
                    ];
                }
            } catch (\Throwable $e) {
                $exception_class = get_class($e);
            }
        }

        return [
            'file' => null,
            'source' => '',
            'attempt_count' => $attempt_count,
            'exception_class' => $exception_class,
        ];
    }

    private function displayNameForSender(Client $client, string $sender_id): string {
        $sender_id = trim($sender_id);
        if ($sender_id === '' || !preg_match('/^\d+$/', $sender_id)) {
            return '';
        }
        if (array_key_exists($sender_id, $this->user_name_cache)) {
            return $this->user_name_cache[$sender_id];
        }
        $name = '';
        $user = $this->userProfile($client, $sender_id);
        if ($user instanceof PHPMaxUser) {
            $name = $this->displayNameFromUser($user);
        }

        $this->user_name_cache[$sender_id] = $name;
        return $name;
    }

    






    private function displayNamesForSystemEvent(
        Client $client,
        array $user_ids,
        string $sender_id,
        string $sender_display_name
    ): array {
        $ids = [];
        foreach (array_slice($user_ids, 0, 10) as $user_id) {
            $user_id = trim((string)$user_id);
            if ($user_id !== '' && preg_match('/^\d+$/', $user_id)) {
                $ids[$user_id] = (int)$user_id;
            }
        }

        $names = [];
        if ($ids) {
            try {
                foreach ($client->getUsers(array_values($ids)) as $user) {
                    if (!$user instanceof PHPMaxUser) {
                        continue;
                    }
                    $name = $this->displayNameFromUser($user);
                    if ($name === '') {
                        continue;
                    }
                    $names[$name] = $name;
                    $user_id = trim((string)($user->id ?? ''));
                    if ($user_id !== '') {
                        $this->user_profile_cache[$user_id] = $user;
                        $this->user_name_cache[$user_id] = $name;
                    }
                }
            } catch (\Throwable $e) {
                $this->logger->warning('max_polling_system_event_users_lookup_failed', [
                    'user_count' => count($ids),
                    'exception_class' => get_class($e),
                ]);
            }
        }

        if (
            !$names
            && count($ids) <= 1
            && $sender_display_name !== ''
            && ($sender_id === '' || !$ids || isset($ids[$sender_id]))
        ) {
            $names[$sender_display_name] = $sender_display_name;
        }

        return array_values(array_slice($names, 0, 4, true));
    }

    





    private function systemEventText(string $event_type, array $names, int $user_count): string {
        $subject = $this->systemEventSubject($names, $user_count);

        if ($event_type === MaxEventNormalizer::SYSTEM_EVENT_MEMBER_LEFT) {
            return $subject . ' больше не в чате';
        }
        if ($event_type === MaxEventNormalizer::SYSTEM_EVENT_GROUP_CREATED) {
            return 'Группа MAX создана';
        }
        if ($event_type === MaxEventNormalizer::SYSTEM_EVENT_GROUP_UPDATED) {
            return 'В группе MAX произошло служебное обновление';
        }

        return $subject . ' теперь в чате';
    }

    


    private function systemEventSubject(array $names, int $user_count): string {
        $names = array_values(array_filter(array_map([$this, 'cleanDisplayName'], $names)));
        if ($names && $user_count > count($names)) {
            $visible = array_slice($names, 0, 3);
            return implode(', ', $visible) . ' и ещё ' . ($user_count - count($visible));
        }
        if (count($names) === 1) {
            return $names[0];
        }
        if (count($names) === 2) {
            return $names[0] . ' и ' . $names[1];
        }
        if (count($names) > 2) {
            $last = array_pop($names);
            return implode(', ', $names) . ' и ' . $last;
        }
        if ($user_count > 1) {
            return $user_count . ' ' . $this->participantWord($user_count);
        }

        return 'Новый участник';
    }

    private function participantWord(int $count): string {
        $count = abs($count) % 100;
        $last = $count % 10;
        if ($count > 10 && $count < 20) {
            return 'участников';
        }
        if ($last === 1) {
            return 'участник';
        }
        if ($last >= 2 && $last <= 4) {
            return 'участника';
        }
        return 'участников';
    }

    private function normalizeMaxChatType(string $chat_type): string {
        return MaxConversationIdentity::normalizeChatType($chat_type);
    }

    private function counterpartyUserIdFromChat(?PHPMaxChat $chat, string $account_id): string {
        $account_id = MaxConversationIdentity::normalizeUserId($account_id);
        if (!$chat instanceof PHPMaxChat || !$chat->participants || $account_id === '') {
            return '';
        }

        $candidates = [];
        foreach ((array)$chat->participants as $key => $participant) {
            if (is_scalar($participant)) {
                $candidates[] = (string)$participant;
            } elseif (is_object($participant)) {
                foreach (['userId', 'user_id', 'id'] as $field) {
                    if (isset($participant->{$field})) {
                        $candidates[] = (string)$participant->{$field};
                    }
                }
                if (isset($participant->contact) && is_object($participant->contact)) {
                    foreach (['userId', 'user_id', 'id'] as $field) {
                        if (isset($participant->contact->{$field})) {
                            $candidates[] = (string)$participant->contact->{$field};
                        }
                    }
                }
            } elseif (is_array($participant)) {
                $candidates[] = (string)($participant['userId'] ?? $participant['user_id'] ?? $participant['id'] ?? '');
                $contact = $participant['contact'] ?? [];
                if (is_array($contact)) {
                    $candidates[] = (string)($contact['userId'] ?? $contact['user_id'] ?? $contact['id'] ?? '');
                }
            }

             
             
            if ((is_int($key) || (is_string($key) && preg_match('/^\d+$/', $key))) && (int)$key > 1) {
                $candidates[] = $key;
            }
        }
        $candidates[] = $chat->owner ?? '';

        return MaxConversationIdentity::firstCounterpartyUserId(
            $candidates,
            $account_id,
            [(string)($chat->id ?? '')]
        );
    }

    private function displayNameForChat(Client $client, string $chat_id): string {
        $chat_id = trim($chat_id);
        if ($chat_id === '' || !preg_match('/^-?\d+$/', $chat_id)) {
            return '';
        }
        if (array_key_exists($chat_id, $this->chat_name_cache)) {
            return $this->chat_name_cache[$chat_id];
        }
        $name = '';
        $chat = $this->chatProfile($client, $chat_id);
        if ($chat instanceof PHPMaxChat) {
            $name = $this->displayNameFromChat($chat);
        }

        $this->chat_name_cache[$chat_id] = $name;
        return $name;
    }

    private function userProfile(Client $client, string $sender_id): ?PHPMaxUser {
        $sender_id = trim($sender_id);
        if ($sender_id === '' || !preg_match('/^\d+$/', $sender_id)) {
            return null;
        }
        if (array_key_exists($sender_id, $this->user_profile_cache)) {
            return $this->user_profile_cache[$sender_id] instanceof PHPMaxUser ? $this->user_profile_cache[$sender_id] : null;
        }

        $user = null;
        try {
            $lookup = $client->getUser((int)$sender_id);
            if ($lookup instanceof PHPMaxUser) {
                $user = $lookup;
                $name = $this->displayNameFromUser($user);
                $avatar_url = $this->avatarUrlFromUser($user);
                $this->logger->info('max_polling_user_profile_received', [
                    'user_hash' => MessengerLogger::hashExternalId($sender_id),
                    'display_name_present' => $name !== '' ? 1 : 0,
                    'display_name_hash' => $name !== '' ? substr(hash('sha256', $name), 0, 12) : '',
                    'display_name_length' => $name !== '' ? mb_strlen($name, 'UTF-8') : 0,
                    'avatar_url_present' => $avatar_url !== '' ? 1 : 0,
                    'avatar_field_source' => $this->avatarSourceFromUser($user),
                    'names_count' => count((array)$user->names),
                    'user_fields' => $this->safeObjectFieldSummary($user),
                    'first_name_fields' => $this->safeObjectFieldSummary(((array)$user->names)[0] ?? null),
                ]);
            }
        } catch (\Throwable $e) {
            $this->logger->warning('max_polling_user_profile_lookup_failed', [
                'user_hash' => MessengerLogger::hashExternalId($sender_id),
                'error' => substr(MaxRuntime::safeError($e), 0, 180),
            ]);
        }

        $this->user_profile_cache[$sender_id] = $user;
        return $user;
    }

    



    private function recordProviderPhoneEvidence(array $event, array $result): void {
        $chat_id = (int)($result['chat_id'] ?? 0);
        $metadata = is_array($event['metadata'] ?? null) ? $event['metadata'] : [];
        $chat_type = $this->normalizeMaxChatType((string)($metadata['chat_type'] ?? ''));
        $counterparty_id = trim((string)($metadata['counterparty_sender_id'] ?? ''));
        if ($chat_id <= 0 || $chat_type !== 'DIALOG' || $counterparty_id === '') {
            return;
        }
        $profile = $this->user_profile_cache[$counterparty_id] ?? null;
        if (!$profile instanceof PHPMaxUser) {
            return;
        }
        $phone = $this->phoneFromMaxProfile($profile->phone ?? null);
        if ($phone === '') {
            return;
        }
        try {
            $saved = (new CustomerContactService($this->registry))->recordProviderConfirmedPhoneForChat(
                $chat_id,
                $phone,
                'max_user_profile'
            );
            $this->logger->info('max_polling_phone_evidence_processed', [
                'chat_id' => $chat_id,
                'saved' => !empty($saved['success']) ? 1 : 0,
                'counterparty_hash' => MessengerLogger::hashExternalId($counterparty_id),
            ]);
        } catch (\Throwable $e) {
             
             
            $this->logger->warning('max_polling_phone_evidence_failed', [
                'chat_id' => $chat_id,
                'counterparty_hash' => MessengerLogger::hashExternalId($counterparty_id),
                'exception_class' => get_class($e),
            ]);
        }
    }

    private function phoneFromMaxProfile($value, int $depth = 0): string {
        if ($depth > 2 || $value === null) {
            return '';
        }
        if (is_scalar($value)) {
            return SmsPhoneNumber::normalize((string)$value);
        }
        $values = is_object($value) ? get_object_vars($value) : (is_array($value) ? $value : []);
         
         
         
         
        foreach (['e164', 'phone', 'phoneNumber', 'number', 'value', 'msisdn', 'international'] as $key) {
            if (!array_key_exists($key, $values)) {
                continue;
            }
            $phone = $this->phoneFromMaxProfile($values[$key], $depth + 1);
            if ($phone !== '') {
                return $phone;
            }
        }
        return '';
    }

    private function chatProfile(Client $client, string $chat_id): ?PHPMaxChat {
        $chat_id = trim($chat_id);
        if ($chat_id === '' || !preg_match('/^-?\d+$/', $chat_id)) {
            return null;
        }
        if (array_key_exists($chat_id, $this->chat_profile_cache)) {
            return $this->chat_profile_cache[$chat_id] instanceof PHPMaxChat ? $this->chat_profile_cache[$chat_id] : null;
        }

        $chat = null;
        try {
            foreach ((array)$client->chats() as $cached_chat) {
                if ($cached_chat instanceof PHPMaxChat && (string)($cached_chat->id ?? '') === $chat_id) {
                    $chat = $cached_chat;
                    break;
                }
            }
        } catch (\Throwable $e) {
            $chat = null;
        }
        try {
            if (!$chat instanceof PHPMaxChat) {
                $lookup = $client->getChat((int)$chat_id);
                if ($lookup instanceof PHPMaxChat) {
                    $chat = $lookup;
                }
            }
            if ($chat instanceof PHPMaxChat) {
                $name = $this->displayNameFromChat($chat);
                $avatar_url = $this->avatarUrlFromChat($chat);
                $this->logger->info('max_polling_chat_profile_received', [
                    'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                    'chat_type' => (string)($chat->type ?? ''),
                    'display_name_present' => $name !== '' ? 1 : 0,
                    'display_name_hash' => $name !== '' ? substr(hash('sha256', $name), 0, 12) : '',
                    'display_name_length' => $name !== '' ? mb_strlen($name, 'UTF-8') : 0,
                    'avatar_url_present' => $avatar_url !== '' ? 1 : 0,
                    'avatar_field_source' => $this->avatarSourceFromChat($chat),
                    'chat_fields' => $this->safeObjectFieldSummary($chat),
                    'last_message_fields' => $this->safeObjectFieldSummary($chat->lastMessage ?? null),
                ]);
            }
        } catch (\Throwable $e) {
            $this->logger->warning('max_polling_chat_profile_lookup_failed', [
                'chat_hash' => MessengerLogger::hashExternalId($chat_id),
                'error' => substr(MaxRuntime::safeError($e), 0, 180),
            ]);
        }

        $this->chat_profile_cache[$chat_id] = $chat;
        return $chat;
    }

    private function avatarUrlForMessage(Client $client, string $sender_id, string $chat_id, string $display_name_source): array {
        $chat = $this->chatProfile($client, $chat_id);
        $chat_type = $chat instanceof PHPMaxChat
            ? MaxConversationIdentity::normalizeChatType((string)($chat->type ?? ''))
            : 'unknown';
        if ($chat instanceof PHPMaxChat && ($display_name_source === 'chat' || in_array($chat_type, ['CHANNEL', 'CHAT'], true))) {
            $avatar_url = $this->avatarUrlFromChat($chat);
            if ($avatar_url !== '') {
                return ['url' => $avatar_url, 'source' => 'chat', 'chat_type' => $chat_type];
            }
        }

        $user = $this->userProfile($client, $sender_id);
        if ($user instanceof PHPMaxUser) {
            $avatar_url = $this->avatarUrlFromUser($user);
            if ($avatar_url !== '') {
                return ['url' => $avatar_url, 'source' => 'sender', 'chat_type' => $chat_type];
            }
        }

        if ($chat instanceof PHPMaxChat && $chat_type === 'unknown') {
            $avatar_url = $this->avatarUrlFromChat($chat);
            if ($avatar_url !== '') {
                return ['url' => $avatar_url, 'source' => 'chat', 'chat_type' => $chat_type];
            }
        }

        return ['url' => '', 'source' => '', 'chat_type' => $chat_type];
    }

    private function avatarUrlFromUser(PHPMaxUser $user): string {
        return $this->firstNonEmptyString([
            $user->baseRawUrl ?? null,
            $user->baseUrl ?? null,
            $this->extraString($user, ['base_raw_url', 'baseRawUrl', 'base_url', 'baseUrl', 'avatar_url', 'photo_url']),
        ]);
    }

    private function avatarUrlFromChat(PHPMaxChat $chat): string {
        return $this->firstNonEmptyString([
            $chat->baseRawIconUrl ?? null,
            $chat->baseIconUrl ?? null,
            $this->extraString($chat, ['base_raw_icon_url', 'baseRawIconUrl', 'base_icon_url', 'baseIconUrl', 'avatar_url', 'icon_url']),
        ]);
    }

    private function avatarSourceFromUser(PHPMaxUser $user): string {
        if (trim((string)($user->baseRawUrl ?? '')) !== '') {
            return 'baseRawUrl';
        }
        if (trim((string)($user->baseUrl ?? '')) !== '') {
            return 'baseUrl';
        }
        return $this->extraString($user, ['avatar_url', 'photo_url']) !== '' ? 'extra' : '';
    }

    private function avatarSourceFromChat(PHPMaxChat $chat): string {
        if (trim((string)($chat->baseRawIconUrl ?? '')) !== '') {
            return 'baseRawIconUrl';
        }
        if (trim((string)($chat->baseIconUrl ?? '')) !== '') {
            return 'baseIconUrl';
        }
        return $this->extraString($chat, ['avatar_url', 'icon_url']) !== '' ? 'extra' : '';
    }

    private function extraString($model, array $keys): string {
        if (!is_object($model) || !method_exists($model, 'extra')) {
            return '';
        }
        $extra = $model->extra();
        if (!is_array($extra)) {
            return '';
        }
        foreach ($keys as $key) {
            $value = $extra[$key] ?? null;
            if (is_scalar($value)) {
                $value = trim((string)$value);
                if ($value !== '') {
                    return $value;
                }
            }
        }
        return '';
    }

    private function firstNonEmptyString(array $values): string {
        foreach ($values as $value) {
            if (is_scalar($value)) {
                $value = trim((string)$value);
                if ($value !== '') {
                    return $value;
                }
            }
        }
        return '';
    }

    private function displayNameFromUser(PHPMaxUser $user): string {
        foreach ((array)$user->names as $name) {
            $display = trim((string)($name->name ?? ''));
            if ($display === '') {
                $display = trim(trim((string)($name->firstName ?? '')) . ' ' . trim((string)($name->lastName ?? '')));
            }
            $display = $this->cleanDisplayName($display);
            if ($display !== '') {
                return $display;
            }
        }
        return '';
    }

    private function displayNameFromChat(PHPMaxChat $chat): string {
        return $this->cleanDisplayName((string)($chat->title ?? ''));
    }

    private function cleanDisplayName(string $name): string {
        $name = trim(preg_replace('/\s+/u', ' ', $name) ?: '');
        if ($name === '' || preg_match('/^\d+$/', $name)) {
            return '';
        }
        return mb_substr($name, 0, 120, 'UTF-8');
    }

    private function repairConversationIdentities(Client $client, MessengerModel $model_messenger, SessionModel $model_session, array $channel, ?MaxPollingRunBudget $run_budget = null): void {
        foreach ($model_messenger->listContactsNeedingDisplayNameRepair((int)$channel['channel_id'], MaxProvider::PROVIDER, 20) as $contact) {
            if ($run_budget !== null && !$run_budget->canProcessIdentityRepair()) {
                break;
            }
            $external_user_id = trim((string)($contact['external_user_id'] ?? ''));
            $external_chat_id = trim((string)($contact['external_chat_id'] ?? ''));
            $metadata = json_decode((string)($contact['metadata_json'] ?? ''), true);
            $metadata = is_array($metadata) ? $metadata : [];
            $model_messenger->markContactDisplayNameRepairChecked((int)($contact['contact_id'] ?? 0));
            $stored_chat_type = MaxConversationIdentity::chatTypeFromMetadata($metadata);

            $display_name = '';
            $display_name_source = '';
            $chat_lookup_id = $external_chat_id !== '' ? $external_chat_id : $external_user_id;
            $chat = $this->chatProfile($client, $chat_lookup_id);
            $actual_chat_type = MaxConversationIdentity::firstKnownChatType([
                $chat instanceof PHPMaxChat ? (string)($chat->type ?? '') : '',
                $stored_chat_type,
            ]);
            $is_shared_conversation = in_array($actual_chat_type, ['CHAT', 'CHANNEL'], true);
            $counterparty_id = '';
            $account_id = MaxConversationIdentity::normalizeUserId(
                (string)($metadata['account_id'] ?? $channel['bot_user_id'] ?? '')
            );
            $metadata['chat_type'] = $actual_chat_type;
            $metadata['chat_id'] = MaxConversationIdentity::normalizeChatId($chat_lookup_id);
            $metadata['account_id'] = $account_id;

            if ($is_shared_conversation && $chat instanceof PHPMaxChat) {
                unset($metadata['sender_id'], $metadata['counterparty_sender_id']);
                $display_name = $this->displayNameFromChat($chat);
                if ($display_name !== '') {
                    $display_name_source = 'chat';
                    $metadata['display_name_authority'] = 'chat';
                }
            }
            if (!$is_shared_conversation && $actual_chat_type === 'DIALOG') {
                $counterparty_id = MaxConversationIdentity::firstCounterpartyUserId([
                    $metadata['counterparty_sender_id'] ?? '',
                    (string)($metadata['identity_owner_type'] ?? '') === MaxConversationIdentity::OWNER_USER
                        ? ($metadata['identity_owner_id'] ?? '')
                        : '',
                    $metadata['sender_id'] ?? '',
                    $chat instanceof PHPMaxChat
                        ? $this->counterpartyUserIdFromChat($chat, $account_id)
                        : '',
                ], $account_id, [$chat_lookup_id]);
                if ($counterparty_id !== '') {
                    $metadata['counterparty_sender_id'] = $counterparty_id;
                    $metadata['sender_id'] = $counterparty_id;
                    $display_name = $this->displayNameForSender($client, $counterparty_id);
                }
                if ($display_name !== '') {
                    $display_name_source = 'sender';
                    $metadata['display_name_authority'] = 'user';
                }
            }
            if ($display_name === '' || $this->isGenericMaxDisplayName($display_name)) {
                continue;
            }

            $metadata['chat_display_name'] = $is_shared_conversation ? $display_name : (string)($metadata['chat_display_name'] ?? '');
            $metadata = MaxConversationIdentity::enrichMetadata(
                $metadata,
                $chat_lookup_id,
                $external_user_id,
                $account_id
            );
            $metadata = array_merge($metadata, ReplyCapability::metadataForMaxChatType($actual_chat_type));
            if ((int)($metadata['identity_version'] ?? 0) < MaxConversationIdentity::VERSION
                || empty($metadata['identity_fingerprint'])
            ) {
                continue;
            }
            $model_messenger->updateContactIdentity((int)($contact['contact_id'] ?? 0), $display_name, $metadata);
            $chat_id = (int)($contact['chat_id'] ?? 0);
            if ($chat_id > 0) {
                $session = $model_session->getSessionById($chat_id);
                $session_name = trim((string)($session['customer_name'] ?? ''));
                if ($session_name !== $display_name) {
                    $model_session->updateSession($chat_id, ['customer_name' => $display_name]);
                }
            }
            $fresh_contact = $model_messenger->getContactById((int)($contact['contact_id'] ?? 0));
            if ($fresh_contact) {
                 
                 
                (new ContactAvatarService($this->registry))->enqueueFromEvent($channel, $fresh_contact, [
                    'provider' => MaxProvider::PROVIDER,
                ]);
            }

            $this->logger->info('max_polling_contact_name_repaired', [
                'channel_id' => (int)$channel['channel_id'],
                'contact_id' => (int)($contact['contact_id'] ?? 0),
                'chat_id' => $chat_id,
                'user_hash' => MessengerLogger::hashExternalId($external_user_id),
                'counterparty_hash' => MessengerLogger::hashExternalId($counterparty_id),
                'external_chat_hash' => MessengerLogger::hashExternalId($external_chat_id),
                'display_name_source' => $display_name_source,
                'display_name_hash' => substr(hash('sha256', $display_name), 0, 12),
                'display_name_length' => mb_strlen($display_name, 'UTF-8'),
            ]);
        }
    }

    private function isGenericMaxDisplayName(string $name): bool {
        return MaxDisplayNamePolicy::isPlaceholder($name);
    }

    private function safeObjectFieldSummary($value): string {
        if (!is_object($value)) {
            return '';
        }
        $fields = array_keys(get_object_vars($value));
        sort($fields, SORT_STRING);
        return implode(',', $fields);
    }

    private function safeArrayFieldSummary(array $value): string {
        $fields = [];
        foreach ($value as $key => $_field) {
            if (!is_string($key) && !is_int($key)) {
                continue;
            }
            $fields[] = (string)$key;
        }
        sort($fields, SORT_STRING);
        return implode(',', $fields);
    }

    private function safeMessageExtraFieldSummary(PHPMaxMessage $message): string {
        $extra = method_exists($message, 'extra') ? $message->extra() : [];
        return is_array($extra) ? $this->safeArrayFieldSummary($extra) : '';
    }

    private function safeMessageLinkFieldSummary(PHPMaxMessage $message): string {
        $extra = method_exists($message, 'extra') ? $message->extra() : [];
        $link = is_array($extra) ? ($extra['link'] ?? null) : null;
        if (is_object($link)) {
            $link = get_object_vars($link);
        }
        return is_array($link) ? $this->safeArrayFieldSummary($link) : '';
    }

    private function safeMessageLinkMessageFieldSummary(PHPMaxMessage $message): string {
        $extra = method_exists($message, 'extra') ? $message->extra() : [];
        $link = is_array($extra) ? ($extra['link'] ?? null) : null;
        if (is_object($link)) {
            $link = get_object_vars($link);
        }
        $source = is_array($link) ? ($link['message'] ?? null) : null;
        if (is_object($source)) {
            $source = get_object_vars($source);
        }
        return is_array($source) ? $this->safeArrayFieldSummary($source) : '';
    }

    




    private function safeAttachmentFieldSummary(array $attachments): string {
        $fields = [];
        foreach ($attachments as $attachment) {
            if (!is_object($attachment)) {
                continue;
            }
            foreach (array_keys(get_object_vars($attachment)) as $field) {
                $fields[(string)$field] = true;
            }
            if (method_exists($attachment, 'extra')) {
                foreach (array_keys((array)$attachment->extra()) as $field) {
                    $fields[(string)$field] = true;
                }
            }
        }
        $names = array_keys($fields);
        sort($names, SORT_STRING);
        return implode(',', $names);
    }

    private function acquireLock(int $channel_id) {
        $dir = defined('DIR_LOGS') ? DIR_LOGS : sys_get_temp_dir();
        $path = rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'easychat_max_polling_' . $channel_id . '.lock';
        $handle = fopen($path, 'c');
        if (!$handle) {
            return null;
        }
        if (!flock($handle, LOCK_EX | LOCK_NB)) {
            fclose($handle);
            return null;
        }
        return $handle;
    }

    private function inboundService(): InboundService {
        if ($this->inbound_service_factory) {
            return call_user_func($this->inbound_service_factory);
        }
        return new InboundService($this->registry);
    }

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

    private function decryptSecretOrPlain(string $stored): string {
        $stored = trim($stored);
        if ($stored === '') {
            return '';
        }
        if (strpos($stored, 'enc:v1:') !== 0) {
            return $stored;
        }
        $model_setting = new Setting($this->registry);
        return SecretBox::decrypt($stored, $model_setting->get('chat_install_secret', ''));
    }
}
