<?php

namespace App\Support;

use App\Models\DevicePreset;

/**
 * Cross-factor device propagation via `device_presets.family_id`.
 *
 * Phase 3.5 of `multi-device-plan.md`: when a frame layer is stamped
 * into a new factor (project create with multi-factor, add-factor mid-
 * project), the source's device preset's family_id is used to look up
 * the equivalent device on the target factor. iPhone 17 Pro Max maps
 * to iPad Pro 13" maps to Galaxy S26 maps to Galaxy Tab S8 Ultra — one
 * 'pro_max' family across 4 form-factor families.
 *
 * Always user-triggered + visible (never silent at render-time, unlike
 * the responsive engine v3 trap this lives in the shadow of). The user
 * sees the iPad device get assigned on save and can override per-factor
 * by toggling `linkDeviceFamily = false` on the layer (UI lands later).
 *
 * Behaviour:
 *   - `equivalentFor($sourceId, $targetFactor)` returns the target factor's
 *     active preset id sharing the source's family_id, or null when the
 *     source is family-less / the target factor has no equivalent / the
 *     source preset doesn't exist
 *   - `resolveFramesForFactor($envelope, $targetFactor)` walks every frame
 *     layer in a v4 envelope and rewrites `perFactor[$targetFactor].devicePresetId`
 *     to the family equivalent. Frames with `linkDeviceFamily === false`
 *     are left alone. Missing equivalents fall back to the target factor's
 *     default preset (so an iPhone preset id never bleeds onto an iPad
 *     perFactor entry, even when the family map misses).
 */
class DevicePresetFamily
{
    /**
     * Find the target factor's active device preset whose family_id matches
     * the source preset's family_id. Returns null when no path exists.
     */
    public static function equivalentFor(int $sourcePresetId, string $targetFactor): ?int
    {
        $source = DevicePreset::query()
            ->whereKey($sourcePresetId)
            ->first(['id', 'family_id']);

        if (! $source || $source->family_id === null || $source->family_id === '') {
            return null;
        }

        $id = DevicePreset::query()
            ->where('form_factor', $targetFactor)
            ->where('family_id', $source->family_id)
            ->where('is_active', true)
            ->orderBy('id')
            ->value('id');

        return $id ? (int) $id : null;
    }

