<?php
/**
 * Copyright © Simplyin. All rights reserved.
 */
declare(strict_types=1);

namespace Simplyin\Payment\Service;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory as CategoryCollectionFactory;
use Psr\Log\LoggerInterface;

/**
 * Resolves typed product identifiers (sku/brand/ean) and a bounded open
 * attributes[] list for the SimplyIN order payload.
 *
 * Shared by OrderPayloadBuilder (cron/backfill sync, from a Sales\Order item)
 * and OrderDataEnricher (live checkout, from a quote item). Every field is
 * optional: absent/empty values are omitted, values are truncated and the
 * attribute list is capped, so the payload stays bounded regardless of the
 * merchant catalog (BE limits — SIN-1470).
 */
class ProductAttributesResolver
{
    /**
     * Maximum number of open attributes emitted per line item.
     */
    private const MAX_ATTRIBUTES = 30;

    /**
     * Maximum length of a single attribute value before truncation.
     */
    private const MAX_VALUE_LENGTH = 500;

    /**
     * Candidate attribute codes for the EAN/GTIN identifier, in priority order.
     */
    private const EAN_CODES = ['ean', 'gtin', 'barcode', 'ean13', 'upc'];

    /**
     * Broad "product profile" attribute codes copied into attributes[] when present.
     */
    private const PROFILE_CODES = [
        'color',
        'size',
        'gender',
        'material',
        'style',
        'pattern',
        'season',
        'fit',
        'collection',
        'age_group',
        'format'
    ];

    /**
     * Candidate attribute codes for physical dimensions.
     */
    private const DIMENSION_CODES = ['length', 'width', 'height', 'depth', 'dimensions'];

    /**
     * Maximum number of category names emitted (and queried) per product.
     */
    private const MAX_CATEGORIES = 5;

    /**
     * @var CategoryCollectionFactory
     */
    private $categoryCollectionFactory;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @param CategoryCollectionFactory $categoryCollectionFactory
     * @param LoggerInterface $logger
     */
    public function __construct(
        CategoryCollectionFactory $categoryCollectionFactory,
        LoggerInterface $logger
    ) {
        $this->categoryCollectionFactory = $categoryCollectionFactory;
        $this->logger = $logger;
    }

    /**
     * Build the enrichment fields for a single order/quote line product.
     *
     * @param ProductInterface $product resolved (child for variants) catalog product
     * @param array $selectedOptions caller-supplied super-attributes: [['name'=>, 'value'=>], ...]
     * @param int|null $storeId store view for resolving store-scoped values (e.g. category name)
     * @return array subset of ['sku'=>, 'brand'=>, 'ean'=>, 'attributes'=>[]] (empty entries omitted)
     */
    public function resolve(ProductInterface $product, array $selectedOptions = [], ?int $storeId = null): array
    {
        $result = [];

        $sku = $this->clean((string) $product->getSku());
        if ($sku !== '') {
            $result['sku'] = $sku;
        }

        $brand = $this->resolveAttributeText($product, 'manufacturer');
        if ($brand !== null) {
            $result['brand'] = $brand;
        }

        $ean = $this->resolveEan($product);
        if ($ean !== null) {
            $result['ean'] = $ean;
        }

        $attributes = $this->buildAttributes($product, $selectedOptions, $storeId);
        if (!empty($attributes)) {
            $result['attributes'] = $attributes;
        }

        return $result;
    }

    /**
     * Assemble the bounded open attributes[] list for a product.
     *
     * @param ProductInterface $product
     * @param array $selectedOptions
     * @param int|null $storeId
     * @return array
     */
    private function buildAttributes(ProductInterface $product, array $selectedOptions, ?int $storeId): array
    {
        $attributes = [];

        $category = $this->resolveCategory($product, $storeId);
        if ($category !== null) {
            $this->pushAttribute($attributes, 'category', $category);
        }

        foreach ($selectedOptions as $option) {
            $name = isset($option['name']) ? (string) $option['name'] : '';
            $value = isset($option['value']) ? (string) $option['value'] : '';
            $this->pushAttribute($attributes, $name, $value);
        }

        // Profile attrs are option-backed (select/multiselect) — numeric raw values
        // are dropped to avoid leaking option ids. Dimensions are genuine numbers.
        $this->appendCodeAttributes($attributes, $product, self::PROFILE_CODES, false);
        $this->appendText($attributes, 'short_description', $product->getData('short_description'));
        $this->appendText($attributes, 'description', $product->getData('description'));
        $this->appendWeight($attributes, $product);
        $this->appendCodeAttributes($attributes, $product, self::DIMENSION_CODES, true);

        return $attributes;
    }

    /**
     * Append every resolvable attribute from a list of candidate codes.
     *
     * @param array $attributes
     * @param ProductInterface $product
     * @param array $codes
     * @param bool $allowNumeric whether a numeric raw value is acceptable (e.g. dimensions)
     * @return void
     */
    private function appendCodeAttributes(
        array &$attributes,
        ProductInterface $product,
        array $codes,
        bool $allowNumeric
    ): void {
        foreach ($codes as $code) {
            $value = $this->resolveAttributeText($product, $code, $allowNumeric);
            if ($value !== null) {
                $this->pushAttribute($attributes, $code, $value);
            }
        }
    }

