<?php
namespace R2_Offload\Media;

use R2_Offload\DB;
use R2_Offload\Logger;
use R2_Offload\Settings;
use R2_Offload\Storage\Storage_Client;

defined( 'ABSPATH' ) || exit;

final class Pruner {

	public const STATE_OPTION = 'r2_offload_prune_state';

	private Storage_Client $storage;
	private Settings $settings;
	private Logger $logger;

	public function __construct( Storage_Client $storage, Settings $settings, Logger $logger ) {
		$this->storage  = $storage;
		$this->settings = $settings;
		$this->logger   = $logger;
		add_action( 'r2_offload_after_offloaded', [ $this, 'maybe_auto_prune' ], 10, 2 );
	}

	public function maybe_auto_prune( int $attachment_id, array $paths ): void {
		if ( ! $this->settings->get( 'auto_prune' ) ) {
			return;
		}
		if ( get_post_meta( $attachment_id, '_r2_offload_pruned', true ) ) {
			return;
		}
		$bytes = 0;
		foreach ( $paths as $local ) {
			$bytes += (int) @filesize( $local );
			@unlink( $local );
		}
		update_post_meta( $attachment_id, '_r2_offload_pruned', 1 );
		$this->logger->info( 'Auto-pruned ' . $attachment_id, 'prune', [ 'bytes' => $bytes ] );
	}

	public function reset_state(): void {
		delete_option( self::STATE_OPTION );
	}

	public function get_state(): array {
		return wp_parse_args(
			(array) get_option( self::STATE_OPTION, [] ),
			[
				'status'      => 'idle',
				'last_id'     => 0,
				'total'       => 0,
				'considered'  => 0,
				'pruned'      => 0,
				'skipped'     => 0,
				'bytes_freed' => 0,
				'dry_run'     => 0,
				'batch_size'  => 25,
				'limit'       => 0,
				'errors'      => [],
				'last_error'  => '',
				'message'     => '',
				'started_at'  => '',
				'finished_at' => '',
			]
		);
	}

	private function save_state( array $state ): void {
		update_option( self::STATE_OPTION, $state, false );
	}

	/**
	 * Start a batched prune run.
	 *
	 * @return array State snapshot.
	 */
	public function start( bool $dry_run = false, int $batch_size = 25 ): array {
		global $wpdb;
		$table = DB::items_table();
		$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE status='offloaded'" );

		$batch_size = max( 1, min( 200, $batch_size ) );

		$state = [
			'status'      => 'running',
			'last_id'     => 0,
			'total'       => $total,
			'considered'  => 0,
			'pruned'      => 0,
			'skipped'     => 0,
			'bytes_freed' => 0,
			'dry_run'     => $dry_run ? 1 : 0,
			'batch_size'  => $batch_size,
			'limit'       => 0,
			'errors'      => [],
			'last_error'  => '',
			'message'     => $total > 0 ? 'Starting prune…' : 'Nothing to prune.',
			'started_at'  => current_time( 'mysql' ),
			'finished_at' => '',
		];

		if ( ! $this->storage->is_available() ) {
			$state['status']     = 'error';
			$state['last_error'] = 'Storage is not available. Check provider credentials and connection.';
			$state['message']    = $state['last_error'];
			$state['finished_at'] = current_time( 'mysql' );
			$this->save_state( $state );
			$this->logger->error( $state['last_error'], 'prune' );
			return $state;
		}

		if ( $total === 0 ) {
			$state['status']      = 'finished';
			$state['finished_at'] = current_time( 'mysql' );
			$state['message']     = 'Nothing to prune.';
		}

		$this->save_state( $state );
		$this->logger->info( 'Prune started', 'prune', [ 'total' => $total, 'dry_run' => $dry_run ] );
		return $state;
	}

	public function stop(): array {
		$state = $this->get_state();
		if ( $state['status'] === 'running' ) {
			$state['status']      = 'stopped';
			$state['finished_at'] = current_time( 'mysql' );
			$state['message']     = $this->format_message( $state );
			$this->save_state( $state );
			$this->logger->info( 'Prune stopped', 'prune', $state );
		}
		return $state;
	}