    /**
     * Stamp newly-added layers from `$sourceFactor` across every other
     * non-marketing factor present in `$newEnvelope`. Companion to
     * {@see propagateFramesFromFactor}: that one rewrites devicePresetId
     * on layers that already exist on multiple factors; this one fills in
     * missing `perFactor[$other]` entries on layers freshly added on the
     * source factor (the editor can only add a layer to one factor at a
     * time).
     *
     * Scope (the `$onlyFrames` parameter):
     *   - `true` (default) — only frame layers propagate. The Phase 3c
     *     behaviour that has shipped since multi-device v4 landed. Used
     *     when a project's `sync_new_layers` is off.
     *   - `false` — every layer type propagates (text / shape / image /
     *     callout / group / frame). Used when a blank project's
     *     `sync_new_layers` is on (kit-author affordance).
     *
     * Common behaviour:
     *   - Diff strategy: compare layer ids in `$newEnvelope` against ids
     *     in `$oldEnvelope`. Only ids absent from the old envelope are
     *     considered new.
     *   - Skips frames with `linkDeviceFamily === false`
     *   - Skips when source factor is marketing (`playstore_feature_graphic`)
     *     — those layers don't belong on device factors
     *   - Skips target factors that are marketing — those stay standalone
     *   - Skips when `perFactor[$targetFactor]` already exists (don't
     *     overwrite — that's `propagateFramesFromFactor`'s job)
     *   - Family-equivalent device on frame layers (source family →
     *     equivalent on `$targetFactor`); falls back to target's default
     *     preset when family lookup misses
     *   - Axis-scales px anchors; percent anchors auto-rescale at render
     *   - Drops per-factor screenshot on frames (`screen`) — user uploads
     *     per-device screenshots separately
     *
     * @param  array<string, mixed>  $oldEnvelope  envelope BEFORE the edit
     * @param  array<string, mixed>  $newEnvelope  envelope AFTER `applyFactorEditV4`
     * @param  string  $sourceFactor               the factor that was edited
     * @param  bool  $onlyFrames                   when false, every layer type propagates
     */
    public static function stampNewLayersIntoOtherFactors(
        array $oldEnvelope,
        array $newEnvelope,
        string $sourceFactor,
        bool $onlyFrames = true,
    ): array {
        if (($newEnvelope['version'] ?? null) !== LayoutMigrator::LATEST_V4_VERSION) {
            return $newEnvelope;
        }
        // Marketing-source layers don't bleed onto device factors —
        // feature graphic is its own canvas. Skip entirely.
        if (LayoutResolver::isMarketingFactor($sourceFactor)) {
            return $newEnvelope;
        }
        $canvasMap = is_array($newEnvelope['canvas'] ?? null) ? $newEnvelope['canvas'] : [];
        $sourceCanvas = $canvasMap[$sourceFactor] ?? null;
        if (! is_array($sourceCanvas)) {
            return $newEnvelope;
        }
        $targetFactors = array_values(array_filter(
            array_keys($canvasMap),
            fn (string $f) => $f !== $sourceFactor && ! LayoutResolver::isMarketingFactor($f),
        ));
        if ($targetFactors === []) {
            return $newEnvelope;
        }

        $oldLayerIds = self::collectLayerIds($oldEnvelope);
        $sw = (float) ($sourceCanvas['width'] ?? 0);
        $sh = (float) ($sourceCanvas['height'] ?? 0);
        if ($sw <= 0 || $sh <= 0) {
            return $newEnvelope;
        }

        $layers = is_array($newEnvelope['layers'] ?? null) ? $newEnvelope['layers'] : [];
        foreach ($layers as &$layer) {
            if (! is_array($layer)) {
                continue;
            }
            $type = $layer['type'] ?? null;
            $isFrame = $type === 'frame';
            // Scope gate: when caller restricts to frames (default), skip
            // every other type. The sync_new_layers project flag flips
            // this to false so all propagatable types propagate.
            if ($onlyFrames && ! $isFrame) {
                continue;
            }
            // Even when sync_new_layers is on, `callout` stays per-factor —
            // it highlights a region of a per-device screenshot, so the
            // anchor only makes sense on the factor it was authored against.
            // `image` layers (decorative graphics like logos / icons /
            // hero photos) DO propagate; the per-device screenshot case
            // is handled by frame's bound `screen` field which is always
            // cleared on stamp.
            if (! $onlyFrames && $type === 'callout') {
                continue;
            }
            $id = $layer['id'] ?? null;
            if (! is_string($id) || isset($oldLayerIds[$id])) {
                continue; // Existing layer — propagateFramesFromFactor handles it.
            }
            if ($isFrame
                && array_key_exists('linkDeviceFamily', $layer)
                && $layer['linkDeviceFamily'] === false
            ) {
                continue;
            }
            $perFactor = is_array($layer['perFactor'] ?? null) ? $layer['perFactor'] : [];
            $sourceEntry = $perFactor[$sourceFactor] ?? null;
            if (! is_array($sourceEntry)) {
                continue; // No source-factor anchor — nothing to stamp from.
            }

            $sourceFamilyId = null;
            if ($isFrame) {
                $sourceDeviceId = $sourceEntry['devicePresetId'] ?? null;
                if (is_int($sourceDeviceId) || is_numeric($sourceDeviceId)) {
                    $sourceFamilyId = DevicePreset::query()
                        ->whereKey((int) $sourceDeviceId)
                        ->value('family_id');
                }
            }

            foreach ($targetFactors as $targetFactor) {
                if (isset($perFactor[$targetFactor])) {
                    continue; // Already populated — skip.
                }
                $targetCanvas = $canvasMap[$targetFactor] ?? null;
                if (! is_array($targetCanvas)) {
                    continue;
                }
                $tw = (float) ($targetCanvas['width'] ?? 0);
                $th = (float) ($targetCanvas['height'] ?? 0);
                if ($tw <= 0 || $th <= 0) {
                    continue;
                }
                $rw = $tw / $sw;
                $rh = $th / $sh;

                $stamped = $sourceEntry;
                if (isset($stamped['anchors']) && is_array($stamped['anchors'])) {
                    $stamped['anchors'] = self::scaleAnchors($stamped['anchors'], $rw, $rh);
                }

                if ($isFrame) {
                    // Per-factor screenshot stays empty.
                    unset($stamped['screen']);

                    // Pick devicePresetId via family map, then fall back to
                    // target factor's default preset when family misses (so
                    // an iPhone preset id never ends up on an iPad layer).
                    $equivalent = null;
                    if (is_string($sourceFamilyId) && $sourceFamilyId !== '') {
                        $equivalent = DevicePreset::query()
                            ->where('form_factor', $targetFactor)
                            ->where('family_id', $sourceFamilyId)
                            ->where('is_active', true)
                            ->orderBy('id')
                            ->value('id');
                    }
                    if ($equivalent === null) {
                        $equivalent = Templates::defaultPresetIdFor($targetFactor);
                    }
                    if ($equivalent !== null) {
                        $stamped['devicePresetId'] = (int) $equivalent;
                    } else {
                        // No equivalent + no default — drop the field rather
                        // than carry source's preset id onto the wrong factor.
                        unset($stamped['devicePresetId']);
                    }
                }

                $layer['perFactor'][$targetFactor] = $stamped;
            }
        }
        unset($layer);

        $newEnvelope['layers'] = $layers;
        return $newEnvelope;
    }

