<?php

namespace App\Services\Configurator;

use App\Facades\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Mtc\ContentManager\Models\MediaFolder;
use Mtc\MercuryDataModels\Tenant;
use RuntimeException;
use Throwable;

/**
 * Copies car configurator models (with the full configuration workflow and
 * imagery) from one tenant to another.
 *
 * The work is done in two context switches per run: everything is read and
 * buffered while the source tenant is initialised, then written while the
 * destination tenant is initialised. Rows are re-inserted with fresh ids and
 * every foreign key / polymorphic reference is remapped through a
 * {@see ConfiguratorIdMap}. Vehicle taxonomy ids (make/model/fuel type/...) are
 * central-synced and therefore copied verbatim, guarded by a pre-flight check
 * that they exist in the destination.
 *
 * Mirrors the tenancy-switch + raw-insert approach of
 * {@see \App\Console\Commands\DownloadSiteCommand} but selectively and with id
 * remapping rather than a whole-table truncate-and-preserve.
 */
class ConfiguratorCopyService
{
    private const MODELS_TABLE = 'car_configurator_models';

    /** Owned child tables (FK model_id), in FK-safe copy order. */
    private const OWNED_CHILDREN = [
        ConfiguratorIdMap::BUCKET_SECTIONS => 'car_configurator_sections',
        ConfiguratorIdMap::BUCKET_CUSTOM => 'car_configurator_custom_sections',
        ConfiguratorIdMap::BUCKET_EDITIONS => 'car_configurator_editions',
        ConfiguratorIdMap::BUCKET_TRIMS => 'car_configurator_trims',
        ConfiguratorIdMap::BUCKET_ENGINES => 'car_configurator_engines',
        ConfiguratorIdMap::BUCKET_COLOURS => 'car_configurator_colours',
        ConfiguratorIdMap::BUCKET_WHEELS => 'car_configurator_wheels',
        ConfiguratorIdMap::BUCKET_INTERIORS => 'car_configurator_interiors',
    ];

    private const RESTRICTIONS_TABLE = 'car_configurator_restrictions';

    /** Destination folder ids keyed by model name, reset per copy() run. */
    private array $folderCache = [];

    /** Shared catalogue tables + their pivots. */
    private const CATALOGUE = [
        ConfiguratorIdMap::BUCKET_PACKAGES => [
            'table' => 'car_configurator_packages',
            'pivot' => 'car_configurator_model_packages',
            'pivot_key' => 'package_id',
        ],
        ConfiguratorIdMap::BUCKET_EXTRAS => [
            'table' => 'car_configurator_extras',
            'pivot' => 'car_configurator_model_extras',
            'pivot_key' => 'extra_id',
        ],
    ];

    /**
     * Distinct makes that have at least one configurator model in the source.
     *
     * @return Collection<int, object{make_id: int, name: string}>
     */
    public function availableMakes(Tenant $source): Collection
    {
        return $this->onTenant($source, fn () => DB::table(self::MODELS_TABLE)
            ->join('vehicle_makes', 'vehicle_makes.id', '=', self::MODELS_TABLE . '.make_id')
            ->whereNull(self::MODELS_TABLE . '.deleted_at')
            ->select('vehicle_makes.id as make_id', 'vehicle_makes.name')
            ->distinct()
            ->orderBy('vehicle_makes.name')
            ->get());
    }

    /**
     * Configurator models for a make in the source, with their vehicle model name.
     *
     * @return Collection<int, object{id: int, name: string, slug: ?string, model_name: ?string}>
     */
    public function modelsForMake(Tenant $source, int $makeId): Collection
    {
        return $this->onTenant($source, fn () => DB::table(self::MODELS_TABLE)
            ->leftJoin('vehicle_models', 'vehicle_models.id', '=', self::MODELS_TABLE . '.model_id')
            ->where(self::MODELS_TABLE . '.make_id', $makeId)
            ->whereNull(self::MODELS_TABLE . '.deleted_at')
            ->select(
                self::MODELS_TABLE . '.id',
                self::MODELS_TABLE . '.name',
                self::MODELS_TABLE . '.slug',
                'vehicle_models.name as model_name',
            )
            ->orderBy(self::MODELS_TABLE . '.name')
            ->get());
    }

