<?php
namespace EasyChat\Library\Messenger;

use EasyChat\Library\CustomerContactService;
use EasyChat\Library\Notification\TrialLockGuard;
use EasyChat\Model\Customer;
use EasyChat\Model\Message;
use EasyChat\Model\Messenger as MessengerModel;
use EasyChat\Model\OutboundContact;
use EasyChat\Model\Session;
use PHPMax\Client;

class OutboundContactService {
    private $registry;
    private $logger;
    private $lock_namespace;

    public function __construct($registry) {
        $this->registry = $registry;
        $this->logger = new MessengerLogger($registry);
    }

    





    public function prepareScheduledChat(array $input): array {
        $channel_code = strtolower(trim((string)($input['channel'] ?? '')));
        $phone = SmsPhoneNumber::normalize((string)($input['phone'] ?? ''));
        $text = trim((string)($input['text'] ?? ''));
        $manager_id = max(0, (int)($input['manager_id'] ?? 0));
        $client_request_id = trim((string)($input['client_request_id'] ?? ''));
        if (!in_array($channel_code, ['sms', 'max'], true)) {
            return ['success' => false, 'code' => 'channel_not_supported', 'error' => 'Канал не поддерживается.'];
        }
        if ($phone === '' || $text === '') {
            return ['success' => false, 'code' => 'contact_invalid', 'error' => 'Проверьте телефон и текст сообщения.'];
        }
        if ($channel_code === 'sms' && (new TrialLockGuard($this->registry))->isSmsBlocked(false)) {
            return [
                'success' => false,
                'code' => 'trial_limit_exceeded',
                'error' => 'Бесплатный лимит push-уведомлений исчерпан. Чтобы планировать SMS, подключите лицензию EasyChat.',
            ];
        }

        $request_model = new OutboundContact($this->registry);
        $reservation = $request_model->reserveRateLimited([
            'channel' => $channel_code,
            'phone' => $phone,
            'customer_name' => trim((string)($input['customer_name'] ?? '')),
            'reason' => trim((string)($input['reason'] ?? 'manual')) ?: 'manual',
            'message_text' => $text,
            'manager_id' => $manager_id,
            'client_request_id' => $client_request_id,
            'payload_hash' => DirectMessageIdempotency::payloadHash([
                'channel' => $channel_code,
                'phone' => $phone,
                'text' => $text,
                'scheduled_at' => (string)($input['scheduled_at'] ?? ''),
            ]),
        ]);
        if (empty($reservation['success'])) {
            return $this->rateLimitResult(
                (string)($reservation['code'] ?? 'outbound_rate_storage_failed'),
                !empty($reservation['retryable'])
            );
        }

        $request_id = (int)($reservation['request_id'] ?? 0);
        $resolved = [];
        $contact_lock_name = '';
        try {
            if ($channel_code === 'sms') {
                $channel = (new SmsOutboundService($this->registry))->ensureChannel();
                if (!$channel) {
                    return ['success' => false, 'code' => 'sms_channel_missing', 'error' => 'SMS-канал не готов.'];
                }
                $name = trim((string)($input['customer_name'] ?? '')) ?: $phone;
                $resolved = $this->ensureExternalChat(
                    SmsProvider::PROVIDER,
                    (int)$channel['channel_id'],
                    $phone,
                    $phone,
                    $name,
                    $phone
                );
            } else {
                $model_messenger = new MessengerModel($this->registry);
                $channel = $model_messenger->getChannelByProvider(MaxProvider::PROVIDER);
                if (!$channel || (string)($channel['status'] ?? '') !== 'active') {
                    return ['success' => false, 'code' => 'max_channel_inactive', 'error' => 'Личная учётная запись MAX не подключена.'];
                }
                $credentials = json_decode((string)$channel['credentials_json'], true);
                $config_json = (string)($credentials['client_config'] ?? $credentials['phpmax_session'] ?? $channel['credentials_json']);
                $config = json_decode($config_json, true);
                if (!is_array($config)) {
                    $config = json_decode((string)$channel['credentials_json'], true) ?: [];
                }
                $client_options = $this->maxClientOptions($channel, $config);
                if (!$client_options) {
                    return ['success' => false, 'code' => 'max_session_missing', 'error' => 'MAX-сессия не найдена.'];
                }
                MaxRuntime::requireAutoload();
                $found = (new Client($client_options))->withOpenSession(function (Client $client) use ($phone) {
                    return $client->searchByPhone($phone);
                });
                $external_user_id = isset($found->id) ? (string)$found->id : '';
                if ($external_user_id === '') {
                    return ['success' => false, 'code' => 'max_user_not_found', 'error' => 'Не нашли этот номер в MAX или MAX не разрешил написать ему первым.'];
                }
                $display_name = $this->maxDisplayName($found, (string)($input['customer_name'] ?? ''));
                $account_id = (string)($config['account_id'] ?? '');
                if ($account_id === '' || preg_match('/^\d+$/', $account_id) !== 1) {
                    throw new \RuntimeException('max_account_identity_missing');
                }
                $external_chat_id = (string)(new Client($client_options))->withOpenSession(function (Client $client) use ($account_id, $external_user_id) {
                    return $client->getChatId((int)$account_id, (int)$external_user_id);
                });
                if ($external_chat_id === '' || preg_match('/^\d+$/', $external_chat_id) !== 1) {
                    throw new \RuntimeException('max_dialog_resolution_failed');
                }
                $resolved = $this->ensureExternalChat(
                    MaxProvider::PROVIDER,
                    (int)$channel['channel_id'],
                    $external_user_id,
                    $external_chat_id,
                    $display_name,
                    $phone,
                    $account_id
                );
            }
            $contact_lock_name = (string)($resolved['_contact_lock_name'] ?? '');
            $chat_id = (int)($resolved['chat_id'] ?? 0);
            if ($chat_id <= 0) {
                throw new \RuntimeException('scheduled_contact_chat_missing');
            }
            if ($request_id > 0) {
                $request_model->update($request_id, [
                    'status' => 'scheduled',
                    'chat_id' => $chat_id,
                    'messenger_contact_id' => (int)($resolved['contact']['contact_id'] ?? 0),
                    'last_error' => '',
                ]);
            }
            return [
                'success' => true,
                'chat_id' => $chat_id,
                'source_provider' => $channel_code,
                'phone' => $phone,
                'request_id' => $request_id,
            ];
        } catch (\Throwable $e) {
            if ($request_id > 0) {
                $request_model->update($request_id, [
                    'status' => 'failed',
                    'last_error' => 'scheduled_contact_prepare_failed',
                ]);
            }
            return ['success' => false, 'code' => 'scheduled_contact_prepare_failed', 'error' => 'Не удалось подготовить диалог для будущей отправки.'];
        } finally {
            if ($contact_lock_name !== '') {
                $this->releaseContactResolutionLock($contact_lock_name);
            }
        }
    }

