<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Commerce;

/**
 * Project a WooCommerce product (and its variations) into the doc
 * shape the gateway expects.
 *
 * Field shape is locked to the gateway's `commerce` vertical
 * Typesense collection schema as defined in
 * `services/mcp-gateway/ops/tenant-add.php` (commerce default branch,
 * lines 472-583), plus the three optional WooCommerce-driven fields
 * `on_sale` / `sale_price` / `currency` that were added in the
 * companion `add-woocommerce-commerce-fields.php` migration.
 *
 *   REQUIRED:
 *     id            string   (product ID, stringified)
 *     name          string   (product display name)
 *
 *   OPTIONAL:
 *     description          string         (rich short_description, html-stripped)
 *     brand                string         (facet=true; auto-detected)
 *     category_id          int32[]        (facet=true; WC term IDs)
 *     category_breadcrumbs string[]       (facet=true; "Apparel > Hoodies" etc.)
 *     price                float
 *     sale_price           float          (Woo sale price; gateway extension)
 *     on_sale              bool           (facet=true; gateway extension)
 *     currency             string         (index=false; gateway extension)
     *     in_stock             bool           (facet=true; mapped from stock_status)
     *     purchasable          bool           (facet=true; WC is_purchasable / backorder)
 *     sku                  string
 *     url                  string         (index=false; permalink)
 *     image_url            string         (index=false)
 *     last_indexed_at      int64          (connector stamps `time()`)
 *
 * Pre-v0.3.0 emitted WC-shaped names (`title`, `permalink`,
 * `stock_status`, `categories`, `regular_price`, `excerpt`,
 * `content_plain`, `attributes`, `variations`) that never matched
 * the gateway's strict-schema collection — every upsert would have
 * 400'd. v0.3.0 ports the realignment that the content vertical
 * landed in v0.1.1.
 *
 * The connector-only signal we still want to roundtrip back to the
 * storefront (Woo variations, raw attributes, regular_price) is
 * intentionally NOT shipped to the gateway — the storefront
 * re-resolves it via `wc_get_product()` at typeahead/SERP render
 * time (see Commerce/Typeahead::nativeFallback). Keeping the
 * doc lean is the right move: variations alone can be hundreds of
 * floats per product and would dominate the per-tenant Typesense
 * RAM budget in §6 of `seekmodo/PROJECT_PLAN.md`.
 *
 * All access to WC core types is via `function_exists`/`method_exists`
 * guards so PHPUnit unit tests can exercise this class without Woo
 * loaded.
 */
final class ProductFields
{
    /**
     * @param mixed $product A WC_Product instance.
     * @return array<string,mixed>
     */
    public static function buildDoc($product): array
    {
        if (!is_object($product) || !method_exists($product, 'get_id')) {
            return [];
        }

        $id          = (int) $product->get_id();
        $name        = (string) (method_exists($product, 'get_name') ? $product->get_name() : '');
        $description = self::buildDescription($product);
        $sku         = method_exists($product, 'get_sku') ? (string) $product->get_sku() : '';
        $permalink   = method_exists($product, 'get_permalink') ? (string) $product->get_permalink() : '';
        $image_url   = self::imageUrl($product);
        $currency    = function_exists('get_woocommerce_currency') ? (string) get_woocommerce_currency() : '';

        [$price, $sale_price, $on_sale] = self::priceFields($product);

        $stock_status = method_exists($product, 'get_stock_status') ? (string) $product->get_stock_status() : 'instock';
        $in_stock     = $stock_status === 'instock';
        $purchasable  = self::isPurchasable($product, $stock_status);

        [$category_ids, $category_breadcrumbs] = self::categoryFields($id);
        $brand                                 = self::detectBrand($id);

        $doc = [
            'id'              => (string) $id,
            'name'            => $name,
            'description'     => $description,
            'brand'           => $brand,
            'category_id'     => $category_ids,
            'category_breadcrumbs' => $category_breadcrumbs,
            'price'           => $price,
            'sale_price'      => $sale_price,
            'on_sale'         => $on_sale,
            'currency'        => $currency,
            'in_stock'        => $in_stock,
            'purchasable'     => $purchasable,
            'sku'             => $sku,
            'url'             => $permalink,
            'image_url'       => $image_url,
            'last_indexed_at' => time(),
        ];

        // Drop any null/empty optional fields so the gateway's strict
        // schema doesn't see explicit nulls (which Typesense rejects
        // for typed optional fields). `name` and `id` are required and
        // never get culled.
        $doc = self::pruneEmptyOptional($doc);

        // Site owners can extend / override fields via this filter.
        // Anything they emit MUST stay schema-compatible with the
        // gateway commerce collection. Adding more optional fields to
        // the gateway schema (PR against the seekmodo monorepo) is
        // the supported way to enrich the projection.
        return apply_filters('seekmodo_indexer_doc', $doc, $product);
    }

    /**
     * Shopper can add to cart today (includes backorder-eligible OOS).
     *
     * @param mixed $product
     */
    private static function isPurchasable($product, string $stock_status): bool
    {
        if (is_object($product) && method_exists($product, 'is_purchasable')) {
            return (bool) $product->is_purchasable();
        }
        if ($stock_status === 'instock') {
            return true;
        }
        if ($stock_status === 'onbackorder'
            && is_object($product)
            && method_exists($product, 'get_backorders')) {
            return (string) $product->get_backorders() !== 'no';
        }

        return false;
    }