    /**
     * Copy the given source configurator model ids into the destination tenant.
     */
    public function copy(
        Tenant $source,
        Tenant $destination,
        array $modelIds,
        bool $overwrite = false,
        bool $dryRun = false,
    ): ConfiguratorCopyReport {
        $report = new ConfiguratorCopyReport();
        $this->folderCache = [];

        // Read + buffer the source rows (and the source/destination paths of any
        // media originals) before switching context, since we can only be
        // initialised on one tenant at a time. File bytes are never buffered -
        // they are copied disk-to-disk at flush time (see readMedia/flushFiles).
        $bundles = $this->onTenant($source, function () use ($modelIds, $destination, $report) {
            $this->warnIfCustomMediaBucket('source', $report);
            $bundles = [];
            foreach ($modelIds as $modelId) {
                $bundle = $this->readBundle((int) $modelId, (string) $destination->id, $report);
                if ($bundle !== null) {
                    $bundles[] = $bundle;
                }
            }
            return $bundles;
        });

        // Write each buffered model into the destination.
        $this->onTenant($destination, function () use ($bundles, $overwrite, $dryRun, $report) {
            $this->warnIfCustomMediaBucket('destination', $report);
            foreach ($bundles as $bundle) {
                $this->writeBundle($bundle, $overwrite, $dryRun, $report);
            }
        });

        return $report;
    }

    /**
     * Read a configurator model and its whole subtree (children, catalogue
     * pivots, restrictions and media) into plain arrays, plus the source/dest
     * paths of any local media files. Returns null when the model no longer
     * exists.
     */
    private function readBundle(int $modelId, string $destId, ConfiguratorCopyReport $report): ?array
    {
        $model = DB::table(self::MODELS_TABLE)->whereNull('deleted_at')->find($modelId);
        if ($model === null) {
            $report->addWarning("Source configurator model #{$modelId} not found - skipped.");
            return null;
        }
        $model = (array) $model;

        $children = [];
        $sourceIds = [ConfiguratorIdMap::BUCKET_MODEL => [$modelId]];
        foreach (self::OWNED_CHILDREN as $bucket => $table) {
            $rows = $this->rows($table, fn ($q) => $q->where('model_id', $modelId));
            $children[$bucket] = $rows;
            $sourceIds[$bucket] = array_column($rows, 'id');
        }
        $this->backfillCustomSectionParents($children, $sourceIds);

        $catalogue = [];
        foreach (self::CATALOGUE as $bucket => $meta) {
            $pivots = $this->rows($meta['pivot'], fn ($q) => $q->where('model_id', $modelId));
            $catalogueIds = array_values(array_unique(array_column($pivots, $meta['pivot_key'])));
            $rows = $catalogueIds === []
                ? []
                : $this->rows($meta['table'], fn ($q) => $q->whereIn('id', $catalogueIds));
            $catalogue[$bucket] = ['rows' => $rows, 'pivots' => $pivots];
            $sourceIds[$bucket] = array_column($rows, 'id');
        }

        $restrictions = $this->rows(self::RESTRICTIONS_TABLE, fn ($q) => $q->where('model_id', $modelId));

        [$mediaUses, $media] = $this->readMedia($sourceIds, $destId, $report);

        return [
            'display_name' => $model['name'] ?? ('#' . $modelId),
            'model' => $model,
            'children' => $children,
            'catalogue' => $catalogue,
            'restrictions' => $restrictions,
            'media_uses' => $mediaUses,
            'media' => $media,
        ];
    }