    public function start(array $input): array {
        $channel = strtolower(trim((string)($input['channel'] ?? 'max')));
        if (!in_array($channel, ['max', 'sms'], true)) {
            return ['success' => false, 'error' => 'Канал не поддерживается.'];
        }
        $phone = SmsPhoneNumber::normalize((string)($input['phone'] ?? ''));
        $text = trim((string)($input['text'] ?? ''));
        $customer_name = trim((string)($input['customer_name'] ?? ''));
        $reason = trim((string)($input['reason'] ?? 'manual')) ?: 'manual';
        $manager_id = (int)($input['manager_id'] ?? 0);
        $client_request_id = trim((string)($input['client_request_id'] ?? ''));
        if ($phone === '') {
            return ['success' => false, 'error' => 'Укажите телефон в международном формате.'];
        }
        if ($text === '') {
            return ['success' => false, 'error' => 'Введите первое сообщение.'];
        }
         
         
        $idempotency_payload = [
            'chat_id' => 0,
            'manager_id' => $manager_id,
            'channel' => $channel,
            'phone' => $phone,
            'customer_name' => $customer_name,
            'reason' => $reason,
            'text' => $text,
        ];
        $idempotency = new DirectMessageIdempotency($this->registry);
         
         
         
        $claim = $idempotency->begin(
            'web_outbound',
            $manager_id,
            $client_request_id,
            $idempotency_payload
        );
        $claim_state = (string)($claim['state'] ?? 'invalid');
        if ($claim_state === 'invalid') {
            return [
                'success' => false,
                'http_code' => 422,
                'code' => 'client_request_id_invalid',
                'error' => 'Некорректный идентификатор отправки.',
            ];
        }
        if ($claim_state === 'conflict') {
            return [
                'success' => false,
                'http_code' => 409,
                'code' => 'client_request_id_reused',
                'error' => 'Эта попытка отправки уже относится к другому сообщению.',
            ];
        }
        if ($claim_state === 'completed') {
            $replayed = is_array($claim['result'] ?? null) ? $claim['result'] : [];
            $replayed['replayed'] = true;
            return $replayed;
        }
        if ($claim_state === 'processing') {
            return $this->processingResult((int)($claim['retry_after'] ?? 1));
        }

        $input['channel'] = $channel;
        $input['phone'] = $phone;
        $input['text'] = $text;
        $input['customer_name'] = $customer_name;
        $input['reason'] = $reason;
        $input['client_request_id'] = $client_request_id;
        $input['_idempotency'] = $idempotency;
        $input['_idempotency_payload'] = $idempotency_payload;
        $input['_idempotency_claim'] = $claim;
        $input['_dispatch_payload_hash'] = DirectMessageIdempotency::payloadHash([
            'channel' => $channel,
            'phone' => $phone,
            'text' => $text,
        ]);

        $request_model = new OutboundContact($this->registry);
        if ((int)($claim['message_id'] ?? 0) > 0) {
             
             
             
             
            try {
                return $this->saveAndDispatch(
                    $request_model,
                    $phone,
                    $text,
                    $input,
                    $channel,
                    0,
                    function(): array {
                        throw new \RuntimeException('outbound_contact_recovery_must_not_create_message');
                    }
                );
            } catch (\Throwable $e) {
                $this->releaseClaimForRetry($input);
                $this->logger->warning('outbound_contact_recovery_failed', [
                    'channel' => $channel,
                    'manager_id' => $manager_id,
                    'class' => get_class($e),
                ]);
                return [
                    'success' => false,
                    'http_code' => 500,
                    'code' => 'outbound_contact_save_failed',
                    'error' => 'Не удалось восстановить предыдущую отправку. Повторите попытку.',
                    'retryable' => 1,
                ];
            }
        }

        if ($channel === 'sms' && (new TrialLockGuard($this->registry))->isSmsBlocked(false)) {
            $response = [
                'success' => false,
                'error' => 'Бесплатный лимит push-уведомлений исчерпан. Чтобы отправлять SMS через телефон оператора, подключите лицензию EasyChat.',
                'code' => 'trial_limit_exceeded',
            ];
            $this->completeClaim($input, $response);
            return $response;
        }

        $reservation = $request_model->reserveRateLimited([
            'channel' => $channel,
            'phone' => $phone,
            'customer_name' => $customer_name,
            'reason' => $reason,
            'message_text' => $text,
            'manager_id' => $manager_id,
            'client_request_id' => $client_request_id,
            'payload_hash' => (string)$input['_dispatch_payload_hash'],
        ]);
        if (empty($reservation['success'])) {
            $response = $this->rateLimitResult((string)($reservation['code'] ?? 'outbound_rate_storage_failed'), !empty($reservation['retryable']));
            if (!empty($reservation['retryable'])) $this->releaseClaimForRetry($input);
            else $this->completeClaim($input, $response);
            return $response;
        }
        $input['_request_id'] = (int)($reservation['request_id'] ?? 0);
        if (!empty($reservation['active_duplicate']) && (int)($reservation['message_id'] ?? 0) > 0) {
            $message_id = (int)$reservation['message_id'];
            $sms_message_state = $this->smsMessageState($message_id);
            if (!empty($sms_message_state['active'])) {
                $input['_existing_message_id'] = $message_id;
                try {
                    return $this->saveAndDispatch(
                        $request_model,
                        $phone,
                        $text,
                        $input,
                        SmsProvider::PROVIDER,
                        0,
                        function(): array {
                            throw new \RuntimeException('active_sms_duplicate_must_not_create_chat');
                        }
                    );
                } catch (\Throwable $e) {
                    $this->releaseClaimForRetry($input);
                    $this->logger->warning('outbound_contact_duplicate_resume_failed', [
                        'manager_id' => $manager_id,
                        'class' => get_class($e),
                    ]);
                    return [
                        'success' => false,
                        'http_code' => 500,
                        'code' => 'outbound_contact_save_failed',
                        'error' => 'Не удалось продолжить принятую SMS. Повторите попытку.',
                        'retryable' => 1,
                    ];
                }
            }

             
             
            $request_model->update((int)$reservation['request_id'], [
                'status' => (string)($sms_message_state['status'] ?? 'failed'),
                'last_error' => (string)($sms_message_state['error'] ?? 'sms_delivery_state_missing'),
            ]);
            $reservation = $request_model->reserveRateLimited([
                'channel' => $channel,
                'phone' => $phone,
                'customer_name' => $customer_name,
                'reason' => $reason,
                'message_text' => $text,
                'manager_id' => $manager_id,
                'client_request_id' => $client_request_id,
                'payload_hash' => (string)$input['_dispatch_payload_hash'],
            ]);
            if (empty($reservation['success'])) {
                $response = $this->rateLimitResult((string)($reservation['code'] ?? 'outbound_rate_storage_failed'), !empty($reservation['retryable']));
                if (!empty($reservation['retryable'])) $this->releaseClaimForRetry($input);
                else $this->completeClaim($input, $response);
                return $response;
            }
            $input['_request_id'] = (int)($reservation['request_id'] ?? 0);
        }

        try {
            if ($channel === 'sms') {
                return $this->startSms($request_model, $phone, $text, $input);
            }
            return $this->startMax($request_model, $phone, $text, $input);
        } catch (\Throwable $e) {
            $this->releaseClaimForRetry($input);
            $this->logger->warning('outbound_contact_idempotent_start_failed', [
                'channel' => $channel,
                'manager_id' => $manager_id,
                'class' => get_class($e),
            ]);
            return [
                'success' => false,
                'http_code' => 500,
                'code' => 'outbound_contact_save_failed',
                'error' => 'Не удалось надёжно сохранить первое сообщение. Повторите отправку.',
                'retryable' => 1,
            ];
        }
    }

