<?php
/**
 * Save product use case.
 *
 * Phase 6 Application-layer UseCase that drives the §8.4
 * `POST /clb/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 CommerceLinkBuilder\Application\Product\UseCase
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Product\UseCase;

use CommerceLinkBuilder\Domain\Clock;
use CommerceLinkBuilder\Domain\Pricing\PriceInfo;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRecord;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRepository;

/**
 * Phase 6 UseCase that drives the §8.4 `POST /clb/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;

	/**
	 * Construct the UseCase with explicit repository and clock
	 * bindings.
	 *
	 * @param ProductRepository $repository Persistence port.
	 * @param Clock             $clock      Clock port for §6.4
	 *                                      sync timestamps.
	 */
	public function __construct( ProductRepository $repository, Clock $clock ) {
		$this->repository = $repository;
		$this->clock      = $clock;
	}

	/**
	 * 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.
	 *
	 * @return SaveProductResult Post-save record plus the
	 *                           insert-vs-update flag.
	 */
	public function execute( ProductSnapshot $snapshot ): SaveProductResult {
		$existing = $this->repository->findByProviderItem(
			$snapshot->provider(),
			$snapshot->providerProductId()->value()
		);

		$now = $this->clock->now();

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

			return new SaveProductResult( $persisted, true );
		}

		$record    = $this->buildUpdatedRecord( $existing, $snapshot, $now );
		$persisted = $this->repository->save( $record );

		return new SaveProductResult( $persisted, false );
	}

	/**
	 * 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() );

		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
		);
	}

	/**
	 * 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() );

		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
		);
	}

	/**
	 * 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 `clb_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 );
	}
}