    /**
     * Custom sections FK to a parent section. If a referenced parent is
     * soft-deleted (excluded from the live read) it must still be copied so the
     * custom section keeps its FK; pull those parents in regardless of state.
     *
     * @param array<string, array<int, array>> $children  (by reference)
     * @param array<string, int[]> $sourceIds             (by reference)
     */
    private function backfillCustomSectionParents(array &$children, array &$sourceIds): void
    {
        $referenced = array_values(array_unique(array_filter(
            array_column($children[ConfiguratorIdMap::BUCKET_CUSTOM], 'section_id'),
        )));
        $missing = array_diff($referenced, $sourceIds[ConfiguratorIdMap::BUCKET_SECTIONS]);
        if ($missing === []) {
            return;
        }

        $extra = $this->rows(
            self::OWNED_CHILDREN[ConfiguratorIdMap::BUCKET_SECTIONS],
            fn ($q) => $q->whereIn('id', $missing),
            softDeletes: false,
        );
        $children[ConfiguratorIdMap::BUCKET_SECTIONS] = array_merge(
            $children[ConfiguratorIdMap::BUCKET_SECTIONS],
            $extra,
        );
        $sourceIds[ConfiguratorIdMap::BUCKET_SECTIONS] = array_merge(
            $sourceIds[ConfiguratorIdMap::BUCKET_SECTIONS],
            array_column($extra, 'id'),
        );
    }

    /**
     * Read the media_uses attached to the copied owners, the media rows they
     * reference, and record the source/destination paths of any local originals.
     *
     * The media disk is a single shared disk keyed by a per-tenant path prefix
     * (it does not change with tenancy - see {@see self::mediaDisk()}), so file
     * bytes are never held in memory: we buffer only the source and destination
     * paths and copy the file disk-to-disk at flush time (a server-side copy on
     * S3). Buffering the bytes here is what previously exhausted memory when
     * copying many models' imagery in one run.
     *
     * @param array<string, int[]> $sourceIds bucket => source owner ids
     * @return array{0: array<int, array>, 1: array<int, array>}
     */
    private function readMedia(array $sourceIds, string $destId, ConfiguratorCopyReport $report): array
    {
        $idMap = new ConfiguratorIdMap();
        $uses = [];
        foreach ($idMap->mediaOwnerBuckets() as $bucket) {
            $ownerIds = $sourceIds[$bucket] ?? [];
            if ($ownerIds === []) {
                continue;
            }
            // owner_type may be a morph alias or the FQCN - match either.
            $uses = array_merge($uses, $this->rows(
                'media_uses',
                fn ($q) => $q->whereIn('owner_type', $idMap->ownerTypes($bucket))->whereIn('owner_id', $ownerIds),
                softDeletes: false,
            ));
        }

        $mediaIds = array_values(array_unique(array_column($uses, 'media_id')));
        if ($mediaIds === []) {
            return [[], []];
        }

        $disk = Storage::disk($this->mediaDisk());
        $media = [];
        foreach ($this->rows('media', fn ($q) => $q->whereIn('id', $mediaIds), softDeletes: false) as $row) {
            $isLocal = empty($row['external']) && !empty($row['path']) && !empty($row['src']);
            // Rehome the file under the destination tenant's own media prefix.
            $destPath = $isLocal ? $this->rehomePath((string) $row['path'], $destId) : ($row['path'] ?? '');
            $files = [];

            if ($isLocal) {
                foreach ($this->originalFileNames((string) $row['src']) as $filename) {
                    $sourceFile = $row['path'] . '/' . $filename;
                    if (!$disk->exists($sourceFile)) {
                        continue;
                    }
                    $files[] = ['store_at' => $destPath . '/' . $filename, 'copy_from' => $sourceFile];
                }
                if ($files === []) {
                    $report->addWarning("Original file missing for media #{$row['id']} ({$row['path']}/{$row['src']}).");
                }
            }

            $media[$row['id']] = ['row' => $row, 'dest_path' => $destPath, 'files' => $files];
        }

        return [$uses, $media];
    }

