<?php

namespace App\NotificationMaps;

use App\Models\NotificationMap;
use App\TaxonomyMap;
use App\Traits\EnsuresVehicleAttribute;
use App\Traits\MapsTaxonomies;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Mtc\ContentManager\Facades\Media as MediaFacade;
use Mtc\MercuryDataModels\Dealership;
use Mtc\MercuryDataModels\Media;
use Mtc\MercuryDataModels\MediaUse;
use Mtc\MercuryDataModels\Vehicle;
use Mtc\MercuryDataModels\VehicleAttribute;

/**
 * Applies a mapped notification payload to a vehicle: dedupes on the map's provider
 * key + external id (falling back to registration/vin), fills the mapped columns,
 * syncs the image set, and persists any custom attributes. This is the configurable
 * counterpart to the provider-specific *ToVehicleImport classes.
 */
class NotificationMapVehicleUpsert
{
    use EnsuresVehicleAttribute;
    use MapsTaxonomies;

    /** @var array<string, array<string, mixed>> attribute definitions keyed by slug */
    private array $vehicleAttributes = [];

    /** Taxonomy mapping provider; tenant-inclusive because taxonomy_map is central. */
    private string $taxonomyProvider = '';

    protected function getProviderName(): string
    {
        return $this->taxonomyProvider;
    }

    public function upsert(NotificationMap $map, MappedPayload $mapped): Vehicle
    {
        $provider = $map->providerKey();
        $attributes = $mapped->attributes;

        $vehicle = $this->findExisting($provider, $attributes);
        $isNew = $vehicle === null;
        $vehicle ??= new Vehicle();

        if ($vehicle->trashed()) {
            $vehicle->restore();
        }

        // Never blank out existing data with nulls from a partial payload.
        $vehicle->fill(array_filter($attributes, fn ($value) => $value !== null));
        $vehicle->stock_provider = $provider;

        $this->resolveTaxonomies($map, $mapped, $vehicle);
        $this->resolveDealership($mapped, $vehicle);

        // A newly created vehicle is published unless the payload says otherwise.
        if ($isNew && !array_key_exists('is_published', $attributes)) {
            $vehicle->is_published = true;
        }

        $vehicle->save();

        // Link any unmapped taxonomy terms to the saved vehicle for later admin resolution.
        if (!empty($mapped->taxonomies)) {
            $this->storeUnmappedTaxonomy($vehicle);
        }

        $this->syncImages($vehicle, $provider, $mapped->images);
        $this->syncCustomAttributes($vehicle, $mapped->customAttributes);
        $this->syncSpecLists($vehicle, $mapped);

        return $vehicle;
    }

    /**
     * Resolve taxonomy name strings (make, model, fuel type, ...) to record ids via the
     * shared taxonomy mapping system and set them on the vehicle. Make resolves first so
     * model lookup is scoped to it. An unresolved name leaves the existing id untouched
     * and is recorded (storeUnmappedTaxonomy) for later admin resolution.
     */
    private function resolveTaxonomies(NotificationMap $map, MappedPayload $mapped, Vehicle $vehicle): void
    {
        if (empty($mapped->taxonomies)) {
            return;
        }

        $this->taxonomyProvider = tenant('id') . '-notification-map-' . $map->id;
        $taxonomies = $mapped->taxonomies;

        $makeId = $vehicle->make_id;
        if (isset($taxonomies['make'])) {
            $resolved = $this->getMappedTaxonomy(TaxonomyMap::MAKE, $taxonomies['make']['value']);
            if ($resolved !== null) {
                $vehicle->make_id = $resolved;
                $makeId = $resolved;
            }
            unset($taxonomies['make']);
        }

        foreach ($taxonomies as $taxonomy => $entry) {
            $resolved = $taxonomy === TaxonomyMap::MODEL
                ? $this->getMappedTaxonomy(TaxonomyMap::MODEL, $entry['value'], null, $makeId)
                : $this->getMappedTaxonomy($taxonomy, $entry['value']);

            if ($resolved !== null) {
                $vehicle->{$entry['destination']} = $resolved;
            }
        }
    }

    /**
     * Resolve a dealership by its stock location or name and set dealership_id. An
     * unmatched value leaves the existing dealership untouched.
     */
    private function resolveDealership(MappedPayload $mapped, Vehicle $vehicle): void
    {
        if ($mapped->dealership === null) {
            return;
        }

        $dealership = Dealership::query()
            ->where('location_stock', $mapped->dealership)
            ->orWhere('name', $mapped->dealership)
            ->first();

        if ($dealership !== null) {
            $vehicle->dealership_id = $dealership->id;
        }
    }

