<?php
/**
 * TitanCart Product Ribbons (core).
 *
 * Rule-based product-card / PDP ribbons (Sale -X% / New / Featured / Best
 * Seller) decided in ONE place and rendered consistently across every
 * storefront surface (shop, search, homepages, PDP). The engine is
 * self-sufficient: each surface passes the product row it already has and the
 * engine lazily fetches created_at / featured / sales_count by id (memoized)
 * when a rule needs a field the surface row does not carry — so no listing
 * query anywhere needs to be widened.
 *
 * Theme-agnostic: compute() decides WHICH ribbons apply; render() emits a
 * consistent .tc-ribbons stack whose colours / labels / position / shape come
 * from the core "Product Ribbons" settings group, so a merchant tunes the look
 * to match their theme. Each surface hides its own native sale/new badge (via
 * output_css) so this feature owns one coherent stack.
 *
 * Settings: 'appearance' section, 'ribbon_*' keys (a "Product Ribbons" group
 * in SettingsController::schema_appearance).
 *
 * @package TitanCart\Storefront
 */

namespace TitanCart\Storefront;

use TitanCart\Services\SettingsService;

defined( 'ABSPATH' ) || exit;

class ProductRibbons {

	/** Per-request memo of {id => [created_at, featured, sales_count]} lazy lookups. */
	private static array $cache = [];

	/* ======================================================================
	 * PUBLIC API
	 * ====================================================================== */

