<?php

declare(strict_types=1);

/**
 * Short-window duplicate-submission and concurrent-operation protection.
 *
 * Business tables remain authoritative. This ledger only prevents the same
 * high-risk POST payload from being processed twice while the first request is
 * running or immediately after it completes.
 */

final class DentalDuplicateOperationException extends RuntimeException
{
}

function operation_integrity_schema_ready(): bool
{
    try {
        return schema_table_exists(table('operation_requests'))
            && schema_column_exists(table('operation_requests'), 'dedupe_key')
            && schema_column_exists(table('operation_requests'), 'status')
            && schema_column_exists(table('operation_requests'), 'duplicate_count');
    } catch (Throwable) {
        return false;
    }
}

/** @return mixed */
function operation_integrity_normalize_value(mixed $value): mixed
{
    if (is_array($value)) {
        $normalized = [];
        foreach ($value as $key => $item) {
            if (in_array((string)$key, ['csrf','operation_token','_token','submit'], true)) continue;
            $normalized[(string)$key] = operation_integrity_normalize_value($item);
        }
        ksort($normalized, SORT_STRING);
        return $normalized;
    }
    if (is_bool($value) || is_int($value) || is_float($value) || $value === null) return $value;
    return trim((string)$value);
}

function operation_integrity_payload_hash(array $payload): string
{
    $normalized = operation_integrity_normalize_value($payload);
    return hash('sha256', (string)json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION));
}

function operation_integrity_dedupe_key(
    string $scope,
    string $action,
    array $payload,
    ?string $entityType = null,
    ?int $entityId = null,
    ?int $userId = null,
    ?int $branchId = null,
    ?string $role = null
): string {
    $user = current_user() ?? [];
    $userId ??= (int)($user['id'] ?? 0);
    $branchId ??= (int)($user['branch_id'] ?? 0);
    $role ??= function_exists('active_role') ? active_role() : (string)($user['role'] ?? '');
    $identity = [
        'user_id' => max(0, $userId),
        'branch_id' => max(0, $branchId),
        'role' => strtolower(trim($role)),
        'scope' => strtolower(trim($scope)),
        'action' => strtolower(trim($action)),
        'entity_type' => strtolower(trim((string)$entityType)),
        'entity_id' => max(0, (int)$entityId),
        'payload_hash' => operation_integrity_payload_hash($payload),
    ];
    return hash('sha256', (string)json_encode($identity, JSON_UNESCAPED_SLASHES));
}

/**
 * @return array{id:int,dedupe_key:string,scope:string,action:string,status:string}|null
 */
function operation_integrity_begin(
    PDO $pdo,
    string $scope,
    string $action,
    array $payload,
    ?string $entityType = null,
    ?int $entityId = null,
    int $processingTtlSeconds = 180
): ?array {
    if (!operation_integrity_schema_ready()) return null;

    $scope = mb_substr(trim($scope), 0, 100);
    $action = mb_substr(trim($action), 0, 80);
    if ($scope === '' || $action === '') return null;

    $user = current_user() ?? [];
    $userId = (int)($user['id'] ?? 0);
    $branchId = (int)($user['branch_id'] ?? 0);
    $role = function_exists('active_role') ? active_role() : (string)($user['role'] ?? '');
    $payloadHash = operation_integrity_payload_hash($payload);
    $dedupeKey = operation_integrity_dedupe_key($scope, $action, $payload, $entityType, $entityId, $userId, $branchId, $role);
    $processingTtlSeconds = max(30, min(900, $processingTtlSeconds));
    $expiresAt = date('Y-m-d H:i:s', time() + $processingTtlSeconds);

    try {
        $insert = $pdo->prepare(
            'INSERT INTO `' . table('operation_requests') . '` '
            . '(dedupe_key,user_id,branch_id,active_role,route_name,action_name,entity_type,entity_id,payload_hash,status,attempt_count,duplicate_count,started_at,expires_at,created_at,updated_at) '
            . 'VALUES (?,?,?,?,?,?,?,?,?,"processing",1,0,NOW(),?,NOW(),NOW())'
        );
        $insert->execute([
            $dedupeKey,
            $userId > 0 ? $userId : null,
            $branchId > 0 ? $branchId : null,
            mb_substr($role, 0, 40),
            $scope,
            $action,
            $entityType !== null ? mb_substr(trim($entityType), 0, 80) : null,
            $entityId !== null && $entityId > 0 ? $entityId : null,
            $payloadHash,
            $expiresAt,
        ]);
        return ['id'=>(int)$pdo->lastInsertId(),'dedupe_key'=>$dedupeKey,'scope'=>$scope,'action'=>$action,'status'=>'processing'];
    } catch (PDOException $exception) {
        if ((string)$exception->getCode() !== '23000') throw $exception;
    }

    $takeover = $pdo->prepare(
        'UPDATE `' . table('operation_requests') . '` SET '
        . 'status="processing",attempt_count=attempt_count+1,started_at=NOW(),completed_at=NULL,expires_at=?,last_error_code=NULL,last_error_message=NULL,response_reference=NULL,updated_at=NOW() '
        . 'WHERE dedupe_key=? AND (status="failed" OR expires_at<NOW())'
    );
    $takeover->execute([$expiresAt,$dedupeKey]);
    if ($takeover->rowCount() === 1) {
        $idStmt = $pdo->prepare('SELECT id FROM `' . table('operation_requests') . '` WHERE dedupe_key=? LIMIT 1');
        $idStmt->execute([$dedupeKey]);
        return ['id'=>(int)$idStmt->fetchColumn(),'dedupe_key'=>$dedupeKey,'scope'=>$scope,'action'=>$action,'status'=>'processing'];
    }

    $pdo->prepare('UPDATE `' . table('operation_requests') . '` SET duplicate_count=duplicate_count+1,updated_at=NOW() WHERE dedupe_key=?')->execute([$dedupeKey]);
    $existingStmt = $pdo->prepare('SELECT id,status,completed_at,expires_at FROM `' . table('operation_requests') . '` WHERE dedupe_key=? LIMIT 1');
    $existingStmt->execute([$dedupeKey]);
    $existing = $existingStmt->fetch() ?: [];
    $status = (string)($existing['status'] ?? 'processing');
    if ($status === 'completed') {
        throw new DentalDuplicateOperationException('This action was already completed. Refresh the page before submitting it again.');
    }
    throw new DentalDuplicateOperationException('This action is already being processed. Wait a moment, then refresh the page.');
}

