<?php
namespace EasyChat\Model;

class SmsJob extends Base {
    public const DEFAULT_TTL_SECONDS = 7200;
    public const RESERVATION_KEY_REQUIRED_VERSION_CODE = 47;
    public const RECEIPT_WRITE_TTL_DAYS = 35;

    private $lock_namespace;

    public function create(array $data): int {
        $expires_at = trim((string)($data['expires_at'] ?? ''));
        $expires_sql = $expires_at !== ''
            ? "'" . $this->db->escape($expires_at) . "'"
            : 'DATE_ADD(UTC_TIMESTAMP(), INTERVAL ' . self::DEFAULT_TTL_SECONDS . ' SECOND)';
        $this->db->query(
            "INSERT INTO `{$this->prefix}sms_jobs`
             SET `public_id` = '" . $this->db->escape('sms_' . bin2hex(random_bytes(12))) . "',
                 `device_id` = " . (int)($data['device_id'] ?? 0) . ",
                 `app_device_id` = " . ((int)($data['app_device_id'] ?? 0) > 0 ? (int)$data['app_device_id'] : 'NULL') . ",
                 `app_session_id` = " . (int)($data['app_session_id'] ?? 0) . ",
                 `manager_id` = " . (int)($data['manager_id'] ?? 0) . ",
                 `chat_id` = " . (int)($data['chat_id'] ?? 0) . ",
                 `message_id` = " . (int)($data['message_id'] ?? 0) . ",
                 `delivery_id` = " . (int)($data['delivery_id'] ?? 0) . ",
                 `phone_e164` = '" . $this->db->escape((string)($data['phone_e164'] ?? '')) . "',
                 `message_text` = '" . $this->db->escape((string)($data['message_text'] ?? '')) . "',
                 `expires_at` = {$expires_sql},
                 `status` = 'pending',
                 `date_added` = NOW(),
                 `date_modified` = NOW()"
        );
        return (int)$this->db->getLastId();
    }

     
    public function createRateLimited(array $data, int $max_per_day = 3, bool $hold_until_commit = false): array {
        $phone = (string)($data['phone_e164'] ?? '');
        if ($phone === '') return ['success' => false, 'error' => 'sms_phone_invalid'];
        $max_per_day = max(1, min(50, $max_per_day));
        $lock_name = '';
        $acquired = false;
        $created = false;
        try {
            $lock_name = $this->acquirePhoneDispatchLock($phone, 2);
            $acquired = $lock_name !== '';
            if (!$acquired) return ['success' => false, 'error' => 'sms_dispatch_busy'];
            $existing = $this->getByMessageOrDelivery(
                (int)($data['message_id'] ?? 0),
                (int)($data['delivery_id'] ?? 0)
            );
            if ($existing) {
                if (((int)($data['message_id'] ?? 0) > 0 && (int)$existing['message_id'] !== (int)$data['message_id'])
                    || ((int)($data['delivery_id'] ?? 0) > 0 && (int)$existing['delivery_id'] !== (int)$data['delivery_id'])
                    || !hash_equals((string)$existing['phone_e164'], $phone)
                    || !hash_equals((string)$existing['message_text'], (string)($data['message_text'] ?? ''))) {
                    return ['success' => false, 'error' => 'sms_job_payload_conflict'];
                }
                return ['success' => true, 'job_id' => (int)$existing['job_id'], 'reused' => 1];
            }
             
             
             
            $expires_at = trim((string)($data['expires_at'] ?? ''));
            if ($expires_at !== '') {
                $expires_date = \DateTimeImmutable::createFromFormat(
                    '!Y-m-d H:i:s',
                    $expires_at,
                    new \DateTimeZone('UTC')
                );
                if (!$expires_date instanceof \DateTimeImmutable || $expires_date->format('Y-m-d H:i:s') !== $expires_at) {
                    return ['success' => false, 'error' => 'sms_expiry_invalid'];
                }
                if ($expires_date->getTimestamp() <= time()) {
                    return ['success' => false, 'error' => 'deadline_expired'];
                }
            }
            if ($this->countRecentByPhone($phone, 86400) >= $max_per_day) {
                return ['success' => false, 'error' => 'sms_phone_rate_limited'];
            }
            $job_id = $this->create($data);
            $created = true;
            $result = ['success' => true, 'job_id' => $job_id];
             
             
            if ($hold_until_commit) $result['rate_lock_name'] = $lock_name;
            return $result;
        } catch (\Throwable $e) {
             
            return ['success' => false, 'error' => 'sms_dispatch_storage_failed'];
        } finally {
            if ($acquired && (!$hold_until_commit || !$created)) $this->releaseRateLock($lock_name);
        }
    }

     
    public function acquirePhoneDispatchLock(string $phone, int $timeout_seconds = 2): string {
        if ($phone === '') return '';
        $timeout_seconds = max(0, min(5, $timeout_seconds));
        $lock_name = $this->dispatchLockName('phone', $phone);
        if ($lock_name === '') return '';
        $lock = $this->db->query(
            "SELECT GET_LOCK('" . $this->db->escape($lock_name) . "', {$timeout_seconds}) AS acquired"
        );
        return (int)($lock->row['acquired'] ?? 0) === 1 ? $lock_name : '';
    }

     
    public function acquireDeviceCapacityLock(int $device_id, int $timeout_seconds = 2): string {
        if ($device_id <= 0) return '';
        $timeout_seconds = max(0, min(5, $timeout_seconds));
        $lock_name = $this->dispatchLockName('device', (string)$device_id);
        if ($lock_name === '') return '';
        $lock = $this->db->query(
            "SELECT GET_LOCK('" . $this->db->escape($lock_name) . "', {$timeout_seconds}) AS acquired"
        );
        return (int)($lock->row['acquired'] ?? 0) === 1 ? $lock_name : '';
    }

