<?php

namespace App\Jobs;

use App\Mail\OrphanedTaxonomyRemovedMail;
use App\Services\MeilisearchService;
use App\TaxonomyMap;
use App\TaxonomyMapable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Broadcasting\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Mail;
use Mtc\MercuryDataModels\NewCar;
use Mtc\MercuryDataModels\Tenant;
use Mtc\MercuryDataModels\Vehicle;
use Mtc\MercuryDataModels\VehicleOffer;

class RemoveOrphanedTaxonomiesFromModelsJob implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    /**
     * Number of vehicles indexed into Meilisearch per request when refreshing
     * the search index for affected records.
     */
    private const REINDEX_BATCH_SIZE = 200;

    private Collection $orphaned_taxonomy_makes;
    private Collection $orphaned_taxonomy_models;
    private Collection $affected_models;

    /**
     * Create a new job instance.
     */
    public function __construct(private Collection $orphaned_taxonomy_maps)
    {
        $this->orphaned_taxonomy_makes = $this->orphaned_taxonomy_maps
            ->filter(fn ($map) => in_array($map->taxonomy_type, ['make', 'master-make']));

        $this->orphaned_taxonomy_models = $this->orphaned_taxonomy_maps
            ->filter(fn ($map) => in_array($map->taxonomy_type, ['model', 'master-model']));
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        if (
            $this->orphaned_taxonomy_makes->isNotEmpty()
            || $this->orphaned_taxonomy_models->isNotEmpty()
        ) {
            $affected_tenants = Tenant::query()->get()->mapWithKeys(fn ($tenant) => [
                $tenant->name => $this->processOrphanedMapsForTenant($tenant),
            ])->filter(fn ($records) => $records['vehicles']->isNotEmpty()
                || $records['vehicle_offers']->isNotEmpty()
                || $records['new_cars']->isNotEmpty());

            $this->sendNotification($affected_tenants);
        }

        $this->deleteOrphanedMaps();
    }

    private function processOrphanedMapsForTenant(Tenant $tenant): Collection
    {
        tenancy()->initialize($tenant);

        $affected_vehicles = $this->handleforModel(Vehicle::class);
        $affected_vehicle_offers = $this->handleforModel(VehicleOffer::class);
        $affected_new_cars = $this->handleforModel(NewCar::class);

        $this->reindexAffectedVehicles($affected_vehicles);

        return $this->mapAffectedRecordData($affected_vehicles, $affected_vehicle_offers, $affected_new_cars);
    }

    private function mapAffectedRecordData(
        Collection $affected_vehicles,
        Collection $affected_vehicle_offers,
        Collection $affected_new_cars,
    ): Collection {
        return collect([
            'vehicles' => $affected_vehicles->map(fn ($vehicle) => [
                'id' => $vehicle->id,
                'name' => $vehicle->title,
                'slug' => $vehicle->slug,
                'vrm' => $vehicle->vrm_condensed,
            ]),
            'vehicle_offers' => $affected_vehicle_offers->map(fn ($offer) => [
                'id' => $offer->id,
                'name' => $offer->name,
                'slug' => $offer->slug,
            ]),
            'new_cars' => $affected_new_cars->map(fn ($new_car) => [
                'id' => $new_car->id,
                'name' => $new_car->name,
                'slug' => $new_car->slug,
            ]),
        ]);
    }

    /**
     * return the list of models affected by missing make and/or model taxonomy
     *
     * @param string $model_name
     * @return Collection
     */
    private function handleforModel(string $model_name): Collection
    {
        $affected_by_make = $this->setAffectedModelColumnToNull(
            $model_name,
            'make_id',
            $this->orphaned_taxonomy_makes
        );

        $affected_by_model = $this->setAffectedModelColumnToNull(
            $model_name,
            'model_id',
            $this->orphaned_taxonomy_models
        );

        return $affected_by_make->merge($affected_by_model);
    }

    private function setAffectedModelColumnToNull(
        string $model_name,
        string $column,
        Collection $orphaned_taxonomy_items
    ): Collection {
        $model = App::make($model_name);
        $taxonomy_ids = $orphaned_taxonomy_items->pluck('taxonomy_id');

        if ($taxonomy_ids->isEmpty()) {
            return $model->newCollection();
        }

        $affected = $model->newQuery()
            ->whereIn($column, $taxonomy_ids)
            ->get();

        if ($affected->isEmpty()) {
            return $affected;
        }

        // Clear the orphaned foreign key in a single statement rather than saving
        // each model individually. The per-model save fired the search-index
        // observer once per record (a synchronous Meilisearch request for every
        // vehicle), which is what caused this job to time out. The search index is
        // refreshed in batches afterwards via reindexAffectedVehicles().
        $model->newQuery()
            ->whereIn($column, $taxonomy_ids)
            ->update([$column => null]);

        // Reflect the cleared value on the in-memory models so the batched reindex
        // serialises the nulled taxonomy rather than the stale one.
        $affected->each(fn ($affected_model) => $affected_model->setAttribute($column, null));

        return $affected;
    }

    /**
     * Refresh the Meilisearch index for affected vehicles in batches.
     *
     * Mirrors the VehicleObserver guards that the per-row save previously applied:
     * provider stock is skipped, and unpublished or trashed vehicles are removed
     * from the index rather than (re)indexed.
     */
    private function reindexAffectedVehicles(Collection $vehicles): void
    {
        $meilisearch = App::make(MeilisearchService::class);

        if (!$meilisearch->isEnabled()) {
            return;
        }

        $indexable = $vehicles->filter(fn (Vehicle $vehicle) => empty($vehicle->stock_provider));

        if ($indexable->isEmpty()) {
            return;
        }

        [$to_index, $to_remove] = $indexable->partition(
            fn (Vehicle $vehicle) => $vehicle->is_published && !$vehicle->trashed()
        );

        $to_index
            ->load(MeilisearchService::VEHICLE_RELATIONS)
            ->chunk(self::REINDEX_BATCH_SIZE)
            ->each(fn (Collection $chunk) => $meilisearch->indexVehicles($chunk));

        $to_remove->each(fn (Vehicle $vehicle) => $meilisearch->deleteVehicle($vehicle->id));
    }

    /**
     * Delete orphaned maps, but never one that is still referenced by a mapable
     * pointing at a record that still exists on a tenant. Dead mapables (whose
     * target no longer exists) are pruned regardless of whether their map survives.
     */
    private function deleteOrphanedMaps(): void
    {
        $orphaned_map_ids = $this->orphaned_taxonomy_maps->pluck('id');

        $mapables = TaxonomyMapable::query()
            ->whereIn('taxonomy_map_id', $orphaned_map_ids)
            ->get();

        $live_map_ids = collect();
        $dead_mapable_ids = collect();

        $mapables->groupBy('tenant')->each(function (Collection $tenant_mapables, $tenant_id) use (
            &$live_map_ids,
            &$dead_mapable_ids
        ) {
            $tenant = Tenant::find($tenant_id);

            if ($tenant === null) {
                $dead_mapable_ids = $dead_mapable_ids->merge($tenant_mapables->pluck('id'));

                return;
            }

            tenancy()->initialize($tenant);

            $tenant_mapables->groupBy('mappable_type')->each(function (
                Collection $typed_mapables,
                $mappable_type
            ) use (&$live_map_ids, &$dead_mapable_ids) {
                $existing_ids = $this->existingTargetIds($mappable_type, $typed_mapables->pluck('mappable_id'));

                foreach ($typed_mapables as $mapable) {
                    if ($existing_ids->has((string) $mapable->mappable_id)) {
                        $live_map_ids->push($mapable->taxonomy_map_id);
                    } else {
                        $dead_mapable_ids->push($mapable->id);
                    }
                }
            });
        });

        if ($dead_mapable_ids->isNotEmpty()) {
            TaxonomyMapable::query()->whereIn('id', $dead_mapable_ids->unique())->delete();
        }

        $deletable_map_ids = $orphaned_map_ids->diff($live_map_ids->unique());

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

    /**
     * Resolve, in a single query, which of the given target ids still exist for a
     * morph type. Returns a set keyed by id (as string) for O(1) lookups, replacing
     * what was previously one existence query per mapable.
     */
    private function existingTargetIds(string $mappable_type, Collection $mappable_ids): Collection
    {
        $model_class = Relation::getMorphedModel($mappable_type) ?? $mappable_type;

        if (!is_string($model_class) || !class_exists($model_class)) {
            return collect();
        }

        $key_name = (new $model_class())->getKeyName();

        return $model_class::query()
            ->whereIn($key_name, $mappable_ids->unique()->all())
            ->pluck($key_name)
            ->mapWithKeys(fn ($id) => [(string) $id => true]);
    }

    private function sendNotification(Collection $affected_tenants): void
    {
        Mail::to(config('mail.developer_alerts'))->send(new OrphanedTaxonomyRemovedMail(
            $this->orphaned_taxonomy_makes,
            $this->orphaned_taxonomy_models,
            $affected_tenants,
        ));
    }
}