function operation_integrity_complete(PDO $pdo, ?array $operation, array $result = [], int $completedTtlSeconds = 90): void
{
    if (!$operation || empty($operation['id']) || !operation_integrity_schema_ready()) return;
    try {
        $completedTtlSeconds = max(30, min(900, $completedTtlSeconds));
        $reference = trim((string)($result['reference'] ?? $result['redirect'] ?? ''));
        if ($reference === '' && !empty($result['entity_id'])) {
            $reference = trim((string)($result['entity_type'] ?? 'record')) . ':' . (int)$result['entity_id'];
        }
        $stmt = $pdo->prepare(
            'UPDATE `' . table('operation_requests') . '` SET status="completed",completed_at=NOW(),expires_at=?,response_reference=?,last_error_code=NULL,last_error_message=NULL,updated_at=NOW() WHERE id=?'
        );
        $stmt->execute([
            date('Y-m-d H:i:s', time() + $completedTtlSeconds),
            $reference !== '' ? mb_substr($reference, 0, 255) : null,
            (int)$operation['id'],
        ]);
    } catch (Throwable $exception) {
        // The business transaction may already be committed. Never turn a
        // successful appointment, queue, prescription, or release into a
        // retryable UI error merely because the monitoring row could not be
        // finalized. The processing TTL continues to suppress rapid repeats.
        error_log('Operation-integrity completion could not be recorded: ' . $exception->getMessage());
    }
}

function operation_integrity_fail(PDO $pdo, ?array $operation, Throwable $exception): void
{
    if (!$operation || empty($operation['id']) || !operation_integrity_schema_ready()) return;
    try {
        $stmt = $pdo->prepare(
            'UPDATE `' . table('operation_requests') . '` SET status="failed",expires_at=NOW(),last_error_code=?,last_error_message=?,updated_at=NOW() WHERE id=?'
        );
        $stmt->execute([
            mb_substr((string)$exception->getCode(), 0, 64) ?: null,
            mb_substr($exception->getMessage(), 0, 500),
            (int)$operation['id'],
        ]);
    } catch (Throwable $logException) {
        error_log('Operation-integrity failure could not be recorded: ' . $logException->getMessage());
    }
}

/** @return array{processing:int,completed:int,failed:int,duplicates:int,stale:int} */
function operation_integrity_summary(PDO $pdo, int $hours = 24, ?int $branchId = null): array
{
    if (!operation_integrity_schema_ready()) return ['processing'=>0,'completed'=>0,'failed'=>0,'duplicates'=>0,'stale'=>0];
    $hours = max(1, min(720, $hours));
    $where = 'created_at>=DATE_SUB(NOW(),INTERVAL ' . $hours . ' HOUR)';
    $args = [];
    if ($branchId !== null && $branchId > 0) { $where .= ' AND branch_id=?'; $args[]=$branchId; }
    $stmt = $pdo->prepare(
        'SELECT '
        . 'SUM(status="processing") processing_count,'
        . 'SUM(status="completed") completed_count,'
        . 'SUM(status="failed") failed_count,'
        . 'COALESCE(SUM(duplicate_count),0) duplicate_count,'
        . 'SUM(status="processing" AND expires_at<NOW()) stale_count '
        . 'FROM `' . table('operation_requests') . '` WHERE ' . $where
    );
    $stmt->execute($args);
    $row = $stmt->fetch() ?: [];
    return [
        'processing'=>(int)($row['processing_count'] ?? 0),
        'completed'=>(int)($row['completed_count'] ?? 0),
        'failed'=>(int)($row['failed_count'] ?? 0),
        'duplicates'=>(int)($row['duplicate_count'] ?? 0),
        'stale'=>(int)($row['stale_count'] ?? 0),
    ];
}
