<?php
/**
 * Async matching orchestration for POST /jobs/{id}/match (see docs/SPEC.md §8).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Rest;

use EmberPrice\Core\Jobs;
use EmberPrice\Core\Rows;
use EmberPrice\Matching\AliasLookupInterface;
use EmberPrice\Matching\AliasStore;
use EmberPrice\Matching\CandidateIndex;
use EmberPrice\Matching\CatalogRepositoryInterface;
use EmberPrice\Matching\MatchPipeline;
use EmberPrice\Matching\WooCommerceCatalogRepository;

defined( 'ABSPATH' ) || exit;

/**
 * Walks a job's rows in batches of 500 via Action Scheduler: each batch run
 * matches its rows and, if more remain, schedules itself again for the next
 * offset; the last batch flips the job to `review` and fills `totals`. Lives
 * in Rest (not Matching) because it's the orchestrator wiring Core (jobs/rows)
 * to Matching (the cascade) via a WordPress hook -- the same role
 * JobsController already plays for the synchronous parts of the pipeline.
 */
final class JobMatchScheduler {

	/**
	 * Action Scheduler hook name for one batch run.
	 */
	public const HOOK = 'embp_process_match_batch';

	/**
	 * Action Scheduler group, mostly for the admin UI (Tools > Scheduled Actions).
	 */
	private const GROUP = 'emberprice';

	private const BATCH_SIZE          = 500;
	private const STALE_AFTER_SECONDS = 120;

	/**
	 * Job data access.
	 *
	 * @var Jobs
	 */
	private Jobs $jobs;

	/**
	 * Row data access.
	 *
	 * @var Rows
	 */
	private Rows $rows;

	/**
	 * Rebuilt into a fresh CandidateIndex on every batch (a separate Action
	 * Scheduler run can't share the previous batch's in-memory index).
	 *
	 * @var CatalogRepositoryInterface
	 */
	private CatalogRepositoryInterface $catalog_repository;

	/**
	 * Stage 0 source.
	 *
	 * @var AliasLookupInterface
	 */
	private AliasLookupInterface $aliases;

	/**
	 * Constructor.
	 *
	 * @param Jobs|null                       $jobs               Defaults to a new instance.
	 * @param Rows|null                       $rows               Defaults to a new instance.
	 * @param CatalogRepositoryInterface|null $catalog_repository Defaults to WooCommerceCatalogRepository.
	 * @param AliasLookupInterface|null       $aliases            Defaults to AliasStore.
	 */
	public function __construct(
		?Jobs $jobs = null,
		?Rows $rows = null,
		?CatalogRepositoryInterface $catalog_repository = null,
		?AliasLookupInterface $aliases = null
	) {
		$this->jobs               = $jobs ?? new Jobs();
		$this->rows               = $rows ?? new Rows();
		$this->catalog_repository = $catalog_repository ?? new WooCommerceCatalogRepository();
		$this->aliases            = $aliases ?? new AliasStore();
	}

	/**
	 * Wires the Action Scheduler hook to process_batch(). Call once, from
	 * the composition root.
	 */
	public function register_hook(): void {
		add_action( self::HOOK, array( $this, 'process_batch' ), 10, 2 );
	}

	/**
	 * Kicks off matching for a job: schedules the first batch (offset 0),
	 * unless one is already pending/running.
	 *
	 * @param int $job_id Job id.
	 */
	public function schedule( int $job_id ): void {
		if ( false === as_next_scheduled_action( self::HOOK, array( $job_id, 0 ), self::GROUP ) ) {
			$action_id = as_schedule_single_action( time(), self::HOOK, array( $job_id, 0 ), self::GROUP );
			$this->record_progress( $job_id, 0, $this->rows->count_by_job( $job_id ), (int) $action_id );

			if ( 0 === (int) $action_id ) {
				$this->jobs->mark_failed( $job_id, 'matching', __( 'Matching could not be scheduled. Check Action Scheduler and WP-Cron.', 'emberprice' ) );
			}
		}
	}

	/**
	 * Replaces a stale pending action at the current durable offset.
	 *
	 * @param int $job_id Job id.
	 */
	public function retry( int $job_id ): void {
		$offset = $this->rows->count_matched( $job_id );
		as_unschedule_all_actions( self::HOOK, array( $job_id, $offset ), self::GROUP );
		$action_id = as_schedule_single_action( time(), self::HOOK, array( $job_id, $offset ), self::GROUP );
		$this->record_progress( $job_id, $offset, $this->rows->count_by_job( $job_id ), (int) $action_id );
	}

