<?php
/**
 * Trash manager — moves files to uploads/cwpm/trash/.
 *
 * All destructive clean actions route through this class so the trash
 * mechanics are in one place.
 *
 * @package CoffeeWP_Media_Cleanup
 */

namespace CWPM\Core;

use CWPM\Singleton;

defined( 'ABSPATH' ) || exit;

class Trash extends Singleton {

	/**
	 * Relative path from the uploads basedir where trashed files live.
	 */
	const REL_PATH = 'cwpm/trash';

	/**
	 * Constructor — register WordPress hooks.
	 */
	protected function __construct() {
		parent::__construct();
		$this->register_hooks();
	}

	/**
	 * Register WordPress hooks for the trash system.
	 */
	protected function register_hooks() {
		add_filter( 'wp_unique_filename', array( __CLASS__, 'protect_trash_filenames' ), 10, 4 );
		add_action( 'admin_init', array( __CLASS__, 'auto_delete_expired' ) );
	}

	/**
	 * Prevent new uploads from claiming a filename that exists in the trash.
	 *
	 * WordPress calls wp_unique_filename() to find a free name inside the
	 * uploads directory. This filter additionally checks the corresponding
	 * path inside cwpm-trash/ so a new upload can never silently overwrite a
	 * trashed file if it were later restored.
	 *
	 * @param string        $filename                Proposed filename.
	 * @param string        $ext                     File extension with leading dot.
	 * @param string        $dir                     Target directory (absolute path).
	 * @param callable|null $unique_filename_callback Optional callback (unused).
	 * @return string Safe filename.
	 */
	public static function protect_trash_filenames( $filename, $ext, $dir, $unique_filename_callback ) {
		$upload_basedir = wp_normalize_path( rtrim( wp_upload_dir()['basedir'], '/\\' ) );
		$dir            = wp_normalize_path( rtrim( $dir, '/\\' ) );

		if ( 0 !== strpos( $dir, $upload_basedir ) ) {
			return $filename;
		}

		$rel_dir   = ltrim( substr( $dir, strlen( $upload_basedir ) ), '/\\' );
		$trash_dir = self::get_trash_dir();

		if ( '' !== $rel_dir ) {
			$trash_path = $trash_dir . '/' . $rel_dir . '/' . $filename;
		} else {
			$trash_path = $trash_dir . '/' . $filename;
		}

		if ( ! file_exists( $trash_path ) ) {
			return $filename;
		}

		$name   = pathinfo( $filename, PATHINFO_FILENAME );
		$number = 1;

		do {
			$alt = $name . '-' . $number . $ext;

			$uploads_exists = file_exists( $dir . '/' . $alt );

			if ( '' !== $rel_dir ) {
				$trash_exists = file_exists( $trash_dir . '/' . $rel_dir . '/' . $alt );
			} else {
				$trash_exists = file_exists( $trash_dir . '/' . $alt );
			}

			++$number;
		} while ( $uploads_exists || $trash_exists );

		return $alt;
	}

	/**
	 * Return the absolute path to the trash root directory.
	 *
	 * Lives under the WP uploads basedir (uploads/cwpm/trash/) so we never
	 * write outside the wp.org-permitted user-data location.
	 *
	 * @return string
	 */
	public static function get_trash_dir() {
		$upload_dir = wp_upload_dir();
		return $upload_dir['basedir'] . '/' . self::REL_PATH;
	}

