<?php

namespace App\Filament\Forms\Components;

use App\Models\Concerns\HasPermissions;
use App\Models\Role;
use App\Services\PermissionCatalog;
use Closure;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Utilities\Get;

/**
 * The per-user permission editor. Unlike {@see PermissionPicker} — which edits a
 * flat grant set (a role's own permissions, a tenant's entitlement ceiling) —
 * this field edits the *exception layer* on top of whatever the user's roles
 * grant, and its state says so literally:
 *
 *   ['grant' => ['module.action', ...], 'revoke' => ['module.action', ...]]
 *
 * Anything absent from both lists is inherited: it resolves through the roles
 * every time {@see HasPermissions::computeEffectivePermissionNames()}
 * runs, so editing a role immediately moves its members. Anything present in
 * either list is pinned for this user and survives any later change to the
 * roles — including being reassigned to a completely different role.
 *
 * This replaces an editor that stored the *effective* set and re-derived
 * "minimal" allow/deny exceptions against the role union on save. That could not
 * distinguish "the admin deliberately deviated from the role" from "the role
 * selection changed while the form was open", so changing a user's role turned
 * every one of their inherited permissions into a hard allow override and every
 * permission of the incoming role into a hard deny override — the role change
 * had no effect at all, and the user was permanently pinned to the old set.
 */
class PermissionOverridePicker extends ViewField
{
    /**
     * Sibling form field holding the selected role ids. Read to render which
     * permissions are currently inherited; never written to.
     */
    protected string $rolesStatePath = 'role_ids';

    /**
     * Explicit tenant to cap the matrix at. Only needed when this field isn't
     * rendered under the acted-on tenant's own `web` guard — e.g. Central's
     * per-tenant user editor, where `Auth::user()` has no `tenant_id` of its
     * own. Null (the default) falls back to `Auth::user()->tenant_id`.
     */
    protected int|Closure|null $tenantId = null;

    protected function setUp(): void
    {
        parent::setUp();

        $this->view('filament.forms.permission-override-picker');
        $this->columnSpanFull();
        $this->default(['grant' => [], 'revoke' => []]);

        $this->afterStateHydrated(
            fn (PermissionOverridePicker $component, mixed $state) => $component->state(static::normalize($state)),
        );

        $this->dehydrateStateUsing(fn (mixed $state): array => static::normalize($state));

        $this->viewData(fn (Get $get): array => [
            // Entitled, not full: a permission outside the tenant's ceiling is
            // refused for everyone in that tenant, so listing it here would
            // advertise access that can never take effect.
            'groups' => PermissionCatalog::entitledMatrix($this->resolveTenantId()),
            'inherited' => $this->inheritedPermissionNames($get),
        ]);

        $this->rule(static fn (): Closure => static function (string $attribute, mixed $value, Closure $fail): void {
            $known = PermissionCatalog::allPermissionNames();

            foreach (static::normalize($value) as $names) {
                foreach ($names as $name) {
                    if (! in_array($name, $known, true)) {
                        $fail("\"{$name}\" is not a permission this system defines.");

                        return;
                    }
                }
            }
        });
    }

    /**
     * Point the field at a differently-named role select.
     */
    public function rolesStatePath(string $path): static
    {
        $this->rolesStatePath = $path;

        return $this;
    }

    /**
     * Cap the rendered matrix at a specific tenant's entitlement ceiling
     * instead of the current auth user's own. Needed by editors that act on a
     * tenant other than the one `Auth::user()` belongs to — e.g. Central's
     * per-tenant user editor, run under the `central` guard.
     */
    public function tenantId(int|Closure|null $tenantId): static
    {
        $this->tenantId = $tenantId;

        return $this;
    }

    protected function resolveTenantId(): ?int
    {
        return $this->tenantId instanceof Closure ? ($this->tenantId)() : $this->tenantId;
    }

    /**
     * Coerce whatever Livewire sends back into the two-list shape.
     *
     * Livewire syncs an all-unchecked checkbox group back as `false` rather than
     * `[]`, and the field's own state can arrive as `null` on a fresh create
     * form, so neither list can be trusted to be an array.
     *
     * @return array{grant: array<int, string>, revoke: array<int, string>}
     */
    public static function normalize(mixed $state): array
    {
        $state = is_array($state) ? $state : [];

        $list = static function (mixed $value): array {
            $value = is_array($value) ? $value : [];

            return array_values(array_unique(array_filter($value, 'is_string')));
        };

        $revoke = $list($state['revoke'] ?? []);

        return [
            // A permission cannot be granted and revoked at once. The UI never
            // produces that, but a hand-crafted Livewire payload could, so
            // revoke wins — of the two readings it is the one that hands out
            // less access.
            'grant' => array_values(array_diff($list($state['grant'] ?? []), $revoke)),
            'revoke' => $revoke,
        ];
    }

    /**
     * The permissions the currently-selected roles grant. Re-evaluated on every
     * render, so making the role select `->live()` reshades the matrix as soon as
     * the roles change — but note this only drives *display*: what gets stored is
     * the override lists above, which is why a stale shading can no longer
     * corrupt anyone's permissions the way the old derive-on-save did.
     *
     * @return array<int, string>
     */
    protected function inheritedPermissionNames(Get $get): array
    {
        $roleIds = array_filter(array_map('intval', (array) ($get($this->rolesStatePath) ?: [])));

        if ($roleIds === []) {
            return [];
        }

        return Role::query()
            ->whereIn('id', $roleIds)
            ->where('is_active', true)
            ->with('permissions:id,name')
            ->get()
            ->flatMap(fn (Role $role): array => $role->permissions->pluck('name')->all())
            ->unique()
            ->values()
            ->all();
    }
}