	/**
	 * Compute the applicable ribbons for a product row.
	 *
	 * @param array|object $product  Product row (array or stdClass).
	 * @param bool|null    $on_sale  Pre-computed on-sale flag. When null it is
	 *                               derived from list_price > price in the row.
	 * @return array<int,array{type:string,label:string,color:string}>
	 */
	public static function compute( $product, ?bool $on_sale = null ): array {
		$product = self::normalize( $product );
		if ( empty( $product ) ) {
			return [];
		}

		$pid   = (int) ( $product['id'] ?? 0 );
		$price = (float) ( $product['price'] ?? 0 );
		$list  = (float) ( $product['list_price'] ?? 0 );
		if ( null === $on_sale ) {
			$on_sale = ( $list > $price && $price > 0 );
		}

		$ribbons = [];

		// ── Sale (shows "-X%") ───────────────────────────────────────────────
		if ( self::sbool( 'sale_enabled', true ) && $on_sale ) {
			$pct = ( $list > $price && $list > 0 ) ? (int) round( ( ( $list - $price ) / $list ) * 100 ) : 0;
			$tpl = (string) self::sget( 'sale_label', '-{pct}%' );
			if ( false !== strpos( $tpl, '{pct}' ) ) {
				// Percent template: substitute when we have a %, else a plain "Sale".
				$label = $pct > 0 ? str_replace( '{pct}', (string) $pct, $tpl ) : 'Sale';
			} else {
				// Literal label (e.g. "HOT DEAL") — used verbatim, percent ignored.
				$label = '' !== trim( $tpl ) ? $tpl : 'Sale';
			}
			$ribbons[] = [ 'type' => 'sale', 'label' => $label, 'color' => self::color( 'sale_color', '#DC2626' ) ];
		}

		// ── New (created within the configured window) ───────────────────────
		if ( self::sbool( 'new_enabled', false ) ) {
			$days    = max( 1, (int) self::sget( 'new_days', 14 ) );
			$created = self::field( $pid, 'created_at', $product );
			$ts      = $created ? strtotime( (string) $created ) : 0;
			if ( $ts && $ts >= ( time() - ( $days * DAY_IN_SECONDS ) ) ) {
				$ribbons[] = [
					'type'  => 'new',
					'label' => (string) self::sget( 'new_label', 'New' ),
					'color' => self::color( 'new_color', '#2563EB' ),
				];
			}
		}

		// ── Featured (editor flag) ───────────────────────────────────────────
		if ( self::sbool( 'featured_enabled', false ) ) {
			if ( (int) self::field( $pid, 'featured', $product ) === 1 ) {
				$ribbons[] = [
					'type'  => 'featured',
					'label' => (string) self::sget( 'featured_label', 'Featured' ),
					'color' => self::color( 'featured_color', '#D97706' ),
				];
			}
		}

		// ── Best Seller (sales_count at/above threshold) ─────────────────────
		if ( self::sbool( 'bestseller_enabled', false ) ) {
			$min = max( 1, (int) self::sget( 'bestseller_min', 25 ) );
			if ( (int) self::field( $pid, 'sales_count', $product ) >= $min ) {
				$ribbons[] = [
					'type'  => 'bestseller',
					'label' => (string) self::sget( 'bestseller_label', 'Best Seller' ),
					'color' => self::color( 'bestseller_color', '#16A34A' ),
				];
			}
		}

		// ── Top Rated (rating at/above threshold with enough reviews) ────────
		if ( self::sbool( 'toprated_enabled', false ) ) {
			$min_rating  = (float) self::sget( 'toprated_min_rating', 4.5 );
			$min_reviews = max( 1, (int) self::sget( 'toprated_min_reviews', 5 ) );
			$r_avg = (float) self::field( $pid, 'rating_avg', $product );
			$r_cnt = (int) self::field( $pid, 'rating_count', $product );
			if ( $r_cnt >= $min_reviews && $r_avg >= $min_rating ) {
				$ribbons[] = [
					'type'  => 'toprated',
					'label' => (string) self::sget( 'toprated_label', 'Top Rated' ),
					'color' => self::color( 'toprated_color', '#7C3AED' ),
				];
			}
		}

		// ── Availability (ONE mutually-exclusive ribbon) ─────────────────────
		// Precedence: when NOT in stock -> Preorder (buy-in-advance) else Out of
		// Stock; when in stock -> Low Stock (qty at/under the threshold, only if
		// inventory is tracked) else In Stock. Each gated by its own toggle. Stock
		// fields come from the row or are lazily fetched; an unknown status is
		// treated as in-stock (safe default — no false "Sold Out").
		$stock_status = (string) self::field( $pid, 'stock_status', $product );
		$is_in_stock  = ( '' === $stock_status ) ? true : ( 'in_stock' === $stock_status );
		if ( $is_in_stock ) {
			$track = (int) self::field( $pid, 'track_inventory', $product );
			$qty   = (int) self::field( $pid, 'stock_quantity', $product );
			$lowmax = max( 1, (int) self::sget( 'lowstock_max', 5 ) );
			if ( self::sbool( 'lowstock_enabled', false ) && 1 === $track && $qty > 0 && $qty <= $lowmax ) {
				$tpl   = (string) self::sget( 'lowstock_label', 'Only {qty} left' );
				$label = ( false !== strpos( $tpl, '{qty}' ) ) ? str_replace( '{qty}', (string) $qty, $tpl ) : $tpl;
				$ribbons[] = [
					'type'  => 'lowstock',
					'label' => '' !== trim( $label ) ? $label : 'Low Stock',
					'color' => self::color( 'lowstock_color', '#D97706' ),
				];
			} elseif ( self::sbool( 'instock_enabled', false ) ) {
				$ribbons[] = [
					'type'  => 'instock',
					'label' => (string) self::sget( 'instock_label', 'In Stock' ),
					'color' => self::color( 'instock_color', '#16A34A' ),
				];
			}
		} else {
			$oos_action = (string) self::field( $pid, 'out_of_stock_actions', $product );
			if ( self::sbool( 'preorder_enabled', false ) && 'buy' === $oos_action ) {
				$ribbons[] = [
					'type'  => 'preorder',
					'label' => (string) self::sget( 'preorder_label', 'Preorder' ),
					'color' => self::color( 'preorder_color', '#2563EB' ),
				];
			} elseif ( self::sbool( 'outofstock_enabled', false ) ) {
				$ribbons[] = [
					'type'  => 'outofstock',
					'label' => (string) self::sget( 'outofstock_label', 'Sold Out' ),
					'color' => self::color( 'outofstock_color', '#6B7280' ),
				];
			}
		}

		return $ribbons;
	}

