<?php
namespace EasyChat\Controller;

use EasyChat\Library\AttachmentStorage;
use EasyChat\Library\Attachment\AttachmentBatchUploadService;
use EasyChat\Library\AttachmentPolicy;
use EasyChat\Library\AppClock;
use EasyChat\Library\CustomerContactService;
use EasyChat\Library\Chat\ChatDeletionService;
use EasyChat\Library\CustomerMergeService;
use EasyChat\Library\CustomerProfileDetector;
use EasyChat\Library\CustomerWebPushNotifier;
use EasyChat\Library\Http\ExternalHttpClient;
use EasyChat\Library\Messenger\CommerceContextService;
use EasyChat\Library\Messenger\ContactAvatarService;
use EasyChat\Library\Messenger\AvitoHistorySyncService;
use EasyChat\Library\Messenger\OutboundContactService;
use EasyChat\Library\Messenger\ScheduledMessageService;
use EasyChat\Library\Messenger\SmsPhoneNumber;
use EasyChat\Library\Messenger\OutboundDispatchGuard;
use EasyChat\Library\Messenger\DirectMessageIdempotency;
use EasyChat\Library\Messenger\DirectSmsDispatchOutbox;
use EasyChat\Library\Messenger\AttachmentCapabilities;
use EasyChat\Library\Messenger\ReplyCapability;
use EasyChat\Library\Messenger\ChannelCatalog;
use EasyChat\Library\Messenger\Marketplace\MarketplaceReadReceiptService;
use EasyChat\Library\Messenger\Marketplace\MarketplaceConversationService;
use EasyChat\Library\Messenger\MessageReadReceiptService;
use EasyChat\Library\MessageTextRenderer;
use EasyChat\Library\Notification\TrialLockGuard;
use EasyChat\Library\Support\ChatAssignmentService;
use EasyChat\Library\Support\NextConversationSelector;
use EasyChat\Library\SuggestedReplyAssistant;
use EasyChat\Library\Ai\OutboundDraftAssistant;
use EasyChat\Model\Manager;
use EasyChat\Model\MessageContext;
use EasyChat\Model\Messenger as MessengerModel;
use EasyChat\Model\Reply;
use EasyChat\Model\Setting;
use EasyChat\Model\Session;
use EasyChat\Model\Message;
use EasyChat\Model\Customer;
use EasyChat\Model\ChatAssignment;
use EasyChat\Library\Pagination;
use EasyChat\Library\PresenceService;
use EasyChat\Library\Operator\OperatorDraftService;
use EasyChat\Library\AndroidPushNotifier;
use EasyChat\Library\Update\UpdateCenterService;
use EasyChat\Library\Voice\VoiceMediaService;
use EasyChat\Library\Android\AndroidReleaseCatalog;
use EasyChat\Library\Android\AndroidUpdateNoticeService;
use EasyChat\View\AccessibleColor;
use Exception;

class Index extends Base {

    private const OUTBOUND_DRAFT_AI_WINDOW_SECONDS = 600;
    private const OUTBOUND_DRAFT_AI_MAX_REQUESTS = 20;
    private const OUTBOUND_DRAFT_AI_MIN_INTERVAL_SECONDS = 2;

    protected $layouts = 'layout';

    public function __construct($registry) {
        parent::__construct($registry);
         
        if (!isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            header('Location: index.php?route=auth');
            exit;
        }

         
        if (!$this->isLightweightAdminRoute() && isset(\EasyChat\Library\RequestContext::session()['manager_role']) && \EasyChat\Library\RequestContext::session()['manager_role'] === 'admin') {
            $model_manager = new Manager($this->registry);
            $model_manager->runMigrations();
        }
    }

    public function index() {
        $this->vars['title'] = 'Диалоги | EasyChat';

        $model_reply = new Reply($this->registry);
        $model_setting = new Setting($this->registry);
        $model_manager = new Manager($this->registry);
        
        $manager_id = (int)\EasyChat\Library\RequestContext::session()['manager_id'];

        $this->vars['can_delete_chats'] = $this->canManagerDeleteChats($manager_id);

         
        $this->vars['quick_replies'] = $model_reply->getQuickRepliesForManager($manager_id);

         
        $widget_color = AccessibleColor::normalizeHex($model_setting->get('chat_widget_color', '#6366f1'));
        $this->vars['widget_color'] = $widget_color;
        $this->vars['widget_foreground_color'] = AccessibleColor::foregroundFor(
            $widget_color,
            $model_setting->get('chat_on_accent_mode', AccessibleColor::MODE_AUTO)
        );
        $this->vars['scheduled_timezone'] = (new AppClock($this->registry))->timezoneName();
        $this->vars['chat_max_file_size'] = (int)$model_setting->get('chat_max_file_size', '5');
        $start_options = (new MarketplaceConversationService($this->registry))->startOptions();
        $this->vars['ozon_outbound_accounts'] = is_array($start_options['ozon_accounts'] ?? null)
            ? $start_options['ozon_accounts']
            : [];
        $this->vars['yandex_market_outbound_accounts'] = is_array($start_options['yandex_market_accounts'] ?? null)
            ? $start_options['yandex_market_accounts']
            : [];

        $suggested_reply_assistant = new SuggestedReplyAssistant($this->registry);
        $ai_suggest_availability = $suggested_reply_assistant->availability($manager_id);
        $this->vars['ai_suggestions_can_use'] = !empty($ai_suggest_availability['available']);
        $this->vars['ai_suggestions_reason'] = (string)($ai_suggest_availability['reason'] ?? 'unknown');
        $outbound_draft_availability = (new OutboundDraftAssistant($this->registry))->availability();
        $this->vars['outbound_draft_ai_available'] = !empty($outbound_draft_availability['available']);
        $this->vars['outbound_draft_ai_reason'] = (string)($outbound_draft_availability['reason'] ?? 'unknown');

        $this->template->view('index');
    }

     
    public function getChats() {
        header('Content-Type: application/json; charset=utf-8');
        $this->releaseSessionLockForReadOnlyJsonResponse();
        
        $model_session = new Session($this->registry);

        $get = \EasyChat\Library\RequestContext::get();
        $scope = strtolower(trim((string)($get['scope'] ?? 'unassigned')));
        if (!in_array($scope, ['unassigned', 'mine', 'all', 'archive'], true)) {
            $scope = 'unassigned';
        }
        $archived = $scope === 'archive';
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $operator_id = in_array($scope, ['all', 'archive'], true)
            ? max(0, (int)($get['operator_id'] ?? 0))
            : 0;
        
        $page = isset($get['page']) ? (int)$get['page'] : 1;
        if ($page < 1) $page = 1;
        $limit = 15;
        $start = ($page - 1) * $limit;
        $source_provider = $this->normalizeSourceProvider($get['source_provider'] ?? 'all');
        $include_meta = (string)($get['meta'] ?? '1') !== '0';
        $search_query = trim((string)($get['q'] ?? ''));
        $search_query = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $search_query) ?: '';
        $search_query = preg_replace('/\s+/u', ' ', $search_query) ?: '';
        $search_query = mb_substr($search_query, 0, 100);

         
        $rows = $model_session->getChats(
            $start,
            $limit,
            $archived,
            '',
            $source_provider,
            $scope,
            $manager_id,
            $operator_id,
            $search_query
        );
        $chats = [];
        $avatar_service = new ContactAvatarService($this->registry);
        $reply_service = new ReplyCapability($this->registry);
        $commerce_contexts = (new CommerceContextService($this->registry))->contextsForChatRows($rows, 'index/commerceImage');
        $customer_ids = [];
        foreach ($rows as $row) {
            $customer_id = (int)($row['customer_id'] ?? 0);
            if ($customer_id > 0) {
                $customer_ids[$customer_id] = $customer_id;
            }
        }
        $primary_contacts = (new CustomerContactService($this->registry))->primaryContactsForCustomers($customer_ids);
        $scheduled_counts = (new \EasyChat\Model\ScheduledMessage($this->registry))->countsForChats(
            array_column($rows, 'chat_id')
        );
        $assignment_snapshots = (new ChatAssignment($this->registry))->getSnapshotsForChatIds(
            array_column($rows, 'chat_id'),
            $manager_id
        );
        $operator_drafts = (new OperatorDraftService($this->registry))->summariesForChats(
            $manager_id,
            array_column($rows, 'chat_id')
        );

        foreach ($rows as $row) {
            $customer_id = (int)($row['customer_id'] ?? 0);
            $contacts = $primary_contacts[$customer_id] ?? ['email' => '', 'phone' => ''];
            $reply_capability = $reply_service->forChatRow($row);
            $last_msg = 'Нет сообщений';
            if ($row['last_message_enc']) {
                $decoded = urldecode($row['last_message_enc']);
                $last_msg = (mb_strlen($decoded) > 40) ? mb_substr($decoded, 0, 40) . '...' : $decoded;
            }

            $chats[] = [
                'chat_id'       => (int)$row['chat_id'],
                'customer_name' => htmlspecialchars($row['customer_name'] ? $row['customer_name'] : 'Гость', ENT_QUOTES, 'UTF-8'),
                'primary_contacts' => [
                    'email' => htmlspecialchars($contacts['email'], ENT_QUOTES, 'UTF-8'),
                    'phone' => htmlspecialchars($contacts['phone'], ENT_QUOTES, 'UTF-8'),
                ],
                'source_provider' => htmlspecialchars($row['source_provider'] ? $row['source_provider'] : 'widget', ENT_QUOTES, 'UTF-8'),
                'source_label'  => $this->sourceLabel($row['source_provider'] ?? 'widget'),
                'unread'        => (int)$row['unread_count'],
                'scheduled_count' => (int)($scheduled_counts[(int)$row['chat_id']] ?? 0),
                'last_message'  => htmlspecialchars($last_msg, ENT_QUOTES, 'UTF-8'),
                'last_author'   => (int)$row['last_author'],
                'last_date'     => $this->formatSqlDate((string)($row['last_date'] ?? ''), 'd.m.y H:i'),
                'is_blocked'    => (int)($row['is_blocked'] ?? 0),
                'avatar_url'    => $avatar_service->avatarUrlFromFields($row, 'index/contactAvatar'),
                'avatar_version'=> $avatar_service->avatarVersionFromFields($row),
                'reply_capability' => $reply_capability,
                'commerce_context' => $commerce_contexts[(int)$row['chat_id']] ?? null,
                'assignment' => $this->assignmentPayload(
                    $assignment_snapshots[(int)$row['chat_id']] ?? [],
                    $manager_id
                ),
                'operator_draft' => !empty($reply_capability['can_reply'])
                    ? ($operator_drafts[(int)$row['chat_id']] ?? ['exists' => false])
                    : ['exists' => false],
            ];
        }

        $payload = [
            'success' => true, 
            'chats' => $chats,
        ];

        if ($include_meta) {
            $total = $model_session->getTotalChats(
                $archived,
                $source_provider,
                $scope,
                $manager_id,
                $operator_id,
                $search_query
            );

            $pagination = new Pagination();
            $pagination->total = $total;
            $pagination->page = $page;
            $pagination->limit = $limit;
            $pagination->num_links = 3;
            $pagination->url = '#page={page}';

            $model_message = new Message($this->registry);
            $payload['pagination'] = $pagination->render();
            $payload['total_unread'] = $model_message->getUnreadCountForManager($manager_id);
            $payload['source_filters'] = $this->sourceFiltersPayload(
                $model_session->getSourceProviderCounts($archived, $scope, $manager_id, $operator_id, $search_query),
                $source_provider
            );
            $payload['scope'] = $scope;
            $payload['queue_counts'] = $model_session->getQueueCounts($manager_id);
            $payload['operator_filters'] = $this->operatorFiltersPayload($manager_id);
            $payload['selected_operator_id'] = $operator_id;
            $payload['total'] = $total;
            $payload['search_query'] = $search_query;

            $next_after_reply = (string)($get['next_after_reply'] ?? '') === '1';
            $exclude_chat_id = max(0, (int)($get['exclude_chat_id'] ?? 0));
            if ($next_after_reply
                && $exclude_chat_id > 0
                && $scope !== 'archive'
                && $model_session->wasRecentlyAnsweredByManager($exclude_chat_id, $manager_id)) {
                $waiting_rows = $model_session->getOldestAwaitingReplyCandidates(
                    $scope,
                    $manager_id,
                    $operator_id,
                    $source_provider,
                    $search_query,
                    $exclude_chat_id
                );
                $selection = (new NextConversationSelector())->select(
                    $waiting_rows,
                    static function (array $row) use ($reply_service): array {
                        return $reply_service->forChatRow($row);
                    }
                );
                $payload['next_conversation'] = $selection
                    ? $this->nextConversationPayload($selection['row'], $selection['reply_capability'])
                    : null;
            }
        }

