<?php

/**
 * Copyright 2024-2025 Simply.IN Sp. z o.o.
 *
 * NOTICE OF LICENSE
 *
 * Licensed under the EUPL-1.2 or later.
 * You may not use this work except in compliance with the Licence.
 *
 * Copy of the Licence is available at:
 * https://joinup.ec.europa.eu/software/page/eupl
 * It is bundled with this package in the file LICENSE.txt
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the Licence is distributed on an as is basis,
 * without warranties or conditions of any kind, either express or implied.
 * Check the Licence for the specific language governing permissions
 * and limitations under the License.
 *
 * @author   Simply.IN Sp. z o.o.
 * @copyright 2024-2025 Simply.IN Sp. z o.o.
 * @license   https://joinup.ec.europa.eu/software/page/eupl
 */

declare(strict_types=1);

namespace SimplyIn\Checkout;

use Address;
use Configuration;
use Context;
use Country;
use Currency;
use Customer;
use DateTime;
use DateTimeZone;
use Image;
use ImageType;
use Language;
use Manufacturer;
use Order;
use Product;
use SimplyIn\Session\PrestashopSessionAdapter;
use SimplyInPoint;
use State;
use Throwable;
use Tools;
use Validate;

if (!defined('_PS_VERSION_')) {
    exit;
}

final class OrderPayloadBuilder
{
    /** Upper bound on open per-item attributes. */
    private const MAX_ATTRIBUTES = 30;

    /** Default truncation length for enrichment string values. */
    private const VALUE_MAX_LENGTH = 255;

    /** Brand label used as the payment method for the SimplyIN gateway. */
    private const SIMPLYIN_BRAND = 'Simply.IN';

    /** Separator between the brand and the provider: space + U+00B7 (middle dot) + space. */
    private const PROVIDER_SEPARATOR = " \u{00B7} ";

    /** @var array<string, Product|null> per-build cache so each product is loaded at most once */
    private array $productCache = [];

    public function __construct(
        private readonly OrderAddressMapper $addressMapper,
        private readonly OrderIpResolver $ipResolver,
    ) {
    }

    private function resolveThumbnailUrl(Order $order, int $productId, int $idProductAttribute, string $productName): string
    {
        $context = Context::getContext();

        $best = Image::getBestImageAttribute((int) $context->shop->id, (int) $order->id_lang, $productId, $idProductAttribute);
        $idImage = isset($best['id_image']) ? (int) $best['id_image'] : 0;
        if (!$idImage) {
            $cover = Product::getCover($productId);
            $idImage = isset($cover['id_image']) ? (int) $cover['id_image'] : 0;
        }
        if (!$idImage) {
            return '';
        }

        $url = (string) $context->link->getImageLink(Tools::str2url($productName), (string) $idImage, ImageType::getFormattedName('small'));

        return $this->forceHttps($url);
    }

