<?php
namespace R2_Offload\Migration;

use R2_Offload\DB;
use R2_Offload\Logger;
use R2_Offload\Media\Offloader;
use R2_Offload\Queue\Scheduler;

defined( 'ABSPATH' ) || exit;

final class Migrator {

	public const CONTROL_OPTION = 'r2_offload_migration_control';

	private Scheduler $queue;
	private Offloader $offloader;
	private Logger $logger;

	public function __construct( Scheduler $queue, Offloader $offloader, Logger $logger ) {
		$this->queue     = $queue;
		$this->offloader = $offloader;
		$this->logger    = $logger;
	}

	public function register(): void {
		add_action( 'r2_offload_dispatch_batch', [ $this, 'dispatch_batch' ], 10, 0 );
		add_action( 'r2_offload_retry_failed', [ $this, 'retry_failed' ], 10, 0 );
	}

	public function control(): array {
		return wp_parse_args(
			(array) get_option( self::CONTROL_OPTION, [] ),
			[
				'status'       => 'idle',      // idle, running, paused, stopped
				'concurrency'  => 4,
				'batch_size'   => 25,
				'max_attempts' => 5,
				'started_at'   => '',
			]
		);
	}

	public function update_control( array $values ): void {
		$current = $this->control();
		update_option( self::CONTROL_OPTION, array_merge( $current, $values ), false );
	}

	public function start( int $batch_size = 25, int $concurrency = 4 ): void {
		$this->reset_queue();
		$this->update_control(
			[
				'status'      => 'running',
				'batch_size'  => max( 1, $batch_size ),
				'concurrency' => max( 1, min( 16, $concurrency ) ),
				'started_at'  => current_time( 'mysql' ),
			]
		);
		$this->logger->info( 'Migration started.', 'migrate', $this->debug_counts() );
	}

	/**
	 * Hard reset of the queue at the start of a run: attempts=0, revive failed/processing → pending.
	 * Only touches non-offloaded rows.
	 */
	public function reset_queue(): int {
		global $wpdb;
		$table = DB::items_table();
		$rows  = (int) $wpdb->query(
			"UPDATE {$table}
			 SET status='pending', attempts=0, last_error=NULL, updated_at=NOW()
			 WHERE status IN ('pending','processing','failed')"
		);
		$this->logger->warn( 'Queue reset.', 'migrate', [ 'rows' => $rows ] );
		return $rows;
	}

	public function debug_counts(): array {
		global $wpdb;
		$table = DB::items_table();
		$out   = [];
		foreach ( (array) $wpdb->get_results( "SELECT status, COUNT(*) AS c FROM {$table} GROUP BY status" ) as $r ) {
			$out[ $r->status ] = (int) $r->c;
		}
		$out['max_attempts_seen'] = (int) $wpdb->get_var( "SELECT MAX(attempts) FROM {$table}" );
		return $out;
	}

	public function debug_sample( int $limit = 5 ): array {
		global $wpdb;
		$table = DB::items_table();
		return (array) $wpdb->get_results(
			$wpdb->prepare(
				"SELECT attachment_id, relative_path, status, attempts, last_error, updated_at FROM {$table} ORDER BY id ASC LIMIT %d",
				$limit
			)
		);
	}

	public function pause(): void {
		$this->update_control( [ 'status' => 'paused' ] );
	}

	public function resume(): void {
		$this->recover_stuck( true );
		$this->update_control( [ 'status' => 'running' ] );
	}

	/**
	 * Process a single pending item synchronously. Returns the attachment id processed or 0 if none left.
	 */
	public function process_one(): int {
		$ctrl = $this->control();
		if ( $ctrl['status'] !== 'running' ) {
			$this->logger->info( 'Tick skipped; status=' . $ctrl['status'], 'migrate' );
			return 0;
		}
		$ids = $this->claim_pending( 1 );
		if ( empty( $ids ) ) {
			$recovered = $this->recover_stuck( true );
			if ( $recovered > 0 ) {
				$ids = $this->claim_pending( 1 );
			}
		}
		if ( empty( $ids ) ) {
			$this->update_control( [ 'status' => 'idle' ] );
			$this->logger->warn( 'Tick found no claimable rows; going idle.', 'migrate', $this->debug_counts() );
			return 0;
		}
		$id = (int) $ids[0];
		$this->logger->info( 'Tick claimed attachment ' . $id, 'migrate' );
		$this->offloader->process_attachment( $id );
		return $id;
	}