    private function startMax(OutboundContact $request_model, string $phone, string $text, array $input): array {
        $model_messenger = new MessengerModel($this->registry);
        $channel = $model_messenger->getChannelByProvider(MaxProvider::PROVIDER);
        if (!$channel || ($channel['status'] ?? '') !== 'active') {
            return $this->recordFailure($request_model, $phone, $text, $input, 'failed', 'max_channel_inactive', 'Личная учетная запись MAX не подключена.');
        }

        $credentials = json_decode((string)$channel['credentials_json'], true);
        $config_json = (string)($credentials['client_config'] ?? $credentials['phpmax_session'] ?? $channel['credentials_json']);
        $config = json_decode($config_json, true);
        if (!is_array($config)) {
            $config = json_decode((string)$channel['credentials_json'], true) ?: [];
        }
        $client_options = $this->maxClientOptions($channel, $config);
        if (!$client_options) {
            return $this->recordFailure($request_model, $phone, $text, $input, 'failed', 'max_session_missing', 'MAX-сессия не найдена.');
        }

        try {
            MaxRuntime::requireAutoload();
            $client = new Client($client_options);
            $found = $client->withOpenSession(function (Client $client) use ($phone) {
                return $client->searchByPhone($phone);
            });
        } catch (\Throwable $e) {
            return $this->recordFailure($request_model, $phone, $text, $input, 'failed', MaxRuntime::safeError($e), 'MAX не смог проверить этот номер. Попробуйте позже.');
        }

        $external_user_id = isset($found->id) ? (string)$found->id : '';
        if ($external_user_id === '') {
            return $this->recordFailure($request_model, $phone, $text, $input, 'not_found', 'max_user_not_found', 'Не нашли этот номер в MAX или MAX не разрешил написать ему первым.');
        }

        $display_name = $this->maxDisplayName($found, (string)($input['customer_name'] ?? ''));
        $account_id = (string)($config['account_id'] ?? '');
        if ($account_id === '' || preg_match('/^\d+$/', $account_id) !== 1) {
            return $this->recordFailure($request_model, $phone, $text, $input, 'failed', 'max_account_identity_missing', 'MAX не подтвердил подключённую учётную запись. Переподключите личный MAX.');
        }
        try {
            $external_chat_id = (string)(new Client($client_options))->withOpenSession(function (Client $client) use ($account_id, $external_user_id) {
                return $client->getChatId((int)$account_id, (int)$external_user_id);
            });
        } catch (\Throwable $e) {
            return $this->recordFailure($request_model, $phone, $text, $input, 'failed', 'max_dialog_resolution_failed', 'MAX не подтвердил личный диалог. Попробуйте позже.');
        }
        if ($external_chat_id === '' || preg_match('/^\d+$/', $external_chat_id) !== 1) {
            return $this->recordFailure($request_model, $phone, $text, $input, 'failed', 'max_dialog_resolution_failed', 'MAX не подтвердил личный диалог. Попробуйте позже.');
        }

        return $this->saveAndDispatch(
            $request_model,
            $phone,
            $text,
            $input,
            MaxProvider::PROVIDER,
            (int)$channel['channel_id'],
            function() use ($channel, $external_user_id, $external_chat_id, $display_name, $phone, $account_id): array {
                return $this->ensureExternalChat(MaxProvider::PROVIDER, (int)$channel['channel_id'], $external_user_id, $external_chat_id, $display_name, $phone, $account_id);
            }
        );
    }

