<?php
/**
 * Import existing affiliate-link product data into affilun products.
 *
 * @package Affilun\Application\Migration
 */

declare(strict_types=1);

namespace Affilun\Application\Migration;

use Affilun\Application\Product\UseCase\SaveProductUseCase;
use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\ProviderProductId;
use Affilun\Domain\Clock;
use Affilun\Infrastructure\Persistence\ProductRepository;
use DateTimeImmutable;
use InvalidArgumentException;

/**
 * Scans WordPress content rows supplied by the infrastructure layer and imports
 * product-link block payloads that affilun can normalize safely.
 */
final class ExternalAffiliateProductImportUseCase {

	/**
	 * Product repository used for duplicate checks and upserts.
	 */
	private ProductRepository $repository;

	/**
	 * Clock used for imported snapshot timestamps.
	 */
	private Clock $clock;

	/**
	 * @var callable(): list<array{id:int|string, title:string, content:string}>
	 */
	private $postRowsProvider;

	/**
	 * Construct the use case.
	 *
	 * @param ProductRepository $repository       Product repository.
	 * @param Clock             $clock            Clock for import timestamps.
	 * @param callable          $postRowsProvider Supplies candidate post rows.
	 *
	 * @phpstan-param callable(): list<array{id:int|string, title:string, content:string}> $postRowsProvider
	 */
	public function __construct(
		ProductRepository $repository,
		Clock $clock,
		callable $postRowsProvider
	) {
		$this->repository       = $repository;
		$this->clock            = $clock;
		$this->postRowsProvider = $postRowsProvider;
	}

	/**
	 * Count detected products, including products already present in affilun.
	 */
	public function detectedCount(): int {
		return count( $this->detectCandidates() );
	}

	/**
	 * Count detected products that are not already present in affilun.
	 */
	public function pendingCount(): int {
		return count( $this->pendingCandidates() );
	}

	/**
	 * Import detected products that are not already present in affilun.
	 */
	public function import(): ExternalAffiliateProductImportResult {
		$candidates = $this->detectCandidates();
		$save       = new SaveProductUseCase( $this->repository, $this->clock );
		$imported   = 0;
		$updated    = 0;

		foreach ( $candidates as $candidate ) {
			$result = $save->execute( $this->toSnapshot( $candidate ) );
			if ( $result->created() ) {
				++$imported;
			} else {
				++$updated;
			}
		}

		return new ExternalAffiliateProductImportResult( count( $candidates ), $imported, $updated );
	}

	/**
	 * Return unique detected products that do not already exist in affilun.
	 *
	 * @return list<ExternalAffiliateProductCandidate>
	 */
	public function pendingCandidates(): array {
		$pending = array();
		foreach ( $this->detectCandidates() as $candidate ) {
			$existing = $this->repository->findByProviderItem(
				$candidate->provider(),
				$candidate->providerItemId()
			);
			if ( $existing !== null ) {
				continue;
			}

			$pending[] = $candidate;
		}

		return $pending;
	}

	/**
	 * Detect all unique importable candidates from supplied post content.
	 *
	 * @return list<ExternalAffiliateProductCandidate>
	 */
	public function detectCandidates(): array {
		$rows       = ( $this->postRowsProvider )();
		$candidates = array();

		foreach ( $rows as $row ) {
			$content = is_string( $row['content'] ?? null ) ? $row['content'] : '';
			foreach ( self::extractCandidatesFromContent( $content ) as $candidate ) {
				$candidates[ $candidate->identityKey() ] = $candidate;
			}
		}

		return array_values( $candidates );
	}

	/**
	 * Extract importable product candidates from a post-content string.
	 *
	 * @param string $content Post content.
	 *
	 * @return list<ExternalAffiliateProductCandidate>
	 */
	public static function extractCandidatesFromContent( string $content ): array {
		if ( $content === '' || ! str_contains( $content, '<!-- wp:' ) ) {
			return array();
		}

		$matchCount = preg_match_all( '/<!--\s+wp:[^\s]+(?:\s+({.*?}))?\s*\/?-->/s', $content, $matches );
		if ( $matchCount === false || $matchCount < 1 ) {
			return array();
		}

		$candidates = array();
		foreach ( $matches[1] as $json ) {
			$attrs = self::decodeBlockAttributes( (string) $json );
			if ( $attrs === null ) {
				continue;
			}

			$candidate = self::candidateFromAttributes( $attrs );
			if ( $candidate !== null ) {
				$candidates[ $candidate->identityKey() ] = $candidate;
			}
		}

		return array_values( $candidates );
	}

	/**
	 * Decode Gutenberg block attributes JSON.
	 *
	 * @param string $json Raw block attributes JSON.
	 *
	 * @return array<string, mixed>|null
	 */
	private static function decodeBlockAttributes( string $json ): ?array {
		$json = trim( $json );
		if ( $json === '' ) {
			return null;
		}

		$decoded = json_decode( $json, true );
		if ( is_array( $decoded ) ) {
			/** @var array<string, mixed> $decoded */
			return $decoded;
		}

		$cleanJson = self::stripInvalidUtf8( $json );
		if ( $cleanJson === $json ) {
			return null;
		}

		$decoded = json_decode( $cleanJson, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}

		/** @var array<string, mixed> $decoded */
		return $decoded;
	}