    public function releaseRateLock(string $lock_name): void {
        if (!preg_match('/^easychat_sms_(?:phone|device)_[a-f0-9]{32}$/D', $lock_name)) return;
        try {
            $this->db->query("SELECT RELEASE_LOCK('" . $this->db->escape($lock_name) . "')");
        } catch (\Throwable $ignored) {
        }
    }

    public function nextForSession(int $app_session_id, int $requested_job_id = 0, string $reservation_key = '', string $receipt_secret = ''): array {
        $reservation_key = preg_replace('/[^a-zA-Z0-9._:-]/', '', trim($reservation_key));
         
         
         
        $reservation_key = substr($reservation_key, 0, 96);
        $reservation_e = $this->db->escape($reservation_key);
        $reserved_retry_filter = $reservation_key !== ''
            ? " OR (`status` = 'reserved' AND `reservation_key` = '{$reservation_e}')"
            : '';
        $job_filter = $requested_job_id > 0
            ? " AND `job_id` = " . (int)$requested_job_id
            : '';
         
         
         
         
        $active_session_filter =
            " AND EXISTS (
                SELECT 1
                FROM `{$this->prefix}app_session` active_session
                LEFT JOIN `{$this->prefix}app_devices` active_device
                  ON active_device.`device_id` = active_session.`app_device_id`
                WHERE active_session.`session_id` = " . (int)$app_session_id . "
                  AND active_session.`revoked_at` IS NULL
                  AND (active_session.`refresh_token_hash` IS NULL
                       OR active_session.`refresh_expires_at` > NOW())
                  AND (active_session.`app_device_id` IS NULL
                       OR active_device.`status` = 'active')
            )";
         
         
         