    private function startSms(OutboundContact $request_model, string $phone, string $text, array $input): array {
        $channel = (new SmsOutboundService($this->registry))->ensureChannel();
        if (!$channel) {
            return $this->recordFailure($request_model, $phone, $text, $input, 'failed', 'sms_channel_missing', 'SMS-канал не готов.');
        }
        $name = trim((string)($input['customer_name'] ?? '')) ?: $phone;
        return $this->saveAndDispatch(
            $request_model,
            $phone,
            $text,
            $input,
            SmsProvider::PROVIDER,
            (int)$channel['channel_id'],
            function() use ($channel, $phone, $name): array {
                return $this->ensureExternalChat(SmsProvider::PROVIDER, (int)$channel['channel_id'], $phone, $phone, $name, $phone);
            }
        );
    }

    private function ensureExternalChat(
        string $provider,
        int $channel_id,
        string $external_user_id,
        string $external_chat_id,
        string $display_name,
        string $phone,
        string $account_id = ''
    ): array {
        $messenger = new MessengerModel($this->registry);
        $contact_lock_name = $this->acquireContactResolutionLock($channel_id, $external_user_id);
        if ($contact_lock_name === '') {
            throw new \RuntimeException('outbound_contact_resolution_busy');
        }

        try {
             
            $contact = $messenger->findContact($channel_id, $external_user_id);
            if ($contact && (int)($contact['chat_id'] ?? 0) > 0) {
                if ($provider === MaxProvider::PROVIDER) {
                     
                     
                     
                    $metadata = json_decode((string)($contact['metadata_json'] ?? ''), true);
                    $known_type = MaxConversationIdentity::chatTypeFromMetadata(
                        is_array($metadata) ? $metadata : []
                    );
                    if ($known_type === 'unknown' || $known_type === 'DIALOG') {
                        $contact = $this->repairVerifiedMaxDialogContact(
                            $messenger,
                            $contact,
                            $external_user_id,
                            $external_chat_id,
                            $display_name,
                            $phone,
                            $account_id
                        );
                    }
                }
                $this->releaseContactResolutionLock($contact_lock_name);
                return ['chat_id' => (int)$contact['chat_id'], 'contact' => $contact];
            }

            $session = new Session($this->registry);
            $chat_id = (int)$session->createSession('out_' . $provider . '_' . bin2hex(random_bytes(8)), $display_name, [
                'source_provider' => $provider,
                'source_channel_id' => $channel_id,
            ]);
            $metadata = [
                'phone' => $phone,
                'source' => 'outbound_first_contact',
            ];
            if ($provider === MaxProvider::PROVIDER) {
                $metadata = MaxConversationIdentity::outboundDialogMetadata(
                    $metadata,
                    $external_chat_id,
                    $external_user_id,
                    $account_id
                );
            }
            $contact = $messenger->upsertContact(
                $channel_id,
                $provider,
                $external_user_id,
                $external_chat_id,
                $display_name,
                '',
                '',
                $chat_id,
                $metadata,
                true
            );
            $messenger->linkContactToChat((int)$contact['contact_id'], $chat_id);
            $session->updateSession($chat_id, ['external_contact_id' => (int)$contact['contact_id']]);

            $customer_id = (new Customer($this->registry))->ensureCustomerForChat($chat_id);
            if ($customer_id > 0) {
                (new CustomerContactService($this->registry))->upsertContact($customer_id, 'phone', $phone, [
                    'source' => 'operator',
                    'status' => CustomerContactService::STATUS_OPERATOR_CONFIRMED,
                    'source_chat_id' => $chat_id,
                    'primary' => true,
                ]);
            }
             
            return [
                'chat_id' => $chat_id,
                'contact' => $contact,
                '_contact_lock_name' => $contact_lock_name,
            ];
        } catch (\Throwable $e) {
            $this->releaseContactResolutionLock($contact_lock_name);
            throw $e;
        }
    }

    



