<?php
/**
 * Save product use case.
 *
 * Phase 6 Application-layer UseCase that drives the §8.4
 * `POST /affilun/v1/products` endpoint. The class implements the
 * `(provider, provider_item_id)` upsert flow `rest-api-spec.md`
 * §8.4 specifies as the canonical save semantics: a snapshot whose
 * `(provider, providerProductId)` pair matches an existing row
 * updates that row in place (returning `created = false` on the
 * {@see SaveProductResult} envelope so the controller can render
 * the §8.4 200 response shape), while a snapshot whose pair has
 * never been persisted produces a fresh row (returning
 * `created = true` so the controller can render the §8.4 201
 * response shape).
 *
 * Upsert decision flow:
 *
 *   1. Look up the existing row by
 *      {@see ProductRepository::findByProviderItem()} on the
 *      snapshot's `(provider, providerProductId)` pair.
 *
 *   2. Read "now" once from the injected {@see Clock}. The same
 *      timestamp is used for `lastSyncedAt`,
 *      `lastSyncAttemptedAt`, and `updatedAt` (and, on insert,
 *      `createdAt`) so the persisted row reflects a single
 *      consistent observation point — the §6.4 sync-stamping
 *      rule.
 *
 *   3. On insert: build a fresh {@see ProductRecord} carrying
 *      `id = null`, `lifecycleState = active`, `syncState = fresh`,
 *      and the three sync timestamps stamped with the clock value.
 *      `lastSyncErrorCode` / `lastSyncErrorMessage` start null
 *      because the snapshot's existence proves the sync just
 *      succeeded.
 *
 *   4. On update: rebuild the {@see ProductRecord} reusing the
 *      existing row's `id` and `createdAt` (the §4.2 invariant
 *      that `createdAt` is immutable post-insert binds here),
 *      and refresh every other column from the snapshot plus the
 *      "now" timestamp. The §6.4 update-stamping rule binds the
 *      same way as insert: every successful re-fetch refreshes
 *      `lastSyncedAt`, `lastSyncAttemptedAt`, and `updatedAt` to
 *      the clock value.
 *
 *   5. Return {@see SaveProductResult} carrying the post-save
 *      record (always with a non-null id) and the boolean
 *      `created` flag the REST mapper consults.
 *
 * Field mapping from {@see ProductSnapshot}:
 *
 *   - `provider` ← `$snapshot->provider()`.
 *   - `providerItemId` ← `$snapshot->providerProductId()->value()`.
 *   - `canonicalHash` ← computed via the §4.4 pseudocode (`ch1:`
 *     prefix + SHA-256 over normalized title / brand / first
 *     image-url-without-query). The Phase 2 schema requires the
 *     column be non-empty; computing it here keeps the UseCase
 *     deterministic without depending on a yet-to-land Phase 9
 *     hash service.
 *   - `title` ← `$snapshot->title()`.
 *   - `brand` ← `$snapshot->brand()`.
 *   - `description` ← `$snapshot->description()`.
 *   - `imagePrimaryUrl` ← `$snapshot->primaryImage()`.
 *   - `imageGalleryJson` ← JSON-encoded
 *     `$snapshot->galleryImages()` (null when the list is empty).
 *   - `priceAmount` / `priceCurrency` / `priceLabel` ← extracted
 *     from `$snapshot->price()` when non-null; all three null
 *     when the snapshot carries no price.
 *   - `availabilityStatus` ← `$snapshot->availability()->status()`.
 *   - `detailUrl` ← `$snapshot->detailUrl()`.
 *   - `affiliateUrl` ← `$snapshot->affiliateUrl()`.
 *   - `rawPayloadJson` ← null at this layer; the Phase 9 sync
 *     pipeline owns the raw-payload column.
 *   - `snapshotVersion` ← `1` on insert (per §4.2 default), reused
 *     verbatim on update (the §4.2 column has no automatic-bump
 *     contract; future migrations land as separate UseCases).
 *
 * TODO Phase 6 minimum does not handle the optional If-Match
 * conflict (§8.4 mentions an opt-in 409 path for clients that send
 * `If-Match` with an ETag/`updated_at` hash). The Controller will
 * examine the request header and raise outside this UseCase if
 * needed; this UseCase always upserts unconditionally.
 *
 * @package Affilun\Application\Product\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\Product\UseCase;

use Affilun\Application\Product\FallbackProductSearchKeywordsExtractor;
use Affilun\Application\Product\ProductSearchKeywordsExtractor;
use Affilun\Application\Product\ProductUrlDraft;
use Affilun\Domain\Clock;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\ProductUrlRecord;
use Affilun\Infrastructure\Persistence\ProductUrlRepository;

/**
 * Phase 6 UseCase that drives the §8.4 `POST /affilun/v1/products`
 * endpoint.
 *
 * See the file-level docblock for the upsert decision flow, the
 * field-mapping rules from {@see ProductSnapshot} to
 * {@see ProductRecord}, and the §6.4 sync-stamping rule that
 * binds the timestamp columns.
 */