    /**
     * Append the product weight as an attribute when it is a positive number.
     *
     * @param array $attributes
     * @param ProductInterface $product
     * @return void
     */
    private function appendWeight(array &$attributes, ProductInterface $product): void
    {
        $weight = $product->getWeight();
        if ($weight !== null && (float) $weight > 0) {
            $this->pushAttribute($attributes, 'weight', (string) (float) $weight);
        }
    }

    /**
     * Resolve the human-readable value of a product attribute.
     *
     * Prefers the option label (getAttributeText). Falls back to a raw scalar
     * value: non-numeric strings always, numeric values only when $allowNumeric
     * (so option ids from select/multiselect attributes are never leaked, while
     * genuine numeric attributes such as dimensions are still emitted).
     *
     * @param ProductInterface $product
     * @param string $code
     * @param bool $allowNumeric
     * @return string|null
     */
    private function resolveAttributeText(ProductInterface $product, string $code, bool $allowNumeric = false): ?string
    {
        $text = $product->getAttributeText($code);
        if (is_array($text)) {
            $text = implode(', ', $text);
        }

        if ($text === null || $text === false || $text === '') {
            $text = $this->rawScalarValue($product->getData($code), $allowNumeric);
        }

        $text = $this->clean((string) $text);

        return $text === '' ? null : $text;
    }

    /**
     * Extract a usable scalar value from a raw attribute value.
     *
     * @param mixed $raw
     * @param bool $allowNumeric
     * @return string
     */
    private function rawScalarValue($raw, bool $allowNumeric): string
    {
        if (is_string($raw) && !is_numeric($raw)) {
            return $raw;
        }

        if ($allowNumeric && is_numeric($raw)) {
            return (string) $raw;
        }

        return '';
    }

    /**
     * Resolve the EAN/GTIN identifier from the first populated candidate code.
     *
     * @param ProductInterface $product
     * @return string|null
     */
    private function resolveEan(ProductInterface $product): ?string
    {
        foreach (self::EAN_CODES as $code) {
            $raw = $product->getData($code);
            if (is_scalar($raw)) {
                $value = $this->clean((string) $raw);
                if ($value !== '') {
                    return $value;
                }
            }
        }

        return null;
    }

    /**
     * Resolve up to five category names the product belongs to, joined as a path.
     *
     * @param ProductInterface $product
     * @param int|null $storeId store view for the store-scoped category name
     * @return string|null
     */
    private function resolveCategory(ProductInterface $product, ?int $storeId): ?string
    {
        $categoryIds = $product->getCategoryIds();
        if (empty($categoryIds)) {
            return null;
        }

        // Cap the queried ids: only up to MAX_CATEGORIES names are ever emitted.
        $categoryIds = array_slice($categoryIds, 0, self::MAX_CATEGORIES);

        try {
            $collection = $this->categoryCollectionFactory->create();
            // category name is store-scoped; in the cron/sync path the current store
            // may be admin, so pin the collection to the order's store view.
            if ($storeId !== null) {
                $collection->setStoreId($storeId);
            }
            $collection->addAttributeToSelect('name');
            $collection->addFieldToFilter('entity_id', ['in' => $categoryIds]);

            $names = [];
            foreach ($collection as $category) {
                $name = $this->clean((string) $category->getName());
                if ($name !== '') {
                    $names[] = $name;
                }
            }
        } catch (\Exception $e) {
            $this->logger->debug('SimplyIN: category resolve failed: ' . $e->getMessage());
            return null;
        }

        if (empty($names)) {
            return null;
        }

        return implode(' / ', $names);
    }

    /**
     * Push a strip-tagged text attribute (e.g. product description) when non-empty.
     *
     * @param array $attributes
     * @param string $name
     * @param mixed $raw
     * @return void
     */
    private function appendText(array &$attributes, string $name, $raw): void
    {
        if (!is_string($raw) || $raw === '') {
            return;
        }

        $text = $this->clean(strip_tags($raw));
        if ($text !== '') {
            $this->pushAttribute($attributes, $name, $text);
        }
    }

    /**
     * Append a normalized {name, value} attribute.
     *
     * Honours the count cap, truncation and omit-when-empty rules.
     *
     * @param array $attributes
     * @param string $name
     * @param string $value
     * @return void
     */
    private function pushAttribute(array &$attributes, string $name, string $value): void
    {
        if (count($attributes) >= self::MAX_ATTRIBUTES) {
            return;
        }

        $name = $this->clean($name);
        $value = $this->clean($value);
        if ($name === '' || $value === '') {
            return;
        }

        // Dedupe by name (case-insensitive, first wins): super-attribute labels from
        // selectedOptions (e.g. "Color"/"Size") can collide with category / profile codes.
        $key = mb_strtolower($name);
        foreach ($attributes as $existing) {
            if (mb_strtolower((string) $existing['name']) === $key) {
                return;
            }
        }

        $attributes[] = [
            'name' => $name,
            'value' => $this->truncate($value)
        ];
    }

    /**
     * Collapse whitespace and trim a raw string value.
     *
     * @param string $value
     * @return string
     */
    private function clean(string $value): string
    {
        return trim((string) preg_replace('/\s+/', ' ', $value));
    }

    /**
     * Truncate a value to the configured maximum length.
     *
     * @param string $value
     * @return string
     */
    private function truncate(string $value): string
    {
        if (mb_strlen($value) <= self::MAX_VALUE_LENGTH) {
            return $value;
        }

        return mb_substr($value, 0, self::MAX_VALUE_LENGTH - 3) . '...';
    }
}