    /**
     * Replace the vehicle's equipment / technical-data relations from the payload. Only
     * runs for a list that was present in the payload (null = key omitted), so a partial
     * payload never clears these; an explicit list (even empty) replaces wholesale.
     */
    private function syncSpecLists(Vehicle $vehicle, MappedPayload $mapped): void
    {
        if ($mapped->equipment !== null) {
            $vehicle->equipment()->delete();
            foreach ($mapped->equipment as $entry) {
                $vehicle->equipment()->create($entry);
            }
        }

        if ($mapped->technicalData !== null) {
            $vehicle->specs()->delete();
            foreach ($mapped->technicalData as $entry) {
                $vehicle->specs()->create($entry);
            }
        }
    }

    /**
     * @param array<string, mixed> $attributes
     */
    private function findExisting(string $provider, array $attributes): ?Vehicle
    {
        $candidates = [
            ['stock_provider' => $provider, 'uuid' => $attributes['uuid'] ?? null],
            ['registration_number' => $attributes['registration_number'] ?? null],
            ['vin' => $attributes['vin'] ?? null],
        ];

        foreach ($candidates as $criteria) {
            $value = end($criteria);
            if (empty($value)) {
                continue;
            }

            $vehicle = Vehicle::query()->withTrashed()->where($criteria)->first();
            if ($vehicle) {
                return $vehicle;
            }
        }

        return null;
    }

    /**
     * Diff the desired image set against what is already linked, mirroring the
     * provider imports: add new srcs, drop ones no longer present, prune orphans.
     *
     * @param array<int, string> $imageUrls
     */
    private function syncImages(Vehicle $vehicle, string $provider, array $imageUrls): void
    {
        if (empty($imageUrls)) {
            return;
        }

        $desiredSrcs = collect($imageUrls)->unique()->values();

        $currentUses = $vehicle->mediaUses()
            ->with('media:id,src')
            ->get()
            ->filter(fn (MediaUse $use) => $use->media !== null);

        $currentSrcs = $currentUses->map(fn (MediaUse $use) => $use->media->src);

        $newSrcs = $desiredSrcs->reject(fn ($src) => $currentSrcs->contains($src))->values();
        $removedUses = $currentUses->reject(fn (MediaUse $use) => $desiredSrcs->contains($use->media->src));

        if ($newSrcs->isEmpty() && $removedUses->isEmpty()) {
            return;
        }

        if ($newSrcs->isNotEmpty()) {
            $this->addImages($vehicle, $provider, $newSrcs);
        }

        if ($removedUses->isNotEmpty()) {
            $this->removeImageUses($removedUses);
        }
    }

    /**
     * @param Collection<int, string> $newSrcs
     */
    private function addImages(Vehicle $vehicle, string $provider, Collection $newSrcs): void
    {
        $records = $newSrcs->map(fn ($src) => [
            'src' => $src,
            'image_provider' => $provider,
            'type' => 'image',
            'external' => true,
            'folder_id' => null,
            'path' => '',
            'upload_date' => Carbon::now()->format('Y-m'),
        ]);

        Media::query()->upsert($records->toArray(), ['src', 'image_provider']);

        $mediaIds = Media::query()
            ->whereIn('src', $newSrcs)
            ->where('image_provider', $provider)
            ->pluck('id')
            ->toArray();

        MediaFacade::setUsesForModel($mediaIds, $vehicle, ['primary' => true], false);
    }

    /**
     * @param Collection<int, MediaUse> $removedUses
     */
    private function removeImageUses(Collection $removedUses): void
    {
        $removedMediaIds = $removedUses->pluck('media_id')->unique()->values();

        MediaUse::query()->whereIn('id', $removedUses->pluck('id'))->delete();

        $stillReferenced = MediaUse::query()
            ->whereIn('media_id', $removedMediaIds)
            ->pluck('media_id')
            ->unique();

        $orphanIds = $removedMediaIds->reject(fn ($id) => $stillReferenced->contains($id))->values();

        if ($orphanIds->isNotEmpty()) {
            Media::query()->whereIn('id', $orphanIds)->delete();
        }
    }

    /**
     * Persist custom attributes (attribute.<slug> destinations) as text vehicle
     * attributes, creating the attribute definition on first use.
     *
     * @param array<string, mixed> $customAttributes
     */
    private function syncCustomAttributes(Vehicle $vehicle, array $customAttributes): void
    {
        $values = array_filter($customAttributes, fn ($value) => $value !== null);

        if (empty($values)) {
            return;
        }

        $this->vehicleAttributes = [];
        foreach (array_keys($values) as $slug) {
            /** @var VehicleAttribute $definition */
            $definition = $this->getVehicleAttribute(Str::headline($slug), 'text', $slug);
            $this->vehicleAttributes[$slug] = $definition->toArray();
        }

        $this->setVehicleAttributes($values, $vehicle);
    }
}
