<?php
/**
 * Sync product job handler.
 *
 * Phase 9 Application-layer collaborator that processes a single
 * `sync_product` job claimed off the `clb_sync_jobs` queue, per
 * `specifications/sync-and-cache-strategy.md` §10.2. The handler is
 * deliberately a single-job processor (NOT a worker loop): it accepts
 * one {@see SyncJobRecord}, drives the §10.2 process steps, persists
 * the resulting state transitions, and returns a {@see SyncJobOutcome}
 * the surrounding worker can branch on.
 *
 * Process steps (per §10.2):
 *
 *   1. Decode the payload and extract `product_id`.
 *   2. Load the matching {@see ProductRecord} via the repository.
 *      A missing local row terminates the job with the
 *      `product_not_found` code — there is nothing to refresh.
 *   3. Resolve the provider key from the loaded record through the
 *      injected {@see ProviderResolver}. Resolution failures
 *      terminate the job with the `unknown_provider` code.
 *   4. Call `getProduct()` on the resolved provider with a fresh
 *      {@see ProviderProductId} built from the record's
 *      `(provider, providerItemId)` pair.
 *   5. Persist the returned snapshot through
 *      {@see SaveProductUseCase} (which owns the §6.4 sync-stamp
 *      semantics and the upsert decision).
 *   6. Mark the job `succeeded` and return
 *      {@see SyncJobOutcome::success()}.
 *
 * Failure mapping (per §10.2 / §11):
 *
 *   - {@see TemporaryUnavailableException} (transient): increment
 *     the attempt count; if the §12.4 retry budget allows another
 *     attempt, reschedule the job for `now + backoff(attempts)` and
 *     return {@see SyncJobOutcome::retryScheduled()}; otherwise mark
 *     the job `failed` and return
 *     {@see SyncJobOutcome::failedTerminal()}.
 *
 *   - {@see ProviderRequestException}: NON-retryable. The provider
 *     rejected the request shape itself, so retries cannot succeed
 *     without operator intervention. Mark the job `failed` with
 *     code `provider_request_error`.
 *
 *   - {@see ProductNotFoundException} (Domain-layer, raised by the
 *     provider): the upstream confirmed the product no longer
 *     exists. Transition the local record's `syncState` to `broken`
 *     so the policy / finder layers stop scheduling further sync
 *     work, then mark the job `failed` with code
 *     `product_missing_at_provider`.
 *
 *   - Any other {@see \Throwable}: NON-retryable, recorded as
 *     `unexpected_error` with a 500-character-truncated copy of the
 *     exception message. The handler deliberately catches at the
 *     {@see \Throwable} boundary so a defective provider
 *     implementation cannot leak a fatal error into the worker
 *     loop.
 *
 * @package CommerceLinkBuilder\Application\Sync
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Sync;

use CommerceLinkBuilder\Application\Cache\CacheInvalidator;
use CommerceLinkBuilder\Application\Product\Exception\ProductNotFoundException as ApplicationProductNotFoundException;
use CommerceLinkBuilder\Application\Product\UseCase\SaveProductUseCase;
use CommerceLinkBuilder\Application\Sync\Exception\UnknownProviderException;
use CommerceLinkBuilder\Domain\Clock;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Domain\Provider\Exception\ProductNotFoundException as ProviderProductNotFoundException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderRequestException;
use CommerceLinkBuilder\Domain\Provider\Exception\TemporaryUnavailableException;
use CommerceLinkBuilder\Domain\Provider\ProviderProductId;
use CommerceLinkBuilder\Domain\Sync\RetryPolicy;
use CommerceLinkBuilder\Domain\Sync\SyncState;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRecord;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\SyncJobRecord;
use CommerceLinkBuilder\Infrastructure\Persistence\SyncJobRepository;
use DateTimeImmutable;
use DateTimeZone;
use RuntimeException;
use Throwable;

/**
 * Phase 9 single-job processor for `sync_product` jobs.
 *
 * See the file-level docblock for the §10.2 process steps and the
 * §10.2 / §11 failure-mapping rules that bind the catch blocks.
 */
final class SyncProductJobHandler {

	/**
	 * Maximum length (in characters) for the persisted
	 * `last_error` summary on the unexpected-error catch.
	 */
	private const MAX_ERROR_MESSAGE_LENGTH = 500;