	/**
	 * Ensure the trash directory exists and is protected from direct access.
	 *
	 * Creates the directory and writes .htaccess + index.php stubs on first call.
	 * On first load after an upgrade, runs sync_trash_table() to populate the
	 * DB table from any existing trash files on disk.
	 *
	 * @return string Absolute path to the trash root directory.
	 */
	public static function ensure_trash_dir() {
		$trash_dir = self::get_trash_dir();

		if ( ! is_dir( $trash_dir ) ) {
			wp_mkdir_p( $trash_dir );
		}

		$htaccess = $trash_dir . '/.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === file_put_contents( $htaccess, "deny from all\n" ) ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- bootstrap-time fallback when trash dir can't be protected.
				error_log( '[CWPM] Failed to write trash htaccess at ' . $htaccess );
			}
		}

		$index = $trash_dir . '/index.php';
		if ( ! file_exists( $index ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === file_put_contents( $index, "<?php // Silence is golden.\n" ) ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- bootstrap-time fallback when trash dir can't be protected.
				error_log( '[CWPM] Failed to write trash index.php at ' . $index );
			}
		}

		if ( ! get_option( 'cwpm_trash_synced', false ) ) {
			self::sync_trash_table();
			update_option( 'cwpm_trash_synced', true );
		}

		return $trash_dir;
	}

	/**
	 * Move a single file into the trash, preserving its relative sub-path.
	 *
	 * Example: moving uploads/2024/06/image-300x200.jpg places it at
	 * wp-content/cwpm-trash/2024/06/image-300x200.jpg.
	 *
	 * If the destination filename already exists a timestamp suffix is appended
	 * to avoid overwriting a previously trashed copy.
	 *
	 * @param string $abs_src Absolute path to the source file.
	 * @param string $rel_dir Relative directory within uploads (e.g. '2024/06').
	 * @return bool True on success, false if the source file does not exist or rename fails.
	 */
	public static function move_to_trash( $abs_src, $rel_dir ) {
		if ( ! file_exists( $abs_src ) ) {
			return false;
		}

		$trash_dir = self::ensure_trash_dir();
		$dest_dir  = $trash_dir . '/' . ltrim( $rel_dir, '/' );

		if ( ! is_dir( $dest_dir ) ) {
			wp_mkdir_p( $dest_dir );
		}

		$filename = basename( $abs_src );
		$dest     = $dest_dir . '/' . $filename;

		if ( file_exists( $dest ) ) {
			$ext  = pathinfo( $filename, PATHINFO_EXTENSION );
			$base = pathinfo( $filename, PATHINFO_FILENAME );
			$dest = $dest_dir . '/' . $base . '-' . time() . ( $ext ? '.' . $ext : '' );
		}

		$file_size     = (int) filesize( $abs_src );
		$trash_rel     = ltrim( str_replace( $trash_dir, '', $dest ), '/' );
		$original_path = '' !== $rel_dir ? $rel_dir . '/' . $filename : $filename;

		$insert_id = Db::insert_trash_record(
			array(
				'rel_path'      => $trash_rel,
				'original_path' => $original_path,
				'file_name'     => $filename,
				'file_size'     => $file_size,
			)
		);

		if ( false === $insert_id || 0 === $insert_id ) {
			return false;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
		$success = rename( $abs_src, $dest );

		if ( ! $success ) {

			Db::delete_trash_record_by_id( $insert_id );
			return false;
		}

		return true;
	}

	/**
	 * List all files currently in the trash, newest first.
	 *
	 * @deprecated Not used by the AJAX handler. Use Db::query_trash() instead.
	 *
	 * @return array[] Each entry: { rel_path, file, size, size_formatted, trashed_at }.
	 */
	public static function list_trash() {
		$trash_dir = self::get_trash_dir();

		if ( ! is_dir( $trash_dir ) ) {
			return array();
		}

		$results = array();
		$iter    = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $trash_dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::LEAVES_ONLY
		);

		foreach ( $iter as $file_info ) {
			if ( ! $file_info->isFile() ) {
				continue;
			}

			$basename = $file_info->getFilename();

			if ( '.htaccess' === $basename || 'index.php' === $basename ) {
				continue;
			}

			$abs_path = $file_info->getPathname();
			$rel_path = ltrim( str_replace( $trash_dir, '', $abs_path ), DIRECTORY_SEPARATOR . '/' );
			$rel_path = str_replace( DIRECTORY_SEPARATOR, '/', $rel_path );
			$size     = (int) $file_info->getSize();

			$results[] = array(
				'rel_path'       => $rel_path,
				'file'           => $basename,
				'size'           => $size,
				'size_formatted' => size_format( $size, 1 ),
				'trashed_at'     => (int) $file_info->getMTime(),
			);
		}

		usort( $results, fn( $a, $b ) => $b['trashed_at'] - $a['trashed_at'] );

		return $results;
	}

	/**
	 * Resolve a trash row's rel_path to the linked attachment ID.
	 *
	 * Looks up the post that has _wp_attached_file === $rel_path AND
	 * post_type IN ('attachment','cwpm-trash'). Returns 0 when no
	 * attachment is linked (orphan tier-1 rows, e.g. inserted by the
	 * delete-missing flow for files that never had a Media Library entry,
	 * or thumbnail rows whose rel_path doesn't match any _wp_attached_file).
	 *
	 * @param string $rel_path Uploads-relative path (matches both wp_cwpm_trash.original_path
	 *                         and wp_postmeta._wp_attached_file for the main file).
	 * @return int Attachment ID, or 0 if not found.
	 */
	public static function find_attachment_by_rel_path( $rel_path ) {
		global $wpdb;

		if ( '' === $rel_path ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT p.ID FROM {$wpdb->posts} p
				 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
				 WHERE pm.meta_key = '_wp_attached_file'
				   AND pm.meta_value = %s
				   AND p.post_type IN ('attachment','cwpm-trash')
				 LIMIT 1",
				$rel_path
			)
		);

		return (int) $id;
	}

	/**
	 * Restore a trashed file back to the uploads directory.
	 *
	 * The trash mirrors the uploads directory structure, so a file at
	 * cwpm-trash/2024/06/image.jpg is restored to uploads/2024/06/image.jpg.
	 *
	 * Decision tree:
	 * | File on disk | Linked attachment | Result                                |
	 * | Yes          | Yes               | Rename + flip post_type. Return true. |
	 * | Yes          | No                | Rename. Tier-2 no-op. Return true.    |
	 * | No           | Yes (broken)      | Skip rename. Flip post_type. Return true. |
	 * | No           | No (true orphan)  | Return false (caller shows modal).    |
	 *
	 * @param string $trash_rel Relative path within the trash (e.g. '2024/06/image.jpg').
	 * @return bool True on success.
	 */
	public static function restore_file( $trash_rel ) {
		$trash_abs     = self::get_trash_dir() . '/' . ltrim( $trash_rel, '/' );
		$attachment_id = self::find_attachment_by_rel_path( $trash_rel );

		clearstatcache( true, $trash_abs );

		if ( ! file_exists( $trash_abs ) ) {

			if ( $attachment_id > 0 ) {
				Db::delete_trash_record( $trash_rel );

				global $wpdb;
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->update(
					$wpdb->posts,
					array( 'post_type' => 'attachment' ),
					array( 'ID' => $attachment_id ),
					array( '%s' ),
					array( '%d' )
				);
				  clean_post_cache( $attachment_id );
				  self::refresh_scan_thumb( $attachment_id, $trash_rel );
				  self::refresh_scan_sizes( $attachment_id, $trash_rel );

				  return true;
			}

			return false;
		}

		$upload_dir  = wp_upload_dir();
		$restore_abs = $upload_dir['basedir'] . '/' . $trash_rel;
		$restore_dir = dirname( $restore_abs );

		if ( ! is_dir( $restore_dir ) ) {
			wp_mkdir_p( $restore_dir );
		}

     // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename,WordPress.PHP.NoSilencedErrors.Discouraged
		$success = @rename( $trash_abs, $restore_abs );

		if ( ! $success ) {
			Logger::error( 'restore_file: rename failed for ' . $trash_rel . ' (file_exists=' . ( file_exists( $trash_abs ) ? '1' : '0' ) . ')' );
		}

		if ( $success ) {
			Db::delete_trash_record( $trash_rel );

			if ( $attachment_id > 0 ) {
				global $wpdb;
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->update(
					$wpdb->posts,
					array( 'post_type' => 'attachment' ),
					array( 'ID' => $attachment_id ),
					array( '%s' ),
					array( '%d' )
				);
				clean_post_cache( $attachment_id );
				self::refresh_scan_thumb( $attachment_id, $trash_rel );
				self::refresh_scan_sizes( $attachment_id, $trash_rel );
			} else {

				list( $parent_id, $parent_rel ) = self::find_parent_for_thumb( $trash_rel );
				if ( $parent_id > 0 && '' !== $parent_rel ) {
					self::refresh_scan_sizes( $parent_id, $parent_rel );
				}
			}
		}

		return $success;
	}

	/**
	 * Refresh scan_results.thumb_url for a restored attachment.
	 *
	 * @param int    $attachment_id Restored attachment ID.
	 * @param string $rel_path      Scan-results rel_path key (uploads-relative).
	 * @return void
	 */
	private static function refresh_scan_thumb( $attachment_id, $rel_path ) {
		$thumb_url = wp_get_attachment_image_url( $attachment_id, 'thumbnail', false );
		if ( $thumb_url ) {
			Db::update_result_data( $rel_path, array( 'thumb_url' => $thumb_url ) );
		}
	}

	/**
	 * Refresh scan_results.sizes_json by re-checking disk reality for an
	 * attachment. Used after restore so partial-restore state (some thumbs
	 * back, others still trashed) is visible in the results table.
	 *
	 * @param int    $attachment_id Attachment ID to re-derive sizes for.
	 * @param string $rel_path      Scan-results rel_path key (the attachment's
	 *                              `_wp_attached_file` value).
	 * @return void
	 */
	private static function refresh_scan_sizes( $attachment_id, $rel_path ) {
		$sizes = \CWPM\Media\Scanner::derive_sizes_for_attachment( $attachment_id );
		Db::update_result_data( $rel_path, array( 'sizes_json' => wp_json_encode( $sizes ) ) );
	}

	/**
	 * Best-effort: derive the parent attachment ID for a restored thumbnail
	 * row by stripping the WP "-WIDTHxHEIGHT" suffix from the filename and
	 * looking the resulting main-file path up in `_wp_attached_file` meta.
	 *
	 * @param string $thumb_rel Thumbnail rel_path (uploads-relative).
	 * @return array{0:int,1:string} [parent_attachment_id, parent_rel_path] —
	 *                               id is 0 when no parent can be derived.
	 */
	private static function find_parent_for_thumb( $thumb_rel ) {
		$dir      = dirname( $thumb_rel );
		$filename = basename( $thumb_rel );
		$pos      = strrpos( $filename, '.' );
		if ( false === $pos ) {
			return array( 0, '' );
		}
		$base = substr( $filename, 0, $pos );
		$ext  = substr( $filename, $pos );

		if ( ! preg_match( '/^(.+)-\d+x\d+$/', $base, $m ) ) {
			return array( 0, '' );
		}
		$parent_filename = $m[1] . $ext;
		$parent_rel      = '.' === $dir ? $parent_filename : $dir . '/' . $parent_filename;

		$parent_id = self::find_attachment_by_rel_path( $parent_rel );
		return array( $parent_id, $parent_rel );
	}

	/**
	 * Permanently delete a single file from the trash.
	 *
	 * Decision tree:
	 * | File on disk | Linked attachment | Result                                |
	 * | Yes          | Yes               | Unlink + hard-delete. Return true.    |
	 * | Yes          | No                | Unlink. Tier-2 no-op. Return true.    |
	 * | No           | Yes (broken)      | Skip unlink. Hard-delete. Return true.|
	 * | No           | No (true orphan)  | Return false (caller shows modal).    |
	 *
	 * @param string $trash_rel Relative path within the trash (e.g. '2024/06/image.jpg').
	 * @return bool True on success.
	 */
	public static function permanent_delete( $trash_rel ) {
		$trash_abs     = self::get_trash_dir() . '/' . ltrim( $trash_rel, '/' );
		$attachment_id = self::find_attachment_by_rel_path( $trash_rel );

		clearstatcache( true, $trash_abs );

		if ( ! file_exists( $trash_abs ) ) {

			if ( $attachment_id > 0 ) {
				Db::delete_trash_record( $trash_rel );
				self::hard_delete_soft_attachment( $attachment_id );
				return true;
			}

			return false;
		}

     // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink,WordPress.PHP.NoSilencedErrors.Discouraged
		$success = @unlink( $trash_abs );

		if ( ! $success ) {
			Logger::error( 'permanent_delete: unlink failed for ' . $trash_rel . ' (file_exists=' . ( file_exists( $trash_abs ) ? '1' : '0' ) . ')' );
		}

		if ( $success ) {
			Db::delete_trash_record( $trash_rel );

			if ( $attachment_id > 0 ) {
				self::hard_delete_soft_attachment( $attachment_id );
			}
		}

		return $success;
	}

	/**
	 * Hard-delete a soft-deleted attachment (post_type='cwpm-trash').
	 *
	 * `wp_delete_attachment()` early-returns for non-'attachment' post_types,
	 * so we flip back to 'attachment' first, then delegate. Falls back to a
	 * direct wp_delete_post call if the row was somehow deleted between
	 * lookup and flip.
	 *
	 * @param int $attachment_id Attachment ID.
	 * @return void
	 */
	public static function hard_delete_soft_attachment( $attachment_id ) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$wpdb->posts,
			array( 'post_type' => 'attachment' ),
			array( 'ID' => $attachment_id ),
			array( '%s' ),
			array( '%d' )
		);
		clean_post_cache( $attachment_id );

		wp_delete_attachment( $attachment_id, true );
	}

	/**
	 * Permanently delete all files in the trash and clean up empty sub-directories.
	 *
	 * @return int Number of files deleted.
	 */
	public static function empty_trash() {
		$trash_dir = self::get_trash_dir();
		$count     = 0;

		if ( is_dir( $trash_dir ) ) {
			$iter = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $trash_dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
				\RecursiveIteratorIterator::CHILD_FIRST
			);

			foreach ( $iter as $file_info ) {
				$basename = $file_info->getFilename();

				if ( $file_info->isFile() && '.htaccess' !== $basename && 'index.php' !== $basename ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
					if ( unlink( $file_info->getPathname() ) ) {
						++$count;
					}
				} elseif ( $file_info->isDir() ) {

					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
					@rmdir( $file_info->getPathname() );
				}
			}
		}

		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$soft_deleted_ids = $wpdb->get_col(
			"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'cwpm-trash'"
		);

		if ( is_array( $soft_deleted_ids ) ) {
			foreach ( $soft_deleted_ids as $id ) {
				self::hard_delete_soft_attachment( (int) $id );
			}
		}

		Db::truncate_trash();

		return $count;
	}

	/**
	 * Permanently delete trashed files older than the configured retention period.
	 *
	 * Reads the `cwpm_trash_retention_days` option (0 = disabled). Deletes
	 * matching files from disk and removes their DB records individually so
	 * each deletion is logged and handled gracefully.
	 *
	 * @return int Number of files deleted.
	 */
	public static function auto_delete_expired() {

		$days = (int) get_option( 'cwpm_trash_retention_days', CWPM_TRASH_RETENTION_DEFAULT_DAYS );

		if ( $days <= 0 ) {
			return 0;
		}

		$expired = Db::get_expired_trash( $days );

		if ( empty( $expired ) ) {
			return 0;
		}

		$count     = 0;
		$trash_dir = self::get_trash_dir();

		foreach ( $expired as $record ) {
			$abs_path = $trash_dir . '/' . $record['rel_path'];

			if ( file_exists( $abs_path ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
				if ( unlink( $abs_path ) ) {
					++$count;
				}
			}

			Db::delete_trash_record( $record['rel_path'] );
		}

		if ( $count > 0 ) {
			Logger::info( sprintf( 'Auto-deleted %d expired trash files (retention: %d days).', $count, $days ) );
		}

		return $count;
	}

	/**
	 * One-time migration: populate the trash DB table from existing trash files.
	 *
	 * Iterates the cwpm-trash/ folder and inserts a DB record for any file
	 * that does not already have one. Safe to call multiple times — uses
	 * INSERT IGNORE logic via the caller to avoid duplicates.
	 *
	 * @return int Count of newly synced records.
	 */
	public static function sync_trash_table() {
		$trash_dir = self::get_trash_dir();

		if ( ! is_dir( $trash_dir ) ) {
			return 0;
		}

		$count = 0;
		$iter  = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $trash_dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::LEAVES_ONLY
		);

		foreach ( $iter as $file_info ) {
			if ( ! $file_info->isFile() ) {
				continue;
			}

			$basename = $file_info->getFilename();

			if ( '.htaccess' === $basename || 'index.php' === $basename ) {
				continue;
			}

			$abs_path = $file_info->getPathname();
			$rel_path = ltrim( str_replace( $trash_dir, '', $abs_path ), DIRECTORY_SEPARATOR . '/' );
			$rel_path = str_replace( DIRECTORY_SEPARATOR, '/', $rel_path );

			if ( Db::trash_record_exists( $rel_path ) ) {
				continue;
			}

			Db::insert_trash_record(
				array(
					'rel_path'      => $rel_path,
					'original_path' => $rel_path,
					'file_name'     => $basename,
					'file_size'     => (int) $file_info->getSize(),
				)
			);
			++$count;
		}

		return $count;
	}
}
