<?php
namespace TitanCart\Services;

defined( 'ABSPATH' ) || exit;

/**
 * SchemaService — Schema.org JSON-LD payload builder.
 *
 * Pure utility class that constructs Schema.org-compliant JSON-LD
 * payloads for two surfaces:
 *
 *   1. PDP product schema — @type:Product, including name, description,
 *      sku, image[], offers, and aggregateRating (when reviews exist).
 *   2. Vendor storefront organization schema — @type:Organization,
 *      including name, url, description, and logo.
 *
 * Both surfaces emit inline from their respective templates rather than
 * via wp_head. Inline emission keeps the source data (`$product` /
 * `$vendor`) in natural local scope at the point of emission, avoiding
 * the need to bridge data into wp_head listeners via global/static
 * state. Google's structured-data guidelines explicitly allow JSON-LD
 * anywhere in the document; positioning inside <body> is equivalent
 * to positioning inside <head> for indexing purposes.
 *
 * Architectural notes (Rule 4 — WordPress independence):
 *   - This service registers ZERO WordPress hooks. It is a pure
 *     input→output utility. Templates call it directly.
 *   - The only WP-specific call is `apply_filters()` used to expose
 *     the `titancart_jsonld_payload` extension point. The filter
 *     surface is identical in shape to TitanCart's own HookManager
 *     dispatch, so a portable swap is trivial.
 *   - JSON encoding uses native PHP — no WordPress dependency.
 *
 * Extension point — `titancart_jsonld_payload` filter:
 *
 *   Listeners receive the assembled base payload plus a type
 *   discriminator and context array. They return the (possibly
 *   modified) payload. Marketplace extension uses this at S199 to
 *   inject the `seller` block on PDP schemas from $product['vendor'].
 *
 *   add_filter( 'titancart_jsonld_payload', function( array $payload, string $type, array $context ): array {
 *       if ( $type === 'product' && ! empty( $context['product']['vendor'] ) ) {
 *           $vendor = $context['product']['vendor'];
 *           $payload['seller'] = [
 *               '@type' => 'Organization',
 *               'name'  => $vendor['display_name'],
 *               'url'   => $vendor['url'],
 *           ];
 *       }
 *       return $payload;
 *   }, 10, 3 );
 *
 *   Pattern #158 eighth-instance reinforcement — the cross-extension
 *   filter family (codified S187) gains a new "structured-data payload"
 *   layer. Prior instances span response (S185), query (S187), write
 *   (S190), gateway-params (S192), settings-fields (S193), query-products
 *   (S197), and sitemap (S198) layers. Shape-discipline: TC core fires
 *   filter with shaped payload + type discriminator + context; listeners
 *   MAY ADD keys, MUST NOT remove or mutate existing keys.
 *
 * @package TitanCart\Services
 * @since   S199
 */

class SchemaService {

    /**
     * Filter name for payload enrichment. Pulled to a constant so
     * tests and listeners reference a single source of truth.
     */
    public const FILTER_PAYLOAD = 'titancart_jsonld_payload';

    /**
     * Schema.org context URL. Constant so we can swap to 'https://schema.org'
     * uniformly if Google's guidance changes (currently both work).
     */
    private const SCHEMA_CONTEXT = 'https://schema.org';

    /* ──────────────────────────────────────────────────────────────────────────
     * Public — Product PDP JSON-LD
     * ────────────────────────────────────────────────────────────────────────── */

