<?php
/**
 * Manual saved-product categories and tags helper.
 *
 * @package Affilun\Application\Product
 */

declare(strict_types=1);

namespace Affilun\Application\Product;

use Affilun\Infrastructure\Persistence\ProductMetaRecord;
use Affilun\Infrastructure\Persistence\ProductMetaRepository;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Stores editor-managed saved-product categories/tags in product meta.
 */
final class ProductManualTaxonomy {

	/**
	 * Meta key for saved-product categories.
	 */
	public const META_KEY_CATEGORIES = 'manual_categories';

	/**
	 * Meta key for saved-product tags.
	 */
	public const META_KEY_TAGS = 'manual_tags';

	/**
	 * Maximum visible term length.
	 */
	private const MAX_TERM_LENGTH = 64;

	/**
	 * Product meta repository.
	 */
	private ProductMetaRepository $repository;

	/**
	 * @param ProductMetaRepository $repository Product meta repository.
	 */
	public function __construct( ProductMetaRepository $repository ) {
		$this->repository = $repository;
	}

	/**
	 * Read categories for a product.
	 *
	 * @param int $productId Product id.
	 *
	 * @return list<string>
	 */
	public function categoriesForProduct( int $productId ): array {
		return $this->termsForProduct( $productId, self::META_KEY_CATEGORIES );
	}

	/**
	 * Read tags for a product.
	 *
	 * @param int $productId Product id.
	 *
	 * @return list<string>
	 */
	public function tagsForProduct( int $productId ): array {
		return $this->termsForProduct( $productId, self::META_KEY_TAGS );
	}

	/**
	 * Save categories for a product.
	 *
	 * @param int          $productId Product id.
	 * @param list<string> $terms     Terms.
	 */
	public function saveCategoriesForProduct( int $productId, array $terms ): void {
		$this->saveTermsForProduct( $productId, self::META_KEY_CATEGORIES, $terms );
	}

	/**
	 * Save tags for a product.
	 *
	 * @param int          $productId Product id.
	 * @param list<string> $terms     Terms.
	 */
	public function saveTagsForProduct( int $productId, array $terms ): void {
		$this->saveTermsForProduct( $productId, self::META_KEY_TAGS, $terms );
	}

	/**
	 * Whether a product has the requested category.
	 *
	 * @param int    $productId Product id.
	 * @param string $category  Category to check.
	 */
	public function productHasCategory( int $productId, string $category ): bool {
		$needle = self::normalizeComparableTerm( $category );
		if ( $needle === '' ) {
			return false;
		}

		foreach ( $this->categoriesForProduct( $productId ) as $term ) {
			if ( self::normalizeComparableTerm( $term ) === $needle ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Convert a form value to a normalized term list.
	 *
	 * @param mixed $input Form/list input.
	 *
	 * @return list<string>
	 */
	public static function termsFromInput( mixed $input ): array {
		$rawTerms = array();
		if ( is_array( $input ) ) {
			foreach ( $input as $value ) {
				if ( is_scalar( $value ) ) {
					$rawTerms[] = (string) $value;
				}
			}
		} elseif ( is_scalar( $input ) ) {
			$rawTerms = (array) preg_split( '/[\r\n,]+/u', (string) $input );
		}

		$terms = array();
		$seen  = array();
		foreach ( $rawTerms as $rawTerm ) {
			$term = self::sanitizeTerm( (string) $rawTerm );
			if ( $term === '' ) {
				continue;
			}

			$key = self::normalizeComparableTerm( $term );
			if ( array_key_exists( $key, $seen ) ) {
				continue;
			}

			$seen[ $key ] = true;
			$terms[]      = $term;
		}

		return $terms;
	}

	/**
	 * Join terms for editor display.
	 *
	 * @param list<string> $terms Terms.
	 */
	public static function termsToDisplayString( array $terms ): string {
		return implode( ', ', self::termsFromInput( $terms ) );
	}

	/**
	 * Read terms by meta key.
	 *
	 * @param int    $productId Product id.
	 * @param string $metaKey   Meta key.
	 *
	 * @return list<string>
	 *
	 * @throws InvalidArgumentException When product id is invalid.
	 */
	private function termsForProduct( int $productId, string $metaKey ): array {
		if ( $productId <= 0 ) {
			throw new InvalidArgumentException( 'Product id must be positive.' );
		}

		$record = $this->repository->findByProductAndKey( $productId, $metaKey );
		return $record === null ? array() : self::decodeTerms( $record->metaValue() );
	}

	/**
	 * Save terms by meta key.
	 *
	 * @param int          $productId Product id.
	 * @param string       $metaKey   Meta key.
	 * @param list<string> $terms     Terms.
	 *
	 * @throws InvalidArgumentException When product id is invalid.
	 */
	private function saveTermsForProduct( int $productId, string $metaKey, array $terms ): void {
		if ( $productId <= 0 ) {
			throw new InvalidArgumentException( 'Product id must be positive.' );
		}

		$now      = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
		$existing = $this->repository->findByProductAndKey( $productId, $metaKey );
		$value    = self::encodeTerms( self::termsFromInput( $terms ) );

		if ( $existing === null ) {
			$this->repository->save(
				ProductMetaRecord::createNew(
					$productId,
					$metaKey,
					$now,
					array( 'metaValue' => $value )
				)
			);
			return;
		}

		$this->repository->save( $existing->withMetaValue( $value )->withUpdatedAt( $now ) );
	}

	/**
	 * Decode stored JSON terms.
	 *
	 * @param string|null $encoded Encoded terms.
	 *
	 * @return list<string>
	 */
	private static function decodeTerms( ?string $encoded ): array {
		if ( $encoded === null || trim( $encoded ) === '' ) {
			return array();
		}

		$decoded = json_decode( $encoded, true );
		if ( is_array( $decoded ) ) {
			return self::termsFromInput( $decoded );
		}

		return self::termsFromInput( $encoded );
	}

	/**
	 * Encode normalized terms.
	 *
	 * @param list<string> $terms Terms.
	 */
	private static function encodeTerms( array $terms ): string {
		if ( function_exists( 'wp_json_encode' ) ) {
			$encoded = wp_json_encode( $terms );
		} else {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit fallback when WordPress is unavailable.
			$encoded = json_encode( $terms );
		}

		return is_string( $encoded ) ? $encoded : '[]';
	}

	/**
	 * Sanitize one term.
	 *
	 * @param string $term Raw term.
	 */
	private static function sanitizeTerm( string $term ): string {
		if ( function_exists( 'sanitize_text_field' ) ) {
			$term = (string) sanitize_text_field( $term );
		} elseif ( function_exists( 'wp_strip_all_tags' ) ) {
			$term = (string) wp_strip_all_tags( $term );
		} else {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- Pure-unit fallback when WordPress is unavailable.
			$term = strip_tags( $term );
		}

		$term = (string) preg_replace( '/\s+/u', ' ', $term );
		$term = trim( $term );
		if ( $term === '' ) {
			return '';
		}

		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
			return mb_strlen( $term ) > self::MAX_TERM_LENGTH
				? mb_substr( $term, 0, self::MAX_TERM_LENGTH )
				: $term;
		}

		return strlen( $term ) > self::MAX_TERM_LENGTH ? substr( $term, 0, self::MAX_TERM_LENGTH ) : $term;
	}

	/**
	 * Comparable normalized term key.
	 *
	 * @param string $term Raw term.
	 */
	private static function normalizeComparableTerm( string $term ): string {
		$term = self::sanitizeTerm( $term );
		return function_exists( 'mb_strtolower' ) ? mb_strtolower( $term ) : strtolower( $term );
	}
}