    /**
     * Write one buffered model into the destination inside a transaction, then
     * flush its media files. Records the outcome on the report.
     */
    private function writeBundle(array $bundle, bool $overwrite, bool $dryRun, ConfiguratorCopyReport $report): void
    {
        $name = $bundle['display_name'];

        try {
            $existing = $this->matchingModelIds($bundle['model']);

            if ($existing !== [] && !$overwrite) {
                $report->addModel($name, ConfiguratorCopyReport::STATUS_SKIPPED, 'already exists (use --overwrite)');
                return;
            }

            $this->assertTaxonomyExists($bundle);

            if ($dryRun) {
                $status = $existing !== [] ? ConfiguratorCopyReport::STATUS_REPLACED : ConfiguratorCopyReport::STATUS_COPIED;
                $report->addModel($name, $status, 'dry run - not written', $this->plannedCounts($bundle));
                return;
            }

            $files = [];
            $counts = [];
            DB::transaction(function () use ($bundle, $existing, $report, &$files, &$counts) {
                foreach ($existing as $existingId) {
                    $this->deleteExisting($existingId);
                }
                [$files, $counts] = $this->insertBundle($bundle, $report);
            });

            $this->flushFiles($files, $report);

            $status = $existing !== [] ? ConfiguratorCopyReport::STATUS_REPLACED : ConfiguratorCopyReport::STATUS_COPIED;
            $report->addModel($name, $status, null, $counts);
        } catch (Throwable $e) {
            $report->addModel($name, ConfiguratorCopyReport::STATUS_FAILED, $e->getMessage());
        }
    }

    /**
     * Insert the whole subtree with remapped ids. Returns the media files to
     * flush after commit and a count summary.
     *
     * @return array{0: array<int, array>, 1: array<string, int>}
     */
    private function insertBundle(array $bundle, ConfiguratorCopyReport $report): array
    {
        $idMap = new ConfiguratorIdMap();
        $counts = ['children' => 0, 'restrictions' => 0, 'media' => 0, 'media_uses' => 0, 'files' => 0];

        // 1. The model itself.
        $newModelId = DB::table(self::MODELS_TABLE)->insertGetId($this->prepareRow($bundle['model']));
        $idMap->set(ConfiguratorIdMap::BUCKET_MODEL, (int) $bundle['model']['id'], $newModelId);

        // 2. Owned children (custom sections resolve section_id from the map).
        foreach (self::OWNED_CHILDREN as $bucket => $table) {
            foreach ($bundle['children'][$bucket] as $row) {
                $overrides = ['model_id' => $newModelId];
                if ($bucket === ConfiguratorIdMap::BUCKET_CUSTOM && !empty($row['section_id'])) {
                    $sectionId = $idMap->get(ConfiguratorIdMap::BUCKET_SECTIONS, (int) $row['section_id']);
                    if ($sectionId === null) {
                        $report->addWarning(
                            "Custom section '{$row['name']}' skipped: parent section #{$row['section_id']} was not copied.",
                        );
                        continue;
                    }
                    $overrides['section_id'] = $sectionId;
                }
                $newId = DB::table($table)->insertGetId($this->prepareRow($row, $overrides));
                $idMap->set($bucket, (int) $row['id'], $newId);
                $counts['children']++;
            }
        }

        // 3. Shared catalogue: resolve-or-create by name, then rebuild pivots.
        foreach (self::CATALOGUE as $bucket => $meta) {
            foreach ($bundle['catalogue'][$bucket]['rows'] as $row) {
                $idMap->set($bucket, (int) $row['id'], $this->resolveCatalogueRow($meta['table'], $row));
            }
            foreach ($bundle['catalogue'][$bucket]['pivots'] as $pivot) {
                $catalogueId = $idMap->get($bucket, (int) $pivot[$meta['pivot_key']]);
                if ($catalogueId === null) {
                    continue;
                }
                DB::table($meta['pivot'])->insert($this->prepareRow($pivot, [
                    'model_id' => $newModelId,
                    $meta['pivot_key'] => $catalogueId,
                ]));
            }
        }

        // 4. Restrictions last - both polymorphic sides are now mapped.
        $counts['restrictions'] = $this->insertRestrictions($bundle['restrictions'], $newModelId, $idMap);

        // 5. Media (rows + uses); files are returned for a post-commit flush.
        [$files, $counts['media'], $counts['media_uses']] = $this->insertMedia($bundle, $idMap);
        $counts['files'] = array_sum(array_map(fn ($f) => count($f['files']), $files));

        return [$files, $counts];
    }

