<?php
namespace EasyChat\Library\Messenger;








class DirectMessageIdempotency {
    public const LEASE_SECONDS = 300;
    public const COMPLETED_RETENTION_DAYS = 30;
    public const ABANDONED_RETENTION_DAYS = 7;

    private $registry;
    private $db;
    private $prefix;

    public function __construct($registry) {
        $this->registry = $registry;
        $this->db = $registry->get('db');
        $this->prefix = easychat_db_prefix();
    }

    public static function validClientRequestId(string $client_request_id): bool {
        $length = strlen($client_request_id);
        return $length >= 16
            && $length <= 80
            && preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]*$/D', $client_request_id) === 1;
    }

     
    public static function payloadHash(array $payload): string {
        $canonical = self::canonicalize($payload);
        $encoded = json_encode($canonical, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!is_string($encoded)) {
            throw new \RuntimeException('message_idempotency_payload_encode_failed');
        }
        return hash('sha256', $encoded);
    }

    







    public function inspect(
        string $surface,
        int $manager_id,
        string $client_request_id,
        array $payload
    ): array {
        $client_request_id = trim($client_request_id);
        if (!self::validClientRequestId($client_request_id)) {
            return ['state' => 'invalid'];
        }

        $scope = $this->requestScope($surface, $manager_id);
        if ($scope === '') {
            return ['state' => 'invalid'];
        }

        $query = $this->db->query(
            "SELECT *,
                    (`processing_until` IS NOT NULL AND `processing_until` <= NOW()) AS `lease_expired`,
                    GREATEST(1, TIMESTAMPDIFF(SECOND, NOW(), COALESCE(`processing_until`, NOW()))) AS `retry_after`
             FROM `{$this->prefix}message_send_requests`
             WHERE `request_scope` = '" . $this->db->escape($scope) . "'
               AND `client_request_id` = '" . $this->db->escape($client_request_id) . "'
             LIMIT 1"
        );
        $row = $query->row ?: [];
        if (!$row) {
            return ['state' => 'missing'];
        }

        $payload_hash = self::payloadHash($payload);
        $chat_id = max(0, (int)($payload['chat_id'] ?? 0));
        if (!hash_equals((string)$row['payload_hash'], $payload_hash)
            || (int)$row['chat_id'] !== $chat_id
            || (int)$row['manager_id'] !== max(0, $manager_id)) {
            return ['state' => 'conflict'];
        }

        if ((string)$row['status'] === 'completed') {
            return [
                'state' => 'completed',
                'message_id' => (int)$row['message_id'],
                'result' => $this->decodeResult((string)$row['result_json']),
                'replayed' => 1,
            ];
        }

        if (!empty($row['lease_expired'])) {
            return [
                'state' => 'recoverable',
                'message_id' => (int)$row['message_id'],
                'replayed' => 1,
            ];
        }

        return [
            'state' => 'processing',
            'message_id' => (int)$row['message_id'],
            'retry_after' => max(1, min(3, (int)($row['retry_after'] ?? 1))),
            'replayed' => 1,
        ];
    }

    










    public function begin(
        string $surface,
        int $manager_id,
        string $client_request_id,
        array $payload,
        ?callable $create_message = null
    ): array {
        $client_request_id = trim($client_request_id);
        if (!self::validClientRequestId($client_request_id)) {
            return ['state' => 'invalid'];
        }

        $scope = $this->requestScope($surface, $manager_id);
        if ($scope === '') {
            return ['state' => 'invalid'];
        }
        $this->maybeCleanupJournal($client_request_id);

        $payload_hash = self::payloadHash($payload);
        $processing_token = bin2hex(random_bytes(32));
        $processing_token_hash = hash('sha256', $processing_token);
        $scope_e = $this->db->escape($scope);
        $request_e = $this->db->escape($client_request_id);
        $hash_e = $this->db->escape($payload_hash);
        $token_hash_e = $this->db->escape($processing_token_hash);
        $chat_id = max(0, (int)($payload['chat_id'] ?? 0));
        $manager_id = max(0, $manager_id);

        $this->db->query('START TRANSACTION');
        try {
             
             
            $this->db->query(
                "INSERT IGNORE INTO `{$this->prefix}message_send_requests` SET
                    `request_scope` = '{$scope_e}',
                    `client_request_id` = '{$request_e}',
                    `payload_hash` = '{$hash_e}',
                    `chat_id` = {$chat_id},
                    `manager_id` = {$manager_id},
                    `message_id` = 0,
                    `status` = 'processing',
                    `processing_token_hash` = '{$token_hash_e}',
                    `processing_until` = DATE_ADD(NOW(), INTERVAL " . self::LEASE_SECONDS . " SECOND),
                    `result_json` = '{}',
                    `date_added` = NOW(),
                    `date_modified` = NOW()"
            );
            $created = (int)$this->db->countAffected() === 1;
            $row = $this->lockedRow($scope, $client_request_id);
            if (!$row) {
                throw new \RuntimeException('message_idempotency_claim_missing');
            }

            if (!hash_equals((string)$row['payload_hash'], $payload_hash)
                || (int)$row['chat_id'] !== $chat_id
                || (int)$row['manager_id'] !== $manager_id) {
                $this->db->query('COMMIT');
                return ['state' => 'conflict'];
            }

            if ($created && $create_message !== null) {
                $message_id = (int)$create_message();
                if ($message_id <= 0) {
                    throw new \RuntimeException('message_idempotency_message_create_failed');
                }
                $this->db->query(
                    "UPDATE `{$this->prefix}message_send_requests`
                     SET `message_id` = {$message_id}, `date_modified` = NOW()
                     WHERE `request_id` = " . (int)$row['request_id']
                );
                $this->db->query('COMMIT');
                return [
                    'state' => 'claimed',
                    'message_id' => $message_id,
                    'processing_token' => $processing_token,
                    'created' => 1,
                    'replayed' => 0,
                ];
            }
            if ($created) {
                $this->db->query('COMMIT');
                return [
                    'state' => 'claimed',
                    'message_id' => 0,
                    'processing_token' => $processing_token,
                    'created' => 1,
                    'replayed' => 0,
                ];
            }

            if ((string)$row['status'] === 'completed') {
                $this->db->query('COMMIT');
                return [
                    'state' => 'completed',
                    'message_id' => (int)$row['message_id'],
                    'result' => $this->decodeResult((string)$row['result_json']),
                    'created' => 0,
                    'replayed' => 1,
                ];
            }

            $message_id = (int)$row['message_id'];
            if (!empty($row['lease_expired'])) {
                $this->db->query(
                    "UPDATE `{$this->prefix}message_send_requests`
                     SET `processing_token_hash` = '{$token_hash_e}',
                         `processing_until` = DATE_ADD(NOW(), INTERVAL " . self::LEASE_SECONDS . " SECOND),
                         `date_modified` = NOW()
                     WHERE `request_id` = " . (int)$row['request_id'] . "
                       AND `status` = 'processing'"
                );
                $this->db->query('COMMIT');
                return [
                    'state' => 'claimed',
                    'message_id' => $message_id,
                    'processing_token' => $processing_token,
                    'created' => 0,
                    'replayed' => 1,
                    'recovered' => 1,
                ];
            }

             
             
            $retry_after = max(1, min(3, (int)($row['retry_after'] ?? 1)));
            $this->db->query('COMMIT');
            return [
                'state' => 'processing',
                'message_id' => $message_id,
                'retry_after' => $retry_after,
                'replayed' => 1,
            ];
        } catch (\Throwable $e) {
            try {
                $this->db->query('ROLLBACK');
            } catch (\Throwable $ignored) {
            }
            throw $e;
        }
    }

    






    public function attachMessage(
        string $surface,
        int $manager_id,
        string $client_request_id,
        string $processing_token,
        callable $create_message
    ): array {
        $scope = $this->requestScope($surface, $manager_id);
        if ($scope === ''
            || !self::validClientRequestId($client_request_id)
            || preg_match('/^[a-f0-9]{64}$/D', $processing_token) !== 1) {
            return ['state' => 'invalid'];
        }

        $this->db->query('START TRANSACTION');
        try {
            $row = $this->lockedRow($scope, $client_request_id);
            if (!$row || (string)$row['status'] !== 'processing') {
                $this->db->query('COMMIT');
                return ['state' => 'lost'];
            }
            if (!hash_equals(
                (string)$row['processing_token_hash'],
                hash('sha256', $processing_token)
            )) {
                $this->db->query('COMMIT');
                return ['state' => 'lost'];
            }

            $message_id = (int)$row['message_id'];
            if ($message_id <= 0) {
                $message_id = (int)$create_message();
                if ($message_id <= 0) {
                    throw new \RuntimeException('message_idempotency_message_create_failed');
                }
                $this->db->query(
                    "UPDATE `{$this->prefix}message_send_requests`
                     SET `message_id` = {$message_id}, `date_modified` = NOW()
                     WHERE `request_id` = " . (int)$row['request_id'] . "
                       AND `status` = 'processing'
                       AND `processing_token_hash` = '" . $this->db->escape(hash('sha256', $processing_token)) . "'"
                );
                if ((int)$this->db->countAffected() !== 1) {
                    throw new \RuntimeException('message_idempotency_attach_lost');
                }
            }

            $this->db->query('COMMIT');
            return ['state' => 'attached', 'message_id' => $message_id];
        } catch (\Throwable $e) {
            try {
                $this->db->query('ROLLBACK');
            } catch (\Throwable $ignored) {
            }
            throw $e;
        }
    }

     
    public function complete(
        string $surface,
        int $manager_id,
        string $client_request_id,
        string $processing_token,
        array $result
    ): bool {
        $scope = $this->requestScope($surface, $manager_id);
        if ($scope === '' || !self::validClientRequestId($client_request_id) || preg_match('/^[a-f0-9]{64}$/D', $processing_token) !== 1) {
            return false;
        }

        $safe_result = $this->safeResult($result);
        $result_json = json_encode($safe_result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!is_string($result_json)) {
            $result_json = '{}';
        }
        $this->db->query(
            "UPDATE `{$this->prefix}message_send_requests`
             SET `status` = 'completed',
                 `processing_token_hash` = '',
                 `processing_until` = NULL,
                 `result_json` = '" . $this->db->escape($result_json) . "',
                 `date_modified` = NOW()
             WHERE `request_scope` = '" . $this->db->escape($scope) . "'
               AND `client_request_id` = '" . $this->db->escape($client_request_id) . "'
               AND `status` = 'processing'
               AND `processing_token_hash` = '" . $this->db->escape(hash('sha256', $processing_token)) . "'"
        );
        return (int)$this->db->countAffected() === 1;
    }

    




    public function releaseForRetry(
        string $surface,
        int $manager_id,
        string $client_request_id,
        string $processing_token
    ): bool {
        $scope = $this->requestScope($surface, $manager_id);
        if ($scope === ''
            || !self::validClientRequestId($client_request_id)
            || preg_match('/^[a-f0-9]{64}$/D', $processing_token) !== 1) {
            return false;
        }
        $this->db->query(
            "UPDATE `{$this->prefix}message_send_requests`
             SET `processing_until` = NOW(), `date_modified` = NOW()
             WHERE `request_scope` = '" . $this->db->escape($scope) . "'
               AND `client_request_id` = '" . $this->db->escape($client_request_id) . "'
               AND `status` = 'processing'
               AND `processing_token_hash` = '" . $this->db->escape(hash('sha256', $processing_token)) . "'"
        );
        return (int)$this->db->countAffected() === 1;
    }

    private function lockedRow(string $scope, string $client_request_id): array {
        $query = $this->db->query(
            "SELECT *,
                    (`processing_until` IS NOT NULL AND `processing_until` <= NOW()) AS `lease_expired`,
                    GREATEST(1, TIMESTAMPDIFF(SECOND, NOW(), COALESCE(`processing_until`, NOW()))) AS `retry_after`
             FROM `{$this->prefix}message_send_requests`
             WHERE `request_scope` = '" . $this->db->escape($scope) . "'
               AND `client_request_id` = '" . $this->db->escape($client_request_id) . "'
             LIMIT 1 FOR UPDATE"
        );
        return $query->row ?: [];
    }

    private function requestScope(string $surface, int $manager_id): string {
        $surface = strtolower(trim($surface));
        if (!in_array($surface, ['web', 'android', 'web_outbound'], true) || $manager_id <= 0) {
            return '';
        }
        return 'operator_message:' . $surface . ':' . $manager_id;
    }

    private static function canonicalize($value) {
        if (!is_array($value)) {
            if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
                return $value;
            }
            return (string)$value;
        }

        if (array_keys($value) === range(0, count($value) - 1)) {
            return array_map([self::class, 'canonicalize'], $value);
        }

        ksort($value, SORT_STRING);
        foreach ($value as $key => $item) {
            $value[$key] = self::canonicalize($item);
        }
        return $value;
    }

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

     
    private function safeResult(array $result): array {
        $safe = [
            'message_id' => max(0, (int)($result['message_id'] ?? 0)),
            'date_added' => $this->truncate((string)($result['date_added'] ?? ''), 40),
            'date_modified' => $this->truncate((string)($result['date_modified'] ?? ''), 40),
            'date_added_iso' => $this->truncate((string)($result['date_added_iso'] ?? ''), 48),
        ];
        foreach (['chat_id', 'request_id', 'http_code', 'retry_after'] as $key) {
            if (array_key_exists($key, $result)) {
                $safe[$key] = max(0, (int)$result[$key]);
            }
        }
        foreach (['success', 'replayed', 'dispatch_pending', 'retryable'] as $key) {
            if (array_key_exists($key, $result)) {
                $safe[$key] = !empty($result[$key]);
            }
        }
        foreach (['code', 'error'] as $key) {
            if (array_key_exists($key, $result) && !is_array($result[$key]) && !is_object($result[$key])) {
                $safe[$key] = $this->truncate((string)$result[$key], 512);
            }
        }
        if (isset($result['outbound_delivery']) && is_array($result['outbound_delivery'])) {
            $safe['outbound_delivery'] = $this->safeDeliveryResult($result['outbound_delivery']);
        }
        return $safe;
    }

    private function safeDeliveryResult(array $delivery): array {
        $safe = [];
        $allowed = [
            'success', 'provider', 'label', 'status', 'code', 'error',
            'error_code', 'message', 'skipped', 'skipped_attachments',
            'retryable', 'retry_scheduled', 'outcome_unknown', 'job_id',
            'delivery_id', 'device_id', 'push_sent',
        ];
        foreach ($allowed as $key) {
            if (!array_key_exists($key, $delivery) || is_array($delivery[$key]) || is_object($delivery[$key])) {
                continue;
            }
            $value = $delivery[$key];
            $safe[$key] = is_string($value) ? $this->truncate($value, 512) : $value;
        }
        return $safe;
    }

    




    private function maybeCleanupJournal(string $client_request_id): void {
        $bucket = (int)sprintf('%u', crc32($client_request_id)) % 100;
        if ($bucket !== 0) {
            return;
        }
        try {
            $this->db->query(
                "DELETE FROM `{$this->prefix}message_send_requests`
                 WHERE (`status` = 'completed'
                        AND `date_modified` < DATE_SUB(NOW(), INTERVAL " . self::COMPLETED_RETENTION_DAYS . " DAY))
                    OR (`status` = 'processing'
                        AND `processing_until` < NOW()
                        AND `date_modified` < DATE_SUB(NOW(), INTERVAL " . self::ABANDONED_RETENTION_DAYS . " DAY))
                 ORDER BY `request_id` ASC
                 LIMIT 200"
            );
        } catch (\Throwable $ignored) {
             
        }
    }

    private function truncate(string $value, int $length): string {
        return function_exists('mb_substr')
            ? mb_substr($value, 0, $length, 'UTF-8')
            : substr($value, 0, $length);
    }
}
