<?php

namespace App\Support\Sequences;

use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * Concurrency-safe reference numbers (§80), e.g. `INV-DHK-2026-000101`.
 *
 * The counter row is locked with `SELECT ... FOR UPDATE`, so two simultaneous
 * requests queue instead of colliding. Callers must already be inside a
 * transaction for the lock to mean anything; `next()` opens one if they are not.
 */
class SequenceGenerator
{
    /**
     * @param  array<int, string|int>  $segments  Parts between prefix and counter, e.g. ['DHK', 2026].
     */
    public function next(string $type, string $prefix, array $segments = [], int $padding = 6): string
    {
        $scopeKey = $segments === [] ? '-' : implode('-', $segments);
        $value = $this->nextValue($type, $scopeKey);

        $parts = array_merge([$prefix], $segments, [str_pad((string) $value, $padding, '0', STR_PAD_LEFT)]);

        return implode('-', array_map(fn ($part) => (string) $part, $parts));
    }

    /**
     * A reference guaranteed to be free, skipping any the counter does not know
     * about.
     *
     * The counter alone is not enough once numbers can enter from outside it —
     * a legacy import, a manual correction, a restored backup. Rather than
     * failing on the unique index, this walks forward until it finds a gap.
     *
     * @param  callable(string): bool  $isTaken
     *
     * @throws RuntimeException when no free number is found in $maxAttempts
     */
    public function nextUnique(
        string $type,
        string $prefix,
        array $segments,
        int $padding,
        callable $isTaken,
        int $maxAttempts = 50,
    ): string {
        for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
            $candidate = $this->next($type, $prefix, $segments, $padding);

            if (! $isTaken($candidate)) {
                return $candidate;
            }
        }

        throw new RuntimeException(
            "Could not allocate a free [{$type}] number after {$maxAttempts} attempts. ".
            'The sequence counter is likely behind the stored data; fast-forward it.'
        );
    }

    public function nextValue(string $type, string $scopeKey = '-'): int
    {
        $run = function () use ($type, $scopeKey): int {
            $row = DB::table('number_sequences')
                ->where('type', $type)
                ->where('scope_key', $scopeKey)
                ->lockForUpdate()
                ->first();

            if ($row === null) {
                DB::table('number_sequences')->insert([
                    'type' => $type,
                    'scope_key' => $scopeKey,
                    'next_value' => 2,
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);

                return 1;
            }

            DB::table('number_sequences')
                ->where('id', $row->id)
                ->update(['next_value' => $row->next_value + 1, 'updated_at' => now()]);

            return (int) $row->next_value;
        };

        return DB::transactionLevel() > 0 ? $run() : DB::transaction($run);
    }

    /** Current counter without consuming it — for previews only. */
    public function peek(string $type, string $scopeKey = '-'): int
    {
        return (int) (DB::table('number_sequences')
            ->where('type', $type)
            ->where('scope_key', $scopeKey)
            ->value('next_value') ?? 1);
    }

    /** Move a counter forward, e.g. when migrating legacy data. Never backwards. */
    public function fastForward(string $type, string $scopeKey, int $to): void
    {
        if ($to < 1) {
            throw new RuntimeException('A sequence value must be positive.');
        }

        DB::transaction(function () use ($type, $scopeKey, $to) {
            $current = DB::table('number_sequences')
                ->where('type', $type)
                ->where('scope_key', $scopeKey)
                ->lockForUpdate()
                ->value('next_value');

            DB::table('number_sequences')->updateOrInsert(
                ['type' => $type, 'scope_key' => $scopeKey],
                ['next_value' => max((int) ($current ?? 1), $to), 'updated_at' => now()],
            );
        });
    }
}