final class SaveProductUseCase {

	/**
	 * Persistence port that backs the §8.4 upsert.
	 */
	private ProductRepository $repository;

	/**
	 * Clock port used to stamp the §6.4 sync timestamps.
	 */
	private Clock $clock;

	/**
	 * Extracts cross-provider search keywords before persistence.
	 */
	private ProductSearchKeywordsExtractor $keywordExtractor;

	/**
	 * Optional child-URL repository for the affilun product ID model.
	 */
	private ?ProductUrlRepository $urlRepository;

	/**
	 * Construct the UseCase with explicit repository and clock
	 * bindings.
	 *
	 * @param ProductRepository                   $repository       Persistence port.
	 * @param Clock                               $clock            Clock port for §6.4 sync timestamps.
	 * @param ProductSearchKeywordsExtractor|null $keywordExtractor Optional search-keyword extractor.
	 * @param ProductUrlRepository|null           $urlRepository    Optional product URL repository.
	 */
	public function __construct(
		ProductRepository $repository,
		Clock $clock,
		?ProductSearchKeywordsExtractor $keywordExtractor = null,
		?ProductUrlRepository $urlRepository = null
	) {
		$this->repository       = $repository;
		$this->clock            = $clock;
		$this->keywordExtractor = $keywordExtractor ?? new FallbackProductSearchKeywordsExtractor();
		$this->urlRepository    = $urlRepository;
	}

	/**
	 * Execute the §8.4 upsert dispatch.
	 *
	 * Looks up the existing row by
	 * `(provider, providerProductId)`; on hit, rebuilds the
	 * matching {@see ProductRecord} preserving `id` and
	 * `createdAt` while refreshing every other column from the
	 * snapshot and the clock. On miss, builds a fresh record with
	 * `id = null` and saves through the repository to obtain the
	 * assigned auto-increment primary key.
	 *
	 * @param ProductSnapshot  $snapshot Normalized snapshot to
	 *                                   persist.
	 * @param array<int,mixed> $urlDrafts Additional URL drafts.
	 *
	 * @return SaveProductResult Post-save record plus the
	 *                           insert-vs-update flag.
	 */
	public function execute( ProductSnapshot $snapshot, array $urlDrafts = array() ): SaveProductResult {
		$now    = $this->clock->now();
		$drafts = $this->normalizeUrlDrafts( $snapshot, $urlDrafts );

		$existing = $this->findExistingAmazonProduct( $snapshot )
			?? $this->repository->findByProviderItem(
				$snapshot->provider(),
				$snapshot->providerProductId()->value()
			);

		if ( $existing === null ) {
			$record    = $this->buildNewRecord( $snapshot, $now );
			$persisted = $this->repository->save( $record );
			$id        = $persisted->id();
			if ( $id !== null ) {
				$this->persistUrlDrafts( $id, $drafts, $now );
			}

			return new SaveProductResult( $persisted, true );
		}

		$record    = $this->buildUpdatedRecord( $existing, $snapshot, $now );
		$persisted = $this->repository->save( $record );
		$id        = $persisted->id();
		if ( $id !== null ) {
			$this->persistUrlDrafts( $id, $drafts, $now );
		}

		return new SaveProductResult( $persisted, false );
	}