    private function repairVerifiedMaxDialogContact(
        MessengerModel $messenger,
        array $contact,
        string $external_user_id,
        string $external_chat_id,
        string $display_name,
        string $phone,
        string $account_id
    ): array {
        $metadata = json_decode((string)($contact['metadata_json'] ?? ''), true);
        $metadata = is_array($metadata) ? $metadata : [];
        $metadata['phone'] = $phone;
        if (empty($metadata['source'])) {
            $metadata['source'] = 'outbound_first_contact';
        }
        $metadata = MaxConversationIdentity::outboundDialogMetadata(
            $metadata,
            $external_chat_id,
            $external_user_id,
            $account_id
        );

        return $messenger->upsertContact(
            (int)$contact['channel_id'],
            MaxProvider::PROVIDER,
            $external_user_id,
            $external_chat_id,
            $display_name !== '' ? $display_name : (string)$contact['display_name'],
            (string)($contact['username'] ?? ''),
            (string)($contact['profile_url'] ?? ''),
            (int)$contact['chat_id'],
            $metadata,
            true
        );
    }

    private function saveAndDispatch(
        OutboundContact $request_model,
        string $phone,
        string $text,
        array $input,
        string $provider,
        int $channel_id,
        callable $resolve_chat
    ): array {
        $message_model = new Message($this->registry);
        $manager_id = (int)($input['manager_id'] ?? 0);
        $client_request_id = (string)($input['client_request_id'] ?? '');
        $idempotency = $input['_idempotency'] ?? null;
        $claim = $input['_idempotency_claim'] ?? null;
        if (!$idempotency instanceof DirectMessageIdempotency || !is_array($claim)) {
            throw new \RuntimeException('outbound_contact_idempotency_missing');
        }

        $sms_dispatch_outbox = $provider === SmsProvider::PROVIDER
            ? new DirectSmsDispatchOutbox($this->registry)
            : null;
        $request_id = (int)($input['_request_id'] ?? 0);
        $chat_id = 0;
        $message_id = (int)($claim['message_id'] ?? 0);
        if ($message_id <= 0) {
            $contact_lock_name = '';
            try {
                $attached = $idempotency->attachMessage(
                    'web_outbound',
                    $manager_id,
                    $client_request_id,
                    (string)($claim['processing_token'] ?? ''),
                    function() use (
                        $request_model,
                        $message_model,
                        $resolve_chat,
                        $text,
                        $input,
                        $provider,
                        $channel_id,
                        $manager_id,
                        $sms_dispatch_outbox,
                        &$request_id,
                        &$chat_id,
                        &$contact_lock_name
                    ): int {
                        $existing_message_id = (int)($input['_existing_message_id'] ?? 0);
                        if ($existing_message_id > 0) return $existing_message_id;
                         
                         
                         
                        $chat = $resolve_chat();
                        $contact_lock_name = (string)($chat['_contact_lock_name'] ?? '');
                        $chat_id = (int)($chat['chat_id'] ?? 0);
                        if ($chat_id <= 0) {
                            throw new \RuntimeException('outbound_contact_chat_create_failed');
                        }
                        if ($request_id <= 0) {
                            throw new \RuntimeException('outbound_contact_request_missing');
                        }
                        $message_id = (int)$message_model->addMessage([
                            'chat_id' => $chat_id,
                            'author' => 2,
                            'manager_id' => $manager_id,
                            'manager_name' => (string)($input['manager_name'] ?? 'Оператор'),
                            'text' => urlencode($text),
                            'status_user' => 0,
                            'status_admin' => 1,
                            'ip' => (string)($input['ip'] ?? ''),
                            'source_provider' => $provider,
                            'source_channel_id' => $channel_id,
                        ]);
                        if ($sms_dispatch_outbox) {
                            $sms_dispatch_outbox->enqueue($message_id);
                        }
                        $request_model->update($request_id, [
                            'status' => 'sending',
                            'chat_id' => $chat_id,
                            'messenger_contact_id' => (int)($chat['contact']['contact_id'] ?? 0),
                            'message_id' => $message_id,
                        ]);
                        return $message_id;
                    }
                );
            } finally {
                if ($contact_lock_name !== '') {
                    $this->releaseContactResolutionLock($contact_lock_name);
                }
            }
            if ((string)($attached['state'] ?? '') !== 'attached') {
                return $this->processingResult(1);
            }
            $message_id = (int)($attached['message_id'] ?? 0);
        }

        $saved_message = $message_model->getMessageById($message_id);
        if (!$saved_message) {
            throw new \RuntimeException('outbound_contact_message_missing');
        }
        $chat_id = (int)($saved_message['chat_id'] ?? $chat_id);
        if ($provider === SmsProvider::PROVIDER) {
             
             
            (new SmsInboundFilterRefreshScheduler($this->registry))->enqueue();
        }
        if ($request_id <= 0) {
            $request = $request_model->getByMessageId($message_id);
            $request_id = (int)($request['request_id'] ?? 0);
        }

        $dispatch_guard = new OutboundDispatchGuard($this->registry);
        $result = $dispatch_guard->sendMessageId((int)$message_id);
        if ($provider === MaxProvider::PROVIDER
            && (string)($result['error'] ?? '') === 'reply_not_allowed'
            && (string)($result['reply_capability']['reason'] ?? '') === 'max_chat_type_pending'
        ) {
             
             
             
             
            $repair_lock_name = '';
            try {
                $repaired_chat = $resolve_chat();
                $repair_lock_name = (string)($repaired_chat['_contact_lock_name'] ?? '');
                if ((int)($repaired_chat['chat_id'] ?? 0) === $chat_id) {
                    $result = $dispatch_guard->sendMessageId((int)$message_id);
                }
            } finally {
                if ($repair_lock_name !== '') {
                    $this->releaseContactResolutionLock($repair_lock_name);
                }
            }
        }
        if ($sms_dispatch_outbox) {
            $sms_dispatch_outbox->settleAfterImmediateAttempt($message_id, $result);
        }
        $recovery_active = $sms_dispatch_outbox
            ? $sms_dispatch_outbox->hasPendingRecovery($message_id)
            : false;
        $decision = $sms_dispatch_outbox
            ? SmsDispatchAcceptancePolicy::decide($result, $recovery_active)
            : ['accepted' => !empty($result['success']), 'dispatch_pending' => false, 'delivery' => $result];
        $result = $decision['delivery'];
        $ok = !empty($decision['accepted']);
        $dispatch_pending = false;
        if (!empty($decision['dispatch_pending'])) {
             
             
             
            $dispatch_pending = true;
        }
        $request_status = 'failed';
        if ($ok) {
             
             
             
            $request_status = (string)($result['status'] ?? '') === 'queued' ? 'sent_waiting' : 'sent';
        }
        if ($request_id > 0) {
            $failure_code = (string)($result['error'] ?? 'send_failed');
            if ($failure_code === 'reply_not_allowed'
                && !empty($result['reply_capability']['reason'])
            ) {
                $failure_code = (string)$result['reply_capability']['reason'];
            }
            $request_model->update($request_id, [
                'status' => $request_status,
                'last_error' => $dispatch_pending
                    ? 'sms_dispatch_pending'
                    : ($ok ? '' : $this->stableErrorCode($failure_code)),
            ]);
        }
        $response = [
            'success' => $ok,
            'chat_id' => $chat_id,
            'message_id' => (int)$message_id,
            'request_id' => $request_id,
            'outbound_delivery' => $result,
            'error' => $ok ? '' : $this->safeSendError((string)($result['error'] ?? 'send_failed')),
            'replayed' => !empty($claim['replayed']) || !empty($input['_existing_message_id']),
        ];
        if ($dispatch_pending) {
            $response['dispatch_pending'] = true;
            $response['code'] = 'sms_dispatch_pending';
        }
        $idempotency->complete(
            'web_outbound',
            $manager_id,
            $client_request_id,
            (string)($claim['processing_token'] ?? ''),
            $response
        );
        return $response;
    }