    /**
     * Rebuild restriction rows, remapping model_id and both morph id sides while
     * keeping the type strings verbatim. Rows whose referenced id was not copied
     * are dropped.
     */
    private function insertRestrictions(array $restrictions, int $newModelId, ConfiguratorIdMap $idMap): int
    {
        $inserted = 0;
        foreach ($restrictions as $row) {
            $functionalityId = $this->remapMorphId($idMap, $row['functionality_type'] ?? null, $row['functionality_id'] ?? null);
            $restrictionId = $this->remapMorphId($idMap, $row['restriction_type'] ?? null, $row['restriction_id'] ?? null);

            if ($functionalityId === null || $restrictionId === null) {
                continue;
            }

            DB::table(self::RESTRICTIONS_TABLE)->insert($this->prepareRow($row, [
                'model_id' => $newModelId,
                'functionality_id' => $functionalityId,
                'restriction_id' => $restrictionId,
            ]));
            $inserted++;
        }

        return $inserted;
    }

    /**
     * Insert media rows (resolve-or-create) and rebuild media_uses with remapped
     * media_id + owner_id, preserving owner_type.
     *
     * @return array{0: array<int, array>, 1: int, 2: int}
     */
    private function insertMedia(array $bundle, ConfiguratorIdMap $idMap): array
    {
        $mediaMap = [];
        $files = [];
        $mediaInserted = 0;

        foreach ($bundle['media'] as $oldId => $entry) {
            [$newMediaId, $created] = $this->resolveMediaRow($entry, $bundle['display_name']);
            $mediaMap[$oldId] = $newMediaId;
            if ($created) {
                $mediaInserted++;
            }
            if ($entry['files'] !== []) {
                $files[$newMediaId] = ['files' => $entry['files']];
            }
        }

        $usesInserted = 0;
        foreach ($bundle['media_uses'] as $use) {
            $newMediaId = $mediaMap[$use['media_id']] ?? null;
            $bucket = $idMap->resolveType((string) $use['owner_type']);
            $newOwnerId = $idMap->get($bucket, (int) $use['owner_id']);
            if ($newMediaId === null || $newOwnerId === null) {
                continue;
            }
            $usesInserted += DB::table('media_uses')->insertOrIgnore($this->prepareRow($use, [
                'media_id' => $newMediaId,
                'owner_id' => $newOwnerId,
            ]));
        }

        return [$files, $mediaInserted, $usesInserted];
    }

    /**
     * Find the destination media row for a source row, creating it if absent.
     * External rows dedupe on provider + source filename; local rows on the
     * (prefix-swapped) path + src, so a re-copy reuses the same row.
     *
     * @return array{0: int, 1: bool} [id, wasCreated]
     */
    private function resolveMediaRow(array $entry, string $modelName): array
    {
        $row = $entry['row'];
        $isExternal = !empty($row['external']);

        $match = $isExternal && !empty($row['image_provider']) && !empty($row['source_filename'])
            ? ['image_provider' => $row['image_provider'], 'source_filename' => $row['source_filename']]
            : ['path' => $entry['dest_path'], 'src' => $row['src']];

        $existing = DB::table('media')->where($match)->value('id');
        if ($existing !== null) {
            return [(int) $existing, false];
        }

        $overrides = ['path' => $entry['dest_path'], 'folder_id' => $this->destinationFolderId($modelName)];
        return [DB::table('media')->insertGetId($this->prepareRow($row, $overrides)), true];
    }

    /**
     * Resolve a shared catalogue row (package/extra) by name in the destination,
     * creating it when missing. Returns the destination id.
     */
    private function resolveCatalogueRow(string $table, array $row): int
    {
        $existing = DB::table($table)->whereNull('deleted_at')->where('name', $row['name'])->value('id');
        if ($existing !== null) {
            return (int) $existing;
        }

        return DB::table($table)->insertGetId($this->prepareRow($row));
    }

    /**
     * The destination "Car Configurator" -> "{model name}" folder id, created on
     * demand (nested-set safe via the model). Cached per run.
     */
    private function destinationFolderId(string $modelName): int
    {
        if (isset($this->folderCache[$modelName])) {
            return $this->folderCache[$modelName];
        }

        $parent = MediaFolder::query()->firstOrCreate(['name' => 'Car Configurator', 'parent_id' => null]);
        $folder = MediaFolder::query()
            ->where('name', $modelName)
            ->where('parent_id', $parent->id)
            ->first()
            ?? $parent->children()->create(['name' => $modelName]);

        return $this->folderCache[$modelName] = $folder->id;
    }