	/**
	 * Strip invalid UTF-8 so legacy content can still be decoded when possible.
	 *
	 * @param string $value Raw string.
	 */
	private static function stripInvalidUtf8( string $value ): string {
		if ( function_exists( 'mb_convert_encoding' ) ) {
			return (string) mb_convert_encoding( $value, 'UTF-8', 'UTF-8' );
		}

		if ( function_exists( 'iconv' ) ) {
			$converted = iconv( 'UTF-8', 'UTF-8//IGNORE', $value );
			return is_string( $converted ) ? $converted : $value;
		}

		return $value;
	}

	/**
	 * @param array<string, mixed> $attrs Block attributes.
	 */
	private static function candidateFromAttributes( array $attrs ): ?ExternalAffiliateProductCandidate {
		$asin  = self::attrString( $attrs, 'asin' );
		$title = self::attrString( $attrs, 'title' );
		if ( $asin === null || $title === null ) {
			return null;
		}

		$asin = strtoupper( preg_replace( '/[^A-Z0-9]/i', '', $asin ) ?? '' );
		if ( $asin === '' ) {
			return null;
		}

		$affiliateUrl = self::validUrlOrNull( self::attrString( $attrs, 'amazon_affi_url' ) );
		$detailUrl    = self::amazonDetailUrl( $asin );
		$imageUrl     = self::validUrlOrNull( self::attrString( $attrs, 'image_url' ) );

		return new ExternalAffiliateProductCandidate(
			'amazon',
			$asin,
			$title,
			self::attrString( $attrs, 'info' ),
			$detailUrl,
			$affiliateUrl,
			$imageUrl,
			self::normalizePriceAmount( self::attrString( $attrs, 'price' ) ),
			$attrs
		);
	}

	/**
	 * Build a canonical Amazon JP detail URL from an ASIN.
	 *
	 * @param string $asin Amazon ASIN.
	 */
	private static function amazonDetailUrl( string $asin ): string {
		return 'https://www.amazon.co.jp/dp/' . rawurlencode( $asin );
	}

	/**
	 * Read a scalar attribute as a trimmed string.
	 *
	 * @param array<string, mixed> $attrs Block attributes.
	 * @param string               $key   Attribute key.
	 */
	private static function attrString( array $attrs, string $key ): ?string {
		if ( ! array_key_exists( $key, $attrs ) || ! is_scalar( $attrs[ $key ] ) ) {
			return null;
		}

		$value = trim( (string) $attrs[ $key ] );
		return $value === '' ? null : $value;
	}

	/**
	 * Return a URL only when it is valid.
	 *
	 * @param string|null $value Candidate URL.
	 */
	private static function validUrlOrNull( ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		return filter_var( $value, FILTER_VALIDATE_URL ) === false ? null : $value;
	}

	/**
	 * Normalize a display price into a decimal amount string.
	 *
	 * @param string|null $value Candidate price.
	 */
	private static function normalizePriceAmount( ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$amount = preg_replace( '/[^\d.]/', '', $value );
		if ( $amount === null || $amount === '' || ! preg_match( '/^\d+(?:\.\d{1,2})?$/', $amount ) ) {
			return null;
		}

		return ltrim( $amount, '0' ) === '' ? '0' : ltrim( $amount, '0' );
	}

	/**
	 * Convert an import candidate to the canonical product snapshot.
	 *
	 * @param ExternalAffiliateProductCandidate $candidate Import candidate.
	 */
	private function toSnapshot( ExternalAffiliateProductCandidate $candidate ): ProductSnapshot {
		$now   = $this->clock->now();
		$price = $candidate->priceAmount() === null
			? null
			: new PriceInfo( $candidate->priceAmount(), 'JPY', null, $now, true, false, null );

		try {
			return new ProductSnapshot(
				$candidate->provider(),
				new ProviderProductId( $candidate->provider(), $candidate->providerItemId() ),
				$candidate->title(),
				$candidate->brand(),
				null,
				$candidate->detailUrl(),
				$candidate->affiliateUrl(),
				$candidate->imageUrl(),
				array(),
				$price,
				AvailabilityInfo::unknown(),
				null,
				null,
				array(),
				array(),
				$now,
				array(
					'import_source' => 'external_affiliate_link_data',
					'attributes'    => $candidate->sourceAttributes(),
				)
			);
		} catch ( InvalidArgumentException $exception ) {
			throw new InvalidArgumentException(
				'Detected affiliate-link product data could not be normalized for import.',
				0,
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing chained exception, never rendered to HTML.
				$exception
			);
		}
	}
}
