<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Concerns\GuardsEditLock;
use App\Mail\Workspace\ProjectActivityMail;
use App\Models\DevicePreset;
use App\Models\MediaFile;
use App\Models\Project;
use App\Models\ProjectEditLock;
use App\Models\Screen;
use App\Notifications\ProjectActivity;
use App\Services\AuditLogger;
use App\Services\MediaStorage;
use App\Services\TemplateExporter;
use App\Support\AppMode;
use App\Support\LayoutMigrator;
use App\Support\DevicePresetFamily;
use App\Support\LayoutResolver;
use App\Support\Templates;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use ZipArchive;

class ProjectController extends Controller
{
    use GuardsEditLock;

    /**
     * Canvas-space pixels between adjacent screens in the workspace view.
     * Mirrored on the client by `WORKSPACE_VIEWPORT_GUTTER` in
     * `resources/js/types/layout.ts` — keep the values paired or the workspace
     * canvas drifts from server-stored `viewport_x`.
     */
    public const VIEWPORT_GUTTER = 200;

    /**
     * The disk alias `applyStorageSettings()` overlays the active provider
     * onto. Pulled into a class constant so the post-refactor grep
     * allowlist (the literal disk-public lookup must only appear inside
     * MediaStorage) can still flag drift if someone reintroduces a direct
     * facade call.
     */
    private const ACTIVE_DISK_ALIAS = 'public';

    /**
     * Form factors a user can pick at project creation. Kept as an explicit
     * allowlist so a typo in the request body 422s cleanly instead of being
     * silently coerced. Locale stays the only multi-axis dimension at
     * runtime; form factor is a one-shot pick at creation.
     */
    private const SUPPORTED_FORM_FACTORS = [
        'iphone_portrait',
        'ipad_portrait',
        'android_phone_portrait',
        'android_10_tablet_portrait',
        'iphone_landscape',
        'ipad_landscape',
        'android_phone_landscape',
        'android_10_tablet_landscape',
        'playstore_feature_graphic',
    ];


    public function index(Request $request): Response
    {
        $projects = $this->scope($request)
            ->withCount('screens')
            ->orderByDesc('updated_at')
            ->get(['id', 'name', 'form_factor', 'active_form_factors', 'default_locale', 'active_locales', 'design_tokens', 'updated_at'])
            ->map(fn (Project $p) => [
                'id' => $p->id,
                'name' => $p->name,
                // Duplicate-with-edits modal needs the source's full
                // settings so it can pre-fill the form + show locale +
                // brand-color summaries as read-only carry-overs.
                'form_factor' => $p->form_factor,
                // Multi-device v4 — full factor set the project ships.
                // Falls back to `[form_factor]` for legacy single-factor
                // projects so the gallery card always sees the array shape.
                'active_form_factors' => is_array($p->active_form_factors) && $p->active_form_factors !== []
                    ? array_values($p->active_form_factors)
                    : [$p->form_factor],
                'default_locale' => $p->default_locale,
                'active_locales' => $p->active_locales ?? ['en'],
                'design_tokens' => $p->design_tokens,
                'screens_count' => $p->screens_count,
                'updated_at' => $p->updated_at?->toIso8601String(),
                'is_blank' => (bool) $p->is_blank,
            ]);

        $user = $request->user();

        return Inertia::render('Projects/Index', [
            'projects' => $projects,
            'templates' => Templates::list(),
            // Locale catalog for the create / duplicate-project modal locale
            // picker. Shipped as a page prop (the only app-chrome consumer
            // besides the editor) rather than a global shared prop.
            'locales' => \App\Support\Locales::all(),
            'aiEnabled' => filled(\App\Services\Settings::get('ai.api_key'))
                && (bool) \App\Services\Settings::get('ai.enabled', false)
                && $user->canUseAi(),
        ]);
    }

    /**
     * Project-details modal payload (JSON): authorship + structure + sharing.
     * Auth via authorizeProject (404 if no access — visibility, not capability).
     * Shared-with is per-project grants (SaaS only; self-hosted is a shared pool).
     */
    public function details(Request $request, Project $project): JsonResponse
    {
        $this->authorizeProject($request, $project);

        $project->loadMissing(['creator:id,name,email', 'lastEditor:id,name,email']);

        $shared = [];
        if (AppMode::isSaas()) {
            $grantedIds = \App\Models\ProjectMember::query()
                ->where('project_id', $project->id)
                ->pluck('user_id')
                ->all();
            if ($grantedIds !== []) {
                $shared = \App\Models\User::query()
                    ->whereIn('id', $grantedIds)
                    ->orderBy('name')
                    ->get(['id', 'name', 'email'])
                    ->map(fn ($u) => ['id' => $u->id, 'name' => $u->name, 'email' => $u->email])
                    ->all();
            }
        }

        return response()->json([
            'id' => $project->id,
            'name' => $project->name,
            'creator' => $project->creator
                ? ['name' => $project->creator->name, 'email' => $project->creator->email]
                : null,
            'last_editor' => $project->lastEditor
                ? ['name' => $project->lastEditor->name, 'email' => $project->lastEditor->email]
                : null,
            'last_edited_at' => $project->last_edited_at?->toIso8601String(),
            'created_at' => $project->created_at?->toIso8601String(),
            'form_factors' => array_values($project->active_form_factors ?: [$project->form_factor]),
            'screens_count' => $project->screens()->count(),
            'shared_with' => $shared,
        ]);
    }

