<?php
namespace EasyChat\Controller;

use EasyChat\Library\AttachmentStorage;
use EasyChat\Library\Attachment\AttachmentBatchUploadService;
use EasyChat\Library\AttachmentPolicy;
use EasyChat\Library\AppClock;
use EasyChat\Library\CentralGatewayClient;
use EasyChat\Library\CustomerContactService;
use EasyChat\Library\Chat\ChatDeletionService;
use EasyChat\Library\CustomerMergeService;
use EasyChat\Library\CustomerWebPushNotifier;
use EasyChat\Library\I18n;
use EasyChat\Library\MessageTextRenderer;
use EasyChat\Library\Messenger\CommerceContextService;
use EasyChat\Library\Messenger\ContactAvatarService;
use EasyChat\Library\Messenger\AvitoHistorySyncService;
use EasyChat\Library\PresenceService;
use EasyChat\Library\SuggestedReplyAssistant;
use EasyChat\Library\Log;
use EasyChat\Library\Messenger\OutboundDispatchGuard;
use EasyChat\Library\Messenger\OutboundContactService;
use EasyChat\Library\Messenger\ScheduledMessageService;
use EasyChat\Library\Messenger\SmsPhoneNumber;
use EasyChat\Library\Messenger\DirectMessageIdempotency;
use EasyChat\Library\Messenger\DirectSmsDispatchOutbox;
use EasyChat\Library\Messenger\SmsInboundService;
use EasyChat\Library\Messenger\SmsInboundContactFilter;
use EasyChat\Library\Messenger\SmsInstallationScope;
use EasyChat\Library\Messenger\SmsDeliveryProjector;
use EasyChat\Library\Messenger\AttachmentCapabilities;
use EasyChat\Library\Messenger\ReplyCapability;
use EasyChat\Library\Messenger\ChannelCatalog;
use EasyChat\Library\Messenger\Marketplace\MarketplaceActionService;
use EasyChat\Library\Messenger\Marketplace\MarketplaceReadReceiptService;
use EasyChat\Library\Messenger\MessageReadReceiptService;
use EasyChat\Library\Notification\TrialLockGuard;
use EasyChat\Library\Support\ChatAssignmentService;
use EasyChat\Model\Setting;
use EasyChat\Model\Manager;
use EasyChat\Model\Session;
use EasyChat\Model\Message;
use EasyChat\Model\Customer;
use EasyChat\Model\MessageContext;
use EasyChat\Model\AppSession;
use EasyChat\Model\AppDevice;
use EasyChat\Model\ModerationToken;
use EasyChat\Model\AppPairing;
use EasyChat\Model\Reply;
use EasyChat\Model\SmsDevice;
use EasyChat\Model\SmsJob;
use EasyChat\Model\SmsJobEvent;
use EasyChat\Model\OutboundContact;
use EasyChat\Model\Messenger as MessengerModel;
use EasyChat\Model\ChatAssignment;
use EasyChat\Library\AndroidPushNotifier;
use EasyChat\Library\Security\ApiIncidentLogger;
use EasyChat\Library\Operator\OperatorAvatarService;
use EasyChat\Library\Operator\OperatorSelfService;
use EasyChat\Library\Operator\OperatorDraftService;
use EasyChat\Library\Voice\VoiceMediaService;
use EasyChat\View\AccessibleColor;

class Api extends Base {

    private $manager_id = 0;
    private $trial_expired = false;
    private $last_domain_status = [];

    protected $layouts = '';  

