<?php

namespace App\Support\Confirm;

use Closure;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use InvalidArgumentException;
use JsonException;

/**
 * Server-issued, signed, single-use token that binds one confirm action through a
 * mandatory two-phase lifecycle.
 *
 * The generic ConfirmAction modal (R5) used to carry client-mutable `event` /
 * `params` / `auditAction` / `auditTarget`, so an authenticated operator could call
 * an apply handler directly (skipping the modal) or substitute the target/audit
 * descriptor. A token issued here is the ONLY routing input the modal and the apply
 * handler trust:
 *
 *   - the permitted action must be in {@see self::ACTIONS} (server-side allowlist);
 *   - the payload (event/params/audit descriptor) is sealed by Crypt (APP_KEY MAC),
 *     so it cannot be forged or retargeted on the wire;
 *   - lifecycle is two-phase: {@see issue()} mints a `pending` nonce, {@see confirm()}
 *     (the modal's confirm step) atomically flips it to `confirmed`, and only a
 *     `confirmed` nonce can be {@see consume()}d by the apply handler. So a direct
 *     handler call with a freshly-issued (never-confirmed) token is rejected — the
 *     modal really has to run. Each phase transition happens at most once;
 *   - the token is bound to the issuing operator (uid), so it cannot be replayed by
 *     another account.
 *
 * RBAC now exists (admin > operator > viewer); the mutating actions guard the
 * `manage-users`/`operate`/etc. abilities themselves. ConfirmToken remains an orthogonal
 * integrity/confirmation control — it proves a human passed through the modal and seals the
 * routing/audit descriptor, it does NOT decide authorization. Server-scoped actions
 * additionally seal the server id — the handler must match it against the server it acts on.
 */
class ConfirmToken
{
    /** The only confirm events the modal may dispatch / audit. */
    public const ACTIONS = [
        'serviceConfirmed',
        'userRemoved',
        'userLoggedOut',
        'userRoleChanged',
        'domainChanged',
        'tlsModeChanged',
        'fileConfirmed',
        'keyRemoved',
        'credentialDeleted',
        'firewallRuleDelete',
        'webauthnKeyRemoved',
        'sessionsLogoutOthers',
        'sessionsLogoutAll',
        'twoFactorDisabled',
        'banCleared',
        'bansClearedAll',
        'wgPeerRemoved',
        'wgGateToggle',
        'wgSshGate',
        'wgSetPort',
        'wgSetSubnet',
        'releaseStaged',
        'releasePublic',
        'releaseYank',
        'groupConfirmed',
        'alertRuleDeleted',
        'commandRun',
        'runbookDeleted',
        'patchApply',
        'certEndpointDeleted',
        'healthCheckDeleted',
    ];

    /** How long an issued confirm stays valid (seconds) — generous for a human click. */
    private const TTL = 600;

    private const CACHE_PREFIX = 'confirm-token:';

    private const STATE_PENDING = 'pending';

    private const STATE_CONFIRMED = 'confirmed';

    /**
     * Mint a `pending` token for one confirm action. Called server-side by the opener.
     *
     * @param  array<string, mixed>  $params
     *
     * @throws InvalidArgumentException when $event is not allowlisted
     */
    public static function issue(
        string $event,
        array $params = [],
        string $auditAction = '',
        ?string $auditTarget = null,
        ?int $serverId = null,
    ): string {
        if (! in_array($event, self::ACTIONS, true)) {
            throw new InvalidArgumentException("Unknown confirm action: {$event}");
        }

        $nonce = (string) Str::uuid();

        Cache::put(self::CACHE_PREFIX.$nonce, self::STATE_PENDING, self::TTL);

        return Crypt::encryptString(json_encode([
            'nonce' => $nonce,
            'event' => $event,
            'params' => $params,
            'auditAction' => $auditAction,
            'auditTarget' => $auditTarget,
            'serverId' => $serverId,
            'uid' => Auth::id(),
        ], JSON_THROW_ON_ERROR));
    }

    /**
     * The modal's confirm step: atomically flip the nonce `pending` → `confirmed`.
     * Proves a human passed through the modal before any apply handler may run.
     *
     * @return array<string, mixed>
     *
     * @throws InvalidConfirmToken
     */
    public static function confirm(string $token): array
    {
        $payload = self::decode($token);

        self::transition($payload['nonce'], function (string $key) {
            if (Cache::get($key) !== self::STATE_PENDING) {
                return false;
            }
            Cache::put($key, self::STATE_CONFIRMED, self::TTL);

            return true;
        });

        return $payload;
    }

    /**
     * The apply handler's gate: a `confirmed` nonce is burned (single-use) and the
     * sealed payload returned. $event is the handler's own action; it must match.
     *
     * @return array<string, mixed>
     *
     * @throws InvalidConfirmToken
     */
    public static function consume(string $token, string $event): array
    {
        $payload = self::decode($token);

        if ($payload['event'] !== $event) {
            throw new InvalidConfirmToken('confirm token action mismatch');
        }

        self::transition($payload['nonce'], function (string $key) {
            if (Cache::get($key) !== self::STATE_CONFIRMED) {
                return false;
            }
            Cache::forget($key);

            return true;
        });

        return $payload;
    }

    /**
     * Signature-only decode for DISPLAY (e.g. the audit-target line). Never throws and
     * does not touch the lifecycle state — display must not depend on it.
     *
     * @return array<string, mixed>|null
     */
    public static function peek(string $token): ?array
    {
        try {
            return self::decode($token);
        } catch (InvalidConfirmToken) {
            return null;
        }
    }

    /**
     * Run a state transition under a per-nonce lock so two concurrent requests cannot
     * both pass the check (Cache::pull is get-then-forget, not atomic). The closure
     * returns true on a successful transition; anything else (or a lost lock race) is
     * an invalid token.
     *
     * @param  Closure(string): bool  $mutate
     *
     * @throws InvalidConfirmToken
     */
    private static function transition(string $nonce, Closure $mutate): void
    {
        $key = self::CACHE_PREFIX.$nonce;

        $ok = Cache::lock($key.':lock', 10)->get(fn () => $mutate($key));

        if ($ok !== true) {
            throw new InvalidConfirmToken('confirm token expired, already used, or not confirmed');
        }
    }

    /**
     * Decrypt + integrity/allowlist/uid check. No lifecycle-state check here.
     *
     * @return array<string, mixed>
     *
     * @throws InvalidConfirmToken
     */
    private static function decode(string $token): array
    {
        try {
            $payload = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
        } catch (DecryptException|JsonException) {
            throw new InvalidConfirmToken('malformed confirm token');
        }

        if (! is_array($payload)
            || ! is_string($payload['nonce'] ?? null)
            || ! in_array($payload['event'] ?? null, self::ACTIONS, true)
            || ($payload['uid'] ?? null) !== Auth::id()) {
            throw new InvalidConfirmToken('invalid confirm token');
        }

        return $payload;
    }
}