	/**
	 * Process one batch of offloaded items. Returns state snapshot.
	 */
	public function tick( int $batch_size = 0 ): array {
		$state = $this->get_state();

		if ( $state['status'] !== 'running' ) {
			return $state;
		}

		if ( ! $this->storage->is_available() ) {
			$state['status']      = 'error';
			$state['last_error']  = 'Storage is not available. Check provider credentials and connection.';
			$state['message']     = $state['last_error'];
			$state['finished_at'] = current_time( 'mysql' );
			$this->save_state( $state );
			$this->logger->error( $state['last_error'], 'prune' );
			return $state;
		}

		$batch_size = $batch_size > 0 ? max( 1, min( 200, $batch_size ) ) : (int) $state['batch_size'];
		if ( $batch_size < 1 ) {
			$batch_size = 25;
		}

		global $wpdb;
		$table = DB::items_table();
		$rows  = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, attachment_id, object_prefix, relative_path
				 FROM {$table}
				 WHERE status='offloaded' AND id > %d
				 ORDER BY id ASC
				 LIMIT %d",
				(int) $state['last_id'],
				$batch_size
			)
		);

		if ( empty( $rows ) ) {
			$state['status']      = 'finished';
			$state['finished_at'] = current_time( 'mysql' );
			$state['message']     = $this->format_message( $state );
			$this->save_state( $state );
			$this->logger->info( 'Prune finished', 'prune', $state );
			return $state;
		}

		$uploads = wp_get_upload_dir();
		$base    = trailingslashit( $uploads['basedir'] );
		$dry     = (bool) $state['dry_run'];
		$limit   = (int) $state['limit'];

		foreach ( $rows as $row ) {
			$state['last_id'] = (int) $row->id;
			$id               = (int) $row->attachment_id;

			if ( $limit > 0 && (int) $state['considered'] >= $limit ) {
				$state['status']      = 'finished';
				$state['finished_at'] = current_time( 'mysql' );
				$state['message']     = $this->format_message( $state );
				$this->save_state( $state );
				return $state;
			}

			$state['considered']++;

			$result = $this->prune_one( $id, (string) $row->object_prefix, $base, $dry );
			if ( $result['pruned'] ) {
				$state['pruned']++;
				$state['bytes_freed'] += (int) $result['bytes'];
			} else {
				$state['skipped']++;
				if ( ! empty( $result['error'] ) ) {
					$this->push_error( $state, $result['error'] );
				}
			}

			$state['message'] = $this->format_message( $state );
		}

		if ( $limit > 0 && (int) $state['considered'] >= $limit ) {
			$state['status']      = 'finished';
			$state['finished_at'] = current_time( 'mysql' );
			$state['message']     = $this->format_message( $state );
		}

		$this->save_state( $state );
		return $state;
	}

	/**
	 * Compatibility wrapper: start + loop tick until done (or limit reached).
	 */
	public function prune( bool $dry_run = false, int $limit = 0 ): array {
		$state = $this->start( $dry_run, 25 );
		if ( $state['status'] === 'error' ) {
			return $this->compat_result( $state );
		}

		if ( $limit > 0 ) {
			$state['limit'] = $limit;
			$this->save_state( $state );
		}

		$guard = 0;
		while ( $state['status'] === 'running' && $guard < 100000 ) {
			$state = $this->tick( (int) $state['batch_size'] );
			$guard++;
		}

		return $this->compat_result( $state );
	}

	private function compat_result( array $state ): array {
		return [
			'considered'  => (int) $state['considered'],
			'pruned'      => (int) $state['pruned'],
			'skipped'     => (int) $state['skipped'],
			'bytes_freed' => (int) $state['bytes_freed'],
			'dry_run'     => (bool) $state['dry_run'],
			'errors'      => (array) $state['errors'],
			'status'      => (string) $state['status'],
			'message'     => (string) $state['message'],
			'last_error'  => (string) $state['last_error'],
			'total'       => (int) $state['total'],
		];
	}

	/**
	 * @return array{pruned:bool,bytes:int,error:?array}
	 */
	private function prune_one( int $id, string $row_prefix, string $base, bool $dry_run ): array {
		if ( get_post_meta( $id, '_r2_offload_pruned', true ) ) {
			return [ 'pruned' => false, 'bytes' => 0, 'error' => null ];
		}

		$paths = Path_Resolver::attachment_files( $id );
		if ( empty( $paths ) ) {
			return [
				'pruned' => false,
				'bytes'  => 0,
				'error'  => [ 'id' => $id, 'reason' => 'no_local_files' ],
			];
		}

		$prefix = trim( $row_prefix, '/' );
		if ( $prefix === '' ) {
			$prefix = $this->settings->effective_prefix();
		}

		foreach ( $paths as $local ) {
			$relative = ltrim( str_replace( $base, '', $local ), '/' );
			$object   = $prefix !== '' ? $prefix . '/' . $relative : $relative;
			$info     = $this->storage->head_object( $object );
			if ( ! $info ) {
				return [
					'pruned' => false,
					'bytes'  => 0,
					'error'  => [
						'id'     => $id,
						'reason' => 'missing_on_remote',
						'object' => $object,
						'path'   => $relative,
					],
				];
			}
			$lsize = (int) @filesize( $local );
			if ( $lsize > 0 && isset( $info['size'] ) && (int) $info['size'] !== $lsize ) {
				return [
					'pruned' => false,
					'bytes'  => 0,
					'error'  => [
						'id'     => $id,
						'reason' => 'size_mismatch',
						'object' => $object,
						'path'   => $relative,
						'local'  => $lsize,
						'remote' => (int) $info['size'],
					],
				];
			}
		}

		$bytes       = 0;
		$unlink_fail = [];
		foreach ( $paths as $local ) {
			$bytes += (int) @filesize( $local );
			if ( $dry_run ) {
				continue;
			}
			if ( ! @unlink( $local ) && file_exists( $local ) ) {
				$unlink_fail[] = [
					'path'     => $local,
					'writable' => is_writable( $local ),
					'owner'    => function_exists( 'fileowner' ) ? @fileowner( $local ) : null,
				];
			}
		}

		if ( ! empty( $unlink_fail ) ) {
			$this->logger->error( 'Prune unlink failed for ' . $id, 'prune', [ 'fail' => $unlink_fail ] );
			return [
				'pruned' => false,
				'bytes'  => 0,
				'error'  => [ 'id' => $id, 'reason' => 'unlink_failed', 'details' => $unlink_fail ],
			];
		}

		if ( ! $dry_run ) {
			update_post_meta( $id, '_r2_offload_pruned', 1 );
			$this->logger->info( 'Pruned local files for ' . $id, 'prune', [ 'bytes' => $bytes, 'files' => count( $paths ) ] );
		}

		return [ 'pruned' => true, 'bytes' => $bytes, 'error' => null ];
	}

	private function push_error( array &$state, array $error ): void {
		$reason = (string) ( $error['reason'] ?? 'error' );
		$path   = (string) ( $error['path'] ?? $error['object'] ?? '' );
		$id     = (int) ( $error['id'] ?? 0 );
		$state['last_error'] = trim( sprintf( '#%d %s%s', $id, $reason, $path !== '' ? ' · ' . $path : '' ) );

		$errors = (array) $state['errors'];
		if ( count( $errors ) < 50 ) {
			$errors[] = $error;
			$state['errors'] = $errors;
		}
	}

	private function format_message( array $state ): string {
		$total      = max( 0, (int) $state['total'] );
		$considered = (int) $state['considered'];
		$pct        = $total > 0 ? (int) floor( ( $considered / $total ) * 100 ) : 0;
		$msg        = sprintf(
			'Pruning %d/%d (%d%%) · pruned %d · skipped %d · freed %s',
			$considered,
			$total,
			$pct,
			(int) $state['pruned'],
			(int) $state['skipped'],
			size_format( (int) $state['bytes_freed'], 2 )
		);
		if ( ! empty( $state['last_error'] ) ) {
			$msg .= ' · last error: ' . $state['last_error'];
		}
		if ( $state['status'] === 'finished' ) {
			$msg = 'Finished · ' . $msg;
		} elseif ( $state['status'] === 'stopped' ) {
			$msg = 'Stopped · ' . $msg;
		}
		return $msg;
	}

	public function restore( int $attachment_id ): array {
		global $wpdb;
		$item = $wpdb->get_row( $wpdb->prepare( "SELECT object_prefix FROM " . DB::items_table() . " WHERE attachment_id=%d", $attachment_id ) );
		$meta = wp_get_attachment_metadata( $attachment_id );
		if ( ! $meta ) {
			return [ 'ok' => false, 'message' => 'No metadata.' ];
		}
		$uploads = wp_get_upload_dir();
		$base    = trailingslashit( $uploads['basedir'] );
		$prefix  = $item && $item->object_prefix ? trim( (string) $item->object_prefix, '/' ) : $this->settings->effective_prefix();

		$original = get_attached_file( $attachment_id );
		if ( ! $original ) {
			return [ 'ok' => false, 'message' => 'No attached file path.' ];
		}

		$targets = [ $original ];
		$dir     = trailingslashit( dirname( $original ) );
		if ( ! empty( $meta['sizes'] ) ) {
			foreach ( $meta['sizes'] as $s ) {
				if ( ! empty( $s['file'] ) ) {
					$targets[] = $dir . $s['file'];
				}
			}
		}

		$restored = 0;
		foreach ( $targets as $local ) {
			$relative = ltrim( str_replace( $base, '', $local ), '/' );
			$object   = $prefix !== '' ? $prefix . '/' . $relative : $relative;
			wp_mkdir_p( dirname( $local ) );
			if ( $this->storage->download_object( $object, $local ) ) {
				$restored++;
			}
		}
		delete_post_meta( $attachment_id, '_r2_offload_pruned' );
		$this->logger->info( 'Restored ' . $restored . ' files for ' . $attachment_id, 'prune' );
		return [ 'ok' => $restored > 0, 'restored' => $restored ];
	}
}
