<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\AssetCategory;
use App\Models\AssetTagSequence;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;

/**
 * Race-free, gapless asset tag numbering.
 *
 * `MAX(asset_tag) + 1` produces duplicates under concurrent creation: two
 * requests can both read the same MAX before either commits. This locks a
 * single counter row with `SELECT ... FOR UPDATE` inside the caller's
 * transaction instead, which makes a duplicate tag impossible rather than
 * merely unlikely.
 */
class AssetTagGenerator
{
    /**
     * The next tag for an asset in the given category, e.g. "AUM-LAP-0042".
     *
     * Reads its format entirely from settings, so changing the prefix or
     * padding takes effect on the next asset without a deploy:
     *
     *   asset_tag_prefix           leading segment, default "AUM"
     *   asset_tag_scope            "per_category" (default) or "global"
     *   asset_tag_sequence_padding digits the number is padded to, default 4
     */
    public function next(AssetCategory $category): string
    {
        $prefix = (string) setting('asset_tag_prefix', 'AUM');
        $scope = (string) setting('asset_tag_scope', 'per_category');
        $padding = max(1, (int) setting('asset_tag_sequence_padding', 4));

        $scopeKey = $scope === 'global' ? 'global' : $category->code;

        $number = DB::transaction(function () use ($scopeKey): int {
            // Idempotent seed. If two requests race to create the same
            // scope's first-ever row, one INSERT wins and the other fails a
            // unique-key check — harmless, because the lockForUpdate() select
            // below finds the row either way once one of them has landed.
            try {
                AssetTagSequence::query()->firstOrCreate(['scope_key' => $scopeKey], ['last_number' => 0]);
            } catch (QueryException $exception) {
                if (! str_contains(mb_strtolower($exception->getMessage()), 'duplicate')
                    && ! str_contains(mb_strtolower($exception->getMessage()), 'unique constraint')) {
                    throw $exception;
                }
            }

            /** @var AssetTagSequence $sequence */
            $sequence = AssetTagSequence::query()
                ->where('scope_key', $scopeKey)
                ->lockForUpdate()
                ->firstOrFail();

            $sequence->increment('last_number');

            return $sequence->last_number;
        });

        $padded = str_pad((string) $number, $padding, '0', STR_PAD_LEFT);

        return $scope === 'global'
            ? sprintf('%s-%s', $prefix, $padded)
            : sprintf('%s-%s-%s', $prefix, $category->code, $padded);
    }

    /**
     * What {@see next()} would currently produce, without consuming a number.
     *
     * Read-only: no lock, no increment, no row created. Used only to show a
     * suggested tag on the Add Asset form before the asset actually exists —
     * the real, collision-free tag is still assigned by next() at save time,
     * so this preview can drift (another asset created in between) without
     * that ever mattering.
     */
    public function preview(AssetCategory $category): string
    {
        $prefix = (string) setting('asset_tag_prefix', 'AUM');
        $scope = (string) setting('asset_tag_scope', 'per_category');
        $padding = max(1, (int) setting('asset_tag_sequence_padding', 4));

        $scopeKey = $scope === 'global' ? 'global' : $category->code;

        $lastNumber = AssetTagSequence::query()
            ->where('scope_key', $scopeKey)
            ->value('last_number');

        $padded = str_pad((string) ((int) $lastNumber + 1), $padding, '0', STR_PAD_LEFT);

        return $scope === 'global'
            ? sprintf('%s-%s', $prefix, $padded)
            : sprintf('%s-%s-%s', $prefix, $category->code, $padded);
    }
}