    /**
     * @return array{0:float|null,1:float|null,2:bool}
     */
    private static function priceFields($product): array
    {
        $price      = method_exists($product, 'get_price') ? self::asFloat($product->get_price()) : null;
        $sale_price = method_exists($product, 'get_sale_price') ? self::asFloat($product->get_sale_price()) : null;
        $on_sale    = method_exists($product, 'is_on_sale') ? (bool) $product->is_on_sale() : false;

        return [$price, $sale_price, $on_sale];
    }

    /**
     * @param mixed $v
     */
    private static function asFloat($v): ?float
    {
        if ($v === null || $v === '' || $v === false) {
            return null;
        }

        return (float) $v;
    }

    private static function imageUrl($product): string
    {
        if (!is_object($product) || !method_exists($product, 'get_image_id')) {
            return '';
        }
        $image_id = (int) $product->get_image_id();
        if ($image_id <= 0) {
            return '';
        }
        if (function_exists('wp_get_attachment_image_url')) {
            return (string) wp_get_attachment_image_url($image_id, 'medium');
        }

        return '';
    }

    /**
     * Combine WC's short_description (preferred for SERP/typeahead
     * snippets) with a tag-stripped fallback to the long description.
     * Capped at 4 KB so a 10k-char description doesn't blow up the
     * Typesense doc.
     */
    private static function buildDescription($product): string
    {
        $short = method_exists($product, 'get_short_description')
            ? trim((string) $product->get_short_description())
            : '';
        $long = method_exists($product, 'get_description')
            ? trim((string) $product->get_description())
            : '';

        $combined = $short !== '' ? $short : $long;

        $plain = (string) wp_strip_all_tags($combined);

        // Trim whitespace artefacts left over from html stripping.
        $plain = preg_replace('/\s+/u', ' ', $plain) ?? $plain;

        return trim(mb_substr($plain, 0, 4000));
    }

    /**
     * Build the (`category_id`, `category_breadcrumbs`) pair.
     *
     * Both fields are optional on the gateway. `category_id` is the
     * raw WC term-ID list (int32[]) so cross-vertical filter URLs
     * like `?filter_category_id=14` round-trip through the storefront.
     * `category_breadcrumbs` is the human-readable hierarchy used by
     * the rerank features (`category_breadcrumbs:=Apparel > Hoodies`).
     *
     * @return array{0:array<int,int>,1:array<int,string>}
     */
    private static function categoryFields(int $post_id): array
    {
        $ids   = [];
        $names = [];
        if (!function_exists('wp_get_post_terms')) {
            return [[], []];
        }
        $terms = wp_get_post_terms($post_id, 'product_cat');
        if (!is_array($terms)) {
            return [[], []];
        }
        foreach ($terms as $t) {
            if (!is_object($t)) {
                continue;
            }
            if (isset($t->term_id)) {
                $ids[] = (int) $t->term_id;
            }
            $crumb = self::breadcrumbForTerm($t);
            if ($crumb !== '') {
                $names[] = $crumb;
            }
        }

        return [array_values(array_unique($ids)), array_values(array_unique($names))];
    }

    /**
     * Produce a "Parent > Child > Leaf" breadcrumb for a WC term by
     * walking `term->parent` upward until we hit 0. Falls back to the
     * leaf name alone if the WP API isn't available (unit tests, etc.).
     */
    private static function breadcrumbForTerm($term): string
    {
        if (!is_object($term) || !isset($term->name)) {
            return '';
        }
        $parts  = [(string) $term->name];
        $parent = isset($term->parent) ? (int) $term->parent : 0;
        $guard  = 0; // anti-cycle guard
        while ($parent > 0 && $guard < 12 && function_exists('get_term')) {
            $p = get_term($parent, isset($term->taxonomy) ? (string) $term->taxonomy : 'product_cat');
            if (!is_object($p) || !isset($p->name)) {
                break;
            }
            array_unshift($parts, (string) $p->name);
            $parent = isset($p->parent) ? (int) $p->parent : 0;
            $guard++;
        }

        return implode(' > ', $parts);
    }

    /**
     * Auto-detect product brand from common providers in the order
     * they're tried: WC Brands (product_brand), Perfect Brands
     * (pwb-brand), YITH Brand (yith_product_brand). Returns null if
     * none match — the field is optional on the gateway schema.
     */
    private static function detectBrand(int $post_id): ?string
    {
        if (!function_exists('wp_get_post_terms')) {
            return null;
        }
        foreach (['product_brand', 'pwb-brand', 'yith_product_brand'] as $taxonomy) {
            $names = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'names']);
            if (is_array($names) && !empty($names)) {
                $first = (string) $names[0];
                if ($first !== '') {
                    return $first;
                }
            }
        }

        return null;
    }

    /**
     * Drop entries that are empty/null AND not the two required
     * fields (`id`, `name`). Keeps `false` booleans (`in_stock=false`,
     * `on_sale=false`) intact because they carry meaning.
     *
     * @param array<string,mixed> $doc
     * @return array<string,mixed>
     */
    private static function pruneEmptyOptional(array $doc): array
    {
        $required = ['id' => true, 'name' => true];
        foreach ($doc as $key => $value) {
            if (isset($required[$key])) {
                continue;
            }
            if ($value === null || $value === '' || $value === []) {
                unset($doc[$key]);
            }
        }

        return $doc;
    }
}