    private function forceHttps(string $url): string
    {
        if (strpos($url, 'http://') === 0 && Configuration::get('PS_SSL_ENABLED')) {
            return 'https://' . substr($url, 7);
        }

        return $url;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function orderItemsData(Order $order): array
    {
        $items_data = [];
        $products = $order->getProducts();
        $currency = new Currency($order->id_currency);
        $context = Context::getContext();
        foreach ($products as $product) {
            $total_tax = 0;
            $tax_rate = 0;
            if ($product['unit_price_tax_excl'] > 0) {
                $tax_rate = Tools::ps_round($product['unit_price_tax_incl'] / $product['unit_price_tax_excl'], 2);
                $tax_rate = (int) ($tax_rate * 100 - 100);

                $total_tax = $product['unit_price_tax_incl'] - $product['unit_price_tax_excl'];
            } else {
                $total_tax = 0;
                $tax_rate = 0;
            }
            $url = $this->forceHttps((string) $context->link->getProductLink($product['id_product']));
            $id_product_attribute = isset($product['product_attribute_id']) ? (int) $product['product_attribute_id'] : 0;
            $image = $this->resolveThumbnailUrl($order, (int) $product['id_product'], $id_product_attribute, (string) $product['product_name']);
            $item = [
                'name' => $product['product_name'],
                'url' => $url,
                'price_net' => Tools::ps_round($product['unit_price_tax_excl'], 2),
                'price' => Tools::ps_round($product['unit_price_tax_incl'], 2),
                'quantity' => (int) $product['product_quantity'],
                'tax_amount' => Tools::ps_round($total_tax, 2),
                'tax_rate' => $tax_rate,
                'thumbnailUrl' => $image,
                'currency' => $currency->iso_code,
                // Line total incl. tax (B2C gross).
                'total_amount' => Tools::ps_round($product['total_price_tax_incl'], 2),
            ];
            $items_data[] = array_merge($item, $this->itemEnrichment($product, $order));
        }

        return $items_data;
    }

    /**
     * @param array<int, mixed> $items_data
     * @param array{payment?: string, module?: string} $payment_method native shop payment values ($order->payment / $order->module); the sync path never has a SimplyIN session, so this always resolves to Case B
     *
     * @return array<string, mixed>
     */
    public function existingAccountOrderData(
        Order $order,
        array $items_data,
        array $payment_method,
        string $plugin_version,
        string $shop_version,
        string $user_email,
        string $parcel_machine_id,
        string $parcel_provider_name = 'inpost',
    ): array {
        $billingData = $this->addressMapper->billingData($order);
        $hostAddres = gethostname();
        $hostIp = gethostbyname($hostAddres);
        $order_ip_address = $this->ipResolver->resolve((int) $order->id);
        $currency = new Currency($order->id_currency);
        $payment = $this->composePaymentDisplay(
            '',
            '',
            '',
            (string) ($payment_method['payment'] ?? ''),
            (string) ($payment_method['module'] ?? ''),
        );
        // orderDate is optional on the wire — omit the key entirely when date_add is
        // missing/unparsable rather than sending a garbage placeholder date.
        $orderDate = $this->orderDate($order);
        $body_data = [
            'newOrderData' => [
                'price' => (float) $order->total_paid_tax_incl,
                'shippingPrice' => $order->total_shipping_tax_incl,
                'currency' => $currency->iso_code,
                // Order-level money breakdown (B2C gross, PrestaShop Order API).
                ...$this->orderMoneyBreakdown($order),
                'items' => $items_data,
                'statusInfo' => $this->orderStatusInfo($order),
                // Original order placement date (date_add), not the status date above.
                ...($orderDate !== null ? ['orderDate' => $orderDate] : []),
                'trackingNumbers' => $this->trackingNumbers($order),
                'shopOrderNumber' => $order->reference,
                'placedDuringAccountCreation' => false,
                'pluginVersion' => $plugin_version,
                'shopVersion' => $shop_version,
                'shopName' => Configuration::get('PS_SHOP_NAME'),
                'shopUserEmail' => $user_email,
                'userIp' => $order_ip_address,
                'shopIp' => $hostIp,
                'billingData' => $billingData,
                'payment_method' => $payment['method'],
                'payment_method_title' => $payment['title'],
            ],
            'pluginVersion' => $plugin_version,
            'shopVersion' => $shop_version,
            'shopName' => Configuration::get('PS_SHOP_NAME'),
            'shopUserEmail' => $user_email,
            'userIp' => $order_ip_address,
            'shopIp' => $hostIp,
        ];

        if (!empty($parcel_machine_id)) {
            $body_data['newOrderData']['parcelLockerMinimalInfo'] = [
                'lockerId' => $parcel_machine_id,
                'providerName' => $parcel_provider_name,
            ];
        } else {
            $shippingData = $this->addressMapper->shippingData($order);
            $body_data['newOrderData']['shippingData'] = $shippingData;
        }
        // The merchant API key is authenticated via request headers (SimplyInApiClient::request);
        // the backend never reads it from the JSON body, so it is not added here.

        return $body_data;
    }

    public function parcelMachineId(int $id_order): string
    {
        $order = new Order($id_order);
        if (!$order->id_cart) {
            return '';
        }
        $point = SimplyInPoint::getByIdCart($order->id_cart);
        if ($point->id && !empty($point->lockerId)) {
            return $point->lockerId;
        }

        return '';
    }

    public function parcelProvider(int $id_order): string
    {
        $order = new Order($id_order);
        if (!$order->id_cart) {
            return 'inpost';
        }
        $point = SimplyInPoint::getByIdCart($order->id_cart);
        if ($point->id && !empty($point->label)) {
            return $point->label;
        }

        return 'inpost';
    }

    /**
     * @return array<string, mixed>
     */
    public function orderData(int $id_order): array
    {
        $context = Context::getContext();
        $simplyin_session = new PrestashopSessionAdapter($context->cookie, $context->cart);
        $order = new Order($id_order);
        $customer = new Customer($order->id_customer);
        $currency = new Currency($order->id_currency);
        $products = [];
        $orderProducts = $order->getProducts();
        $currencyISO = $currency->iso_code;
        foreach ($orderProducts as $product) {
            $productId = $product['product_id'];

            $productObj = $this->loadProduct((int) $productId, (int) $order->id_lang);
            $productDescription = $productObj !== null ? strip_tags((string) $productObj->description) : '';
            $productLink = $this->forceHttps((string) Context::getContext()->link->getProductLink($product));
            $id_product_attribute = isset($product['product_attribute_id']) ? (int) $product['product_attribute_id'] : 0;
            $thumbnailUrl = $this->resolveThumbnailUrl($order, (int) $productId, $id_product_attribute, (string) $product['product_name']);
            $productRow = [
                'name' => $product['product_name'],
                'quantity' => (int) $product['product_quantity'],
                'price_net' => (float) $product['total_price_tax_excl'],
                'price' => (float) $product['total_price_tax_incl'],
                'productDescription' => $productDescription,
                'url' => $productLink,
                'thumbnailUrl' => $thumbnailUrl,
                'currency' => $currencyISO,
                // Line total incl. tax (B2C gross). Rounded to currency precision to
                // match orderItemsData() so both build paths emit the same value for an order.
                'total_amount' => Tools::ps_round($product['total_price_tax_incl'], 2),
            ];
            $products[] = array_merge($productRow, $this->itemEnrichment($product, $order));
        }
        $language = new Language($order->id_lang);

        $billing = new Address($order->id_address_invoice);
        $delivery = new Address($order->id_address_delivery);

        $billingCountry = new Country($billing->id_country);
        $deliveryCountry = new Country($delivery->id_country);
        $billingState = new State($billing->id_state);
        $deliveryState = new State($delivery->id_state);
        $simplyin_billing = $simplyin_session->get('simplyin_billing', '');
        $simplyin_shipping = $simplyin_session->get('simplyin_shipping', '');
        $icon_billing = trim($billing->company) != '' ? '🏢' : '🏡';
        $icon_shipping = trim($delivery->company) != '' ? '🏢' : '🏡';

        $exploded = explode(' ', $billing->address1);
        if (!isset($exploded[1])) {
            $exploded[1] = '';
        }
        $billingData = [
            '_id' => $simplyin_billing,
            'icon' => $icon_billing,
            'addressName' => '',
            'street' => trim($exploded[0]),
            'appartmentNumber' => trim($exploded[1] != '' ? $exploded[1] : ''),
            'city' => trim($billing->city),
            'postalCode' => trim($billing->postcode),
            'country' => trim($billingCountry->iso_code),
            'companyName' => trim($billing->company),
            'name' => trim($billing->firstname),
            'surname' => trim($billing->lastname),
            'taxId' => trim($billing->vat_number),
            'state' => trim($billingState->iso_code ?? ''),
        ];

        $exploded = explode(' ', $delivery->address1);
        if (!isset($exploded[1])) {
            $exploded[1] = '';
        }

        $shippingData = [
            '_id' => $simplyin_shipping,
            'icon' => $icon_shipping,
            'addressName' => '',
            'street' => trim($exploded[0]),
            'appartmentNumber' => trim($exploded[1] != '' ? $exploded[1] : ''),
            'city' => trim($delivery->city),
            'postalCode' => trim($delivery->postcode),
            'country' => trim($deliveryCountry->iso_code),
            'companyName' => trim($delivery->company),
            'name' => trim($delivery->firstname),
            'surname' => trim($delivery->lastname),
            'state' => trim($deliveryState->iso_code ?? ''),
        ];

        $delivery_point = SimplyInPoint::getByIdCart($order->id_cart);
        $phoneNumber = $delivery->phone;
        $phone = $simplyin_session->get('new_account_phone', false);
        if ($phone) {
            $phoneNumber = $phone;
        }
        $payment = $this->composePaymentDisplay(
            (string) $simplyin_session->get('simplyin_payment_type', ''),
            (string) $simplyin_session->get('simplyin_payment_provider', ''),
            (string) $simplyin_session->get('simplyin_card_last_digits', ''),
            (string) $order->payment,
            (string) $order->module,
        );

        // orderDate is optional on the wire — omit the key entirely when date_add is
        // missing/unparsable rather than sending a garbage placeholder date.
        $orderDateValue = $this->orderDate($order);
        $orderData = [
            'newAccountData' => [
                'name' => $delivery->firstname,
                'surname' => $delivery->lastname,
                'phoneNumber' => $phoneNumber,
                'email' => strtolower($customer->email),
                'language' => strtoupper(substr($language->language_code, 0, 2)),
                'marketingConsent' => false,
            ],
            'newOrderData' => [
                'shopOrderNumber' => $order->reference,
                'price' => (float) $order->total_paid,
                'shippingPrice' => (float) $order->total_shipping,
                'currency' => $currency->iso_code,
                // Order-level money breakdown (B2C gross, PrestaShop Order API).
                ...$this->orderMoneyBreakdown($order),
                'items' => $products,
                // Original order placement date (date_add).
                ...($orderDateValue !== null ? ['orderDate' => $orderDateValue] : []),
                'placedDuringAccountCreation' => true,
                'billingData' => $billingData,
                'shippingData' => $shippingData,
                'payment_method' => $payment['method'],
                'payment_method_title' => $payment['title'],
            ],
        ];

        if ($delivery_point->id) {
            unset($orderData['newOrderData']['shippingData']);
            $orderData['newOrderData']['parcelLockerMinimalInfo'] = [
                'providerName' => $delivery_point->label,
                'lockerId' => $delivery_point->lockerId,
            ];
        }

        return $orderData;
    }

    /**
     * Compose the payment display strings sent to the backend. Single source of truth for
     * both build paths (orderData = account-creation checkout, existingAccountOrderData = sync).
     *
     * Case A — SimplyIN gateway ($simplyinType present in session):
     *   method = "Simply.IN · {Provider}" (U+00B7 middle dot; empty provider → "Simply.IN")
     *   title  = card w/ last4 → "**** 1234"; card w/o last4 → "Card"; BLIK → "BLIK"; other → type name
     * Case B — shop's own method (no SimplyIN selection):
     *   method = native $order->payment (module fallback when empty)
     *   title  = '' — the title line only carries a payment TYPE (card/BLIK/…) and PrestaShop has
     *            no separate type field for native methods; duplicating method there is noise
     *
     * @return array{method: string, title: string}
     */
    private function composePaymentDisplay(
        string $simplyinType,
        string $simplyinProvider,
        string $cardLastDigits,
        string $nativePayment,
        string $nativeModule,
    ): array {
        $type = trim($simplyinType);

        if ($type === '') {
            $nativePayment = trim($nativePayment);
            $nativeModule = trim($nativeModule);

            return [
                'method' => $nativePayment !== '' ? $nativePayment : $nativeModule,
                'title' => '',
            ];
        }

        $provider = trim($simplyinProvider);
        $method = $provider !== ''
            ? self::SIMPLYIN_BRAND . self::PROVIDER_SEPARATOR . $provider
            : self::SIMPLYIN_BRAND;

        return [
            'method' => $method,
            'title' => $this->paymentInstrumentTitle($type, $cardLastDigits),
        ];
    }

    /**
     * Case A title — the payment instrument shown to the user. Never carries a card brand
     * or a PAN beyond last4, and never surfaces a "0000" placeholder (PCI). $simplyinType is already
     * trimmed by the caller.
     */
    private function paymentInstrumentTitle(string $simplyinType, string $cardLastDigits): string
    {
        if (strcasecmp($simplyinType, 'card') === 0 || strcasecmp($simplyinType, 'add-card') === 0) {
            // Parity with Magento PaymentDisplayFormatter: at least 4 digits must remain after
            // stripping non-digits (take the trailing 4), otherwise fall back to "Card".
            $digits = preg_replace('/\D/', '', $cardLastDigits) ?? '';
            $last4 = strlen($digits) >= 4 ? substr($digits, -4) : '';

            return ($last4 !== '' && $last4 !== '0000') ? '**** ' . $last4 : 'Card';
        }

        if (strcasecmp($simplyinType, 'blik') === 0) {
            return 'BLIK';
        }

        return $simplyinType;
    }

    /**
     * @return array{orderStatus: string, statusDate: string}
     */
    private function orderStatusInfo(Order $order): array
    {
        // Real UTC conversion (see toUtcIso8601()) — date_upd is a NOT NULL column
        // always populated by PrestaShop, so the fallback below is defensive only.
        $date = $this->toUtcIso8601((string) $order->date_upd, (string) Configuration::get('PS_TIMEZONE'))
            ?? gmdate('Y-m-d\TH:i:s.u\Z');
        $order_status = 'processing';

        return [
            'orderStatus' => $order_status,
            'statusDate' => $date,
        ];
    }

    /**
     * Original order placement date (Order::date_add), ISO-8601 w/ time.
     * Distinct from orderStatusInfo()'s statusDate (date_upd, last status change): the backend
     * needs the true purchase date on history import instead of defaulting to the import time.
     *
     * Converted via toUtcIso8601() to a real UTC timestamp — see that method's doc for
     * why a bare strtotime()+date()+"Z" suffix (the previous implementation) lied on non-UTC
     * servers.
     *
     * `orderDate` is optional on the wire — returns null (key omitted by callers) when date_add
     * is empty/unparsable/zero-date, rather than emitting a garbage epoch/negative-year date.
     */
    private function orderDate(Order $order): ?string
    {
        return $this->toUtcIso8601((string) $order->date_add, (string) Configuration::get('PS_TIMEZONE'));
    }

    /**
     * Convert a shop-local datetime string (as stored by PrestaShop in Order::date_add /
     * date_upd) to a real UTC ISO-8601 string. PrestaShop persists these columns in the shop's
     * configured timezone (Configuration::PS_TIMEZONE), not UTC, so a bare strtotime()+date() with
     * a literal "Z" suffix lies on any server whose process timezone differs from the shop's.
     *
     * Returns null for empty/zero-date/unparsable input so callers can omit the key rather than
     * emit a garbage epoch/negative-year date. An unknown/invalid timezone name falls back to UTC
     * instead of throwing (DateTimeZone rejects bad names with an exception).
     */
    private function toUtcIso8601(?string $date, ?string $timezone): ?string
    {
        if ($date === null || $date === '') {
            return null;
        }

        try {
            $shopTimezone = new DateTimeZone($timezone !== null && $timezone !== '' ? $timezone : 'UTC');
        } catch (Throwable) {
            $shopTimezone = new DateTimeZone('UTC');
        }

        try {
            $dateTime = new DateTime($date, $shopTimezone);
        } catch (Throwable) {
            return null;
        }

        if ($dateTime->getTimestamp() <= 0) {
            return null;
        }

        $dateTime->setTimezone(new DateTimeZone('UTC'));

        return $dateTime->format('Y-m-d\TH:i:s.u\Z');
    }

    /**
     * @return array<int, string>
     */
    private function trackingNumbers(Order $order): array
    {
        $tracking_numbers = [];
        if (!empty($order->shipping_number)) {
            $tracking_numbers[] = $order->shipping_number;
        }

        return $tracking_numbers;
    }

    /**
     * Order-level money breakdown (B2C gross, PrestaShop Order API). Shared by both build
     * paths so guest checkout and account-creation checkout emit identical totals.
     *
     * @return array<string, float>
     */
    private function orderMoneyBreakdown(Order $order): array
    {
        return [
            'total_amount' => (float) $order->total_paid_tax_incl,
            'subtotal_amount' => (float) $order->total_products_wt,
            'discount_amount' => -1 * (float) $order->total_discounts_tax_incl,
            'shipping_amount' => (float) $order->total_shipping_tax_incl,
            'tax_amount' => (float) $order->total_paid_tax_incl - (float) $order->total_paid_tax_excl,
        ];
    }

    /**
     * Load a product once per build (memoized by product + language) so the description read and the
     * enrichment below don't each trigger their own query for the same line item.
     */
    private function loadProduct(int $productId, int $langId): ?Product
    {
        if ($productId <= 0) {
            return null;
        }
        $key = $productId . ':' . $langId;
        if (!array_key_exists($key, $this->productCache)) {
            $product = new Product($productId, false, $langId);
            $this->productCache[$key] = Validate::isLoadedObject($product) ? $product : null;
        }

        return $this->productCache[$key];
    }

    /**
     * Per-item enrichment: SKU / EAN / brand plus an open list of catalog attributes.
     * Additive and best-effort — any failure is swallowed so it never blocks the order.
     *
     * @param array<string, mixed> $product an Order::getProducts() row
     *
     * @return array<string, mixed>
     */
    private function itemEnrichment(array $product, Order $order): array
    {
        $out = [];

        $sku = trim((string) ($product['product_reference'] ?? ''));
        if ($sku !== '') {
            $out['sku'] = $sku;
        }

        $ean = trim((string) ($product['product_ean13'] ?? ''));
        if ($ean === '') {
            $ean = trim((string) ($product['product_upc'] ?? ''));
        }
        if ($ean === '') {
            $ean = trim((string) ($product['product_isbn'] ?? ''));
        }
        if ($ean !== '') {
            $out['ean'] = $ean;
        }

        $productId = (int) ($product['product_id'] ?? $product['id_product'] ?? 0);
        if ($productId <= 0) {
            return $out;
        }
        $idProductAttribute = (int) ($product['product_attribute_id'] ?? 0);

        try {
            $productObj = $this->loadProduct($productId, (int) $order->id_lang);
            if ($productObj === null) {
                return $out;
            }

            $brand = Manufacturer::getNameById((int) $productObj->id_manufacturer);
            if (is_string($brand) && trim($brand) !== '') {
                $out['brand'] = trim($brand);
            }

            $attributes = $this->itemAttributes($productObj, $productId, $idProductAttribute, (int) $order->id_lang);
            if ($attributes !== []) {
                $out['attributes'] = array_slice($attributes, 0, self::MAX_ATTRIBUTES);
            }
        } catch (Throwable) {
            // Enrichment is optional; never block the order on it.
        }

        return $out;
    }

    /**
     * Build the open attribute list for a product: categories, features, the selected combination
     * (colour/size/…), tags, short description and physical dimensions. Absent values are omitted.
     *
     * @return array<int, array{name: string, value: string}>
     */
    private function itemAttributes(Product $productObj, int $productId, int $idProductAttribute, int $langId): array
    {
        $attrs = [];

        $names = [];
        foreach (Product::getProductCategoriesFull($productId, $langId) as $category) {
            $name = isset($category['name']) ? trim((string) $category['name']) : '';
            if ($name !== '') {
                $names[] = $name;
            }
        }
        if ($names !== []) {
            $attrs[] = ['name' => 'categories', 'value' => $this->truncate(implode(', ', $names))];
        }

        foreach ($productObj->getFrontFeatures($langId) as $feature) {
            $name = isset($feature['name']) ? trim((string) $feature['name']) : '';
            $value = isset($feature['value']) ? trim((string) $feature['value']) : '';
            if ($name !== '' && $value !== '') {
                $attrs[] = ['name' => $name, 'value' => $this->truncate($value)];
            }
        }

        if ($idProductAttribute > 0) {
            foreach ($productObj->getAttributeCombinationsById($idProductAttribute, $langId) as $combination) {
                $group = isset($combination['group_name']) ? trim((string) $combination['group_name']) : '';
                $value = isset($combination['attribute_name']) ? trim((string) $combination['attribute_name']) : '';
                if ($group !== '' && $value !== '') {
                    $attrs[] = ['name' => $group, 'value' => $this->truncate($value)];
                }
            }
        }

        $tags = $productObj->getTags($langId);
        if (trim($tags) !== '') {
            $attrs[] = ['name' => 'tags', 'value' => $this->truncate($tags)];
        }

        $shortDescription = trim(strip_tags((string) $productObj->description_short));
        if ($shortDescription !== '') {
            $attrs[] = ['name' => 'description_short', 'value' => $this->truncate($shortDescription)];
        }

        $weight = (float) $productObj->weight;
        if ($weight > 0) {
            $attrs[] = ['name' => 'weight', 'value' => (string) $weight];
        }

        $dimensions = [];
        foreach (['width', 'height', 'depth'] as $dimension) {
            $value = (float) $productObj->{$dimension};
            if ($value > 0) {
                $dimensions[] = $dimension . '=' . $value;
            }
        }
        if ($dimensions !== []) {
            $attrs[] = ['name' => 'dimensions', 'value' => implode(', ', $dimensions)];
        }

        return $attrs;
    }

    private function truncate(string $value, int $maxLength = self::VALUE_MAX_LENGTH): string
    {
        return mb_substr($value, 0, $maxLength);
    }
}