    private function recordFailure(
        OutboundContact $request_model,
        string $phone,
        string $text,
        array $input,
        string $status,
        string $last_error,
        string $message
    ): array {
        $request_id = (int)($input['_request_id'] ?? 0);
        if ($request_id <= 0) {
            $existing = $request_model->getByClientRequestId(
                (int)($input['manager_id'] ?? 0),
                (string)($input['client_request_id'] ?? '')
            );
            $request_id = (int)($existing['request_id'] ?? 0);
        }
        if ($request_id > 0) {
            $request_model->update($request_id, [
                'status' => $status,
                'last_error' => $this->stableErrorCode($last_error),
            ]);
        }
        $response = [
            'success' => false,
            'code' => $this->stableErrorCode($last_error),
            'error' => $message,
            'request_id' => $request_id,
        ];
        $this->completeClaim($input, $response);
        return $response;
    }

    private function processingResult(int $retry_after): array {
        return [
            'success' => false,
            'http_code' => 409,
            'code' => 'outbound_contact_in_progress',
            'error' => 'Сервер завершает предыдущую попытку отправки.',
            'retryable' => 1,
            'retry_after' => max(1, min(3, $retry_after)),
        ];
    }

    private function rateLimitResult(string $code, bool $retryable): array {
        $messages = [
            'outbound_manager_rate_limited' => 'Слишком много проверок подряд. Попробуйте через минуту.',
            'outbound_channel_rate_limited' => 'Достигнут часовой лимит первых сообщений. Попробуйте позже.',
            'outbound_phone_rate_limited' => 'Этот номер уже проверяли несколько раз сегодня.',
            'client_request_id_reused' => 'Эта попытка отправки уже относится к другому сообщению.',
            'outbound_rate_busy' => 'Сервер завершает другую проверку. Повторите попытку.',
            'outbound_rate_storage_failed' => 'Не удалось надёжно сохранить проверку. Повторите попытку.',
        ];
        $code = isset($messages[$code]) ? $code : 'outbound_rate_storage_failed';
        $response = [
            'success' => false,
            'code' => $code,
            'error' => $messages[$code],
        ];
        if ($retryable) {
            $response['retryable'] = 1;
            $response['retry_after'] = 2;
        }
        return $response;
    }