    public function __construct($registry) {
        parent::__construct($registry);
         
        ini_set('display_errors', 0);
        ini_set('display_startup_errors', 0);

         
         
        ini_set('log_errors', 0);
        ini_set('error_log', DIR_LOGS . '/api_error.log');

         
        set_exception_handler(function($exception) {
            $incident_id = ApiIncidentLogger::newId();
            $log = new Log('api_error');
            $log->write(ApiIncidentLogger::exceptionLine($exception, $incident_id));

            if (!headers_sent()) {
                http_response_code(500);
                header('Content-Type: application/json; charset=utf-8');
            }
            echo json_encode([
                'success' => 0,
                'error' => I18n::translate('api.internal_error', [], $this->resolveApiLocale()),
                'code' => 'internal_error',
                'message_key' => 'api.internal_error',
                'incident_id' => $incident_id
            ], JSON_UNESCAPED_UNICODE);
            exit;
        });

         
        register_shutdown_function(function() {
            $error = error_get_last();
            if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
                $incident_id = ApiIncidentLogger::newId();
                $log = new Log('api_error');
                $log->write(ApiIncidentLogger::fatalLine($error, $incident_id));

                if (!headers_sent()) {
                    http_response_code(500);
                    header('Content-Type: application/json; charset=utf-8');
                }
                echo json_encode([
                    'success' => 0,
                    'error' => I18n::translate('api.internal_error', [], $this->resolveApiLocale()),
                    'code' => 'internal_error',
                    'message_key' => 'api.internal_error',
                    'incident_id' => $incident_id
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
        });
    }

    public function index() {
        $this->jsonError('endpoint_not_found', 'api.endpoint_not_found', [], 404);
    }

     
    public function appLogin() {
        $json = $this->readJsonInput();

        $url       = trim($json['url']       ?? '');
        $token     = trim($json['token']     ?? '');
        $sig       = trim($json['sig']       ?? '');
        $pairing_id = trim($json['pairing_id'] ?? '');
        $pairing_secret = trim($json['pairing_secret'] ?? '');
        $fcm_token = trim($json['fcm_token'] ?? '');
        $moderation_token = trim($json['moderation_token'] ?? '');
        $moderation_purpose = trim($json['moderation_purpose'] ?? 'rustore_moderation');
        $app_version_code = (int)($json['app_version_code'] ?? 0);
        $app_version_name = trim((string)($json['app_version_name'] ?? $json['app_version'] ?? ''));
        $device_label = trim((string)($json['device_label'] ?? ''));
        $auth_version = (int)($json['auth_version'] ?? 1);
        $installation_id = trim((string)($json['installation_id'] ?? ''));
        $device_transfer_token = trim((string)($json['device_transfer_token'] ?? ''));
        $device_client = [
            'app_version_code' => $app_version_code,
            'app_version_name' => $app_version_name,
            'device_label' => $device_label,
            'manufacturer' => (string)($json['manufacturer'] ?? ''),
            'model' => (string)($json['model'] ?? ''),
            'android_sdk' => (int)($json['android_sdk'] ?? 0),
        ];

        $model_setting = new Setting($this->registry);
        if ($model_setting->get('chat_api_auth_logging', '0') === '1') {
            $log = new Log('api_auth');
        } else {
            $log = new class { public function write($message) {} };
        }
        $log->write("=== Login Attempt ===");
        $log->write("Input parameters: URL: '$url', Token: '" . $this->maskSecret($token) . "', Sig: '" . $this->maskSecret($sig) . "', Pairing ID: '" . $this->maskSecret($pairing_id) . "'");

        $model_manager = new Manager($this->registry);
        $model_appsession = new AppSession($this->registry);

         
        $model_manager->runMigrations();

        $authorized_manager = null;
        $used_pairing_row = null;

        if ($pairing_id !== '' && $pairing_secret !== '') {
            $model_pairing = new AppPairing($this->registry);
            $pairing_result = $model_pairing->validateBySecret($pairing_id, $pairing_secret);

            if (empty($pairing_result['success'])) {
                $reason = (string)($pairing_result['error'] ?? 'invalid');
                $log->write("Pairing token rejected: {$reason}");
                $this->jsonError(
                    $this->pairingErrorCode($reason),
                    $this->pairingMessageKey($reason),
                    [],
                    $this->pairingStatusCode($reason)
                );
                return;
            }

            $authorized_manager = $model_manager->getManagerById((int)$pairing_result['manager_id']);
            if (!$authorized_manager || (int)$authorized_manager['status'] !== 1) {
                $log->write("Pairing token rejected: manager disabled or missing.");
                $this->jsonError('invalid_pairing_token', 'api.invalid_pairing_token');
                return;
            }

            $used_pairing_row = $pairing_result['row'];
            $log->write("Pairing token accepted for Manager ID: " . (int)$authorized_manager['id']);
        } else {
             
            $master_token = $model_setting->get('chat_app_master_token');

            if (!$master_token || !$token || !hash_equals((string)$master_token, (string)$token)) {
                $log->write("Master token mismatch. Expected: '" . $this->maskSecret($master_token) . "', Got: '" . $this->maskSecret($token) . "'");
                $this->jsonError('invalid_token', 'api.invalid_token');
                return;
            }

             
             
            $active_managers = $model_manager->getActiveManagers();
            $log->write("Active managers count: " . count($active_managers));
            foreach ($active_managers as $manager) {
                $key = md5($manager['salt'] . $manager['password']);
                $normalized_url = rtrim($url, '/');
                $expected_sig = hash_hmac('sha256', $normalized_url . '|' . $token, $key);

                $log->write("Checking Manager ID: {$manager['id']} ({$manager['username']})");
                $log->write("Expected sig: '" . $this->maskSecret($expected_sig) . "'");
                $log->write("Received sig: '" . $this->maskSecret($sig) . "'");

                if (hash_equals($expected_sig, $sig)) {
                    $authorized_manager = $manager;
                    $log->write("Match found! Manager ID: {$manager['id']}");
                    break;
                }
            }

            if (!$authorized_manager) {
                $log->write("Signature verification failed or manager disabled.");
                $this->jsonError('invalid_signature', 'api.invalid_signature');
                return;
            }
        }

        $manager_id = (int)$authorized_manager['id'];
        $log->write("Successfully authorized Manager ID: {$manager_id}");

        $model_device = new AppDevice($this->registry);
        $device_auth = $auth_version >= 2 && $pairing_id !== '';
        if ($device_auth && !$model_device->isValidInstallationId($installation_id)) {
            $this->jsonError('installation_id_invalid', 'api.bad_request', [], 422);
            return;
        }

        if ($moderation_token !== '') {
            if (!$this->isAllowedModerationPurpose($moderation_purpose)) {
                $log->write("Store moderation token rejected: unsupported purpose '{$moderation_purpose}'");
                $this->jsonError('invalid_moderation_qr', 'api.invalid_moderation_qr');
                return;
            }

            $model_moderation = new ModerationToken($this->registry);
            $moderation_result = $model_moderation->validateFreshToken(
                $moderation_token,
                $moderation_purpose,
                $app_version_code
            );

            if (!$moderation_result['success']) {
                $log->write("Store moderation token rejected ({$moderation_purpose}): " . $moderation_result['error']);
                $this->jsonError('invalid_moderation_qr', 'api.invalid_moderation_qr');
                return;
            }
        }

        $domain_status = $this->resolveCentralDomainStatus(false);
        if ($domain_status['state'] === 'admin_blocked') {
            $log->write('Central gateway rejected appLogin: domain is blocked.');
            $this->jsonDomainBlocked();
            return;
        }
        if ($domain_status['state'] === 'trial_expired') {
            $log->write('Central gateway rejected appLogin: trial limit exceeded.');
            $this->jsonTrialExpired($domain_status);
            return;
        }

        $existing_device = $device_auth ? $model_device->getByInstallationId($installation_id) : [];
        if (!$device_auth && $fcm_token !== '') {
            $latest_fcm_session = $model_appsession->getLatestSessionByFcmToken($fcm_token);
            $latest_device_id = (int)($latest_fcm_session['app_device_id'] ?? 0);
            $latest_device = $latest_device_id > 0 ? $model_device->getById($latest_device_id) : [];
            if ($latest_device && (string)($latest_device['identity_status'] ?? '') === 'verified') {
                 
                 
                 
                $this->jsonOut([
                    'success' => 0,
                    'code' => 'client_upgrade_required',
                    'error' => 'Обновите приложение EasyChat и подключите телефон новым QR-кодом.',
                ], 409);
                return;
            }
        }
        $transfer = $existing_device && (int)$existing_device['manager_id'] !== $manager_id;
        if ($transfer && !$this->verifyDeviceTransferToken(
            $device_transfer_token,
            $pairing_id,
            (string)$existing_device['installation_hash'],
            (int)$existing_device['manager_id'],
            $manager_id
        )) {
            $current_manager = $model_manager->getManagerById((int)$existing_device['manager_id']);
            $this->jsonOut([
                'success' => 0,
                'code' => 'device_transfer_confirmation_required',
                'error' => 'Телефон уже подключён к другому оператору. Подтвердите перенос на телефоне.',
                'device_transfer_token' => $this->createDeviceTransferToken(
                    $pairing_id,
                    (string)$existing_device['installation_hash'],
                    (int)$existing_device['manager_id'],
                    $manager_id
                ),
                'current_manager_name' => (string)($current_manager['name'] ?? $current_manager['username'] ?? 'Другой оператор'),
                'new_manager_name' => (string)($authorized_manager['name'] ?? $authorized_manager['username'] ?? 'Новый оператор'),
                'device_label' => (string)($existing_device['display_name'] ?: $existing_device['device_label']),
            ], 409);
            return;
        }

        if ($transfer) {
            $db = $this->registry->get('db');
            $in_flight = $db->query(
                "SELECT COUNT(*) AS total FROM `" . easychat_db_prefix() . "sms_jobs`
                 WHERE `app_device_id` = " . (int)$existing_device['device_id'] . "
                   AND `status` = 'reserved'"
            );
            if ($in_flight && (int)$in_flight->row['total'] > 0) {
                $this->jsonOut([
                    'success' => 0,
                    'code' => 'device_transfer_sms_in_flight',
                    'error' => 'Телефон завершает отправку SMS. Повторите перенос через несколько минут.',
                    'retryable' => 1,
                ], 409);
                return;
            }
        }

        $session_token = '';
        $refresh_token = '';
        $access_expires_in = 0;
        $refresh_expires_in = 0;
        $device = [];
        $new_session_id = 0;
        $db = $this->registry->get('db');
        $transaction_open = false;
        try {
            $db->query('START TRANSACTION');
            $transaction_open = true;

            if ($used_pairing_row) {
                $consume = $model_pairing->consumeBySecret($pairing_id, $pairing_secret, $fcm_token);
                if (empty($consume['success']) || (int)$consume['manager_id'] !== $manager_id) {
                    $db->query('ROLLBACK');
                    $transaction_open = false;
                    $reason = (string)($consume['error'] ?? 'invalid');
                    $this->jsonError(
                        $this->pairingErrorCode($reason),
                        $this->pairingMessageKey($reason),
                        [],
                        $this->pairingStatusCode($reason)
                    );
                    return;
                }
            }

            if ($device_auth) {
                $previous_fcm_session = $fcm_token !== '' ? $model_appsession->getLatestSessionByFcmToken($fcm_token) : [];
                $device = $model_device->upsertVerified($installation_id, $manager_id, $device_client, $transfer);
                $session = $model_appsession->createDeviceSession(
                    (int)$device['device_id'],
                    $manager_id,
                    $fcm_token,
                    $device_client
                );
                $new_session_id = (int)$session['session_id'];
                $session_token = (string)$session['access_token'];
                $refresh_token = (string)$session['refresh_token'];
                $access_expires_in = (int)$session['access_expires_in'];
                $refresh_expires_in = (int)$session['refresh_expires_in'];
                $model_appsession->revokeDeviceSessions(
                    (int)$device['device_id'],
                    $transfer ? 'device_transferred' : 'device_repaired',
                    $new_session_id
                );
                if ($transfer) {
                    $reserved_after_revoke = $db->query(
                        "SELECT `job_id` FROM `" . easychat_db_prefix() . "sms_jobs`
                         WHERE `app_device_id` = " . (int)$device['device_id'] . "
                           AND `status` = 'reserved'
                         LIMIT 1 FOR UPDATE"
                    );
                    if ($reserved_after_revoke->num_rows) {
                        $db->query('ROLLBACK');
                        $transaction_open = false;
                        $this->jsonOut([
                            'success' => 0,
                            'code' => 'device_transfer_sms_in_flight',
                            'error' => 'Телефон завершает отправку SMS. Повторите перенос через несколько минут.',
                            'retryable' => 1,
                        ], 409);
                        return;
                    }
                }

                 
                 
                $db->query(
                    "UPDATE `" . easychat_db_prefix() . "sms_devices` SET
                        `app_device_id` = " . (int)$device['device_id'] . ",
                        `app_session_id` = {$new_session_id},
                        `manager_id` = {$manager_id}" .
                        ($transfer ? ",
                        `admin_requested` = 0, `operator_consent` = 'none',
                        `status` = 'not_requested',
                        `inbound_admin_requested` = 0,
                        `inbound_operator_consent` = 'none',
                        `inbound_status` = 'not_requested'" : "") . ",
                        `date_modified` = NOW()
                     WHERE `app_device_id` = " . (int)$device['device_id']
                );

                 
                 
                if ($previous_fcm_session
                    && (int)$previous_fcm_session['manager_id'] === $manager_id
                    && (int)($previous_fcm_session['app_device_id'] ?? 0) !== (int)$device['device_id']
                ) {
                    $legacy_device_id = (int)($previous_fcm_session['app_device_id'] ?? 0);
                    $db->query(
                        "UPDATE IGNORE `" . easychat_db_prefix() . "sms_devices` SET
                            `app_device_id` = " . (int)$device['device_id'] . ",
                            `app_session_id` = {$new_session_id}
                         WHERE `app_session_id` = " . (int)$previous_fcm_session['session_id']
                    );
                    $db->query(
                        "UPDATE `" . easychat_db_prefix() . "sms_jobs`
                         SET `app_device_id` = " . (int)$device['device_id'] . "
                         WHERE `app_session_id` = " . (int)$previous_fcm_session['session_id']
                    );
                    if ($legacy_device_id > 0) {
                        $db->query(
                            "UPDATE `" . easychat_db_prefix() . "app_devices`
                             SET `status` = 'history', `revoked_at` = NOW(),
                                 `revoke_reason` = 'adopted_by_verified_identity',
                                 `date_modified` = NOW()
                             WHERE `device_id` = {$legacy_device_id} AND `identity_status` = 'legacy'"
                        );
                    }
                    $model_appsession->revokeSessionById(
                        (int)$previous_fcm_session['session_id'],
                        'adopted_by_verified_identity'
                    );
                }

                if ($transfer) {
                    $db->query(
                        "UPDATE `" . easychat_db_prefix() . "sms_jobs`
                         SET `status` = 'failed',
                             `last_error` = 'device_reassigned',
                             `last_stage` = 'device_reassigned',
                             `last_stage_code` = 'device_reassigned',
                             `last_stage_at` = NOW(),
                             `date_modified` = NOW()
                         WHERE `app_device_id` = " . (int)$device['device_id'] . "
                           AND `status` = 'pending'"
                    );
                } else {
                    $db->query(
                        "UPDATE `" . easychat_db_prefix() . "sms_jobs`
                         SET `app_session_id` = {$new_session_id}
                         WHERE `app_device_id` = " . (int)$device['device_id'] . "
                           AND `status` = 'pending'"
                    );
                }
                $model_device->recordEvent(
                    (int)$device['device_id'],
                    $manager_id,
                    $transfer ? 'transferred' : ($existing_device ? 'repaired' : 'paired'),
                    '',
                    [
                        'from_manager_id' => $transfer ? (int)$existing_device['manager_id'] : $manager_id,
                        'to_manager_id' => $manager_id,
                        'session_id' => $new_session_id,
                        'app_version_code' => $app_version_code,
                    ]
                );
            } else {
                 
                 
                $session_token = bin2hex(random_bytes(32));
                if ($fcm_token) {
                    $model_appsession->deleteSessionsByFcmToken($fcm_token);
                }
                $model_appsession->createSession($session_token, $fcm_token, $manager_id, $device_client);
            }

            $db->query('COMMIT');
            $transaction_open = false;
        } catch (\Throwable $e) {
            if ($transaction_open) {
                try { $db->query('ROLLBACK'); } catch (\Throwable $ignored) {}
            }
            throw $e;
        }

        $presence = new PresenceService($this->registry);
        $presence->markManagerActive($manager_id, 'android_login', true);

         
         
         
        if ($moderation_token !== '') {
            $model_moderation->markUsed($moderation_token, $fcm_token);
        }

         
        if ($fcm_token) {
            $registration = $this->registerFcmTokenOnCentralGateway($fcm_token, $model_setting, $log, $manager_id);
            if (!empty($registration['registered'])) {
                $model_appsession->markFcmTokenSyncedBySessionToken($session_token);
            }
        }

        $this->manager_id = $manager_id;

        $this->jsonOut([
            'success' => 1,
            'session_token' => $session_token,
            'access_token' => $session_token,
            'refresh_token' => $refresh_token,
            'access_expires_in' => $access_expires_in,
            'refresh_expires_in' => $refresh_expires_in,
            'auth_version' => $device_auth ? 2 : 1,
            'device' => $device ? [
                'device_id' => (string)$device['public_id'],
                'label' => (string)($device['display_name'] ?: $device['device_label']),
                'manager_id' => (int)$device['manager_id'],
                'sms_scope_credential' => $model_device->scopeCredential($device),
            ] : null,
            'permission_delete_chats' => (int)($authorized_manager['permission_delete_chats'] ?? 0),
            'chat_widget_color' => $model_setting->get('chat_widget_color', '#6366f1'),
            'ui_theme' => $this->appUiTheme($model_setting),
            'capabilities' => $this->buildAppCapabilities($manager_id),
            'operator_profile' => (new OperatorSelfService($this->registry))->profile($manager_id),
        ]);
    }

    public function appGetOperatorProfile() {
         
         
        $manager_id = $this->checkAppToken(false, true);
        if (!$manager_id) return;
        $profile = (new OperatorSelfService($this->registry))->profile((int)$manager_id);
        if (!$profile) {
            $this->jsonOut(['success' => 0, 'error' => 'operator_not_found'], 404);
            return;
        }
        $this->jsonOut(['success' => 1, 'profile' => $profile]);
    }

    public function appChangeOperatorPassword() {
        $manager_id = $this->checkAppToken(false, true);
        if (!$manager_id) return;
        if (\EasyChat\Library\RequestContext::method() !== 'POST') {
            $this->jsonOut(['success' => 0, 'error' => 'method_not_allowed'], 405);
            return;
        }
        $json = $this->readJsonInput();
        $session = $this->currentAppSession();
        $result = (new OperatorSelfService($this->registry))->changePassword(
            (int)$manager_id,
            (string)($json['current_password'] ?? ''),
            (string)($json['new_password'] ?? ''),
            (string)($json['password_confirmation'] ?? ''),
            (string)(\EasyChat\Library\RequestContext::server()['REMOTE_ADDR'] ?? ''),
            (int)($session['session_id'] ?? 0)
        );
        if (empty($result['success'])) {
            unset($result['password_hash']);
            $this->jsonOut($result, ($result['code'] ?? '') === 'password_rate_limited' ? 429 : 422);
            return;
        }
        unset($result['password_hash']);
        $result['message'] = 'password_changed_other_sessions_revoked';
        $this->jsonOut($result);
    }

    public function appUploadOperatorAvatar() {
        $manager_id = $this->checkAppToken(false, true);
        if (!$manager_id) return;
        if (\EasyChat\Library\RequestContext::method() !== 'POST') {
            $this->jsonOut(['success' => 0, 'error' => 'method_not_allowed'], 405);
            return;
        }
        $files = \EasyChat\Library\RequestContext::files();
        if (empty($files['avatar']) || !is_array($files['avatar'])) {
            $this->jsonOut(['success' => 0, 'error' => 'avatar_required'], 422);
            return;
        }
        $result = (new OperatorAvatarService($this->registry))->upload((int)$manager_id, $files['avatar']);
        if (empty($result['success'])) {
            $result['success'] = 0;
            $this->jsonOut($result, 422);
            return;
        }
        $result['profile'] = (new OperatorSelfService($this->registry))->profile((int)$manager_id);
        $this->jsonOut($result);
    }

    public function appRemoveOperatorAvatar() {
        $manager_id = $this->checkAppToken(false, true);
        if (!$manager_id) return;
        if (\EasyChat\Library\RequestContext::method() !== 'POST') {
            $this->jsonOut(['success' => 0, 'error' => 'method_not_allowed'], 405);
            return;
        }
        $result = (new OperatorAvatarService($this->registry))->remove((int)$manager_id);
        if (empty($result['success'])) {
            $result['success'] = 0;
            $this->jsonOut($result, 422);
            return;
        }
        $result['profile'] = (new OperatorSelfService($this->registry))->profile((int)$manager_id);
        $this->jsonOut($result);
    }

    private function isAllowedModerationPurpose($purpose) {
        return in_array($purpose, ['rustore_moderation', 'appgallery_moderation'], true);
    }

    private function pairingErrorCode($reason) {
        if ($reason === 'expired') return 'pairing_expired';
        if ($reason === 'already_used') return 'pairing_already_used';
        if ($reason === 'rate_limited') return 'pairing_rate_limited';
        return 'invalid_pairing_token';
    }

    private function pairingMessageKey($reason) {
        if ($reason === 'expired') return 'api.pairing_expired';
        if ($reason === 'already_used') return 'api.pairing_already_used';
        if ($reason === 'rate_limited') return 'api.pairing_rate_limited';
        return 'api.invalid_pairing_token';
    }

    private function pairingStatusCode($reason) {
        if ($reason === 'expired') return 410;
        if ($reason === 'already_used') return 409;
        if ($reason === 'rate_limited') return 429;
        return 401;
    }

    private function createDeviceTransferToken($pairing_id, $installation_hash, $from_manager_id, $to_manager_id) {
        $payload = [
            'v' => 1,
            'pairing_id' => (string)$pairing_id,
            'installation_hash' => (string)$installation_hash,
            'from_manager_id' => (int)$from_manager_id,
            'to_manager_id' => (int)$to_manager_id,
            'expires_at' => time() + 300,
            'nonce' => bin2hex(random_bytes(12)),
        ];
        $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
        $encoded = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
        $signature = hash_hmac('sha256', $encoded, $this->deviceTransferSecret());
        return $encoded . '.' . $signature;
    }

    private function verifyDeviceTransferToken($token, $pairing_id, $installation_hash, $from_manager_id, $to_manager_id) {
        $parts = explode('.', (string)$token, 2);
        if (count($parts) !== 2 || !preg_match('/^[A-Za-z0-9_-]+$/D', $parts[0]) || !preg_match('/^[a-f0-9]{64}$/D', $parts[1])) {
            return false;
        }
        $expected = hash_hmac('sha256', $parts[0], $this->deviceTransferSecret());
        if (!hash_equals($expected, $parts[1])) return false;
        $encoded = strtr($parts[0], '-_', '+/');
        $encoded .= str_repeat('=', (4 - strlen($encoded) % 4) % 4);
        $raw = base64_decode($encoded, true);
        $payload = $raw !== false ? json_decode($raw, true) : null;
        return is_array($payload)
            && (int)($payload['v'] ?? 0) === 1
            && hash_equals((string)($payload['pairing_id'] ?? ''), (string)$pairing_id)
            && hash_equals((string)($payload['installation_hash'] ?? ''), (string)$installation_hash)
            && (int)($payload['from_manager_id'] ?? 0) === (int)$from_manager_id
            && (int)($payload['to_manager_id'] ?? 0) === (int)$to_manager_id
            && (int)($payload['expires_at'] ?? 0) >= time();
    }

    private function deviceTransferSecret() {
        $settings = new Setting($this->registry);
        $secret = trim((string)$settings->get('app_device_transfer_secret', ''));
        if (!preg_match('/^[a-f0-9]{64}$/D', $secret)) {
            $candidate = bin2hex(random_bytes(32));
            $db = $this->registry->get('db');
            $db->query(
                "UPDATE `" . easychat_db_prefix() . "settings`
                 SET `value` = '" . $db->escape($candidate) . "'
                 WHERE `key` = 'app_device_transfer_secret'
                   AND `value` = '" . $db->escape($secret) . "'"
            );
            $db->query(
                "INSERT INTO `" . easychat_db_prefix() . "settings` (`key`, `value`)
                 VALUES ('app_device_transfer_secret', '" . $db->escape($candidate) . "')
                 ON DUPLICATE KEY UPDATE `value` = `value`"
            );
            $secret = trim((string)$settings->get('app_device_transfer_secret', ''));
        }
        if (!preg_match('/^[a-f0-9]{64}$/D', $secret)) {
            throw new \RuntimeException('app_device_transfer_secret_unavailable');
        }
        return $secret;
    }

    private function maskSecret($value) {
        $value = (string)$value;
        if ($value === '') return '';
        if (strlen($value) <= 8) return '***';
        return substr($value, 0, 4) . '...' . substr($value, -4);
    }

     
    public function appUpdateFcmToken() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $fcm_token = trim($json['fcm_token'] ?? '');
        if ($fcm_token === '') {
            $this->jsonError('fcm_empty', 'api.fcm_empty', [], 422);
            return;
        }

        $headers = $this->getAllHeaders();
        $session_token = $headers['X-App-Token'] ?? $headers['x-app-token'] ?? '';
        $model_setting = new Setting($this->registry);
        $model_appsession = new AppSession($this->registry);

        if ($model_setting->get('chat_api_auth_logging', '0') === '1') {
            $log = new Log('api_auth');
        } else {
            $log = new class { public function write($message) {} };
        }

         
         
         
        $model_appsession->deleteSessionsByFcmTokenExcept($fcm_token, $session_token);
        $model_appsession->updateFcmTokenBySessionToken($session_token, $fcm_token);
         
         
        if (array_key_exists('app_version_code', $json)
            || array_key_exists('app_version_name', $json)
            || array_key_exists('app_version', $json)
            || array_key_exists('device_label', $json)
        ) {
            $model_appsession->updateClientMetadataBySessionToken($session_token, [
                'app_version_code' => (int)($json['app_version_code'] ?? 0),
                'app_version_name' => (string)($json['app_version_name'] ?? $json['app_version'] ?? ''),
                'device_label' => (string)($json['device_label'] ?? ''),
            ]);
        }
        $registration = $this->registerFcmTokenOnCentralGateway($fcm_token, $model_setting, $log, $manager_id);
        if (empty($registration['success'])) {
            $this->jsonError('fcm_registration_failed', 'api.fcm_registration_failed', [], 502);
            return;
        }
        if (!empty($registration['registered'])) {
            $model_appsession->markFcmTokenSyncedBySessionToken($session_token);
        }

        $this->jsonOut(['success' => 1]);
    }

    




    public function appAcknowledgePushSync() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $signal_id = strtolower(trim((string)($json['signal_id'] ?? '')));
        if (!preg_match('/^psig_[a-f0-9]{24}$/D', $signal_id)) {
            $this->jsonError('push_signal_invalid', 'api.bad_request', [], 422);
            return;
        }

        $model_setting = new Setting($this->registry);
        if ((string)$model_setting->get('chat_easychat_services_consent', '0') !== '1') {
             
             
            $this->jsonOut(['success' => 1, 'status' => 'disabled']);
            return;
        }
        $central_api = CentralGatewayClient::normalizeBaseUrl($model_setting->get('chat_central_api'));
        if ($central_api === '') {
            $this->jsonOut(['success' => 1, 'status' => 'disabled']);
            return;
        }
        $install_id = CentralGatewayClient::ensureInstallId($model_setting);
        $domain = \EasyChat\Library\RequestContext::server()['HTTP_HOST'] ?? 'localhost';
        $payload = CentralGatewayClient::withBaseDomain($model_setting, [
            'domain' => $domain,
            'signal_id' => $signal_id,
        ]);
        $license = trim((string)$model_setting->get('chat_license'));
        $body = $license !== ''
            ? CentralGatewayClient::signedBody($license, $install_id, $payload)
            : CentralGatewayClient::signedUnlicensedBody(
                CentralGatewayClient::ensureInstallSecret($model_setting),
                $install_id,
                $payload
            );
        $result = CentralGatewayClient::postJson(
            $central_api,
            '/api/v1/push/acks',
            $body,
            5,
            $this->registry
        );
        $central_status = (int)($result['status'] ?? 0);
        if (!in_array($central_status, array(200, 404, 422), true)) {
            $this->jsonError('push_ack_failed', 'api.temporarily_unavailable', [], 502);
            return;
        }
         
         
         
        $this->jsonOut(array(
            'success' => 1,
            'status' => $central_status === 200 ? 'confirmed' : 'ignored',
        ));
    }

    public function appRefreshToken() {
        $json = $this->readJsonInput();
        $refresh_token = trim((string)($json['refresh_token'] ?? ''));
        $result = (new AppSession($this->registry))->refreshDeviceSession($refresh_token);
        if (empty($result['success'])) {
            $code = (string)($result['error'] ?? 'refresh_token_invalid');
            $this->jsonOut([
                'success' => 0,
                'code' => $code,
                'error' => $code === 'refresh_token_reused'
                    ? 'Сессия отозвана: обнаружено повторное использование refresh token.'
                    : 'Срок авторизации истёк. Подключите приложение заново.',
            ], 401);
            return;
        }
        $this->jsonOut([
            'success' => 1,
            'access_token' => (string)$result['access_token'],
            'refresh_token' => (string)$result['refresh_token'],
            'access_expires_in' => (int)$result['access_expires_in'],
            'refresh_expires_in' => (int)$result['refresh_expires_in'],
        ]);
    }

    public function appLogout() {
        $token = $this->appAccessTokenFromHeaders();
        if ($token !== '') {
            (new AppSession($this->registry))->revokeSessionByToken($token, 'operator_logout');
        }
        $this->jsonOut(['success' => 1]);
    }

     
    public function appCheckLicenseStatus() {
        $manager_id = $this->checkAppToken(false, true);
        if (!$manager_id) return;

        $domain_status = $this->resolveCentralDomainStatus(true);
        if ($domain_status['state'] === 'admin_blocked') {
            $headers = $this->getAllHeaders();
            $token = $headers['X-App-Token'] ?? $headers['x-app-token'] ?? '';
            if ($token !== '') {
                $model_appsession = new AppSession($this->registry);
                $model_appsession->deleteSessionByToken($token);
            }
            $this->jsonDomainBlocked();
            return;
        }

        $this->jsonOut([
            'success' => 1,
            'blocked' => false,
            'trial_expired' => $domain_status['state'] === 'trial_expired' ? 1 : 0,
            'code' => $domain_status['state'] === 'trial_expired' ? 'trial_limit_exceeded' : '',
            'message_key' => $domain_status['state'] === 'trial_expired' ? 'auth.trial_limit_exceeded' : '',
            'mode' => $domain_status['mode'] ?? '',
            'trial_push_used' => (int)($domain_status['trial_push_used'] ?? 0),
            'trial_push_limit' => (int)($domain_status['trial_push_limit'] ?? 300),
            'purchase_url' => $domain_status['purchase_url'] ?? 'https://easychat24.ru/pricing/',
        ]);
    }

     
    public function appGetChats() {
        $get = \EasyChat\Library\RequestContext::get();
        $manager_id = $this->checkAppToken($this->shouldTouchAndroidPresence($get));
        if (!$manager_id) return;

        $model_session = new Session($this->registry);

        $page  = max(1, (int)($get['page'] ?? 1));
        $updated_after = $this->normalizeSyncDate($get['updated_after'] ?? '');
        $source_provider = $this->normalizeSourceProviderFilter($get['source_provider'] ?? 'all');
        $scope = strtolower(trim((string)($get['scope'] ?? 'all')));
        if (!in_array($scope, ['unassigned', 'mine', 'all', 'archive'], true)) {
            $scope = 'all';
        }
        $operator_id = in_array($scope, ['all', 'archive'], true)
            ? max(0, (int)($get['operator_id'] ?? 0))
            : 0;
        $include_meta = (string)($get['meta'] ?? '1') !== '0';
        $limit = $updated_after !== '' ? min(500, max(1, (int)($get['limit'] ?? 200))) : 20;
        $query_limit = $updated_after !== '' ? $limit + 1 : $limit;
        $start = $updated_after !== '' ? 0 : (($page - 1) * $limit);

        $query_scope = $updated_after !== '' ? 'sync' : $scope;
        $rows = $model_session->getChats(
            $start,
            $query_limit,
            $scope === 'archive',
            $updated_after,
            $source_provider,
            $query_scope,
            $manager_id,
            $updated_after !== '' ? 0 : $operator_id
        );
        $has_more = $updated_after !== '' && count($rows) > $limit;
        if ($has_more) {
            array_pop($rows);
        }
        $chats = [];
        $clock = new AppClock($this->registry);
        $avatar_service = new ContactAvatarService($this->registry);
        $reply_service = new ReplyCapability($this->registry);
        $commerce_contexts = (new CommerceContextService($this->registry))->contextsForChatRows($rows, 'api/appGetCommerceImage');
        $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);
            $customer_name = !empty($row['customer_name']) ? $row['customer_name'] : I18n::translate('widget.guest', [], $this->resolveApiLocale());
            $last_text = $row['last_message_enc'] ? urldecode($row['last_message_enc']) : '';
            
            if (!$last_text && !empty($row['last_image'])) {
                $last_text = I18n::translate('api.image_label', [], $this->resolveApiLocale());
            }

            if (mb_strlen($last_text) > 60) {
                $last_text = mb_substr($last_text, 0, 60) . '...';
            }

            $chats[] = [
                'chat_id'       => (int)$row['chat_id'],
                'customer_name' => $customer_name,
                'primary_contacts' => [
                    'email' => $contacts['email'],
                    'phone' => $contacts['phone'],
                ],
                'source_provider' => $this->normalizeSourceProvider($row['source_provider'] ?? 'widget'),
                'source_label'  => $this->sourceLabel($row['source_provider'] ?? 'widget'),
                'unread_count'  => (int)$row['unread_count'],
                'scheduled_count' => (int)($scheduled_counts[(int)$row['chat_id']] ?? 0),
                'last_message'  => $last_text,
                'last_author'   => (int)($row['last_author'] ?? 0),
                'last_manager_name' => $row['last_manager_name'] ?? '',
                'is_my_reply'   => (int)($row['last_author'] ?? 0) === 2 && (int)($row['last_manager_id'] ?? 0) === $manager_id,
                'last_date'     => $row['last_date'] ?? $row['date_modified'],
                'last_date_iso' => $clock->sqlToIsoUtc((string)($row['last_date'] ?? $row['date_modified'])),
                'date_modified' => $row['date_modified'],
                'date_added'    => $row['date_added'],
                'date_added_iso' => $clock->sqlToIsoUtc((string)$row['date_added']),
                'is_blocked'    => (int)($row['is_blocked'] ?? 0),
                'is_archived'   => (int)($row['is_archived'] ?? 0),
                'customer_avatar_url' => $avatar_service->avatarUrlFromFields($row, 'api/appGetAvatar'),
                'customer_avatar_version' => $avatar_service->avatarVersionFromFields($row),
                'reply_capability' => $reply_capability,
                'commerce_context' => $commerce_contexts[(int)$row['chat_id']] ?? null,
                'assignment' => $this->appAssignmentPayload(
                    $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' => 1,
            'chats'   => $chats,
            'page'    => $page,
        ];

        if ($include_meta) {
            $payload['total'] = $model_session->getTotalChats(
                $scope === 'archive',
                $source_provider,
                $scope,
                $manager_id,
                $operator_id
            );
            $payload['source_filters'] = $this->sourceFiltersPayload(
                $model_session->getSourceProviderCounts($scope === 'archive', $scope, $manager_id, $operator_id),
                $source_provider
            );
            $payload['scope'] = $scope;
            $payload['queue_counts'] = $model_session->getQueueCounts($manager_id);
            $payload['operator_filters'] = $this->appOperatorFiltersPayload($manager_id);
            $payload['selected_operator_id'] = $operator_id;
        }

        if ($updated_after !== '') {
            $payload['sync'] = [
                'mode' => 'incremental',
                'server_time' => (new AppClock($this->registry))->nowSql(),
                'updated_after' => $updated_after,
                'has_more' => $has_more ? 1 : 0,
                'deleted_chat_ids' => $model_session->getDeletedChatIdsSince($updated_after, $source_provider),
            ];
        }

        $this->jsonOut($payload);
    }

    public function appGetCommerceImage() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        $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 appGetQuickReplies() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $model_reply = new Reply($this->registry);
        $this->jsonOut([
            'success' => 1,
            'quick_replies' => $model_reply->getQuickRepliesForManager($manager_id),
        ]);
    }

     
    public function appSuggestReplies() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $skill = trim((string)($json['skill'] ?? 'support'));

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