	/**
	 * Stable error code stamped on the job and the outcome when
	 * the local product row cannot be located.
	 */
	private const CODE_PRODUCT_NOT_FOUND = 'product_not_found';

	/**
	 * Stable error code stamped on the job and the outcome when
	 * no item-lookup provider can be resolved.
	 */
	private const CODE_UNKNOWN_PROVIDER = 'unknown_provider';

	/**
	 * Stable error code stamped on the job and the outcome when
	 * the provider rejected the request shape itself.
	 */
	private const CODE_PROVIDER_REQUEST_ERROR = 'provider_request_error';

	/**
	 * Stable error code stamped on the job and the outcome when
	 * the upstream confirmed the product no longer exists.
	 */
	private const CODE_PRODUCT_MISSING_AT_PROVIDER = 'product_missing_at_provider';

	/**
	 * Stable error code stamped on the job and the outcome when
	 * the upstream returned a transient failure.
	 */
	private const CODE_TEMPORARY_UNAVAILABLE = 'temporary_unavailable';

	/**
	 * Stable error code stamped on the job and the outcome when
	 * an otherwise-uncategorized exception escaped the provider
	 * call.
	 */
	private const CODE_UNEXPECTED_ERROR = 'unexpected_error';

	/**
	 * Sync-job status string for "ran to completion successfully".
	 */
	private const STATUS_SUCCEEDED = 'succeeded';

	/**
	 * Sync-job status string for "ran to terminal failure".
	 */
	private const STATUS_FAILED = 'failed';

	/**
	 * Sync-job status string for "queued, eligible for the next
	 * runnable claim".
	 */
	private const STATUS_PENDING = 'pending';

	/**
	 * Persistence port for `clb_products` rows.
	 */
	private ProductRepository $products;

	/**
	 * Phase 6 UseCase that owns the §6.4 sync-stamp upsert.
	 */
	private SaveProductUseCase $saveProduct;

	/**
	 * Persistence port for `clb_sync_jobs` rows.
	 */
	private SyncJobRepository $jobs;

	/**
	 * Clock port that resolves "now" for finishedAt /
	 * availableAt / updatedAt stamps.
	 */
	private Clock $clock;

	/**
	 * §12.4 retry policy for the `sync_product` job type.
	 */
	private RetryPolicy $retryPolicy;

	/**
	 * Application-layer port that maps a provider key to an
	 * item-lookup-capable provider implementation.
	 */
	private ProviderResolver $providerResolver;

	/**
	 * Optional §14 cache invalidator. When supplied, the handler
	 * calls {@see CacheInvalidator::invalidateProductSync()} after
	 * a successful sync per `specifications/sync-and-cache-strategy.md`
	 * §10.2 step 7 ("render cache 無効化"). When null, no
	 * invalidation runs — this keeps the constructor backward
	 * compatible with existing call sites that pre-date the
	 * Phase-9-slice-9h cache wiring while leaving an explicit
	 * upgrade path for the worker once it lands.
	 */
	private ?CacheInvalidator $invalidator;

	/**
	 * Construct the handler with explicit collaborators.
	 *
	 * @param ProductRepository     $products         Persistence port.
	 * @param SaveProductUseCase    $saveProduct      Phase 6 upsert UseCase.
	 * @param SyncJobRepository     $jobs             Persistence port for jobs.
	 * @param Clock                 $clock            Clock port.
	 * @param RetryPolicy           $retryPolicy      §12.4 retry policy.
	 * @param ProviderResolver      $providerResolver Provider resolver port.
	 * @param CacheInvalidator|null $invalidator      Optional §14 invalidator
	 *                                                called on successful sync;
	 *                                                null disables invalidation.
	 */
	public function __construct(
		ProductRepository $products,
		SaveProductUseCase $saveProduct,
		SyncJobRepository $jobs,
		Clock $clock,
		RetryPolicy $retryPolicy,
		ProviderResolver $providerResolver,
		?CacheInvalidator $invalidator = null
	) {
		$this->products         = $products;
		$this->saveProduct      = $saveProduct;
		$this->jobs             = $jobs;
		$this->clock            = $clock;
		$this->retryPolicy      = $retryPolicy;
		$this->providerResolver = $providerResolver;
		$this->invalidator      = $invalidator;
	}