    private function completeClaim(array $input, array $response): void {
        $idempotency = $input['_idempotency'] ?? null;
        $claim = $input['_idempotency_claim'] ?? null;
        if (!$idempotency instanceof DirectMessageIdempotency || !is_array($claim)) return;
        $idempotency->complete(
            'web_outbound',
            (int)($input['manager_id'] ?? 0),
            (string)($input['client_request_id'] ?? ''),
            (string)($claim['processing_token'] ?? ''),
            $response
        );
    }

    private function releaseClaimForRetry(array $input): void {
        $idempotency = $input['_idempotency'] ?? null;
        $claim = $input['_idempotency_claim'] ?? null;
        if (!$idempotency instanceof DirectMessageIdempotency || !is_array($claim)) return;
        $idempotency->releaseForRetry(
            'web_outbound',
            (int)($input['manager_id'] ?? 0),
            (string)($input['client_request_id'] ?? ''),
            (string)($claim['processing_token'] ?? '')
        );
    }

     
    private function smsMessageState(int $message_id): array {
        if ($message_id <= 0) {
            return ['active' => false, 'status' => 'failed', 'error' => 'sms_delivery_state_missing'];
        }
        $job = (new \EasyChat\Model\SmsJob($this->registry))->getByMessageOrDelivery($message_id, 0);
        if ($job) {
            $job_status = (string)($job['status'] ?? '');
            $delivery_status = (string)($job['delivery_status'] ?? 'not_requested');
            if ($delivery_status === 'delivered' || $job_status === 'sent') {
                return ['active' => false, 'status' => 'sent', 'error' => ''];
            }
             
             
             
            if (in_array($job_status, ['pending', 'reserved', 'outcome_unknown'], true)
                || $delivery_status === 'unknown') {
                return ['active' => true, 'status' => 'pending', 'error' => ''];
            }
            $code = trim((string)($job['last_stage_code'] ?? ''));
            if ($code === '') $code = (string)($job['last_error'] ?? 'sms_delivery_failed');
            return ['active' => false, 'status' => 'failed', 'error' => $this->stableErrorCode($code)];
        }
        if ((new DirectSmsDispatchOutbox($this->registry))->hasPendingRecovery($message_id)) {
            return ['active' => true, 'status' => 'pending', 'error' => ''];
        }
        return ['active' => false, 'status' => 'failed', 'error' => 'sms_delivery_state_missing'];
    }