    public function store(Request $request): RedirectResponse|JsonResponse
    {
        if ($gate = $this->planGateForCreate($request, 'create')) {
            return $gate;
        }

        $locales = \App\Support\Locales::codes();
        $supportedFactors = self::SUPPORTED_FORM_FACTORS;
        $data = $request->validate([
            'name' => ['required', 'string', 'max:120'],
            'template' => ['sometimes', 'string', 'max:60'],
            'form_factor' => ['sometimes', 'string', 'in:' . implode(',', $supportedFactors)],
            'active_form_factors' => ['sometimes', 'array', 'min:1'],
            'active_form_factors.*' => ['string', 'in:' . implode(',', $supportedFactors)],
            'default_locale' => ['sometimes', 'string', 'in:' . implode(',', $locales)],
            'active_locales' => ['sometimes', 'array', 'min:1'],
            'active_locales.*' => ['string', 'in:' . implode(',', $locales)],
            'design_tokens' => ['sometimes', 'nullable', 'array'],
            'design_tokens.bg' => ['required_with:design_tokens', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
            'design_tokens.accent' => ['required_with:design_tokens', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
        ]);

        $activeLocales = $data['active_locales'] ?? ['en'];
        $activeLocales = array_values(array_unique($activeLocales));
        $defaultLocale = $data['default_locale'] ?? 'en';

        if (! in_array($defaultLocale, $activeLocales, true)) {
            return back()->withErrors([
                'default_locale' => 'Default locale must be one of the active locales.',
            ])->withInput();
        }

        $kit = isset($data['template'])
            ? Templates::projectForKey($data['template'])
            : null;

        if (isset($data['template']) && $kit === null) {
            abort(422, "Unknown template '{$data['template']}'.");
        }

        // Premium-kit plan gate. Kits authored with `marketing.is_premium = true`
        // are gated to Pro+ plans — Free users get a 402 with a pricing
        // pointer. Self-hosted bypasses (single-user install, no billing).
        if (
            isset($data['template'])
            && Templates::isPremiumKit($data['template'])
            && ! $request->user()->canUsePremiumKits()
        ) {
            $message = 'This template is available on Pro and Scale plans. Upgrade to use it.';
            if ($request->expectsJson() && ! $request->header('X-Inertia')) {
                return response()->json([
                    'message' => $message,
                    'plan_key' => $request->user()->activePlan()->key,
                    'pricing_url' => route('pricing'),
                ], 402);
            }
            return back()->with('error', $message);
        }

        // Form factor: kit dictates when present (the kit's authored canvas
        // dims drive the project's canvas size); otherwise the user's pick;
        // falls back to the canonical default.
        $formFactor = $kit['form_factor']
            ?? $data['form_factor']
            ?? Templates::CANONICAL_BASE;

        // Multi-device pivot Phase 2: every project carries an
        // `active_form_factors` set. The primary factor (kit pick / user
        // pick / canonical default) is guaranteed to be in the set. If the
        // caller didn't ship a multi-factor list, we default to just the
        // primary — preserves single-factor behavior for existing flows.
        $activeFormFactors = $data['active_form_factors'] ?? [$formFactor];
        $activeFormFactors = array_values(array_unique($activeFormFactors));
        if (! in_array($formFactor, $activeFormFactors, true)) {
            array_unshift($activeFormFactors, $formFactor);
        }

        // Normalize tokens to lowercase so equality checks downstream
        // (preset-match in the UI, future "is brand X" queries) don't depend
        // on the casing the user typed.
        $tokens = null;
        if (! empty($data['design_tokens'])) {
            $tokens = [
                'bg' => strtolower($data['design_tokens']['bg']),
                'accent' => strtolower($data['design_tokens']['accent']),
            ];
        }

        $project = Project::create([
            'tenant_id' => $request->user()->tenant_id,
            'created_by' => $request->user()->id,
            'name' => $data['name'],
            'form_factor' => $formFactor,
            'active_form_factors' => $activeFormFactors,
            // Blank projects (no kit applied) get the sync-new-layers
            // affordance in Project Settings. Kit-based projects don't —
            // the kit already authored every factor, so adding cross-
            // factor sync would just confuse template users.
            'is_blank' => $kit === null,
            // Source kit key — lets later factor additions re-materialize
            // the kit's authored layout for that factor (see
            // applyFactorChanges) instead of stamping a bare scaffold.
            'template_key' => $kit !== null ? $data['template'] : null,
            'sync_new_layers' => false, // user opts in via Project Settings
            'default_locale' => $defaultLocale,
            'active_locales' => $activeLocales,
            'design_tokens' => $tokens,
        ]);

        $canvas = Templates::defaultCanvasFor($formFactor);
        $step = $canvas['w'] + self::VIEWPORT_GUTTER;

        if ($kit !== null) {
            // App Store Connect ceiling: 10 screenshots per device size.
            // Kits authored with more get truncated to the first 10 so the
            // project never starts in an over-cap state (which would block
            // future "Add screen" + "Duplicate" actions until the user
            // manually deletes screens). See ScreenController::MAX_SCREENS_PER_PROJECT.
            $kitScreens = array_slice($kit['screens'], 0, \App\Http\Controllers\ScreenController::MAX_SCREENS_PER_PROJECT);
            foreach ($kitScreens as $i => $kitScreen) {
                Screen::create([
                    'project_id' => $project->id,
                    'position' => $i,
                    'viewport_x' => $i * $step,
                    'viewport_y' => 0,
                    'name' => $kitScreen['name'],
                    'layouts' => $this->stampToActiveFactors(
                        $kitScreen['layout'],
                        $formFactor,
                        $activeFormFactors,
                        null,
                        $i === 0,
                    ),
                ]);
            }
            $this->materializeKitAssets($project, $data['template']);
            $this->grantCreatorVisibility($request, $project);
        } else {
            // Blank project — same layer structure as the "add screen"
            // button (bg + "Add Text" headline + iPhone frame). When brand
            // tokens are present, the bg gradient + headline color tint to
            // match; layers + content are otherwise identical.
            Screen::create([
                'project_id' => $project->id,
                'position' => 0,
                'viewport_x' => 0,
                'viewport_y' => 0,
                'name' => 'Screen 1',
                'layouts' => $this->stampToActiveFactors(
                    Templates::scaffoldedLayout($formFactor, $tokens),
                    $formFactor,
                    $activeFormFactors,
                    $tokens,
                ),
            ]);
            $this->grantCreatorVisibility($request, $project);
        }

        $this->recordProjectActivity($request, $project, 'created');

        // Explicit JSON callers (AI generate flow: X-Return-Json header) get a
        // minimal response; postJson tests and Inertia forms still get the redirect.
        if ($request->header('X-Return-Json')) {
            return response()->json([
                'project_id' => $project->id,
                'is_blank'   => (bool) $project->is_blank,
            ]);
        }

        return redirect("/editor/{$project->id}");
    }

    /**
     * Auto-grant the creator per-project visibility (Team Workspaces P3d /
     * Δ23 + Δ25/#2) — on EVERY creation path (store blank, store kit-install,
     * duplicate). A plain editor would otherwise create a project they can't
     * see (scope() filters to granted ids). Owner/admin see all via
     * short-circuit, so they need no grant row. Self-hosted writes none
     * (all-see-all pool — canAccessProject is always true there).
     *
     * Idempotent: firstOrCreate guards the UNIQUE(project_id,user_id) index.
     */
    private function grantCreatorVisibility(Request $request, Project $project): void
    {
        if (! AppMode::isSaas()) {
            return;
        }

        $user = $request->user();
        $tenant = $project->tenant;
        $role = $tenant !== null ? $user->roleIn($tenant) : null;

        // Owner/admin (and a null-pivot legacy owner — pre-backfill safety,
        // see `canAccessProject`) see every project anyway — no grant needed.
        // Only a resolved editor/viewer creator needs a visibility row.
        if ($role === null || in_array($role, ['owner', 'admin'], true)) {
            return;
        }

        \App\Models\ProjectMember::query()->firstOrCreate([
            'project_id' => $project->id,
            'user_id' => $user->id,
        ]);
    }

    /**
     * Record a project create/delete for the operator audit log + (when a
     * non-owner member acted) the workspace owner's bell + email.
     *
     * TWO layers, deliberately decoupled:
     *  1. AUDIT ROW — ALWAYS written, every actor (owner included), BOTH modes.
     *     So `/admin/audit-log` is a complete who-created/deleted-what record.
     *     The actor is captured by AuditLogger via `Auth::id()`. In self-hosted
     *     `$project->tenant` is null (single shared pool) — the row still lands,
     *     just with a null `tenant_id` context.
     *  2. OWNER NOTIFICATION — SaaS team-workspace only, and only when a
     *     NON-owner member acted. The owner's OWN actions are self-evident (and
     *     a personal workspace has the owner as sole member), so they're skipped
     *     for the bell/email — but they STILL get the audit row above. Viewers
     *     never reach a create/delete endpoint (EnsureCanEdit 403s them first).
     *
     * Call this BEFORE `$project->delete()` so the relations + id are still
     * live (the audit target + the notification's scalar payload).
     *
     * @param 'created'|'deleted' $kind
     */
    private function recordProjectActivity(Request $request, Project $project, string $kind): void
    {
        $actor = $request->user();
        $tenant = $project->tenant; // null in self-hosted (single shared pool)

        // Layer 1 — audit row, unconditional (both modes, every actor).
        AuditLogger::record(
            'workspace.project.'.$kind,
            $project,
            ($kind === 'created' ? 'Created project "' : 'Deleted project "').$project->name.'"',
            ['tenant_id' => $tenant?->id, 'project_id' => $project->id],
        );

        // Layer 2 — owner bell + email. SaaS team-workspace + non-owner actor.
        if (! AppMode::isSaas() || $tenant === null) {
            return;
        }
        $owner = $tenant->owner()->first();
        if ($owner === null || $owner->id === $actor->id) {
            return;
        }

        $actorName = $actor->name ?: $actor->email;

        // In-app bell row (synchronous) + queued email (always-on).
        $owner->notify(new ProjectActivity(
            projectName: $project->name,
            projectId: $kind === 'created' ? $project->id : null,
            actorName: $actorName,
            kind: $kind,
            workspaceName: $tenant->name,
        ));
        Mail::to($owner->email)->queue(new ProjectActivityMail(
            recipientName: (string) $owner->name,
            projectName: $project->name,
            actorName: $actorName,
            kind: $kind,
            openUrl: $kind === 'created' ? url("/editor/{$project->id}") : null,
        ));
    }

    public function update(Request $request, Project $project): RedirectResponse|JsonResponse
    {
        $this->authorizeProject($request, $project);

        // Project-level edits (rename / factors / locales) respect the edit
        // lock too (P4/Δ21) — a non-holder must take over first. The lock
        // applies on the default transport AND on Reverb when editing stays
        // 'locked'; only the opt-in 'concurrent' Reverb mode bypasses it (the
        // whole-document save can't merge true concurrent edits).
        if (! $this->concurrentEditingActive() && $guard = $this->guardEditLock($request, $project)) {
            return $guard;
        }

        $locales = \App\Support\Locales::codes();
        $data = $request->validate([
            'name' => ['sometimes', 'required', 'string', 'max:120'],
            'default_locale' => ['sometimes', 'string', 'in:'.implode(',', $locales)],
            'active_locales' => ['sometimes', 'array', 'min:1'],
            'active_locales.*' => ['string', 'in:'.implode(',', $locales)],
            'active_form_factors' => ['sometimes', 'array', 'min:1'],
            'active_form_factors.*' => ['string', 'in:'.implode(',', self::SUPPORTED_FORM_FACTORS)],
            'form_factor' => ['sometimes', 'string', 'in:'.implode(',', self::SUPPORTED_FORM_FACTORS)],
            'sync_new_layers' => ['sometimes', 'boolean'],
            'design_tokens' => ['sometimes', 'nullable', 'array'],
            'design_tokens.bg' => ['sometimes', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
            'design_tokens.accent' => ['sometimes', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
        ]);

        // Default must be in the active set. Validate against whichever
        // value lands in the final write (caller's new active_locales if
        // provided, else the project's current list).
        $nextActiveLocales = $data['active_locales'] ?? $project->active_locales ?? ['en'];
        if (isset($data['default_locale']) && ! in_array($data['default_locale'], $nextActiveLocales, true)) {
            throw \Illuminate\Validation\ValidationException::withMessages([
                'default_locale' => 'Default locale must be one of the active locales.',
            ]);
        }
        // If user dropped the previous default from active_locales without
        // specifying a new default, auto-promote the first remaining entry
        // — mirrors the project-create modal's behavior.
        $nextDefaultLocale = $data['default_locale'] ?? $project->default_locale ?? 'en';
        if (! in_array($nextDefaultLocale, $nextActiveLocales, true)) {
            $nextDefaultLocale = $nextActiveLocales[0];
        }

        $updates = [];
        if (array_key_exists('name', $data)) {
            $updates['name'] = $data['name'];
        }
        if (array_key_exists('active_locales', $data)) {
            $updates['active_locales'] = array_values(array_unique($nextActiveLocales));
            $updates['default_locale'] = $nextDefaultLocale;
        } elseif (array_key_exists('default_locale', $data)) {
            $updates['default_locale'] = $nextDefaultLocale;
        }
        if (array_key_exists('design_tokens', $data)) {
            $updates['design_tokens'] = $data['design_tokens'];
        }
        // sync_new_layers is editable on every project (blank + kit-based).
        // Even on kit-copied projects, the user adds custom layers and the
        // sync workflow is equally useful — gating to is_blank only was an
        // earlier guess that didn't match real usage.
        if (array_key_exists('sync_new_layers', $data)) {
            $updates['sync_new_layers'] = (bool) $data['sync_new_layers'];
        }

        // Active form factor mutations stamp into added factors + strip
        // removed ones across every screen. Primary is `[0]` by convention
        // — caller's `form_factor` field overrides; otherwise we infer from
        // the new list (first entry) or keep the current primary if it
        // survived the change.
        $factorMutation = null;
        if (array_key_exists('active_form_factors', $data)) {
            $currentFactors = is_array($project->active_form_factors) && $project->active_form_factors !== []
                ? array_values($project->active_form_factors)
                : [$project->form_factor];
            $nextFactors = array_values(array_unique($data['active_form_factors']));

            $nextPrimary = $data['form_factor']
                ?? (in_array($project->form_factor, $nextFactors, true)
                    ? $project->form_factor
                    : $nextFactors[0]);
            if (! in_array($nextPrimary, $nextFactors, true)) {
                throw \Illuminate\Validation\ValidationException::withMessages([
                    'form_factor' => 'Primary form factor must be in active_form_factors.',
                ]);
            }
            // Primary lives at index 0 by convention so the editor's
            // FormFactorSwitcher renders it first.
            $nextFactors = array_values(array_unique(array_merge([$nextPrimary], $nextFactors)));

            $added = array_values(array_diff($nextFactors, $currentFactors));
            $removed = array_values(array_diff($currentFactors, $nextFactors));

            // Deferred so the per-screen factor mutation runs in the SAME
            // transaction as the project update below — otherwise a mid-loop
            // failure strips a factor's per-screen data while the project still
            // lists it, leaving a corrupted, unrecoverable project.
            if ($added !== [] || $removed !== []) {
                $factorMutation = ['primary' => $nextPrimary, 'added' => $added, 'removed' => $removed];
            }

            $updates['active_form_factors'] = $nextFactors;
            $updates['form_factor'] = $nextPrimary;
        } elseif (array_key_exists('form_factor', $data)
            && $data['form_factor'] !== $project->form_factor
        ) {
            // Caller flipped primary without touching active_form_factors —
            // verify the new primary is in the existing active set, then
            // just rotate it to the front.
            $currentFactors = is_array($project->active_form_factors) && $project->active_form_factors !== []
                ? array_values($project->active_form_factors)
                : [$project->form_factor];
            if (! in_array($data['form_factor'], $currentFactors, true)) {
                throw \Illuminate\Validation\ValidationException::withMessages([
                    'form_factor' => 'Primary form factor must be in active_form_factors.',
                ]);
            }
            $updates['active_form_factors'] = array_values(array_unique(array_merge(
                [$data['form_factor']],
                $currentFactors,
            )));
            $updates['form_factor'] = $data['form_factor'];
        }

        if (! empty($updates) || $factorMutation !== null) {
            DB::transaction(function () use ($project, $updates, $factorMutation) {
                if ($factorMutation !== null) {
                    $this->mutateScreenFactors(
                        $project,
                        $factorMutation['primary'],
                        $factorMutation['added'],
                        $factorMutation['removed'],
                    );
                }
                if (! empty($updates)) {
                    $project->update($updates);
                }
            });
            Project::stampEdited($project->id, $request->user()->id);

            // Live-sync the project-level edit to other editors (Reverb tier) —
            // rename / factors / locales aren't screen saves, so they need
            // their own broadcast. Peers re-hydrate the `project` prop.
            if ($this->reverbTransportActive()) {
                broadcast(new \App\Events\ProjectUpdated(
                    projectId: $project->id,
                    by: ['id' => (int) $request->user()->id, 'name' => (string) $request->user()->name],
                    originTab: $request->header('X-Editor-Tab'),
                ))->toOthers();
            }
        }

        return back()->with('success', 'Project updated.');
    }

    /**
     * Apply added + removed factor mutations across every screen in the
     * project. Added factors get stamped from the project's primary
     * factor; removed factors get stripped via {@see LayoutResolver::removeFactorFromEnvelope}.
     */
    private function mutateScreenFactors(
        Project $project,
        string $primaryFactor,
        array $added,
        array $removed,
    ): void {
        if ($added === [] && $removed === []) {
            return;
        }

        $screens = Screen::where('project_id', $project->id)
            ->orderBy('position')
            ->orderBy('id')
            ->get();
        $firstScreenId = $screens->first()?->id;
        $tokens = is_array($project->design_tokens) ? $project->design_tokens : null;
        foreach ($screens as $screen) {
            $stored = $screen->layouts ?? [];
            if (! is_array($stored)) {
                continue;
            }
            $stored = LayoutMigrator::migrate($stored);
            $envelope = ($stored['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION
                ? $stored
                : LayoutMigrator::migrateToV4($stored, $project->form_factor);

            foreach ($removed as $factor) {
                $envelope = LayoutResolver::removeFactorFromEnvelope($envelope, $factor);
            }

            foreach ($added as $factor) {
                if ($factor === $primaryFactor) {
                    continue;
                }

                // Marketing factor + kit-created project: re-materialize the
                // kit's AUTHORED banner (all layers + assets) instead of
                // stamping — the kit designer's 1024×500 layout is exactly
                // what the user expects to land. Screen 1 only (the feature
                // graphic is a single asset rendered from the first screen);
                // later screens still need the canvas entry, which the
                // stamp below provides.
                if ($screen->id === $firstScreenId
                    && LayoutResolver::isMarketingFactor($factor)
                ) {
                    $kitSlice = $this->kitFactorSlice($project, $factor);
                    if ($kitSlice !== null) {
                        $envelope = $this->mergeFactorSlice($envelope, $factor, $kitSlice);
                        continue;
                    }
                }

                $targetCanvas = Templates::defaultCanvasFor($factor);
                $envelope = LayoutResolver::stampEnvelopeToFactor(
                    $envelope,
                    $primaryFactor,
                    $factor,
                    ['width' => $targetCanvas['w'], 'height' => $targetCanvas['h']],
                );
                $envelope = DevicePresetFamily::resolveFramesForFactor($envelope, $factor);
                // Device factors: the stamp clears frame screenshots (the
                // cutout dims differ per bezel) unless the source carries
                // `screen.factorPaths`. Kits that instead authored a full
                // per-factor `screen` (TemplateExporter output) have the
                // right screenshot sitting in the kit JSON — bind it.
                $envelope = $this->bindKitFactorScreens(
                    $project,
                    $envelope,
                    $factor,
                    (int) $screen->position,
                );
                // Marketing targets only inherit backgrounds from the
                // stamp — seed the banner headline (Screen 1 only; the
                // feature graphic renders from the first screen).
                if ($screen->id === $firstScreenId
                    && LayoutResolver::isMarketingFactor($factor)
                ) {
                    $envelope = $this->appendMarketingHeadline($envelope, $factor, $tokens);
                }
            }

            $screen->layouts = $envelope;
            $screen->save();
        }
    }

    public function duplicate(Request $request, Project $project): RedirectResponse|JsonResponse
    {
        $this->authorizeProject($request, $project);

        if ($gate = $this->planGateForCreate($request, 'duplicate')) {
            return $gate;
        }

        if ($gate = $this->planGateForDuplicateStorage($request, $project)) {
            return $gate;
        }

        // Duplicate-with-edits: user can re-target the copy at a different
        // form factor, rename, swap locales, and swap brand colors in one
        // step. Background layers auto-scale via their percent anchors
        // when the canvas resizes; everything else (text, frame, image,
        // shape) keeps its source anchors — user manually re-positions to
        // fit the new aspect ratio.
        $localeCodes = \App\Support\Locales::codes();
        $data = $request->validate([
            'name' => ['sometimes', 'string', 'max:120'],
            'form_factor' => ['sometimes', 'string', 'in:'.implode(',', Templates::supportedFormFactors())],
            'default_locale' => ['sometimes', 'string', 'in:'.implode(',', $localeCodes)],
            'active_locales' => ['sometimes', 'array', 'min:1'],
            'active_locales.*' => ['string', 'in:'.implode(',', $localeCodes)],
        ]);

        $newFormFactor = $data['form_factor'] ?? $project->form_factor;
        $newName = trim($data['name'] ?? '') !== ''
            ? trim($data['name'])
            : $this->copyName($project->name, $project->tenant_id);
        $factorChanged = $newFormFactor !== $project->form_factor;
        $newCanvas = Templates::defaultCanvasFor($newFormFactor);

        // Locale carry-over: accept caller-supplied overrides if present
        // (the duplicate modal exposes the picker). Validate default is in
        // the active set, mirroring the create path.
        $newActiveLocales = isset($data['active_locales'])
            ? array_values(array_unique($data['active_locales']))
            : ($project->active_locales ?? ['en']);
        $newDefaultLocale = $data['default_locale'] ?? $project->default_locale ?? 'en';
        if (! in_array($newDefaultLocale, $newActiveLocales, true)) {
            throw \Illuminate\Validation\ValidationException::withMessages([
                'default_locale' => 'Default locale must be one of the active locales.',
            ]);
        }

        // Carry the source's factor set, with the (possibly retargeted)
        // primary moved to the front — `active_form_factors[0]` mirrors
        // `form_factor` by convention. A retarget to a factor the source
        // didn't ship gets stamped per screen below.
        $sourceFactors = is_array($project->active_form_factors) && $project->active_form_factors !== []
            ? array_values($project->active_form_factors)
            : [$project->form_factor];
        // Single-factor sources stay v3 on disk; a retarget there swaps the
        // one factor outright (the flat canvas rewrite below). Multi-factor
        // sources keep everything they authored and gain the retarget
        // factor via the stamp.
        $newActiveFactors = count($sourceFactors) === 1 && $factorChanged
            ? [$newFormFactor]
            : array_values(array_unique(array_merge([$newFormFactor], $sourceFactors)));

        $copy = Project::create([
            'tenant_id' => $project->tenant_id,
            'created_by' => $request->user()->id,
            'name' => $newName,
            'form_factor' => $newFormFactor,
            'active_form_factors' => $newActiveFactors,
            // Copies inherit duplicate-relevant flags: the source kit key
            // (late factor-adds re-materialize kit content), blank-origin,
            // and the user's sync preference.
            'is_blank' => (bool) $project->is_blank,
            'template_key' => $project->template_key,
            'sync_new_layers' => (bool) $project->sync_new_layers,
            'default_locale' => $newDefaultLocale,
            'active_locales' => $newActiveLocales,
            'design_tokens' => $project->design_tokens,
        ]);

        // One-time path remap shared across screens — same source asset
        // referenced from multiple screens copies once into the new project's
        // media dir.
        $pathMap = [];

        // Collapse the per-asset audit rows into one batch entry so a
        // multi-screen duplicate doesn't pollute the audit log with N
        // `media_files.create` rows.
        MediaStorage::beginBatch('media.project.duplicate');
        try {
            Screen::query()
                ->where('project_id', $project->id)
                ->orderBy('position')
                ->orderBy('id')
                ->get()
                ->each(function (Screen $src) use ($copy, $project, $factorChanged, $newFormFactor, $newCanvas, &$pathMap): void {
                    // Migrate the layout body first (v1 → v3 layer-type folds),
                    // then re-ID every layer so the duplicate doesn't collide
                    // with the source on the workspace canvas, then rewrite
                    // asset paths into the destination project's media dir.
                    $layout = LayoutMigrator::migrate($src->layouts ?? []);
                    $layout = Templates::reIdLayout($layout);
                    $layout = $this->materializeProjectAssets(
                        $layout,
                        $project->id,
                        $copy->id,
                        $pathMap,
                    );
                    // Form-factor change: update the canvas dims so anchors
                    // resolve against the new aspect ratio. Background layers
                    // use percent anchors (0/0/100/100) and auto-rescale; the
                    // rest of the layers (text, frame, image, shape) keep
                    // their authored anchors — the user manually adjusts to
                    // fit the new aspect ratio. We intentionally skip
                    // geometric-mean font scaling and frame-preset swapping
                    // here; the goal is a clean handoff, not an auto-derive.
                    if ($factorChanged) {
                        if (($layout['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION) {
                            // v4 envelope: `canvas` is keyed by factor — a
                            // flat write would corrupt it. If the source
                            // didn't ship the retarget factor, stamp it from
                            // the source primary (same path Project Settings
                            // add-factor uses); factors the source authored
                            // pass through untouched.
                            if (! isset($layout['canvas'][$newFormFactor])) {
                                $layout = LayoutResolver::stampEnvelopeToFactor(
                                    $layout,
                                    $project->form_factor,
                                    $newFormFactor,
                                    ['width' => $newCanvas['w'], 'height' => $newCanvas['h']],
                                );
                                $layout = DevicePresetFamily::resolveFramesForFactor($layout, $newFormFactor);
                            }
                        } else {
                            $layout['canvas'] = [
                                'width' => $newCanvas['w'],
                                'height' => $newCanvas['h'],
                            ];
                        }
                    }

                    Screen::create([
                        'project_id' => $copy->id,
                        'position' => $src->position,
                        'viewport_x' => $src->viewport_x,
                        'viewport_y' => $src->viewport_y,
                        'name' => $src->name,
                        'layouts' => $layout,
                        'clip_overflow' => $src->clip_overflow,
                    ]);
                });
        } finally {
            MediaStorage::endBatch("Duplicated project #{$project->id} → #{$copy->id}");
        }

        // Auto-grant the creator visibility on the copy (Δ25/#2) — duplicate
        // is a creation path too. A plain editor who duplicates would otherwise
        // not see their own copy.
        $this->grantCreatorVisibility($request, $copy);

        $this->recordProjectActivity($request, $copy, 'created');

        return redirect("/editor/{$copy->id}")->with('success', "Duplicated as \"{$copy->name}\".");
    }

    /**
     * Walk every layer asset reference inside a flat Layout and, when it
     * points at the source project's media dir, copy the file into the new
     * project's dir + rewrite the path. Without this, the duplicate's images
     * point at the source's files and break when the source is deleted.
     *
     * Asset references walked per layer:
     *   - image.storagePath          (image layer)
     *   - background.image.storagePath  (background layer)
     *   - frame.screen.storagePath
     *   - frame.screen.localePaths.*    (per-locale screenshot)
     *
     * @param  array<string, mixed>  $layout
     * @param  array<string, string>  $pathMap projects/{src}/x.png => projects/{dst}/x.png
     * @return array<string, mixed>
     */
    private function materializeProjectAssets(
        array $layout,
        int $sourceProjectId,
        int $destProjectId,
        array &$pathMap,
    ): array {
        $srcPrefix = "projects/{$sourceProjectId}/";
        $dstPrefix = "projects/{$destProjectId}/";

        $remap = function (string $path) use ($srcPrefix, $dstPrefix, $destProjectId, &$pathMap): string {
            if (! str_starts_with($path, $srcPrefix)) {
                return $path;
            }
            if (isset($pathMap[$path])) {
                return $pathMap[$path];
            }
            $basename = substr($path, strlen($srcPrefix));
            $dest = $dstPrefix.$basename;

            $sourceBytes = $this->readMediaBytes($path);
            if ($sourceBytes !== null) {
                MediaStorage::put($sourceBytes, $dstPrefix === '' ? '' : rtrim($dstPrefix, '/'), $basename, [
                    'owner_type' => 'project',
                    'owner_id' => $destProjectId,
                    'purpose' => 'screen-image',
                ]);
            }

            $pathMap[$path] = $dest;

            return $dest;
        };

        if (isset($layout['layers']) && is_array($layout['layers'])) {
            foreach ($layout['layers'] as &$layer) {
                $this->rewriteLayerPaths($layer, $remap);
            }
            unset($layer);
        }

        return $layout;
    }

    /**
     * Rewrite every asset-bearing field on a layer through `$remap`. Probes
     * each field independently so the same helper covers image / background
     * / frame.screen variants without needing a discriminator.
     *
     * @param  callable(string): string  $remap
     */
    private function rewriteLayerPaths(mixed &$layer, callable $remap): void
    {
        if (! is_array($layer)) {
            return;
        }
        if (isset($layer['storagePath']) && is_string($layer['storagePath'])) {
            $layer['storagePath'] = $remap($layer['storagePath']);
        }
        if (isset($layer['image']['storagePath']) && is_string($layer['image']['storagePath'])) {
            $layer['image']['storagePath'] = $remap($layer['image']['storagePath']);
        }
        if (isset($layer['screen']) && is_array($layer['screen'])) {
            if (isset($layer['screen']['storagePath']) && is_string($layer['screen']['storagePath'])) {
                $layer['screen']['storagePath'] = $remap($layer['screen']['storagePath']);
            }
            foreach (['localePaths', 'factorPaths'] as $mapKey) {
                if (isset($layer['screen'][$mapKey]) && is_array($layer['screen'][$mapKey])) {
                    foreach ($layer['screen'][$mapKey] as $k => $path) {
                        if (is_string($path)) {
                            $layer['screen'][$mapKey][$k] = $remap($path);
                        }
                    }
                }
            }
        }
        // v4 layers keep their per-factor asset slots under
        // `perFactor.{factor}` (frame screens, per-factor bg images) — the
        // entries mirror the flat slot shape, so recurse once per factor.
        // Without this, a duplicated multi-factor project's non-primary
        // factors kept pointing at the SOURCE project's files and broke
        // when the source was deleted.
        if (isset($layer['perFactor']) && is_array($layer['perFactor'])) {
            foreach ($layer['perFactor'] as &$pf) {
                $this->rewriteLayerPaths($pf, $remap);
            }
            unset($pf);
        }
    }

    public function destroy(Request $request, Project $project): RedirectResponse
    {
        $this->authorizeProject($request, $project);

        $name = $project->name;
        $projectId = $project->id;

        // Notify the workspace owner BEFORE the row is gone (scalar payload,
        // relations still loadable).
        $this->recordProjectActivity($request, $project, 'deleted');

        $project->delete();

        MediaStorage::beginBatch('media.project.delete');
        try {
            MediaFile::query()
                ->where('owner_type', 'project')
                ->where('owner_id', $projectId)
                ->get()
                ->each(function (MediaFile $row): void {
                    MediaStorage::delete($row->path);
                });
        } finally {
            MediaStorage::endBatch("Deleted project #{$projectId} media");
        }

        return redirect('/projects')->with('success', "Deleted \"{$name}\".");
    }

    /**
     * JSON twin of `show()` scoped to what the Export-as-Template modal needs:
     * each screen's name + Layout. The modal renders each layout through the
     * canonical `renderScreenToBlob` Konva pipeline to produce marketing
     * preview PNGs, then submits them alongside the metadata in the export
     * POST.
     */
    public function exportTemplatePreflight(Request $request, Project $project): JsonResponse
    {
        $this->authorizeProject($request, $project);
        Gate::authorize('author-kits');

        $screens = Screen::query()
            ->where('project_id', $project->id)
            ->orderBy('position')
            ->orderBy('id')
            ->get(['id', 'name', 'layouts']);

        $layoutsByScreen = [];
        $payload = $screens->map(function (Screen $screen) use ($project, &$layoutsByScreen): array {
            // Marketing previews render via the client's Konva pipeline which
            // expects a v3 Layout. Multi-factor projects store as v4 envelope
            // — without the projection here, renderScreenToBlob would receive
            // a v4 shape it doesn't understand, the rendered fullBlob would be
            // empty/garbage, and downscaleImageBlob would either fail or
            // produce a blank thumbnail. Project to v3 for the project's
            // primary factor (kit author's canonical authoring surface).
            $stored = LayoutMigrator::migrate($screen->layouts ?? []);
            $envelope = ($stored['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION
                ? $stored
                : LayoutMigrator::migrateToV4($stored, $project->form_factor);
            $layout = LayoutResolver::resolveLayoutForFactorV4($envelope, $project->form_factor);
            $layoutsByScreen[$screen->id] = $layout;

            return [
                'id' => $screen->id,
                'name' => $screen->name,
                'layout' => $layout,
            ];
        })->values();

        $mediaUrlMap = $this->collectMediaUrlMap($layoutsByScreen);

        // The marketing render path uses `renderScreenToBlob` like the
        // editor's PNG export — without devicePresets the real-frame
        // lookup (`has_real_frame`, `real_frame_screen_rect`) can't
        // resolve and the renderer falls back to procedural bezels.
        // Ship the same shape Editor.tsx already consumes.
        $devicePresets = \App\Models\DevicePreset::query()
            ->where('is_active', true)
            ->orderBy('category')
            ->orderBy('export_w')
            ->get(['id', 'key', 'label', 'category', 'form_factor', 'apple_tier', 'export_w', 'export_h', 'safe_area', 'has_real_frame', 'real_frame_color_options', 'real_frame_screen_rect'])
            ->map(fn (\App\Models\DevicePreset $p) => [
                'id' => $p->id,
                'key' => $p->key,
                'label' => $p->label,
                'category' => $p->category,
                'form_factor' => $p->form_factor,
                'apple_tier' => $p->apple_tier,
                'export_w' => $p->export_w,
                'export_h' => $p->export_h,
                'safe_area' => $p->safe_area,
                'has_real_frame' => (bool) $p->has_real_frame,
                'real_frame_color_options' => $p->real_frame_color_options,
                'real_frame_screen_rect' => $p->real_frame_screen_rect,
            ]);

        return response()->json([
            'screens' => $payload,
            'mediaUrlMap' => $mediaUrlMap,
            'devicePresets' => $devicePresets,
        ]);
    }

    public function exportTemplate(
        Request $request,
        Project $project,
        TemplateExporter $exporter,
    ): BinaryFileResponse {
        $this->authorizeProject($request, $project);
        Gate::authorize('author-kits');

        $data = $request->validate([
            'key' => ['required', 'string', 'max:60', 'regex:/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/'],
            'name' => ['required', 'string', 'max:120'],
            'description' => ['nullable', 'string', 'max:300'],
            'category' => ['nullable', 'string', 'max:60'],
            'tagline' => ['nullable', 'string', 'max:200'],
            'marketing_pngs' => ['nullable', 'array', 'max:24'],
            'marketing_pngs.*' => ['file', 'mimetypes:image/jpeg,image/png', 'max:2048'],
            'compress' => ['sometimes', 'boolean'],
        ]);

        $marketingPngs = [];
        foreach ((array) $request->file('marketing_pngs', []) as $slug => $file) {
            if (is_string($slug) && $file && $file->isValid()) {
                $marketingPngs[$slug] = (string) file_get_contents($file->getRealPath());
            }
        }

        $bundle = $exporter->export(
            $project,
            $data['key'],
            $data['name'],
            $data['description'] ?? null,
            $data['category'] ?? null,
            $data['tagline'] ?? null,
            $marketingPngs,
            $request->boolean('compress'),
        );

        $zipPath = tempnam(sys_get_temp_dir(), 'appshot-tpl-').'.zip';
        $zip = new ZipArchive();
        if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            abort(500, 'Could not create template ZIP.');
        }
        $prefix = $data['key'].'/';
        $zip->addFromString(
            $prefix.$data['key'].'.json',
            json_encode(
                $bundle['json'],
                JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
            ),
        );
        foreach ($bundle['assets'] as $asset) {
            $zip->addFromString($prefix.$asset['bundlePath'], $asset['bytes']);
        }
        $zip->close();

        return response()
            ->download($zipPath, $data['key'].'.zip', [
                'Content-Type' => 'application/zip',
            ])
            ->deleteFileAfterSend(true);
    }

    /**
     * After applying a kit, copy any bundled `assets/*` files from the kit
     * directory into the new project's media dir and rewrite every matching
     * layer `storagePath` to the concrete `projects/{id}/{file}` reference.
     *
     * Layers left pointing at `assets/*` when no matching file exists are a
     * kit bug — the linter catches these before a kit ships.
     */
    private function materializeKitAssets(Project $project, string $kitKey): void
    {
        $assetsDir = resource_path("templates/projects/{$kitKey}/assets");
        if (! is_dir($assetsDir)) {
            return;
        }

        $nameMap = [];
        MediaStorage::beginBatch('media.kit.install');
        try {
            foreach ((array) scandir($assetsDir) as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }
                $src = $assetsDir.DIRECTORY_SEPARATOR.$entry;
                if (! is_file($src)) {
                    continue;
                }
                $bytes = (string) file_get_contents($src);
                $dest = MediaStorage::put($bytes, "projects/{$project->id}", $entry, [
                    'owner_type' => 'project',
                    'owner_id' => $project->id,
                    'purpose' => 'kit-asset',
                ]);
                $nameMap["assets/{$entry}"] = $dest;
            }
        } finally {
            MediaStorage::endBatch("Materialized kit '{$kitKey}' assets for project #{$project->id}");
        }

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

        Screen::query()
            ->where('project_id', $project->id)
            ->get()
            ->each(function (Screen $screen) use ($nameMap): void {
                $layout = $screen->layouts ?? [];
                if (! is_array($layout) || ! isset($layout['layers']) || ! is_array($layout['layers'])) {
                    return;
                }
                $changed = false;
                foreach ($layout['layers'] as &$layer) {
                    $changed = $this->rewriteLayerAssets($layer, $nameMap) || $changed;
                }
                unset($layer);

                if ($changed) {
                    $screen->layouts = $layout;
                    $screen->save();
                }
            });
    }

    /**
     * @param  array<string, string>  $nameMap assets/x.png => projects/N/x.png
     *
     * Field-presence checks (rather than `type` discrimination) so any layer
     * carrying any of these paths gets rewritten without a per-type branch.
     *
     * Walks both shapes: v3 top-level paths on the layer body AND v4
     * per-factor paths under `perFactor.{factor}.*`. v4 multi-device kits
     * lift frame.screen / background.image into perFactor, so a v3-only
     * walk would leave kit-asset references unrewritten on imports of
     * multi-factor kits.
     */
    private function rewriteLayerAssets(array &$layer, array $nameMap): bool
    {
        $changed = $this->rewriteAssetSlots($layer, $nameMap);

        if (isset($layer['perFactor']) && is_array($layer['perFactor'])) {
            foreach ($layer['perFactor'] as $factor => &$entry) {
                if (! is_array($entry)) {
                    continue;
                }
                $changed = $this->rewriteAssetSlots($entry, $nameMap) || $changed;
            }
            unset($entry);
        }

        return $changed;
    }

    /**
     * Rewrite asset paths in one "slot" — either a v3 layer body or a v4
     * perFactor entry. The shapes are the same (image.storagePath /
     * screen.storagePath / screen.localePaths.*), only their nesting path
     * differs, so the rewrite logic is shared.
     *
     * @param  array<string, mixed>  $slot
     * @param  array<string, string> $nameMap
     */
    private function rewriteAssetSlots(array &$slot, array $nameMap): bool
    {
        $changed = false;

        if (isset($slot['storagePath'], $nameMap[$slot['storagePath']])
            && is_string($slot['storagePath'])
        ) {
            $slot['storagePath'] = $nameMap[$slot['storagePath']];
            $changed = true;
        }
        if (isset($slot['image']['storagePath'], $nameMap[$slot['image']['storagePath']])
            && is_string($slot['image']['storagePath'])
        ) {
            $slot['image']['storagePath'] = $nameMap[$slot['image']['storagePath']];
            $changed = true;
        }
        if (isset($slot['screen']) && is_array($slot['screen'])) {
            if (isset($slot['screen']['storagePath'], $nameMap[$slot['screen']['storagePath']])
                && is_string($slot['screen']['storagePath'])
            ) {
                $slot['screen']['storagePath'] = $nameMap[$slot['screen']['storagePath']];
                $changed = true;
            }
            if (isset($slot['screen']['localePaths']) && is_array($slot['screen']['localePaths'])) {
                foreach ($slot['screen']['localePaths'] as $locale => $path) {
                    if (is_string($path) && isset($nameMap[$path])) {
                        $slot['screen']['localePaths'][$locale] = $nameMap[$path];
                        $changed = true;
                    }
                }
            }
            // v4 frame.screen also carries per-factor screenshot paths
            // under `factorPaths.{factor}` — used when a master frame
            // wants different screenshots per device.
            if (isset($slot['screen']['factorPaths']) && is_array($slot['screen']['factorPaths'])) {
                foreach ($slot['screen']['factorPaths'] as $factor => $path) {
                    if (is_string($path) && isset($nameMap[$path])) {
                        $slot['screen']['factorPaths'][$factor] = $nameMap[$path];
                        $changed = true;
                    }
                }
            }
        }

        return $changed;
    }

    private function copyName(string $original, ?int $tenantId): string
    {
        $base = preg_replace('/\s*\(copy(?: \d+)?\)$/', '', $original) ?? $original;
        $existing = Project::query()
            ->when(
                $tenantId === null,
                fn (Builder $q) => $q->whereNull('tenant_id'),
                fn (Builder $q) => $q->where('tenant_id', $tenantId),
            )
            ->where('name', 'like', $base . ' (copy%')
            ->pluck('name')
            ->all();

        if (! in_array("$base (copy)", $existing, true)) {
            return "$base (copy)";
        }

        $n = 2;
        while (in_array("$base (copy $n)", $existing, true)) {
            $n++;
        }
        return "$base (copy $n)";
    }

    public function show(Request $request, Project $project): Response
    {
        $this->authorizeProject($request, $project);

        $screens = Screen::where('project_id', $project->id)
            ->orderBy('position')
            ->orderBy('id')
            ->get();

        // Multi-device pivot Phase 2: resolve the active factor from
        // ?factor= against the project's active_form_factors set. Defaults
        // to project.form_factor (the primary factor seeded at create
        // time). Single-factor projects always render their one factor.
        $activeFormFactors = is_array($project->active_form_factors) && $project->active_form_factors !== []
            ? array_values($project->active_form_factors)
            : [$project->form_factor];
        $requestedFactor = $request->string('factor')->toString();
        $activeFactor = ($requestedFactor !== '' && in_array($requestedFactor, $activeFormFactors, true))
            ? $requestedFactor
            : $project->form_factor;

        $canvas = Templates::defaultCanvasFor($activeFactor);

        if ($screens->isEmpty()) {
            $screens = EloquentCollection::make([
                Screen::create([
                    'project_id' => $project->id,
                    'position' => 0,
                    'viewport_x' => 0,
                    'viewport_y' => 0,
                    'name' => 'Screen 1',
                    'layouts' => Templates::emptyLayout($activeFactor),
                ]),
            ]);
        } else {
            $this->ensureViewports($screens, $project, $canvas);
            $this->migrateLayouts($screens);
        }

        // Active locale resolves from ?locale= against the project's active
        // set, falling back to default. Server resolves once and ships the
        // locale-overlaid Layout — the editor reads it and persists locale
        // edits back through `LayoutResolver::applyLocaleOverrides` on the
        // client.
        $projectLocales = $project->active_locales ?? ['en'];
        $defaultLocale = $project->default_locale ?? 'en';
        $requestedLocale = $request->string('locale')->toString();
        $activeLocale = ($requestedLocale !== '' && in_array($requestedLocale, $projectLocales, true))
            ? $requestedLocale
            : $defaultLocale;

        $layoutsByScreen = [];
        $envelopesByScreen = [];
        foreach ($screens as $screen) {
            $stored = $screen->layouts ?? [];
            // Two-stage migration: v1/v2 layouts upgrade to v3 first (older
            // boundary chain), then the v3 layout is wrapped into a single-
            // factor v4 envelope and projected for the active factor. v4
            // envelopes already on disk pass through both stages cleanly
            // (idempotent on v4).
            $stored = LayoutMigrator::migrate($stored);
            $envelope = ($stored['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION
                ? $stored
                : LayoutMigrator::migrateToV4($stored, $project->form_factor);
            $envelopesByScreen[$screen->id] = $envelope;
            $layout = LayoutResolver::resolveLayoutForFactorV4($envelope, $activeFactor);
            $layout = LayoutResolver::applyLocaleOverrides($layout, $activeLocale, $defaultLocale);
            $layout = LayoutResolver::applyLocaleScreenPaths(
                $layout,
                $activeLocale !== $defaultLocale ? $activeLocale : null,
            );
            $layoutsByScreen[$screen->id] = $layout;
        }

        // Per-factor screenshot completeness audit: count frame layers and
        // how many of them have no screenshot on each active factor. Drives
        // the ExportModal pre-flight warning + the FormFactorSwitcher badge
        // so users discover unfinished factors BEFORE an export ships empty
        // device bezels. Frames start screenshot-less on every factor the
        // user hasn't authored — the cross-factor stamp intentionally clears
        // `screen` (different bezel cutout dims per device).
        $factorScreenshotGaps = [];
        foreach ($activeFormFactors as $factor) {
            $frames = 0;
            $missing = 0;
            $isMarketing = LayoutResolver::isMarketingFactor($factor);
            foreach ($screens as $screen) {
                // Marketing factors (feature graphic) only render Screen 1.
                if ($isMarketing && $screen->id !== $screens->first()->id) {
                    continue;
                }
                $layout = LayoutResolver::resolveLayoutForFactorV4(
                    $envelopesByScreen[$screen->id],
                    $factor,
                );
                foreach (($layout['layers'] ?? []) as $layer) {
                    if (! is_array($layer) || ($layer['type'] ?? null) !== 'frame') {
                        continue;
                    }
                    $frames++;
                    $path = $layer['screen']['storagePath'] ?? null;
                    if (! is_string($path) || $path === '') {
                        $missing++;
                    }
                }
            }
            $factorScreenshotGaps[$factor] = [
                'frames' => $frames,
                'missing' => $missing,
            ];
        }

        $requestedScreenId = $request->integer('screen');
        $active = $requestedScreenId > 0
            ? $screens->firstWhere('id', $requestedScreenId)
            : null;
        $active ??= $screens->first();

        // Marketing-asset factors (Play Store feature graphic) are a single
        // asset per project — they don't vary per screen. Force the active
        // screen to the first one regardless of `?screen=` so the editor
        // always shows + edits the same banner. Switching screens on
        // device factors doesn't change which feature graphic the user
        // sees; the marketing factor's data lives on Screen 1.
        if ($activeFactor === 'playstore_feature_graphic') {
            $active = $screens->first();
        }

        // Ship every active preset — the editor's surfaces each apply
        // their own narrower filter:
        //   - FramePickerPanel (in-canvas device frame layer picker) →
        //     by platform group (Apple / Android). So an iPhone project
        //     can drop an iPad mockup onto its canvas as marketing
        //     hardware, or vice-versa.
        //   - ExportModal device list → by the project's exact form
        //     factor (App Store / Play Store expect per-tier uploads).
        //   - PropertiesPanel + CanvasStage → lookups by id only; need
        //     every preset present so frame layers pointing at any
        //     preset can resolve their geometry.
        $devicePresets = DevicePreset::query()
            ->where('is_active', true)
            ->orderBy('category')
            ->orderBy('export_w')
            ->get(['id', 'key', 'label', 'category', 'form_factor', 'apple_tier', 'export_w', 'export_h', 'safe_area', 'has_real_frame', 'real_frame_color_options', 'real_frame_screen_rect'])
            ->map(fn (DevicePreset $p) => [
                'id' => $p->id,
                'key' => $p->key,
                'label' => $p->label,
                'category' => $p->category,
                'form_factor' => $p->form_factor,
                'apple_tier' => $p->apple_tier,
                'export_w' => $p->export_w,
                'export_h' => $p->export_h,
                'safe_area' => $p->safe_area,
                'has_real_frame' => (bool) $p->has_real_frame,
                'real_frame_color_options' => $p->real_frame_color_options,
                'real_frame_screen_rect' => $p->real_frame_screen_rect,
            ]);

        $mediaUrlMap = $this->collectMediaUrlMap($layoutsByScreen);

        return Inertia::render('Editor', [
            'projectId' => (string) $project->id,
            'project' => [
                'id' => $project->id,
                'name' => $project->name,
                'form_factor' => $project->form_factor,
                'active_form_factors' => $activeFormFactors,
                'is_blank' => (bool) $project->is_blank,
                'sync_new_layers' => (bool) $project->sync_new_layers,
                'canvas_w' => $canvas['w'],
                'canvas_h' => $canvas['h'],
                'default_locale' => $defaultLocale,
                'active_locales' => $projectLocales,
            ],
            'activeFactor' => $activeFactor,
            'factorScreenshotGaps' => $factorScreenshotGaps,
            'screens' => $screens->map(function (Screen $s) use ($activeFactor, $layoutsByScreen, $screens): array {
                // Marketing-asset factors show a fixed name independent
                // of the underlying device-screen name. The feature
                // graphic is one asset per project, named at creation
                // level — user can't rename it.
                $name = $s->name;
                if ($activeFactor === 'playstore_feature_graphic'
                    && $s->id === $screens->first()->id
                ) {
                    $name = 'Featured image';
                }
                return [
                    'id' => $s->id,
                    'name' => $name,
                    'position' => $s->position,
                    'viewport_x' => $s->viewport_x,
                    'viewport_y' => $s->viewport_y,
                    'layout' => $layoutsByScreen[$s->id] ?? null,
                    'clip_overflow' => (bool) $s->clip_overflow,
                    // Optimistic-concurrency baseline per screen — lets the AI
                    // polish pass save each screen with a correct base_version.
                    'version' => (int) $s->version,
                ];
            })->values(),
            'activeScreenId' => $active->id,
            'activeLocale' => $activeLocale,
            'screen' => [
                'id' => $active->id,
                'name' => $activeFactor === 'playstore_feature_graphic'
                    ? 'Featured image'
                    : $active->name,
                'layout' => $layoutsByScreen[$active->id] ?? null,
                // Optimistic-concurrency baseline. Autosave echoes this back as
                // `base_version`; the server 409s a write whose base is older
                // than stored, so a stale tab can't clobber newer work.
                'version' => (int) $active->version,
            ],
            // Concurrency baseline (Team Workspaces P4 / Δ15+Δ21). Lets the
            // editor render read-only-vs-editable on first paint instead of
            // flashing editable then locking. `held_by_me`/`holder` are
            // resolved against the live lock at request time; the mount-time
            // `ping` then re-confirms / acquires.
            'editLock' => $this->resolveEditLock($request, $project),
            // Tier-1 collab transport (Δ21). Default 'lock' (pessimistic
            // project lock, both modes). Tier-2 'reverb' is an SSH/VPS opt-in
            // shipped in P6 — the client's createRealtimeAdapter reads this.
            'collabTransport' => 'lock',
            'devicePresets' => $devicePresets,
            'mediaUrlMap' => $mediaUrlMap,
            // Locale catalog (code → name/direction/font_stack). The editor is
            // the only surface that reads it (RTL-aware Konva renders + the
            // locale picker), so it ships as an Editor page prop rather than a
            // global shared prop. `App\Support\Locales` is the single source.
            'locales' => \App\Support\Locales::all(),
            'aiEnabled' => filled(\App\Services\Settings::get('ai.api_key'))
                && (bool) \App\Services\Settings::get('ai.enabled', false)
                && $request->user()->canUseAi(),
        ]);
    }

    /**
     * Walk every layer across every screen and resolve each unique
     * storagePath to a renderable URL via MediaStorage::url. Thin wrapper
     * over the shared harvester ({@see \App\Support\LayoutMediaPaths}) —
     * the export preflight uses the same walker for its per-tile map.
     *
     * @param  array<int, array<string, mixed>>  $layoutsByScreen  screenId → Layout
     * @return array<string, string>  path → url
     */
    private function collectMediaUrlMap(array $layoutsByScreen): array
    {
        return \App\Support\LayoutMediaPaths::urlMapForLayouts($layoutsByScreen);
    }

    /**
     * Lay out screens horizontally by `position` order if any are missing
     * viewport coords. Persists the assignment so clients always see concrete
     * coordinates.
     *
     * @param  array{w: int, h: int}  $canvas
     */
    private function ensureViewports(EloquentCollection $screens, Project $project, array $canvas): void
    {
        $needsBackfill = $screens->contains(
            fn (Screen $s) => $s->viewport_x === null || $s->viewport_y === null,
        );
        if (! $needsBackfill) {
            return;
        }

        $step = $canvas['w'] + self::VIEWPORT_GUTTER;
        $x = 0;
        foreach ($screens as $screen) {
            $screen->viewport_x = $x;
            $screen->viewport_y = 0;
            $screen->save();
            $x += $step;
        }
    }

    /**
     * Lazy-upgrade older screen layouts to LATEST_VERSION on read via the
     * per-Layout migrator (v1 layer-type folds). The on-disk shape is a flat
     * Layout per screen.
     */
    private function migrateLayouts(EloquentCollection $screens): void
    {
        foreach ($screens as $screen) {
            $current = $screen->layouts ?? [];
            if (! is_array($current) || empty($current)) {
                continue;
            }
            $migrated = LayoutMigrator::migrate($current);
            if ($migrated !== $current) {
                $screen->layouts = $migrated;
                $screen->save();
            }
        }
    }

    /**
     * Align a layout (v3 OR v4) with the user's active form factors at
     * project-create time.
     *
     * Single-factor projects keep v3 on disk (migrate-on-read upgrades
     * lazily). Multi-factor projects materialize a v4 envelope.
     *
     * v3 input (legacy single-factor scaffold OR legacy v3 kit):
     *   - Single-factor user pick → return v3 unchanged
     *   - Multi-factor user pick → migrate to v4 + stamp into others
     *
     * v4 input (multi-factor kit):
     *   - Single-factor user pick → project v4 → v3 for user's factor
     *     (strips kit factors user didn't pick wholesale)
     *   - Multi-factor user pick → strip kit's extras + stamp into user's
     *     extras kit didn't have. User's primary anchors the stamp source.
     *
     * @param  array<int|string, mixed>  $scaffoldedLayout  v3 Layout OR v4 envelope
     * @param  string  $primaryFactor
     * @param  array<int, string>  $activeFormFactors
     */
    private function stampToActiveFactors(
        array $scaffoldedLayout,
        string $primaryFactor,
        array $activeFormFactors,
        ?array $tokens = null,
        bool $isFirstScreen = true,
    ): array {
        $isV4 = ($scaffoldedLayout['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION;
        $others = array_values(array_filter(
            $activeFormFactors,
            fn (string $f) => $f !== $primaryFactor,
        ));

        if ($others === []) {
            // Single-factor user pick.
            if ($isV4) {
                // v4 kit → strip down to user's single factor. Project to
                // v3 so single-factor projects stay on the v3 shape on
                // disk (consistent with legacy single-factor flow).
                return LayoutResolver::resolveLayoutForFactorV4(
                    $scaffoldedLayout,
                    $primaryFactor,
                );
            }
            return $scaffoldedLayout;
        }

        // Multi-factor user pick — work in v4 envelope shape.
        $envelope = $isV4
            ? $scaffoldedLayout
            : LayoutMigrator::migrateToV4($scaffoldedLayout, $primaryFactor);

        // Strip any factors the kit ships that the user didn't pick.
        $canvasFactors = array_keys(is_array($envelope['canvas'] ?? null) ? $envelope['canvas'] : []);
        foreach ($canvasFactors as $factor) {
            if (! in_array($factor, $activeFormFactors, true)) {
                $envelope = LayoutResolver::removeFactorFromEnvelope($envelope, $factor);
            }
        }

        // Install-time transformation policy: when the kit ALREADY ships a
        // factor (canvas + perFactor anchors authored by the kit author),
        // land it verbatim — no stamp, no family-resolution, no aspect-lock.
        // The kit author's intent is authoritative.
        //
        // Only when the user picks a factor the kit DIDN'T author do we
        // stamp from the primary factor and apply family-resolution +
        // aspect-lock — those transforms exist to make a single-factor or
        // partial kit usable for an extra factor the kit didn't anticipate.
        $canvasMap = is_array($envelope['canvas'] ?? null) ? $envelope['canvas'] : [];
        foreach ($others as $factor) {
            if (isset($canvasMap[$factor])) {
                continue; // Kit pre-authored this factor; preserve verbatim.
            }
            $targetCanvas = Templates::defaultCanvasFor($factor);
            $envelope = LayoutResolver::stampEnvelopeToFactor(
                $envelope,
                $primaryFactor,
                $factor,
                ['width' => $targetCanvas['w'], 'height' => $targetCanvas['h']],
            );
            // Family-resolution + aspect-lock only on stamped factors — the
            // kit author's authored anchors stay untouched. Frames with
            // `linkDeviceFamily=false` are skipped inside the resolver.
            $envelope = DevicePresetFamily::resolveFramesForFactor($envelope, $factor);
            // Marketing targets only inherit backgrounds from the stamp —
            // seed the banner headline so the canvas doesn't read as
            // broken/empty. First screen only: the feature graphic is a
            // single asset rendered from Screen 1.
            if ($isFirstScreen && LayoutResolver::isMarketingFactor($factor)) {
                $envelope = $this->appendMarketingHeadline($envelope, $factor, $tokens);
            }
        }
        return $envelope;
    }

    /**
     * Append the marketing headline scaffold layer to a v4 envelope under
     * the given (marketing) factor. {@see Templates::marketingHeadlineLayer}
     * for the why.
     *
     * @param  array{bg: string, accent: string}|null  $tokens
     */
    private function appendMarketingHeadline(array $envelope, string $factor, ?array $tokens): array
    {
        $layer = Templates::marketingHeadlineLayer($tokens);
        $perFactor = [
            'anchors' => $layer['anchors'],
            'fontSize' => $layer['fontSize'],
        ];
        unset($layer['anchors'], $layer['fontSize']);
        $layer['perFactor'] = [$factor => $perFactor];
        $envelope['layers'][] = $layer;

        return $envelope;
    }

    /**
     * The source kit's authored v3 Layout slice for `$factor`, with layer
     * ids re-issued and `assets/<file>` references materialized into this
     * project's media dir. Returns null when the project didn't come from
     * a kit, the kit is no longer installed, or the kit didn't author the
     * factor — callers fall back to the stamp + scaffold path.
     *
     * @return array{version: int, canvas: array{width: int, height: int}, layers: array<int, array<string, mixed>>}|null
     */
    private function kitFactorSlice(Project $project, string $factor): ?array
    {
        $key = $project->template_key;
        if (! is_string($key) || $key === '') {
            return null;
        }
        $kit = Templates::projectForKey($key);
        if ($kit === null) {
            return null;
        }
        $kitLayout = $kit['screens'][0]['layout'] ?? null;
        if (! is_array($kitLayout)) {
            return null;
        }
        $kitLayout = LayoutMigrator::migrate($kitLayout);
        if (($kitLayout['version'] ?? null) !== LayoutMigrator::LATEST_V4_VERSION) {
            return null; // single-factor kit — nothing authored beyond primary
        }
        if (! isset($kitLayout['canvas'][$factor])) {
            return null; // kit didn't author this factor
        }

        $slice = LayoutResolver::resolveLayoutForFactorV4($kitLayout, $factor);
        if (($slice['layers'] ?? []) === []) {
            return null;
        }

        // Copy only the assets THIS slice references (materializeKitAssets
        // copies the whole kit — wasteful for one factor) and rewrite the
        // `assets/<file>` paths to the project's media dir.
        $assetsDir = resource_path("templates/projects/{$key}/assets");
        $nameMap = [];
        MediaStorage::beginBatch('media.kit.factor-add');
        try {
            foreach ($slice['layers'] as $layer) {
                foreach ($this->collectKitAssetRefs($layer) as $ref) {
                    if (isset($nameMap[$ref])) {
                        continue;
                    }
                    $file = substr($ref, strlen('assets/'));
                    $srcPath = $assetsDir.DIRECTORY_SEPARATOR.$file;
                    if (! is_file($srcPath)) {
                        continue;
                    }
                    $dest = MediaStorage::put((string) file_get_contents($srcPath), "projects/{$project->id}", $file, [
                        'owner_type' => 'project',
                        'owner_id' => $project->id,
                        'purpose' => 'kit-asset',
                    ]);
                    $nameMap[$ref] = $dest;
                }
            }
        } finally {
            MediaStorage::endBatch("Materialized kit '{$key}' {$factor} assets for project #{$project->id}");
        }
        if ($nameMap !== []) {
            foreach ($slice['layers'] as &$layer) {
                $this->rewriteLayerAssets($layer, $nameMap);
            }
            unset($layer);
        }

        // Fresh ids — the project's layers were re-ID'd at install, so the
        // kit-era ids can't pair with them anyway, and re-issuing prevents
        // collisions on remove-then-re-add cycles.
        return Templates::reIdLayout($slice);
    }

    /**
     * Merge a v3 factor slice into a v4 envelope: canvas entry + each layer
     * appended as an independent LayerV4 scoped to `$factor`.
     */
    private function mergeFactorSlice(array $envelope, string $factor, array $slice): array
    {
        $sliceEnv = LayoutMigrator::migrateToV4($slice, $factor);
        $canvas = $sliceEnv['canvas'][$factor] ?? null;
        if (is_array($canvas)) {
            $envelope['canvas'][$factor] = $canvas;
        }
        foreach (($sliceEnv['layers'] ?? []) as $layer) {
            if (is_array($layer)) {
                $envelope['layers'][] = $layer;
            }
        }

        return $envelope;
    }

    /**
     * Bind kit-authored screenshots onto freshly-stamped frames for an
     * added factor. The cross-factor stamp intentionally clears
     * `frame.screen` (bezel cutout dims differ per device) and only
     * promotes `screen.factorPaths` — but kits exported from multi-factor
     * projects author each factor's screenshot as a full per-factor
     * `screen` instead. Recover those: pair the screen's stamped frames
     * with the kit screen's frames by order (install re-IDs layers, so ids
     * can't pair), materialize each referenced asset, and copy the kit's
     * `screen` (fit / zoom / offsets included) with the path rewritten.
     *
     * No-op for blank projects, uninstalled kits, factors the kit didn't
     * author, screens past the kit's screen count, or frames that already
     * carry a screenshot (factorPaths promotion won).
     */
    private function bindKitFactorScreens(
        Project $project,
        array $envelope,
        string $factor,
        int $screenPosition,
    ): array {
        if (LayoutResolver::isMarketingFactor($factor)) {
            return $envelope; // banners take the full kit-slice path
        }
        $key = $project->template_key;
        if (! is_string($key) || $key === '') {
            return $envelope;
        }
        $kit = Templates::projectForKey($key);
        $kitLayout = $kit['screens'][$screenPosition]['layout'] ?? null;
        if (! is_array($kitLayout)) {
            return $envelope;
        }
        $kitLayout = LayoutMigrator::migrate($kitLayout);
        if (($kitLayout['version'] ?? null) !== LayoutMigrator::LATEST_V4_VERSION
            || ! isset($kitLayout['canvas'][$factor])
        ) {
            return $envelope;
        }

        // Kit frames (in order) that carry an authored screenshot for the
        // factor — keep the whole `screen` dict so fit/zoom/offset survive.
        $kitScreens = [];
        foreach (($kitLayout['layers'] ?? []) as $kitLayer) {
            if (! is_array($kitLayer) || ($kitLayer['type'] ?? null) !== 'frame') {
                continue;
            }
            $kitScreen = $kitLayer['perFactor'][$factor]['screen'] ?? null;
            $kitScreens[] = is_array($kitScreen) && is_string($kitScreen['storagePath'] ?? null)
                ? $kitScreen
                : null;
        }
        if (array_filter($kitScreens) === []) {
            return $envelope;
        }

        $assetsDir = resource_path("templates/projects/{$key}/assets");
        $nameMap = [];
        $frameIdx = 0;
        MediaStorage::beginBatch('media.kit.factor-add');
        try {
            foreach ($envelope['layers'] as &$layer) {
                if (! is_array($layer) || ($layer['type'] ?? null) !== 'frame') {
                    continue;
                }
                if (! is_array($layer['perFactor'][$factor] ?? null)) {
                    continue; // frame not present on the added factor
                }
                $kitScreen = $kitScreens[$frameIdx] ?? null;
                $frameIdx++;
                if ($kitScreen === null) {
                    continue;
                }
                if (is_string($layer['perFactor'][$factor]['screen']['storagePath'] ?? null)) {
                    continue; // factorPaths promotion already bound one
                }
                $ref = $kitScreen['storagePath'];
                if (! str_starts_with($ref, 'assets/')) {
                    continue;
                }
                if (! isset($nameMap[$ref])) {
                    $file = substr($ref, strlen('assets/'));
                    $srcPath = $assetsDir.DIRECTORY_SEPARATOR.$file;
                    if (! is_file($srcPath)) {
                        continue;
                    }
                    $nameMap[$ref] = MediaStorage::put((string) file_get_contents($srcPath), "projects/{$project->id}", $file, [
                        'owner_type' => 'project',
                        'owner_id' => $project->id,
                        'purpose' => 'kit-asset',
                    ]);
                }
                $bound = $kitScreen;
                $bound['storagePath'] = $nameMap[$ref];
                unset($bound['factorPaths']);
                $layer['perFactor'][$factor]['screen'] = $bound;
            }
            unset($layer);
        } finally {
            MediaStorage::endBatch("Bound kit '{$key}' {$factor} screenshots for project #{$project->id}");
        }

        return $envelope;
    }

    /**
     * Every `assets/<file>` reference reachable from a v3 layer body —
     * direct image, background image, frame screen + its factorPaths.
     *
     * @return list<string>
     */
    private function collectKitAssetRefs(array $layer): array
    {
        $refs = [];
        $push = function ($v) use (&$refs): void {
            if (is_string($v) && str_starts_with($v, 'assets/')) {
                $refs[] = $v;
            }
        };
        $push($layer['storagePath'] ?? null);
        $push($layer['image']['storagePath'] ?? null);
        $push($layer['screen']['storagePath'] ?? null);
        foreach ((array) ($layer['screen']['factorPaths'] ?? []) as $v) {
            $push($v);
        }
        foreach ((array) ($layer['screen']['localePaths'] ?? []) as $v) {
            $push($v);
        }

        return $refs;
    }

    private function scope(Request $request): Builder
    {
        $q = Project::query();

        if (AppMode::isSaas()) {
            $user = $request->user();
            $q->where('tenant_id', $user->tenant_id);

            // Per-project visibility (Team Workspaces P3d / Δ23). Owner/admin
            // (and a null-pivot pre-backfill legacy owner — see
            // `User::canAccessProject`) see every project in the tenant; a
            // resolved editor/viewer sees only the projects granted to them via
            // `project_user`. Tenant_id stays the outer filter (no cross-tenant
            // leak); this narrows WITHIN it. Kept in lockstep with
            // `canAccessProject` so the index list and direct-access authorize
            // agree on what's visible.
            $tenant = $user->tenant;
            $role = $tenant !== null ? $user->roleIn($tenant) : null;
            $seesAll = $role === null || in_array($role, ['owner', 'admin'], true);

            if (! $seesAll) {
                $grantedIds = \App\Models\ProjectMember::query()
                    ->where('user_id', $user->id)
                    ->pluck('project_id');
                $q->whereIn('id', $grantedIds);
            }

            return $q;
        }

        return $q->whereNull('tenant_id');
    }

    /**
     * Read raw bytes for a media path via the active storage disk. Always
     * uses the 'public' alias — that's the disk MediaStorage writes through,
     * overlaid at boot to point at the active cloud bucket. The
     * `media_files.disk` column stores informational metadata, NOT a literal
     * Laravel disk name.
     */
    private function readMediaBytes(string $path): ?string
    {
        $disk = Storage::disk(self::ACTIVE_DISK_ALIAS);

        return $disk->exists($path) ? (string) $disk->get($path) : null;
    }

    private function authorizeProject(Request $request, Project $project): void
    {
        if (AppMode::isSelfHosted()) {
            abort_unless($project->tenant_id === null, 404);

            return;
        }

        abort_unless($project->tenant_id === $request->user()->tenant_id, 404);

        // Per-project visibility (Team Workspaces P3d / Δ23). 404 (NOT 403) so
        // an ungranted member can't even tell the project exists. Capability
        // denial (viewer write) stays 403 via EnsureCanEdit — visibility and
        // capability are orthogonal axes.
        abort_unless($request->user()->canAccessProject($project), 404);
    }

    /**
     * Resolve the editor's concurrency baseline prop (Team Workspaces P4 /
     * Δ15+Δ21): `{can_edit, held_by_me, holder:{id,name}|null}`.
     *
     * A live lock held by someone else → `held_by_me:false` + holder identity
     * so the editor renders read-only on first paint. An absent or stale
     * (TTL-expired) lock → `held_by_me:true` (the mount `ping` will acquire).
     *
     * @return array{can_edit: bool, held_by_me: bool, holder: array{id: int, name: string}|null}
     */
    private function resolveEditLock(Request $request, Project $project): array
    {
        // Viewers get can_edit:false and never heartbeat / take over. Editors,
        // admins, and owners (and every self-hosted non-viewer) edit normally.
        $canEdit = $request->user()->canEditActiveWorkspace();

        $lock = ProjectEditLock::query()
            ->where('project_id', $project->id)
            ->first();

        $userId = $request->user()->id;
        $now = now();

        $heldByMe = true;
        $holder = null;

        if ($lock !== null && $lock->user_id !== $userId) {
            $isStale = $lock->last_heartbeat_at === null
                || $lock->last_heartbeat_at->lt($now->copy()->subSeconds(ProjectEditLockController::LOCK_TTL));
            if (! $isStale) {
                $heldByMe = false;
                $holderUser = $lock->user()->first();
                $holder = [
                    'id' => $lock->user_id,
                    'name' => $holderUser?->name ?? 'Someone',
                ];
            }
        }

        // Eagerly write the lock on page-load when we can hold it. This makes
        // the lock visible to concurrent requests immediately — without it a
        // second user who opens the editor before the first user's async
        // mount-ping completes will also see held_by_me:true and acquire the
        // lock, racing the first user to the same row.
        if ($canEdit && $heldByMe) {
            ProjectEditLock::updateOrCreate(
                ['project_id' => $project->id],
                ['user_id' => $userId, 'last_heartbeat_at' => $now],
            );
        }

        return [
            'can_edit' => $canEdit,
            'held_by_me' => $heldByMe,
            'holder' => $holder,
        ];
    }

    private function planGateForCreate(Request $request, string $action): RedirectResponse|JsonResponse|null
    {
        $user = $request->user();
        if ($user === null) {
            return null;
        }

        $limit = $user->projectLimit();
        if ($limit === null) {
            return null;
        }

        $count = $this->scope($request)->count();
        if ($count < $limit) {
            return null;
        }

        $verb = $action === 'duplicate' ? 'duplicate' : 'create more';
        $message = $limit === 1
            ? "Your current plan allows 1 project. Upgrade to {$verb}."
            : "Your current plan allows up to {$limit} projects. Upgrade to {$verb}.";

        if ($request->expectsJson() && ! $request->header('X-Inertia')) {
            return response()->json([
                'message' => $message,
                'plan_key' => $user->activePlan()->key,
                'pricing_url' => route('pricing'),
            ], 402);
        }

        return back()->with('error', $message);
    }

    private function planGateForDuplicateStorage(Request $request, Project $project): RedirectResponse|JsonResponse|null
    {
        $user = $request->user();
        if ($user === null) {
            return null;
        }

        $limit = $user->storageBytesLimit();
        if ($limit === null) {
            return null;
        }

        $estimatedBytes = (int) MediaFile::query()
            ->where('owner_type', 'project')
            ->where('owner_id', $project->id)
            ->sum('size_bytes');

        if ($user->canUpload($estimatedBytes)) {
            return null;
        }

        $message = 'Duplicating this project would exceed your plan\'s storage limit. Upgrade to free up headroom.';

        if ($request->expectsJson() && ! $request->header('X-Inertia')) {
            return response()->json([
                'message' => $message,
                'plan_key' => $user->activePlan()?->key,
                'pricing_url' => route('pricing'),
                'used_bytes' => $user->storageBytesUsed(),
                'limit_bytes' => $limit,
                'estimated_bytes' => $estimatedBytes,
            ], 402);
        }

        return back()->with('error', $message);
    }
}