	/**
	 * Render the ribbon stack HTML for a surface. Returns '' when no ribbon
	 * applies (so a surface can echo unconditionally).
	 *
	 * @param array|object $product
	 * @param bool|null    $on_sale
	 */
	public static function render( $product, ?bool $on_sale = null ): string {
		$ribbons = self::compute( $product, $on_sale );
		if ( empty( $ribbons ) ) {
			return '';
		}

		$pos   = ( (string) self::sget( 'position', 'left' ) === 'right' ) ? 'right' : 'left';
		$shape = (string) self::sget( 'shape', 'rounded' );
		$shape = in_array( $shape, [ 'rounded', 'square', 'corner' ], true ) ? $shape : 'rounded';

		$out = '<div class="tc-ribbons tc-ribbons--' . esc_attr( $pos ) . ' tc-ribbons--' . esc_attr( $shape ) . '">';
		foreach ( $ribbons as $r ) {
			$out .= '<span class="tc-ribbon tc-ribbon--' . esc_attr( $r['type'] ) . '" style="background:' . esc_attr( $r['color'] ) . ';">'
				. esc_html( $r['label'] ) . '</span>';
		}
		return $out . '</div>';
	}

	/** True when at least one ribbon type is enabled (gate for CSS output). */
	public static function any_enabled(): bool {
		return self::sbool( 'sale_enabled', true )
			|| self::sbool( 'new_enabled', false )
			|| self::sbool( 'featured_enabled', false )
			|| self::sbool( 'bestseller_enabled', false )
			|| self::sbool( 'toprated_enabled', false )
			|| self::sbool( 'instock_enabled', false )
			|| self::sbool( 'lowstock_enabled', false )
			|| self::sbool( 'outofstock_enabled', false )
			|| self::sbool( 'preorder_enabled', false );
	}

	/**
	 * Enqueue the static ribbon CSS. Positions the .tc-ribbons stack
	 * inside each surface's image wrapper and hides each surface's NATIVE
	 * sale/new badge so this feature owns one coherent stack. Stock badges
	 * (.product-card__stock-badge, .tc-XX-badge--stock/--out) are intentionally
	 * left intact. Hooked sitewide on wp_enqueue_scripts (WP.org R1 🅱1: was a
	 * raw style-tag echo on wp_head) so it reaches theme homepages (which render
	 * via their own card functions / preset layouts), shop, and the PDP
	 * regardless of which stylesheet a surface enqueues.
	 */
	public static function output_css(): void {
		if ( ! self::any_enabled() ) {
			return;
		}
		wp_register_style( 'titancart-ribbons', false, [], TITANCART_VERSION );
		wp_enqueue_style( 'titancart-ribbons' );
		wp_add_inline_style( 'titancart-ribbons', self::css() );
	}