	/**
	 * Process the supplied claimed sync job and persist its
	 * post-handle state.
	 *
	 * See the file-level docblock for the §10.2 process steps and
	 * the failure-mapping rules. The returned outcome reflects the
	 * persisted state of the job; callers MUST use the outcome (not
	 * an in-flight assumption) to drive the worker loop.
	 *
	 * @param SyncJobRecord $job Claimed job record.
	 */
	public function handle( SyncJobRecord $job ): SyncJobOutcome {
		$now = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) );

		try {
			$payload = SyncDispatcher::payloadFor( $job );
		} catch ( RuntimeException $e ) {
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_UNEXPECTED_ERROR,
				self::truncateMessage( $e->getMessage() )
			);
		}

		$productId = self::extractProductId( $payload );
		if ( $productId === null ) {
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_UNEXPECTED_ERROR,
				'sync_product payload missing or invalid product_id'
			);
		}

		$record = $this->products->findById( $productId );
		if ( $record === null ) {
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_PRODUCT_NOT_FOUND,
				sprintf( 'Local product with id %d was not found.', $productId )
			);
		}

		try {
			$provider = $this->providerResolver->resolveItemLookup( $record->provider() );
		} catch ( UnknownProviderException $e ) {
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_UNKNOWN_PROVIDER,
				self::truncateMessage( $e->getMessage() )
			);
		}

		try {
			$snapshot = $provider->getProduct(
				new ProviderProductId( $record->provider(), $record->providerItemId() )
			);
		} catch ( TemporaryUnavailableException $e ) {
			return $this->handleTransientFailure( $job, $now, $e );
		} catch ( ProviderRequestException $e ) {
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_PROVIDER_REQUEST_ERROR,
				self::truncateMessage( $e->getMessage() )
			);
		} catch ( ProviderProductNotFoundException $e ) {
			$this->markRecordBroken( $record, $now );
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_PRODUCT_MISSING_AT_PROVIDER,
				self::truncateMessage( $e->getMessage() )
			);
		} catch ( ApplicationProductNotFoundException $e ) {
			$this->markRecordBroken( $record, $now );
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_PRODUCT_MISSING_AT_PROVIDER,
				self::truncateMessage( $e->getMessage() )
			);
		} catch ( Throwable $e ) {
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_UNEXPECTED_ERROR,
				self::truncateMessage( $e->getMessage() )
			);
		}

		try {
			$this->saveProduct->execute( $snapshot );
		} catch ( ApplicationProductNotFoundException $e ) {
			$this->markRecordBroken( $record, $now );
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_PRODUCT_MISSING_AT_PROVIDER,
				self::truncateMessage( $e->getMessage() )
			);
		} catch ( Throwable $e ) {
			return $this->finishTerminal(
				$job,
				$now,
				self::CODE_UNEXPECTED_ERROR,
				self::truncateMessage( $e->getMessage() )
			);
		}

		unset( $snapshot );

		if ( null !== $this->invalidator ) {
			// §10.2 step 7: invalidate render-cache entries that
			// reference this product so subsequent renders pick up
			// the freshly-persisted snapshot. The local product id
			// is guaranteed non-null here because $record was
			// loaded by id earlier in handle(); we re-extract via
			// the record rather than re-using $productId for
			// clarity at the invalidation seam.
			$recordId = $record->id();
			if ( null !== $recordId ) {
				$this->invalidator->invalidateProductSync(
					$record->provider(),
					$record->providerItemId(),
					$recordId
				);
			}
		}

		return $this->finishSuccess( $job, $now );
	}

	/**
	 * Persist the success state and return the matching outcome.
	 *
	 * @param SyncJobRecord     $job Claimed job record.
	 * @param DateTimeImmutable $now UTC instant for finishedAt /
	 *                               updatedAt stamps.
	 */
	private function finishSuccess( SyncJobRecord $job, DateTimeImmutable $now ): SyncJobOutcome {
		$attempts = $job->attempts() + 1;

		$updated = $this->buildJobRecord(
			$job,
			array(
				'status'        => self::STATUS_SUCCEEDED,
				'attempts'      => $attempts,
				'lockedAt'      => null,
				'lockedBy'      => null,
				'finishedAt'    => $now,
				'lastErrorCode' => null,
				'lastError'     => null,
				'availableAt'   => $job->availableAt(),
				'updatedAt'     => $now,
			)
		);

		$this->jobs->save( $updated );

		return SyncJobOutcome::success( $attempts );
	}

	/**
	 * Persist a terminal-failure state and return the matching
	 * outcome.
	 *
	 * @param SyncJobRecord     $job          Claimed job record.
	 * @param DateTimeImmutable $now          UTC instant for stamps.
	 * @param string            $errorCode    Stable error code.
	 * @param string            $errorMessage Truncated error summary.
	 */
	private function finishTerminal(
		SyncJobRecord $job,
		DateTimeImmutable $now,
		string $errorCode,
		string $errorMessage
	): SyncJobOutcome {
		$attempts = $job->attempts() + 1;

		$updated = $this->buildJobRecord(
			$job,
			array(
				'status'        => self::STATUS_FAILED,
				'attempts'      => $attempts,
				'lockedAt'      => null,
				'lockedBy'      => null,
				'finishedAt'    => $now,
				'lastErrorCode' => $errorCode,
				'lastError'     => $errorMessage,
				'availableAt'   => $job->availableAt(),
				'updatedAt'     => $now,
			)
		);

		$this->jobs->save( $updated );

		return SyncJobOutcome::failedTerminal( $attempts, $errorCode, $errorMessage );
	}

	/**
	 * Decide between rescheduling and terminating on a transient
	 * provider failure, persist the chosen state, and return the
	 * matching outcome.
	 *
	 * @param SyncJobRecord                 $job Claimed job record.
	 * @param DateTimeImmutable             $now UTC instant for stamps.
	 * @param TemporaryUnavailableException $e   Transient failure.
	 */
	private function handleTransientFailure(
		SyncJobRecord $job,
		DateTimeImmutable $now,
		TemporaryUnavailableException $e
	): SyncJobOutcome {
		$attempts = $job->attempts() + 1;
		$message  = self::truncateMessage( $e->getMessage() );

		if ( ! $this->retryPolicy->hasNextAttempt( $attempts ) ) {
			$updated = $this->buildJobRecord(
				$job,
				array(
					'status'        => self::STATUS_FAILED,
					'attempts'      => $attempts,
					'lockedAt'      => null,
					'lockedBy'      => null,
					'finishedAt'    => $now,
					'lastErrorCode' => self::CODE_TEMPORARY_UNAVAILABLE,
					'lastError'     => $message,
					'availableAt'   => $job->availableAt(),
					'updatedAt'     => $now,
				)
			);

			$this->jobs->save( $updated );

			return SyncJobOutcome::failedTerminal(
				$attempts,
				self::CODE_TEMPORARY_UNAVAILABLE,
				$message
			);
		}

		$backoffSeconds = $this->retryPolicy->backoffSeconds( $attempts );
		$nextAvailable  = $now->add( new \DateInterval( sprintf( 'PT%dS', $backoffSeconds ) ) );

		$updated = $this->buildJobRecord(
			$job,
			array(
				'status'        => self::STATUS_PENDING,
				'attempts'      => $attempts,
				'lockedAt'      => null,
				'lockedBy'      => null,
				'finishedAt'    => null,
				'lastErrorCode' => self::CODE_TEMPORARY_UNAVAILABLE,
				'lastError'     => $message,
				'availableAt'   => $nextAvailable,
				'updatedAt'     => $now,
			)
		);

		$this->jobs->save( $updated );

		return SyncJobOutcome::retryScheduled(
			$attempts,
			self::CODE_TEMPORARY_UNAVAILABLE,
			$message,
			$nextAvailable
		);
	}

	/**
	 * Transition the supplied product record's `syncState` to
	 * `broken` and persist the change.
	 *
	 * Used on the {@see ProviderProductNotFoundException} catch path
	 * (and the analogous {@see ApplicationProductNotFoundException}
	 * path raised from {@see SaveProductUseCase}) so the policy /
	 * finder layers stop scheduling further sync work for the row.
	 *
	 * @param ProductRecord     $record Existing record.
	 * @param DateTimeImmutable $now    UTC instant for `updatedAt`.
	 */
	private function markRecordBroken( ProductRecord $record, DateTimeImmutable $now ): void {
		$updated = new ProductRecord(
			$record->id(),
			$record->provider(),
			$record->providerItemId(),
			$record->canonicalHash(),
			$record->title(),
			$record->brand(),
			$record->description(),
			$record->imagePrimaryUrl(),
			$record->imageGalleryJson(),
			$record->priceAmount(),
			$record->priceCurrency(),
			$record->priceLabel(),
			$record->availabilityStatus(),
			$record->detailUrl(),
			$record->affiliateUrl(),
			$record->rawPayloadJson(),
			$record->snapshotVersion(),
			SyncState::BROKEN,
			$record->lifecycleState(),
			$record->archivedAt(),
			$record->lastSyncedAt(),
			$record->lastRenderedAt(),
			$now,
			null,
			null,
			$record->createdAt(),
			$now
		);

		$this->products->save( $updated );
	}

	/**
	 * Build a post-handle {@see SyncJobRecord} that preserves the
	 * existing immutable columns (id, jobType, payload, priority,
	 * maxAttempts, createdAt) and applies the supplied overrides.
	 *
	 * @param SyncJobRecord        $job       Claimed job record.
	 * @param array<string, mixed> $overrides Column overrides.
	 */
	private function buildJobRecord( SyncJobRecord $job, array $overrides ): SyncJobRecord {
		/** @var string $status */
		$status = $overrides['status'];
		/** @var int $attempts */
		$attempts = $overrides['attempts'];
		/** @var DateTimeImmutable|null $lockedAt */
		$lockedAt = $overrides['lockedAt'];
		/** @var string|null $lockedBy */
		$lockedBy = $overrides['lockedBy'];
		/** @var DateTimeImmutable|null $finishedAt */
		$finishedAt = $overrides['finishedAt'];
		/** @var string|null $lastErrorCode */
		$lastErrorCode = $overrides['lastErrorCode'];
		/** @var string|null $lastError */
		$lastError = $overrides['lastError'];
		/** @var DateTimeImmutable $availableAt */
		$availableAt = $overrides['availableAt'];
		/** @var DateTimeImmutable $updatedAt */
		$updatedAt = $overrides['updatedAt'];

		return new SyncJobRecord(
			$job->id(),
			$job->jobType(),
			$job->payloadJson(),
			$status,
			$job->priority(),
			$attempts,
			$job->maxAttempts(),
			$availableAt,
			$lockedAt,
			$lockedBy,
			$finishedAt,
			$lastErrorCode,
			$lastError,
			$job->createdAt(),
			$updatedAt
		);
	}

	/**
	 * Extract the `product_id` field from the decoded payload.
	 *
	 * Returns null when the field is missing, not numeric, or
	 * non-positive; callers map a null return onto the
	 * `unexpected_error` terminal outcome.
	 *
	 * @param array<string, mixed> $payload Decoded payload.
	 */
	private static function extractProductId( array $payload ): ?int {
		if ( ! array_key_exists( 'product_id', $payload ) ) {
			return null;
		}

		$raw = $payload['product_id'];
		if ( ! is_int( $raw ) && ! ( is_string( $raw ) && ctype_digit( $raw ) ) ) {
			return null;
		}

		$id = (int) $raw;
		if ( $id <= 0 ) {
			return null;
		}

		return $id;
	}

	/**
	 * Trim and cap the supplied error message at
	 * {@see self::MAX_ERROR_MESSAGE_LENGTH} characters.
	 *
	 * Falls back to the literal `'unknown error'` when the trimmed
	 * input is empty so the persisted `last_error` column is never
	 * blank — a blank message would conflict with the
	 * {@see SyncJobOutcome} constructor's "non-success outcomes
	 * require a non-empty errorMessage" rule.
	 *
	 * @param string $message Raw exception message.
	 */
	private static function truncateMessage( string $message ): string {
		$trimmed = trim( $message );
		if ( $trimmed === '' ) {
			return 'unknown error';
		}

		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $trimmed, 0, self::MAX_ERROR_MESSAGE_LENGTH, 'UTF-8' );
		}

		return substr( $trimmed, 0, self::MAX_ERROR_MESSAGE_LENGTH );
	}
}
