<?php
/**
 * Product meta keys and accessors.
 *
 * @package WPDudeProducts
 */

declare(strict_types=1);

namespace WPDude\Products\Products;

use WPDude\Products\Settings\Settings;
use WPDude\Products\Support\Currencies;
use WPDude\Products\Support\Money;

/**
 * Product configuration stored as post meta.
 */
final class Product_Meta {

	public const TYPE_STANDARD = 'standard';
	public const TYPE_OPTIONS  = 'options';

	public const SHIPPING_FREE = 'free';
	public const SHIPPING_FLAT = 'flat';

	public const MAX_OPTIONS = 5;

	/**
	 * Supported product types.
	 *
	 * @return list<string>
	 */
	public static function supported_types(): array {
		return array( self::TYPE_STANDARD, self::TYPE_OPTIONS );
	}

	/**
	 * Meta key map.
	 *
	 * @return array<string, string>
	 */
	public static function keys(): array {
		return array(
			'product_type'      => '_wpdude_dc_product_type',
			'currency'          => '_wpdude_dc_currency',
			'price'             => '_wpdude_dc_price', // minor units.
			'sale_price'        => '_wpdude_dc_sale_price', // minor units; 0 = none.
			'sku'               => '_wpdude_dc_sku',
			'stock'             => '_wpdude_dc_stock',
			'sold_individually' => '_wpdude_dc_sold_individually',
			'shipping_type'     => '_wpdude_dc_shipping_type',
			'shipping_amount'   => '_wpdude_dc_shipping_amount', // minor units.
			'gallery'           => '_wpdude_dc_gallery', // array of attachment IDs.
			'options'           => '_wpdude_dc_options', // list of {name, values: [{value, image_id}]} (image_id unused; images live on combinations).
			'combinations'      => '_wpdude_dc_combinations', // list of combination rows including image_id.
		);
	}

	/**
	 * Get meta with default.
	 *
	 * @param int    $product_id Product ID.
	 * @param string $key        Logical key.
	 * @param mixed  $default    Default.
	 * @return mixed
	 */
	public static function get( int $product_id, string $key, $default = '' ) {
		$keys = self::keys();
		if ( ! isset( $keys[ $key ] ) ) {
			return $default;
		}
		$value = get_post_meta( $product_id, $keys[ $key ], true );
		return ( '' === $value || false === $value ) ? $default : $value;
	}

	/**
	 * Update meta.
	 *
	 * @param int    $product_id Product ID.
	 * @param string $key        Logical key.
	 * @param mixed  $value      Value.
	 */
	public static function update( int $product_id, string $key, $value ): void {
		$keys = self::keys();
		if ( ! isset( $keys[ $key ] ) ) {
			return;
		}
		update_post_meta( $product_id, $keys[ $key ], $value );
	}

	/**
	 * Product type.
	 */
	public static function get_type( int $product_id ): string {
		$type = (string) self::get( $product_id, 'product_type', self::TYPE_STANDARD );
		// Early Phase 1 aliases (never ship Woo terminology going forward).
		if ( 'simple' === $type ) {
			$type = self::TYPE_STANDARD;
		}
		if ( 'variable' === $type ) {
			$type = self::TYPE_OPTIONS;
		}
		return in_array( $type, self::supported_types(), true ) ? $type : self::TYPE_STANDARD;
	}

	/**
	 * Price in minor units (standard products).
	 */
	public static function get_price_minor( int $product_id ): int {
		return max( 0, (int) self::get( $product_id, 'price', 0 ) );
	}

	/**
	 * Sale price in minor units; 0 means none.
	 */
	public static function get_sale_price_minor( int $product_id ): int {
		return max( 0, (int) self::get( $product_id, 'sale_price', 0 ) );
	}

	/**
	 * Effective selling price for a standard product.
	 */
	public static function get_effective_price_minor( int $product_id ): int {
		$price = self::get_price_minor( $product_id );
		$sale  = self::get_sale_price_minor( $product_id );
		if ( $sale > 0 && $sale < $price ) {
			return $sale;
		}
		return $price;
	}

	/**
	 * Whether a standard product currently has an active sale price.
	 */
	public static function is_on_sale( int $product_id ): bool {
		$price = self::get_price_minor( $product_id );
		$sale  = self::get_sale_price_minor( $product_id );
		return $sale > 0 && $sale < $price;
	}

	/**
	 * Whether a combination row has an active sale price.
	 *
	 * @param array<string, mixed> $combo Combination.
	 */
	public static function combination_is_on_sale( array $combo ): bool {
		$price = max( 0, (int) ( $combo['price'] ?? 0 ) );
		$sale  = max( 0, (int) ( $combo['sale_price'] ?? 0 ) );
		return $sale > 0 && $sale < $price;
	}