    /**
     * Companion to {@see stampNewLayersIntoOtherFactors} for the sync_new_layers
     * authoring affordance — when the user deletes a layer on the active
     * factor, also remove it from every OTHER factor so add/delete stay
     * symmetric under sync.
     *
     * Without sync, deletes are per-factor scoped (the safer default —
     * delete on iPad doesn't blow up iPhone). Under sync, the user has
     * opted into "all factors move together," so deletes follow adds.
     *
     * Algorithm:
     *   1. Build set of layer ids present in `$editedLayout` (the v3
     *      payload the editor sent)
     *   2. Walk old envelope layers; for any layer that HAD
     *      `perFactor[$sourceFactor]` and is NOT in edited → it's a delete
     *   3. Drop those layers wholesale from `$newEnvelope.layers`
     *
     * `applyFactorEditV4` already removes the per-factor entry on the
     * source factor; this helper takes the next step and removes the
     * layer entirely (so other factors' perFactor entries are also wiped).
     *
     * @param  array<string, mixed>  $oldEnvelope    envelope BEFORE the edit
     * @param  array<string, mixed>  $newEnvelope    envelope AFTER `applyFactorEditV4`
     * @param  string  $sourceFactor                 the factor that was edited
     * @param  array<string, mixed>  $editedLayout   the v3 layout the editor posted
     */
    public static function propagateDeletesAcrossFactors(
        array $oldEnvelope,
        array $newEnvelope,
        string $sourceFactor,
        array $editedLayout,
    ): array {
        if (($newEnvelope['version'] ?? null) !== LayoutMigrator::LATEST_V4_VERSION) {
            return $newEnvelope;
        }
        // Marketing factor never participates in cross-factor sync.
        if (LayoutResolver::isMarketingFactor($sourceFactor)) {
            return $newEnvelope;
        }

        $editedIds = [];
        $editedLayers = is_array($editedLayout['layers'] ?? null) ? $editedLayout['layers'] : [];
        foreach ($editedLayers as $layer) {
            if (is_array($layer) && is_string($layer['id'] ?? null)) {
                $editedIds[$layer['id']] = true;
            }
        }

        $deletedIds = [];
        $oldLayers = is_array($oldEnvelope['layers'] ?? null) ? $oldEnvelope['layers'] : [];
        foreach ($oldLayers as $oldLayer) {
            if (! is_array($oldLayer)) {
                continue;
            }
            $id = $oldLayer['id'] ?? null;
            if (! is_string($id) || isset($editedIds[$id])) {
                continue;
            }
            $perFactor = is_array($oldLayer['perFactor'] ?? null) ? $oldLayer['perFactor'] : [];
            // Only propagate the delete when the layer was actually on
            // the source factor — layers absent from source can't be
            // "deleted" by an edit on that factor.
            if (isset($perFactor[$sourceFactor])) {
                $deletedIds[$id] = true;
            }
        }

        if ($deletedIds === []) {
            return $newEnvelope;
        }

        $layers = is_array($newEnvelope['layers'] ?? null) ? $newEnvelope['layers'] : [];
        $newEnvelope['layers'] = array_values(array_filter(
            $layers,
            function ($layer) use ($deletedIds): bool {
                if (! is_array($layer)) {
                    return true;
                }
                $id = $layer['id'] ?? null;
                return ! is_string($id) || ! isset($deletedIds[$id]);
            },
        ));

        return $newEnvelope;
    }

