<?php
namespace R2_Offload\Media;

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

defined( 'ABSPATH' ) || exit;

final class Offloader {

	public const META_ORIGINAL_KEY   = '_r2_offload_original_key';
	public const META_AWAIT_IMAGIFY  = '_r2_offload_await_imagify';

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

	public function __construct( Storage_Client $storage, Settings $settings, Logger $logger, Scheduler $queue ) {
		$this->storage  = $storage;
		$this->settings = $settings;
		$this->logger   = $logger;
		$this->queue    = $queue;
	}

	public function register(): void {
		// Filter (not action): must return $metadata. Priority 20 — still the pristine
		// original on disk; Imagify auto-optimize launches after metadata is saved.
		add_filter( 'wp_generate_attachment_metadata', [ $this, 'on_metadata' ], 20, 2 );
		add_action( 'r2_offload_process_attachment', [ $this, 'process_attachment' ], 10, 1 );
		add_action( 'delete_attachment', [ $this, 'on_delete' ], 10, 1 );

		// Imagify: serve-copy offload only after optimize finishes (local file must remain
		// readable for Imagify). Fallback timeout covers skipped/failed Imagify.
		add_action( 'imagify_after_optimize', [ $this, 'on_imagify_after_optimize' ], 20, 2 );
		add_action( 'imagify_new_attachment_auto_optimization_disabled', [ $this, 'on_imagify_skipped' ], 20, 2 );

		// BuddyBoss / BuddyPress avatar uploads land outside the Media Library.
		add_action( 'xprofile_avatar_uploaded', [ $this, 'on_avatar_uploaded' ], 20, 3 );
		add_action( 'groups_avatar_uploaded', [ $this, 'on_avatar_uploaded' ], 20, 3 );
	}

	public function on_metadata( $metadata, $attachment_id ) {
		if ( ! is_array( $metadata ) ) {
			$metadata = [];
		}
		$attachment_id = (int) $attachment_id;

		if ( ! $this->settings->get( 'offload_on_upload' ) ) {
			return $metadata;
		}
		if ( ! $this->storage->is_available() ) {
			$this->logger->warn( 'Storage not configured; skipping offload.', 'offload', [ 'attachment' => $attachment_id ] );
			return $metadata;
		}

		DB::upsert_item(
			$attachment_id,
			[
				'relative_path' => '',
				'status'        => 'pending',
				'provider'      => $this->settings->provider(),
				'bucket'        => (string) $this->settings->get( 'bucket' ),
				'object_prefix' => $this->settings->effective_prefix(),
				'updated_at'    => current_time( 'mysql' ),
			]
		);

		// Archive the pristine original to R2 *before* Imagify rewrites the local file.
		// Local file is intentionally kept so Imagify can still read it.
		$this->archive_original( $attachment_id );

		if ( $this->should_wait_for_imagify( $attachment_id ) ) {
			update_post_meta( $attachment_id, self::META_AWAIT_IMAGIFY, time() );
			// Safety net if Imagify never fires (unsupported mime, quota, failure).
			$delay = max( 60, (int) $this->settings->get( 'imagify_fallback_delay', 180 ) );
			$this->queue->enqueue_attachment( $attachment_id, $delay );
			$this->logger->info(
				'Deferred serve offload until Imagify finishes (or fallback timeout).',
				'offload',
				[ 'attachment' => $attachment_id, 'fallback_s' => $delay ]
			);
			return $metadata;
		}

		// No Imagify wait — offload serve copy in this request (previous behaviour).
		$id = $attachment_id;
		add_action(
			'shutdown',
			function () use ( $id ) {
				$this->process_attachment( $id );
			},
			5
		);
		$this->queue->enqueue_attachment( $attachment_id, 15 );
		return $metadata;
	}

	/**
	 * Imagify finished optimizing (may be async). Queue a short-delayed serve offload
	 * so next-gen webp/avif sidecars have time to land on disk.
	 *
	 * @param mixed $process Imagify ProcessInterface.
	 * @param mixed $item    Job item (unused).
	 */
	public function on_imagify_after_optimize( $process, $item = null ): void {
		$id = $this->attachment_id_from_imagify_process( $process );
		if ( $id <= 0 ) {
			return;
		}
		if ( ! get_post_meta( $id, self::META_AWAIT_IMAGIFY, true ) ) {
			return;
		}
		if ( ! $this->settings->get( 'offload_on_upload' ) || ! $this->storage->is_available() ) {
			return;
		}

		delete_post_meta( $id, self::META_AWAIT_IMAGIFY );
		$this->logger->info( 'Imagify done; scheduling serve offload (+20s for sidecars).', 'offload', [ 'attachment' => $id ] );
		$this->queue->enqueue_attachment( $id, 20 );
	}

