<?php

namespace App\NotificationMaps;

use App\Models\NotificationMap;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;

/**
 * Maps an inbound notification payload onto a normalised vehicle shape, driven by
 * a NotificationMap's field_map / validation / config.
 *
 * This is the inbound counterpart to ExportMapRepository: where the export side
 * reads a vehicle and writes an external row, this reads an external payload and
 * produces a column => value bag (plus images and custom attributes) for the
 * vehicle upsert pipeline. Field resolution honours dot-notation source paths.
 */
class NotificationPayloadMapper
{
    public const TYPE_STRING = 'string';
    public const TYPE_INTEGER = 'integer';
    public const TYPE_FLOAT = 'float';
    public const TYPE_BOOLEAN = 'boolean';
    public const TYPE_DATE = 'date';
    public const TYPE_IMAGE_LIST = 'image_list';
    public const TYPE_TAXONOMY = 'taxonomy';
    public const TYPE_DEALERSHIP = 'dealership';
    public const TYPE_EQUIPMENT = 'equipment';
    public const TYPE_TECHNICAL_DATA = 'technical_data';

    /** Supported field_map value types. */
    public const TYPES = [
        self::TYPE_STRING,
        self::TYPE_INTEGER,
        self::TYPE_FLOAT,
        self::TYPE_BOOLEAN,
        self::TYPE_DATE,
        self::TYPE_IMAGE_LIST,
        self::TYPE_TAXONOMY,
        self::TYPE_DEALERSHIP,
        self::TYPE_EQUIPMENT,
        self::TYPE_TECHNICAL_DATA,
    ];

    /** Whitelisted keys for equipment entries, mapped onto VehicleStandardEquipment. */
    private const EQUIPMENT_KEYS = ['type', 'description', 'category', 'code', 'price', 'vat_amount'];

    /** Whitelisted keys for technical-data entries, mapped onto VehicleTechnicalData. */
    private const TECHNICAL_DATA_KEYS = ['code', 'description', 'value', 'category'];

    private const ATTRIBUTE_PREFIX = 'attribute.';

    /**
     * Map a payload through the given notification map. Validation rules are applied
     * to the mapped output; a failure throws Illuminate\Validation\ValidationException.
     *
     * @param array<string, mixed> $payload
     */
    public function map(NotificationMap $map, array $payload): MappedPayload
    {
        $fieldMap = $map->field_map ?? [];
        $config = $map->config ?? [];

        $result = new MappedPayload();

        foreach ($fieldMap as $field) {
            $source = $field['source'] ?? null;
            $destination = $field['destination'] ?? null;

            if (empty($source) || empty($destination)) {
                continue;
            }

            $type = $field['type'] ?? self::TYPE_STRING;

            if ($type === self::TYPE_IMAGE_LIST) {
                $result->images = $this->normaliseImages($payload, $source, $destination, $config);
                continue;
            }

            // A source key absent from the payload leaves the destination untouched,
            // so partial updates never blank out existing vehicle data.
            if (!Arr::has($payload, $source)) {
                continue;
            }

            // Taxonomy / dealership carry a name string resolved to an id later, against
            // the taxonomy mapping system and the dealerships table respectively.
            if ($type === self::TYPE_TAXONOMY) {
                $name = $this->coerce(Arr::get($payload, $source), self::TYPE_STRING);
                $taxonomy = $field['taxonomy'] ?? null;
                if (!empty($name) && !empty($taxonomy)) {
                    $result->taxonomies[$taxonomy] = ['destination' => $destination, 'value' => $name];
                }
                continue;
            }

            if ($type === self::TYPE_DEALERSHIP) {
                $name = $this->coerce(Arr::get($payload, $source), self::TYPE_STRING);
                if (!empty($name)) {
                    $result->dealership = $name;
                }
                continue;
            }

            // Equipment / technical data replace the relation wholesale, but only when the
            // key is present: an omitted key leaves existing rows intact.
            if ($type === self::TYPE_EQUIPMENT) {
                $defaults = ['type' => 'Standard'];
                $result->equipment = $this->normaliseEntries(Arr::get($payload, $source), self::EQUIPMENT_KEYS, $defaults);
                continue;
            }

            if ($type === self::TYPE_TECHNICAL_DATA) {
                $result->technicalData = $this->normaliseEntries(Arr::get($payload, $source), self::TECHNICAL_DATA_KEYS);
                continue;
            }

            $value = $this->coerce(Arr::get($payload, $source), $type);

            if (str_starts_with($destination, self::ATTRIBUTE_PREFIX)) {
                $slug = substr($destination, strlen(self::ATTRIBUTE_PREFIX));
                $result->customAttributes[$slug] = $value;
                continue;
            }

            $result->attributes[$destination] = $value;
        }

        $this->validate($map, $result);

        return $result;
    }