	/** The static ribbon CSS (no user data interpolated; colours are inline per span). */
	public static function css(): string {
		return
			// Image wrappers across every wired surface become the positioning context.
			'.product-card__image-wrap,'
			. '.tc-ap-card__media,.tc-arc-card__media,.tc-fire-card__media,.tc-cr-card__media,.tc-elec-card__media,.tc-pod-card__media,'
			. '.tc-pdp__main-img-wrap{position:relative}'
			// Hide the core card/PDP native sale badge. The per-preset native
			// badges have been removed entirely; Product Ribbons is now the single
			// badge system. .product-card__badge may still be emitted by shop.js
			// AJAX-rendered cards, so the hide is retained.
			. '.product-card__badge,.tc-pdp__badge--sale{display:none!important}'
			// The unified stack.
			. '.tc-ribbons{position:absolute;top:10px;z-index:5;display:flex;flex-direction:column;gap:5px;pointer-events:none}'
			. '.tc-ribbons--left{left:10px;align-items:flex-start}'
			. '.tc-ribbons--right{right:10px;align-items:flex-end}'
			. '.tc-ribbon{display:inline-block;color:#fff;font-size:11px;font-weight:700;line-height:1;letter-spacing:.02em;padding:5px 9px;text-transform:uppercase;box-shadow:0 1px 3px rgba(0,0,0,.18);white-space:nowrap}'
			. '.tc-ribbons--rounded .tc-ribbon{border-radius:999px}'
			. '.tc-ribbons--square .tc-ribbon{border-radius:3px}'
			. '.tc-ribbons--corner .tc-ribbon{border-radius:0}'
			. '.tc-ribbons--corner.tc-ribbons--left .tc-ribbon{clip-path:polygon(0 0,100% 0,calc(100% - 8px) 100%,0 100%)}'
			. '.tc-ribbons--corner.tc-ribbons--right .tc-ribbon{clip-path:polygon(8px 0,100% 0,100% 100%,0 100%)}';
	}

	/* ======================================================================
	 * HELPERS
	 * ====================================================================== */

	/**
	 * Read a product field from the surface row, lazily fetching it by id
	 * (memoized) when the row does not carry it. Only the three rule fields
	 * (created_at / featured / sales_count) are ever fetched, and only when a
	 * rule that needs them is enabled (callers gate on the setting first).
	 *
	 * @param int    $pid
	 * @param string $key
	 * @param array  $product
	 * @return mixed
	 */
	private static function field( int $pid, string $key, array $product ) {
		if ( array_key_exists( $key, $product ) && null !== $product[ $key ] && '' !== $product[ $key ] ) {
			return $product[ $key ];
		}
		if ( $pid <= 0 || ! function_exists( 'titancart_db' ) ) {
			return null;
		}
		if ( ! isset( self::$cache[ $pid ] ) ) {
			$db  = titancart_db();
			$row = $db->getRow(
				"SELECT created_at, featured, sales_count, stock_status, stock_quantity, out_of_stock_actions, track_inventory, rating_avg, rating_count FROM {$db->table( 'products' )} WHERE id = ?i LIMIT 1",
				$pid
			);
			self::$cache[ $pid ] = is_array( $row ) ? $row : [ 'created_at' => null, 'featured' => 0, 'sales_count' => 0, 'stock_status' => 'in_stock', 'stock_quantity' => 0, 'out_of_stock_actions' => 'none', 'track_inventory' => 1, 'rating_avg' => 0, 'rating_count' => 0 ];
		}
		return self::$cache[ $pid ][ $key ] ?? null;
	}

	/** Normalize a row to an associative array (tolerates stdClass). */
	private static function normalize( $product ): array {
		if ( is_object( $product ) ) {
			return get_object_vars( $product );
		}
		return is_array( $product ) ? $product : [];
	}

	/** Read a ribbon string setting (stored in the 'appearance' section, 'ribbon_' prefix). */
	private static function sget( string $key, $default ) {
		return SettingsService::get( 'appearance', 'ribbon_' . $key, $default );
	}

	/** Read a ribbon boolean setting (stored in the 'appearance' section, 'ribbon_' prefix). */
	private static function sbool( string $key, bool $default ): bool {
		return SettingsService::bool( 'appearance', 'ribbon_' . $key, $default );
	}

	/** Validate a #RRGGBB setting, returning the fallback when missing/invalid. */
	private static function color( string $key, string $fallback ): string {
		$v = trim( (string) self::sget( $key, $fallback ) );
		return preg_match( '/^#[0-9a-fA-F]{6}$/', $v ) ? $v : $fallback;
	}
}
