<?php
/**
 * Existing-post scan job service.
 *
 * @package Affilun\Application\Migration
 */

declare(strict_types=1);

namespace Affilun\Application\Migration;

use Affilun\Infrastructure\Persistence\OptionStore;

/**
 * Option-backed batch runner for existing-post opportunity scans.
 */
final class ExistingPostScanJobService {

	private const OPTION_JOBS = 'affilun_existing_post_scan_jobs';

	private const MAX_RETAINED_JOBS = 10;

	/**
	 * Option storage.
	 */
	private OptionStore $options;

	/**
	 * @var callable(int,int): list<PostScanItem>
	 */
	private $postProvider;

	/**
	 * Opportunity scanner.
	 */
	private ExistingPostOpportunityScanner $scanner;

	/**
	 * @param OptionStore                    $options      Option storage.
	 * @param callable                       $postProvider Batch post provider.
	 * @param ExistingPostOpportunityScanner $scanner      Opportunity scanner.
	 *
	 * @phpstan-param callable(int,int): list<PostScanItem> $postProvider
	 */
	public function __construct(
		OptionStore $options,
		callable $postProvider,
		?ExistingPostOpportunityScanner $scanner = null
	) {
		$this->options      = $options;
		$this->postProvider = $postProvider;
		$this->scanner      = $scanner ?? new ExistingPostOpportunityScanner();
	}

	/**
	 * Start a new scan job.
	 *
	 * @return array{id:string,status:string,offset:int,processed:int,opportunities:list<array<string,mixed>>}
	 */
	public function start(): array {
		$job = array(
			'id'            => 'scan_' . gmdate( 'YmdHis' ) . '_' . bin2hex( random_bytes( 4 ) ),
			'status'        => 'queued',
			'offset'        => 0,
			'processed'     => 0,
			'opportunities' => array(),
			'created_at'    => gmdate( 'c' ),
			'updated_at'    => gmdate( 'c' ),
		);
		$this->storeJob( $job );
		return $job;
	}

	/**
	 * Run one bounded batch.
	 *
	 * @param string $jobId Job id.
	 * @param int    $limit Batch size.
	 *
	 * @return array{id:string,status:string,offset:int,processed:int,opportunities:list<array<string,mixed>>}|null
	 */
	public function runBatch( string $jobId, int $limit = 10 ): ?array {
		$job = $this->findJob( $jobId );
		if ( $job === null || $job['status'] === 'complete' ) {
			return $job;
		}

		$limit    = max( 1, min( 25, $limit ) );
		$provider = $this->postProvider;
		$posts    = $provider( (int) $job['offset'], $limit );
		$batch    = $this->scanner->scanBatch( $posts );

		$job['status']        = count( $posts ) < $limit ? 'complete' : 'running';
		$job['offset']        = (int) $job['offset'] + count( $posts );
		$job['processed']     = (int) $job['processed'] + count( $posts );
		$job['opportunities'] = array_merge( $job['opportunities'], $batch['opportunities'] );
		$job['updated_at']    = gmdate( 'c' );

		$this->storeJob( $job );
		return $job;
	}

	/**
	 * Return a job by id.
	 *
	 * @param string $jobId Job id.
	 *
	 * @return array{id:string,status:string,offset:int,processed:int,opportunities:list<array<string,mixed>>}|null
	 */
	public function findJob( string $jobId ): ?array {
		foreach ( $this->readJobs() as $job ) {
			if ( isset( $job['id'] ) && $job['id'] === $jobId ) {
				return $this->normalizeJob( $job );
			}
		}
		return null;
	}

	/**
	 * Return most recent job.
	 *
	 * @return array{id:string,status:string,offset:int,processed:int,opportunities:list<array<string,mixed>>}|null
	 */
	public function latest(): ?array {
		$jobs = $this->readJobs();
		if ( $jobs === array() ) {
			return null;
		}
		$last = end( $jobs );
		return is_array( $last ) ? $this->normalizeJob( $last ) : null;
	}

	/**
	 * Store/replace a job.
	 *
	 * @param array<string,mixed> $job Job.
	 */
	private function storeJob( array $job ): void {
		$jobs     = $this->readJobs();
		$replaced = false;
		foreach ( $jobs as $index => $existing ) {
			if ( isset( $existing['id'] ) && $existing['id'] === $job['id'] ) {
				$jobs[ $index ] = $job;
				$replaced       = true;
			}
		}
		if ( ! $replaced ) {
			$jobs[] = $job;
		}
		if ( count( $jobs ) > self::MAX_RETAINED_JOBS ) {
			$jobs = array_slice( $jobs, - self::MAX_RETAINED_JOBS );
		}
		$this->options->update( self::OPTION_JOBS, array_values( $jobs ) );
	}

	/**
	 * Read raw jobs.
	 *
	 * @return list<array<string,mixed>>
	 */
	private function readJobs(): array {
		$stored = $this->options->get( self::OPTION_JOBS, array() );
		if ( ! is_array( $stored ) ) {
			return array();
		}

		$jobs = array();
		foreach ( $stored as $job ) {
			if ( is_array( $job ) ) {
				$jobs[] = $job;
			}
		}
		return $jobs;
	}

	/**
	 * Normalize a persisted job.
	 *
	 * @param array<string,mixed> $job Job.
	 *
	 * @return array{id:string,status:string,offset:int,processed:int,opportunities:list<array<string,mixed>>}
	 */
	private function normalizeJob( array $job ): array {
		$opportunities = array();
		if ( isset( $job['opportunities'] ) && is_array( $job['opportunities'] ) ) {
			foreach ( $job['opportunities'] as $opportunity ) {
				if ( is_array( $opportunity ) ) {
					$opportunities[] = $opportunity;
				}
			}
		}

		return array(
			'id'            => isset( $job['id'] ) && is_string( $job['id'] ) ? $job['id'] : '',
			'status'        => isset( $job['status'] ) && is_string( $job['status'] ) ? $job['status'] : 'queued',
			'offset'        => isset( $job['offset'] ) && is_int( $job['offset'] ) ? $job['offset'] : 0,
			'processed'     => isset( $job['processed'] ) && is_int( $job['processed'] ) ? $job['processed'] : 0,
			'opportunities' => $opportunities,
		);
	}
}