        $this->db->query(
            "UPDATE `{$this->prefix}sms_jobs`
             SET `status` = CASE
                     WHEN `delivery_status` IN ('delivered','pending') THEN 'sent'
                     WHEN `delivery_status` = 'failed' THEN 'failed'
                     ELSE 'outcome_unknown'
                 END,
                 `last_error` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_error`, 'deadline_expired_after_reservation'),
                 `last_stage_code` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_code`, 'deadline_expired_after_reservation'),
                 `last_stage_at` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_at`, NOW()),
                 `last_stage` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage`, 'deadline_expired'),
                 `completed_at` = COALESCE(`completed_at`, NOW()),
                 `delivery_status` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `delivery_status`, 'unknown'),
                 `date_modified` = NOW()
             WHERE `app_session_id` = " . (int)$app_session_id . "
               AND `status` = 'reserved'
               AND `expires_at` IS NOT NULL
               AND `expires_at` <= UTC_TIMESTAMP()"
        );
         
         
        $this->db->query(
            "UPDATE `{$this->prefix}sms_jobs`
             SET `status` = CASE
                     WHEN `delivery_status` IN ('delivered','pending') THEN 'sent'
                     WHEN `delivery_status` = 'failed' THEN 'failed'
                     WHEN `delivery_status` = 'unknown' THEN 'outcome_unknown'
                     ELSE 'expired'
                 END,
                 `last_error` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_error`, 'deadline_expired'),
                 `last_stage_code` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_code`, 'deadline_expired'),
                 `last_stage_at` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_at`, NOW()),
                 `last_stage` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage`, 'deadline_expired'),
                 `completed_at` = COALESCE(`completed_at`, NOW()),
                 `date_modified` = NOW()
             WHERE `app_session_id` = " . (int)$app_session_id . "
               AND `status` = 'pending'
               AND `expires_at` IS NOT NULL
               AND `expires_at` <= UTC_TIMESTAMP()"
        );
        $query = $this->db->query(
            "SELECT *
             FROM `{$this->prefix}sms_jobs`
             WHERE `app_session_id` = " . (int)$app_session_id . "
               AND (`status` = 'pending'{$reserved_retry_filter})
               AND (`expires_at` IS NULL OR `expires_at` > UTC_TIMESTAMP())
               {$job_filter}
               {$active_session_filter}
             ORDER BY `job_id` ASC
             LIMIT 1"
        );
        if (!$query->num_rows) {
            return [];
        }
        $job = $query->row;
        if ((string)$job['status'] === 'pending') {
            $this->db->query(
                "UPDATE `{$this->prefix}sms_jobs`
                 SET `status` = 'reserved',
                     `reservation_key` = '{$reservation_e}',
                     `reserved_at` = NOW(),
                     `attempt_count` = `attempt_count` + 1,
                     `date_modified` = NOW()
                 WHERE `job_id` = " . (int)$job['job_id'] . "
                   AND `status` = 'pending'
                   {$active_session_filter}"
            );
             
             
             
            if ($reservation_key === '' && (int)$this->db->countAffected() !== 1) return [];
        }
        $reserved = $this->db->query(
            "SELECT * FROM `{$this->prefix}sms_jobs`
             WHERE `job_id` = " . (int)$job['job_id'] . "
               AND `app_session_id` = " . (int)$app_session_id . "
               AND `status` = 'reserved'
               AND `reservation_key` = '{$reservation_e}'
               {$active_session_filter}
             LIMIT 1"
        );
        $job = $reserved->row ?: [];
        if (!$job) return [];

         
         
         
         
        $receipt_token = self::receiptTokenForClaim(
            $receipt_secret,
            (int)$job['job_id'],
            $app_session_id,
            $reservation_key
        );
        if ($receipt_token === '') return [];
        $receipt_hash = hash('sha256', $receipt_token);
        $this->db->query(
            "UPDATE `{$this->prefix}sms_jobs`
             SET `receipt_token_hash` = '" . $this->db->escape($receipt_hash) . "',
                 `date_modified` = NOW()
             WHERE `job_id` = " . (int)$job['job_id'] . "
               AND `app_session_id` = " . (int)$app_session_id . "
               AND `status` = 'reserved'
               AND `reservation_key` = '{$reservation_e}'"
        );
         
         
         
         
        $receipt_row = $this->db->query(
            "SELECT * FROM `{$this->prefix}sms_jobs`
             WHERE `job_id` = " . (int)$job['job_id'] . "
               AND `app_session_id` = " . (int)$app_session_id . "
               AND `status` = 'reserved'
               AND `reservation_key` = '{$reservation_e}'
               AND `receipt_token_hash` = '" . $this->db->escape($receipt_hash) . "'
             LIMIT 1"
        );
        if (!$receipt_row->num_rows) return [];
        $job = $receipt_row->row;
        $job['receipt_token'] = $receipt_token;
        return $job;
    }

    public function getById(int $job_id): array {
        $query = $this->db->query("SELECT * FROM `{$this->prefix}sms_jobs` WHERE `job_id` = " . (int)$job_id . " LIMIT 1");
        return $query->row ?: [];
    }

    public function getByMessageOrDelivery(int $message_id, int $delivery_id): array {
        $conditions = [];
        if ($message_id > 0) $conditions[] = "`message_id` = " . $message_id;
        if ($delivery_id > 0) $conditions[] = "`delivery_id` = " . $delivery_id;
        if (!$conditions) return [];
        $query = $this->db->query(
            "SELECT * FROM `{$this->prefix}sms_jobs`
             WHERE (" . implode(' OR ', $conditions) . ")
             ORDER BY `job_id` ASC
             LIMIT 1"
        );
        return $query->row ?: [];
    }

    public function registerPushAttempt(int $job_id, int $next_delay_seconds): array {
        $next_delay_seconds = max(15, min(1800, $next_delay_seconds));
        $this->db->query(
            "UPDATE `{$this->prefix}sms_jobs`
             SET `push_attempt_count` = `push_attempt_count` + 1,
                 `last_push_at` = UTC_TIMESTAMP(),
                 `next_push_at` = DATE_ADD(UTC_TIMESTAMP(), INTERVAL {$next_delay_seconds} SECOND),
                 `date_modified` = NOW()
             WHERE `job_id` = " . (int)$job_id . "
               AND `status` = 'pending'
               AND (`expires_at` IS NULL OR `expires_at` > UTC_TIMESTAMP())"
        );
        return $this->getById($job_id);
    }

    public function reassignPendingDevice(int $job_id, int $current_app_session_id, array $device): array {
        $device_id = (int)($device['device_id'] ?? 0);
        $app_session_id = (int)($device['app_session_id'] ?? 0);
        if ($job_id <= 0 || $device_id <= 0 || $app_session_id <= 0) return [];
        $this->db->query(
            "UPDATE `{$this->prefix}sms_jobs`
             SET `device_id` = {$device_id}, `app_session_id` = {$app_session_id},
                 `reservation_key` = '', `push_attempt_count` = 0,
                 `last_push_at` = NULL, `next_push_at` = NULL,
                 `last_stage` = 'job_created', `last_stage_code` = '', `last_stage_at` = UTC_TIMESTAMP(),
                 `date_modified` = NOW()
             WHERE `job_id` = {$job_id}
               AND `app_session_id` = " . (int)$current_app_session_id . "
               AND `status` = 'pending'
               AND (`expires_at` IS NULL OR `expires_at` > UTC_TIMESTAMP())"
        );
        return (int)$this->db->countAffected() === 1 ? $this->getById($job_id) : [];
    }

    public function markExpired(int $job_id): array {
        $this->db->query(
            "UPDATE `{$this->prefix}sms_jobs`
             SET `status` = CASE
                     WHEN `delivery_status` IN ('delivered','pending') THEN 'sent'
                     WHEN `delivery_status` = 'failed' THEN 'failed'
                     WHEN `delivery_status` = 'unknown' THEN 'outcome_unknown'
                     ELSE 'expired'
                 END,
                 `last_error` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_error`, 'deadline_expired'),
                 `last_stage_code` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_code`, 'deadline_expired'),
                 `last_stage_at` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_at`, NOW()),
                 `last_stage` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage`, 'deadline_expired'),
                 `completed_at` = COALESCE(`completed_at`, NOW()),
                 `date_modified` = NOW()
             WHERE `job_id` = " . (int)$job_id . " AND `status` = 'pending'"
        );
        return $this->getById($job_id);
    }

     
    public function expireOverdue(int $limit = 200): array {
        $limit = max(1, min(500, $limit));
        $query = $this->db->query(
            "SELECT `job_id`, `status`
             FROM `{$this->prefix}sms_jobs`
             WHERE `status` IN ('pending','reserved')
               AND `expires_at` IS NOT NULL
               AND `expires_at` <= UTC_TIMESTAMP()
             ORDER BY `expires_at` ASC, `job_id` ASC
             LIMIT {$limit}"
        );
        $pending = [];
        $reserved = [];
        foreach ($query->rows ?: [] as $row) {
            $id = (int)$row['job_id'];
            if ((string)$row['status'] === 'reserved') $reserved[] = $id;
            else $pending[] = $id;
        }
        if ($pending) {
            $this->db->query(
                "UPDATE `{$this->prefix}sms_jobs`
                 SET `status` = CASE
                         WHEN `delivery_status` IN ('delivered','pending') THEN 'sent'
                         WHEN `delivery_status` = 'failed' THEN 'failed'
                         WHEN `delivery_status` = 'unknown' THEN 'outcome_unknown'
                         ELSE 'expired'
                     END,
                     `last_error` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_error`, 'deadline_expired'),
                     `last_stage_code` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_code`, 'deadline_expired'),
                     `last_stage_at` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_at`, NOW()),
                     `last_stage` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage`, 'deadline_expired'),
                     `completed_at` = COALESCE(`completed_at`, NOW()),
                     `date_modified` = NOW()
                 WHERE `job_id` IN (" . implode(',', $pending) . ") AND `status` = 'pending'"
            );
        }
        if ($reserved) {
            $this->db->query(
                "UPDATE `{$this->prefix}sms_jobs`
                 SET `status` = CASE
                         WHEN `delivery_status` IN ('delivered','pending') THEN 'sent'
                         WHEN `delivery_status` = 'failed' THEN 'failed'
                         ELSE 'outcome_unknown'
                     END,
                     `last_error` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_error`, 'deadline_expired_after_reservation'),
                     `last_stage_code` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_code`, 'deadline_expired_after_reservation'),
                     `last_stage_at` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage_at`, NOW()),
                     `last_stage` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `last_stage`, 'deadline_expired'),
                     `completed_at` = COALESCE(`completed_at`, NOW()),
                     `delivery_status` = IF(`delivery_status` IN ('delivered','failed','pending','unknown'), `delivery_status`, 'unknown'),
                     `date_modified` = NOW()
                 WHERE `job_id` IN (" . implode(',', $reserved) . ") AND `status` = 'reserved'"
            );
        }
        $ids = array_merge($pending, $reserved);
        if (!$ids) return [];
        $result = $this->db->query(
            "SELECT * FROM `{$this->prefix}sms_jobs` WHERE `job_id` IN (" . implode(',', $ids) . ") ORDER BY `job_id` ASC"
        );
        return $result->rows ?: [];
    }

    public function getForSession(int $app_session_id, int $job_id): array {
        $query = $this->db->query(
            "SELECT * FROM `{$this->prefix}sms_jobs`
             WHERE `job_id` = " . (int)$job_id . "
               AND `app_session_id` = " . (int)$app_session_id . "
             LIMIT 1"
        );
        return $query->row ?: [];
    }

     
    public function getForReceipt(int $job_id, string $receipt_token): array {
        $hash = $this->receiptTokenHash($receipt_token);
        if ($job_id <= 0 || $hash === '') return [];
        $query = $this->db->query(
            "SELECT * FROM `{$this->prefix}sms_jobs`
             WHERE `job_id` = " . (int)$job_id . "
               AND `receipt_token_hash` = '" . $this->db->escape($hash) . "'
               AND `date_added` >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL " . self::RECEIPT_WRITE_TTL_DAYS . " DAY)
             LIMIT 1"
        );
        return $query->row ?: [];
    }

    public function completeForSession(int $app_session_id, int $job_id, string $status, string $error = ''): array {
        return $this->completeForScope(
            $job_id,
            "`app_session_id` = " . (int)$app_session_id,
            $status,
            $error
        );
    }

    public function completeForReceipt(int $job_id, string $receipt_token, string $status, string $error = ''): array {
        $hash = $this->receiptTokenHash($receipt_token);
        if ($job_id <= 0 || $hash === '') return [];
        return $this->completeForScope(
            $job_id,
            "`receipt_token_hash` = '" . $this->db->escape($hash) . "'
             AND `date_added` >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL " . self::RECEIPT_WRITE_TTL_DAYS . " DAY)",
            $status,
            $error
        );
    }

     
    public function cancelPending(int $job_id, string $code = 'cancelled_by_operator'): array {
        $code = $this->normalizeErrorCode($code);
        $this->db->query(
            "UPDATE `{$this->prefix}sms_jobs`
             SET `status` = 'cancelled', `last_error` = '" . $this->db->escape($code) . "',
                 `completed_at` = NOW(), `date_modified` = NOW()
             WHERE `job_id` = " . (int)$job_id . " AND `status` = 'pending'"
        );
        return (int)$this->db->countAffected() === 1 ? $this->getById($job_id) : [];
    }

    private function completeForScope(int $job_id, string $scope_sql, string $status, string $error): array {
        $status = in_array($status, ['sent', 'failed', 'permission_missing', 'sim_missing', 'expired', 'outcome_unknown'], true) ? $status : 'failed';
        $this->db->query('START TRANSACTION');
        try {
            $current = $this->db->query(
                "SELECT * FROM `{$this->prefix}sms_jobs`
                 WHERE `job_id` = " . (int)$job_id . "
                   AND {$scope_sql}
                 LIMIT 1 FOR UPDATE"
            );
            if (!$current || !$current->num_rows) {
                $this->db->query('ROLLBACK');
                return [];
            }
            $transition = self::completionTransition(
                (string)$current->row['status'],
                $status,
                (string)($current->row['delivery_status'] ?? 'not_requested')
            );
            if (empty($transition['apply'])) {
                 
                 
                $this->db->query('COMMIT');
                return $current->row;
            }
            $error = $this->normalizeErrorCode($error);
            $this->db->query(
                "UPDATE `{$this->prefix}sms_jobs`
                 SET `status` = '" . $this->db->escape((string)$transition['status']) . "',
                     `delivery_status` = '" . $this->db->escape((string)$transition['delivery_status']) . "',
                     `last_error` = '" . $this->db->escape(substr($error, 0, 255)) . "',
                     `completed_at` = NOW(),
                     `date_modified` = NOW()
                 WHERE `job_id` = " . (int)$job_id . "
                   AND {$scope_sql}
                   AND `status` = '" . $this->db->escape((string)$current->row['status']) . "'"
            );
            $job_query = $this->db->query(
                "SELECT * FROM `{$this->prefix}sms_jobs`
                 WHERE `job_id` = " . (int)$job_id . " AND {$scope_sql} LIMIT 1"
            );
            $job = $job_query->row ?: [];
            $this->db->query('COMMIT');
            return $job;
        } catch (\Throwable $e) {
            try { $this->db->query('ROLLBACK'); } catch (\Throwable $ignored) {}
            throw $e;
        }
    }

    public function countRecentByDevice(int $device_id, int $seconds): int {
        $seconds = max(60, min(86400, $seconds));
        $query = $this->db->query(
            "SELECT COUNT(*) AS total
             FROM `{$this->prefix}sms_jobs`
             WHERE `device_id` = " . (int)$device_id . "
               AND `date_added` >= DATE_SUB(NOW(), INTERVAL " . $seconds . " SECOND)"
        );
        return (int)($query->row['total'] ?? 0);
    }

    public function countRecentByPhone(string $phone, int $seconds): int {
        $seconds = max(60, min(86400, $seconds));
        $query = $this->db->query(
            "SELECT COUNT(*) AS total
             FROM `{$this->prefix}sms_jobs`
             WHERE `phone_e164` = '" . $this->db->escape($phone) . "'
               AND `date_added` >= DATE_SUB(NOW(), INTERVAL " . $seconds . " SECOND)"
        );
        return (int)($query->row['total'] ?? 0);
    }

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

     
    private function dispatchLockName(string $kind, string $identity): string {
        if (!in_array($kind, ['phone', 'device'], true) || $identity === '') return '';
        if ($this->lock_namespace === null) {
            $database_query = $this->db->query('SELECT DATABASE() AS `database_name`');
            $database_name = (string)($database_query->row['database_name'] ?? '');
            $this->lock_namespace = $database_name !== ''
                ? $database_name . "\0" . $this->prefix
                : '';
        }
        if ($this->lock_namespace === '') return '';
        return 'easychat_sms_' . $kind . '_' . substr(hash(
            'sha256',
            $this->lock_namespace . "\0" . $identity
        ), 0, 32);
    }

    private function receiptTokenHash(string $receipt_token): string {
        $receipt_token = strtolower(trim($receipt_token));
        if (!preg_match('/^[a-f0-9]{64}$/D', $receipt_token)) return '';
        return hash('sha256', $receipt_token);
    }

    private static function receiptTokenForClaim(string $secret, int $job_id, int $app_session_id, string $reservation_key): string {
        $secret = trim($secret);
        if ($secret === '' || $job_id <= 0 || $app_session_id <= 0) return '';
        return hash_hmac(
            'sha256',
            'easychat:sms-receipt:v1|' . $job_id . '|' . $app_session_id . '|' . $reservation_key,
            $secret
        );
    }

     
    private static function expiryTransition(string $current_status, string $delivery_status): array {
        if (in_array($delivery_status, ['delivered', 'pending'], true)) {
            return ['status' => 'sent', 'delivery_status' => $delivery_status, 'preserve_evidence' => true];
        }
        if ($delivery_status === 'failed') {
            return ['status' => 'failed', 'delivery_status' => 'failed', 'preserve_evidence' => true];
        }
        if ($delivery_status === 'unknown') {
            return ['status' => 'outcome_unknown', 'delivery_status' => 'unknown', 'preserve_evidence' => true];
        }
        return $current_status === 'reserved'
            ? ['status' => 'outcome_unknown', 'delivery_status' => 'unknown', 'preserve_evidence' => false]
            : ['status' => 'expired', 'delivery_status' => 'not_requested', 'preserve_evidence' => false];
    }

     
    private static function completionTransition(string $current, string $requested, string $delivery): array {
        if ($current === $requested || !in_array($current, ['reserved', 'outcome_unknown'], true)) {
            return ['apply' => false, 'status' => $current, 'delivery_status' => $delivery];
        }
        if ($requested === 'expired') {
             
             
             
            if ($current !== 'reserved' || $delivery !== 'not_requested') {
                return ['apply' => false, 'status' => $current, 'delivery_status' => $delivery];
            }
            return ['apply' => true, 'status' => 'expired', 'delivery_status' => 'not_requested'];
        }
        if ($requested === 'sent') {
            $next_delivery = in_array($delivery, ['delivered', 'failed'], true) ? $delivery : 'pending';
        } elseif ($requested === 'outcome_unknown') {
            $next_delivery = in_array($delivery, ['delivered', 'failed', 'pending'], true) ? $delivery : 'unknown';
        } else {
            $next_delivery = $delivery === 'delivered' ? 'delivered' : 'failed';
        }
        return ['apply' => true, 'status' => $requested, 'delivery_status' => $next_delivery];
    }
}