    /**
     * Hard-delete a destination configurator subtree (model + owned children +
     * restrictions + pivots + the model's/children's media_uses) ahead of an
     * overwrite. Shared catalogue rows and media originals are left in place.
     */
    private function deleteExisting(int $modelId): void
    {
        $idMap = new ConfiguratorIdMap();

        // media_uses for the model and its owned children (owner_type may be a
        // morph alias or the FQCN - match either).
        DB::table('media_uses')
            ->whereIn('owner_type', $idMap->ownerTypes(ConfiguratorIdMap::BUCKET_MODEL))
            ->where('owner_id', $modelId)
            ->delete();
        foreach (self::OWNED_CHILDREN as $bucket => $table) {
            $childIds = DB::table($table)->where('model_id', $modelId)->pluck('id')->all();
            if ($childIds !== []) {
                DB::table('media_uses')
                    ->whereIn('owner_type', $idMap->ownerTypes($bucket))
                    ->whereIn('owner_id', $childIds)
                    ->delete();
            }
            DB::table($table)->where('model_id', $modelId)->delete();
        }

        DB::table(self::RESTRICTIONS_TABLE)->where('model_id', $modelId)->delete();
        foreach (self::CATALOGUE as $meta) {
            DB::table($meta['pivot'])->where('model_id', $modelId)->delete();
        }
        DB::table(self::MODELS_TABLE)->where('id', $modelId)->delete();
    }

    /**
     * Guard that every vehicle-taxonomy id referenced by the bundle exists in
     * the destination (taxonomy is central-synced, so ids are copied verbatim).
     */
    private function assertTaxonomyExists(array $bundle): void
    {
        $needed = [
            'vehicle_makes' => [$bundle['model']['make_id'] ?? null],
            'vehicle_models' => [$bundle['model']['model_id'] ?? null],
            'fuel_types' => array_merge(
                [$bundle['model']['fuel_type_id'] ?? null],
                array_column($bundle['children'][ConfiguratorIdMap::BUCKET_TRIMS], 'fuel_type_id'),
                array_column($bundle['children'][ConfiguratorIdMap::BUCKET_ENGINES], 'fuel_type_id'),
            ),
            'drivetrains' => array_column($bundle['children'][ConfiguratorIdMap::BUCKET_ENGINES], 'drivetrain_id'),
            'transmissions' => array_column($bundle['children'][ConfiguratorIdMap::BUCKET_ENGINES], 'transmission_id'),
        ];

        foreach ($needed as $table => $ids) {
            $ids = array_values(array_unique(array_filter($ids, fn ($id) => $id !== null)));
            if ($ids === []) {
                continue;
            }
            $present = DB::table($table)->whereIn('id', $ids)->pluck('id')->all();
            $missing = array_diff($ids, $present);
            if ($missing !== []) {
                throw new RuntimeException(
                    "Destination is missing {$table} id(s) " . implode(', ', $missing)
                    . ' - run `php artisan tenants:sync-taxonomies` against the destination first.',
                );
            }
        }
    }

    /**
     * Copy each media original from its source path to its destination path on
     * the shared media disk. Uses a disk-to-disk copy (server-side on S3) so no
     * file bytes pass through PHP memory. Not transactional: a failure warns but
     * does not roll back the DB (the row still points at the correct path and a
     * media resync can backfill).
     */
    private function flushFiles(array $files, ConfiguratorCopyReport $report): void
    {
        $disk = Storage::disk($this->mediaDisk());
        foreach ($files as $entry) {
            foreach ($entry['files'] as $file) {
                try {
                    $disk->copy($file['copy_from'], $file['store_at']);
                } catch (Throwable $e) {
                    $report->addWarning("Failed to write media file {$file['store_at']}: {$e->getMessage()}");
                }
            }
        }
    }