	/**
	 * Reset rows stuck in 'processing' back to pending. With $force, ignore the 5-min age window
	 * (safe because migration runs single-process).
	 */
	public function recover_stuck( bool $force = false ): int {
		global $wpdb;
		$table = DB::items_table();
		$ctrl  = $this->control();
		$where = $force ? "status='processing'" : "status='processing' AND updated_at < ( NOW() - INTERVAL 5 MINUTE )";
		$rows  = (int) $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table}
				 SET status = IF(attempts >= %d, 'failed', 'pending'),
				     last_error = IFNULL(last_error,'Recovered from stuck processing state.')
				 WHERE {$where}",
				(int) $ctrl['max_attempts']
			)
		);
		if ( $rows > 0 ) {
			$this->logger->warn( 'Recovered stuck rows: ' . $rows, 'migrate', [ 'force' => $force ] );
		}
		return $rows;
	}

	public function failed_items( int $limit = 50 ): array {
		global $wpdb;
		$table = DB::items_table();
		return (array) $wpdb->get_results(
			$wpdb->prepare(
				"SELECT attachment_id, relative_path, attempts, last_error, updated_at
				 FROM {$table}
				 WHERE status='failed'
				 ORDER BY updated_at DESC LIMIT %d",
				$limit
			)
		);
	}

	public function retry_single( int $attachment_id ): void {
		global $wpdb;
		$wpdb->update(
			DB::items_table(),
			[ 'status' => 'pending', 'last_error' => null, 'attempts' => 0 ],
			[ 'attachment_id' => $attachment_id ]
		);
	}

	public function stop(): void {
		$this->update_control( [ 'status' => 'stopped' ] );
	}

	private function schedule_next( int $delay = 5 ): void {
		if ( function_exists( 'as_enqueue_async_action' ) ) {
			as_enqueue_async_action( 'r2_offload_dispatch_batch', [], 'r2-offload' );
			return;
		}
		wp_schedule_single_event( time() + max( 1, $delay ), 'r2_offload_dispatch_batch' );
	}

	public function dispatch_batch(): void {
		$ctrl = $this->control();
		if ( $ctrl['status'] !== 'running' ) {
			return;
		}

		$ids = $this->claim_pending( (int) $ctrl['batch_size'] );
		if ( empty( $ids ) ) {
			$this->update_control( [ 'status' => 'idle' ] );
			$this->logger->info( 'Migration drained.', 'migrate' );
			return;
		}

		foreach ( $ids as $attachment_id ) {
			$this->queue->enqueue_attachment( $attachment_id, 0 );
		}
		$this->schedule_next( 10 );
		spawn_cron();
	}

	/**
	 * Claim next pending items atomically by bumping status → processing.
	 */
	private function claim_pending( int $limit ): array {
		global $wpdb;
		$table = DB::items_table();
		$ctrl  = $this->control();

		$rows = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT attachment_id FROM {$table}
				 WHERE status IN ('pending','failed') AND attempts < %d
				 ORDER BY id ASC LIMIT %d",
				(int) $ctrl['max_attempts'],
				$limit
			)
		);

		if ( empty( $rows ) ) {
			$this->logger->info(
				'claim_pending: no rows matched',
				'migrate',
				[
					'max_attempts' => (int) $ctrl['max_attempts'],
					'counts'       => $this->debug_counts(),
					'last_db_err'  => (string) $wpdb->last_error,
				]
			);
			return [];
		}

		$ids_sql = implode( ',', array_map( 'intval', $rows ) );
		$updated = $wpdb->query(
			"UPDATE {$table}
			 SET status='processing', attempts = attempts + 1, updated_at = NOW()
			 WHERE attachment_id IN ({$ids_sql})"
		);

		$this->logger->info(
			'claim_pending: claimed rows',
			'migrate',
			[ 'ids' => $rows, 'updated' => (int) $updated ]
		);

		return array_map( 'intval', $rows );
	}

	public function retry_failed(): void {
		global $wpdb;
		$table = DB::items_table();
		$ctrl  = $this->control();
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET status='pending', last_error=NULL WHERE status='failed' AND attempts < %d",
				(int) $ctrl['max_attempts']
			)
		);
		if ( $ctrl['status'] !== 'running' ) {
			$this->start( (int) $ctrl['batch_size'], (int) $ctrl['concurrency'] );
		}
	}

	public function progress(): array {
		global $wpdb;
		$table  = DB::items_table();
		$counts = $wpdb->get_results( "SELECT status, COUNT(*) AS c, COALESCE(SUM(bytes_total),0) AS b FROM {$table} GROUP BY status", OBJECT_K );
		$out    = [
			'pending'    => 0,
			'processing' => 0,
			'offloaded'  => 0,
			'failed'     => 0,
			'skipped'    => 0,
			'bytes'      => [ 'total' => 0, 'offloaded' => 0 ],
		];
		foreach ( $counts as $status => $r ) {
			$out[ $status ]      = (int) $r->c;
			$out['bytes']['total'] += (int) $r->b;
			if ( $status === 'offloaded' ) {
				$out['bytes']['offloaded'] = (int) $r->b;
			}
		}
		$out['control'] = $this->control();
		return $out;
	}
}