    /** Index of layer ids in an envelope, keyed for O(1) lookup. */
    private static function collectLayerIds(array $envelope): array
    {
        $ids = [];
        $layers = is_array($envelope['layers'] ?? null) ? $envelope['layers'] : [];
        foreach ($layers as $layer) {
            if (is_array($layer) && is_string($layer['id'] ?? null)) {
                $ids[$layer['id']] = true;
            }
        }
        return $ids;
    }

    /**
     * Multiply px-typed anchor values by the per-axis ratio; percent + auto
     * anchors pass through. Mirror of `LayoutResolver::scaleAnchors` — kept
     * here so the frame-stamp logic doesn't need to expose that helper.
     */
    private static function scaleAnchors(array $anchors, float $rw, float $rh): array
    {
        foreach (['top', 'bottom', 'height'] as $k) {
            if (isset($anchors[$k]['type']) && $anchors[$k]['type'] === 'px'
                && is_numeric($anchors[$k]['value'] ?? null)
            ) {
                $anchors[$k]['value'] = (float) $anchors[$k]['value'] * $rh;
            }
        }
        foreach (['left', 'right', 'width'] as $k) {
            if (isset($anchors[$k]['type']) && $anchors[$k]['type'] === 'px'
                && is_numeric($anchors[$k]['value'] ?? null)
            ) {
                $anchors[$k]['value'] = (float) $anchors[$k]['value'] * $rw;
            }
        }
        return $anchors;
    }

    /**
     * Propagate frame device picks from `$sourceFactor` to every other
     * factor present in the envelope. Triggered by a user edit on the
     * source factor — when they pick iPhone 17 Pro Max on iPhone, the
     * iPad / Android Phone / Android Tablet factors auto-swap to the
     * pro_max family equivalent without the user having to switch
     * factors and re-pick.
     *
     * Differs from {@see resolveFramesForFactor} in that:
     *   - The source factor's `devicePresetId` is the family anchor
     *     (the user's "last touch"), not the target factor's existing pick
     *   - When the source has no family_id, NO propagation happens — we
     *     don't override the user's per-factor pick with a default
     *   - When a target factor has no equivalent, it's left alone (same
     *     reason — preserve the user's existing pick rather than blow
     *     it away with a default)
     *
     * Frames with `linkDeviceFamily === false` are skipped entirely so
     * the user can author divergent per-factor device picks intentionally.
     *
     * Idempotent.
     */
    public static function propagateFramesFromFactor(array $envelope, string $sourceFactor): array
    {
        $version = $envelope['version'] ?? null;
        if ($version !== LayoutMigrator::LATEST_V4_VERSION) {
            return $envelope;
        }
        $canvasMap = is_array($envelope['canvas'] ?? null) ? $envelope['canvas'] : [];
        $layers = is_array($envelope['layers'] ?? null) ? $envelope['layers'] : [];

        $targetFactors = array_values(array_filter(
            array_keys($canvasMap),
            fn (string $f) => $f !== $sourceFactor,
        ));
        if ($targetFactors === []) {
            return $envelope; // Single-factor envelope — nothing to propagate.
        }

        foreach ($layers as &$layer) {
            if (! is_array($layer) || ($layer['type'] ?? null) !== 'frame') {
                continue;
            }
            if (array_key_exists('linkDeviceFamily', $layer)
                && $layer['linkDeviceFamily'] === false
            ) {
                continue;
            }
            $perFactor = is_array($layer['perFactor'] ?? null) ? $layer['perFactor'] : [];
            $sourceEntry = $perFactor[$sourceFactor] ?? null;
            if (! is_array($sourceEntry)) {
                continue;
            }
            $sourceDeviceId = $sourceEntry['devicePresetId'] ?? null;
            if (! is_int($sourceDeviceId) && ! is_numeric($sourceDeviceId)) {
                continue;
            }
            // Pull the source's family. If family-less, do NOT propagate —
            // the user picked a divergent device intentionally and we
            // shouldn't blow other factors' picks away.
            $sourceFamily = DevicePreset::query()
                ->whereKey((int) $sourceDeviceId)
                ->value('family_id');
            if (! is_string($sourceFamily) || $sourceFamily === '') {
                continue;
            }

            foreach ($targetFactors as $targetFactor) {
                if (! isset($perFactor[$targetFactor]) || ! is_array($perFactor[$targetFactor])) {
                    continue; // Layer absent on target factor — nothing to propagate to.
                }
                $equivalent = DevicePreset::query()
                    ->where('form_factor', $targetFactor)
                    ->where('family_id', $sourceFamily)
                    ->where('is_active', true)
                    ->orderBy('id')
                    ->value('id');
                if ($equivalent === null) {
                    continue; // No family equivalent on target — leave the existing pick alone.
                }
                $layer['perFactor'][$targetFactor]['devicePresetId'] = (int) $equivalent;
            }
        }
        unset($layer);

        $envelope['layers'] = $layers;
        return $envelope;
    }