	/**
	 * Whether any combination on an options product is on sale.
	 */
	public static function has_combination_on_sale( int $product_id ): bool {
		foreach ( self::get_combinations( $product_id ) as $combo ) {
			if ( self::combination_is_on_sale( $combo ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * SKU.
	 */
	public static function get_sku( int $product_id ): string {
		return (string) self::get( $product_id, 'sku', '' );
	}

	/**
	 * Normalize a SKU for storage.
	 */
	public static function normalize_sku( string $sku ): string {
		return sanitize_text_field( trim( $sku ) );
	}

	/**
	 * Case-insensitive key for SKU comparison.
	 */
	public static function sku_key( string $sku ): string {
		return strtolower( self::normalize_sku( $sku ) );
	}

	/**
	 * Map of existing SKUs across products (lowercase key => product ID).
	 *
	 * Includes standard product SKUs and combination SKUs. Empty SKUs are omitted.
	 *
	 * @return array<string, int>
	 */
	public static function map_existing_skus( int $exclude_product_id = 0 ): array {
		$ids = get_posts(
			array(
				'post_type'              => Post_Type::POST_TYPE,
				'post_status'            => array( 'publish', 'draft', 'pending', 'private', 'future' ),
				'posts_per_page'         => -1,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => true,
				'update_post_term_cache' => false,
			)
		);

		$map = array();
		foreach ( $ids as $id ) {
			$id = (int) $id;
			if ( $exclude_product_id > 0 && $id === $exclude_product_id ) {
				continue;
			}

			$sku = self::normalize_sku( self::get_sku( $id ) );
			if ( '' !== $sku ) {
				$map[ self::sku_key( $sku ) ] = $id;
			}

			foreach ( self::get_combinations( $id ) as $combination ) {
				$combo_sku = self::normalize_sku( (string) ( $combination['sku'] ?? '' ) );
				if ( '' !== $combo_sku ) {
					$map[ self::sku_key( $combo_sku ) ] = $id;
				}
			}
		}

		return $map;
	}

	/**
	 * Enforce uniqueness for a list of proposed SKUs.
	 *
	 * First occurrence wins within the list. Conflicts against $existing_keys or
	 * later duplicates are replaced with an empty string.
	 *
	 * @param list<string>       $proposed      Proposed SKUs in order.
	 * @param array<string, int> $existing_keys Lowercase sku => owner product ID.
	 * @return array{skus: list<string>, conflicts: list<string>}
	 */
	public static function resolve_unique_skus( array $proposed, array $existing_keys ): array {
		$seen      = array();
		$skus      = array();
		$conflicts = array();

		foreach ( $proposed as $sku ) {
			$sku = self::normalize_sku( (string) $sku );
			if ( '' === $sku ) {
				$skus[] = '';
				continue;
			}

			$key = self::sku_key( $sku );
			if ( isset( $existing_keys[ $key ] ) || isset( $seen[ $key ] ) ) {
				$conflicts[] = $sku;
				$skus[]      = '';
				continue;
			}

			$seen[ $key ] = true;
			$skus[]       = $sku;
		}

		return array(
			'skus'      => $skus,
			'conflicts' => array_values( array_unique( $conflicts ) ),
		);
	}

	/**
	 * Stock quantity.
	 */
	public static function get_stock( int $product_id ): int {
		return (int) self::get( $product_id, 'stock', 0 );
	}

	/**
	 * Whether sold individually.
	 */
	public static function is_sold_individually( int $product_id ): bool {
		return 'yes' === (string) self::get( $product_id, 'sold_individually', 'no' );
	}

	/**
	 * Shipping type.
	 */
	public static function get_shipping_type( int $product_id ): string {
		$type = (string) self::get( $product_id, 'shipping_type', self::SHIPPING_FREE );
		return in_array( $type, array( self::SHIPPING_FREE, self::SHIPPING_FLAT ), true )
			? $type
			: self::SHIPPING_FREE;
	}

	/**
	 * Flat shipping amount in minor units (per order).
	 */
	public static function get_shipping_amount_minor( int $product_id ): int {
		return max( 0, (int) self::get( $product_id, 'shipping_amount', 0 ) );
	}

	/**
	 * Gallery attachment IDs.
	 *
	 * @return list<int>
	 */
	public static function get_gallery( int $product_id ): array {
		$raw = self::get( $product_id, 'gallery', array() );
		if ( ! is_array( $raw ) ) {
			return array();
		}
		$ids = array();
		foreach ( $raw as $id ) {
			$id = absint( $id );
			if ( $id > 0 ) {
				$ids[] = $id;
			}
		}
		return array_values( array_unique( $ids ) );
	}

	/**
	 * Option definitions for an options product (e.g. Size → S, M, L).
	 *
	 * Each value is { value: string, image_id: int }. image_id is optional (0 = none).
	 *
	 * @return list<array{name: string, values: list<array{value: string, image_id: int}>}>
	 */
	public static function get_options( int $product_id ): array {
		$raw = self::get( $product_id, 'options', array() );
		if ( ! is_array( $raw ) || empty( $raw ) ) {
			$legacy = get_post_meta( $product_id, '_wpdude_dc_attributes', true );
			$raw    = is_array( $legacy ) ? $legacy : array();
		}
		if ( ! is_array( $raw ) ) {
			return array();
		}

		$options = array();
		foreach ( $raw as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$name = sanitize_text_field( (string) ( $row['name'] ?? '' ) );
			if ( '' === $name ) {
				continue;
			}
			$values_raw = $row['values'] ?? array();
			if ( ! is_array( $values_raw ) ) {
				$values_raw = array();
			}
			$values = array();
			$seen   = array();
			foreach ( $values_raw as $entry ) {
				$normalized = self::normalize_option_value( $entry );
				if ( null === $normalized ) {
					continue;
				}
				$key = strtolower( $normalized['value'] );
				if ( isset( $seen[ $key ] ) ) {
					continue;
				}
				$seen[ $key ] = true;
				$values[]     = $normalized;
			}
			if ( empty( $values ) ) {
				continue;
			}
			$options[] = array(
				'name'   => $name,
				'values' => $values,
			);
			if ( count( $options ) >= self::MAX_OPTIONS ) {
				break;
			}
		}

		return $options;
	}

	/**
	 * Normalize a stored option value entry.
	 *
	 * @param mixed $entry String label or {value, image_id}.
	 * @return array{value: string, image_id: int}|null
	 */
	public static function normalize_option_value( $entry ): ?array {
		if ( is_array( $entry ) ) {
			$value = sanitize_text_field( (string) ( $entry['value'] ?? $entry['label'] ?? '' ) );
			if ( '' === $value ) {
				return null;
			}
			return array(
				'value'    => $value,
				'image_id' => absint( $entry['image_id'] ?? 0 ),
			);
		}

		$value = sanitize_text_field( (string) $entry );
		if ( '' === $value ) {
			return null;
		}
		return array(
			'value'    => $value,
			'image_id' => 0,
		);
	}

	/**
	 * Value labels for combination generation.
	 *
	 * @param array{name?: string, values?: list<mixed>} $option Option definition.
	 * @return list<string>
	 */
	public static function option_value_labels( array $option ): array {
		$labels = array();
		$raw    = $option['values'] ?? array();
		if ( ! is_array( $raw ) ) {
			return array();
		}
		foreach ( $raw as $entry ) {
			$normalized = self::normalize_option_value( $entry );
			if ( null !== $normalized ) {
				$labels[] = $normalized['value'];
			}
		}
		return array_values( array_unique( $labels ) );
	}

	/**
	 * Attachment ID for an option value image, or 0.
	 */
	public static function get_option_value_image_id( int $product_id, string $option_name, string $value ): int {
		$option_name = sanitize_text_field( $option_name );
		$value       = sanitize_text_field( $value );
		foreach ( self::get_options( $product_id ) as $option ) {
			if ( strtolower( $option['name'] ) !== strtolower( $option_name ) ) {
				continue;
			}
			foreach ( $option['values'] as $entry ) {
				if ( strtolower( $entry['value'] ) === strtolower( $value ) ) {
					return (int) $entry['image_id'];
				}
			}
		}
		return 0;
	}

	/**
	 * Combination rows (SKU / price / stock per option-value mix).
	 *
	 * @return list<array<string, mixed>>
	 */
	public static function get_combinations( int $product_id ): array {
		$raw = self::get( $product_id, 'combinations', array() );
		if ( ! is_array( $raw ) || empty( $raw ) ) {
			$legacy_options = get_post_meta( $product_id, '_wpdude_dc_options', true );
			// Only treat as combination rows if first item looks like a combination (has key/price), not an option definition.
			if ( is_array( $legacy_options ) && isset( $legacy_options[0] ) && is_array( $legacy_options[0] ) && ( isset( $legacy_options[0]['key'] ) || isset( $legacy_options[0]['price'] ) ) ) {
				$raw = $legacy_options;
			} else {
				$legacy_var = get_post_meta( $product_id, '_wpdude_dc_variations', true );
				$raw        = is_array( $legacy_var ) ? $legacy_var : array();
			}
		}
		if ( ! is_array( $raw ) ) {
			return array();
		}

		$combinations = array();
		foreach ( $raw as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$opts = array();
			if ( isset( $row['options'] ) && is_array( $row['options'] ) ) {
				foreach ( $row['options'] as $opt_name => $opt_value ) {
					$opts[ sanitize_text_field( (string) $opt_name ) ] = sanitize_text_field( (string) $opt_value );
				}
			} elseif ( isset( $row['attributes'] ) && is_array( $row['attributes'] ) ) {
				foreach ( $row['attributes'] as $opt_name => $opt_value ) {
					$opts[ sanitize_text_field( (string) $opt_name ) ] = sanitize_text_field( (string) $opt_value );
				}
			}
			$combinations[] = array(
				'key'        => sanitize_text_field( (string) ( $row['key'] ?? self::combination_key( $opts ) ) ),
				'options'    => $opts,
				'sku'        => sanitize_text_field( (string) ( $row['sku'] ?? '' ) ),
				'price'      => max( 0, (int) ( $row['price'] ?? 0 ) ),
				'sale_price' => max( 0, (int) ( $row['sale_price'] ?? 0 ) ),
				'stock'      => (int) ( $row['stock'] ?? 0 ),
				'image_id'   => absint( $row['image_id'] ?? 0 ),
			);
		}

		return $combinations;
	}

	/**
	 * Build a stable combination key from option name => value map.
	 *
	 * @param array<string, string> $options Option name => value.
	 */
	public static function combination_key( array $options ): string {
		ksort( $options );
		$parts = array();
		foreach ( $options as $name => $value ) {
			$parts[] = strtolower( $name ) . '=' . strtolower( $value );
		}
		return implode( '|', $parts );
	}

	/**
	 * Generate all option-value combinations.
	 *
	 * @param list<array{name: string, values: list<mixed>}> $options Option definitions.
	 * @return list<array<string, string>>
	 */
	public static function generate_combinations( array $options ): array {
		if ( empty( $options ) ) {
			return array();
		}

		$combinations = array( array() );
		foreach ( $options as $option ) {
			$name   = (string) ( $option['name'] ?? '' );
			$values = self::option_value_labels( $option );
			if ( '' === $name || empty( $values ) ) {
				continue;
			}
			$next = array();
			foreach ( $combinations as $combo ) {
				foreach ( $values as $value ) {
					$row          = $combo;
					$row[ $name ] = $value;
					$next[]       = $row;
				}
			}
			$combinations = $next;
		}

		if ( array( array() ) === $combinations ) {
			return array();
		}

		return $combinations;
	}

	/**
	 * Merge generated combinations with existing combination data.
	 *
	 * @param list<array{name: string, values: list<mixed>}> $options  Option definitions.
	 * @param list<array<string, mixed>>                     $existing Existing combinations.
	 * @return list<array<string, mixed>>
	 */
	public static function merge_combinations( array $options, array $existing ): array {
		$by_key = array();
		foreach ( $existing as $row ) {
			$key = (string) ( $row['key'] ?? '' );
			if ( '' !== $key ) {
				$by_key[ $key ] = $row;
			}
		}

		$merged = array();
		foreach ( self::generate_combinations( $options ) as $combo ) {
			$key      = self::combination_key( $combo );
			$previous = $by_key[ $key ] ?? array();
			$merged[] = array(
				'key'        => $key,
				'options'    => $combo,
				'sku'        => sanitize_text_field( (string) ( $previous['sku'] ?? '' ) ),
				'price'      => max( 0, (int) ( $previous['price'] ?? 0 ) ),
				'sale_price' => max( 0, (int) ( $previous['sale_price'] ?? 0 ) ),
				'stock'      => (int) ( $previous['stock'] ?? 0 ),
				'image_id'   => absint( $previous['image_id'] ?? 0 ),
			);
		}

		return $merged;
	}

	/**
	 * Format minor units as major decimal string for inputs.
	 */
	public static function minor_to_major_input( int $minor, string $currency ): string {
		if ( $minor <= 0 ) {
			return '';
		}
		if ( Money::is_zero_decimal( $currency ) ) {
			return (string) $minor;
		}
		return number_format( $minor / 100, 2, '.', '' );
	}

	/**
	 * Product currency (falls back to store default).
	 */
	public static function get_currency( int $product_id ): string {
		$currency = strtolower( (string) self::get( $product_id, 'currency', '' ) );
		if ( '' === $currency ) {
			return self::get_store_currency();
		}
		return Currencies::sanitize( $currency, self::get_store_currency() );
	}

	/**
	 * Store default currency (settings).
	 */
	public static function get_store_currency(): string {
		return Settings::get_currency();
	}
}
