<?php

declare(strict_types=1);

namespace App\Infrastructure\Jobs;

use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Throwable;

/**
 * Atomic acquire/release for `job_locks`. The same SQL works on SQLite and
 * MySQL because the locking effect comes from the `job_name` PRIMARY KEY:
 * a second concurrent INSERT raises a unique-constraint violation and we
 * map that to "lock held".
 *
 * Pattern (per SPEC §4 / M05):
 *   1. BEGIN
 *   2. DELETE rows where expires_at < now   ← reclaims crashed-process locks
 *   3. INSERT (job_name, …)                 ← fails on PK collision if held
 *   4. COMMIT
 *
 * Crash recovery works because `expires_at = now + maxRuntime + buffer`. A
 * process that dies before `release()` leaves a stale row that the next
 * caller's step-2 sweep removes before re-attempting the insert.
 */
final class JobLockRepository
{
    public function __construct(private readonly Connection $connection)
    {
    }

    public function tryAcquire(
        string $jobName,
        DateTimeImmutable $now,
        DateTimeImmutable $expiresAt,
        string $owner,
    ): bool {
        try {
            $this->connection->beginTransaction();
            $this->connection->executeStatement(
                'DELETE FROM job_locks WHERE expires_at < :now',
                ['now' => $now->format('Y-m-d H:i:s')],
            );
            $this->connection->executeStatement(
                'INSERT INTO job_locks (job_name, acquired_by, acquired_at, expires_at) '
                . 'VALUES (:job, :owner, :acquired, :expires)',
                [
                    'job' => $jobName,
                    'owner' => $owner,
                    'acquired' => $now->format('Y-m-d H:i:s'),
                    'expires' => $expiresAt->format('Y-m-d H:i:s'),
                ],
            );
            $this->connection->commit();

            return true;
        } catch (UniqueConstraintViolationException) {
            if ($this->connection->isTransactionActive()) {
                $this->connection->rollBack();
            }

            return false;
        } catch (Throwable $e) {
            if ($this->connection->isTransactionActive()) {
                $this->connection->rollBack();
            }

            throw $e;
        }
    }

    /**
     * Releases the lock only if `owner` matches — defensive: a process that
     * overran its `expires_at` and then tries to release shouldn't kick out
     * a freshly-acquired lock held by a different owner.
     */
    public function release(string $jobName, string $owner): void
    {
        $this->connection->executeStatement(
            'DELETE FROM job_locks WHERE job_name = :job AND acquired_by = :owner',
            ['job' => $jobName, 'owner' => $owner],
        );
    }

    /**
     * Read-only snapshot for status reporting. Returns null if not held.
     *
     * @return array{acquired_by: string, acquired_at: string, expires_at: string}|null
     */
    public function status(string $jobName): ?array
    {
        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative(
            'SELECT acquired_by, acquired_at, expires_at FROM job_locks WHERE job_name = :job',
            ['job' => $jobName],
        );
        if ($row === false) {
            return null;
        }

        return [
            'acquired_by' => (string) $row['acquired_by'],
            'acquired_at' => (string) $row['acquired_at'],
            'expires_at' => (string) $row['expires_at'],
        ];
    }
}