	/**
	 * Normalize URL drafts and add the selected provider page itself.
	 *
	 * @param ProductSnapshot  $snapshot  Normalized snapshot to persist.
	 * @param array<int,mixed> $urlDrafts Additional URL drafts.
	 *
	 * @return list<ProductUrlDraft>
	 */
	private function normalizeUrlDrafts( ProductSnapshot $snapshot, array $urlDrafts ): array {
		$drafts = array(
			new ProductUrlDraft(
				$snapshot->provider(),
				$snapshot->providerProductId()->value(),
				$snapshot->detailUrl(),
				'provider'
			),
		);

		if ( $snapshot->affiliateUrl() !== null ) {
			$drafts[] = new ProductUrlDraft(
				$snapshot->provider(),
				$snapshot->providerProductId()->value(),
				$snapshot->affiliateUrl(),
				'provider'
			);
		}

		foreach ( $urlDrafts as $draft ) {
			if ( ! $draft instanceof ProductUrlDraft ) {
				throw new \InvalidArgumentException( 'SaveProductUseCase URL drafts must be ProductUrlDraft instances.' );
			}
			$drafts[] = $draft;
		}

		$seen   = array();
		$result = array();
		foreach ( $drafts as $draft ) {
			$key = $draft->providerType() . "\0" . ProductUrlRecord::hashUrl( $draft->url() );
			if ( array_key_exists( $key, $seen ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$result[]     = $draft;
		}

		return $result;
	}

	/**
	 * Find an already-normalized Amazon product through either parent or URL row.
	 *
	 * @param ProductSnapshot $snapshot Normalized snapshot to inspect.
	 */
	private function findExistingAmazonProduct( ProductSnapshot $snapshot ): ?ProductRecord {
		if ( $snapshot->provider() !== 'amazon' ) {
			return null;
		}

		$asin = $snapshot->providerProductId()->value();
		if ( $this->urlRepository !== null ) {
			$existingUrl = $this->urlRepository->findByProviderItem( 'amazon', $asin );
			if ( $existingUrl !== null ) {
				$record = $this->repository->findById( $existingUrl->productId() );
				if ( $record !== null ) {
					return $record;
				}
			}
		}

		return $this->repository->findByProviderItem( 'amazon', $asin );
	}

	/**
	 * Persist child URL rows for an affilun product ID.
	 *
	 * @param int                   $productId Product ID.
	 * @param list<ProductUrlDraft> $drafts    URL drafts.
	 * @param \DateTimeImmutable    $now       Persistence timestamp.
	 */
	private function persistUrlDrafts( int $productId, array $drafts, \DateTimeImmutable $now ): void {
		if ( $this->urlRepository === null ) {
			return;
		}

		foreach ( $drafts as $draft ) {
			$hash = ProductUrlRecord::hashUrl( $draft->url() );
			if ( $this->urlRepository->findByProductProviderUrl( $productId, $draft->providerType(), $hash ) !== null ) {
				continue;
			}

			$this->urlRepository->save(
				ProductUrlRecord::createNew(
					$productId,
					$draft->providerType(),
					$draft->providerItemId(),
					$draft->url(),
					$now,
					array(
						'source'  => $draft->source(),
						'urlHash' => $hash,
					)
				)
			);
		}
	}

	/**
	 * Build a fresh {@see ProductRecord} for the insert path.
	 *
	 * @param ProductSnapshot    $snapshot Snapshot to persist.
	 * @param \DateTimeImmutable $now      Clock-supplied "now".
	 */
	private function buildNewRecord( ProductSnapshot $snapshot, \DateTimeImmutable $now ): ProductRecord {
		[ $priceAmount, $priceCurrency, $priceLabel ] = self::extractPriceColumns( $snapshot->price() );
		$keywords                                     = $this->keywordExtractor->extract( $snapshot );

		return new ProductRecord(
			null,
			$snapshot->provider(),
			$snapshot->providerProductId()->value(),
			self::computeCanonicalHash( $snapshot ),
			$snapshot->title(),
			$snapshot->brand(),
			$snapshot->description(),
			$snapshot->primaryImage(),
			self::encodeGalleryImages( $snapshot->galleryImages() ),
			$priceAmount,
			$priceCurrency,
			$priceLabel,
			$snapshot->availability()->status(),
			$snapshot->detailUrl(),
			$snapshot->affiliateUrl(),
			null,
			1,
			'fresh',
			'active',
			null,
			$now,
			null,
			$now,
			null,
			null,
			$now,
			$now,
			$keywords->keywords(),
			$keywords->source()
		);
	}

	/**
	 * Build the post-update {@see ProductRecord} that preserves
	 * the existing row's `id` and `createdAt`.
	 *
	 * Every other column is refreshed from the snapshot plus the
	 * supplied "now" timestamp per the §6.4 sync-stamping rule.
	 *
	 * @param ProductRecord      $existing Existing row.
	 * @param ProductSnapshot    $snapshot Snapshot driving the
	 *                                     update.
	 * @param \DateTimeImmutable $now      Clock-supplied "now".
	 */
	private function buildUpdatedRecord(
		ProductRecord $existing,
		ProductSnapshot $snapshot,
		\DateTimeImmutable $now
	): ProductRecord {
		[ $priceAmount, $priceCurrency, $priceLabel ] = self::extractPriceColumns( $snapshot->price() );
		$keywords                                     = $this->keywordExtractor->extract( $snapshot );

		return new ProductRecord(
			$existing->id(),
			$snapshot->provider(),
			$snapshot->providerProductId()->value(),
			self::computeCanonicalHash( $snapshot ),
			$snapshot->title(),
			$snapshot->brand(),
			$snapshot->description(),
			$snapshot->primaryImage(),
			self::encodeGalleryImages( $snapshot->galleryImages() ),
			$priceAmount,
			$priceCurrency,
			$priceLabel,
			$snapshot->availability()->status(),
			$snapshot->detailUrl(),
			$snapshot->affiliateUrl(),
			null,
			$existing->snapshotVersion(),
			'fresh',
			$existing->lifecycleState(),
			$existing->archivedAt(),
			$now,
			$existing->lastRenderedAt(),
			$now,
			null,
			null,
			$existing->createdAt(),
			$now,
			$keywords->keywords(),
			$keywords->source()
		);
	}

	/**
	 * Extract the three persisted price columns from a
	 * {@see PriceInfo} value, or return three nulls when the
	 * snapshot carries no price.
	 *
	 * @param PriceInfo|null $price Snapshot price, or null.
	 *
	 * @return array{0: ?string, 1: ?string, 2: ?string}
	 */
	private static function extractPriceColumns( ?PriceInfo $price ): array {
		if ( $price === null ) {
			return array( null, null, null );
		}

		return array(
			$price->amount(),
			$price->currency(),
			$price->label(),
		);
	}

	/**
	 * Encode the snapshot's gallery-image list as JSON, or null
	 * when the list is empty.
	 *
	 * The §4.2 column is nullable; persisting `[]` would force
	 * downstream consumers to disambiguate "empty list" from "no
	 * data", so the empty case maps to null verbatim.
	 *
	 * @param list<string> $galleryImages Snapshot gallery list.
	 */
	private static function encodeGalleryImages( array $galleryImages ): ?string {
		if ( $galleryImages === array() ) {
			return null;
		}

		if ( function_exists( 'wp_json_encode' ) ) {
			$encoded = wp_json_encode( $galleryImages );
		} else {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit test environment without WordPress runtime; production code path uses wp_json_encode above.
			$encoded = json_encode( $galleryImages );
		}
		if ( ! is_string( $encoded ) ) {
			return null;
		}

		return $encoded;
	}

	/**
	 * Compute the §4.4 canonical hash for the supplied snapshot.
	 *
	 * Returns the raw SHA-256 hex digest (64 characters) of the
	 * normalized title / brand / first-image-url-without-query
	 * components. The Phase 2 schema declares
	 * `canonical_hash char(64) NOT NULL` (see
	 * `database-schema.md` §4.2 / §17.3) so the storage column
	 * cannot accommodate the spec §4.4 pseudocode's `"ch1:" +
	 * sha256(...)` prefix (68 chars). The version-prefix concern is
	 * deferred to a dedicated `snapshot_version` column (already
	 * present on `affilun_products`) so the version metadata is
	 * preserved without overflowing the hash column.
	 *
	 * The implementation is deliberately minimal — it does not
	 * consult JAN / ISBN-13 / EAN-13 attributes because those are
	 * Phase 9 sync-pipeline concerns; the Phase 6 minimum surfaces a
	 * stable, deterministic value that satisfies the
	 * {@see ProductRecord} non-empty invariant.
	 *
	 * @param ProductSnapshot $snapshot Snapshot to hash.
	 */
	private static function computeCanonicalHash( ProductSnapshot $snapshot ): string {
		$normalize = static function ( string $value ): string {
			$lower     = function_exists( 'mb_strtolower' )
				? mb_strtolower( $value, 'UTF-8' )
				: strtolower( $value );
			$collapsed = preg_replace( '/\s+/u', ' ', $lower );
			if ( $collapsed === null ) {
				$collapsed = $lower;
			}

			return trim( $collapsed );
		};

		$titlePart = 't:' . $normalize( $snapshot->title() );
		$brandPart = 'b:' . $normalize( $snapshot->brand() ?? '' );

		$primary  = $snapshot->primaryImage() ?? '';
		$position = strpos( $primary, '?' );
		if ( $position !== false ) {
			$primary = substr( $primary, 0, $position );
		}
		$imagePart = 'i:' . substr( sha1( $primary ), 0, 12 );

		$joined = $titlePart . '|' . $brandPart . '|' . $imagePart;

		return hash( 'sha256', $joined );
	}
}