    /**
     * Walk every frame layer in a v4 envelope and rewrite
     * `perFactor[$targetFactor].devicePresetId` to the family equivalent of
     * its current value. Frames with `linkDeviceFamily === false` are left
     * untouched (caller opted out of cross-factor propagation). Missing
     * equivalents fall back to the target factor's default preset.
     *
     * Idempotent — running twice produces the same result. Safe to call
     * after every stamp without double-applying.
     */
    public static function resolveFramesForFactor(array $envelope, string $targetFactor): array
    {
        $version = $envelope['version'] ?? null;
        if ($version !== LayoutMigrator::LATEST_V4_VERSION) {
            return $envelope;
        }
        $layers = is_array($envelope['layers'] ?? null) ? $envelope['layers'] : [];

        foreach ($layers as &$layer) {
            if (! is_array($layer) || ($layer['type'] ?? null) !== 'frame') {
                continue;
            }
            // Default is `true` when the field is absent — opt-out, not
            // opt-in. Matches the migrator's split behaviour.
            if (array_key_exists('linkDeviceFamily', $layer)
                && $layer['linkDeviceFamily'] === false
            ) {
                continue;
            }
            $perFactor = is_array($layer['perFactor'] ?? null) ? $layer['perFactor'] : [];
            $entry = $perFactor[$targetFactor] ?? null;
            if (! is_array($entry)) {
                continue;
            }
            $currentDeviceId = $entry['devicePresetId'] ?? null;
            if (! is_int($currentDeviceId) && ! is_numeric($currentDeviceId)) {
                continue;
            }
            $equivalent = self::equivalentFor((int) $currentDeviceId, $targetFactor);
            if ($equivalent === null) {
                // No family match — fall back to the target factor's
                // default preset so an iPhone preset id doesn't end up
                // on an iPad layer. If even the default is missing,
                // leave the value as-is and let the renderer surface
                // the gap.
                $equivalent = Templates::defaultPresetIdFor($targetFactor);
                if ($equivalent === null) {
                    continue;
                }
            }
            $layer['perFactor'][$targetFactor]['devicePresetId'] = $equivalent;

            // Aspect-lock the frame's bbox to the resolved device's
            // frame-PNG aspect. Percent anchors don't preserve aspect
            // across factors (a 78%×95% bbox on iPhone canvas has a
            // different pixel aspect on iPad canvas because the canvases
            // have different aspect ratios). Without this fix the device
            // frame renders stretched horizontally or vertically depending
            // on which factor it lands on. Marketing factor skipped —
            // frames don't render there per `stampEnvelopeToFactor`.
            $targetCanvas = $envelope['canvas'][$targetFactor] ?? null;
            if (is_array($targetCanvas) && ! LayoutResolver::isMarketingFactor($targetFactor)) {
                self::lockFrameAspectToDevice(
                    $layer['perFactor'][$targetFactor],
                    $equivalent,
                    (int) ($targetCanvas['width'] ?? 0),
                    (int) ($targetCanvas['height'] ?? 0),
                    $targetFactor,
                );
            }
        }
        unset($layer);

        $envelope['layers'] = $layers;
        return $envelope;
    }