    /**
     * Build the full ld+json structured-data block (via core tag API) for a
     * product detail page. Returns the empty string if the product
     * is insufficient (no name, or otherwise unusable). Callers can
     * echo the return value directly without conditional checks.
     *
     * Required fields per Schema.org/Product:
     *   - name
     * Recommended fields included when present:
     *   - description (HTML-stripped from short_desc / description)
     *   - image (array of absolute URLs from $product['images'])
     *   - sku
     *   - offers { @type:Offer, price, priceCurrency, availability, url }
     *   - aggregateRating { @type:AggregateRating, ratingValue, reviewCount }
     *
     * The marketplace extension contributes the `seller` block via the
     * `titancart_jsonld_payload` filter when $product['vendor'] is set
     * (S197 enricher contract).
     *
     * @param array<string,mixed> $product Full product array from
     *                                     ProductRepository::get() + enrichments.
     * @param string              $lang    Language code (reserved for
     *                                     future i18n; currently unused).
     * @return string  Rendered ld+json structured-data block, or
     *                 '' if payload is unusable.
     */
    public static function product_jsonld( array $product, string $lang = 'en' ): string {
        $name = isset( $product['name'] ) ? trim( (string) $product['name'] ) : '';
        if ( $name === '' ) {
            return '';
        }

        $payload = [
            '@context' => self::SCHEMA_CONTEXT,
            '@type'    => 'Product',
            'name'     => $name,
        ];

        // Description — prefer short_desc, fall back to full description.
        // JSON-LD description must be plain text (not HTML); strip tags
        // and collapse whitespace before truncating to a sane length.
        $short_desc = isset( $product['short_desc'] ) ? (string) $product['short_desc'] : '';
        $full_desc  = isset( $product['description'] ) ? (string) $product['description'] : '';
        $desc_src   = $short_desc !== '' ? $short_desc : $full_desc;
        if ( $desc_src !== '' ) {
            $plain = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $desc_src ) ) );
            if ( $plain !== '' ) {
                $payload['description'] = self::truncate( $plain, 5000 );
            }
        }

        // Images — Schema.org accepts a string OR an array of strings.
        // We emit array form for clarity; collect absolute URLs from the
        // $product['images'] array shape (id/url/alt_text/thumbnail).
        $images_out = [];
        if ( ! empty( $product['images'] ) && is_array( $product['images'] ) ) {
            foreach ( $product['images'] as $img ) {
                if ( ! is_array( $img ) ) {
                    continue;
                }
                $url = isset( $img['url'] ) ? (string) $img['url'] : '';
                if ( $url !== '' ) {
                    $images_out[] = $url;
                }
            }
        }
        if ( ! empty( $images_out ) ) {
            $payload['image'] = array_values( array_unique( $images_out ) );
        }

        // SKU — TitanCart's `sku` column maps to the user-facing
        // "Product Code" field (vocabulary discipline) but Schema.org
        // uses 'sku' as the property name regardless. Emit as-is.
        $sku = isset( $product['sku'] ) ? trim( (string) $product['sku'] ) : '';
        if ( $sku !== '' ) {
            $payload['sku'] = $sku;
        }

        // Offers — only emitted when we have a price. The currency code
        // comes from the global SettingsService so multi-currency stores
        // report their primary currency. Availability maps stock_status
        // to Schema.org's vocabulary, with Buy-In-Advance handling so
        // the Schema availability matches what the UI actually allows.
        $price = isset( $product['price'] ) ? (float) $product['price'] : 0.0;
        if ( $price > 0 ) {
            $currency = self::primary_currency_code();

            $stock_status = isset( $product['stock_status'] ) ? (string) $product['stock_status'] : 'out_of_stock';
            $oos_action   = isset( $product['out_of_stock_actions'] ) ? (string) $product['out_of_stock_actions'] : 'none';
            $availability = self::availability_url( $stock_status, $oos_action );

            $offer = [
                '@type'         => 'Offer',
                'price'         => self::format_price( $price ),
                'priceCurrency' => $currency,
                'availability'  => $availability,
            ];

            // PDP canonical URL — uses SeoService::product_url when slug
            // is present (respects the configured URL pattern), otherwise
            // omitted. We only emit a URL we know is correct.
            $slug = isset( $product['slug'] ) ? (string) $product['slug'] : '';
            if ( $slug !== '' && class_exists( '\TitanCart\Services\SeoService' ) ) {
                $offer['url'] = SeoService::product_url( $slug );
            }

            $payload['offers'] = $offer;
        }

        // Aggregate rating — only when at least one approved review
        // exists. Schema.org requires ratingValue and reviewCount when
        // AggregateRating is present, so we gate on review_count > 0.
        $review_count = isset( $product['review_count'] )
            ? (int) $product['review_count']
            : (int) ( $product['rating_count'] ?? 0 );
        $review_avg = isset( $product['review_avg'] )
            ? (float) $product['review_avg']
            : (float) ( $product['rating_avg'] ?? 0 );
        if ( $review_count > 0 && $review_avg > 0 ) {
            $payload['aggregateRating'] = [
                '@type'       => 'AggregateRating',
                'ratingValue' => round( $review_avg, 1 ),
                'reviewCount' => $review_count,
                'bestRating'  => 5,
                'worstRating' => 1,
            ];
        }

        // Extension point — marketplace seller block, etc. Hook name is the
        // literal (= self::FILTER_PAYLOAD) so the prefix sniff can read it.
        $payload = apply_filters(
            'titancart_jsonld_payload',
            $payload,
            'product',
            [ 'product' => $product, 'lang' => $lang ]
        );

        return self::render_script_tag( $payload );
    }

    /* ──────────────────────────────────────────────────────────────────────────
     * Public — Organization JSON-LD
     * ────────────────────────────────────────────────────────────────────────── */

    /**
     * Build the ld+json structured-data block (via core tag API) for an
     * Organization page (vendor storefront, "about us" surfaces, etc.).
     *
     * Caller passes a normalized organization-shaped array — the service
     * doesn't know about vendors directly (per Rule 4, vendor concepts
     * live in the marketplace extension). The marketplace template
     * builds the org data from its vendor row and passes it in.
     *
     * Required fields per Schema.org/Organization:
     *   - name
     *   - url
     * Recommended fields included when present:
     *   - description (HTML-stripped)
     *   - logo (absolute URL)
     *   - sameAs (array of social profile URLs)
     *   - email, telephone
     *
     * @param array<string,mixed> $org Organization-shaped data: name, url
     *                                 required; description, logo, sameAs,
     *                                 email, telephone optional.
     * @return string  Rendered ld+json structured-data block, or
     *                 '' if payload is unusable.
     */
    public static function organization_jsonld( array $org ): string {
        $name = isset( $org['name'] ) ? trim( (string) $org['name'] ) : '';
        $url  = isset( $org['url']  ) ? trim( (string) $org['url']  ) : '';
        if ( $name === '' || $url === '' ) {
            return '';
        }

        $payload = [
            '@context' => self::SCHEMA_CONTEXT,
            '@type'    => 'Organization',
            'name'     => $name,
            'url'      => $url,
        ];

        // Description — HTML-stripped, whitespace-collapsed.
        $desc_src = isset( $org['description'] ) ? (string) $org['description'] : '';
        if ( $desc_src !== '' ) {
            $plain = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $desc_src ) ) );
            if ( $plain !== '' ) {
                $payload['description'] = self::truncate( $plain, 5000 );
            }
        }

        // Logo — Schema.org expects an absolute URL. Pass-through; caller
        // is responsible for resolving file_id → URL.
        $logo = isset( $org['logo'] ) ? trim( (string) $org['logo'] ) : '';
        if ( $logo !== '' ) {
            $payload['logo'] = $logo;
        }

        // sameAs — social profile URLs, filtered to non-empty strings.
        if ( ! empty( $org['sameAs'] ) && is_array( $org['sameAs'] ) ) {
            $same_as = [];
            foreach ( $org['sameAs'] as $entry ) {
                $entry = trim( (string) $entry );
                if ( $entry !== '' ) {
                    $same_as[] = $entry;
                }
            }
            if ( ! empty( $same_as ) ) {
                $payload['sameAs'] = array_values( array_unique( $same_as ) );
            }
        }

        // Contact fields — emitted as-is if present.
        $email = isset( $org['email'] ) ? trim( (string) $org['email'] ) : '';
        if ( $email !== '' ) {
            $payload['email'] = $email;
        }
        $tel = isset( $org['telephone'] ) ? trim( (string) $org['telephone'] ) : '';
        if ( $tel !== '' ) {
            $payload['telephone'] = $tel;
        }

        // Extension point — other extensions may enrich the org schema.
        $payload = apply_filters(
            'titancart_jsonld_payload',
            $payload,
            'organization',
            [ 'org' => $org ]
        );

        return self::render_script_tag( $payload );
    }

    /**
     * WP.org R2 item 6: print a product's ld+json block directly via
     * wp_print_inline_script_tag() — the core API performs the output, so
     * template callers never echo a variable (no EscapeOutput surface at all).
     * Prefer this over `echo product_jsonld()` in templates.
     *
     * Implementation: product_jsonld() runs the single build path (payload +
     * filter + encode via render_script_tag, which caches the encoded JSON in
     * self::$last_json); this method then hands that exact JSON to the core
     * printer. One build path, zero duplication.
     *
     * @param array<string,mixed> $product Same contract as product_jsonld().
     * @param string              $lang    Same contract as product_jsonld().
     */
    public static function print_product_jsonld( array $product, string $lang = 'en' ): void {
        if ( self::product_jsonld( $product, $lang ) === '' || self::$last_json === '' ) {
            return;
        }
        echo "\n";
        wp_print_inline_script_tag( self::$last_json, array( 'type' => 'application/ld+json' ) );
        echo "\n";
    }

    /* ──────────────────────────────────────────────────────────────────────────
     * Internal helpers
     * ────────────────────────────────────────────────────────────────────────── */

    /**
     * Render a Schema.org payload as an ld+json structured-data block via
     * wp_get_inline_script_tag(). JSON encoding uses unescaped slashes and
     * unescaped unicode for readability and accurate string preservation.
     * JSON_HEX_TAG encodes < and > as \u003C/\u003E so payload strings
     * (product names, descriptions) can never break out of the element —
     * the block is safe to echo verbatim. Output is wrapped in a newline
     * pair so it stands cleanly between the surrounding HTML blocks.
     *
     * @param array<string,mixed> $payload  Filter-applied payload.
     */
    private static function render_script_tag( array $payload ): string {
        self::$last_json = '';
        $json = wp_json_encode(
            $payload,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG
        );
        if ( ! is_string( $json ) || $json === '' ) {
            return '';
        }
        self::$last_json = $json;
        // WP.org R2: emitted via wp_get_inline_script_tag() (core API) with the
        // application/ld+json type attribute — no hand-built tag text anywhere.
        return "\n" . wp_get_inline_script_tag( $json, array( 'type' => 'application/ld+json' ) ) . "\n";
    }

    /**
     * Encoded JSON from the most recent render_script_tag() call — consumed by
     * print_product_jsonld() so print and string paths share one build. '' when
     * the last encode failed or produced nothing.
     *
     * @var string
     */
    private static $last_json = '';

    /**
     * Get the active store currency code (3-letter ISO). Falls back
     * to 'USD' when CurrencyService isn't loadable (e.g. during install).
     */
    private static function primary_currency_code(): string {
        if ( class_exists( '\TitanCart\Services\CurrencyService' ) ) {
            try {
                $code = CurrencyService::primary_code();
                if ( is_string( $code ) && strlen( $code ) === 3 ) {
                    return strtoupper( $code );
                }
            } catch ( \Throwable $e ) {
                // Fall through to default.
            }
        }
        return 'USD';
    }

    /**
     * Map TitanCart stock state to Schema.org availability URL.
     *
     * Buy-In-Advance handling: when out_of_stock_actions === 'buy' the
     * customer-facing UI shows Add to Cart even when stock_status is
     * out_of_stock. Schema.org's matching availability is BackOrder,
     * which signals "orderable but not currently in stock" to search
     * engines. This avoids contradicting the visible UI in the SERP.
     *
     * @param string $stock_status 'in_stock' | 'out_of_stock' | 'preorder' (S122).
     * @param string $oos_action   Per-product setting: 'none' | 'buy' | 'subscribe'.
     */
    private static function availability_url( string $stock_status, string $oos_action ): string {
        if ( $stock_status === 'in_stock' ) {
            return self::SCHEMA_CONTEXT . '/InStock';
        }
        if ( $oos_action === 'buy' ) {
            return self::SCHEMA_CONTEXT . '/BackOrder';
        }
        if ( $stock_status === 'preorder' ) {
            return self::SCHEMA_CONTEXT . '/PreOrder';
        }
        return self::SCHEMA_CONTEXT . '/OutOfStock';
    }

    /**
     * Format a price as a Schema.org-compliant string. Schema.org's
     * Offer.price accepts numeric strings; we render with up to two
     * decimal places and no thousands separator (matches Google's
     * Rich Results Test recommendations).
     */
    private static function format_price( float $price ): string {
        return number_format( $price, 2, '.', '' );
    }

    /**
     * Multi-byte safe truncate to a hard character limit. Used to bound
     * description fields so absurdly long product descriptions don't
     * bloat the JSON-LD payload. 5,000 chars is well above Google's
     * recommended description length but well below pathological cases.
     */
    private static function truncate( string $str, int $limit ): string {
        if ( function_exists( 'mb_strlen' ) ) {
            if ( mb_strlen( $str ) <= $limit ) {
                return $str;
            }
            return rtrim( mb_substr( $str, 0, $limit ) ) . '…';
        }
        if ( strlen( $str ) <= $limit ) {
            return $str;
        }
        return rtrim( substr( $str, 0, $limit ) ) . '…';
    }
}