    /**
     * Validate the mapped attributes against the map's rules (keyed by destination).
     * Rules referencing custom attributes are checked against the attribute bag.
     */
    private function validate(NotificationMap $map, MappedPayload $result): void
    {
        $rules = $map->validation ?? [];

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

        $columnRules = [];
        $attributeRules = [];

        foreach ($rules as $field => $rule) {
            if (str_starts_with($field, self::ATTRIBUTE_PREFIX)) {
                $attributeRules[substr($field, strlen(self::ATTRIBUTE_PREFIX))] = $rule;
                continue;
            }

            $columnRules[$field] = $rule;
        }

        if (!empty($columnRules)) {
            Validator::make($result->attributes, $columnRules)->validate();
        }

        if (!empty($attributeRules)) {
            Validator::make($result->customAttributes, $attributeRules)->validate();
        }
    }

    /**
     * Normalise a nested image list. Items may be plain URL strings or objects that
     * expose the URL under config[destination][url_key] (default "url").
     *
     * @param array<string, mixed> $payload
     * @param array<string, mixed> $config
     * @return array<int, string>
     */
    private function normaliseImages(array $payload, string $source, string $destination, array $config): array
    {
        $items = Arr::get($payload, $source);

        if (!is_array($items)) {
            return [];
        }

        $urlKey = $config[$destination]['url_key'] ?? 'url';

        $urls = [];
        foreach ($items as $item) {
            $url = match (true) {
                is_string($item) => $item,
                is_array($item) => $item[$urlKey] ?? null,
                default => null,
            };

            $url = is_string($url) ? trim($url) : null;
            if (!empty($url)) {
                $urls[] = $url;
            }
        }

        return array_values(array_unique($urls));
    }

    /**
     * Normalise a nested list of spec-style entries (equipment / technical data) to a
     * list of whitelisted-key arrays. Non-array items and entries without a description
     * are dropped. Always returns an array (empty when the payload value is not a list),
     * so a present-but-empty key still replaces the relation wholesale.
     *
     * @param array<string, mixed> $defaults Applied when the entry omits the key.
     * @param array<int, string> $allowedKeys
     * @return array<int, array<string, mixed>>
     */
    private function normaliseEntries(mixed $items, array $allowedKeys, array $defaults = []): array
    {
        if (!is_array($items)) {
            return [];
        }

        $entries = [];
        foreach ($items as $item) {
            if (!is_array($item)) {
                continue;
            }

            $entry = array_merge($defaults, array_intersect_key($item, array_flip($allowedKeys)));

            if (empty($entry['description'])) {
                continue;
            }

            $entries[] = $entry;
        }

        return $entries;
    }

    /**
     * Coerce a raw payload value into the configured type. Unrecognised types and
     * uncoercible values fall through unchanged / to null where appropriate.
     */
    private function coerce(mixed $value, string $type): mixed
    {
        if ($value === null) {
            return null;
        }

        return match ($type) {
            self::TYPE_STRING => is_scalar($value) ? trim((string) $value) : null,
            self::TYPE_INTEGER => is_numeric($value) ? (int) $value : null,
            self::TYPE_FLOAT => is_numeric($value) ? (float) $value : null,
            self::TYPE_BOOLEAN => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE),
            self::TYPE_DATE => $this->coerceDate($value),
            default => $value,
        };
    }

    private function coerceDate(mixed $value): ?string
    {
        if (!is_string($value) && !is_numeric($value)) {
            return null;
        }

        try {
            return Carbon::parse($value)->format('Y-m-d H:i:s');
        } catch (\Throwable) {
            return null;
        }
    }
}