        $this->jsonOut($result);
    }

     
    public function appGetMessages() {
        $get = \EasyChat\Library\RequestContext::get();
        $manager_id = $this->checkAppToken($this->shouldTouchAndroidPresence($get));
        if (!$manager_id) return;

        $chat_id = (int)($get['chat_id'] ?? 0);
        if (!$chat_id) {
            $this->jsonError('chat_id_required', 'api.chat_id_required');
            return;
        }

        $since_message_id = max(0, (int)($get['since_message_id'] ?? 0));
        $updated_after = $this->normalizeSyncDate($get['updated_after'] ?? '');
        $sync_requested = $since_message_id > 0 || $updated_after !== '' || isset($get['limit']) || isset($get['mark_read']);
        $limit = $sync_requested ? min(500, max(1, (int)($get['limit'] ?? 200))) : 0;
        $query_limit = $limit > 0 ? $limit + 1 : 0;
        $mark_read = !isset($get['mark_read']) || (string)$get['mark_read'] !== '0';

        $model_message = new Message($this->registry);
        $model_session = new Session($this->registry);
        $chat = $model_session->getSessionById($chat_id);

        if ($mark_read) {
             
             
             
            $model_message->markAsReadByAdmin($chat_id);
             
             
            (new MarketplaceReadReceiptService($this->registry))->enqueueForChat($chat_id);
        }

        $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);
        }
        $message_ids = [];
        foreach ($rows as $row) {
            $message_ids[] = (int)$row['message_id'];
        }
        $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['read_receipt_capability']
            ?? MessageReadReceiptService::CAPABILITY_UNSUPPORTED);

        $messages = [];
        $storage = new AttachmentStorage($this->registry);
        $attachments_by_message = $storage->getAttachmentsByMessageIds($message_ids);
        $sync_variant_count = 0;
        $clock = new AppClock($this->registry);
        $message_text_renderer = new MessageTextRenderer();
        foreach ($rows as $row) {
            $image_urls = ['url' => '', 'full_url' => '', 'preview_url' => '', 'thumb_url' => ''];
            if (isset($row['image']) && $row['image']) {
                $image_urls = $storage->imageUrlSet($chat_id, (string)$row['image'], 'api/appGetImage', [], $sync_variant_count < 2);
                if ($sync_variant_count < 2) {
                    $sync_variant_count++;
                }
            }
            $attachments = [];
            foreach ($attachments_by_message[(int)$row['message_id']] ?? [] as $attachment) {
                $sync_variants = $sync_variant_count < 2;
                $payload = $storage->attachmentPayload($chat_id, $attachment, 'api/appGetImage', $sync_variants);
                $attachments[] = $payload;
                if ($sync_variants && (($payload['kind'] ?? '') === 'image')) {
                    $sync_variant_count++;
                }
            }
            
            $msg_text = urldecode($row['text']);
            if ((int)$row['author'] === 3) {
                $msg_text = MessageTextRenderer::withoutCitationMarkers($msg_text);
            }
            $msg_text_html = $message_text_renderer->toHtml($msg_text);
            
            $marketplace_context = $marketplace_contexts_by_message[(int)$row['message_id']] ?? null;
            if (is_array($marketplace_context) && $marketplace_context) {
                $marketplace_context = $this->appMarketplaceContextPayload($chat ?: ['chat_id' => $chat_id], $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'],
                'chat_id'      => (int)$row['chat_id'],
                'text'         => $msg_text,
                'text_html'    => $msg_text_html,
                'image_url'    => $image_urls['url'],
                'image_thumb_url' => $image_urls['thumb_url'],
                'image_preview_url' => $image_urls['preview_url'],
                'image_full_url' => $image_urls['full_url'],
                'attachments'  => $attachments,
                'author'       => (int)$row['author'],
                'message_kind' => (string)($row['message_kind'] ?? 'normal'),
                'status_user'  => (int)$row['status_user'],
                'status_admin' => (int)$row['status_admin'],
                'date_added'   => $row['date_added'],
                'date_added_iso' => $clock->sqlToIsoUtc((string)$row['date_added']),
                'date_modified' => $row['date_modified'] ?? $row['date_added'],
                'date_modified_iso' => $clock->sqlToIsoUtc((string)($row['date_modified'] ?? $row['date_added'])),
                'manager_name' => $row['manager_name'] ?? '',
                'manager_image' => (!empty($row['manager_image']) && (int)$row['author'] === 2) ? HTTP_AVATARS . $row['manager_image'] : '',
                '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),
            ];

        }

        $customer_name = $chat ? ($chat['customer_name'] ?: I18n::translate('widget.guest', [], $this->resolveApiLocale())) : I18n::translate('widget.guest', [], $this->resolveApiLocale());
        $source_provider = $chat ? $this->normalizeSourceProvider($chat['source_provider'] ?? 'widget') : 'widget';
        $avatar_payload = $this->contactAvatarPayload($chat ?: [], 'api/appGetAvatar');
        $reply_capability = (new ReplyCapability($this->registry))->forChatId($chat_id);
        $customer_id = $chat ? (new Customer($this->registry))->ensureCustomerForChat($chat_id) : 0;
        $primary_contacts = (new CustomerContactService($this->registry))->primaryContactsForCustomer((int)$customer_id);
        $current_avito_item = [];
        $current_avito_context = [];
        $current_marketplace_context = [];
        $current_site_context = $message_context->getCurrentSiteContext($chat_id);
        if ($chat && in_array($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->appMarketplaceContextPayload($chat, $current_marketplace_context);
            }
            $current_avito_item = $current_marketplace_context;
            $current_avito_context = $current_marketplace_context;
            $source_channel_id = (int)($chat['source_channel_id'] ?? 0);
            if ($source_provider === 'avito' && $source_channel_id > 0) {
                (new AvitoHistorySyncService($this->registry))->enqueue($source_channel_id, $chat_id, 'android_open');
            }
        }

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

        $payload = [
            'success'       => 1,
            'chat_id'       => $chat_id,
            'customer_name' => $customer_name,
            'primary_contacts' => [
                'email' => $primary_contacts['email'],
                'phone' => $primary_contacts['phone'],
            ],
            'is_blocked' => (int)($chat['is_blocked'] ?? 0),
            'source_provider' => $source_provider,
            'source_label'  => $this->sourceLabel($source_provider),
            'reply_capability' => $reply_capability,
             
             
             
            'capabilities' => $this->buildAppCapabilities($manager_id),
            'read_receipt_capability' => $read_receipt_capability,
            'customer_avatar_url' => $avatar_payload['avatar_url'],
            'customer_avatar_version' => $avatar_payload['avatar_version'],
            'current_marketplace_context' => $current_marketplace_context,
            'current_avito_item' => $current_avito_item,
            'current_avito_context' => $current_avito_context,
            'current_site_context' => $current_site_context,
            'messages'      => $messages,
            'is_archived' => (int)($chat['is_archived'] ?? 0),
            'assignment' => $this->appAssignmentPayload(
                (new ChatAssignment($this->registry))->getSnapshot($chat_id, $manager_id),
                $manager_id
            ),
            'operator_draft' => (new OperatorDraftService($this->registry))->get(
                $manager_id,
                $chat_id
            )['operator_draft'],
        ];
        $payload = array_merge($payload, (new ScheduledMessageService($this->registry))->payloadForChat($chat_id));

        if ($sync_requested) {
            $payload['sync'] = [
                'mode' => 'incremental',
                'server_time' => (new AppClock($this->registry))->nowSql(),
                'updated_after' => $updated_after,
                'max_message_id' => $max_message_id,
                'next_since_message_id' => $max_message_id,
                'has_more' => $has_more ? 1 : 0,
                'deleted_message_ids' => [],
                'mark_read' => $mark_read ? 1 : 0,
            ];
        }

        $this->jsonOut($payload);
    }

    public function appRetryVoiceTranscription() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        if (\EasyChat\Library\RequestContext::method() !== 'POST') {
            $this->jsonOut(['success' => 0, 'error' => 'method_not_allowed'], 405);
            return;
        }
        $json = $this->readJsonInput();
        $result = (new VoiceMediaService($this->registry))->retry(
            (int)($json['attachment_id'] ?? 0),
            (int)($json['chat_id'] ?? 0)
        );
        $this->jsonOut($result, !empty($result['success']) ? 200 : 422);
    }

     
    public function appGetCustomerPanel() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $chat_id = (int)(\EasyChat\Library\RequestContext::get()['chat_id'] ?? 0);
        if ($chat_id <= 0) {
            $this->jsonError('chat_id_required', 'api.chat_id_required');
            return;
        }

        $refresh_commerce = (string)(\EasyChat\Library\RequestContext::get()['refresh_commerce'] ?? '0') === '1';
        $panel = (new Customer($this->registry))->panelForChat($chat_id, $refresh_commerce);
        if (!$panel) {
            $this->jsonError('chat_not_found', 'api.chat_not_found');
            return;
        }

        $assignment_service = new ChatAssignmentService($this->registry);
        $this->jsonOut([
            'success' => 1,
            'assignment' => $this->appAssignmentPayload(
                (new ChatAssignment($this->registry))->getSnapshot($chat_id, $manager_id),
                $manager_id
            ),
            'operator_filters' => $this->appOperatorFiltersPayload($manager_id),
            'assignment_capabilities' => $assignment_service->capabilities($manager_id),
        ] + $panel);
    }

    public function appSetChatAssignment() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        if (\EasyChat\Library\RequestContext::method() !== 'POST') {
            $this->jsonOut(['success' => 0, 'error' => 'method_not_allowed'], 405);
            return;
        }

        $json = $this->readJsonInput();
        $target_manager_id = max(0, (int)($json['manager_id'] ?? 0));
        if ($target_manager_id <= 0) {
            $target_manager_id = $manager_id;
        }
        $action = strtolower(trim((string)($json['action'] ?? 'assign')));
        if (!in_array($action, ['assign', 'unassign'], true)) {
            $this->jsonOut(['success' => 0, 'error' => 'action_invalid'], 422);
            return;
        }
        $result = (new ChatAssignmentService($this->registry))->change(
            (int)($json['chat_id'] ?? 0),
            $target_manager_id,
            $action === 'assign',
            $manager_id
        );
        if (!empty($result['success'])) {
            $result['assignment'] = $this->appAssignmentPayload(
                is_array($result['assignment'] ?? null) ? $result['assignment'] : [],
                $manager_id
            );
            $result['success'] = 1;
        }
        $error = (string)($result['error'] ?? '');
        $status = !empty($result['success'])
            ? 200
            : (in_array($error, ['assignment_forbidden', 'actor_disabled'], true)
                ? 403
                : ($error === 'chat_not_found'
                    ? 404
                    : ($error === 'assignment_storage_failed' ? 503 : 422)));
        $this->jsonOut($result, $status);
    }

     
    public function appAddCustomerProfile() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $profile_url = trim((string)($json['profile_url'] ?? ''));
        $message_id = (int)($json['message_id'] ?? 0);

        $model_customer = new Customer($this->registry);
        $result = $model_customer->addProfileFromChat($chat_id, $profile_url, (int)$manager_id, $message_id, 'android');
        if (empty($result['success'])) {
            $this->jsonOut([
                'success' => 0,
                'code' => (string)($result['error'] ?? 'profile_save_failed'),
                'error' => (string)($result['error'] ?? 'profile_save_failed'),
                'conflict' => $result['conflict'] ?? null,
            ]);
            return;
        }

        $this->jsonOut(['success' => 1] + $model_customer->panelForChat($chat_id));
    }

     
    public function appRemoveCustomerProfile() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $profile_id = (int)($json['profile_id'] ?? 0);
        $reason_code = (string)($json['reason_code'] ?? 'wrong_customer');
        $reason_note = (string)($json['reason_note'] ?? '');
        $suppress_candidate = !array_key_exists('suppress_candidate', $json)
            || (bool)$json['suppress_candidate']
            || (int)$json['suppress_candidate'] === 1;

        $model_customer = new Customer($this->registry);
        $result = $model_customer->removeProfileFromChat($chat_id, $profile_id, (int)$manager_id, $reason_code, $reason_note, $suppress_candidate);
        if (empty($result['success'])) {
            $this->jsonOut([
                'success' => 0,
                'code' => (string)($result['error'] ?? 'profile_unlink_failed'),
                'error' => (string)($result['error'] ?? 'profile_unlink_failed'),
            ]);
            return;
        }

        $this->jsonOut(['success' => 1] + $model_customer->panelForChat($chat_id));
    }

     
    public function appMergeCustomers() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $source_customer_id = (int)($json['source_customer_id'] ?? 0);
        $profile_url = trim((string)($json['profile_url'] ?? ''));
        $reason = (string)($json['reason'] ?? 'manual');

        $result = (new CustomerMergeService($this->registry))->mergeCustomersForChat($chat_id, $source_customer_id, (int)$manager_id, $reason, $profile_url);
        if (empty($result['success'])) {
            $this->jsonOut([
                'success' => 0,
                'code' => (string)($result['error'] ?? 'merge_failed'),
                'error' => (string)($result['error'] ?? 'merge_failed'),
            ]);
            return;
        }

        $this->jsonOut(['success' => 1] + ($result['panel'] ?? (new Customer($this->registry))->panelForChat($chat_id)));
    }

     
    public function appAddCustomerNote() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $model_setting = new Setting($this->registry);
        if ((string)$model_setting->get('chat_dialog_notes_status', '1') !== '1') {
            $this->jsonOut([
                'success' => 0,
                'code' => 'notes_disabled',
                'error' => 'Внутренние заметки отключены в настройках.',
            ]);
            return;
        }

        $json = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $text = (string)($json['text'] ?? '');
        $model_customer = new Customer($this->registry);
        $result = $model_customer->addNoteForChat($chat_id, $text, (int)$manager_id);
        if (empty($result['success'])) {
            $this->jsonOut([
                'success' => 0,
                'code' => (string)($result['error'] ?? 'note_save_failed'),
                'error' => (string)($result['error'] ?? 'note_save_failed'),
            ]);
            return;
        }

        $this->jsonOut(['success' => 1] + $model_customer->panelForChat($chat_id));
    }

     
    public function appAddCustomerTag() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $model_setting = new Setting($this->registry);
        if ((string)$model_setting->get('chat_dialog_tags_status', '1') !== '1') {
            $this->jsonOut([
                'success' => 0,
                'code' => 'tags_disabled',
                'error' => 'Теги диалога отключены в настройках.',
            ]);
            return;
        }

        $json = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $label = (string)($json['label'] ?? '');
        $model_customer = new Customer($this->registry);
        $result = $model_customer->addTagForChat($chat_id, $label);
        if (empty($result['success'])) {
            $this->jsonOut([
                'success' => 0,
                'code' => (string)($result['error'] ?? 'tag_save_failed'),
                'error' => (string)($result['error'] ?? 'tag_save_failed'),
            ]);
            return;
        }

        $this->jsonOut(['success' => 1] + $model_customer->panelForChat($chat_id));
    }

     
    public function appSendMessage() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json    = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $text    = trim($json['text']      ?? '');
        $file_name = trim($json['file_name'] ?? '');
        $display_name = trim((string)($json['display_name'] ?? ''));
        $client_request_id = trim((string)($json['client_request_id'] ?? ''));
        $operator_draft_revision = max(0, (int)($json['operator_draft_revision'] ?? 0));
        $file_names = isset($json['file_names']) && is_array($json['file_names'])
            ? array_values(array_filter(array_map('strval', $json['file_names'])))
            : ($file_name !== '' ? [$file_name] : []);
        $file_names = array_values(array_map('basename', $file_names));
        $display_names = isset($json['display_names']) && is_array($json['display_names'])
            ? array_values(array_map('strval', $json['display_names']))
            : ($display_name !== '' ? [$display_name] : []);

        if (!$chat_id) {
            $this->jsonError('chat_id_required', 'api.chat_id_required');
            return;
        }

        if (!$text && !$file_names) {
            $this->jsonError('empty_message', 'api.empty_message');
            return;
        }

        $model_message = new Message($this->registry);
        $model_session = new Session($this->registry);
        $chat = $model_session->getSessionById($chat_id);
        if (!$chat) {
            $this->jsonError('chat_not_found', 'api.chat_not_found');
            return;
        }
        if ((string)($chat['source_provider'] ?? '') === 'sms'
            && (new TrialLockGuard($this->registry))->isSmsBlocked(false)) {
            $this->jsonTrialExpired();
            return;
        }
        $reply_capability = (new ReplyCapability($this->registry))->forChatId($chat_id);
        if (empty($reply_capability['can_reply'])) {
            $this->jsonOut([
                'success' => 0,
                'code' => 'reply_not_allowed',
                'error' => 'reply_not_allowed',
                'message' => (string)($reply_capability['message'] ?? 'Получаем сообщения, ответ недоступен.'),
                'reply_capability' => $reply_capability,
            ]);
            return;
        }

        $image = '';
        $uploaded_attachments = [];
        $storage = new AttachmentStorage($this->registry);
        $capabilities = AttachmentCapabilities::forProvider((string)($chat['source_provider'] ?? 'widget'));
        $attachment_limit = max(0, (int)($capabilities['max_attachments'] ?? 0));
        if (count($file_names) > $attachment_limit) {
            $this->jsonError('too_many_attachments', 'api.too_many_attachments', ['limit' => $attachment_limit]);
            return;
        }
        foreach ($file_names as $index => $stored_file_name) {
            $stored_file_name = basename($stored_file_name);
            $image_path = DIR_IMAGE . 'chat_id_' . $chat_id . '/' . $stored_file_name;
            if (is_file($image_path)) {
                $requested_kind = $this->attachmentKindFromStoredFile($image_path, $stored_file_name);
                if (!AttachmentCapabilities::isKindAllowed((string)($chat['source_provider'] ?? 'widget'), $requested_kind)) {
                    $this->jsonError('attachment_type_unsupported', 'api.attachment_type_unsupported');
                    return;
                }
                $model_setting = new Setting($this->registry);
                $saved = $storage->saveExistingAttachment($chat_id, $stored_file_name, (int)$model_setting->get('chat_max_file_size', '5'), (string)($display_names[$index] ?? ''));
                if (!empty($saved['success'])) {
                    if (($saved['kind'] ?? 'image') === 'image' && $image === '') {
                        $image = (string)($saved['filename'] ?? $stored_file_name);
                    }
                    $uploaded_attachments[] = $saved;
                }
            }
        }
        $kinds = array_values(array_unique(array_map(function(array $attachment): string {
            return (string)($attachment['kind'] ?? 'image');
        }, $uploaded_attachments)));
        $document_count = count(array_filter($uploaded_attachments, function(array $attachment): bool {
            return (string)($attachment['kind'] ?? 'image') === 'document';
        }));
        if (count($kinds) > 1 && isset($capabilities['mixed_image_document']) && !$capabilities['mixed_image_document']) {
            $this->jsonError('mixed_attachments_unsupported', 'api.mixed_attachments_unsupported');
            return;
        }
        if ($document_count > (int)($capabilities['max_documents'] ?? $attachment_limit)) {
            $this->jsonError('too_many_documents', 'api.too_many_documents');
            return;
        }
        if ($text === '' && !$uploaded_attachments) {
            $this->jsonError('file_missing', 'api.file_missing');
            return;
        }
        $attachment_fingerprints = [];
        foreach ($uploaded_attachments as $uploaded_attachment) {
            $attachment_path = (string)($uploaded_attachment['path'] ?? '');
            $content_hash = ($attachment_path !== '' && is_file($attachment_path))
                ? hash_file('sha256', $attachment_path)
                : false;
            if (!is_string($content_hash) || preg_match('/^[a-f0-9]{64}$/D', $content_hash) !== 1) {
                $this->jsonOut(['success' => 0, 'code' => 'attachment_fingerprint_failed', 'error' => 'attachment_fingerprint_failed'], 500);
                return;
            }
             
             
             
            $attachment_fingerprints[] = [
                'content_hash' => $content_hash,
                'mime' => (string)($uploaded_attachment['mime'] ?? ''),
                'size' => (int)($uploaded_attachment['size'] ?? 0),
                'kind' => (string)($uploaded_attachment['kind'] ?? ''),
                'display_name' => (string)($uploaded_attachment['display_name'] ?? $uploaded_attachment['original_name'] ?? ''),
            ];
        }
        $model_manager = new Manager($this->registry);
         
        $model_manager->runMigrations();
        $manager = $model_manager->getManagerById($manager_id);
        $manager_name = $manager ? $manager['name'] : I18n::translate('widget.operator', [], $this->resolveApiLocale());

        $encoded_text = urlencode($text);
        $ip = \EasyChat\Library\RequestContext::server()['REMOTE_ADDR'] ?? '';

        $idempotency = null;
        $is_sms_chat = (string)($chat['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 !== '') {
            $idempotency = new DirectMessageIdempotency($this->registry);
            $idempotency_claim = $idempotency->begin(
                'android',
                (int)$manager_id,
                $client_request_id,
                [
                    'chat_id' => $chat_id,
                    'manager_id' => (int)$manager_id,
                    'text' => $text,
                    'attachments' => $attachment_fingerprints,
                ],
                function() use ($model_message, $chat_id, $encoded_text, $image, $manager_id, $manager_name, $ip, $uploaded_attachments, $storage, $sms_dispatch_outbox): int {
                    $message_id = $model_message->addMessage([
                        'chat_id' => $chat_id,
                        'text' => $encoded_text,
                        'image' => $image,
                        'author' => 2,
                        'manager_id' => $manager_id,
                        'manager_name' => $manager_name,
                        'status_user' => 0,
                        'status_admin' => 1,
                        'ip' => $ip,
                         
                         
                        'skip_metrics' => true,
                    ]);
                    foreach ($uploaded_attachments as $uploaded_attachment) {
                        $storage->attachToMessage((int)$message_id, $chat_id, $uploaded_attachment, 'android');
                    }
                    if ($sms_dispatch_outbox) {
                        $sms_dispatch_outbox->enqueue((int)$message_id);
                    }
                    return (int)$message_id;
                }
            );

            $state = (string)($idempotency_claim['state'] ?? 'invalid');
            if ($state === 'invalid') {
                $this->jsonOut(['success' => 0, 'code' => 'client_request_id_invalid', 'error' => 'client_request_id_invalid'], 422);
                return;
            }
            if ($state === 'conflict') {
                $this->jsonOut(['success' => 0, 'code' => 'client_request_id_reused', 'error' => 'client_request_id_reused'], 409);
                return;
            }
            if ($state === 'processing') {
                 
                 
                if ((int)($idempotency_claim['message_id'] ?? 0) <= 0) {
                    $retry_after = max(1, (int)($idempotency_claim['retry_after'] ?? 1));
                    header('Retry-After: ' . $retry_after);
                    $this->jsonOut([
                        'success' => 0,
                        'code' => 'message_send_in_progress',
                        'error' => 'message_send_in_progress',
                        'retryable' => 1,
                        'retry_after' => $retry_after,
                    ], 409);
                    return;
                }
            }
            $message_id = (int)($idempotency_claim['message_id'] ?? 0);
            $idempotency_replayed = !empty($idempotency_claim['replayed']);
        } else {
             
             
             
             
            $create_legacy_message = function() use ($model_message, $chat_id, $encoded_text, $image, $manager_id, $manager_name, $ip, $uploaded_attachments, $storage, $is_sms_chat): int {
                $message_id = (int)$model_message->addMessage([
                    'chat_id' => $chat_id,
                    'text' => $encoded_text,
                    'image' => $image,
                    'author' => 2,
                    'manager_id' => $manager_id,
                    'manager_name' => $manager_name,
                    'status_user' => 0,
                    'status_admin' => 1,
                    'ip' => $ip,
                    'skip_metrics' => $is_sms_chat,
                ]);
                foreach ($uploaded_attachments as $uploaded_attachment) {
                    $storage->attachToMessage($message_id, $chat_id, $uploaded_attachment, 'android');
                }
                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) {
                foreach ($uploaded_attachments as $uploaded_attachment) {
                    $storage->discardPendingUpload($chat_id, $uploaded_attachment);
                }
                $this->jsonOut(['success' => 0, 'code' => 'message_save_failed', 'error' => 'message_save_failed'], 500);
                return;
            }
        }

        $saved_message = $model_message->getMessageById((int)$message_id);
        if (!$saved_message) {
            $this->jsonOut(['success' => 0, 'code' => 'message_send_result_gone', 'error' => 'message_send_result_gone'], 410);
            return;
        }
        if ($client_request_id !== '' && empty($idempotency_claim['created']) && $uploaded_attachments) {
            $attached = $storage->getAttachmentsByMessageIds([(int)$message_id]);
            $attached_names = [];
            foreach ($attached[(int)$message_id] ?? [] as $attachment) {
                $attached_names[(string)($attachment['file_name'] ?? '')] = true;
            }
            foreach ($uploaded_attachments as $uploaded_attachment) {
                $filename = (string)($uploaded_attachment['filename'] ?? '');
                if ($filename !== '' && empty($attached_names[$filename])) {
                     
                     
                     
                    $storage->discardPendingUpload($chat_id, $uploaded_attachment);
                }
            }
        }
        $completed_replay = (string)($idempotency_claim['state'] ?? '') === 'completed';
        $processing_replay = (string)($idempotency_claim['state'] ?? '') === 'processing';
        $passive_replay = $completed_replay || $processing_replay;
        if (!$passive_replay) {
            if (!empty($idempotency_claim['created']) || $legacy_sms_created) {
                try {
                    (new \EasyChat\Library\ResponseTimeMetrics($this->registry))->recordMessage((int)$message_id);
                } catch (\Throwable $e) {
                     
                     
                }
            }
            $model_session->updateModifiedDate($chat_id);
        }

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

                $android_push = new AndroidPushNotifier($this->registry);
                $android_push->notifySyncChat(
                    $chat_id,
                    $chat['source_provider'] ?? 'widget',
                    $chat['customer_name'] ?? '',
                    strtoupper($chat['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 && !$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->appOutboundDeliveryPayload($outbound_result);
        }

        $clock = new AppClock($this->registry);
        $response = [
            'success' => 1,
            'message_id' => (int)$message_id,
            'date_added' => (string)($saved_message['date_added'] ?? ''),
            'date_modified' => (string)($saved_message['date_modified'] ?? $saved_message['date_added'] ?? ''),
            'date_added_iso' => $clock->sqlToIsoUtc((string)($saved_message['date_added'] ?? '')),
            'replayed' => $idempotency_replayed ? 1 : 0,
            'dispatch_pending' => $processing_replay ? 1 : 0,
            'assignment' => $this->appAssignmentPayload(
                (new ChatAssignment($this->registry))->getSnapshot($chat_id, $manager_id),
                $manager_id
            ),
        ];
        $response['operator_draft_cleared'] = $operator_draft_revision > 0
            && (new OperatorDraftService($this->registry))->clearAfterAccepted((int)$manager_id, $chat_id, $operator_draft_revision);
        if ($outbound_delivery !== null) {
            $response['outbound_delivery'] = $outbound_delivery;
        }
        if ($idempotency && (string)($idempotency_claim['state'] ?? '') === 'claimed') {
            $idempotency->complete(
                'android',
                (int)$manager_id,
                $client_request_id,
                (string)($idempotency_claim['processing_token'] ?? ''),
                $response
            );
        }
        $this->jsonOut($response);
    }

    public function appGetOperatorDraft() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;
        $chat_id = (int)(\EasyChat\Library\RequestContext::get()['chat_id'] ?? 0);
        $result = (new OperatorDraftService($this->registry))->get((int)$manager_id, $chat_id);
        $this->jsonOut($result, (int)($result['status'] ?? 200));
    }

    public function appSaveOperatorDraft() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;
        if (\EasyChat\Library\RequestContext::method() !== 'POST') {
            $this->jsonOut(['success' => 0, 'error' => 'method_not_allowed'], 405);
            return;
        }
        (new Manager($this->registry))->runMigrations();
        $json = $this->readJsonInput();
        $result = (new OperatorDraftService($this->registry))->save([
            'manager_id' => (int)$manager_id,
            'chat_id' => (int)($json['chat_id'] ?? 0),
            'text' => (string)($json['text'] ?? ''),
            'base_revision' => (int)($json['base_revision'] ?? 0),
            'mutation_id' => (string)($json['mutation_id'] ?? ''),
            'context_message_id' => (int)($json['context_message_id'] ?? 0),
            'surface' => 'android',
        ]);
        $this->jsonOut($result, (int)($result['status'] ?? 200));
    }

    public function appScheduleMessage() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        (new Manager($this->registry))->runMigrations();
        $json = $this->readJsonInput();
        $this->scheduledApiOut((new ScheduledMessageService($this->registry))->createForChat([
            'chat_id' => (int)($json['chat_id'] ?? 0),
            'text' => (string)($json['text'] ?? ''),
            'scheduled_at' => (string)($json['scheduled_at'] ?? ''),
            'client_request_id' => (string)($json['client_request_id'] ?? ''),
            'manager_id' => (int)$manager_id,
            'manager_name' => $this->appManagerName((int)$manager_id),
            'surface' => 'android',
        ]));
    }

    public function appUpdateScheduledMessage() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        $json = $this->readJsonInput();
        $this->scheduledApiOut((new ScheduledMessageService($this->registry))->update([
            'public_id' => (string)($json['public_id'] ?? ''),
            'revision' => (int)($json['revision'] ?? 0),
            'text' => (string)($json['text'] ?? ''),
            'scheduled_at' => (string)($json['scheduled_at'] ?? ''),
            'client_request_id' => (string)($json['client_request_id'] ?? ''),
            'manager_id' => (int)$manager_id,
            'manager_name' => $this->appManagerName((int)$manager_id),
        ]));
    }

    public function appSendScheduledMessageNow() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        $json = $this->readJsonInput();
        $this->scheduledApiOut((new ScheduledMessageService($this->registry))->sendNow([
            'public_id' => (string)($json['public_id'] ?? ''),
            'revision' => (int)($json['revision'] ?? 0),
            'client_request_id' => (string)($json['client_request_id'] ?? ''),
            'manager_id' => (int)$manager_id,
            'manager_name' => $this->appManagerName((int)$manager_id),
        ]));
    }

    public function appCancelScheduledMessage() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        $json = $this->readJsonInput();
        $this->scheduledApiOut((new ScheduledMessageService($this->registry))->cancel([
            'public_id' => (string)($json['public_id'] ?? ''),
            'revision' => (int)($json['revision'] ?? 0),
            'client_request_id' => (string)($json['client_request_id'] ?? ''),
            'manager_id' => (int)$manager_id,
            'manager_name' => $this->appManagerName((int)$manager_id),
        ]));
    }

    public function appStartOutboundContact() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        (new Manager($this->registry))->runMigrations();
        $json = $this->readJsonInput();
        $delivery_mode = strtolower(trim((string)($json['delivery_mode'] ?? 'now')));
        $manager_name = $this->appManagerName((int)$manager_id);
        if ($delivery_mode !== 'scheduled') {
            $result = (new OutboundContactService($this->registry))->start([
                'channel' => (string)($json['channel'] ?? 'max'),
                'phone' => (string)($json['phone'] ?? ''),
                'customer_name' => (string)($json['customer_name'] ?? ''),
                'reason' => (string)($json['reason'] ?? 'manual'),
                'text' => (string)($json['text'] ?? ''),
                'client_request_id' => (string)($json['client_request_id'] ?? ''),
                'manager_id' => (int)$manager_id,
                'manager_name' => $manager_name,
                'ip' => (string)(\EasyChat\Library\RequestContext::server()['REMOTE_ADDR'] ?? ''),
            ]);
            $this->scheduledApiOut($result);
            return;
        }

        $phone = SmsPhoneNumber::normalize((string)($json['phone'] ?? ''));
        $idempotency_payload = [
            'channel' => strtolower(trim((string)($json['channel'] ?? 'max'))),
            'phone' => $phone,
            'customer_name' => trim((string)($json['customer_name'] ?? '')),
            'reason' => trim((string)($json['reason'] ?? 'manual')) ?: 'manual',
            'text' => trim((string)($json['text'] ?? '')),
            'scheduled_at' => trim((string)($json['scheduled_at'] ?? '')),
        ];
        $service = new ScheduledMessageService($this->registry);
        $draft_validation = $service->validateDraft(
            (string)($json['text'] ?? ''),
            (string)($json['scheduled_at'] ?? '')
        );
        if (empty($draft_validation['success'])) {
            $this->scheduledApiOut($draft_validation);
            return;
        }
        $existing = $service->existingCreateResult(
            (int)$manager_id,
            trim((string)($json['client_request_id'] ?? '')),
            $idempotency_payload
        );
        if ((string)($existing['state'] ?? '') === 'completed') {
            $this->jsonOut($existing['result']);
            return;
        }
        if ((string)($existing['state'] ?? '') === 'conflict') {
            $this->jsonOut(['success' => 0, 'code' => 'client_request_id_reused', 'error' => 'client_request_id_reused'], 409);
            return;
        }
        $prepared = (new OutboundContactService($this->registry))->prepareScheduledChat([
            'channel' => (string)($json['channel'] ?? 'max'),
            'phone' => $phone,
            'customer_name' => (string)($json['customer_name'] ?? ''),
            'reason' => (string)($json['reason'] ?? 'manual'),
            'text' => (string)($json['text'] ?? ''),
            'scheduled_at' => (string)($json['scheduled_at'] ?? ''),
            'client_request_id' => (string)($json['client_request_id'] ?? ''),
            'manager_id' => (int)$manager_id,
        ]);
        if (empty($prepared['success'])) {
            $this->scheduledApiOut($prepared);
            return;
        }
        $result = $service->createForChat([
            'chat_id' => (int)$prepared['chat_id'],
            'text' => (string)($json['text'] ?? ''),
            'scheduled_at' => (string)($json['scheduled_at'] ?? ''),
            'client_request_id' => (string)($json['client_request_id'] ?? ''),
            'manager_id' => (int)$manager_id,
            'manager_name' => $manager_name,
            'surface' => 'android_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->scheduledApiOut($result);
        return;
    }

    private function scheduledApiOut(array $result): void {
        $status = (int)($result['http_code'] ?? (!empty($result['success']) ? 200 : 422));
        unset($result['http_code']);
        if (array_key_exists('success', $result)) {
            $result['success'] = !empty($result['success']) ? 1 : 0;
        }
        $this->jsonOut($result, max(200, min(599, $status)));
    }

    private function appManagerName(int $manager_id): string {
        $manager = (new Manager($this->registry))->getManagerById($manager_id);
        return trim((string)($manager['name'] ?? '')) ?: 'Оператор';
    }

    






    public function appMarketplaceAction() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $result = (new MarketplaceActionService($this->registry))->execute(
            (int)($json['chat_id'] ?? 0),
            (string)($json['action'] ?? ''),
            [
                'text' => (string)($json['text'] ?? ''),
                'request_id' => (string)($json['request_id'] ?? ''),
                'action_id' => (int)($json['action_id'] ?? 0),
                'claim_action' => (string)($json['claim_action'] ?? ''),
                'compensation_amount' => $json['compensation_amount'] ?? null,
                'rejection_reason_id' => $json['rejection_reason_id'] ?? null,
                'return_for_back_way' => $json['return_for_back_way'] ?? null,
            ],
            (int)$manager_id
        );

        if (empty($result['success'])) {
            $code = preg_replace('/[^a-z0-9_\-]/i', '', (string)($result['error'] ?? 'marketplace_action_failed'));
            $this->jsonOut([
                'success' => 0,
                'code' => $code !== '' ? $code : 'marketplace_action_failed',
                'error' => $code !== '' ? $code : 'marketplace_action_failed',
                'action_id' => (int)($result['action_id'] ?? 0),
                'replayed' => !empty($result['replayed']) ? 1 : 0,
            ]);
            return;
        }

        $this->jsonOut([
            'success' => 1,
            'action_id' => (int)($result['action_id'] ?? 0),
            'replayed' => !empty($result['replayed']) ? 1 : 0,
        ]);
    }

     
    public function appSendFile() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $chat_id = (int)(\EasyChat\Library\RequestContext::post()['chat_id'] ?? 0);
        if (!$chat_id) {
            $this->jsonError('chat_id_required', 'api.chat_id_required');
            return;
        }

        $model_session = new Session($this->registry);

         
        $chat = $model_session->getSessionById($chat_id);
        if (!$chat) {
            $this->jsonError('chat_not_found', 'api.chat_not_found');
            return;
        }
        if ((string)($chat['source_provider'] ?? '') === 'sms'
            && (new TrialLockGuard($this->registry))->isSmsBlocked(false)) {
            $this->jsonTrialExpired();
            return;
        }
        $reply_capability = (new ReplyCapability($this->registry))->forChatId($chat_id);
        if (empty($reply_capability['can_reply'])) {
            $this->jsonOut([
                'success' => 0,
                'code' => 'reply_not_allowed',
                'error' => 'reply_not_allowed',
                'message' => (string)($reply_capability['message'] ?? 'Получаем сообщения, ответ недоступен.'),
                'reply_capability' => $reply_capability,
            ]);
            return;
        }

        $request_files = \EasyChat\Library\RequestContext::files();
        $normalized_files = AttachmentBatchUploadService::normalizeFiles([
            $request_files['files'] ?? [],
            $request_files['file'] ?? [],
        ]);
        if (!$normalized_files) {
            $this->jsonError('file_missing', 'api.file_missing');
            return;
        }

        $model_setting = new Setting($this->registry);
        $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'),
            'app',
            (string)($chat['source_provider'] ?? 'widget')
        );
        if (empty($batch['success'])) {
            $error = (string)($batch['error'] ?? 'file_save_failed');
            $translation_key = ($error === 'file_mime_denied') ? 'api.file_mime_denied'
                : (($error === 'file_extension_denied') ? 'api.file_extension_denied'
                    : (($error === 'too_many_attachments') ? 'api.too_many_attachments'
                        : (($error === 'mixed_attachments_unsupported') ? 'api.mixed_attachments_unsupported'
                            : (($error === 'too_many_documents') ? 'api.too_many_documents' : 'api.file_save_failed'))));
            $this->jsonError($error, $translation_key);
            return;
        }

        $uploads = (array)$batch['uploads'];
        $upload = $uploads[0];

        $this->jsonOut([
            'success' => 1,
            'file_name' => $upload['filename'],
            'file_names' => array_values(array_map(function(array $item): string { return (string)$item['filename']; }, $uploads)),
            'kind' => (string)($upload['kind'] ?? 'image'),
            'mime_type' => (string)($upload['mime'] ?? ''),
            'file_size' => (int)($upload['size'] ?? 0),
            'display_name' => (string)($upload['display_name'] ?? $upload['original_name'] ?? $upload['filename']),
        ]);
    }

    public function appSaveAttachmentPreview() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $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'])) {
            $this->jsonError('preview_required', 'api.file_missing');
            return;
        }

        $storage = new AttachmentStorage($this->registry);
        $result = $storage->savePdfPreviewUpload($attachment_id, $chat_id, $files['thumbnail'], 'api/appGetImage');
        $this->jsonOut($result);
    }

    public function appGetAttachmentPreview() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $chat_id = (int)(\EasyChat\Library\RequestContext::get()['chat_id'] ?? 0);
        $attachment_id = (int)(\EasyChat\Library\RequestContext::get()['attachment_id'] ?? 0);
        if ($chat_id <= 0 || $attachment_id <= 0) {
            $this->jsonError('attachment_required', 'api.file_missing');
            return;
        }

        $storage = new AttachmentStorage($this->registry);
        $attachment = $storage->getAttachmentById($attachment_id);
        if (!$attachment || (int)($attachment['chat_id'] ?? 0) !== $chat_id) {
            $this->jsonError('attachment_not_found', 'api.file_missing', [], 404);
            return;
        }

        $payload = $storage->attachmentPayload($chat_id, $attachment, 'api/appGetImage', false);
        $this->jsonOut([
            'success' => 1,
            'attachment' => $payload,
        ]);
    }

    public function appGetMediaGallery() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $chat_id = (int)(\EasyChat\Library\RequestContext::get()['chat_id'] ?? 0);
        if ($chat_id <= 0) {
            $this->jsonError('chat_id_required', 'api.chat_id_required');
            return;
        }

        $model_session = new Session($this->registry);
        if (!$model_session->getSessionById($chat_id)) {
            $this->jsonError('chat_not_found', 'api.chat_id_required', [], 404);
            return;
        }

        $limit = min(120, max(1, (int)(\EasyChat\Library\RequestContext::get()['limit'] ?? 60)));
        $before_message_id = max(0, (int)(\EasyChat\Library\RequestContext::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 = [];
        $clock = new AppClock($this->registry);
        foreach ($rows as $row) {
            $payload = $storage->attachmentPayload($chat_id, $row, 'api/appGetImage', false);
            if (!in_array((string)($payload['kind'] ?? ''), ['image', 'document'], true)) {
                continue;
            }
            $items[] = array_merge($payload, [
                'message_id' => (int)($row['message_id'] ?? 0),
                'author' => (int)($row['author'] ?? 0),
                'date_added' => (string)($row['date_added'] ?? ''),
                'date_added_iso' => $clock->sqlToIsoUtc((string)($row['date_added'] ?? '')),
            ]);
        }

        $this->jsonOut([
            'success' => 1,
            'items' => $items,
            'has_more' => $has_more ? 1 : 0,
            'next_before_message_id' => $items ? (int)$items[count($items) - 1]['message_id'] : 0,
        ]);
    }

     
    public function appPoll() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $model_session = new Session($this->registry);

        $unread_rows = $model_session->getUnreadChats($manager_id);

        $chats = [];
        $total_unread = 0;
        foreach ($unread_rows as $row) {
            $total_unread += (int)$row['unread_count'];
            $chats[] = [
                'chat_id'      => (int)$row['chat_id'],
                'unread_count' => (int)$row['unread_count'],
            ];
        }

        $this->jsonOut([
            'success'      => 1,
            'has_new'      => !empty($chats),
            'total_unread' => $total_unread,
            'chats'        => $chats,
        ]);
    }

     
    public function appDeleteChats() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $model_manager = new Manager($this->registry);
        $manager = $model_manager->getManagerById($manager_id);
        
        $can_delete = false;
        if ($manager) {
            if ($manager['role'] === 'admin' || (int)$manager['permission_delete_chats'] === 1) {
                $can_delete = true;
            }
        }
        
        if (!$can_delete) {
            $this->jsonError('delete_forbidden', 'api.delete_forbidden');
            return;
        }

        $json = $this->readJsonInput();
        $chat_ids = $json['chat_ids'] ?? [];

        if (empty($chat_ids) || !is_array($chat_ids)) {
            $this->jsonError('chat_ids_required', 'api.chat_ids_required');
            return;
        }

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

        if ($clean_ids) {
            $result = (new ChatDeletionService($this->registry))->delete($clean_ids);
            if (empty($result['success'])) {
                $this->jsonOut($this->appMutationError($result), (int)($result['http_code'] ?? 409));
                return;
            }
        }

        $this->jsonOut(['success' => 1]);
    }

     
    public function appSaveCustomer() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json    = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $name    = trim($json['customer_name'] ?? '');
        $email   = trim($json['primary_email'] ?? '');
        $phone   = trim($json['primary_phone'] ?? '');

        if (!$chat_id) {
            $this->jsonError('chat_id_required', 'api.chat_id_required');
            return;
        }

        $result = (new CustomerContactService($this->registry))->saveCustomerFromPanel($chat_id, $name, $email, $phone, (int)$manager_id);
        if (empty($result['success'])) {
            $this->jsonError($result['error'] ?? 'save_failed');
            return;
        }

        $this->jsonOut(['success' => 1]);
    }

     
    public function appToggleBlockChat() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $chat_id = (int)($json['chat_id'] ?? 0);
        $blocked = isset($json['blocked']) ? (int)$json['blocked'] : 1;  

        if (!$chat_id) {
            $this->jsonError('chat_id_required', 'api.chat_id_required');
            return;
        }

        $model_session = new Session($this->registry);
        $chat_info = $model_session->getSessionById($chat_id);
        if (!$chat_info) {
            $this->jsonError('chat_not_found', 'api.chat_not_found');
            return;
        }

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

        $this->jsonOut(['success' => 1]);
    }

     
    public function appGetImage() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $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;
        }

        $file_path = DIR_IMAGE . 'chat_id_' . $chat_id . DS . $file;
        if (is_file($file_path)) {
            $response = $this->localAttachmentResponse($file_path, $file, $download_name);
            $this->releaseSessionLockForReadOnlyBinaryResponse();
            $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 appGetAvatar() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;
        $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 appResetToken() {
        $token = $this->appAccessTokenFromHeaders();
        
        if ($token) {
            $model_appsession = new AppSession($this->registry);
             
             
            $model_appsession->revokeSessionByToken($token, 'operator_logout');
        }

        $this->jsonOut(['success' => 1]);
    }

    public function appSmsRegisterDevice() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $session = $this->currentAppSession();
        if (!$session) {
            $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
            return;
        }

        $json = $this->readJsonInput();
        $device_data = [
            'app_device_id' => (int)($session['app_device_id'] ?? 0),
            'device_label' => (string)($json['device_label'] ?? 'Android'),
            'app_version' => (string)($json['app_version'] ?? ''),
            'android_permission' => !empty($json['android_permission']),
            'send_available' => array_key_exists('send_available', $json) ? !empty($json['send_available']) : true,
            'inbound_android_permission' => !empty($json['inbound_android_permission']),
        ];
        if (array_key_exists('default_sms_subscription_id', $json)) {
            $device_data['default_sms_subscription_id'] = (int)$json['default_sms_subscription_id'];
        }
        $has_inbound_health_report = false;
        foreach (['inbound_sim_mode', 'inbound_health_status', 'inbound_last_error_code', 'inbound_last_error_at_ms', 'inbound_pending_count'] as $field) {
            if (array_key_exists($field, $json)) {
                 
                 
                $device_data[$field] = is_scalar($json[$field]) ? (string)$json[$field] : '';
                $has_inbound_health_report = true;
            }
        }
        if (array_key_exists('reliability_status', $json)) {
            $device_data += [
                'reliability_status' => (string)$json['reliability_status'],
                'android_sdk' => (int)($json['android_sdk'] ?? 0),
                'manufacturer' => (string)($json['manufacturer'] ?? ''),
                'model' => (string)($json['model'] ?? ''),
                'battery_unrestricted' => !empty($json['battery_unrestricted']),
                'background_restricted' => !empty($json['background_restricted']),
                'notifications_allowed' => !empty($json['notifications_allowed']),
                'fcm_synced' => !empty($json['fcm_synced']),
            ];
        }
        $device_model = new SmsDevice($this->registry);
        $previous_device = $has_inbound_health_report
            ? $device_model->getByAppSessionId((int)$session['session_id'])
            : [];
        $device = $device_model->upsertFromAppSession((int)$session['session_id'], (int)$manager_id, $device_data);
        if ($has_inbound_health_report) {
            $this->logSmsInboundHealthTransition($previous_device, $device);
        }

        $this->jsonOut([
            'success' => 1,
            'device' => $this->smsDevicePayload($device),
        ]);
    }

    public function appSmsPermissionStatus() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $session = $this->currentAppSession();
        if (!$session) {
            $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
            return;
        }
        $device_model = new SmsDevice($this->registry);
        $device = $device_model->getByAppSessionId((int)$session['session_id']);
        if (!$device) {
            $device = $device_model->upsertFromAppSession(
                (int)$session['session_id'],
                (int)$manager_id,
                ['app_device_id' => (int)($session['app_device_id'] ?? 0)]
            );
        }
        $this->jsonOut([
            'success' => 1,
            'device' => $this->smsDevicePayload($device),
        ]);
    }

    public function appSmsPermissionRespond() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $session = $this->currentAppSession();
        if (!$session) {
            $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
            return;
        }
        $device_model = new SmsDevice($this->registry);
        $current_device = $device_model->getByAppSessionId((int)$session['session_id']);
        if (!$current_device || (int)($current_device['admin_requested'] ?? 0) !== 1) {
             
             
            $this->jsonError('sms_permission_not_requested', 'api.bad_request', [], 409);
            return;
        }
        $json = $this->readJsonInput();
        $device = $device_model->saveOperatorConsent(
            (int)$session['session_id'],
            !empty($json['granted']) ? 'granted' : 'denied',
            !empty($json['android_permission']),
            array_key_exists('send_available', $json) ? !empty($json['send_available']) : true
        );

        $this->jsonOut([
            'success' => 1,
            'device' => $this->smsDevicePayload($device),
        ]);
    }

    public function appSmsInboundPermissionStatus() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $session = $this->currentAppSession();
        if (!$session) {
            $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
            return;
        }
        $device_model = new SmsDevice($this->registry);
        $device = $device_model->getByAppSessionId((int)$session['session_id']);
        if (!$device) {
            $device = $device_model->upsertFromAppSession(
                (int)$session['session_id'],
                (int)$manager_id,
                ['app_device_id' => (int)($session['app_device_id'] ?? 0)]
            );
        }
        $inbound_allowed = (int)($device['inbound_admin_requested'] ?? 0) === 1
            && (string)($device['inbound_operator_consent'] ?? '') === 'granted'
            && (string)($device['inbound_android_permission'] ?? '') === 'granted'
            && (string)($device['inbound_status'] ?? '') === 'ready';
        $scope_credential = !empty($session['app_device_id'])
            ? (new AppDevice($this->registry))->scopeCredentialById((int)$session['app_device_id'])
            : (string)($session['session_token'] ?? '');
        $installation_scope = SmsInstallationScope::create(
            SmsInstallationScope::currentSiteUrl(),
            $scope_credential
        );
        $revision_input = \EasyChat\Library\RequestContext::get()['revision'] ?? '';
        $known_revision = is_scalar($revision_input) ? (string)$revision_input : '';
        $inbound_filter = (new SmsInboundContactFilter($this->registry))->build(
            $scope_credential,
            $installation_scope,
            $known_revision,
            $inbound_allowed
        );
        $this->jsonOut([
            'success' => 1,
            'device' => $this->smsDevicePayload($device),
            'inbound_permission' => [
                'admin_requested' => (int)($device['inbound_admin_requested'] ?? 0),
                'consent' => (string)($device['inbound_operator_consent'] ?? 'none'),
                'android_permission' => (string)($device['inbound_android_permission'] ?? 'unknown'),
                'status' => (string)($device['inbound_status'] ?? 'not_requested'),
                'allowed' => $inbound_allowed ? 1 : 0,
            ],
            'inbound_filter' => $inbound_filter,
        ]);
    }

    public function appSmsInboundPermissionRespond() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;

        $session = $this->currentAppSession();
        if (!$session) {
            $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
            return;
        }
        $device_model = new SmsDevice($this->registry);
        $current_device = $device_model->getByAppSessionId((int)$session['session_id']);
        if (!$current_device || (int)($current_device['inbound_admin_requested'] ?? 0) !== 1) {
             
             
            $this->jsonError('sms_inbound_permission_not_requested', 'api.bad_request', [], 409);
            return;
        }
        $json = $this->readJsonInput();
        $device = $device_model->saveInboundOperatorConsent(
            (int)$session['session_id'],
            !empty($json['granted']) ? 'granted' : 'denied',
            !empty($json['android_permission'])
        );

        $this->jsonOut([
            'success' => 1,
            'device' => $this->smsDevicePayload($device),
        ]);
    }

    public function appSmsInboundMessage() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;
        $session = $this->currentAppSession();
        if (!$session) {
            $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
            return;
        }
        $device = (new SmsDevice($this->registry))->getByAppSessionId((int)$session['session_id']);
        if (!$device) {
            $this->jsonError('sms_device_missing', 'api.bad_request', [], 422);
            return;
        }
        $json = $this->readJsonInput();
        $result = (new SmsInboundService($this->registry))->receiveFromDevice($device, [
            'phone' => (string)($json['phone'] ?? ''),
            'text' => (string)($json['text'] ?? ''),
            'received_at' => (string)($json['received_at'] ?? ''),
            'dedupe_key' => (string)($json['dedupe_key'] ?? ''),
            'sim_subscription_id' => (int)($json['sim_subscription_id'] ?? -1),
            'device_message_id' => (string)($json['device_message_id'] ?? ''),
        ]);
        if (empty($result['success'])) {
            if ((string)($result['error'] ?? '') === 'trial_limit_exceeded') {
                $this->jsonTrialExpired();
                return;
            }
            $error = (string)($result['error'] ?? 'sms_inbound_failed');
            $http_code = in_array($error, ['sms_inbound_busy', 'sms_inbound_storage_failed'], true) ? 503 : 422;
            $this->jsonError($error, 'api.bad_request', ['retryable' => $http_code === 503 ? 1 : 0], $http_code);
            return;
        }
        $this->jsonOut([
            'success' => 1,
            'ignored' => (string)($result['ignored'] ?? ''),
            'duplicate' => !empty($result['duplicate']) ? 1 : 0,
            'chat_id' => (int)($result['chat_id'] ?? 0),
            'message_id' => (int)($result['message_id'] ?? 0),
        ]);
    }

    public function appSmsNextJob() {
        $manager_id = $this->checkAppToken(false);
        if (!$manager_id) return;
        $session = $this->currentAppSession();
        if (!$session) {
            $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
            return;
        }
        $device_model = new SmsDevice($this->registry);
        $device = $device_model->getByAppSessionId((int)$session['session_id']);
        if (!$device_model->canSend($device)) {
            $this->jsonOut(['success' => 1, 'job' => null]);
            return;
        }
        $requested_job_id = (int)(\EasyChat\Library\RequestContext::get()['job_id'] ?? 0);
        $reservation_key = (string)(\EasyChat\Library\RequestContext::get()['claim_id'] ?? '');
        if ((int)($session['app_version_code'] ?? 0) >= SmsJob::RESERVATION_KEY_REQUIRED_VERSION_CODE
            && trim($reservation_key) === '') {
            $this->jsonError('sms_claim_id_required', 'api.bad_request', [], 422);
            return;
        }
        $receipt_secret = CentralGatewayClient::ensureInstallSecret(new Setting($this->registry));
        $job = (new SmsJob($this->registry))->nextForSession(
            (int)$session['session_id'],
            $requested_job_id,
            $reservation_key,
            $receipt_secret
        );
        if (!$job) {
            $this->jsonOut(['success' => 1, 'job' => null]);
            return;
        }
        (new SmsJobEvent($this->registry))->recordServer(
            (int)$job['job_id'],
            'job_reserved',
            '',
            [],
            'server:job_reserved:' . (int)($job['attempt_count'] ?? 0)
        );
        (new SmsDeliveryProjector($this->registry))->syncFromSmsJob($job);
        $expires_at = trim((string)($job['expires_at'] ?? ''));
        $expires_ts = $expires_at !== ''
            ? \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $expires_at, new \DateTimeZone('UTC'))
            : false;
        $server_time_ms = (int)floor(microtime(true) * 1000);
        $expires_at_ms = $expires_ts instanceof \DateTimeImmutable ? $expires_ts->getTimestamp() * 1000 : 0;
        $this->jsonOut([
            'success' => 1,
            'job' => [
                'job_id' => (int)$job['job_id'],
                'phone' => (string)$job['phone_e164'],
                'text' => (string)$job['message_text'],
                'chat_id' => (int)$job['chat_id'],
                'message_id' => (int)$job['message_id'],
                'receipt_token' => (string)($job['receipt_token'] ?? ''),
                'expires_at' => $expires_at,
                'expires_at_iso' => $expires_ts instanceof \DateTimeImmutable ? $expires_ts->format('Y-m-d\TH:i:s\Z') : '',
                'expires_at_ms' => $expires_at_ms,
                 
                 
                'server_time_ms' => $server_time_ms,
                'expires_in_ms' => $expires_at_ms > 0 ? max(0, $expires_at_ms - $server_time_ms) : 0,
            ],
        ]);
    }

    public function appSmsCompleteJob() {
        $json = $this->readJsonInput();
        $job_id = (int)($json['job_id'] ?? 0);
        $status = (string)($json['status'] ?? 'failed');
        $error = (string)($json['error'] ?? '');
        $receipt_token = (string)($json['receipt_token'] ?? '');
        if ($job_id <= 0) {
            $this->jsonError('sms_job_id_required', 'api.bad_request', [], 422);
            return;
        }
        $job_model = new SmsJob($this->registry);
        $job = $receipt_token !== ''
            ? $job_model->completeForReceipt($job_id, $receipt_token, $status, $error)
            : [];
        if (!$job) {
             
             
            $manager_id = $this->checkAppToken(false, true);
            if (!$manager_id) return;
            $session = $this->currentAppSession();
            if (!$session) {
                $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
                return;
            }
            $job = $job_model->completeForSession((int)$session['session_id'], $job_id, $status, $error);
        }
        if ($job) {
            (new SmsJobEvent($this->registry))->recordServer(
                $job_id,
                'completion_reported',
                (string)$job['status'],
                [],
                'server:completion_reported'
            );
             
            (new SmsDeliveryProjector($this->registry))->syncFromSmsJob($job);
        }
        $this->jsonOut(['success' => 1, 'job' => $job ? [
            'job_id' => (int)$job['job_id'],
            'status' => (string)$job['status'],
            'delivery_status' => (string)($job['delivery_status'] ?? 'not_requested'),
            'aggregate_delivery_status' => (string)($job['delivery_status'] ?? 'not_requested'),
        ] : null]);
    }

    public function appSmsJobEvent() {
        $json = $this->readJsonInput();
        $job_id = (int)($json['job_id'] ?? 0);
        if ($job_id <= 0) {
            $this->jsonError('sms_job_id_required', 'api.bad_request', [], 422);
            return;
        }
        $context = is_array($json['context'] ?? null) ? $json['context'] : [];
        $receipt_token = (string)($json['receipt_token'] ?? '');
        $events = new SmsJobEvent($this->registry);
        $event = [];
        $job = [];
        $job_model = new SmsJob($this->registry);
        if ($receipt_token !== '') {
            $event = $events->recordFromReceipt(
                $job_id, $receipt_token, (string)($json['stage'] ?? ''),
                (string)($json['code'] ?? ''), $context,
                (string)($json['event_id'] ?? ''), max(0, (int)($json['client_time_ms'] ?? 0))
            );
            if (!$event) {
                 
                 
                $this->jsonError('sms_job_event_rejected', 'api.bad_request', [], 422);
                return;
            }
            $job = $job_model->getForReceipt($job_id, $receipt_token);
        } else {
            $manager_id = $this->checkAppToken(false, true);
            if (!$manager_id) return;
            $session = $this->currentAppSession();
            if (!$session) {
                $this->jsonError('unauthorized', 'api.session_invalid', [], 401);
                return;
            }
            $event = $events->recordFromApp(
                (int)$session['session_id'], $job_id,
                (string)($json['stage'] ?? ''), (string)($json['code'] ?? ''),
                $context, (string)($json['event_id'] ?? ''),
                max(0, (int)($json['client_time_ms'] ?? 0))
            );
            $job = $job_model->getForSession((int)$session['session_id'], $job_id);
        }
        if (!empty($event['retryable']) && (string)($event['error'] ?? '') === 'sms_job_event_busy') {
            $this->jsonError('sms_job_event_busy', 'api.temporarily_unavailable', ['retryable' => 1], 503);
            return;
        }
        if (!$event) {
            $this->jsonError('sms_job_event_rejected', 'api.bad_request', [], 422);
            return;
        }
        if ($job) {
            (new SmsDeliveryProjector($this->registry))->syncFromSmsJob($job);
        }
        $this->jsonOut([
            'success' => 1,
            'event' => $event,
            'sms_delivery_status' => (string)($job['delivery_status'] ?? 'not_requested'),
            'aggregate_delivery_status' => (string)($job['delivery_status'] ?? 'not_requested'),
            'job_status' => (string)($job['status'] ?? ''),
        ]);
    }

     
     
     

    private function appMutationError(array $result): array {
        $result['success'] = 0;
        $result['code'] = (string)($result['code'] ?? $result['error'] ?? 'request_failed');
        $result['error'] = (string)($result['error'] ?? $result['code']);
        return $result;
    }

    private function checkAppToken($touch_presence = true, $allow_trial_expired = false) {
        $token = $this->appAccessTokenFromHeaders();

        if (!$token) {
            $this->jsonError('auth_token_missing', 'api.auth_token_missing', [], 401);
            exit;
        }

        $model_appsession = new AppSession($this->registry);

        $session_data = $model_appsession->getManagerSessionData($token);
        if (!$session_data) {
            $expired = $model_appsession->getSessionByAccessTokenIncludingExpired($token);
            $this->jsonError(
                $expired ? 'access_token_expired' : 'unauthorized',
                'api.session_invalid',
                [],
                401
            );
            exit;
        }

        $domain_status = $this->resolveCentralDomainStatus(false);
        if ($domain_status['state'] === 'admin_blocked') {
            $model_appsession->deleteSessionByToken($token);
            $this->jsonDomainBlocked();
            exit;
        }
        if ($domain_status['state'] === 'trial_expired' && !$allow_trial_expired) {
            $this->jsonTrialExpired($domain_status);
            exit;
        }

        $manager_id  = (int)$session_data['manager_id'];
        
         
        $model_manager = new Manager($this->registry);
        $manager = $model_manager->getManagerById($manager_id);
        if (!$manager || (int)$manager['status'] !== 1) {
            $model_appsession->deleteSessionByToken($token);
            $this->jsonError('unauthorized', 'api.session_blocked', [], 401);
            exit;
        }

        $this->manager_id = $manager_id;
        $model_appsession->touchSession((int)($session_data['session_id'] ?? 0));
        if (!empty($session_data['app_device_id'])) {
            (new AppDevice($this->registry))->touchFromSession((int)$session_data['app_device_id']);
        }

        if ($touch_presence) {
            $presence = new PresenceService($this->registry);
            $presence->markManagerActive($manager_id, 'android_foreground');
        }

        return $manager_id;
    }

    private function currentAppSession() {
        $token = $this->appAccessTokenFromHeaders();
        if (!$token) {
            return null;
        }
        return (new AppSession($this->registry))->getSessionByToken($token);
    }

    private function appAccessTokenFromHeaders() {
        $headers = $this->getAllHeaders();
        $authorization = trim((string)($headers['Authorization'] ?? $headers['authorization'] ?? ''));
        if (preg_match('/^Bearer\s+([A-Za-z0-9._~-]+)$/iD', $authorization, $matches)) {
            return (string)$matches[1];
        }
        return trim((string)($headers['X-App-Token'] ?? $headers['x-app-token'] ?? ''));
    }

    private function smsDevicePayload(array $device): array {
        return [
            'device_id' => (int)($device['device_id'] ?? 0),
            'status' => (string)($device['status'] ?? 'not_requested'),
            'admin_requested' => (int)($device['admin_requested'] ?? 0),
            'operator_consent' => (string)($device['operator_consent'] ?? 'none'),
            'android_permission' => (string)($device['android_permission'] ?? 'unknown'),
            'inbound_status' => (string)($device['inbound_status'] ?? 'not_requested'),
            'inbound_admin_requested' => (int)($device['inbound_admin_requested'] ?? 0),
            'inbound_operator_consent' => (string)($device['inbound_operator_consent'] ?? 'none'),
            'inbound_android_permission' => (string)($device['inbound_android_permission'] ?? 'unknown'),
            'device_label' => (string)($device['device_label'] ?? ''),
            'reliability_status' => (string)($device['reliability_status'] ?? 'unknown'),
            'default_sms_subscription_id' => (int)($device['default_sms_subscription_id'] ?? -1),
            'last_inbound_subscription_id' => (int)($device['last_inbound_subscription_id'] ?? -1),
            'inbound_sim_mode' => (string)($device['inbound_sim_mode'] ?? 'default_sim'),
            'inbound_health_status' => (string)($device['inbound_health_status'] ?? 'unknown'),
            'inbound_last_error_code' => (string)($device['inbound_last_error_code'] ?? ''),
            'inbound_last_error_at' => (string)($device['inbound_last_error_at'] ?? ''),
            'inbound_pending_count' => (int)($device['inbound_pending_count'] ?? 0),
        ];
    }

    private function logSmsInboundHealthTransition(array $previous, array $current): void {
        $allowed_statuses = ['ready', 'warning', 'error'];
        $allowed_codes = [
            '',
            'local_persistence_failed',
            'local_decryption_failed',
            'local_outbox_capacity_exceeded',
            'local_outbox_retention_pruned',
            'inbound_filter_unavailable',
            'inbound_filter_stale',
            'inbound_filter_invalid',
            'sms_inbound_permission_revoked',
            'inbound_sim_filtered',
            'inbound_sim_unknown',
            'inbound_default_sim_unavailable',
            'upload_pending',
            'invalid_payload',
            'license_blocked',
            'session_changed',
        ];
        $status = (string)($current['inbound_health_status'] ?? '');
        if (!in_array($status, $allowed_statuses, true) || (int)($current['device_id'] ?? 0) <= 0) {
            return;
        }

        $code = (string)($current['inbound_last_error_code'] ?? '');
        $current_context = [
            'device_id' => (int)$current['device_id'],
            'health_status' => $status,
            'error_code' => in_array($code, $allowed_codes, true) ? $code : '',
        ];
        $previous_status = (string)($previous['inbound_health_status'] ?? '');
        $previous_code = (string)($previous['inbound_last_error_code'] ?? '');
        $previous_context = [
            'health_status' => in_array($previous_status, $allowed_statuses, true) ? $previous_status : '',
            'error_code' => in_array($previous_code, $allowed_codes, true) ? $previous_code : '',
        ];
        $comparable_current = $current_context;
        unset($comparable_current['device_id']);
        if ($previous && $previous_context === $comparable_current) {
            return;
        }

        try {
            (new Log('sms_inbound_health'))->write(json_encode(
                ['event' => 'sms_inbound_health_changed'] + $current_context,
                JSON_UNESCAPED_SLASHES
            ));
        } catch (\Throwable $ignored) {
             
        }
    }

    private function shouldTouchAndroidPresence(array $query) {
        return (string)($query['presence'] ?? '1') !== '0';
    }

    private function isCentralDomainBlocked() {
        return $this->resolveCentralDomainStatus(false)['state'] === 'admin_blocked';
    }

    private function resolveCentralDomainStatus($force_refresh = false) {
        $status = (new TrialLockGuard($this->registry))->resolveCentralDomainStatus((bool)$force_refresh);
        $this->trial_expired = $status['state'] === 'trial_expired';
        $this->last_domain_status = $status;
        return $status;
    }

    private function normalizeCentralDomainStatus(array $data) {
        $code = isset($data['code']) && is_scalar($data['code']) ? (string)$data['code'] : '';
        $reason = isset($data['reason']) && is_scalar($data['reason']) ? (string)$data['reason'] : '';
        $trial_expired = !empty($data['trial_expired'])
            || $code === 'trial_limit_exceeded'
            || $reason === 'trial_limit_exceeded';
        $blocked = !empty($data['blocked']);

        $state = 'active';
        if ($trial_expired) {
            $state = 'trial_expired';
        } elseif ($blocked || $code === 'domain_blocked' || $reason === 'blocked_by_admin') {
            $state = 'admin_blocked';
        }

        $this->trial_expired = $state === 'trial_expired';

        return [
            'state' => $state,
            'domain' => isset($data['domain']) && is_scalar($data['domain']) ? (string)$data['domain'] : (\EasyChat\Library\RequestContext::server()['HTTP_HOST'] ?? 'localhost'),
            'blocked' => $state === 'admin_blocked',
            'trial_expired' => $state === 'trial_expired',
            'reason' => $state === 'trial_expired' ? 'trial_limit_exceeded' : ($reason !== '' ? $reason : ($state === 'admin_blocked' ? 'blocked_by_admin' : '')),
            'code' => $state === 'trial_expired' ? 'trial_limit_exceeded' : ($code !== '' ? $code : ($state === 'admin_blocked' ? 'domain_blocked' : '')),
            'mode' => isset($data['mode']) && is_scalar($data['mode']) ? (string)$data['mode'] : '',
            'trial_push_used' => isset($data['trial_push_used']) ? (int)$data['trial_push_used'] : 0,
            'trial_push_limit' => isset($data['trial_push_limit']) ? (int)$data['trial_push_limit'] : 300,
            'purchase_url' => isset($data['purchase_url']) && is_scalar($data['purchase_url']) ? (string)$data['purchase_url'] : 'https://easychat24.ru/pricing/',
            'checked_at' => isset($data['checked_at']) ? (int)$data['checked_at'] : time(),
        ];
    }

    private function jsonDomainBlocked() {
        $this->jsonOut([
            'success' => 0,
            'error' => I18n::translate('api.domain_blocked', [], $this->resolveApiLocale()),
            'code' => 'domain_blocked',
            'message_key' => 'auth.domain_blocked'
        ], 403);
    }

    private function jsonTrialExpired(array $domain_status = []) {
        $this->trial_expired = true;
        $payload = (new TrialLockGuard($this->registry))->trialExpiredPayload($domain_status ?: $this->last_domain_status);
        $payload['error'] = I18n::translate('api.trial_limit_exceeded', [], $this->resolveApiLocale());
        $this->jsonOut($payload + [
            'success' => 0,
            'code' => 'trial_limit_exceeded',
            'message_key' => 'auth.trial_limit_exceeded',
            'trial_expired' => 1,
        ], 402);
    }

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

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

        $normalized = (new AppClock($this->registry))->fromDateStringSql($value);
        if ($normalized === '') {
            return '';
        }

        return $normalized;
    }

    private function resolveApiLocale() {
        $json = $this->readJsonInput();
        if (!empty($json['locale']) && is_scalar($json['locale'])) {
            return I18n::normalizeLocale((string)$json['locale']);
        }
        return I18n::resolveFromAcceptLanguage(\EasyChat\Library\RequestContext::server()['HTTP_ACCEPT_LANGUAGE'] ?? '');
    }

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

    private function normalizeSourceProviderFilter($source_provider) {
        $source_provider = $this->normalizeSourceProvider($source_provider);
        return $source_provider === 'all' ? '' : $source_provider;
    }

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

        $all_providers = ChannelCatalog::filterOrder();
        $providers = ['all'];
        $configured_providers = \EasyChat\Library\Messenger\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 = $provider === 'all' ? 'all' : $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 attachmentKindFromStoredFile(string $path, string $filename): string {
        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        $mime = AttachmentPolicy::detectMime($path, $extension);
        if ($extension === '' && $mime !== '') {
            $extension = AttachmentPolicy::extensionForMime($mime);
        }
        return AttachmentPolicy::isDocumentExtension($extension) || AttachmentPolicy::isDocumentMime($mime)
            ? 'document'
            : 'image';
    }

    private function appMarketplaceContextPayload(array $chat_info, array $context): array {
        $commerce_payload = (new CommerceContextService($this->registry))->payloadForRow($chat_info, $context, 'api/appGetCommerceImage');
        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;
    }

    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 \EasyChat\Model\Messenger($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 appOperatorFiltersPayload(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 ? 1 : 0,
            ];
        }
        return $operators;
    }

    private function appAssignmentPayload(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']) ? 1 : 0,
                'is_me' => (int)($assignee['id'] ?? 0) === $current_manager_id ? 1 : 0,
            ];
        }
        return [
            'assignees' => $assignees,
            'assigned_to_me' => !empty($snapshot['assigned_to_me']) ? 1 : 0,
            'has_active_assignees' => !empty($snapshot['has_active_assignees']) ? 1 : 0,
            'active_assignee_count' => (int)($snapshot['active_assignee_count'] ?? 0),
        ];
    }

    private function registerFcmTokenOnCentralGateway($fcm_token, $model_setting, $log, $manager_id = 0) {
        $central_api = CentralGatewayClient::normalizeBaseUrl($model_setting->get('chat_central_api'));
        if ($central_api === '') {
            $log->write('Central API URL is empty; FCM token registration skipped.');
            return ['success' => true, 'registered' => false];
        }

        $license = trim((string)$model_setting->get('chat_license'));
        $install_id = CentralGatewayClient::ensureInstallId($model_setting);
        $payload = [
            'domain' => \EasyChat\Library\RequestContext::server()['HTTP_HOST'] ?? 'localhost',
            'fcm_token' => $fcm_token,
            'operator_ref_hash' => (int)$manager_id > 0
                ? hash('sha256', $install_id . ':' . (int)$manager_id)
                : '',
        ];
        $body = $license !== ''
            ? CentralGatewayClient::signedBody($license, $install_id, $payload)
            : CentralGatewayClient::signedUnlicensedBody(
                CentralGatewayClient::ensureInstallSecret($model_setting),
                $install_id,
                $payload
            );

        $result = CentralGatewayClient::postJson(
            $central_api,
            '/api/v1/push/tokens',
            $body,
            4,
            $this->registry
        );

        $token_hash = substr(hash('sha256', $fcm_token), 0, 16);
        if ((int)$result['status'] === 200) {
            $log->write("FCM token registered on central gateway: token_hash={$token_hash}");
            return ['success' => true, 'registered' => true];
        } else {
            $err_msg = $result['error'] !== '' ? ', cURL Error: ' . $result['error'] : '';
            $error_code = (string)($result['error_code'] ?? '');
            $log->write("FCM token central registration failed: token_hash={$token_hash}, HTTP Status={$result['status']}, error_code={$error_code}{$err_msg}");
            return ['success' => false, 'registered' => false];
        }
    }

    private function readJsonInput(): array {
        $data = \EasyChat\Library\RequestContext::post();
        if (empty($data)) {
            $raw = file_get_contents('php://input');
            $data = json_decode($raw, true) ?? [];
        }
        return $data;
    }

     
    private function appOutboundDeliveryPayload(array $result): array {
        $payload = [
            'success' => !empty($result['success']) ? 1 : 0,
            'retryable' => !empty($result['retryable']) ? 1 : 0,
            'retry_scheduled' => !empty($result['retry_scheduled']) ? 1 : 0,
            'outcome_unknown' => !empty($result['outcome_unknown']) ? 1 : 0,
        ];
        foreach (['status', 'code', 'error'] as $key) {
            if (!isset($result[$key]) || !is_scalar($result[$key])) continue;
            $value = strtolower(trim((string)$result[$key]));
            $payload[$key] = preg_match('/^[a-z0-9_.-]{1,64}$/D', $value) ? $value : 'send_failed';
        }
        foreach (['job_id', 'delivery_id', 'device_id', 'push_sent'] as $key) {
            if (isset($result[$key])) $payload[$key] = max(0, (int)$result[$key]);
        }
        return $payload;
    }

    private function appUiTheme(Setting $model_setting): array {
        $accent = AccessibleColor::normalizeHex($model_setting->get('chat_widget_color', '#6366f1'));
        $mode = AccessibleColor::normalizeMode($model_setting->get('chat_on_accent_mode', AccessibleColor::MODE_AUTO));
        return [
            'accent' => $accent,
            'on_accent' => AccessibleColor::foregroundFor($accent, $mode),
            'on_accent_mode' => $mode,
        ];
    }

    private function jsonOut(array $data, $status_code = 200) {
        if ($status_code !== 200) {
            http_response_code($status_code);
        }

         
         
        header('Cache-Control: no-store, private, max-age=0, must-revalidate');
        header('Pragma: no-cache');

         
        $model_setting = new Setting($this->registry);
        $widget_color = $model_setting->get('chat_widget_color', '#6366f1');
        header('X-Chat-Widget-Color: ' . $widget_color);
        $ui_theme = $this->appUiTheme($model_setting);
        header('X-EasyChat-On-Accent: ' . $ui_theme['on_accent']);

         
        if ($this->manager_id > 0) {
            $model_manager = new Manager($this->registry);
            $manager = $model_manager->getManagerById($this->manager_id);
            if ($manager) {
                $permission = ($manager['role'] === 'admin' || (int)$manager['permission_delete_chats'] === 1) ? '1' : '0';
                header('X-Permission-Delete-Chats: ' . $permission);
            }
        }

         
        $trial_expired = false;
        if (isset($this->trial_expired)) {
            $trial_expired = $this->trial_expired;
        } else {
            $cache_key = 'chat_central_domain_status_cache';
            $cached = json_decode((string)$model_setting->get($cache_key, ''), true);
            if (is_array($cached)) {
                $trial_expired = !empty($cached['trial_expired']);
            }
        }
        header('X-Chat-Trial-Expired: ' . ($trial_expired ? '1' : '0'));

        if ($this->manager_id > 0) {
            $capabilities = $this->buildAppCapabilities($this->manager_id);
            $ai = $capabilities['ai_suggestions'];
            header('X-Chat-Ai-Suggestions: ' . (!empty($ai['available']) ? '1' : '0'));
            header('X-Chat-Ai-Suggestions-Reason: ' . preg_replace('/[^a-z0-9_\-]/i', '', (string)$ai['reason']));
        }

        header('Content-Type: application/json; charset=utf-8');
        header('Access-Control-Allow-Origin: *');
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
    }

    private function buildAppCapabilities($manager_id) {
        $model_setting = new Setting($this->registry);
        $settings = $model_setting->getAll();
        $model_manager = new Manager($this->registry);

        $suggestions_enabled = (string)($settings['chat_ai_suggestions_status'] ?? '0') === '1';
        $api_key = trim((string)($settings['chat_ai_key'] ?? ''));
        $manager_allowed = $model_manager->isAiSuggestionsAllowed((int)$manager_id, $settings);

        $reason = 'ready';
        if (!$suggestions_enabled) {
            $reason = 'ai_suggestions_disabled';
        } elseif ($api_key === '') {
            $reason = 'api_key_missing';
        } elseif (!$manager_allowed) {
            $reason = 'manager_disabled';
        }

        $context_limit = (int)($settings['chat_ai_suggestions_context_limit'] ?? 12);
        if ($context_limit < 4) {
            $context_limit = 4;
        } elseif ($context_limit > 12) {
            $context_limit = 12;
        }

        $dialog_tags_enabled = (string)($settings['chat_dialog_tags_status'] ?? '1') === '1';
        $dialog_notes_enabled = (string)($settings['chat_dialog_notes_status'] ?? '1') === '1';

        return [
            'ai_suggestions' => [
                'available' => $reason === 'ready' ? 1 : 0,
                'reason' => $reason,
                'use_knowledge' => (string)($settings['chat_ai_suggestions_use_knowledge'] ?? '1') === '1' ? 1 : 0,
                'context_limit' => $context_limit,
                'skills' => ['support', 'sales', 'clarify'],
            ],
            'quick_replies' => [
                'available' => 1,
            ],
            'operator_profile' => [
                'available' => 1,
                'avatar_upload' => 1,
                'password_change' => 1,
            ],
            'operator_drafts' => [
                'available' => 1,
                'version' => 1,
                'max_length' => OperatorDraftService::MAX_TEXT_LENGTH,
                'attachments' => 0,
            ],
            'customer_read_receipts' => [
                'available' => 1,
                'channels' => ['widget', 'max_dialog'],
            ],
            'dialog_tags' => [
                'enabled' => $dialog_tags_enabled ? 1 : 0,
            ],
            'dialog_notes' => [
                'enabled' => $dialog_notes_enabled ? 1 : 0,
            ],
        ];
    }

    private function jsonError($code, $message_key, array $params = [], $status_code = 200) {
        $this->jsonOut([
            'success' => 0,
            'error' => I18n::translate($message_key, $params, $this->resolveApiLocale()),
            'code' => $code,
            'message_key' => $message_key,
        ], $status_code);
    }

     
    public function appTyping() {
        $manager_id = $this->checkAppToken();
        if (!$manager_id) return;

        $json = $this->readJsonInput();
        $chat_id = isset($json['chat_id']) ? (int)$json['chat_id'] : 0;
        if ($chat_id) {
            $model_session = new Session($this->registry);
            $model_manager = new Manager($this->registry);
            $manager = $model_manager->getManagerById($manager_id);
            $manager_name = $manager ? $manager['name'] : I18n::translate('widget.operator', [], $this->resolveApiLocale());
            $manager_img = $manager ? $manager['image'] : '';
            $model_session->updateSession($chat_id, [
                'operator_typing_expire' => time() + 6,  
                'operator_typing_name'   => $manager_name,
                'operator_typing_image'  => $manager_img
            ]);
        }

        $this->jsonOut(['success' => 1]);
    }

    private function getAllHeaders(): array {
        if (function_exists('getallheaders')) {
            return getallheaders();
        }

        $headers = [];
        foreach (\EasyChat\Library\RequestContext::server() as $key => $value) {
            if (strpos($key, 'HTTP_') === 0) {
                $header = str_replace('_', '-', substr($key, 5));
                $header = ucwords(strtolower($header), '-');
                $headers[$header] = $value;
            }
        }
        return $headers;
    }

}