	/**
	 * Action Scheduler callback: matches up to BATCH_SIZE rows starting at
	 * $offset, then either schedules the next batch or finishes the job.
	 *
	 * @param int $job_id Job id.
	 * @param int $offset Rows already processed by earlier batches.
	 */
	public function process_batch( int $job_id, int $offset ): void {
		$job = $this->jobs->find( $job_id );

		if ( null === $job || 'matching' !== $job['status'] ) {
			return;
		}

		try {
			$batch = $this->rows->get_batch( $job_id, $offset, self::BATCH_SIZE );

			if ( empty( $batch ) ) {
				$this->finish( $job_id );
				return;
			}

			$pipeline = new MatchPipeline(
				new CandidateIndex( $this->catalog_repository ),
				$this->aliases,
				null,
				null,
				$this->rows
			);

			foreach ( $batch as $row ) {
				$pipeline->match_and_persist(
					(int) $row['id'],
					(array) ( $row['parsed'] ?? array() ),
					(int) $job['supplier_id']
				);
			}

			$next_offset = $offset + count( $batch );
			$total       = $this->rows->count_by_job( $job_id );
			$this->record_progress( $job_id, $next_offset, $total, 0 );

			if ( $next_offset >= $total ) {
				$this->finish( $job_id );
				return;
			}

			$action_id = as_schedule_single_action( time(), self::HOOK, array( $job_id, $next_offset ), self::GROUP );
			$this->record_progress( $job_id, $next_offset, $total, (int) $action_id );
		} catch ( \Throwable $exception ) {
			$this->jobs->mark_failed( $job_id, 'matching', __( 'Matching stopped unexpectedly. Retry the job after checking Action Scheduler.', 'emberprice' ) );
		}
	}

	/**
	 * Progress snapshot for GET /jobs/{id}/progress, or null if the job
	 * doesn't exist.
	 *
	 * @param int $job_id Job id.
	 *
	 * @return array{stage: string, done: int, total: int}|null
	 */
	public function progress( int $job_id ): ?array {
		$job = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return null;
		}

		$matching  = (array) ( $job['totals']['matching'] ?? array() );
		$heartbeat = (string) ( $matching['heartbeat'] ?? '' );
		$stalled   = 'matching' === $job['status']
			&& '' !== $heartbeat
			&& time() - (int) strtotime( $heartbeat . ' UTC' ) > self::STALE_AFTER_SECONDS;

		return array(
			'stage'   => $job['status'],
			'done'    => $this->rows->count_matched( $job_id ),
			'total'   => $this->rows->count_by_job( $job_id ),
			'stalled' => $stalled,
			'message' => $stalled ? __( 'Matching is not progressing. WP-Cron or Action Scheduler may be unavailable.', 'emberprice' ) : '',
			'error'   => $job['totals']['error'] ?? null,
		);
	}

	/**
	 * Flips the job to `review` and records final row counts by status.
	 *
	 * @param int $job_id Job id.
	 */
	private function finish( int $job_id ): void {
		$this->jobs->update_status( $job_id, 'review' );
		$this->jobs->merge_totals(
			$job_id,
			array_merge(
				$this->rows->counts_by_status( $job_id ),
				array(
					'matching' => array(
						'done'      => $this->rows->count_by_job( $job_id ),
						'total'     => $this->rows->count_by_job( $job_id ),
						'heartbeat' => current_time( 'mysql', true ),
					),
				)
			)
		);
	}

	/**
	 * Stores heartbeat/action information for diagnostics and stale-job UI.
	 *
	 * @param int $job_id    Job id.
	 * @param int $done      Rows processed.
	 * @param int $total     Total rows.
	 * @param int $action_id Scheduled action id, if known.
	 */
	private function record_progress( int $job_id, int $done, int $total, int $action_id ): void {
		$job    = $this->jobs->find( $job_id );
		$totals = null !== $job ? (array) $job['totals'] : array();

		$totals['matching'] = array(
			'done'      => $done,
			'total'     => $total,
			'action_id' => $action_id,
			'heartbeat' => current_time( 'mysql', true ),
		);

		$this->jobs->update_totals( $job_id, $totals );
	}
}