    /**
     * Destination model ids matching this source model by make + model + slug.
     *
     * @return int[]
     */
    private function matchingModelIds(array $model): array
    {
        $query = DB::table(self::MODELS_TABLE)
            ->whereNull('deleted_at')
            ->where('make_id', $model['make_id'])
            ->where('model_id', $model['model_id']);

        $model['slug'] === null
            ? $query->whereNull('slug')
            : $query->where('slug', $model['slug']);

        return $query->pluck('id')->map(fn ($id) => (int) $id)->all();
    }

    /**
     * @return array<string, int>
     */
    private function plannedCounts(array $bundle): array
    {
        $children = array_sum(array_map('count', $bundle['children']));

        return [
            'children' => $children,
            'restrictions' => count($bundle['restrictions']),
            'media' => count($bundle['media']),
            'media_uses' => count($bundle['media_uses']),
            'files' => array_sum(array_map(fn ($m) => count($m['files']), $bundle['media'])),
        ];
    }

    private function remapMorphId(ConfiguratorIdMap $idMap, ?string $type, mixed $id): ?int
    {
        if ($type === null || $id === null) {
            return null;
        }

        return $idMap->get($idMap->resolveType($type), (int) $id);
    }

    /**
     * Read whole rows as plain arrays. Soft-deleted rows are excluded unless the
     * table has no deleted_at column (media / media_uses).
     *
     * @return array<int, array<string, mixed>>
     */
    private function rows(string $table, callable $constrain, bool $softDeletes = true): array
    {
        $query = DB::table($table);
        if ($softDeletes) {
            $query->whereNull('deleted_at');
        }
        $constrain($query);

        return $query->get()->map(fn ($row) => (array) $row)->all();
    }

    /**
     * Strip the source id and stamp fresh timestamps, then apply overrides.
     *
     * @return array<string, mixed>
     */
    private function prepareRow(array $row, array $overrides = []): array
    {
        unset($row['id']);
        $now = now()->toDateTimeString();
        $row['created_at'] = $now;
        $row['updated_at'] = $now;
        if (array_key_exists('deleted_at', $row)) {
            $row['deleted_at'] = null;
        }

        return array_merge($row, $overrides);
    }

    /**
     * The stored original plus its jpg sibling (the webp originals are written
     * alongside a jpg copy on upload).
     *
     * @return string[]
     */
    private function originalFileNames(string $src): array
    {
        $names = [$src];
        if (str_ends_with($src, '.webp')) {
            $names[] = substr($src, 0, -5) . '.jpg';
        }

        return $names;
    }

    /**
     * Rehome a media path under the destination tenant's own prefix. Tenant media
     * paths are `{tenantPrefix}/...` (see MediaRepository::storagePrefix), but the
     * leading prefix is not necessarily the current tenant id - imported/cloned
     * tenants keep their origin prefix. So replace the FIRST path segment
     * (whatever it is) with the destination tenant id rather than assuming it
     * equals the source id.
     */
    private function rehomePath(string $path, string $destId): string
    {
        $slash = strpos($path, '/');
        $rest = $slash === false ? $path : substr($path, $slash + 1);

        return $destId . '/' . $rest;
    }

    private function mediaDisk(): string
    {
        return config('filesystems.default_media');
    }

    /**
     * Tenants on a per-tenant custom media bucket resolve their media disk only
     * inside media operations, not on plain tenancy init, so this service reads
     * and writes the shared default disk. Warn (rather than silently copy to the
     * wrong bucket) so the operator can run a media resync for such a site.
     */
    private function warnIfCustomMediaBucket(string $role, ConfiguratorCopyReport $report): void
    {
        try {
            if (Settings::get('filesystem-custom-s3-bucket-enabled')) {
                $report->addWarning(
                    "The {$role} site uses a custom media bucket - image files may need a manual "
                    . 'media resync after this copy.',
                );
            }
        } catch (Throwable) {
            // Settings unavailable/undecryptable in this context - not fatal to the copy.
        }
    }

    /**
     * Run a callback with the given tenant initialised, always restoring the
     * previous (central) context afterwards.
     */
    private function onTenant(Tenant $tenant, callable $callback): mixed
    {
        tenancy()->initialize($tenant);
        try {
            return $callback();
        } finally {
            tenancy()->end();
        }
    }
}