        echo json_encode($payload);
        exit;
    }

    public function commerceImage() {
        if (!isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            header("HTTP/1.1 403 Forbidden");
            exit;
        }
        $this->releaseSessionLockForReadOnlyBinaryResponse();

        $get = \EasyChat\Library\RequestContext::get();
        $provider = (string)($get['provider'] ?? '');
        $chat_id = (int)($get['chat_id'] ?? 0);
        $item_id = (string)($get['item_id'] ?? '');
        $service = new CommerceContextService($this->registry);

        $if_none_match = (string)(\EasyChat\Library\RequestContext::server()['HTTP_IF_NONE_MATCH'] ?? '');
        if ($if_none_match !== '') {
            $cached_meta = $service->cachedImageMetaForContext($provider, $chat_id, $item_id);
            if (!empty($cached_meta['success'])) {
                $this->sendPrivateImmutableBinaryCacheHeaders((string)$cached_meta['etag'], (int)($cached_meta['last_modified'] ?? 0));
                if (hash_equals((string)$cached_meta['etag'], $if_none_match)) {
                    http_response_code(304);
                    exit;
                }
            }
        }

        $result = $service->loadImageForContext($provider, $chat_id, $item_id);
        if (empty($result['success'])) {
            header("HTTP/1.1 404 Not Found");
            exit;
        }

        $this->sendPrivateImmutableBinaryCacheHeaders((string)$result['etag'], (int)($result['last_modified'] ?? 0));

        if ($if_none_match !== '' && hash_equals((string)$result['etag'], $if_none_match)) {
            http_response_code(304);
            exit;
        }

        header("Content-Type: " . $result['mime']);
        header("Content-Length: " . strlen($result['body']));
        echo $result['body'];
        exit;
    }

     
    public function getMessages() {
        header('Content-Type: application/json; charset=utf-8');

        $get = \EasyChat\Library\RequestContext::get();
        $chat_id = isset($get['chat_id']) ? (int)$get['chat_id'] : 0;
        if (!$chat_id) {
            echo json_encode(['success' => false, 'error' => 'Чат не найден']);
            exit;
        }

        $model_message = new Message($this->registry);
        $since_message_id = max(0, (int)($get['since_message_id'] ?? 0));
        $updated_after = $this->normalizeSyncDate($get['updated_after'] ?? '');
        $before_message_id = max(0, (int)($get['before_message_id'] ?? 0));
        $sync_requested = true;
        $limit = min(200, max(1, (int)($get['limit'] ?? 100)));
        $query_limit = $limit + 1;

        $this->releaseSessionLockForReadOnlyJsonResponse();

        if ($before_message_id > 0) {
            $rows = $model_message->getChatHistoryBefore($chat_id, $query_limit, $before_message_id);
            $has_more = $limit > 0 && count($rows) > $limit;
            if ($has_more) {
                array_shift($rows);
            }
            $sync_mode = 'history_before';
        } elseif ($since_message_id > 0 || $updated_after !== '') {
             
            $rows = $model_message->getChatHistory($chat_id, $query_limit, $since_message_id, $updated_after);
            $has_more = $limit > 0 && count($rows) > $limit;
            if ($has_more) {
                array_pop($rows);
            }
            $sync_mode = 'incremental';
        } else {
             
             
            $rows = $model_message->getInitialChatHistory($chat_id, $query_limit);
            $has_more = count($rows) > $limit;
            if ($has_more) {
                array_shift($rows);
            }
            $sync_mode = 'initial';
        }

        $payload = $this->buildChatMessagesPayload($chat_id, $rows, [
            'sync_requested' => $sync_requested,
            'sync_mode' => $sync_mode,
            'since_message_id' => $since_message_id,
            'updated_after' => $updated_after,
            'before_message_id' => $before_message_id,
            'has_more' => $has_more,
            'mark_read' => false,
            'enqueue_avito_sync' => false,
        ]);

        echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function openChat() {
        header('Content-Type: application/json; charset=utf-8');
         
         
         
        header('Cache-Control: private, no-store, max-age=0');

        $get = \EasyChat\Library\RequestContext::get();
        $chat_id = isset($get['chat_id']) ? (int)$get['chat_id'] : 0;
        if ($chat_id <= 0) {
            echo json_encode(['success' => false, 'error' => 'chat_id_required']);
            exit;
        }

        $limit = min(160, max(20, (int)($get['limit'] ?? 80)));
        $query_limit = $limit + 1;

        $model_message = new Message($this->registry);
        $this->releaseSessionLockForReadOnlyJsonResponse();

        $rows = $model_message->getInitialChatHistory($chat_id, $query_limit);
        $has_more = $limit > 0 && count($rows) > $limit;
        if ($has_more) {
            array_shift($rows);
        }

        $payload = $this->buildChatMessagesPayload($chat_id, $rows, [
            'sync_requested' => true,
            'sync_mode' => 'initial',
            'since_message_id' => 0,
            'updated_after' => '',
            'before_message_id' => 0,
            'has_more' => $has_more,
            'mark_read' => false,
            'enqueue_avito_sync' => false,
        ]);

        echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    




    public function acknowledgeChatView() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = max(0, (int)($post['chat_id'] ?? 0));
        $message_ids = isset($post['message_ids']) && is_array($post['message_ids'])
            ? $post['message_ids']
            : [];
        $message_ids = array_values(array_unique(array_filter(array_map('intval', $message_ids), function ($id) {
            return $id > 0;
        })));
        $message_ids = array_slice($message_ids, 0, 100);
        if ($chat_id <= 0 || !$message_ids) {
            http_response_code(422);
            echo json_encode(['success' => false, 'error' => 'view_cursor_invalid']);
            exit;
        }

        $model_session = new Session($this->registry);
        $chat_info = $model_session->getSessionById($chat_id);
        $model_message = new Message($this->registry);
        if (!$chat_info) {
            http_response_code(404);
            echo json_encode(['success' => false, 'error' => 'chat_view_not_found']);
            exit;
        }

         
         
         
        $this->releaseSessionLockForReadOnlyJsonResponse();
        $result = $model_message->markAsReadByAdminIds($chat_id, $message_ids);

        if ((int)$result['remaining'] === 0) {
             
             
            (new MarketplaceReadReceiptService($this->registry))->enqueueForChat($chat_id);
            if ((string)($chat_info['source_provider'] ?? '') === 'avito'
                && (int)($chat_info['source_channel_id'] ?? 0) > 0
            ) {
                (new AvitoHistorySyncService($this->registry))->enqueue(
                    (int)$chat_info['source_channel_id'],
                    $chat_id,
                    'admin_visible'
                );
            }
        }

        echo json_encode([
            'success' => true,
            'message_ids' => array_values($result['message_ids']),
            'marked_read' => (int)$result['affected'],
            'remaining_unread' => (int)$result['remaining'],
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function getMediaGallery() {
        header('Content-Type: application/json; charset=utf-8');

        $get = \EasyChat\Library\RequestContext::get();
        $chat_id = max(0, (int)($get['chat_id'] ?? 0));
        if ($chat_id <= 0) {
            echo json_encode(['success' => false, 'error' => 'chat_id_required'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $model_session = new Session($this->registry);
        if (!$model_session->getSessionById($chat_id)) {
            echo json_encode(['success' => false, 'error' => 'chat_not_found'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $this->releaseSessionLockForReadOnlyJsonResponse();

        $limit = min(120, max(1, (int)($get['limit'] ?? 60)));
        $before_message_id = max(0, (int)($get['before_message_id'] ?? 0));
        $storage = new AttachmentStorage($this->registry);
        $rows = $storage->getMediaAttachmentsForChat($chat_id, $limit, $before_message_id);
        $has_more = count($rows) > $limit;
        if ($has_more) {
            array_pop($rows);
        }

        $items = [];
        foreach ($rows as $row) {
            $payload = $storage->attachmentPayload($chat_id, $row, 'index/image', false);
            $kind = (string)($payload['kind'] ?? '');
            if (!in_array($kind, ['image', 'document'], true)) {
                continue;
            }

            $items[] = array_merge($payload, [
                'message_id' => (int)($row['message_id'] ?? 0),
                'author' => (int)($row['author'] ?? 0),
                'manager_name' => htmlspecialchars((string)($row['manager_name'] ?: 'Оператор'), ENT_QUOTES, 'UTF-8'),
                'date_added' => $this->formatSqlDate((string)($row['date_added'] ?? ''), 'H:i d.m.y'),
            ]);
        }

        echo json_encode([
            'success' => true,
            'items' => $items,
            'has_more' => $has_more,
            'next_before_message_id' => $items ? (int)$items[count($items) - 1]['message_id'] : 0,
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    private function buildChatMessagesPayload($chat_id, array $rows, array $options = []) {
        $model_session = new Session($this->registry);
        $chat_info = $model_session->getSessionById($chat_id);
        if (!$chat_info) {
            return ['success' => false, 'error' => 'Чат не найден'];
        }

        $message_ids = [];
        foreach ($rows as $row) {
            $message_ids[] = (int)$row['message_id'];
        }
        $storage = new AttachmentStorage($this->registry);
        $attachments_by_message = $storage->getAttachmentsByMessageIds($message_ids);
        $message_context = new MessageContext($this->registry);
        $marketplace_contexts_by_message = $message_context->getByMessageIds($message_ids, ['avito_item', 'avito_general', 'youla_item', 'wildberries_item', 'ozon_item', 'yandex_market_item']);
        $site_contexts_by_message = $message_context->getByMessageIds($message_ids, ['site_page']);
        $messenger_contexts_by_message = $message_context->getByMessageIds($message_ids, MessageContext::messengerContextTypes());
        $delivery_states_by_message = (new MessengerModel($this->registry))->getOutboundDeliveryStatesByMessageIds($message_ids);
        $read_receipt_service = new MessageReadReceiptService($this->registry);
        $read_receipt_capability = (string)($chat_info['read_receipt_capability']
            ?? MessageReadReceiptService::CAPABILITY_UNSUPPORTED);
                             
        $messages = [];
        $client_ips = [];
        $profile_detector = new CustomerProfileDetector();
        $customer_merge_service = new CustomerMergeService($this->registry);
        $model_customer = new Customer($this->registry);
         
         
        $customer_id = max(0, (int)($chat_info['customer_id'] ?? 0));
        $message_text_renderer = new MessageTextRenderer();
        $confirmed_profile_hashes = [];
        $profile_conflict_cache = [];
        if ($customer_id > 0) {
            $confirmed_profile_hashes = $model_customer->profileCandidateSkipHashSet($chat_id, $customer_id);
        }

        foreach ($rows as $row) {
            if ((int)$row['author'] === 1 && !empty($row['ip'])) {
                $ip = trim($row['ip']);
                if (!in_array($ip, $client_ips)) {
                    $client_ips[] = $ip;
                }
            }
            $attachments = [];
            foreach ($attachments_by_message[(int)$row['message_id']] ?? [] as $attachment) {
                 
                 
                $attachments[] = $storage->attachmentPayload($chat_id, $attachment, 'index/image', false);
            }

            $decoded_text = urldecode($row['text']);
            if ((int)$row['author'] === 3) {
                $decoded_text = MessageTextRenderer::withoutCitationMarkers($decoded_text);
            }
            $parsed_text = $message_text_renderer->toHtml($decoded_text);

            $image_urls = ['url' => '', 'full_url' => '', 'preview_url' => '', 'thumb_url' => ''];
            if ($row['image']) {
                $image_urls = $storage->imageUrlSet($chat_id, (string)$row['image'], 'index/image', [], false);
            }

            $profile_candidates = [];
            if ((int)$row['author'] === 1) {
                foreach ($profile_detector->detect($decoded_text) as $candidate) {
                    $key = $candidate['provider'] . '|' . $candidate['profile_url_hash'];
                    if (isset($confirmed_profile_hashes[$key])) {
                        continue;
                    }
                    $profile_url = (string)$candidate['profile_url'];
                    if (!array_key_exists($profile_url, $profile_conflict_cache)) {
                        $profile_conflict_cache[$profile_url] = $customer_merge_service->profileConflictForChat($chat_id, $profile_url);
                    }
                    $conflict = $profile_conflict_cache[$profile_url];
                    if ($conflict) {
                        $candidate['conflict'] = $conflict;
                    }
                    $profile_candidates[] = $candidate;
                }
            }

            $marketplace_context = $marketplace_contexts_by_message[(int)$row['message_id']] ?? null;
            if (is_array($marketplace_context) && $marketplace_context) {
                $marketplace_context = $this->webMarketplaceContextPayload($chat_info, $marketplace_context);
            }

            $delivery_state = $delivery_states_by_message[(int)$row['message_id']] ?? null;
            if (is_array($delivery_state)) {
                $delivery_state['provider_label'] = $this->sourceLabel((string)($delivery_state['provider'] ?? ''));
            }

            $messages[] = [
                'message_id'    => (int)$row['message_id'],
                'author'        => (int)$row['author'],  
                'admin_read'    => (int)($row['status_admin'] ?? 0) === 1 ? 1 : 0,
                'message_kind'  => (string)($row['message_kind'] ?? 'normal'),
                'manager_name'  => htmlspecialchars($row['manager_name'] ? $row['manager_name'] : 'Оператор', ENT_QUOTES, 'UTF-8'),
                'manager_image' => (!empty($row['manager_image']) && (int)$row['author'] === 2) ? HTTP_AVATARS . $row['manager_image'] : '',
                'text'          => $parsed_text,
                'text_plain'    => $decoded_text,
                'text_html'     => $parsed_text,
                'image'         => $image_urls['url'],
                'full_url'      => $image_urls['full_url'],
                'preview_url'   => $image_urls['preview_url'],
                'thumb_url'     => $image_urls['thumb_url'],
                'preview_ready' => $image_urls['preview_ready'] ?? true,
                'variant_status' => $image_urls['variant_status'] ?? '',
                'width'         => (int)($image_urls['width'] ?? 0),
                'height'        => (int)($image_urls['height'] ?? 0),
                'attachments'   => $attachments,
                'date_added'    => $this->formatSqlDate((string)$row['date_added'], 'H:i d.m.y'),
                 
                 
                'date_added_sort' => (string)$row['date_added'],
                'marketplace_context' => $marketplace_context,
                'avito_context' => $marketplace_context,
                'site_context' => $site_contexts_by_message[(int)$row['message_id']] ?? null,
                'message_context' => $site_contexts_by_message[(int)$row['message_id']] ?? null,
                'messenger_context' => $messenger_contexts_by_message[(int)$row['message_id']] ?? null,
                'delivery' => $delivery_state,
                'read_receipt' => $read_receipt_service->payload($row, $read_receipt_capability),
                'profile_candidates' => $profile_candidates,
            ];
        }

        $current_avito_item = [];
        $current_avito_context = [];
        $current_marketplace_context = [];
        $current_site_context = $message_context->getCurrentSiteContext($chat_id);
        if (in_array(($chat_info['source_provider'] ?? ''), ['avito', 'youla', 'wildberries', 'ozon', 'yandex_market'], true)) {
            $current_marketplace_context = $message_context->getCurrentMarketplaceContext($chat_id);
            if ($current_marketplace_context) {
                $current_marketplace_context = $this->webMarketplaceContextPayload($chat_info, $current_marketplace_context);
            }
            $current_avito_item = $current_marketplace_context;
            $current_avito_context = $current_marketplace_context;
            $source_channel_id = (int)($chat_info['source_channel_id'] ?? 0);
            if (($chat_info['source_provider'] ?? '') === 'avito' && !empty($options['enqueue_avito_sync']) && $source_channel_id > 0) {
                (new AvitoHistorySyncService($this->registry))->enqueue($source_channel_id, $chat_id, 'admin_open');
            }
        }

        $max_message_id = 0;
        $min_message_id = 0;
        foreach ($messages as $message) {
            $message_id = (int)$message['message_id'];
            $max_message_id = max($max_message_id, $message_id);
            $min_message_id = $min_message_id === 0 ? $message_id : min($min_message_id, $message_id);
        }

        $avatar_payload = $this->contactAvatarPayload($chat_info, 'index/contactAvatar');
        $reply_capability = (new ReplyCapability($this->registry))->forChatId($chat_id);
        $primary_contacts = (new CustomerContactService($this->registry))->primaryContactsForCustomer((int)$customer_id);
        $payload = [
            'success'   => true, 
            'messages'  => $messages,
            'reply_capability' => $reply_capability,
            'read_receipt_capability' => $read_receipt_capability,
            'current_marketplace_context' => $current_marketplace_context,
            'current_avito_item' => $current_avito_item,
            'current_avito_context' => $current_avito_context,
            'current_site_context' => $current_site_context,
            'ips'       => $client_ips,
            'customer'  => [
                'customer_id' => $customer_id,
                'name'    => htmlspecialchars($chat_info['customer_name'] ? $chat_info['customer_name'] : 'Гость', ENT_QUOTES, 'UTF-8'),
                'primary_contacts' => [
                    'email' => htmlspecialchars($primary_contacts['email'], ENT_QUOTES, 'UTF-8'),
                    'phone' => htmlspecialchars($primary_contacts['phone'], ENT_QUOTES, 'UTF-8'),
                ],
                'created' => $this->formatSqlDate((string)($chat_info['date_added'] ?? ''), 'd.m.Y H:i'),
                'source_provider' => htmlspecialchars($chat_info['source_provider'] ?? 'widget', ENT_QUOTES, 'UTF-8'),
                'source_label' => $this->sourceLabel($chat_info['source_provider'] ?? 'widget'),
                'profile_url' => $this->contactProfileUrl($chat_info),
                'avatar_url' => $avatar_payload['avatar_url'],
                'avatar_version' => $avatar_payload['avatar_version'],
                'reply_capability' => $reply_capability,
            ],
            'is_archived' => (int)($chat_info['is_archived'] ?? 0),
            'is_blocked' => (int)($chat_info['is_blocked'] ?? 0),
            'assignment' => $this->assignmentPayload(
                (new ChatAssignment($this->registry))->getSnapshot(
                    $chat_id,
                    (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0)
                ),
                (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0)
            ),
            'operator_draft' => (new OperatorDraftService($this->registry))->get(
                (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0),
                (int)$chat_id
            )['operator_draft'],
        ];
        $payload = array_merge($payload, (new ScheduledMessageService($this->registry))->payloadForChat((int)$chat_id));

        if (!empty($options['sync_requested'])) {
            $since_message_id = (int)($options['since_message_id'] ?? 0);
            $before_message_id = (int)($options['before_message_id'] ?? 0);
            $payload['sync'] = [
                'mode' => (string)($options['sync_mode'] ?? 'incremental'),
                'server_time' => (new AppClock($this->registry))->nowSql(),
                'since_message_id' => $since_message_id,
                'updated_after' => (string)($options['updated_after'] ?? ''),
                'before_message_id' => $before_message_id,
                'max_message_id' => $max_message_id,
                'min_message_id' => $min_message_id,
                'next_since_message_id' => $max_message_id > 0 ? $max_message_id : $since_message_id,
                'next_before_message_id' => $min_message_id > 0 ? $min_message_id : $before_message_id,
                'has_more' => !empty($options['has_more']) ? 1 : 0,
                'mark_read' => !empty($options['mark_read']) ? 1 : 0,
            ];
        }

        return $payload;
    }

    private function webMarketplaceContextPayload(array $chat_info, array $context): array {
        $commerce_payload = (new CommerceContextService($this->registry))->payloadForRow($chat_info, $context, 'index/commerceImage');
        foreach (['thumbnail_url', 'thumbnail_version', 'has_thumbnail', 'customer_group_id', 'customer_group_label', 'external_customer_id', 'group_fallback'] as $key) {
            if (array_key_exists($key, $commerce_payload)) {
                $context[$key] = $commerce_payload[$key];
            }
        }
        return $context;
    }

    public function customerPanel() {
        header('Content-Type: application/json; charset=utf-8');

        $chat_id = isset(\EasyChat\Library\RequestContext::get()['chat_id']) ? (int)\EasyChat\Library\RequestContext::get()['chat_id'] : 0;
        if ($chat_id <= 0) {
            echo json_encode(['success' => false, 'error' => 'chat_id_required']);
            exit;
        }

        $this->releaseSessionLockForReadOnlyJsonResponse();

        $refresh_commerce = (string)(\EasyChat\Library\RequestContext::get()['refresh_commerce'] ?? '0') === '1';
        try {
            $panel = (new Customer($this->registry))->panelForChat($chat_id, $refresh_commerce);
        } catch (\Throwable $e) {
            try {
                (new \EasyChat\Library\Log('customer_panel'))->write([
                    'event' => 'customer_panel_failed',
                    'chat_id' => $chat_id,
                    'error' => $e->getMessage(),
                ]);
            } catch (\Throwable $log_error) {
                 
            }
            echo json_encode(['success' => false, 'error' => 'customer_panel_failed']);
            exit;
        }

        if (!$panel) {
            echo json_encode(['success' => false, 'error' => 'chat_not_found']);
            exit;
        }

        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $assignment_service = new ChatAssignmentService($this->registry);
        echo json_encode([
            'success' => true,
            'assignment' => $this->assignmentPayload(
                (new ChatAssignment($this->registry))->getSnapshot($chat_id, $manager_id),
                $manager_id
            ),
            'operator_filters' => $this->operatorFiltersPayload($manager_id),
            'assignment_capabilities' => $assignment_service->capabilities($manager_id),
        ] + $panel, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function setChatAssignment() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $session = \EasyChat\Library\RequestContext::session();
        $actor_manager_id = (int)($session['manager_id'] ?? 0);
        $target_manager_id = max(0, (int)($post['manager_id'] ?? 0));
        if ($target_manager_id <= 0) {
            $target_manager_id = $actor_manager_id;
        }
        $action = strtolower(trim((string)($post['action'] ?? 'assign')));
        if (!in_array($action, ['assign', 'unassign'], true)) {
            http_response_code(422);
            echo json_encode(['success' => false, 'error' => 'action_invalid'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $result = (new ChatAssignmentService($this->registry))->change(
            (int)($post['chat_id'] ?? 0),
            $target_manager_id,
            $action === 'assign',
            $actor_manager_id
        );
        if (empty($result['success'])) {
            $error = (string)($result['error'] ?? '');
            if (in_array($error, ['assignment_forbidden', 'actor_disabled'], true)) {
                http_response_code(403);
            } elseif ($error === 'chat_not_found') {
                http_response_code(404);
            } elseif ($error === 'assignment_storage_failed') {
                http_response_code(503);
            } else {
                http_response_code(422);
            }
        } else {
            $result['assignment'] = $this->assignmentPayload(
                is_array($result['assignment'] ?? null) ? $result['assignment'] : [],
                $actor_manager_id
            );
        }
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function addCustomerProfile() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $url = isset($post['profile_url']) ? trim((string)$post['profile_url']) : '';
        $message_id = isset($post['message_id']) ? (int)$post['message_id'] : 0;
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);

        $result = (new Customer($this->registry))->addProfileFromChat($chat_id, $url, $manager_id, $message_id, 'message');
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        $panel = (new Customer($this->registry))->panelForChat($chat_id);
        echo json_encode(['success' => true] + $panel, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function hideCustomerProfileCandidate() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $url = isset($post['profile_url']) ? trim((string)$post['profile_url']) : '';
        $message_id = isset($post['message_id']) ? (int)$post['message_id'] : 0;
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);

        $model_customer = new Customer($this->registry);
        $result = $model_customer->hideProfileCandidateForChat($chat_id, $url, $manager_id, $message_id);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function removeCustomerProfile() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $profile_id = isset($post['profile_id']) ? (int)$post['profile_id'] : 0;
        $reason_code = isset($post['reason_code']) ? (string)$post['reason_code'] : 'wrong_customer';
        $reason_note = isset($post['reason_note']) ? (string)$post['reason_note'] : '';
        $suppress_candidate = !isset($post['suppress_candidate']) || (string)$post['suppress_candidate'] === '1';
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);

        $model_customer = new Customer($this->registry);
        $result = $model_customer->removeProfileFromChat($chat_id, $profile_id, $manager_id, $reason_code, $reason_note, $suppress_candidate);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function mergeCustomers() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $source_customer_id = isset($post['source_customer_id']) ? (int)$post['source_customer_id'] : 0;
        $profile_url = isset($post['profile_url']) ? trim((string)$post['profile_url']) : '';
        $reason = isset($post['reason']) ? (string)$post['reason'] : 'manual';
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);

        $result = (new CustomerMergeService($this->registry))->mergeCustomersForChat($chat_id, $source_customer_id, $manager_id, $reason, $profile_url);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        $panel = $result['panel'] ?? (new Customer($this->registry))->panelForChat($chat_id);
        echo json_encode(['success' => true] + $panel, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function addCustomerNote() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $text = isset($post['text']) ? (string)$post['text'] : '';
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $model_setting = new Setting($this->registry);
        if ((string)$model_setting->get('chat_dialog_notes_status', '1') !== '1') {
            echo json_encode(['success' => false, 'error' => 'Внутренние заметки отключены в настройках'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        $model_customer = new Customer($this->registry);
        $result = $model_customer->addNoteForChat($chat_id, $text, $manager_id);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function deleteCustomerNote() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $note_id = isset($post['note_id']) ? (int)$post['note_id'] : 0;
        $model_customer = new Customer($this->registry);
        $result = $model_customer->deleteNoteForChat($chat_id, $note_id);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function pinCustomerNote() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $note_id = isset($post['note_id']) ? (int)$post['note_id'] : 0;
        $pinned = isset($post['pinned']) && (string)$post['pinned'] === '1';
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $model_customer = new Customer($this->registry);
        $result = $model_customer->setNotePinnedForChat($chat_id, $note_id, $pinned, $manager_id);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function makeCustomerContactPrimary() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $contact_point_id = isset($post['contact_point_id']) ? (int)$post['contact_point_id'] : 0;
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $model_customer = new Customer($this->registry);
        $result = (new CustomerContactService($this->registry))->makeContactPrimaryForChat($chat_id, $contact_point_id, $manager_id);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function deleteCustomerContact() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $contact_point_id = isset($post['contact_point_id']) ? (int)$post['contact_point_id'] : 0;
        $model_customer = new Customer($this->registry);
        $result = (new CustomerContactService($this->registry))->archiveContactForChat($chat_id, $contact_point_id);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function addCustomerTag() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $label = isset($post['label']) ? (string)$post['label'] : '';
        $model_setting = new Setting($this->registry);
        if ((string)$model_setting->get('chat_dialog_tags_status', '1') !== '1') {
            echo json_encode(['success' => false, 'error' => 'Теги диалога отключены в настройках'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        $model_customer = new Customer($this->registry);
        $result = $model_customer->addTagForChat($chat_id, $label);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function removeCustomerTag() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $chat_id = isset($post['chat_id']) ? (int)$post['chat_id'] : 0;
        $tag_id = isset($post['tag_id']) ? (int)$post['tag_id'] : 0;
        $model_customer = new Customer($this->registry);
        $result = $model_customer->removeTagForChat($chat_id, $tag_id);
        if (empty($result['success'])) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true] + $model_customer->panelForChat($chat_id), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function suggestReply() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        $skill = isset(\EasyChat\Library\RequestContext::post()['skill']) ? (string)\EasyChat\Library\RequestContext::post()['skill'] : 'support';

        $assistant = new SuggestedReplyAssistant($this->registry);
        $result = $assistant->suggest($manager_id, $chat_id, [
            'skill' => $skill,
        ]);

        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

     
    public function sendMessage() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        $text    = isset(\EasyChat\Library\RequestContext::post()['text']) ? trim(\EasyChat\Library\RequestContext::post()['text']) : '';
        $client_request_id = isset(\EasyChat\Library\RequestContext::post()['client_request_id'])
            ? trim((string)\EasyChat\Library\RequestContext::post()['client_request_id'])
            : '';
        $operator_draft_revision = max(0, (int)(\EasyChat\Library\RequestContext::post()['operator_draft_revision'] ?? 0));

        if (!$chat_id || !$text) {
            echo json_encode(['success' => false, 'error' => 'Сообщение не может быть пустым']);
            exit;
        }

        $model_message = new Message($this->registry);
        $model_session = new Session($this->registry);
        $chat_info = $model_session->getSessionById($chat_id);
        if ($chat_info && (string)($chat_info['source_provider'] ?? '') === 'sms'
            && (new TrialLockGuard($this->registry))->isSmsBlocked(false)) {
            echo json_encode([
                'success' => false,
                'code' => 'trial_limit_exceeded',
                'error' => 'Бесплатный лимит push-уведомлений исчерпан. Чтобы отправлять SMS через телефон оператора, подключите лицензию EasyChat.',
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }
        $reply_capability = (new ReplyCapability($this->registry))->forChatId($chat_id);
        if (empty($reply_capability['can_reply'])) {
            echo json_encode(['success' => false, 'error' => (string)($reply_capability['message'] ?? 'В этот диалог нельзя ответить из EasyChat.'), 'reply_capability' => $reply_capability], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }
        
        $manager_id = (int)\EasyChat\Library\RequestContext::session()['manager_id'];
        $presence = new PresenceService($this->registry);
        $presence->markManagerActive($manager_id, 'web_send_message', true);

        $manager_name = \EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор';
        $ip = \EasyChat\Library\RequestContext::server()['REMOTE_ADDR'] ?? '';

         
         
        $encoded_text = urlencode($text);
        $idempotency = null;
        $is_sms_chat = (string)($chat_info['source_provider'] ?? '') === 'sms';
        $sms_dispatch_outbox = $is_sms_chat
            ? new DirectSmsDispatchOutbox($this->registry)
            : null;
        $idempotency_claim = [];
        $idempotency_replayed = false;
        $legacy_sms_created = false;
        if ($client_request_id !== '' || $is_sms_chat) {
             
             
            (new Manager($this->registry))->runMigrations();
        }
        if ($client_request_id !== '') {
            $idempotency = new DirectMessageIdempotency($this->registry);
            $idempotency_claim = $idempotency->begin(
                'web',
                $manager_id,
                $client_request_id,
                [
                    'chat_id' => $chat_id,
                    'manager_id' => $manager_id,
                    'text' => $text,
                    'file_names' => [],
                    'display_names' => [],
                ],
                function() use ($model_message, $chat_id, $manager_id, $manager_name, $encoded_text, $ip, $sms_dispatch_outbox): int {
                    $message_id = (int)$model_message->addMessage([
                        'chat_id' => $chat_id,
                        'author' => 2,
                        'manager_id' => $manager_id,
                        'manager_name' => $manager_name,
                        'text' => $encoded_text,
                        'image' => '',
                        'status_user' => 0,
                        'status_admin' => 1,
                        'ip' => $ip,
                        'skip_metrics' => true,
                    ]);
                    if ($sms_dispatch_outbox) {
                        $sms_dispatch_outbox->enqueue($message_id);
                    }
                    return $message_id;
                }
            );
            $state = (string)($idempotency_claim['state'] ?? 'invalid');
            if ($state === 'invalid') {
                http_response_code(422);
                echo json_encode(['success' => false, 'code' => 'client_request_id_invalid', 'error' => 'Некорректный идентификатор отправки.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            if ($state === 'conflict') {
                http_response_code(409);
                echo json_encode(['success' => false, 'code' => 'client_request_id_reused', 'error' => 'Эта попытка отправки уже относится к другому сообщению.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            if ($state === 'processing') {
                 
                 
                 
                if ((int)($idempotency_claim['message_id'] ?? 0) <= 0) {
                    $retry_after = max(1, (int)($idempotency_claim['retry_after'] ?? 1));
                    http_response_code(409);
                    header('Retry-After: ' . $retry_after);
                    echo json_encode([
                        'success' => false,
                        'code' => 'message_send_in_progress',
                        'error' => 'Сервер завершает предыдущую попытку отправки.',
                        'retryable' => 1,
                        'retry_after' => $retry_after,
                    ], JSON_UNESCAPED_UNICODE);
                    exit;
                }
            }
            $message_id = (int)($idempotency_claim['message_id'] ?? 0);
            $idempotency_replayed = !empty($idempotency_claim['replayed']);
        } else {
             
            $create_legacy_message = function() use ($model_message, $chat_id, $manager_id, $manager_name, $encoded_text, $ip, $is_sms_chat): int {
                return (int)$model_message->addMessage([
                    'chat_id' => $chat_id,
                    'author' => 2,
                    'manager_id' => $manager_id,
                    'manager_name' => $manager_name,
                    'text' => $encoded_text,
                    'image' => '',
                    'status_user' => 0,
                    'status_admin' => 1,
                    'ip' => $ip,
                    'skip_metrics' => $is_sms_chat,
                ]);
            };
            try {
                $message_id = $sms_dispatch_outbox
                    ? $sms_dispatch_outbox->createLegacyMessageWithRecovery($create_legacy_message)
                    : $create_legacy_message();
                $legacy_sms_created = $is_sms_chat;
            } catch (\Throwable $e) {
                http_response_code(500);
                echo json_encode(['success' => false, 'code' => 'message_save_failed', 'error' => 'Не удалось надёжно сохранить сообщение. Повторите отправку.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }
        $saved_message = $model_message->getMessageById((int)$message_id);
        if (!$saved_message) {
            http_response_code(410);
            echo json_encode(['success' => false, 'code' => 'message_send_result_gone', 'error' => 'Сообщение уже удалено.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $completed_replay = (string)($idempotency_claim['state'] ?? '') === 'completed';
        $processing_replay = (string)($idempotency_claim['state'] ?? '') === 'processing';
        $passive_replay = $completed_replay || $processing_replay;
        if (!empty($idempotency_claim['created']) || $legacy_sms_created) {
            try {
                (new \EasyChat\Library\ResponseTimeMetrics($this->registry))->recordMessage((int)$message_id);
            } catch (\Throwable $e) {
                 
            }
        }

         
        if (!$passive_replay) {
            $model_session->updateModifiedDate($chat_id);
        }

         
        if ($chat_info && !$passive_replay) {
            try {
                $push_notifier = new CustomerWebPushNotifier($this->registry);
                $push_notifier->notifyReply($chat_id, 'operator');

                $android_push = new AndroidPushNotifier($this->registry);
                $android_push->notifySyncChat(
                    $chat_id,
                    $chat_info['source_provider'] ?? 'widget',
                    $chat_info['customer_name'] ?? '',
                    strtoupper($chat_info['source_provider'] ?? 'widget')
                );
            } catch (\Throwable $e) {
                 
            }
        }
        
        $outbound_delivery = isset($idempotency_claim['result']['outbound_delivery']) && is_array($idempotency_claim['result']['outbound_delivery'])
            ? $idempotency_claim['result']['outbound_delivery']
            : null;
        if ($chat_info && !$passive_replay) {
            $outbound_guard = new OutboundDispatchGuard($this->registry);
            $outbound_result = $outbound_guard->sendMessageId((int)$message_id);
            if ($sms_dispatch_outbox) {
                $sms_dispatch_outbox->settleAfterImmediateAttempt((int)$message_id, $outbound_result);
            }
            $outbound_delivery = $this->outboundDeliveryPayload(
                $outbound_result,
                $chat_info
            );
        }

        $clock = new AppClock($this->registry);
        $saved_date = (string)($saved_message['date_added'] ?? '');
        $saved_display = $this->formatSqlDate($saved_date, 'H:i d.m.y');
        $message_text_renderer = new MessageTextRenderer();
        $decoded_text = urldecode((string)($saved_message['text'] ?? $encoded_text));
        $text_html = $message_text_renderer->toHtml($decoded_text);
        $response_manager_name = (string)($saved_message['manager_name'] ?? $manager_name);
        if ($response_manager_name === '') {
            $response_manager_name = 'Оператор';
        }
        $response = [
            'success' => true,
            'message_id' => (int)$message_id,
            'author' => 2,
            'manager_name' => htmlspecialchars($response_manager_name, ENT_QUOTES, 'UTF-8'),
            'text' => $text_html,
            'text_plain' => $decoded_text,
            'text_html' => $text_html,
            'date_added' => $saved_display,
            'date_added_sql' => $saved_date,
            'date_added_iso' => $clock->sqlToIsoUtc($saved_date),
            'replayed' => $idempotency_replayed,
            'dispatch_pending' => $processing_replay,
            'assignment' => $this->assignmentPayload(
                (new ChatAssignment($this->registry))->getSnapshot($chat_id, $manager_id),
                $manager_id
            ),
        ];
        $response['operator_draft_cleared'] = $operator_draft_revision > 0
            && (new OperatorDraftService($this->registry))->clearAfterAccepted($manager_id, $chat_id, $operator_draft_revision);
        $read_receipt_capability = (string)($chat_info['read_receipt_capability'] ?? MessageReadReceiptService::CAPABILITY_UNSUPPORTED);
        $response['read_receipt_capability'] = $read_receipt_capability;
        $response['read_receipt'] = (new MessageReadReceiptService($this->registry))->payload($saved_message, $read_receipt_capability);
        if ($outbound_delivery !== null) {
            $response['outbound_delivery'] = $outbound_delivery;
        }
        if ($idempotency && (string)($idempotency_claim['state'] ?? '') === 'claimed') {
            $idempotency->complete(
                'web',
                $manager_id,
                $client_request_id,
                (string)($idempotency_claim['processing_token'] ?? ''),
                $response
            );
        }
        echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function getOperatorDraft() {
        header('Content-Type: application/json; charset=utf-8');
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $chat_id = (int)(\EasyChat\Library\RequestContext::get()['chat_id'] ?? 0);
        $result = (new OperatorDraftService($this->registry))->get($manager_id, $chat_id);
        http_response_code((int)($result['status'] ?? 200));
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function saveOperatorDraft() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();
        (new Manager($this->registry))->runMigrations();
        $post = \EasyChat\Library\RequestContext::post();
        $result = (new OperatorDraftService($this->registry))->save([
            'manager_id' => (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0),
            'chat_id' => (int)($post['chat_id'] ?? 0),
            'text' => (string)($post['text'] ?? ''),
            'base_revision' => (int)($post['base_revision'] ?? 0),
            'mutation_id' => (string)($post['mutation_id'] ?? ''),
            'context_message_id' => (int)($post['context_message_id'] ?? 0),
            'surface' => 'web',
        ]);
        http_response_code((int)($result['status'] ?? 200));
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function startOutboundContact() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $post = \EasyChat\Library\RequestContext::post();
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        $presence = new PresenceService($this->registry);
        $presence->markManagerActive($manager_id, 'web_start_outbound_contact', true);

         
         
        (new Manager($this->registry))->runMigrations();
        $delivery_mode = strtolower(trim((string)($post['delivery_mode'] ?? 'now')));
        if ($delivery_mode === 'scheduled') {
            $phone = SmsPhoneNumber::normalize((string)($post['phone'] ?? ''));
            $idempotency_payload = [
                'channel' => strtolower(trim((string)($post['channel'] ?? 'max'))),
                'phone' => $phone,
                'customer_name' => trim((string)($post['customer_name'] ?? '')),
                'reason' => trim((string)($post['reason'] ?? 'manual')) ?: 'manual',
                'text' => trim((string)($post['text'] ?? '')),
                'scheduled_at' => trim((string)($post['scheduled_at'] ?? '')),
            ];
            $scheduled_service = new ScheduledMessageService($this->registry);
            $draft_validation = $scheduled_service->validateDraft(
                (string)($post['text'] ?? ''),
                (string)($post['scheduled_at'] ?? '')
            );
            if (empty($draft_validation['success'])) {
                $this->scheduledMessageJson($draft_validation);
            }
            $existing = $scheduled_service->existingCreateResult(
                $manager_id,
                trim((string)($post['client_request_id'] ?? '')),
                $idempotency_payload
            );
            if ((string)($existing['state'] ?? '') === 'completed') {
                echo json_encode($existing['result'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                exit;
            }
            if ((string)($existing['state'] ?? '') === 'conflict') {
                http_response_code(409);
                echo json_encode(['success' => false, 'code' => 'client_request_id_reused', 'error' => 'Эта операция уже относится к другому сообщению.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            $prepared = (new OutboundContactService($this->registry))->prepareScheduledChat([
                'channel' => (string)($post['channel'] ?? 'max'),
                'phone' => $phone,
                'customer_name' => (string)($post['customer_name'] ?? ''),
                'reason' => (string)($post['reason'] ?? 'manual'),
                'text' => (string)($post['text'] ?? ''),
                'scheduled_at' => (string)($post['scheduled_at'] ?? ''),
                'client_request_id' => (string)($post['client_request_id'] ?? ''),
                'manager_id' => $manager_id,
            ]);
            if (empty($prepared['success'])) {
                $this->scheduledMessageJson($prepared);
            }
            $result = $scheduled_service->createForChat([
                'chat_id' => (int)$prepared['chat_id'],
                'text' => (string)($post['text'] ?? ''),
                'scheduled_at' => (string)($post['scheduled_at'] ?? ''),
                'client_request_id' => (string)($post['client_request_id'] ?? ''),
                'manager_id' => $manager_id,
                'manager_name' => (string)(\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор'),
                'surface' => 'web_outbound',
                'idempotency_payload' => $idempotency_payload,
            ]);
            if (!empty($result['success'])) {
                $result['chat_id'] = (int)$prepared['chat_id'];
                $result['request_id'] = (int)($prepared['request_id'] ?? 0);
            }
            $this->scheduledMessageJson($result);
        }
        $service = new OutboundContactService($this->registry);
        $result = $service->start([
            'channel' => (string)($post['channel'] ?? 'max'),
            'phone' => (string)($post['phone'] ?? ''),
            'customer_name' => (string)($post['customer_name'] ?? ''),
            'reason' => (string)($post['reason'] ?? 'manual'),
            'text' => (string)($post['text'] ?? ''),
            'client_request_id' => (string)($post['client_request_id'] ?? ''),
            'manager_id' => $manager_id,
            'manager_name' => (string)(\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор'),
            'ip' => (string)(\EasyChat\Library\RequestContext::server()['REMOTE_ADDR'] ?? ''),
        ]);

        if ((string)($result['code'] ?? '') === 'outbound_contact_in_progress') {
            header('Retry-After: ' . max(1, min(3, (int)($result['retry_after'] ?? 1))));
        }
        if (isset($result['http_code'])) {
            http_response_code(max(400, min(599, (int)$result['http_code'])));
            unset($result['http_code']);
        }
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function scheduleMessage() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();
        (new Manager($this->registry))->runMigrations();
        $post = \EasyChat\Library\RequestContext::post();
        $this->scheduledMessageJson((new ScheduledMessageService($this->registry))->createForChat([
            'chat_id' => (int)($post['chat_id'] ?? 0),
            'text' => (string)($post['text'] ?? ''),
            'scheduled_at' => (string)($post['scheduled_at'] ?? ''),
            'client_request_id' => (string)($post['client_request_id'] ?? ''),
            'manager_id' => (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0),
            'manager_name' => (string)(\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор'),
            'surface' => 'web',
        ]));
    }

    public function updateScheduledMessage() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();
        $post = \EasyChat\Library\RequestContext::post();
        $this->scheduledMessageJson((new ScheduledMessageService($this->registry))->update([
            'public_id' => (string)($post['public_id'] ?? ''),
            'revision' => (int)($post['revision'] ?? 0),
            'text' => (string)($post['text'] ?? ''),
            'scheduled_at' => (string)($post['scheduled_at'] ?? ''),
            'client_request_id' => (string)($post['client_request_id'] ?? ''),
            'manager_id' => (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0),
            'manager_name' => (string)(\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор'),
        ]));
    }

    public function sendScheduledMessageNow() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();
        $post = \EasyChat\Library\RequestContext::post();
        $this->scheduledMessageJson((new ScheduledMessageService($this->registry))->sendNow([
            'public_id' => (string)($post['public_id'] ?? ''),
            'revision' => (int)($post['revision'] ?? 0),
            'client_request_id' => (string)($post['client_request_id'] ?? ''),
            'manager_id' => (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0),
            'manager_name' => (string)(\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор'),
        ]));
    }

    public function cancelScheduledMessage() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();
        $post = \EasyChat\Library\RequestContext::post();
        $this->scheduledMessageJson((new ScheduledMessageService($this->registry))->cancel([
            'public_id' => (string)($post['public_id'] ?? ''),
            'revision' => (int)($post['revision'] ?? 0),
            'client_request_id' => (string)($post['client_request_id'] ?? ''),
            'manager_id' => (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0),
            'manager_name' => (string)(\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор'),
        ]));
    }

    private function scheduledMessageJson(array $result): void {
        $http_code = (int)($result['http_code'] ?? (!empty($result['success']) ? 200 : 422));
        unset($result['http_code']);
        http_response_code(max(200, min(599, $http_code)));
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function improveOutboundDraft() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        if (!$this->consumeOutboundDraftAiRateLimit()) {
            http_response_code(429);
            echo json_encode(array(
                'success' => false,
                'error' => 'rate_limited',
                'message' => 'Подождите пару секунд перед повторной обработкой.',
            ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        if (session_status() === PHP_SESSION_ACTIVE) {
             
             
             
            session_write_close();
        }

        $post = \EasyChat\Library\RequestContext::post();
        try {
            $result = (new OutboundDraftAssistant($this->registry))->improve(array(
                'channel' => (string)($post['channel'] ?? ''),
                'action' => (string)($post['action'] ?? ''),
                'tone' => (string)($post['tone'] ?? 'current'),
                'text' => (string)($post['text'] ?? ''),
            ));
        } catch (\Throwable $e) {
            $result = array('success' => false, 'error' => 'assistant_failed');
        }

        if (empty($result['success'])) {
            http_response_code(422);
            $messages = array(
                'api_key_missing' => 'Для помощника текста пока не настроен AI-ключ.',
                'text_required' => 'Сначала введите сообщение.',
                'channel_not_supported' => 'Для выбранного канала помощник пока недоступен.',
                'rate_limited' => 'Подождите пару секунд перед повторной обработкой.',
            );
            $code = (string)($result['error'] ?? 'assistant_failed');
            $result['message'] = $messages[$code] ?? 'Не удалось обработать текст. Исходное сообщение осталось без изменений.';
        }

        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    private function consumeOutboundDraftAiRateLimit(): bool {
        $session = &\EasyChat\Library\RequestContext::session();
        $now = time();
        $hits = isset($session['outbound_draft_ai_hits']) && is_array($session['outbound_draft_ai_hits'])
            ? $session['outbound_draft_ai_hits']
            : array();
        $window_start = $now - self::OUTBOUND_DRAFT_AI_WINDOW_SECONDS;
        $hits = array_values(array_filter($hits, function ($timestamp) use ($window_start) {
            return (int)$timestamp >= $window_start;
        }));
        $last_request = empty($hits) ? 0 : (int)end($hits);
        if (($last_request > 0 && $now - $last_request < self::OUTBOUND_DRAFT_AI_MIN_INTERVAL_SECONDS)
            || count($hits) >= self::OUTBOUND_DRAFT_AI_MAX_REQUESTS) {
            $session['outbound_draft_ai_hits'] = $hits;
            return false;
        }
        $hits[] = $now;
        $session['outbound_draft_ai_hits'] = $hits;
        return true;
    }

     
    public function sendAttachment() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        $text = isset(\EasyChat\Library\RequestContext::post()['text']) ? trim((string)\EasyChat\Library\RequestContext::post()['text']) : '';
        $client_request_id = isset(\EasyChat\Library\RequestContext::post()['client_request_id'])
            ? trim((string)\EasyChat\Library\RequestContext::post()['client_request_id'])
            : '';
        $operator_draft_revision = max(0, (int)(\EasyChat\Library\RequestContext::post()['operator_draft_revision'] ?? 0));
        $expected_attachment_count = isset(\EasyChat\Library\RequestContext::post()['expected_attachment_count'])
            ? (int)\EasyChat\Library\RequestContext::post()['expected_attachment_count']
            : 0;
        $request_files = \EasyChat\Library\RequestContext::files();
        $normalized_files = AttachmentBatchUploadService::normalizeFiles([
            $request_files['files'] ?? [],
            $request_files['file'] ?? [],
        ]);
        if (!$chat_id || !$normalized_files) {
            echo json_encode(['success' => false, 'error' => 'Выберите файл для отправки.']);
            exit;
        }
         
         
        if ($expected_attachment_count > 0 && count($normalized_files) !== $expected_attachment_count) {
            echo json_encode([
                'success' => false,
                'code' => 'attachment_count_mismatch',
                'error' => 'Не все выбранные файлы дошли до сервера. Подборка сохранена в форме — попробуйте отправить еще раз.',
                'expected_count' => $expected_attachment_count,
                'received_count' => count($normalized_files),
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        $model_setting = new Setting($this->registry);
        $model_session = new Session($this->registry);
        $chat_info = $model_session->getSessionById($chat_id);
        if (!$chat_info) {
            echo json_encode(['success' => false, 'error' => 'Чат не найден.']);
            exit;
        }
        if ((string)($chat_info['source_provider'] ?? '') === 'sms'
            && (new TrialLockGuard($this->registry))->isSmsBlocked(false)) {
            echo json_encode([
                'success' => false,
                'code' => 'trial_limit_exceeded',
                'error' => 'Бесплатный лимит push-уведомлений исчерпан. SMS через телефон оператора доступны после подключения лицензии EasyChat.',
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }
        $reply_capability = (new ReplyCapability($this->registry))->forChatId($chat_id);
        if (empty($reply_capability['can_reply'])) {
            echo json_encode(['success' => false, 'error' => (string)($reply_capability['message'] ?? 'В этот диалог нельзя отправить вложение.'), 'reply_capability' => $reply_capability], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }
        $storage = new AttachmentStorage($this->registry);
        $batch = (new AttachmentBatchUploadService($storage))->save(
            $chat_id,
            [$request_files['files'] ?? [], $request_files['file'] ?? []],
            (int)$model_setting->get('chat_max_file_size', '5'),
            'op',
            (string)($chat_info['source_provider'] ?? 'widget')
        );
        if (empty($batch['success'])) {
            echo json_encode(['success' => false, 'error' => $this->uploadErrorText($batch['error'] ?? 'file_denied'), 'limit' => (int)($batch['limit'] ?? 0)]);
            exit;
        }
        $uploads = (array)$batch['uploads'];
        $first_image = null;
        foreach ($uploads as $candidate) {
            if ((string)($candidate['kind'] ?? 'image') === 'image') {
                $first_image = $candidate;
                break;
            }
        }
        $message_image = $first_image ? (string)$first_image['filename'] : '';

        $manager_id = (int)\EasyChat\Library\RequestContext::session()['manager_id'];
        $presence = new PresenceService($this->registry);
        $presence->markManagerActive($manager_id, 'web_send_attachment', true);

        $model_message = new Message($this->registry);
        $manager_name = (string)(\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор');
        $encoded_text = urlencode($text);
        $ip = (string)(\EasyChat\Library\RequestContext::server()['REMOTE_ADDR'] ?? '');
        $discard_uploads = function() use ($storage, $chat_id, $uploads): void {
            foreach ($uploads as $upload) {
                $storage->discardPendingUpload($chat_id, $upload);
            }
        };

        $attachment_fingerprints = [];
        foreach ($uploads as $upload) {
            $path = (string)($upload['path'] ?? '');
            $content_hash = $path !== '' && is_file($path) ? hash_file('sha256', $path) : false;
            if (!is_string($content_hash) || preg_match('/^[a-f0-9]{64}$/D', $content_hash) !== 1) {
                $discard_uploads();
                http_response_code(500);
                echo json_encode(['success' => false, 'code' => 'attachment_fingerprint_failed', 'error' => 'Не удалось проверить загруженный файл.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            $attachment_fingerprints[] = [
                'content_hash' => $content_hash,
                'mime' => (string)($upload['mime'] ?? ''),
                'size' => (int)($upload['size'] ?? 0),
                'kind' => (string)($upload['kind'] ?? ''),
                'display_name' => (string)($upload['display_name'] ?? $upload['original_name'] ?? ''),
            ];
        }

        $idempotency = null;
        $is_sms_chat = (string)($chat_info['source_provider'] ?? '') === 'sms';
        $sms_dispatch_outbox = $is_sms_chat
            ? new DirectSmsDispatchOutbox($this->registry)
            : null;
        $idempotency_claim = [];
        $idempotency_replayed = false;
        $legacy_sms_created = false;
        if ($client_request_id !== '' || $is_sms_chat) {
            (new Manager($this->registry))->runMigrations();
        }
        if ($client_request_id !== '') {
            $idempotency = new DirectMessageIdempotency($this->registry);
            try {
                $idempotency_claim = $idempotency->begin(
                    'web',
                    $manager_id,
                    $client_request_id,
                    [
                        'chat_id' => $chat_id,
                        'manager_id' => $manager_id,
                        'text' => $text,
                        'attachments' => $attachment_fingerprints,
                    ],
                    function() use ($model_message, $chat_id, $manager_id, $manager_name, $encoded_text, $message_image, $ip, $uploads, $storage, $sms_dispatch_outbox): int {
                        $message_id = (int)$model_message->addMessage([
                            'chat_id' => $chat_id,
                            'author' => 2,
                            'manager_id' => $manager_id,
                            'manager_name' => $manager_name,
                            'text' => $encoded_text,
                            'image' => $message_image,
                            'status_user' => 0,
                            'status_admin' => 1,
                            'ip' => $ip,
                            'skip_metrics' => true,
                        ]);
                        foreach ($uploads as $upload) {
                            $storage->attachToMessage($message_id, $chat_id, $upload, 'operator');
                        }
                        if ($sms_dispatch_outbox) {
                            $sms_dispatch_outbox->enqueue($message_id);
                        }
                        return $message_id;
                    }
                );
            } catch (\Throwable $e) {
                $discard_uploads();
                throw $e;
            }

            $state = (string)($idempotency_claim['state'] ?? 'invalid');
            if (in_array($state, ['invalid', 'conflict', 'processing'], true)) {
                $discard_uploads();
            }
            if ($state === 'invalid') {
                http_response_code(422);
                echo json_encode(['success' => false, 'code' => 'client_request_id_invalid', 'error' => 'Некорректный идентификатор отправки.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            if ($state === 'conflict') {
                http_response_code(409);
                echo json_encode(['success' => false, 'code' => 'client_request_id_reused', 'error' => 'Эта попытка отправки уже относится к другому сообщению.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            if ($state === 'processing') {
                if ((int)($idempotency_claim['message_id'] ?? 0) <= 0) {
                    $retry_after = max(1, (int)($idempotency_claim['retry_after'] ?? 1));
                    http_response_code(409);
                    header('Retry-After: ' . $retry_after);
                    echo json_encode([
                        'success' => false,
                        'code' => 'message_send_in_progress',
                        'error' => 'Сервер завершает предыдущую попытку отправки.',
                        'retryable' => 1,
                        'retry_after' => $retry_after,
                    ], JSON_UNESCAPED_UNICODE);
                    exit;
                }
            }
            $message_id = (int)($idempotency_claim['message_id'] ?? 0);
            $idempotency_replayed = !empty($idempotency_claim['replayed']);
            if (empty($idempotency_claim['created'])) {
                $discard_uploads();
            }
        } else {
             
            $create_legacy_message = function() use ($model_message, $chat_id, $manager_id, $manager_name, $encoded_text, $message_image, $ip, $uploads, $storage, $is_sms_chat): int {
                $message_id = (int)$model_message->addMessage([
                    'chat_id' => $chat_id,
                    'author' => 2,
                    'manager_id' => $manager_id,
                    'manager_name' => $manager_name,
                    'text' => $encoded_text,
                    'image' => $message_image,
                    'status_user' => 0,
                    'status_admin' => 1,
                    'ip' => $ip,
                    'skip_metrics' => $is_sms_chat,
                ]);
                foreach ($uploads as $upload) {
                    $storage->attachToMessage($message_id, $chat_id, $upload, 'operator');
                }
                return $message_id;
            };
            try {
                $message_id = $sms_dispatch_outbox
                    ? $sms_dispatch_outbox->createLegacyMessageWithRecovery($create_legacy_message)
                    : $create_legacy_message();
                $legacy_sms_created = $is_sms_chat;
            } catch (\Throwable $e) {
                $discard_uploads();
                http_response_code(500);
                echo json_encode(['success' => false, 'code' => 'message_save_failed', 'error' => 'Не удалось надёжно сохранить сообщение. Повторите отправку.'], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        $saved_message = $model_message->getMessageById((int)$message_id);
        if (!$saved_message) {
            http_response_code(410);
            echo json_encode(['success' => false, 'code' => 'message_send_result_gone', 'error' => 'Сообщение уже удалено.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $completed_replay = (string)($idempotency_claim['state'] ?? '') === 'completed';
        $processing_replay = (string)($idempotency_claim['state'] ?? '') === 'processing';
        $passive_replay = $completed_replay || $processing_replay;
        if (!empty($idempotency_claim['created']) || $legacy_sms_created) {
            try {
                (new \EasyChat\Library\ResponseTimeMetrics($this->registry))->recordMessage((int)$message_id);
            } catch (\Throwable $e) {
                 
            }
        }
        if (!$passive_replay) {
            $model_session->updateModifiedDate($chat_id);
        }

        if ($chat_info && !$passive_replay) {
            try {
                $push_notifier = new CustomerWebPushNotifier($this->registry);
                $push_notifier->notifyReply($chat_id, 'operator');

                $android_push = new AndroidPushNotifier($this->registry);
                $android_push->notifySyncChat(
                    $chat_id,
                    $chat_info['source_provider'] ?? 'widget',
                    $chat_info['customer_name'] ?? '',
                    strtoupper($chat_info['source_provider'] ?? 'widget')
                );
            } catch (\Throwable $e) {
                 
            }
        }

        $outbound_delivery = isset($idempotency_claim['result']['outbound_delivery']) && is_array($idempotency_claim['result']['outbound_delivery'])
            ? $idempotency_claim['result']['outbound_delivery']
            : null;
        if (!$passive_replay) {
            $outbound_guard = new OutboundDispatchGuard($this->registry);
            $outbound_result = $outbound_guard->sendMessageId((int)$message_id);
            if ($sms_dispatch_outbox) {
                $sms_dispatch_outbox->settleAfterImmediateAttempt((int)$message_id, $outbound_result);
            }
            $outbound_delivery = $this->outboundDeliveryPayload(
                $outbound_result,
                $chat_info
            );
        }

        $attachment_payloads = [];
        $grouped_attachments = $storage->getAttachmentsByMessageIds([(int)$message_id]);
        foreach ($grouped_attachments[(int)$message_id] ?? [] as $attachment) {
            $attachment_payloads[] = $storage->attachmentPayload($chat_id, $attachment, 'index/image', true);
        }
        $attachment_payload = $attachment_payloads[0] ?? [];
        $image_payload = null;
        foreach ($attachment_payloads as $payload) {
            if ((string)($payload['kind'] ?? '') === 'image') { $image_payload = $payload; break; }
        }
        $image_urls = $image_payload
            ? [
                'url' => $image_payload['url'] ?? '',
                'full_url' => $image_payload['full_url'] ?? '',
                'preview_url' => $image_payload['preview_url'] ?? '',
                'thumb_url' => $image_payload['thumb_url'] ?? '',
            ]
            : ['url' => '', 'full_url' => '', 'preview_url' => '', 'thumb_url' => ''];
        $clock = new AppClock($this->registry);
        $saved_date = (string)($saved_message['date_added'] ?? '');
        $saved_display = $this->formatSqlDate($saved_date, 'H:i d.m.y');
        $message_text_renderer = new MessageTextRenderer();
        $decoded_text = urldecode((string)($saved_message['text'] ?? urlencode($text)));
        $text_html = $message_text_renderer->toHtml($decoded_text);
        $response_manager_name = (string)($saved_message['manager_name'] ?? (\EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор'));
        if ($response_manager_name === '') {
            $response_manager_name = 'Оператор';
        }
        $response = array_merge([
            'success' => true,
            'message_id' => (int)$message_id,
            'author' => 2,
            'manager_name' => htmlspecialchars($response_manager_name, ENT_QUOTES, 'UTF-8'),
            'text' => $text_html,
            'text_plain' => $decoded_text,
            'text_html' => $text_html,
            'date_added' => $saved_display,
            'date_added_sql' => $saved_date,
            'date_added_iso' => $clock->sqlToIsoUtc($saved_date),
            'attachment' => $attachment_payload,
            'attachments' => $attachment_payloads,
            'replayed' => $idempotency_replayed,
            'dispatch_pending' => $processing_replay,
        ], $image_urls);
        $response['operator_draft_cleared'] = $operator_draft_revision > 0
            && (new OperatorDraftService($this->registry))->clearAfterAccepted($manager_id, $chat_id, $operator_draft_revision);
        $read_receipt_capability = (string)($chat_info['read_receipt_capability'] ?? MessageReadReceiptService::CAPABILITY_UNSUPPORTED);
        $response['read_receipt_capability'] = $read_receipt_capability;
        $response['read_receipt'] = (new MessageReadReceiptService($this->registry))->payload($saved_message, $read_receipt_capability);
        if ($outbound_delivery !== null) {
            $response['outbound_delivery'] = $outbound_delivery;
        }
        if ($idempotency && (string)($idempotency_claim['state'] ?? '') === 'claimed') {
            $idempotency->complete(
                'web',
                $manager_id,
                $client_request_id,
                (string)($idempotency_claim['processing_token'] ?? ''),
                $response
            );
        }
        echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function saveAttachmentPreview() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $chat_id = (int)(\EasyChat\Library\RequestContext::post()['chat_id'] ?? 0);
        $attachment_id = (int)(\EasyChat\Library\RequestContext::post()['attachment_id'] ?? 0);
        $files = \EasyChat\Library\RequestContext::files();
        if ($chat_id <= 0 || $attachment_id <= 0 || empty($files['thumbnail'])) {
            echo json_encode(['success' => false, 'error' => 'preview_required'], JSON_UNESCAPED_UNICODE);
            return;
        }

        $storage = new AttachmentStorage($this->registry);
        $result = $storage->savePdfPreviewUpload($attachment_id, $chat_id, $files['thumbnail'], 'index/image');
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

     
    public function deleteChat() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $manager_id = (int)\EasyChat\Library\RequestContext::session()['manager_id'];
        if (!$this->canManagerDeleteChats($manager_id)) {
            echo json_encode(['success' => false, 'error' => 'Доступ запрещен. У вас нет прав на удаление диалогов.']);
            exit;
        }

        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        if (!$chat_id) {
            echo json_encode(['success' => false, 'error' => 'ID чата не передан']);
            exit;
        }

        $result = (new ChatDeletionService($this->registry))->delete([$chat_id]);
        if (empty($result['success'])) {
            http_response_code((int)($result['http_code'] ?? 409));
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

     
    public function toggleArchive() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        $archive = isset(\EasyChat\Library\RequestContext::post()['archive']) ? (int)\EasyChat\Library\RequestContext::post()['archive'] : 1;  

        if (!$chat_id) {
            echo json_encode(['success' => false, 'error' => 'ID чата не передан']);
            exit;
        }

        $model_session = new Session($this->registry);
        $model_session->updateSession($chat_id, ['is_archived' => $archive]);

        echo json_encode(['success' => true]);
        exit;
    }

     
    public function toggleBlockChat() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        $blocked = isset(\EasyChat\Library\RequestContext::post()['blocked']) ? (int)\EasyChat\Library\RequestContext::post()['blocked'] : 1;  

        if (!$chat_id) {
            echo json_encode(['success' => false, 'error' => 'ID чата не передан']);
            exit;
        }

        $blacklist = new \EasyChat\Library\BlacklistService($this->registry);
        $blacklist->blockChat($chat_id, $blocked);

        echo json_encode(['success' => true]);
        exit;
    }

     
    public function saveCustomer() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        $name    = isset(\EasyChat\Library\RequestContext::post()['customer_name']) ? trim(\EasyChat\Library\RequestContext::post()['customer_name']) : '';
        $email   = isset(\EasyChat\Library\RequestContext::post()['primary_email']) ? trim(\EasyChat\Library\RequestContext::post()['primary_email']) : '';
        $phone   = isset(\EasyChat\Library\RequestContext::post()['primary_phone']) ? trim(\EasyChat\Library\RequestContext::post()['primary_phone']) : '';

        if (!$chat_id) {
            echo json_encode(['success' => false, 'error' => 'ID чата не передан']);
            exit;
        }

        $result = (new CustomerContactService($this->registry))->saveCustomerFromPanel(
            $chat_id,
            $name,
            $email,
            $phone,
            (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0)
        );
        if (empty($result['success'])) {
            echo json_encode(['success' => false, 'error' => $result['error'] ?? 'save_failed']);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

     
    public function deleteAllChats() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        if (!$this->canManagerDeleteChats($manager_id)) {
            echo json_encode(['success' => false, 'error' => 'Доступ запрещен. У вас нет прав на удаление диалогов.']);
            exit;
        }

        $model_session = new Session($this->registry);
        $db = $this->registry->get('db');
        $prefix = easychat_db_prefix();

        $delete_scope = isset(\EasyChat\Library\RequestContext::post()['delete_scope'])
            ? (string)\EasyChat\Library\RequestContext::post()['delete_scope']
            : '';
        $chat_ids = $this->selectedChatIdsFromPost(\EasyChat\Library\RequestContext::post()['chat_ids'] ?? null);
        if ($delete_scope === 'selected') {
            if (!$chat_ids) {
                echo json_encode(['success' => false, 'error' => 'Не выбраны диалоги для удаления. Обновите страницу и повторите действие.']);
                exit;
            }
        } else {
            $archived = isset(\EasyChat\Library\RequestContext::post()['archived']) && \EasyChat\Library\RequestContext::post()['archived'] == '1' ? 1 : 0;
            $scope = strtolower(trim((string)(\EasyChat\Library\RequestContext::post()['scope'] ?? ($archived ? 'archive' : 'all'))));
            if (!in_array($scope, ['unassigned', 'mine', 'all', 'archive'], true)) {
                $scope = $archived ? 'archive' : 'all';
            }
            $operator_id = in_array($scope, ['all', 'archive'], true)
                ? max(0, (int)(\EasyChat\Library\RequestContext::post()['operator_id'] ?? 0))
                : 0;
            $source_provider = $this->normalizeSourceProvider(\EasyChat\Library\RequestContext::post()['source_provider'] ?? 'all');
             
             
            $chat_ids = $model_session->getChatIdsForDeletion(
                $archived,
                $source_provider,
                $scope,
                $manager_id,
                $operator_id
            );
        }

        $result = (new ChatDeletionService($this->registry))->delete($chat_ids);
        if (empty($result['success'])) {
            http_response_code((int)($result['http_code'] ?? 409));
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

    public function markChatsRead() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);

        $chat_ids = array_slice(
            $this->selectedChatIdsFromPost(\EasyChat\Library\RequestContext::post()['chat_ids'] ?? null),
            0,
            200
        );
        if (!$chat_ids) {
            echo json_encode(['success' => false, 'error' => 'Выберите хотя бы один диалог.'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        try {
            $result = (new Message($this->registry))->markChatsAsReadByAdmin($chat_ids);
            $receipt_service = new MarketplaceReadReceiptService($this->registry);
            foreach ($result['chat_ids'] as $chat_id) {
                $receipt_service->enqueueForChat((int)$chat_id);
            }
        } catch (\Throwable $e) {
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Не удалось отметить диалоги прочитанными. Повторите действие.'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        echo json_encode([
            'success' => true,
            'dialogs' => count($result['chat_ids']),
            'messages' => (int)$result['messages'],
            'total_unread' => (new Message($this->registry))->getUnreadCountForManager($manager_id),
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    private function selectedChatIdsFromPost($value): array {
        $raw_ids = [];
        if (is_array($value)) {
            $raw_ids = $value;
        } elseif (is_string($value) && trim($value) !== '') {
            $raw_ids = preg_split('/[,\s]+/', trim($value));
        } elseif (is_numeric($value)) {
            $raw_ids = [$value];
        }

        $ids = [];
        foreach ($raw_ids as $id) {
            $id = (int)$id;
            if ($id > 0) {
                $ids[$id] = $id;
            }
        }

        return array_values($ids);
    }

    private function canManagerDeleteChats(int $manager_id): bool {
        if ($manager_id <= 0) {
            return false;
        }
        $manager = (new Manager($this->registry))->getManagerById($manager_id);
        return $manager
            && ((string)($manager['role'] ?? '') === 'admin'
                || (int)($manager['permission_delete_chats'] ?? 0) === 1);
    }

    private function externalHttp(): ExternalHttpClient {
        return new ExternalHttpClient($this->registry);
    }

    private function normalizeSourceProvider($source_provider) {
        $source_provider = strtolower(preg_replace('/[^a-z0-9_\-]/i', '', (string)$source_provider));
        return ($source_provider === '' || $source_provider === 'all') ? 'all' : $source_provider;
    }

    private function normalizeSyncDate($value): string {
        if (!is_scalar($value)) {
            return '';
        }

        $value = trim((string)$value);
        if ($value === '') {
            return '';
        }

        return (new AppClock($this->registry))->fromDateStringSql($value);
    }

    private function sourceFiltersPayload(array $counts, string $active_provider): array {
        $active_provider = $this->normalizeSourceProvider($active_provider);
        $model_setting = new Setting($this->registry);
        $settings = $model_setting->getAll();

        $all_providers = ChannelCatalog::filterOrder();
        $providers = ['all'];
        $configured_providers = ChannelCatalog::configuredProviders($settings, $this->registry);

        foreach ($all_providers as $provider) {
            $count = (int)($counts[$provider] ?? 0);
            if (!empty($configured_providers[$provider]) || $count > 0 || $provider === $active_provider) {
                $providers[] = $provider;
            }
        }

        foreach (array_keys($counts) as $provider) {
            $provider = $this->normalizeSourceProvider($provider);
            if ($provider !== '' && $provider !== 'all' && !in_array($provider, $providers, true)) {
                $count = (int)($counts[$provider] ?? 0);
                if (!empty($configured_providers[$provider]) || $count > 0 || $provider === $active_provider) {
                    $providers[] = $provider;
                }
            }
        }

        $filters = [];
        foreach ($providers as $provider) {
            $filters[] = [
                'provider' => $provider,
                'label' => $provider === 'all' ? 'Все' : $this->sourceLabel($provider),
                'count' => (int)($counts[$provider] ?? 0),
                'active' => $provider === $active_provider,
            ];
        }
        return $filters;
    }

    private function sourceLabel($source_provider) {
        $source_provider = $this->normalizeSourceProvider($source_provider);
        return ChannelCatalog::label($source_provider === 'all' ? 'widget' : $source_provider, true);
    }

    private function contactProfileUrl(array $chat_info) {
        $source_provider = strtolower((string)($chat_info['source_provider'] ?? ''));
        if (!in_array($source_provider, ['max', 'max-bot', 'vk', 'telegram', 'avito', 'youla', 'sms'], true) || empty($chat_info['external_contact_id'])) {
            return '';
        }

        $model_messenger = new MessengerModel($this->registry);
        $contact = $model_messenger->getContactById((int)$chat_info['external_contact_id']);
        $url = $contact ? (string)($contact['profile_url'] ?? '') : '';
        $normalized = (new CustomerProfileDetector())->normalizeUrl($url);
        return $normalized && $normalized['provider'] === $source_provider ? $normalized['profile_url'] : '';
    }

    private function contactAvatarPayload(array $chat_info, string $route): array {
        $payload = ['avatar_url' => '', 'avatar_version' => ''];
        if (empty($chat_info['external_contact_id'])) {
            return $payload;
        }

        $model_messenger = new MessengerModel($this->registry);
        $contact = $model_messenger->getContactById((int)$chat_info['external_contact_id']);
        if (!$contact) {
            return $payload;
        }

        $service = new ContactAvatarService($this->registry);
        return [
            'avatar_url' => $service->avatarUrlFromFields($contact, $route),
            'avatar_version' => $service->avatarVersionFromFields($contact),
        ];
    }

    private function outboundDeliveryPayload(array $result, array $chat_info): array {
        $provider = strtolower((string)($chat_info['source_provider'] ?? 'widget'));
        $label = $this->sourceLabel($provider);
        $success = !empty($result['success']);
        $payload = [
            'success' => $success,
            'provider' => $provider,
            'label' => $label,
            'retry_scheduled' => !empty($result['retry_scheduled']),
            'outcome_unknown' => !empty($result['outcome_unknown']),
        ];

        if ($success) {
            if (!empty($result['skipped'])) {
                $payload['skipped'] = (string)$result['skipped'];
            }
            if (!empty($result['skipped_attachments'])) {
                $payload['skipped_attachments'] = (int)$result['skipped_attachments'];
            }
            return $payload;
        }

        $payload['error_code'] = $this->safeOutboundErrorCode((string)($result['error'] ?? 'send_failed'));
        $sms_messages = [
            'sms_phone_rate_limited' => 'Сообщение сохранено, но не отправлено: достигнут суточный лимит SMS на этот номер.',
            'sms_device_missing' => 'Сообщение сохранено, но не отправлено: нет готового телефона с разрешением SMS.',
            'trial_limit_exceeded' => 'Сообщение сохранено, но отправка SMS недоступна без активной лицензии.',
            'sms_phone_invalid' => 'Сообщение сохранено, но не отправлено: номер телефона некорректен.',
        ];
        if ($provider === 'sms' && isset($sms_messages[$payload['error_code']])) {
            $payload['message'] = $sms_messages[$payload['error_code']];
        } elseif (!empty($payload['outcome_unknown'])) {
            $payload['message'] = 'Сообщение сохранено, но ' . $label . ' не подтвердил доставку. EasyChat не отправляет его повторно автоматически, чтобы покупатель не получил дубль.';
        } elseif (!empty($payload['retry_scheduled'])) {
            $payload['message'] = 'Сообщение сохранено. ' . $label . ' временно не принял доставку, EasyChat повторит отправку автоматически.';
        } else {
            $payload['message'] = 'Сообщение сохранено, но ' . $label . ' не принял доставку. Проверьте настройки интеграции и права доступа.';
        }
        return $payload;
    }

    private function safeOutboundErrorCode(string $error): string {
        $error = strtolower(trim($error));
        return preg_match('/^[a-z0-9_.-]{1,64}$/', $error) ? $error : 'send_failed';
    }

    private function attachmentKindFromUpload(array $file): string {
        $name = (string)($file['name'] ?? '');
        $mime = strtolower(trim((string)($file['type'] ?? '')));
        $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
        if ($extension === '' && $mime !== '') {
            $extension = AttachmentPolicy::extensionForMime($mime);
        }
        return AttachmentPolicy::isDocumentExtension($extension) || AttachmentPolicy::isDocumentMime($mime)
            ? 'document'
            : 'image';
    }

    private function uploadErrorText($error) {
        switch ((string)$error) {
            case 'file_size_exceeded':
            case 'file_size_denied':
                return 'Файл слишком большой.';
            case 'file_extension_denied':
                return 'Этот формат файла не поддерживается.';
            case 'file_mime_denied':
                return 'Содержимое файла не соответствует выбранному формату изображения или документа.';
            case 'file_save_failed':
            case 'upload_dir_failed':
                return 'Не удалось сохранить файл на сервере.';
            case 'too_many_attachments':
                return 'Выбрано слишком много вложений для этого канала.';
            case 'too_many_documents':
                return 'В одно сообщение можно прикрепить только один документ.';
            case 'mixed_attachments_unsupported':
                return 'Этот канал не позволяет смешивать фотографии и документы в одном сообщении.';
            case 'attachment_type_unsupported':
                return 'Этот тип вложения недоступен для текущего канала.';
            case 'attachment_count_mismatch':
                return 'Не все выбранные файлы дошли до сервера. Подборка сохранена в форме — попробуйте отправить еще раз.';
            default:
                return 'Не удалось загрузить файл.';
        }
    }

    public function image() {
         
        if (!isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            header("HTTP/1.1 403 Forbidden");
            exit;
        }

        $chat_id = isset(\EasyChat\Library\RequestContext::get()['chat_id']) ? (int)\EasyChat\Library\RequestContext::get()['chat_id'] : 0;
        $file = isset(\EasyChat\Library\RequestContext::get()['file']) ? basename(trim(\EasyChat\Library\RequestContext::get()['file'])) : '';
        $download_name = isset(\EasyChat\Library\RequestContext::get()['name']) ? basename(trim((string)\EasyChat\Library\RequestContext::get()['name'])) : '';

        if (!$chat_id || !$file) {
            header("HTTP/1.1 400 Bad Request");
            exit;
        }
        $this->releaseSessionLockForReadOnlyBinaryResponse();

        $file_path = DIR_IMAGE . 'chat_id_' . $chat_id . DS . $file;
        if (is_file($file_path)) {
            $response = $this->localAttachmentResponse($file_path, $file, $download_name);
            $this->sendLocalBinaryFile(
                $file_path,
                (string)$response['mime'],
                (string)$response['download_name'],
                !empty($response['force_attachment'])
            );
        } else {
            header("HTTP/1.1 404 Not Found");
            exit;
        }
    }

    public function retryVoiceTranscription() {
        header('Content-Type: application/json; charset=utf-8');
        if (\EasyChat\Library\RequestContext::method() !== 'POST') {
            http_response_code(405);
            echo json_encode(['success' => false, 'error' => 'method_not_allowed']);
            exit;
        }
        $this->requireCsrfTokenJson();
        $post = \EasyChat\Library\RequestContext::post();
        $result = (new VoiceMediaService($this->registry))->retry(
            (int)($post['attachment_id'] ?? 0),
            (int)($post['chat_id'] ?? 0)
        );
        if (empty($result['success'])) http_response_code(422);
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function avatar() {
        if (!isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            header("HTTP/1.1 403 Forbidden");
            exit;
        }
        $this->releaseSessionLockForReadOnlyBinaryResponse();

        $file = isset(\EasyChat\Library\RequestContext::get()['file']) ? basename(trim(\EasyChat\Library\RequestContext::get()['file'])) : '';
        if (!$file) {
            header("HTTP/1.1 400 Bad Request");
            exit;
        }

        $file_path = DIR_AVATARS . $file;
        if (is_file($file_path)) {
            $mime = 'image/png';
            if (function_exists('mime_content_type')) {
                $mime = mime_content_type($file_path);
            } else {
                $ext = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
                $mimes = [
                    'jpg'  => 'image/jpeg',
                    'jpeg' => 'image/jpeg',
                    'png'  => 'image/png',
                    'gif'  => 'image/gif',
                    'webp' => 'image/webp'
                ];
                if (isset($mimes[$ext])) {
                    $mime = $mimes[$ext];
                }
            }
            header("Content-Type: " . $mime);
            header("Content-Length: " . filesize($file_path));
            readfile($file_path);
            exit;
        } else {
            header("HTTP/1.1 404 Not Found");
            exit;
        }
    }

    public function contactAvatar() {
        if (!isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            header("HTTP/1.1 403 Forbidden");
            exit;
        }
        $this->releaseSessionLockForReadOnlyBinaryResponse();

        $file = isset(\EasyChat\Library\RequestContext::get()['file']) ? basename(trim(\EasyChat\Library\RequestContext::get()['file'])) : '';
        $info = (new ContactAvatarService($this->registry))->resolveServableFile($file);
        if (empty($info)) {
            header("HTTP/1.1 404 Not Found");
            exit;
        }

        $mtime = is_file($info['path']) ? (int)filemtime($info['path']) : 0;
        $this->sendPrivateImmutableBinaryCacheHeaders((string)$info['etag'], $mtime);

        $if_none_match = (string)(\EasyChat\Library\RequestContext::server()['HTTP_IF_NONE_MATCH'] ?? '');
        if ($if_none_match !== '' && hash_equals((string)$info['etag'], $if_none_match)) {
            http_response_code(304);
            exit;
        }

        header("Content-Type: " . $info['mime']);
        header('X-Content-Type-Options: nosniff');
        header("Content-Length: " . filesize($info['path']));
        readfile($info['path']);
        exit;
    }

     
    public function getUnreadCount() {
        header('Content-Type: application/json; charset=utf-8');
        if (!isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            echo json_encode(['unread' => 0]);
            exit;
        }
        $this->releaseSessionLockForReadOnlyJsonResponse();

        $model_message = new Message($this->registry);
        $unread = $model_message->getUnreadCountForManager(
            (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0)
        );

        $provider_alert = ['count' => 0, 'last_seen_at' => ''];
        if ((string)(\EasyChat\Library\RequestContext::session()['manager_role'] ?? '') === 'admin') {
            try {
                $summary = (new \EasyChat\Library\Messenger\AvitoAccessIncidentService($this->registry))->summary(1);
                $provider_alert = [
                    'count' => (int)($summary['count'] ?? 0),
                    'last_seen_at' => (string)($summary['last_seen_at'] ?? ''),
                    'key' => 'avito:' . (string)($summary['last_seen_at'] ?? ''),
                    'title' => 'Avito требует внимания',
                    'detail' => 'Avito ограничил Messenger API тарифом.',
                    'route' => 'index.php?route=integration&tab=avito',
                ];
                $max = (new \EasyChat\Library\Messenger\MaxPersonalIncidentService($this->registry))->summary();
                if (!empty($max['active'])) {
                    $provider_alert['count'] += 1;
                    $provider_alert['key'] = (string)($max['key'] ?? 'max');
                    $provider_alert['last_seen_at'] = (string)($max['last_seen_at'] ?? '');
                    $provider_alert['title'] = (string)($max['title'] ?? 'MAX требует внимания');
                    $provider_alert['detail'] = (string)($max['detail'] ?? 'Откройте настройки личного MAX.');
                    $provider_alert['route'] = 'index.php?route=integration&tab=max&subtab=personal';
                }
            } catch (\Throwable $e) {
                 
                 
            }
        }

        echo json_encode(['unread' => $unread, 'provider_alert' => $provider_alert]);
        exit;
    }

     
    public function presence() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        if (\EasyChat\Library\RequestContext::server()['REQUEST_METHOD'] !== 'POST' || !isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            echo json_encode(['success' => false]);
            exit;
        }

        $visibility = isset(\EasyChat\Library\RequestContext::post()['visibility']) ? (string)\EasyChat\Library\RequestContext::post()['visibility'] : '';
        $has_focus = isset(\EasyChat\Library\RequestContext::post()['has_focus']) ? (string)\EasyChat\Library\RequestContext::post()['has_focus'] : '0';
        if ($visibility !== 'visible' || $has_focus !== '1') {
            echo json_encode(['success' => true, 'updated' => false]);
            exit;
        }

        $presence = new PresenceService($this->registry);
        $updated = $presence->markManagerActive((int)\EasyChat\Library\RequestContext::session()['manager_id'], 'web_foreground');

        echo json_encode([
            'success' => true,
            'updated' => $updated,
            'online_threshold_seconds' => PresenceService::ONLINE_THRESHOLD_SECONDS
        ]);
        exit;
    }

     
    public function typing() {
        header('Content-Type: application/json; charset=utf-8');
        $this->requireCsrfTokenJson();

        if (isset(\EasyChat\Library\RequestContext::session()['manager_id'])) {
            $presence = new PresenceService($this->registry);
            $presence->markManagerActive((int)\EasyChat\Library\RequestContext::session()['manager_id'], 'web_typing', true);
        }
        
        $chat_id = isset(\EasyChat\Library\RequestContext::post()['chat_id']) ? (int)\EasyChat\Library\RequestContext::post()['chat_id'] : 0;
        if ($chat_id) {
            $model_session = new Session($this->registry);
            $manager_name = \EasyChat\Library\RequestContext::session()['manager_name'] ?? 'Оператор';
            $manager_img = \EasyChat\Library\RequestContext::session()['manager_img'] ?? '';
            $model_session->updateSession($chat_id, [
                'operator_typing_expire' => time() + 6,  
                'operator_typing_name'   => $manager_name,
                'operator_typing_image'  => $manager_img
            ]);
        }
        
        echo json_encode(['success' => true]);
        exit;
    }

    public function qr() {
        $data = isset(\EasyChat\Library\RequestContext::get()['data']) ? trim(\EasyChat\Library\RequestContext::get()['data']) : '';
        if ($data === '') {
            header("HTTP/1.1 400 Bad Request");
            exit;
        }

        require_once DIR_LIBRARY . DS . 'qrcode' . DS . 'loader.php';

        header('Content-Type: image/svg+xml');
        try {
            echo \splitbrain\phpQRCode\QRCode::svg($data);
        } catch (Exception $e) {
            header("HTTP/1.1 500 Internal Server Error");
        }
        exit;
    }

    private function formatSqlDate(string $value, string $format): string {
        $clock = new AppClock($this->registry);
        $timestamp = $clock->sqlToTimestamp($value);
        if ($timestamp === false) {
            return '';
        }

        return (new \DateTimeImmutable('@' . $timestamp))
            ->setTimezone(new \DateTimeZone($clock->timezoneName()))
            ->format($format);
    }

    private function nextConversationPayload(array $row, array $reply_capability): array {
        $clock = new AppClock($this->registry);
        $waiting_since = $clock->sqlToTimestamp((string)($row['last_date'] ?? ''));

        return [
            'chat_id' => (int)($row['chat_id'] ?? 0),
            'customer_name' => trim((string)($row['customer_name'] ?? '')) ?: 'Гость',
            'source_provider' => (string)($row['source_provider'] ?? 'widget'),
            'source_label' => $this->sourceLabel((string)($row['source_provider'] ?? 'widget')),
            'waiting_since' => $waiting_since === false ? 0 : (int)$waiting_since,
            'reply_capability' => $reply_capability,
        ];
    }

    private function operatorFiltersPayload(int $current_manager_id = 0): array {
        $operators = [];
        foreach ((new Manager($this->registry))->getActiveManagers() as $manager) {
            $manager_id = (int)($manager['id'] ?? 0);
            if ($manager_id <= 0) {
                continue;
            }
            $image = trim((string)($manager['image'] ?? ''));
            $operators[] = [
                'id' => $manager_id,
                'name' => (string)($manager['name'] ?: 'Оператор'),
                'image_url' => $image !== '' && is_file(DIR_AVATARS . $image)
                    ? HTTP_AVATARS . rawurlencode($image)
                    : '',
                'role' => (string)($manager['role'] ?? 'manager'),
                'is_me' => $manager_id === $current_manager_id,
            ];
        }
        return $operators;
    }

    private function assignmentPayload(array $snapshot, int $current_manager_id): array {
        $assignees = [];
        foreach (($snapshot['assignees'] ?? []) as $assignee) {
            if (!is_array($assignee)) {
                continue;
            }
            $image = trim((string)($assignee['image'] ?? ''));
            $assignees[] = [
                'id' => (int)($assignee['id'] ?? 0),
                'name' => (string)($assignee['name'] ?? 'Оператор'),
                'image_url' => $image !== '' && is_file(DIR_AVATARS . $image)
                    ? HTTP_AVATARS . rawurlencode($image)
                    : '',
                'role' => (string)($assignee['role'] ?? 'manager'),
                'active' => !empty($assignee['active']),
                'is_me' => (int)($assignee['id'] ?? 0) === $current_manager_id,
            ];
        }

        return [
            'assignees' => $assignees,
            'assigned_to_me' => !empty($snapshot['assigned_to_me']),
            'has_active_assignees' => !empty($snapshot['has_active_assignees']),
            'active_assignee_count' => (int)($snapshot['active_assignee_count'] ?? 0),
        ];
    }

     
    public function checkUpdate() {
        header('Content-Type: application/json; charset=utf-8');
        if ((\EasyChat\Library\RequestContext::server()['REQUEST_METHOD'] ?? '') !== 'POST') {
            http_response_code(405);
            echo json_encode(['success' => false, 'error' => 'Метод не поддерживается.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $this->requireCsrfTokenJson();

        $session = \EasyChat\Library\RequestContext::session();
        if (!isset($session['manager_id']) || !isset($session['manager_role']) || $session['manager_role'] !== 'admin') {
            http_response_code(403);
            echo json_encode(['success' => false, 'error' => 'Доступ запрещен']);
            exit;
        }

         
         
         
        $this->releaseSessionLockForReadOnlyJsonResponse();
        $snapshot = (new UpdateCenterService($this->registry))->snapshot(false);
        echo json_encode([
            'success' => !empty($snapshot['success']),
            'has_update' => !empty($snapshot['has_update']),
            'latest_version' => (string)($snapshot['latest_version'] ?? ''),
            'current_version' => (string)($snapshot['current_version'] ?? (defined('EASYCHAT_VERSION') ? EASYCHAT_VERSION : '1.0.0')),
            'release_notes_url' => (string)($snapshot['release_notes_url'] ?? ''),
            'verified' => !empty($snapshot['verified']),
            'stale' => !empty($snapshot['stale']),
            'error' => (string)($snapshot['error'] ?? ''),
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function androidUpdateStatus() {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        header('X-Content-Type-Options: nosniff');
        if ((\EasyChat\Library\RequestContext::server()['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
            http_response_code(405);
            echo json_encode(['success' => false, 'error' => 'method_not_allowed']);
            exit;
        }

        $session = \EasyChat\Library\RequestContext::session();
        $manager_id = (int)($session['manager_id'] ?? 0);
        $is_admin = (string)($session['manager_role'] ?? '') === 'admin';
         
         
         
        $this->releaseSessionLockForReadOnlyJsonResponse();
        $service = $this->androidUpdateNoticeService();
        echo json_encode(
            $service->status($manager_id, $is_admin),
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
        );
        exit;
    }

    public function deferAndroidUpdate() {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        header('X-Content-Type-Options: nosniff');
        $this->requireCsrfTokenJson();
        $post = \EasyChat\Library\RequestContext::post();
        $manager_id = (int)(\EasyChat\Library\RequestContext::session()['manager_id'] ?? 0);
        if (session_status() === PHP_SESSION_ACTIVE) {
            session_write_close();
        }
        $result = $this->androidUpdateNoticeService()->defer(
            $manager_id,
            trim((string)($post['action'] ?? '')),
            (int)($post['target_version_code'] ?? 0)
        );
        if (empty($result['success'])) {
            http_response_code(($result['error'] ?? '') === 'release_changed' ? 409 : 422);
        }
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    private function androidUpdateNoticeService(): AndroidUpdateNoticeService {
        $settings = new Setting($this->registry);
        $catalog = new AndroidReleaseCatalog($settings, function (string $url): array {
            return $this->externalHttp()->request('GET', $url, [
                'timeout' => 5,
                'trust_profile' => 'easychat_gateway',
            ]);
        });
        return new AndroidUpdateNoticeService($this->registry, $catalog);
    }
}
