<?php

namespace Database\Seeders;

use App\Models\Central\Tenant;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\CurrentContext;
use Illuminate\Database\Seeder;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;

/**
 * Seeds the live access-control state (exported from the production database):
 * the custom (non-system) roles and their permission grants, every real user
 * with their existing password hash, the multi-role pivot, and per-user
 * allow/deny overrides.
 *
 * The four SYSTEM roles and the permission catalog are produced upstream by
 * {@see PermissionsAndRolesSeeder} (which derives permissions from the menu), so
 * this seeder MUST run after it. Permissions and roles are matched by NAME —
 * never by id — because ids are not stable across a fresh install.
 *
 * Data lives in database/seeders/data/access-control.json. Passwords are stored
 * pre-hashed; the model's `hashed` cast passes an already-hashed value through
 * untouched, so existing users keep their current credentials.
 */
class AccessControlSeeder extends Seeder
{
    public function run(): void
    {
        $context = app(CurrentContext::class);

        if ($context->hasTenant()) {
            $this->seedForCurrentTenant();
        } else {
            Tenant::all()->each(function (Tenant $tenant) use ($context): void {
                $context->runForTenant($tenant, fn () => $this->seedForCurrentTenant());
            });
        }
    }

    private function seedForCurrentTenant(): void
    {
        $data = $this->readData();

        // Every statement below is an idempotent upsert, so a run that collided
        // (see seedUsers/seedCustomRoles for the per-tenant match) can be retried
        // with a fresh transaction. Two concurrent /seed hits on the same tenant
        // (e.g. a browser refresh while the first request is still seeding) both
        // take a REPEATABLE READ snapshot before either commits; the loser's
        // insert then hits the tenant-scoped unique index. Retrying re-reads
        // with a fresh snapshot and converges on the committed rows.
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                DB::transaction(function () use ($data): void {
                    $admins = $this->superAdminUsers($data['users']);

                    $this->seedCustomRoles($data['custom_roles'], $data['role_permissions']);
                    $this->seedUsers($admins);
                    $this->seedUserRoles($admins, $data['user_roles']);
                    $this->seedOverrides($admins, $data['overrides']);
                });

                break;
            } catch (UniqueConstraintViolationException $e) {
                if ($attempt === 3) {
                    throw $e;
                }
            }
        }

        User::query()->lazy()->each(fn (User $user) => $user->flushPermissionCache());
    }

    /**
     * @param  array<int, array<string, mixed>>  $roles
     * @param  array<string, array<int, string>>  $rolePermissions
     */
    private function seedCustomRoles(array $roles, array $rolePermissions): void
    {
        $tenantId = $this->requireTenantId();

        foreach ($roles as $row) {
            // Name is tenant-scoped unique (users/roles share one database across
            // tenants), so the match must include tenant_id or an upsert against
            // a cloned copy resolves to the wrong tenant's row.
            $role = Role::updateOrCreate(
                ['tenant_id' => $tenantId, 'name' => $row['name']],
                [
                    'description' => $row['description'] ?? null,
                    'is_system' => false,
                    'is_full_admin' => (bool) ($row['is_full_admin'] ?? false),
                    'is_active' => (bool) ($row['is_active'] ?? true),
                ],
            );

            $permissionIds = Permission::query()
                ->whereIn('name', $rolePermissions[$row['name']] ?? [])
                ->pluck('id')
                ->all();

            $role->permissions()->sync($permissionIds);
        }
    }

    /**
     * The exported state carries the live tenant's whole operations staff
     * (sales reps, cashiers, stock keepers, managers). A fresh install — and
     * a test instance — provisions only the superadmin (Full Administrator);
     * the rest are the live tenant's people, not the platform's.
     *
     * @param  array<int, array<string, mixed>>  $users
     * @return array<int, array<string, mixed>>
     */
    private function superAdminUsers(array $users): array
    {
        return array_values(array_filter(
            $users,
            fn (array $row): bool => ($row['primary_role'] ?? null) === 'Full Administrator',
        ));
    }

    /**
     * @param  array<int, array<string, mixed>>  $users  superadmin rows only — see {@see superAdminUsers()}
     */
    private function seedUsers(array $users): void
    {
        $tenantId = $this->requireTenantId();
        $roleIds = Role::query()->pluck('id', 'name');

        foreach ($users as $row) {
            // Same tenant-scoped match as seedCustomRoles: email is unique per
            // tenant, so the upsert must target the tenant's own row — otherwise
            // seeding a test instance whose users were cloned from live resolves
            // to (or collides with) the live tenant's row.
            //
            // withTrashed(): a soft-deleted row still occupies the (tenant_id,
            // email) unique index, so a plain updateOrCreate would fail the
            // lookup, attempt an INSERT, and hit a duplicate-key error.
            $user = User::withTrashed()->updateOrCreate(
                ['tenant_id' => $tenantId, 'email' => $row['email']],
                [
                    'name' => $row['name'],
                    // Placeholder — replaced below with the exported hash verbatim.
                    'password' => bin2hex(random_bytes(16)),
                    'status' => $row['status'] ?? User::STATUS_ACTIVE,
                    'role_id' => $roleIds[$row['primary_role']] ?? null,
                    'email_verified_at' => ! empty($row['verified']) ? now() : null,
                ],
            );

            // The seed data is the exported source of truth — a row present in
            // it is never meant to stay soft-deleted, so bring it back.
            $user->restore();

            // Store the production password hash as-is. A raw update bypasses the
            // model's `hashed` cast, which would otherwise reject a hash whose cost
            // differs from the local hashing config. This keeps existing logins working.
            DB::table('users')->where('id', $user->id)->update(['password' => $row['password']]);
        }
    }

    /**
     * @param  array<int, array<string, mixed>>  $admins  superadmin rows — see {@see superAdminUsers()}
     * @param  array<int, array{email: string, role_name: string}>  $userRoles
     */
    private function seedUserRoles(array $admins, array $userRoles): void
    {
        $adminEmails = array_column($admins, 'email');
        $userIds = User::query()->whereIn('email', $adminEmails)->pluck('id', 'email');
        $roleIds = Role::query()->pluck('id', 'name');

        /** @var array<int, array<int, int>> $assignments user id => role ids */
        $assignments = [];
        foreach ($userRoles as $row) {
            if (! in_array($row['email'], $adminEmails, true)) {
                continue;
            }

            $userId = $userIds[$row['email']] ?? null;
            $roleId = $roleIds[$row['role_name']] ?? null;
            if ($userId !== null && $roleId !== null) {
                $assignments[$userId][] = $roleId;
            }
        }

        foreach ($assignments as $userId => $roleIdList) {
            User::query()->find($userId)?->roles()->sync($roleIdList);
        }
    }

    /**
     * @param  array<int, array<string, mixed>>  $admins  superadmin rows — see {@see superAdminUsers()}
     * @param  array<int, array{email: string, permission_name: string, type: string}>  $overrides
     */
    private function seedOverrides(array $admins, array $overrides): void
    {
        $adminEmails = array_column($admins, 'email');
        $userIds = User::query()->whereIn('email', $adminEmails)->pluck('id', 'email');
        $permissionIds = Permission::query()->pluck('id', 'name');

        /** @var array<int, array<int, array<string, mixed>>> $byUser user id => [permission id => pivot] */
        $byUser = [];
        foreach ($overrides as $row) {
            if (! in_array($row['email'], $adminEmails, true)) {
                continue;
            }

            $userId = $userIds[$row['email']] ?? null;
            $permissionId = $permissionIds[$row['permission_name']] ?? null;
            if ($userId === null || $permissionId === null) {
                continue;
            }
            $byUser[$userId][$permissionId] = [
                'type' => $row['type'],
                'granted_by' => null,
                'granted_at' => now(),
            ];
        }

        foreach ($byUser as $userId => $attachments) {
            User::query()->find($userId)?->permissionOverrides()->sync($attachments);
        }
    }

    /**
     * @return array{custom_roles: array<int, array<string, mixed>>, role_permissions: array<string, array<int, string>>, users: array<int, array<string, mixed>>, user_roles: array<int, array{email: string, role_name: string}>, overrides: array<int, array{email: string, permission_name: string, type: string}>}
     */
    private function readData(): array
    {
        return json_decode(file_get_contents(database_path('seeders/data/access-control.json')), true);
    }

    /**
     * run() guarantees a tenant (either bound, or via runForTenant), but the
     * match keys below must never silently lose their scope.
     */
    private function requireTenantId(): int
    {
        $tenantId = app(CurrentContext::class)->tenantId();

        if ($tenantId === null) {
            throw new RuntimeException('AccessControlSeeder requires a bound tenant context.');
        }

        return $tenantId;
    }
}
