<?php

declare(strict_types=1);

/**
 * Shared batching for the admin bulk-delete endpoints.
 *
 * Every one of them used to loop `Model::delete($id)` over the whole selection inside the web
 * request and then answer `count($ids)` whatever happened. Two consequences: a selection big
 * enough to reach max_execution_time died half way through while the browser was told the lot had
 * been deleted, and one failing row aborted every row after it. This runs the work in batches,
 * stops before the request runs out of time, and reports what was really deleted.
 */
final class BulkDelete
{
    /** Rows per statement. Large enough to matter, small enough to stay off the slow log. */
    public const BATCH = 200;

    /** Time kept in reserve so the response is still sent after the last batch. */
    private const RESERVE_SECONDS = 5.0;

    /**
     * @param callable(array):int $deleteBatch receives a slice of ids, returns how many it deleted
     * @return array{deleted:int,failed:int,remaining:int}
     */
    public static function run(array $ids, callable $deleteBatch, int $batch = self::BATCH): array
    {
        $ids = self::normalise($ids);
        $out = ['deleted' => 0, 'failed' => 0, 'remaining' => 0];
        if (!$ids) return $out;

        $batch = max(1, min(1000, $batch));
        foreach (array_chunk($ids, $batch) as $slice) {
            if (!self::hasTimeLeft()) {
                // Out of request budget: report the rest as untouched instead of dying mid-way.
                $out['remaining'] += count($slice);
                continue;
            }
            try {
                $out['deleted'] += (int) $deleteBatch($slice);
            } catch (\Throwable $e) {
                $out['failed'] += count($slice);
                error_log('BulkDelete: batch failed: ' . $e->getMessage());
            }
        }
        return $out;
    }

    /**
     * One set-based DELETE for plain rows, returning the number actually removed - so the count
     * shown to the admin comes from the database rather than from the size of the selection.
     */
    public static function rows(string $table, array $ids, string $idColumn = 'id'): int
    {
        $ids = self::normalise($ids);
        if (!$ids) return 0;
        self::assertIdentifier($table);
        self::assertIdentifier($idColumn);

        $ph = implode(',', array_fill(0, count($ids), '?'));
        return Database::query("DELETE FROM {$table} WHERE {$idColumn} IN ({$ph})", $ids)->rowCount();
    }

    /** Uniform response shape: extra keys appear only when there is something to report. */
    public static function payload(array $result): array
    {
        $out = ['deleted' => (int) ($result['deleted'] ?? 0)];
        if (!empty($result['failed']))    $out['failed'] = (int) $result['failed'];
        if (!empty($result['remaining'])) $out['remaining'] = (int) $result['remaining'];
        return $out;
    }

    /** Human sentence for the admin flash message. */
    public static function message(array $result, string $singular, string $plural): string
    {
        $n = (int) ($result['deleted'] ?? 0);
        $msg = $n . ' ' . ($n === 1 ? $singular : $plural) . ' deleted.';
        if (!empty($result['failed']))    $msg .= ' ' . (int) $result['failed'] . ' could not be deleted.';
        if (!empty($result['remaining'])) $msg .= ' ' . (int) $result['remaining'] . ' left untouched - run it again to finish.';
        return $msg;
    }

    private static function normalise(array $ids): array
    {
        return array_values(array_unique(array_filter(
            array_map('intval', $ids),
            static fn($id) => $id > 0
        )));
    }

    private static function hasTimeLeft(): bool
    {
        $limit = (int) ini_get('max_execution_time');
        if (PHP_SAPI === 'cli' || $limit <= 0) return true;
        $started = (float) ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true));
        return (microtime(true) - $started) < ($limit - self::RESERVE_SECONDS);
    }

    private static function assertIdentifier(string $name): void
    {
        if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) {
            throw new \InvalidArgumentException('Unsafe SQL identifier: ' . $name);
        }
    }
}