    private function stableErrorCode(string $error): string {
        $error = strtolower(trim($error));
        $error = (string)preg_replace('/[^a-z0-9._-]/', '_', $error);
        return substr($error !== '' ? $error : 'send_failed', 0, 64);
    }

    private function acquireContactResolutionLock(int $channel_id, string $external_user_id): string {
        if ($channel_id <= 0 || $external_user_id === '') return '';
        $db = $this->registry->get('db');
        if ($this->lock_namespace === null) {
            $database_query = $db->query('SELECT DATABASE() AS `database_name`');
            $database_name = (string)($database_query->row['database_name'] ?? '');
            $this->lock_namespace = $database_name !== ''
                ? $database_name . "\0" . easychat_db_prefix()
                : '';
        }
        if ($this->lock_namespace === '') return '';
        $lock_name = 'easychat_contact_' . substr(
            hash('sha256', $this->lock_namespace . "\0" . $channel_id . "\0" . $external_user_id),
            0,
            32
        );
        $query = $db->query(
            "SELECT GET_LOCK('" . $db->escape($lock_name) . "', 3) AS acquired"
        );
        return (int)($query->row['acquired'] ?? 0) === 1 ? $lock_name : '';
    }

    private function releaseContactResolutionLock(string $lock_name): void {
        if (!preg_match('/^easychat_contact_[a-f0-9]{32}$/D', $lock_name)) return;
        try {
            $db = $this->registry->get('db');
            $db->query("SELECT RELEASE_LOCK('" . $db->escape($lock_name) . "')");
        } catch (\Throwable $ignored) {
        }
    }

    private function maxClientOptions(array $channel, array $config) {
        $work_dir = (string)($config['work_dir'] ?? MaxRuntime::sessionRoot());
        $session_name = (string)($config['session_name'] ?? '');
        if ($work_dir === '' || $session_name === '') {
            return null;
        }
        $settings = json_decode((string)($channel['settings_json'] ?? '{}'), true);
        $settings = is_array($settings) ? $settings : [];
        return (new MaxSessionLifecycle($this->registry))->clientOptions($channel, [
            'token' => (string)($config['token'] ?? '') ?: null,
            'requestTimeout' => 12,
            'connectTimeout' => 8,
            'executionSafetyMargin' => 3,
            'telemetry' => false,
        ], max(1, (int)($settings['session_generation'] ?? 1)));
    }

    private function maxDisplayName($user, string $fallback): string {
        if ($fallback !== '') {
            return $fallback;
        }
        $names = isset($user->names) && is_array($user->names) ? $user->names : [];
        foreach (['name', 'firstName', 'fullName', 'displayName'] as $key) {
            if (!empty($names[$key])) {
                return (string)$names[$key];
            }
        }
        return isset($user->id) ? 'MAX ' . (string)$user->id : 'MAX клиент';
    }

    private function safeSendError(string $error): string {
        if ($error === 'sms_device_missing') {
            return 'Нет телефона с разрешением на отправку SMS.';
        }
        if ($error === 'sms_phone_rate_limited') {
            return 'На этот номер уже отправляли несколько SMS сегодня.';
        }
        if ($error === 'contact_missing') {
            return 'Не удалось подготовить контакт для отправки.';
        }
        return 'Не удалось отправить первое сообщение.';
    }
}
