<?php

/**
 * Manages async Plugin Builder job state via WordPress transients.
 *
 * Each job represents a background plugin generation task. The state is
 * stored as a transient so both the background worker and the REST polling
 * endpoint can read/write it without a shared DB table.
 *
 * @package LolaCore\PluginBuilder\Async
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\PluginBuilder\Async;

if (! defined('ABSPATH')) {
	exit;
}

class JobManager {

	/** Transient key prefix for builder jobs. */
	public const TRANSIENT_PREFIX = 'lolacore_pb_job_';

	/** Default time-to-live for job transients (30 minutes). */
	public const DEFAULT_TTL_SECONDS = 30 * MINUTE_IN_SECONDS;

	/**
	 * Maximum number of user-initiated retries for a failed job.
	 *
	 * A transient model failure (e.g. malformed output) usually clears on the
	 * second attempt, so the original run plus two retries (three chances total)
	 * covers the transient case without trapping the user in a retry loop.
	 */
	public const MAX_RETRIES = 2;

	/** First-run generation from the user prompt. */
	public const WORKER_MODE_GENERATE = 'generate';

	/** User retry: continue fix-mode on a persisted artifact (no clean generate). */
	public const WORKER_MODE_FIX_RETRY = 'fix_retry';

	public const STATUS_QUEUED     = 'queued';
	public const STATUS_PROCESSING = 'processing';
	public const STATUS_COMPLETED  = 'completed';
	public const STATUS_FAILED     = 'failed';

	/**
	 * Create a new job in queued state.
	 *
	 * @param string               $jobId  Unique UUID for this job.
	 * @param string               $action 'create_plugin' or 'update_plugin_code'.
	 * @param array<string, mixed> $params Original generation parameters.
	 * @param int                   $userId WordPress user ID that initiated the request.
	 * @param int                   $retryCount How many times this work has already been retried (0 for the first run).
	 * @param string                $workerMode {@see WORKER_MODE_GENERATE} or {@see WORKER_MODE_FIX_RETRY}.
	 * @param array<string, mixed>  $retryContext Persisted artifact + ledger when $workerMode is fix_retry.
	 */
	public function createJob(
		string $jobId,
		string $action,
		array $params,
		int $userId,
		int $retryCount = 0,
		string $workerMode = self::WORKER_MODE_GENERATE,
		array $retryContext = array()
	): void {
		$state = array(
			'status'       => self::STATUS_QUEUED,
			'progress'     => 0,
			'step'         => '',
			'result'       => null,
			'error'        => null,
			'params'       => $params,
			'action'       => $action,
			'user_id'      => $userId,
			'retry_count'  => max( 0, $retryCount ),
			'worker_mode'  => $workerMode,
			'created_at'   => time(),
		);

		if ( $workerMode === self::WORKER_MODE_FIX_RETRY ) {
			if ( isset( $retryContext['last_artifact'] ) && is_array( $retryContext['last_artifact'] ) ) {
				$state['last_artifact'] = $retryContext['last_artifact'];
			}
			if ( isset( $retryContext['ledger_snapshot'] ) && is_array( $retryContext['ledger_snapshot'] ) ) {
				$state['ledger_snapshot'] = $retryContext['ledger_snapshot'];
			}
			$state['library_version'] = max( 1, (int) ( $retryContext['library_version'] ?? 1 ) );
		}

		\set_transient( self::TRANSIENT_PREFIX . $jobId, $state, self::DEFAULT_TTL_SECONDS );
	}

	/**
	 * Read the current job state.
	 *
	 * @return array<string, mixed>|null Null if the job does not exist or has expired.
	 */
	public function getState( string $jobId ): ?array {
		$state = \get_transient( self::TRANSIENT_PREFIX . $jobId );

		if ( ! is_array( $state ) ) {
			return null;
		}

		return $state;
	}

	/**
	 * Update the progress and narrative step message.
	 *
	 * Also transitions status from 'queued' to 'processing' on the first call.
	 *
	 * @param int    $progress 0–100 percentage.
	 * @param string $step     Human-readable narrative step for the UI.
	 */
	public function updateProgress( string $jobId, int $progress, string $step ): void {
		$state = $this->getState( $jobId );

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

		$state['progress'] = max( 0, min( 100, $progress ) );
		$state['step']     = $step;

		if ( $state['status'] === self::STATUS_QUEUED ) {
			$state['status'] = self::STATUS_PROCESSING;
		}

		\set_transient( self::TRANSIENT_PREFIX . $jobId, $state, self::DEFAULT_TTL_SECONDS );
	}

	/**
	 * Mark the job as completed and store the result payload.
	 *
	 * @param array<string, mixed> $result The preview data (same shape as dryRun=true output).
	 */
	public function complete( string $jobId, array $result ): void {
		$state = $this->getState( $jobId );

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

		$state['status']   = self::STATUS_COMPLETED;
		$state['progress'] = 100;
		$state['step']     = 'Ready for review!';
		$state['result']   = $result;

		\set_transient( self::TRANSIENT_PREFIX . $jobId, $state, self::DEFAULT_TTL_SECONDS );
	}

	/**
	 * Mark the job as failed with an error message.
	 *
	 * @param array<string, mixed> $failureContext Optional keys: last_artifact, ledger_snapshot, library_version.
	 */
	public function fail( string $jobId, string $error, array $failureContext = array() ): void {
		$state = $this->getState( $jobId );

		if ( $state === null ) {
			// Job already expired — create a minimal failed state so polling
			// can still retrieve the error at least once.
			$state = array(
				'status'      => self::STATUS_FAILED,
				'progress'    => 0,
				'step'        => '',
				'result'      => null,
				'error'       => $error,
				'params'      => array(),
				'action'      => '',
				'user_id'     => 0,
				'retry_count' => 0,
				'worker_mode' => self::WORKER_MODE_GENERATE,
				'created_at'  => time(),
			);
		} else {
			$state['status'] = self::STATUS_FAILED;
			$state['error']  = $error;
		}

		$this->mergeFailureContext( $state, $failureContext );

		\set_transient( self::TRANSIENT_PREFIX . $jobId, $state, self::DEFAULT_TTL_SECONDS );
	}

	/**
	 * Whether the job state includes a persisted generation artifact for fix_retry.
	 *
	 * @param array<string, mixed> $state
	 */
	public static function hasRetryArtifact( array $state ): bool {
		$artifact = $state['last_artifact'] ?? null;

		return is_array( $artifact ) && (string) ( $artifact['php'] ?? '' ) !== '';
	}

	/**
	 * @param array<string, mixed> $state
	 * @param array<string, mixed> $failureContext
	 */
	private function mergeFailureContext( array &$state, array $failureContext ): void {
		if ( isset( $failureContext['last_artifact'] ) && is_array( $failureContext['last_artifact'] ) ) {
			$state['last_artifact'] = $failureContext['last_artifact'];
		}

		if ( isset( $failureContext['ledger_snapshot'] ) && is_array( $failureContext['ledger_snapshot'] ) ) {
			$state['ledger_snapshot'] = $failureContext['ledger_snapshot'];
		}

		if ( isset( $failureContext['library_version'] ) ) {
			$state['library_version'] = max( 1, (int) $failureContext['library_version'] );
		}
	}

	/**
	 * Delete the job transient. Call after the frontend has consumed the result.
	 */
	public function cleanup( string $jobId ): void {
		\delete_transient( self::TRANSIENT_PREFIX . $jobId );
	}
}