    /**
     * Lock a frame's per-factor anchor bbox to the resolved device's
     * natural frame-PNG aspect ratio. Reads `real_frame_screen_rect.{orient}
     * .frame_w / frame_h` off the device preset row (set up by the photoreal
     * frame manifest sync); falls back to `export_w / export_h` (the screen
     * dims) when the photoreal manifest hasn't seeded frame_w/h.
     *
     * Strategy:
     *   - Compute device aspect = frame_w / frame_h.
     *   - For portrait factors: preserve height %, derive width % so the
     *     pixel-aspect of the bbox matches the device. Recenter horizontally.
     *   - For landscape factors: preserve width %, derive height %. Recenter
     *     vertically.
     *   - Normalize bottom/right anchors away so the bbox is unambiguous
     *     after the swap.
     *
     * Px anchors (rare; usually percent in kits) are left untouched — they
     * already got axis-scaled inside `LayoutResolver::stampEnvelopeToFactor`.
     */
    private static function lockFrameAspectToDevice(
        array &$perFactorEntry,
        int $devicePresetId,
        int $canvasW,
        int $canvasH,
        string $targetFactor,
    ): void {
        if ($canvasW <= 0 || $canvasH <= 0) {
            return;
        }
        $row = \App\Models\DevicePreset::query()->find($devicePresetId);
        if (! $row) {
            return;
        }

        $isLandscape = str_contains($targetFactor, '_landscape');
        $orient = $isLandscape ? 'landscape' : 'portrait';
        $rect = is_array($row->real_frame_screen_rect)
            ? $row->real_frame_screen_rect
            : (json_decode((string) $row->real_frame_screen_rect, true) ?: []);
        $frameW = (int) ($rect[$orient]['frame_w'] ?? 0);
        $frameH = (int) ($rect[$orient]['frame_h'] ?? 0);
        if ($frameW <= 0 || $frameH <= 0) {
            $frameW = (int) $row->export_w;
            $frameH = (int) $row->export_h;
        }
        if ($frameW <= 0 || $frameH <= 0) {
            return;
        }
        $deviceAspect = $frameW / $frameH;
        $canvasAspect = $canvasW / $canvasH;

        $anchors = $perFactorEntry['anchors'] ?? null;
        if (! is_array($anchors)) {
            return;
        }
        $widthPercent = self::percentValue($anchors['width'] ?? null);
        $heightPercent = self::percentValue($anchors['height'] ?? null);
        if ($widthPercent === null || $heightPercent === null) {
            return;
        }

        // Pixel aspect after the stamp's anchor copy is:
        //   (canvasW * w% / 100) / (canvasH * h% / 100) = (w/h) * canvasAspect
        // To make it equal deviceAspect, the percent ratio must be:
        //   w / h = deviceAspect / canvasAspect
        $ratio = $deviceAspect / $canvasAspect;

        // Auto-center top/left ONLY when the anchor is completely absent.
        // Kit authors set explicit values (with or without `align: center`)
        // to position the frame intentionally — overwriting their value
        // breaks layouts. Previous heuristic checked `align === 'center'`
        // and clobbered explicit author values like
        // `{value: 75.772, align: center}` (a landscape kit example).
        $leftAnchor = $anchors['left'] ?? null;
        $topAnchor = $anchors['top'] ?? null;

        if ($isLandscape) {
            $newHeightPercent = $widthPercent / $ratio;
            if ($newHeightPercent > 100) {
                $newHeightPercent = 100;
                $widthPercent = $newHeightPercent * $ratio;
            }
            $perFactorEntry['anchors']['width'] = ['type' => 'percent', 'value' => round($widthPercent, 3)];
            $perFactorEntry['anchors']['height'] = ['type' => 'percent', 'value' => round($newHeightPercent, 3)];
            if ($topAnchor === null) {
                $perFactorEntry['anchors']['top'] = ['type' => 'percent', 'value' => round((100 - $newHeightPercent) / 2, 3)];
                unset($perFactorEntry['anchors']['bottom']);
            }
        } else {
            $newWidthPercent = $heightPercent * $ratio;
            if ($newWidthPercent > 100) {
                $newWidthPercent = 100;
                $heightPercent = $newWidthPercent / $ratio;
            }
            $perFactorEntry['anchors']['width'] = ['type' => 'percent', 'value' => round($newWidthPercent, 3)];
            $perFactorEntry['anchors']['height'] = ['type' => 'percent', 'value' => round($heightPercent, 3)];
            if ($leftAnchor === null) {
                $perFactorEntry['anchors']['left'] = ['type' => 'percent', 'value' => round((100 - $newWidthPercent) / 2, 3)];
                unset($perFactorEntry['anchors']['right']);
            }
        }
    }

    private static function percentValue(mixed $anchor): ?float
    {
        if (! is_array($anchor)) {
            return null;
        }
        if (($anchor['type'] ?? null) !== 'percent') {
            return null;
        }
        $value = $anchor['value'] ?? null;
        return is_numeric($value) ? (float) $value : null;
    }
}