	/**
	 * Auto-optimize disabled for this new upload — offload current local files now.
	 *
	 * @param mixed $metadata Unused.
	 */
	public function on_imagify_skipped( $attachment_id, $metadata = null ): void {
		$id = (int) $attachment_id;
		if ( $id <= 0 || ! get_post_meta( $id, self::META_AWAIT_IMAGIFY, true ) ) {
			return;
		}
		delete_post_meta( $id, self::META_AWAIT_IMAGIFY );
		$this->logger->info( 'Imagify skipped; offloading without wait.', 'offload', [ 'attachment' => $id ] );
		add_action(
			'shutdown',
			function () use ( $id ) {
				$this->process_attachment( $id );
			},
			5
		);
	}

	/**
	 * Queue newly written avatar files under configured extra upload dirs.
	 *
	 * @param mixed $item_id Unused BP item id.
	 * @param mixed $type    Unused avatar type.
	 * @param mixed $args    Unused.
	 */
	public function on_avatar_uploaded( $item_id = null, $type = null, $args = null ): void {
		if ( ! $this->settings->get( 'offload_on_upload' ) || ! $this->storage->is_available() ) {
			return;
		}

		$dirs = $this->settings->extra_upload_dirs();
		if ( empty( $dirs ) ) {
			return;
		}

		$uploads = wp_get_upload_dir();
		$basedir = trailingslashit( $uploads['basedir'] );
		$cutoff  = time() - 600;
		$bucket  = (string) $this->settings->get( 'bucket' );
		$prefix  = $this->settings->effective_prefix();

		foreach ( array_intersect( $dirs, [ 'avatars', 'bp-avatars' ] ) as $dir ) {
			$root = $basedir . $dir;
			if ( ! is_dir( $root ) ) {
				continue;
			}
			try {
				$iterator = new \RecursiveIteratorIterator(
					new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS )
				);
			} catch ( \Throwable $e ) {
				continue;
			}
			/** @var \SplFileInfo $file */
			foreach ( $iterator as $file ) {
				if ( ! $file->isFile() || Path_Resolver::should_skip_file( $file->getBasename() ) ) {
					continue;
				}
				if ( (int) $file->getMTime() < $cutoff ) {
					continue;
				}
				$rel = ltrim( str_replace( '\\', '/', substr( $file->getPathname(), strlen( $basedir ) ) ), '/' );
				$existing = DB::get_item_by_path( $rel );
				if ( $existing && in_array( $existing->status, [ 'offloaded', 'pending', 'processing' ], true ) ) {
					continue;
				}
				$id = DB::path_to_id( $rel );
				DB::upsert_item(
					$id,
					[
						'relative_path' => $rel,
						'status'        => 'pending',
						'provider'      => $this->settings->provider(),
						'bucket'        => $bucket,
						'object_prefix' => $prefix,
						'bytes_total'   => (int) $file->getSize(),
						'updated_at'    => current_time( 'mysql' ),
					]
				);
				$this->queue->enqueue_attachment( $id );
			}
		}
	}

	public function process_attachment( int $attachment_id ): void {
		$this->logger->info( 'process_attachment start', 'offload', [ 'attachment' => $attachment_id ] );

		if ( ! $this->storage->is_available() ) {
			$reason = 'Storage not configured (SDK missing or bucket/creds empty).';
			DB::set_status( $attachment_id, 'failed', $reason );
			$this->logger->error( $reason, 'offload', [ 'attachment' => $attachment_id ] );
			return;
		}

		$item = DB::get_item( $attachment_id );
		if ( $item && (string) $item->status === 'offloaded' ) {
			$this->logger->info( 'Already offloaded; skipping.', 'offload', [ 'attachment' => $attachment_id ] );
			return;
		}

		// New-upload Imagify gate (migration/path items never set this meta).
		$await = get_post_meta( $attachment_id, self::META_AWAIT_IMAGIFY, true );
		if ( $await ) {
			$fallback = max( 60, (int) $this->settings->get( 'imagify_fallback_delay', 180 ) );
			$age      = time() - (int) $await;
			if ( $age < $fallback ) {
				$this->logger->info(
					'Still waiting for Imagify; not offloading serve copy yet.',
					'offload',
					[ 'attachment' => $attachment_id, 'age_s' => $age, 'fallback_s' => $fallback ]
				);
				return;
			}
			delete_post_meta( $attachment_id, self::META_AWAIT_IMAGIFY );
			$this->logger->warn(
				'Imagify wait timed out; offloading current local files.',
				'offload',
				[ 'attachment' => $attachment_id, 'age_s' => $age ]
			);
		}

		// Note: status may already be "processing" — Migrator::claim_pending() sets that
		// before calling us. Do NOT bail on processing or migration loops forever.

		if ( $attachment_id < 0 || ( $item && (string) ( $item->relative_path ?? '' ) !== '' ) ) {
			$this->process_path_item( $attachment_id, $item );
			return;
		}

		$paths = Path_Resolver::attachment_files( $attachment_id );
		if ( empty( $paths ) ) {
			DB::set_status( $attachment_id, 'skipped', 'No local files found.' );
			$this->logger->warn( 'Skipped: no local files.', 'offload', [ 'attachment' => $attachment_id ] );
			return;
		}

		$this->logger->info( 'process_attachment paths resolved', 'offload', [ 'attachment' => $attachment_id, 'count' => count( $paths ), 'first' => $paths[0] ] );
		DB::set_status( $attachment_id, 'processing' );

		$uploads = wp_get_upload_dir();
		$base    = trailingslashit( $uploads['basedir'] );
		$prefix  = $this->settings->effective_prefix();
		$item_id = $item ? (int) $item->id : DB::upsert_item( $attachment_id, [ 'status' => 'processing' ] );

		try {
			$file_rows = [];
			$bytes     = 0;
			foreach ( $paths as $local ) {
				$relative   = ltrim( str_replace( $base, '', $local ), '/' );
				$object_key = $prefix !== '' ? $prefix . '/' . $relative : $relative;
				$mime       = function_exists( 'mime_content_type' ) ? (string) @mime_content_type( $local ) : '';
				$size       = (int) @filesize( $local );
				$bytes     += $size;
				$this->storage->upload_file( $local, $object_key, $mime );
				$file_rows[] = [
					'relative_path' => $relative,
					'mime_type'     => $mime,
					'size_bytes'    => $size,
					'status'        => 'offloaded',
				];
			}
			DB::replace_files( $item_id, $file_rows );
			DB::upsert_item(
				$attachment_id,
				[
					'status'         => 'offloaded',
					'last_error'     => null,
					'bytes_total'    => $bytes,
					'bytes_uploaded' => $bytes,
					'updated_at'     => current_time( 'mysql' ),
				]
			);
			update_post_meta( $attachment_id, '_r2_offload_status', 'offloaded' );
			delete_post_meta( $attachment_id, self::META_AWAIT_IMAGIFY );
			$this->logger->info( 'Offloaded attachment ' . $attachment_id, 'offload', [ 'files' => count( $paths ) ] );
			do_action( 'r2_offload_after_offloaded', $attachment_id, $paths );
		} catch ( \Throwable $e ) {
			DB::set_status( $attachment_id, 'failed', $e->getMessage() );
			$this->logger->error( 'Offload failed: ' . $e->getMessage(), 'offload', [ 'attachment' => $attachment_id ] );
		}
	}

	/**
	 * Copy the current attached file to the originals archive prefix on remote.
	 * Does not delete or modify the local file.
	 */
	public function archive_original( int $attachment_id ): bool {
		if ( ! $this->settings->get( 'archive_originals' ) ) {
			return false;
		}
		if ( ! $this->storage->is_available() ) {
			return false;
		}
		if ( ! wp_attachment_is_image( $attachment_id ) ) {
			return false;
		}

		$local = get_attached_file( $attachment_id );
		if ( ! $local || ! file_exists( $local ) ) {
			$this->logger->warn( 'Cannot archive original; local missing.', 'offload', [ 'attachment' => $attachment_id ] );
			return false;
		}

		$uploads = wp_get_upload_dir();
		$base    = trailingslashit( $uploads['basedir'] );
		$rel     = ltrim( str_replace( '\\', '/', str_replace( $base, '', $local ) ), '/' );
		$arch    = $this->settings->originals_prefix();
		$key     = $arch !== '' ? $arch . '/' . $rel : $rel;

		try {
			$mime = function_exists( 'mime_content_type' ) ? (string) @mime_content_type( $local ) : '';
			$this->storage->upload_file( $local, $key, $mime );
			update_post_meta( $attachment_id, self::META_ORIGINAL_KEY, $key );
			$this->logger->info(
				'Archived original to remote.',
				'offload',
				[ 'attachment' => $attachment_id, 'key' => $key, 'bytes' => (int) @filesize( $local ) ]
			);
			return true;
		} catch ( \Throwable $e ) {
			$this->logger->error(
				'Original archive failed: ' . $e->getMessage(),
				'offload',
				[ 'attachment' => $attachment_id, 'key' => $key ]
			);
			return false;
		}
	}

	private function should_wait_for_imagify( int $attachment_id ): bool {
		if ( ! $this->settings->get( 'wait_for_imagify' ) ) {
			return false;
		}
		if ( ! $this->imagify_is_active() ) {
			return false;
		}
		if ( ! wp_attachment_is_image( $attachment_id ) ) {
			return false;
		}
		// Respect Imagify auto-optimize master switch when available.
		if ( function_exists( 'get_imagify_option' ) && ! get_imagify_option( 'auto_optimize' ) ) {
			return false;
		}
		if ( function_exists( 'imagify_is_attachment_mime_type_supported' )
			&& ! imagify_is_attachment_mime_type_supported( $attachment_id ) ) {
			return false;
		}
		return true;
	}

	private function imagify_is_active(): bool {
		return defined( 'IMAGIFY_VERSION' )
			|| function_exists( 'imagify_get_optimization_process' )
			|| class_exists( '\Imagify_Auto_Optimization', false );
	}

	/** @param mixed $process */
	private function attachment_id_from_imagify_process( $process ): int {
		if ( ! is_object( $process ) || ! method_exists( $process, 'get_media' ) ) {
			return 0;
		}
		try {
			$media = $process->get_media();
			if ( is_object( $media ) && method_exists( $media, 'get_id' ) ) {
				return (int) $media->get_id();
			}
		} catch ( \Throwable $e ) {
			return 0;
		}
		return 0;
	}

	private function process_path_item( int $synthetic_id, ?object $item ): void {
		$relative = $item ? (string) ( $item->relative_path ?? '' ) : '';
		if ( $relative === '' ) {
			DB::set_status( $synthetic_id, 'failed', 'Missing relative_path for path item.' );
			return;
		}

		$local = Path_Resolver::absolute_from_relative( $relative );
		if ( ! file_exists( $local ) ) {
			DB::set_status( $synthetic_id, 'skipped', 'No local file for path item.' );
			$this->logger->warn( 'Skipped path: missing file.', 'offload', [ 'path' => $relative ] );
			return;
		}

		DB::set_status( $synthetic_id, 'processing' );
		$prefix  = $this->settings->effective_prefix();
		$item_id = (int) $item->id;

		try {
			$object_key = $prefix !== '' ? $prefix . '/' . $relative : $relative;
			$mime       = function_exists( 'mime_content_type' ) ? (string) @mime_content_type( $local ) : '';
			$size       = (int) @filesize( $local );
			$this->storage->upload_file( $local, $object_key, $mime );
			DB::replace_files(
				$item_id,
				[
					[
						'relative_path' => $relative,
						'mime_type'     => $mime,
						'size_bytes'    => $size,
						'status'        => 'offloaded',
					],
				]
			);
			DB::upsert_item(
				$synthetic_id,
				[
					'status'         => 'offloaded',
					'last_error'     => null,
					'bytes_total'    => $size,
					'bytes_uploaded' => $size,
					'updated_at'     => current_time( 'mysql' ),
				]
			);
			$this->logger->info( 'Offloaded path ' . $relative, 'offload' );
			do_action( 'r2_offload_after_path_offloaded', $relative, $local );
		} catch ( \Throwable $e ) {
			DB::set_status( $synthetic_id, 'failed', $e->getMessage() );
			$this->logger->error( 'Path offload failed: ' . $e->getMessage(), 'offload', [ 'path' => $relative ] );
		}
	}

	public function on_delete( int $attachment_id ): void {
		$item = DB::get_item( $attachment_id );
		if ( ! $this->storage->is_available() ) {
			return;
		}

		// Always try to remove archived original if we stored one.
		$original_key = (string) get_post_meta( $attachment_id, self::META_ORIGINAL_KEY, true );
		if ( $original_key !== '' ) {
			$this->storage->delete_object( $original_key );
			delete_post_meta( $attachment_id, self::META_ORIGINAL_KEY );
		}

		if ( ! $item || $item->status !== 'offloaded' ) {
			return;
		}

		$uploads = wp_get_upload_dir();
		$base    = trailingslashit( $uploads['basedir'] );
		$prefix  = trim( (string) $item->object_prefix, '/' );

		$relatives = [];
		global $wpdb;
		$files_table = DB::files_table();
		$recorded    = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT relative_path FROM {$files_table} WHERE offload_item_id = %d",
				(int) $item->id
			)
		);
		if ( ! empty( $recorded ) ) {
			$relatives = $recorded;
		} else {
			$paths = Path_Resolver::attachment_files( $attachment_id );
			if ( ! empty( $paths ) ) {
				foreach ( $paths as $local ) {
					$relatives[] = ltrim( str_replace( $base, '', $local ), '/' );
				}
			} else {
				$relatives = Path_Resolver::relative_paths_from_meta( $attachment_id, $uploads['basedir'] );
			}
		}

		foreach ( array_unique( $relatives ) as $relative ) {
			$object_key = $prefix !== '' ? $prefix . '/' . $relative : $relative;
			$this->storage->delete_object( $object_key );
		}
	}
}
