diff --git a/.gitignore b/.gitignore
index 93e43974..62fbbb91 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,5 @@ build
 .DS_Store
 cc-test-reporter
 assets/build
-test-results
\ No newline at end of file
+test-results
+tests/assets/filestash
\ No newline at end of file
diff --git a/assets/css/single-attachment.css b/assets/css/single-attachment.css
new file mode 100644
index 00000000..ca7e902e
--- /dev/null
+++ b/assets/css/single-attachment.css
@@ -0,0 +1,241 @@
+table.compat-attachment-fields {
+  border-collapse: collapse;
+  width: 100%;
+}
+
+[class^="compat-field-optml_"] {
+  background: #fff;	
+  border-collapse: separate;
+  border-spacing: 0;
+  border: 1px solid #ccc;
+}
+
+[class^="compat-field-optml_"] tr {
+  background: #fff;
+}
+
+[class^="compat-field-optml_"] th {
+    display: block;
+    float: none;
+    white-space: nowrap;
+}
+
+[class^="compat-field-optml_"] th,
+[class^="compat-field-optml_"] td {
+  padding: 20px;
+}
+
+.compat-field-optml_footer_row {
+  padding: 0 !important;
+  background: #eee;
+}
+
+.compat-field-optml_spacer_row {
+  height: 40px !important;
+  background: transparent !important;
+  border: 0 !important;
+}	
+
+.compat-field-optml_spacer_row td, 
+.compat-field-optml_spacer_row th {
+  padding: 0 !important;
+}
+
+.compat-field-optml_footer_row th, 
+.compat-field-optml_footer_row td {
+  padding: 5px 20px;
+}
+
+
+.optml-logo-contianer {
+  justify-content: flex-end;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 12px;	
+  position:relative;
+}
+
+.optml-logo-contianer img {
+  width: 25px;
+  height: 25px;
+}
+
+.optml-rename-input:focus-within {
+    box-shadow: 0 0 0 1px #577BF9;
+}
+
+.optml-rename-media-container {
+  display: flex; 
+  gap: 10px;
+  align-items: center;
+}
+  
+.optml-rename-input {
+  display: flex;
+  align-items: stretch;
+  border-radius: 3px;
+  border: 1px solid #577BF9;
+  overflow: hidden;
+  background: #fff;
+  flex-grow: 1;
+}
+
+.optml-rename-input #optml_rename_file {
+  border: 0;
+  border-radius: 0;
+  flex-grow: 1;
+  box-shadow: none;
+  background: transparent;
+  min-height: 30px;
+}
+
+.optml-rename-input .optml-file-ext {
+  padding: 0 10px;
+  display: flex;
+  align-items: center;
+  font-weight: 600;
+  background-color: #e6effd;
+  border-left: 1px solid #577BF9;
+  color: #577BF9;
+}	
+
+
+.optml-replace-section {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.optml-description {
+  color: #666;
+  margin: 0;
+  font-style: italic;
+}
+
+.optml-replace-input {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 10px;
+}
+
+.optml-replace-input label {
+  width: 100%;
+  min-height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  cursor: pointer;
+  flex-grow: 1;
+  padding: 8px;
+  border: 1px dashed #577BF9;
+  border-radius: 3px;
+  background: #fff;
+  transition: all 0.3s ease;
+}
+
+.optml-replace-input label:hover {
+  background: #577BF9;
+  color: #fff;
+}
+
+.optml-replace-file-preview {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  justify-content: center;
+  font-weight: 600;
+}
+
+.optml-replace-file-preview img {
+  object-fit: cover;
+  border-radius: 6px;
+  max-width: 250px;
+  max-height: 75px;
+  border: 1px solid #ccc;
+  background: #f0f0f0;
+}
+
+.optml-replace-file-error {
+  color: rgb(163, 11, 0);
+  padding: 5px 10px;
+  border-radius: 3px;
+  border: 1px solid rgb(163, 11, 0);
+  background:rgb(255, 205, 201);
+}
+
+#optml-file-drop-area {
+  box-sizing: border-box;
+  position: relative;
+  transition: all 0.3s ease;
+}
+
+#optml-file-drop-area.drag-active {
+  background-color: #e6effd;
+  border: 1px dashed #577BF9;
+}
+
+.optml-replace-file-actions {
+  display: flex;
+  align-items: center;
+  flex-direction: row-reverse;
+  justify-content: flex-end;
+  gap: 10px;
+  width: 100%;
+}
+
+.optml-rename-actions {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  width: 100%;
+  margin-top: 10px;
+}
+
+.optml-replace-file-actions p {
+  margin-right: auto;
+}
+
+[class^="compat-field-optml_"] .optml-btn {
+  border-radius: 3px !important;
+  border: 0 !important;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  color: #fff !important;
+  margin-bottom: 0 !important;
+}
+
+[class^="compat-field-optml_"] .optml-btn.primary {
+  background: #577BF9 !important;
+}
+
+[class^="compat-field-optml_"] .optml-btn.destructive {
+  background: #D93025 !important;
+}
+
+[class^="compat-field-optml_"] .optml-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed !important;
+  pointer-events: none;
+  color: #fff !important;
+}
+
+.optml-btn.primary:hover {
+  background: #4161d7;
+}
+
+.optml-btn.destructive:hover {
+  background: #c2291e;
+}
+
+.optml-svg-loader {
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
diff --git a/assets/js/single-attachment.js b/assets/js/single-attachment.js
new file mode 100644
index 00000000..f2fc6825
--- /dev/null
+++ b/assets/js/single-attachment.js
@@ -0,0 +1,163 @@
+
+jQuery(document).ready(function($) {
+  const existingFileName = $("#optml_rename_file").attr("placeholder");
+  const renameBtn = $("#optml-rename-file-btn");
+
+  renameBtn.on("click", function(e) {
+    e.preventDefault();
+    $('#publish').click();
+  });
+
+  $("#optml_rename_file").on("input", function(e) {
+    const newFileName = $(this).val();
+    if (newFileName === existingFileName) {
+      return;
+    }
+
+    if(newFileName.trim().length === 0 || newFileName.trim()  .length > 100) {
+      renameBtn.prop("disabled", true);
+      return; 
+    }
+
+    renameBtn.prop("disabled", false);
+  });
+
+  $("#optml-replace-file-field").on("change", function(e) {
+    handleFileSelect(this.files[0]);
+  });
+
+  $("#optml-replace-file-btn").on("click", function(e) {
+    e.preventDefault();
+    uploadFile();
+  });
+
+  $("#optml-replace-clear-btn").on("click", function(e) {
+    e.preventDefault();
+    resetFileReplacer();
+  });
+  
+  const dropArea = document.getElementById("optml-file-drop-area");
+  
+  ["dragenter", "dragover", "dragleave", "drop"].forEach(event => {
+    dropArea.addEventListener(event, preventDefaults, false);
+  });
+  
+  function preventDefaults(e) {
+    e.preventDefault();
+    e.stopPropagation();
+  }
+  
+  ["dragenter", "dragover"].forEach(event => {
+    dropArea.addEventListener(event, highlight, false);
+  });
+  
+  ["dragleave", "drop"].forEach(event => {
+    dropArea.addEventListener(event, unhighlight, false);
+  });
+  
+  function highlight() {
+    dropArea.classList.add("drag-active");
+  }
+  
+  function unhighlight() {
+    dropArea.classList.remove("drag-active");
+  }
+  
+  dropArea.addEventListener("drop", handleDrop, false);
+  
+  function handleDrop(e) {
+    const dt = e.dataTransfer;
+    const file = dt.files[0];
+    handleFileSelect(file);
+  }
+
+  function resetFileReplacer(error = null) {
+    if( error ) {
+      $(".optml-replace-file-error").removeClass("hidden");
+      $(".optml-replace-file-error").text(error);
+    } else {
+      $(".optml-replace-file-error").addClass("hidden");
+    }
+
+    $("#optml-replace-file-btn").prop("disabled", true);
+    $("#optml-replace-file-field").val("");
+    $(".optml-replace-file-preview").html("");
+    $(".label-text").show();
+  }
+  
+  function handleFileSelect(file) {
+    $(".optml-replace-file-error").addClass("hidden");
+    
+    if(!file) return;
+    
+    if(OMAttachmentEdit.mimeType !== file.type) {
+      resetFileReplacer(OMAttachmentEdit.i18n.mimeTypeError);
+      return;
+    }
+
+    // Check file size
+    if(file.size > OMAttachmentEdit.maxFileSize) {
+      resetFileReplacer(OMAttachmentEdit.i18n.maxFileSizeError);
+
+      return;
+    }
+    
+    // Set the file in the input
+    if (file instanceof File) {
+      const dataTransfer = new DataTransfer();
+      dataTransfer.items.add(file);
+      document.getElementById("optml-replace-file-field").files = dataTransfer.files;
+    }
+    
+    // Enable button and update UI
+    $("#optml-replace-file-btn").prop("disabled", false);
+    $("#optml-replace-clear-btn").prop("disabled", false);
+    $(".label-text").hide();
+
+    const type = file.type;
+    let showPreview = type.startsWith("image/");
+    let html = showPreview ? "<img src='" + URL.createObjectURL(file) + "' />" : "";
+    html += "<span>" + file.name + "</span>";
+    
+    $(".optml-replace-file-preview").html(html);
+  }
+  
+  function uploadFile() {
+    var formData = new FormData();
+    formData.append("action", "optml_replace_file");
+    formData.append("attachment_id", OMAttachmentEdit.attachmentId);
+    formData.append("file", $("#optml-replace-file-field")[0].files[0]);
+
+    $(".optml-svg-loader").show();
+
+    jQuery.ajax({
+      url: OMAttachmentEdit.ajaxURL,
+      type: "POST",
+      data: formData,
+      processData: false,
+      contentType: false,
+      success: function(response) {
+        $(".optml-svg-loader").hide();
+        if(response.success) {
+          window.location.reload();
+        } else {
+          $(".optml-replace-file-error").removeClass("hidden");
+          $(".optml-replace-file-error").text(response.message);
+        }
+      },
+      error: function(response) {
+        $(".optml-svg-loader").hide();
+        resetFileReplacer(response.message || OMAttachmentEdit.i18n.replaceFileError);
+      }
+    });
+  }
+
+  function clearFile() {
+    resetFileReplacer();
+    // $(".optml-replace-file-preview").html("");
+    // $(".label-text").show();
+    // $("#optml-replace-file-btn").prop("disabled", true);
+    // $("#optml-replace-clear-btn").prop("disabled", true);
+    // $("#optml-replace-file-field").val("");
+  }
+});
\ No newline at end of file
diff --git a/inc/admin.php b/inc/admin.php
index 2ebcc27d..f34bec74 100755
--- a/inc/admin.php
+++ b/inc/admin.php
@@ -52,6 +52,9 @@ public function __construct() {
 		$this->settings            = new Optml_Settings();
 		$this->conflicting_plugins = new Optml_Conflicting_Plugins();
 
+		$media_rename = new Optml_Attachment_Edit();
+		$media_rename->init();
+
 		$dashboard_widget = new Optml_Dashboard_Widget();
 		$dashboard_widget->init();
 
diff --git a/inc/media_rename/attachment_db_renamer.php b/inc/media_rename/attachment_db_renamer.php
new file mode 100644
index 00000000..8dc60193
--- /dev/null
+++ b/inc/media_rename/attachment_db_renamer.php
@@ -0,0 +1,612 @@
+<?php
+/**
+ * Attachment db renamer class.
+ */
+
+/**
+ * URL Replacer for WordPress
+ *
+ * A standalone class to replace URLs in WordPress database,
+ * including handling image size variations and scaled images.
+ *
+ * @since      4.0.0
+ */
+class Optml_Attachment_Db_Renamer {
+	/**
+	 * Tables to skip during replacement
+	 *
+	 * @var array
+	 */
+	private $skip_tables = [ 'users', 'terms', 'term_relationships', 'term_taxonomy' ];
+
+	/**
+	 * Columns to skip during replacement
+	 *
+	 * @var array
+	 */
+	private $skip_columns = [ 'user_pass' ];
+
+	/**
+	 * Handle image size variations
+	 *
+	 * @var bool
+	 */
+	private $handle_image_sizes = false;
+
+	/**
+	 * Constructor
+	 */
+	public function __construct( $skip_sizes = false ) {
+		$this->handle_image_sizes = ! $skip_sizes;
+	}
+
+	/**
+	 * Replace URLs in the WordPress database
+	 *
+	 * @param string $old_url The base URL to search for (e.g., http://domain.com/wp-content/uploads/2025/03/image.jpg).
+	 * @param string $new_url The base URL to replace with (e.g., http://domain.com/wp-content/uploads/2025/03/new-name.jpg).
+	 *
+	 * @return int Number of replacements made
+	 */
+	public function replace( $old_url, $new_url ) {
+		if ( $old_url === $new_url ) {
+			return 0;
+		}
+
+		if ( empty( $old_url ) || empty( $new_url ) ) {
+			return 0;
+		}
+
+		if ( ! is_string( $old_url ) || ! is_string( $new_url ) ) { // @phpstan-ignore-line docs require a string but it could be empty
+			return 0;
+		}
+
+		$tables = $this->get_tables();
+		$total_replacements = 0;
+
+		foreach ( $tables as $table ) {
+			if ( in_array( $table, $this->skip_tables, true ) ) {
+				continue;
+			}
+
+			list($primary_keys, $columns) = $this->get_columns( $table );
+
+			// Skip tables with no primary keys
+			if ( empty( $primary_keys ) ) {
+				continue;
+			}
+
+			foreach ( $columns as $column ) {
+				if ( in_array( $column, $this->skip_columns, true ) ) {
+					continue;
+				}
+
+				$replacements = $this->process_column( $table, $column, $primary_keys, $old_url, $new_url );
+				$total_replacements += $replacements;
+			}
+		}
+
+		return $total_replacements;
+	}
+
+	/**
+	 * Get WordPress tables
+	 *
+	 * @return array Table names
+	 */
+	private function get_tables() {
+		global $wpdb;
+
+		return array_values( $wpdb->tables() );
+	}
+
+	/**
+	 * Get columns for a table
+	 *
+	 * @param string $table Table name.
+	 *
+	 * @return array Array containing primary keys and text columns
+	 */
+	private function get_columns( $table ) {
+		global $wpdb;
+
+		$primary_keys = [];
+		$text_columns = [];
+
+		// Get table information
+		$results = $wpdb->get_results( $wpdb->prepare( 'DESCRIBE %i', $table ) );
+
+		if ( ! empty( $results ) ) {
+			// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+			foreach ( $results as $col ) {
+				if ( 'PRI' === $col->Key ) {
+					$primary_keys[] = $col->Field;
+				}
+				if ( $this->is_text_col( $col->Type ) ) {
+					$text_columns[] = $col->Field;
+				}
+			}
+			// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+		}
+
+		return [ $primary_keys, $text_columns ];
+	}
+
+	/**
+	 * Check if column is text type
+	 *
+	 * @param string $type Column type.
+	 *
+	 * @return bool True if text column
+	 */
+	private function is_text_col( $type ) {
+		foreach ( [ 'text', 'varchar', 'longtext', 'mediumtext', 'char' ] as $token ) {
+			if ( false !== stripos( $type, $token ) ) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Process a single column for replacements
+	 *
+	 * @param string $table Table name.
+	 * @param string $column Column name.
+	 * @param array  $primary_keys Primary keys.
+	 * @param string $old_url Old URL.
+	 * @param string $new_url New URL.
+	 *
+	 * @return int Number of replacements
+	 */
+	private function process_column( $table, $column, $primary_keys, $old_url, $new_url ) {
+		global $wpdb;
+
+		$count = 0;
+
+		// Check for serialized data
+		$has_serialized = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(%i) FROM %i WHERE %i REGEXP '^[aiO]:[1-9]' LIMIT 1",
+				$column,
+				$table,
+				$column
+			)
+		);
+
+		// Process with PHP if serialized data is found
+		if ( $has_serialized ) {
+			$count = $this->php_handle_column( $table, $column, $primary_keys, $old_url, $new_url );
+		} else {
+			// Use direct SQL replacement for non-serialized data
+			$count = $this->sql_handle_column( $table, $column, $old_url, $new_url );
+		}
+
+		return $count;
+	}
+
+	/**
+	 * Handle column using SQL replacement
+	 *
+	 * @param string $table Table name.
+	 * @param string $column Column name.
+	 * @param string $old_url Old URL.
+	 * @param string $new_url New URL.
+	 *
+	 * @return int Number of replacements
+	 */
+	private function sql_handle_column( $table, $column, $old_url, $new_url ) {
+		global $wpdb;
+		$count = 0;
+
+		// Get the filename components
+		$old_path_parts = parse_url( $old_url );
+		if ( ! isset( $old_path_parts['path'] ) ) {
+			return 0;
+		}
+
+		$old_path = $old_path_parts['path'];
+		$old_file_info = pathinfo( $old_path );
+
+		$old_base = $old_file_info['filename'];
+		$old_dir = dirname( $old_path );
+		$old_domain = isset( $old_path_parts['host'] ) ? 'http' . ( isset( $old_path_parts['scheme'] ) && $old_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $old_path_parts['host'] : '';
+
+		// Build pattern to match any URL containing the base filename
+		$base_url = $old_domain . $old_dir . '/' . $old_base;
+
+		// Get rows with regular URLs
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				'SELECT * FROM %i WHERE %i LIKE %s',
+				$table,
+				$column,
+				'%' . $wpdb->esc_like( $base_url ) . '%'
+			)
+		);
+
+		// Also create a pattern for JSON-escaped version
+		$json_base_url = str_replace( '/', '\/', $base_url );
+
+		// Get rows with JSON-escaped URLs
+		$json_rows = $wpdb->get_results(
+			$wpdb->prepare(
+				'SELECT * FROM %i WHERE %i LIKE %s',
+				$table,
+				$column,
+				'%' . $wpdb->esc_like( $json_base_url ) . '%'
+			)
+		);
+
+		// Merge results, avoiding duplicates
+		$processed_ids = [];
+		$all_rows = array_merge( $rows, $json_rows );
+
+		if ( empty( $all_rows ) ) {
+			return 0;
+		}
+
+		foreach ( $all_rows as $row ) {
+			$id_field = $row->ID ?? $row->id ?? null;
+			if ( ! $id_field ) {
+				foreach ( $row as $field => $value ) {
+					if ( stripos( $field, 'id' ) !== false ) {
+						$id_field = $value;
+						break;
+					}
+				}
+			}
+
+			if ( ! $id_field ) {
+				continue;
+			}
+
+			// Skip if we've already processed this row
+			if ( isset( $processed_ids[ $id_field ] ) ) {
+				continue;
+			}
+			$processed_ids[ $id_field ] = true;
+
+			$content = $row->$column;
+			$new_content = $this->replace_image_urls( $content, $old_url, $new_url );
+
+			if ( $content !== $new_content ) {
+				$wpdb->update(
+					$table,
+					[ $column => $new_content ],
+					[ 'ID' => $id_field ]
+				);
+				++$count;
+			}
+		}
+
+		return $count;
+	}
+
+	/**
+	 * Handle column using PHP for serialized data
+	 *
+	 * @param string $table Table name.
+	 * @param string $column Column name.
+	 * @param array  $primary_keys Primary keys.
+	 * @param string $old_url Old URL.
+	 * @param string $new_url New URL.
+	 *
+	 * @return int Number of replacements
+	 */
+	private function php_handle_column( $table, $column, $primary_keys, $old_url, $new_url ) {
+		global $wpdb;
+
+		$count = 0;
+		$json_old_url = str_replace( '/', '\/', $old_url );
+
+		// Build the query and allow processing with multiple primary keys.
+		$query = 'SELECT ';
+		foreach ( $primary_keys as $key => $value ) {
+			$query .= $wpdb->prepare( '%i, ', $value );
+		}
+		$query .= '%i FROM %i WHERE %s LIKE %s LIMIT 100';
+
+		// Get the rows that need updating - first for regular URLs
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				$query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+				$column,
+				$table,
+				$column,
+				'%' . $wpdb->esc_like( $old_url ) . '%'
+			)
+		);
+
+		// Also get rows with JSON-escaped URLs
+		$json_rows = $wpdb->get_results(
+			$wpdb->prepare(
+				$query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+				$column,
+				$table,
+				$column,
+				'%' . $wpdb->esc_like( $json_old_url ) . '%'
+			)
+		);
+
+		// Merge results, avoiding duplicates
+		$processed_ids = [];
+		$all_rows = array_merge( $rows, $json_rows );
+
+		foreach ( $all_rows as $row ) {
+			// Generate a unique identifier for this row based on primary keys
+			$row_id = '';
+			foreach ( $primary_keys as $key ) {
+				$row_id .= $row->$key . '|';
+			}
+
+			// Skip if we've already processed this row
+			if ( isset( $processed_ids[ $row_id ] ) ) {
+				continue;
+			}
+
+			$processed_ids[ $row_id ] = true;
+			$value = $row->$column;
+
+			// Skip empty values
+			if ( empty( $value ) ) {
+				continue;
+			}
+
+			// Replace URLs in the value (handling serialized data)
+			$new_value = $this->replace_urls_in_value( $value, $old_url, $new_url );
+
+			// Skip if no change
+			if ( $value === $new_value ) {
+				continue;
+			}
+
+			// Build WHERE clause for this row
+			$where_conditions = [];
+			foreach ( $primary_keys as $key ) {
+				$where_conditions[ $key ] = $row->$key;
+			}
+
+			// Update the row
+			$updated = $wpdb->update(
+				$table,
+				[ $column => $new_value ],
+				$where_conditions
+			);
+
+			if ( $updated ) {
+				++$count;
+			}
+		}
+
+		return $count;
+	}
+
+	/**
+	 * Replace URLs in a value, handling serialized data
+	 *
+	 * @param string $value The value to process.
+	 * @param string $old_url Old URL.
+	 * @param string $new_url New URL.
+	 *
+	 * @return string The processed value
+	 */
+	private function replace_urls_in_value( $value, $old_url, $new_url ) {
+		// Check if the value is serialized
+		if ( $this->is_serialized( $value ) ) {
+			$unserialized = @unserialize( $value );
+
+			// If unserialize successful, process the data
+			if ( $unserialized !== false ) {
+				$replaced = $this->replace_in_data( $unserialized, $old_url, $new_url );
+				return serialize( $replaced );
+			}
+		}
+
+		// Handle image sizes for non-serialized content
+		if ( $this->handle_image_sizes ) {
+			return $this->replace_image_urls( $value, $old_url, $new_url );
+		}
+
+		// Simple string replacement for non-serialized data
+		return str_replace( $old_url, $new_url, $value );
+	}
+
+	/**
+	 * Replace image URLs including various WordPress size variations and scaled images
+	 *
+	 * @param string $content The content to process.
+	 * @param string $old_url Old URL pattern.
+	 * @param string $new_url New URL pattern.
+	 *
+	 * @return string The processed content
+	 */
+	private function replace_image_urls( $content, $old_url, $new_url ) {
+		// Get the filename components
+		$old_path_parts = parse_url( $old_url );
+		$new_path_parts = parse_url( $new_url );
+
+		if ( ! isset( $old_path_parts['path'] ) || ! isset( $new_path_parts['path'] ) ) {
+			// If we can't parse the URLs, fallback to direct replacement
+			return str_replace( $old_url, $new_url, $content );
+		}
+
+		// Extract file name info
+		$old_path = $old_path_parts['path'];
+		$new_path = $new_path_parts['path'];
+
+		$old_file_info = pathinfo( $old_path );
+		$new_file_info = pathinfo( $new_path );
+
+		$old_base = $old_file_info['filename'];
+		$new_base = $new_file_info['filename'];
+		$old_ext = isset( $old_file_info['extension'] ) ? $old_file_info['extension'] : '';
+		$new_ext = isset( $new_file_info['extension'] ) ? $new_file_info['extension'] : $old_ext;
+
+		// Define domain parts
+		$old_domain = isset( $old_path_parts['host'] ) ? 'http' . ( isset( $old_path_parts['scheme'] ) && $old_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $old_path_parts['host'] : '';
+		$new_domain = isset( $new_path_parts['host'] ) ? 'http' . ( isset( $new_path_parts['scheme'] ) && $new_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $new_path_parts['host'] : '';
+
+		// Replace original URLs
+		$content = str_replace( $old_url, $new_url, $content );
+
+		// Replace JSON-escaped URLs
+		$json_old_url = str_replace( '/', '\/', $old_url );
+		$json_new_url = str_replace( '/', '\/', $new_url );
+		$content = str_replace( $json_old_url, $json_new_url, $content );
+
+		// If we have a file with extension, handle variations
+		if ( ! empty( $old_ext ) && $this->handle_image_sizes ) {
+			$old_dir = dirname( $old_path );
+			$new_dir = dirname( $new_path );
+
+			// Replace WordPress image size variations (e.g., image-300x200.jpg)
+			$size_pattern = '/' . preg_quote( $old_domain . $old_dir . '/' . $old_base, '/' ) . '-\d+x\d+\.' . preg_quote( $old_ext, '/' ) . '/';
+
+			$content = preg_replace_callback(
+				$size_pattern,
+				function ( $matches ) use ( $old_base, $new_base, $old_domain, $new_domain, $old_dir, $new_dir, $old_ext, $new_ext ) {
+					// Extract the size part (e.g., -300x200)
+					$size_part = substr( $matches[0], strlen( $old_domain . $old_dir . '/' . $old_base ), -strlen( '.' . $old_ext ) );
+
+					// Build the new URL with the same size
+					return $new_domain . $new_dir . '/' . $new_base . $size_part . '.' . $new_ext;
+				},
+				$content
+			);
+
+			// Replace -scaled variations
+			$scaled_pattern = '/' . preg_quote( $old_domain . $old_dir . '/' . $old_base, '/' ) . '-scaled\.' . preg_quote( $old_ext, '/' ) . '/';
+
+			$content = preg_replace_callback(
+				$scaled_pattern,
+				function ( $matches ) use ( $new_base, $new_domain, $new_dir, $new_ext ) {
+					return $new_domain . $new_dir . '/' . $new_base . '-scaled.' . $new_ext;
+				},
+				$content
+			);
+
+			// Replace JSON-escaped variations
+			$json_size_pattern = '/' . preg_quote( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ), '/' ) . '-\d+x\d+\.' . preg_quote( $old_ext, '/' ) . '/';
+
+			$content = preg_replace_callback(
+				$json_size_pattern,
+				function ( $matches ) use ( $old_base, $new_base, $old_domain, $new_domain, $old_dir, $new_dir, $old_ext, $new_ext ) {
+					// Extract the size part (e.g., -300x200)
+					$size_part = substr( $matches[0], strlen( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ) ), -strlen( '.' . $old_ext ) );
+
+					// Build the new URL with the same size
+					return str_replace( '/', '\/', $new_domain . $new_dir . '/' . $new_base . $size_part . '.' . $new_ext );
+				},
+				$content
+			);
+
+			// Replace JSON-escaped scaled variations
+			$json_scaled_pattern = '/' . preg_quote( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ), '/' ) . '-scaled\.' . preg_quote( $old_ext, '/' ) . '/';
+
+			$content = preg_replace_callback(
+				$json_scaled_pattern,
+				function ( $matches ) use ( $new_base, $new_domain, $new_dir, $new_ext ) {
+					return str_replace( '/', '\/', $new_domain . $new_dir . '/' . $new_base . '-scaled.' . $new_ext );
+				},
+				$content
+			);
+		}
+
+		return $content;
+	}
+
+	/**
+	 * Recursively replace URLs in data structure
+	 *
+	 * @param mixed  $data The data to process.
+	 * @param string $old_url Old URL.
+	 * @param string $new_url New URL.
+	 *
+	 * @return mixed The processed data
+	 */
+	private function replace_in_data( $data, $old_url, $new_url ) {
+		if ( is_array( $data ) ) {
+			// Process arrays recursively
+			foreach ( $data as $key => $value ) {
+				$data[ $key ] = $this->replace_in_data( $value, $old_url, $new_url );
+			}
+		} elseif ( is_object( $data ) ) {
+			// Process objects recursively
+			foreach ( $data as $key => $value ) {
+				$data->$key = $this->replace_in_data( $value, $old_url, $new_url );
+			}
+		} elseif ( is_string( $data ) ) {
+			// Replace URLs in strings
+			if ( $this->handle_image_sizes ) {
+				$data = $this->replace_image_urls( $data, $old_url, $new_url );
+			} else {
+				$data = str_replace( $old_url, $new_url, $data );
+			}
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Check if a string is serialized
+	 *
+	 * @param string $data String to check.
+	 *
+	 * @return bool True if serialized
+	 */
+	private function is_serialized( $data ) {
+		// If it isn't a string, it isn't serialized
+		if ( ! is_string( $data ) ) {
+			return false;
+		}
+
+		$data = trim( $data );
+		if ( 'N;' === $data ) {
+			return true;
+		}
+
+		if ( strlen( $data ) < 4 ) {
+			return false;
+		}
+
+		if ( ':' !== $data[1] ) {
+			return false;
+		}
+
+		$last_char = substr( $data, -1 );
+		if ( ';' !== $last_char && '}' !== $last_char ) {
+			return false;
+		}
+
+		$token = $data[0];
+		switch ( $token ) {
+			case 's':
+				if ( '"' !== substr( $data, -2, 1 ) ) {
+					return false;
+				}
+				// Fall through
+			case 'a':
+			case 'O':
+			case 'i':
+			case 'd':
+				return (bool) preg_match( "/^{$token}:[0-9]+:/", $data );
+			default:
+				return false;
+		}
+	}
+}
+
+/**
+ * Example usage:
+ *
+ * $replacer = new Optml_Attachment_Db_Renamer();
+ *
+ * // Replace all variations of the image
+ * $count = $replacer->replace(
+ *     'http://om-wp.test/wp-content/uploads/2025/03/image.jpg',
+ *     'http://om-wp.test/wp-content/uploads/2025/03/new-name.jpg'
+ * );
+ *
+ * echo "Replaced $count instances";
+ */
diff --git a/inc/media_rename/attachment_edit.php b/inc/media_rename/attachment_edit.php
new file mode 100644
index 00000000..c03cfa94
--- /dev/null
+++ b/inc/media_rename/attachment_edit.php
@@ -0,0 +1,357 @@
+<?php
+/**
+ * Attachment edit class.
+ */
+
+/**
+ * Optml_Attachment_Edit
+ *
+ * @since      4.0.0
+ */
+class Optml_Attachment_Edit {
+	/**
+	 * Initialize the attachment edit class.
+	 *
+	 * @return void
+	 */
+	public function init() {
+		add_filter( 'attachment_fields_to_edit', [ $this, 'add_attachment_fields' ], 10, 2 );
+		add_filter( 'attachment_fields_to_save', [ $this, 'prepare_attachment_filename' ], 10, 2 );
+
+		add_action( 'edit_attachment', [ $this, 'save_attachment_filename' ] );
+		add_action( 'optml_after_attachment_url_replace', [ $this, 'bust_cache_on_rename' ], 10, 3 );
+		add_action( 'optml_attachment_replaced', [ $this, 'bust_cache_on_replace' ] );
+		add_action( 'wp_ajax_optml_replace_file', [ $this, 'replace_file' ] );
+
+		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
+	}
+
+	/**
+	 * Enqueue scripts.
+	 *
+	 * @param string $hook The hook.
+	 */
+	public function enqueue_scripts( $hook ) {
+		if ( $hook !== 'post.php' ) {
+			return;
+		}
+
+		$id = (int) sanitize_text_field( $_GET['post'] );
+
+		if ( ! $id ) {
+			return;
+		}
+
+		if ( ! current_user_can( 'edit_post', $id ) ) {
+			return;
+		}
+
+		if ( get_post_type( $id ) !== 'attachment' ) {
+			return;
+		}
+
+		$mime_type = get_post_mime_type( $id );
+
+		$max_file_size = wp_max_upload_size();
+		// translators: %s is the max file size in MB.
+		$max_file_size_error = sprintf( __( 'File size is too large. Max file size is %sMB', 'optimole-wp' ), $max_file_size / 1024 / 1024 );
+
+		wp_enqueue_style( 'optml-attachment-edit', OPTML_URL . 'assets/css/single-attachment.css', [], OPTML_VERSION );
+
+		wp_register_script( 'optml-attachment-edit', OPTML_URL . 'assets/js/single-attachment.js', [ 'jquery' ], OPTML_VERSION, true );
+		wp_localize_script(
+			'optml-attachment-edit',
+			'OMAttachmentEdit',
+			[
+				'ajaxURL'      => admin_url( 'admin-ajax.php' ),
+				'maxFileSize'  => $max_file_size,
+				'attachmentId' => $id,
+				'mimeType'     => $mime_type,
+				'i18n'         => [
+					'maxFileSizeError' => $max_file_size_error,
+					'replaceFileError' => __( 'Error replacing file', 'optimole-wp' ),
+				],
+			]
+		);
+		wp_enqueue_script( 'optml-attachment-edit' );
+	}
+
+	/**
+	 * Add fields to attachment edit form.
+	 *
+	 * @param array   $form_fields Array of form fields.
+	 * @param WP_Post $post The post object.
+	 *
+	 * @return array Modified form fields.
+	 */
+	public function add_attachment_fields( $form_fields, $post ) {
+		$screen = get_current_screen();
+
+		$attachment = new Optml_Attachment_Model( $post->ID );
+
+		if ( ! $attachment->can_be_renamed_or_replaced() ) {
+			return $form_fields;
+		}
+
+		if ( ! isset( $screen ) ) {
+			return $form_fields;
+		}
+
+		if ( $screen->parent_base !== 'upload' ) {
+			return $form_fields;
+		}
+
+		$form_fields['optml_rename_file'] = [
+			'label' => __( 'Rename attached file', 'optimole-wp' ),
+			'input' => 'html',
+			'html'  => $this->get_rename_field( $attachment ),
+		];
+
+		$form_fields['optml_replace_file'] = [
+			'label' => __( 'Replace file', 'optimole-wp' ),
+			'input' => 'html',
+			'html'  => $this->get_replace_field( $attachment ),
+		];
+
+		$form_fields['optml_footer_row'] = [
+			'label' => '',
+			'input' => 'html',
+			'html'  => $this->get_footer_html(),
+		];
+
+		$form_fields['optml_spacer_row'] = [
+			'label' => '',
+			'input' => 'html',
+			'html'  => '<div></div>',
+		];
+
+		return $form_fields;
+	}
+
+	/**
+	 * Get the rename field HTML.
+	 *
+	 * @param \Optml_Attachment_Model $attachment The attachment model.
+	 *
+	 * @return string The HTML.
+	 */
+	private function get_rename_field( \Optml_Attachment_Model $attachment ) {
+		$file_name_no_ext = $attachment->get_filename_no_ext();
+		$file_ext         = $attachment->get_extension();
+
+		$html = '';
+
+		$html .= '<div class="optml-rename-media-container">';
+
+		$html .= '<div class="optml-rename-input">';
+		$html .= '<input type="text" id="optml_rename_file" name="optml_rename_file" placeholder="' . esc_attr( $file_name_no_ext ) . '">';
+		$html .= '<span class="optml-file-ext">.' . esc_html( $file_ext ) . '</span>';
+		$html .= '</div>';
+
+		$html .= '<button type="button" disabled class="button optml-btn primary" id="optml-rename-file-btn">' . __( 'Rename', 'optimole-wp' ) . '</button>';
+		$html .= '</div>';
+
+		$html .= '<input type="hidden" name="optml_current_ext" value="' . esc_attr( $file_ext ) . '">';
+
+		wp_nonce_field( 'optml_rename_media_nonce', 'optml_rename_nonce' );
+
+		return $html;
+	}
+
+	/**
+	 * Get the replace field HTML.
+	 *
+	 * @param \Optml_Attachment_Model $attachment The attachment model.
+	 *
+	 * @return string The HTML.
+	 */
+	private function get_replace_field( \Optml_Attachment_Model $attachment ) {
+		$file_ext = $attachment->get_extension();
+		$file_ext = in_array( $file_ext, [ 'jpg', 'jpeg' ], true ) ? [ '.jpg', '.jpeg' ] : [ '.' . $file_ext ];
+		$html     = '<div class="optml-replace-section">';
+		$html     .= '<div class="optml-replace-input">';
+		$html     .= '<label for="optml-replace-file-field" id="optml-file-drop-area">';
+		$html     .= '<span class="label-text">' . __( 'Click to select a file or drag & drop here', 'optimole-wp' ) . ' (' . implode( ',', $file_ext ) . ')</span>';
+		$html     .= '<div class="optml-replace-file-preview"></div>';
+		$html     .= '</label>';
+
+		$html .= '<input type="file" class="hidden" id="optml-replace-file-field" name="optml-replace-file-field" accept="' . implode( ',', $file_ext ) . '">';
+
+		$html .= '<div class="optml-replace-file-actions">';
+		$html .= '<button disabled type="button" class="button optml-btn primary" id="optml-replace-file-btn">' . __( 'Replace file', 'optimole-wp' ) . '</button>';
+		$html .= '<button disabled type="button" class="button optml-btn destructive" id="optml-replace-clear-btn">' . __( 'Clear', 'optimole-wp' ) . '</button>';
+		$html .= $this->get_svg_loader();
+		$html .= '<p class="optml-description">' . __( 'This will replace the current file with the new one. This action cannot be undone.', 'optimole-wp' ) . '</p>';
+		$html .= '</div>';
+
+		$html .= '<div class="optml-replace-file-error hidden"></div>';
+
+		$html .= '</div>';
+
+		return $html;
+	}
+
+	/**
+	 * Get the footer HTML.
+	 *
+	 * @return string The HTML.
+	 */
+	private function get_footer_html() {
+		$html = '';
+		$html .= '<div class="optml-logo-contianer">';
+		$html .= '<img src="' . OPTML_URL . 'assets/img/logo.svg" alt="' . __( 'Optimole logo', 'optimole-wp' ) . '"/>';
+		// translators: %s is the 'Optimole'.
+		$html .= '<span>' . sprintf( __( 'Powered by %s', 'optimole-wp' ), '<strong>Optimole</strong>' ) . '</span>';
+		$html .= '</div>';
+
+		return $html;
+	}
+
+
+	/**
+	 * Prepare the new filename before saving.
+	 *
+	 * @param array $post_data Array of post data.
+	 * @param array $attachment Array of attachment data.
+	 *
+	 * @return array Modified post data.
+	 */
+	public function prepare_attachment_filename( array $post_data, array $attachment ) {
+		if ( ! current_user_can( 'edit_post', $post_data['ID'] ) ) {
+			return $post_data;
+		}
+
+		if ( ! isset( $post_data['post_type'] ) || $post_data['post_type'] !== 'attachment' ) {
+			return $post_data;
+		}
+
+		if ( ! isset( $post_data['optml_rename_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $post_data['optml_rename_nonce'] ), 'optml_rename_media_nonce' ) ) {
+			return $post_data;
+		}
+
+		$new_name = sanitize_text_field( $post_data['optml_rename_file'] );
+
+		$new_name = trim( $new_name );
+
+		if ( empty( $new_name ) ) {
+			return $post_data;
+		}
+
+		if ( strlen( $new_name ) > 100 ) {
+			return $post_data;
+		}
+
+		/**
+		 * Store filename for later, it will be used to rename the attachment.
+		 *
+		 * We do it this way because we don't want to rename the attachment immediately, during the attachment fields update as it will break things.
+		 *
+		 * @see Optml_Attachment_Edit::save_attachment_filename()
+		 */
+		update_post_meta( $post_data['ID'], '_optml_pending_rename', $new_name );
+
+		return $post_data;
+	}
+
+	/**
+	 * Save the new filename when attachment is updated
+	 *
+	 * @param int $post_id The post ID.
+	 */
+	public function save_attachment_filename( $post_id ) {
+		$new_filename = get_post_meta( $post_id, '_optml_pending_rename', true );
+
+		if ( empty( $new_filename ) ) {
+			return;
+		}
+
+		// Delete the meta so we don't rename again
+		delete_post_meta( $post_id, '_optml_pending_rename' );
+
+		$renamer = new Optml_Attachment_Rename( $post_id, $new_filename );
+		$status  = $renamer->rename();
+
+		if ( is_wp_error( $status ) ) {
+			wp_die( $status->get_error_message() );
+		}
+	}
+
+	/**
+	 * Replace the file
+	 */
+	public function replace_file() {
+		$id = (int) sanitize_text_field( $_POST['attachment_id'] );
+
+		if ( ! current_user_can( 'edit_post', $id ) ) {
+			wp_send_json_error( __( 'You are not allowed to replace this file', 'optimole-wp' ) );
+		}
+
+		if ( ! isset( $_FILES['file'] ) ) {
+			wp_send_json_error( __( 'No file uploaded', 'optimole-wp' ) );
+		}
+
+		$replacer = new Optml_Attachment_Replace( $id, $_FILES['file'] );
+
+		$replaced = $replacer->replace();
+
+		$is_error = is_wp_error( $replaced );
+
+		$response = [
+			'success' => ! $is_error,
+			'message' => $is_error ? $replaced->get_error_message() : __( 'File replaced successfully', 'optimole-wp' ),
+		];
+
+		wp_send_json( $response );
+	}
+
+	/**
+	 * Bust cached assets when an attachment is renamed.
+	 *
+	 * @param int    $attachment_id The attachment ID.
+	 * @param string $old_url The old attachment URL.
+	 * @param string $new_url The new attachment URL.
+	 */
+	public function bust_cache_on_rename( $attachment_id, $old_url, $new_url ) {
+		$this->clear_cache();
+	}
+
+	/**
+	 * Bust cached assets when an attachment is replaced.
+	 *
+	 * @param int $attachment_id The attachment ID.
+	 *
+	 * @return void
+	 */
+	public function bust_cache_on_replace( $attachment_id ) {
+		$this->clear_cache();
+	}
+
+	/**
+	 * Clear the cache for third-party plugins.
+	 *
+	 * @return void
+	 */
+	private function clear_cache() {
+		if (
+			class_exists( '\ThemeIsle\GutenbergBlocks\Server\Dashboard_Server' ) &&
+			is_callable( [ '\ThemeIsle\GutenbergBlocks\Server\Dashboard_Server', 'regenerate_styles' ] )
+		) {
+			\ThemeIsle\GutenbergBlocks\Server\Dashboard_Server::regenerate_styles();
+		}
+
+		if ( did_action( 'elementor/loaded' ) ) {
+			if ( class_exists( '\Elementor\Plugin' ) ) {
+				\Elementor\Plugin::instance()->files_manager->clear_cache();
+			}
+		}
+	}
+
+	/**
+	 * Get the SVG loader.
+	 *
+	 * @return string The SVG loader.
+	 */
+	private function get_svg_loader() {
+		return '<svg style="display: none;" class="optml-svg-loader" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>';
+	}
+}
diff --git a/inc/media_rename/attachment_model.php b/inc/media_rename/attachment_model.php
new file mode 100644
index 00000000..0fa91b2f
--- /dev/null
+++ b/inc/media_rename/attachment_model.php
@@ -0,0 +1,289 @@
+<?php
+/**
+ * Attachment model class.
+ */
+
+/**
+ * Optml_Attachment_Model
+ *
+ * @since      4.0.0
+ */
+class Optml_Attachment_Model {
+	use Optml_Dam_Offload_Utils;
+
+	/**
+	 * The attachment ID.
+	 *
+	 * @var int
+	 */
+	private $attachment_id;
+
+	/**
+	 * The attachment file extension.
+	 *
+	 * @var string
+	 */
+	private $extension;
+
+	/**
+	 * The attachment file name including extension.
+	 *
+	 * @var string
+	 */
+	private $original_attached_file_name;
+
+	/**
+	 * The attachment file name without extension.
+	 *
+	 * @var string
+	 */
+	private $filename_no_ext;
+
+	/**
+	 * The original attached full file path (even if it's scaled).
+	 *
+	 * @var string
+	 */
+	private $origianal_attached_file_path;
+
+	/**
+	 * The original attached file directory path.
+	 *
+	 * @var string
+	 */
+	private $dir_path;
+
+	/**
+	 * Whether the attachment is scaled.
+	 *
+	 * @var bool
+	 */
+	private $is_scaled = false;
+
+	/**
+	 * The attached file main URL.
+	 *
+	 * @var string
+	 */
+	private $main_attachment_url;
+
+	/**
+	 * Whether the attachment is remote - offloaded, imported from DAM or legacy offloaded.
+	 *
+	 * @var bool
+	 */
+	private $is_remote_attachment;
+
+	/**
+	 * The attachment metadata.
+	 *
+	 * @var array
+	 */
+	public $attachment_metadata;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param int $attachment_id Attachment ID.
+	 *
+	 * @return void
+	 */
+	public function __construct( int $attachment_id ) {
+		$this->attachment_id = $attachment_id;
+		$this->attachment_metadata = wp_get_attachment_metadata( $this->attachment_id );
+
+		$mime_type = get_post_mime_type( $attachment_id );
+		$is_image = strpos( $mime_type, 'image' ) !== false;
+
+		$this->main_attachment_url          = $is_image ? wp_get_original_image_url( $attachment_id ) : wp_get_attachment_url( $attachment_id );
+		$this->origianal_attached_file_path = $this->setup_original_attached_file();
+		$this->dir_path = dirname( $this->origianal_attached_file_path );
+		$this->is_scaled = isset( $this->attachment_metadata['original_image'] );
+
+		$filename = $this->is_scaled && isset( $this->attachment_metadata['original_image'] ) ?
+		$this->attachment_metadata['original_image'] :
+		basename( $this->origianal_attached_file_path );
+
+		$this->original_attached_file_name = $filename;
+
+		$file_parts = pathinfo( $filename );
+
+		$this->extension = isset( $file_parts['extension'] ) ? $file_parts['extension'] : '';
+		$this->filename_no_ext = $file_parts['filename'];
+		$this->is_remote_attachment = $this->is_dam_imported_image( $this->attachment_id ) ||
+		$this->is_legacy_offloaded_attachment( $this->attachment_id ) ||
+		$this->is_new_offloaded_attachment( $this->attachment_id );
+	}
+
+	/**
+	 * Check if the attachment is scaled.
+	 *
+	 * @return bool
+	 */
+	public function is_scaled() {
+		return $this->is_scaled;
+	}
+
+	/**
+	 * Get attachment ID.
+	 *
+	 * @return int
+	 */
+	public function get_attachment_id() {
+		return $this->attachment_id;
+	}
+
+	/**
+	 * Get filename no extension.
+	 *
+	 * @return string
+	 */
+	public function get_filename_no_ext() {
+		return $this->filename_no_ext;
+	}
+
+	/**
+	 * Get filename with extension.
+	 *
+	 * @param bool $scaled Whether the filename is scaled.
+	 *
+	 * @return string
+	 */
+	public function get_filename_with_ext( $scaled = false ) {
+		if ( $scaled ) {
+			return sprintf( '%s-scaled.%s', $this->filename_no_ext, $this->extension );
+		}
+
+		return sprintf( '%s.%s', $this->filename_no_ext, $this->extension );
+	}
+
+	/**
+	 * Get extension.
+	 *
+	 * @return string
+	 */
+	public function get_extension() {
+		return $this->extension;
+	}
+
+	/**
+	 * Get source file path.
+	 *
+	 * Returns the original attached file path, if the attachment is scaled, it will return the unscaled file path.
+	 *
+	 * @return string
+	 */
+	public function get_source_file_path() {
+		return $this->origianal_attached_file_path;
+	}
+
+	/**
+	 * Get dir path.
+	 *
+	 * @return string
+	 */
+	public function get_dir_path() {
+		return $this->dir_path;
+	}
+
+	/**
+	 * Get the attachment file main url.
+	 *
+	 * @return string
+	 */
+	public function get_main_url() {
+		return $this->main_attachment_url;
+	}
+
+	/**
+	 * Get attachment metadata.
+	 *
+	 * @return array
+	 */
+	public function get_attachment_metadata() {
+		return $this->attachment_metadata;
+	}
+
+	/**
+	 * Get all image sizes paths.
+	 *
+	 * @return array
+	 */
+	public function get_all_image_sizes_paths() {
+		$paths = [];
+
+		if ( ! isset( $this->attachment_metadata['sizes'] ) ) {
+			return [];
+		}
+
+		foreach ( $this->attachment_metadata['sizes'] as $size => $size_data ) {
+			$paths[ $size ] = $this->dir_path . '/' . $size_data['file'];
+		}
+
+		return $paths;
+	}
+
+	/**
+	 * Get all image sizes URLs.
+	 *
+	 * @return array
+	 */
+	public function get_all_image_sizes_urls() {
+		$attachment_metadata = $this->attachment_metadata;
+
+		$links = [];
+
+		if ( ! isset( $attachment_metadata['sizes'] ) ) {
+			return [];
+		}
+
+		foreach ( $attachment_metadata['sizes'] as $size => $size_data ) {
+			$links[ $size ] = str_replace( $this->original_attached_file_name, $size_data['file'], $this->get_main_url() );
+		}
+
+		return $links;
+	}
+
+	/**
+	 * Get attached file.
+	 *
+	 * @return string
+	 */
+	private function setup_original_attached_file() {
+		$attachment_metadata = wp_get_attachment_metadata( $this->attachment_id );
+		$attached_file = get_attached_file( $this->attachment_id );
+		$file_name = basename( $attached_file );
+
+		if ( isset( $attachment_metadata['original_image'] ) ) {
+			return str_replace( $file_name, $attachment_metadata['original_image'], $attached_file );
+		}
+
+		return $attached_file;
+	}
+
+	/**
+	 * Get metadata 'file' key prefix path.
+	 *
+	 * @return string
+	 */
+	public function get_metadata_prefix_path() {
+		if ( ! isset( $this->attachment_metadata['file'] ) ) {
+			$attached_file = get_post_meta( $this->attachment_id, '_wp_attached_file', true );
+
+			return dirname( $attached_file );
+		}
+
+		$file_path = $this->attachment_metadata['file'];
+
+		return dirname( $file_path );
+	}
+
+	/**
+	 * Check if can be renamed/replaced.
+	 *
+	 * @return bool
+	 */
+	public function can_be_renamed_or_replaced() {
+		return ! $this->is_remote_attachment;
+	}
+}
diff --git a/inc/media_rename/attachment_rename.php b/inc/media_rename/attachment_rename.php
new file mode 100644
index 00000000..f6b29938
--- /dev/null
+++ b/inc/media_rename/attachment_rename.php
@@ -0,0 +1,215 @@
+<?php
+
+/**
+ * Attachment rename class.
+ */
+class Optml_Attachment_Rename {
+	/**
+	 * The attachment ID.
+	 *
+	 * @var int
+	 */
+	private $attachment_id;
+
+	/**
+	 * The new filename.
+	 *
+	 * @var string
+	 */
+	private $new_filename;
+
+	/**
+	 * The attachment model.
+	 *
+	 * @var Optml_Attachment_Model
+	 */
+	private $attachment;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param int    $attachment_id Attachment ID.
+	 * @param string $new_filename New filename.
+	 * @return void
+	 */
+	public function __construct( int $attachment_id, string $new_filename ) {
+		$this->attachment_id = $attachment_id;
+		$this->attachment = new Optml_Attachment_Model( $attachment_id );
+		$this->new_filename = $new_filename;
+
+		if ( ! function_exists( 'WP_Filesystem' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+
+		WP_Filesystem();
+	}
+
+	/**
+	 * Rename the attachment
+	 *
+	 * @return bool|WP_Error
+	 */
+	public function rename() {
+		if ( empty( $this->new_filename ) || sanitize_file_name( $this->new_filename ) === $this->attachment->get_filename_no_ext() ) {
+			return true;
+		}
+
+		$extension = $this->attachment->get_extension();
+
+		$base_dir = trailingslashit( $this->attachment->get_dir_path() );
+		$file_path = $this->attachment->get_source_file_path();
+
+		$new_file_with_ext = sprintf( '%s.%s', $this->new_filename, $extension );
+		$new_unique_filename = wp_unique_filename( $base_dir, $new_file_with_ext );
+		$new_file_path = $base_dir . $new_unique_filename;
+
+		global $wp_filesystem;
+
+		// Bail if original file doesn't exist.
+		if ( ! $wp_filesystem->exists( $file_path ) ) {
+			return new WP_Error( 'optml_attachment_file_not_found', __( 'Error renaming file.', 'optimole-wp' ) );
+		}
+
+		// Rename the file (move) - moves the original, not the scaled image.
+		$moved = $wp_filesystem->move( $file_path, $new_file_path );
+		if ( ! $moved ) {
+			return new WP_Error( 'optml_attachment_rename_failed', __( 'Error renaming file.', 'optimole-wp' ) );
+		}
+
+		$wp_filesystem->chmod( $new_file_path, FS_CHMOD_FILE );
+
+		// Move the scaled image if it exists.
+		if ( $this->attachment->is_scaled() ) {
+			$new_unique_filename_no_ext = pathinfo( $new_unique_filename, PATHINFO_FILENAME );
+
+			$scaled_old_file_path = sprintf( '%s/%s-scaled.%s', $this->attachment->get_dir_path(), $this->attachment->get_filename_no_ext(), $extension );
+			$scaled_new_file_with_ext = sprintf( '%s-scaled.%s', $new_unique_filename_no_ext, $extension );
+
+			$new_scaled_file_path = $base_dir . $scaled_new_file_with_ext;
+			// Move the scaled image. We also override any leftover scaled files.
+			$wp_filesystem->move( $scaled_old_file_path, $new_scaled_file_path, true );
+		}
+
+		// Update attachment metadata
+		$metadata_update = $this->update_attachment_metadata( $new_file_path );
+
+		if ( $metadata_update === false ) {
+			return new WP_Error( 'optml_attachment_metadata_update_failed', __( 'Error renaming file.', 'optimole-wp' ) );
+		}
+
+		try {
+			$replacer = new Optml_Attachment_Db_Renamer();
+			$old_url = $this->attachment->get_main_url();
+			$new_url = $this->get_new_url( $new_unique_filename );
+
+			$count = $replacer->replace( $old_url, $new_url );
+
+			if ( $count > 0 ) {
+				/**
+				 * Action triggered after the attachment file is renamed.
+				 *
+				 * @param int $attachment_id Attachment ID.
+				 * @param string $old_url Old attachment URL.
+				 * @param string $new_url New attachment URL.
+				 */
+				do_action( 'optml_after_attachment_url_replace', $this->attachment_id, $old_url, $new_url );
+			}
+		} catch ( Exception $e ) {
+			return new WP_Error( 'optml_attachment_url_replace_failed', __( 'Error renaming file.', 'optimole-wp' ) );
+		}
+
+		do_action( 'optml_attachment_renamed', $this->attachment_id );
+
+		return true;
+	}
+
+	/**
+	 * Update attachment metadata.
+	 *
+	 * @param string $new_path New path.
+	 * @return bool
+	 */
+	private function update_attachment_metadata( $new_path ) {
+		global $wp_filesystem;
+
+		$new_file_name_no_ext = pathinfo( $new_path, PATHINFO_FILENAME );
+		$extension = $this->attachment->get_extension();
+
+		if ( $this->attachment->is_scaled() ) {
+			$new_path = sprintf( '%s/%s-scaled.%s', $this->attachment->get_metadata_prefix_path(), $new_file_name_no_ext, $extension );
+		}
+
+		$attached_update = update_attached_file( $this->attachment_id, $new_path );
+
+		if ( ! $attached_update ) {
+			return false;
+		}
+
+		// Get current attachment metadata
+		$metadata = $this->attachment->get_attachment_metadata();
+
+		if ( empty( $metadata ) ) {
+			return false;
+		}
+
+		// Update file path in metadata
+		$original_image = sprintf( '%s.%s', $new_file_name_no_ext, $extension );
+		$meta_file = $original_image;
+
+		if ( $this->attachment->is_scaled() ) {
+			$meta_file = sprintf( '%s-scaled.%s', $new_file_name_no_ext, $extension );
+			$metadata['original_image'] = $original_image;
+		}
+
+		if ( isset( $metadata['file'] ) ) {
+			$metadata['file'] = sprintf( '%s/%s', $this->attachment->get_metadata_prefix_path(), $meta_file );
+		}
+
+		// Update image sizes if they exist
+		if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
+			$already_moved_paths = [];
+
+			foreach ( $metadata['sizes'] as $size => $size_data ) {
+				if ( ! isset( $size_data['file'] ) ) {
+					continue;
+				}
+				$size_suffix = $size_data['width'] . 'x' . $size_data['height'];
+				$new_size_file = sprintf( '%s-%s.%s', $new_file_name_no_ext, $size_suffix, $extension );
+
+				$old_size_file_path = sprintf( '%s/%s', $this->attachment->get_dir_path(), $size_data['file'] );
+				$new_size_file_path = sprintf( '%s/%s', $this->attachment->get_dir_path(), $new_size_file );
+
+				$move = $wp_filesystem->move( $old_size_file_path, $new_size_file_path );
+
+				if ( $move || in_array( $old_size_file_path, $already_moved_paths, true ) ) {
+					$already_moved_paths[] = $old_size_file_path;
+					$metadata['sizes'][ $size ]['file'] = $new_size_file;
+					$already_moved_paths = array_unique( $already_moved_paths );
+				}
+			}
+		}
+
+		$metadata_update = wp_update_attachment_metadata( $this->attachment_id, $metadata );
+
+		if ( ! $metadata_update ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Get the new main attached file URL.
+	 *
+	 * @return string
+	 */
+	private function get_new_url( $filename ) {
+		$url = $this->attachment->get_main_url();
+
+		return str_replace(
+			basename( $url ),
+			$filename,
+			$url
+		);
+	}
+}
diff --git a/inc/media_rename/attachment_replace.php b/inc/media_rename/attachment_replace.php
new file mode 100644
index 00000000..c5b4c9fa
--- /dev/null
+++ b/inc/media_rename/attachment_replace.php
@@ -0,0 +1,188 @@
+<?php
+
+/**
+ * Optml_Attachment_Replace
+ *
+ * @since 4.0.0
+ */
+class Optml_Attachment_Replace {
+
+	/**
+	 * Attachment ID.
+	 *
+	 * @var int
+	 */
+	private $attachment_id;
+
+	/**
+	 * File.
+	 *
+	 * @var array
+	 */
+	private $file;
+
+	/**
+	 * Attachment.
+	 *
+	 * @var \Optml_Attachment_Model
+	 */
+	private $attachment;
+
+	/**
+	 * New attachment.
+	 *
+	 * @var \Optml_Attachment_Model
+	 */
+	private $new_attachment;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param int   $attachment_id Attachment ID.
+	 * @param array $file File.
+	 */
+	public function __construct( $attachment_id, $file ) {
+		$this->attachment_id = $attachment_id;
+		$this->file          = $file;
+		$this->attachment    = new Optml_Attachment_Model( $attachment_id );
+
+		if ( ! function_exists( 'WP_Filesystem' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+
+		WP_Filesystem();
+	}
+
+	/**
+	 * Replace the attachment.
+	 *
+	 * @return bool|WP_Error
+	 */
+	public function replace() {
+		if ( ! file_exists( $this->file['tmp_name'] ) ) {
+			return new WP_Error( 'file_error', __( 'Error uploading file.', 'optimole-wp' ) );
+		}
+
+		$original_file  = $this->attachment->get_source_file_path();
+		$old_sizes_urls = $this->attachment->get_all_image_sizes_urls();
+
+		if ( ! file_exists( $original_file ) ) {
+			return new WP_Error( 'file_error', __( 'Original file does not exist.', 'optimole-wp' ) );
+		}
+
+		$original_filetype = wp_check_filetype( $original_file );
+		$uploaded_filetype = wp_check_filetype( $this->file['name'] );
+
+		if ( $original_filetype['type'] !== $uploaded_filetype['type'] ) {
+			return new WP_Error( 'file_error', __( 'The uploaded file type does not match the original file type.', 'optimole-wp' ) );
+		}
+
+		global $wp_filesystem;
+
+		if ( ! $wp_filesystem->move( $this->file['tmp_name'], $original_file, true ) ) {
+			return new WP_Error( 'file_error', __( 'Could not move file.', 'optimole-wp' ) );
+		}
+
+		$wp_filesystem->chmod( $original_file, FS_CHMOD_FILE );
+
+		$this->remove_all_image_sizes();
+
+		clean_attachment_cache( $this->attachment_id );
+
+		$metadata = wp_generate_attachment_metadata( $this->attachment_id, $original_file );
+
+		if ( isset( $metadata['sizes'] ) ) {
+			$this->replace_image_sizes_links( $metadata['sizes'], $old_sizes_urls );
+		}
+
+		wp_update_attachment_metadata( $this->attachment_id, $metadata );
+		$this->new_attachment = new Optml_Attachment_Model( $this->attachment_id );
+
+		$this->handle_scaled_images();
+
+		do_action( 'optml_attachment_replaced', $this->attachment_id );
+
+		return true;
+	}
+
+	/**
+	 * Remove all image sizes files.
+	 *
+	 * @return void
+	 */
+	private function remove_all_image_sizes() {
+		$all_image_sizes_paths = $this->attachment->get_all_image_sizes_paths();
+		global $wp_filesystem;
+
+		foreach ( $all_image_sizes_paths as $path ) {
+			if ( file_exists( $path ) ) {
+				$wp_filesystem->delete( $path );
+			}
+		}
+	}
+
+	/**
+	 * Handle scaled images.
+	 *
+	 * @return bool
+	 */
+	private function handle_scaled_images() {
+		global $wp_filesystem;
+
+		$old_scaled = $this->attachment->is_scaled();
+		$new_scaled = $this->new_attachment->is_scaled();
+		$replacer   = new Optml_Attachment_Db_Renamer( true );
+
+		$new_file_path = $this->new_attachment->get_source_file_path();
+		$file          = apply_filters( 'update_attached_file', $new_file_path, $this->attachment_id );
+
+		// New is scaled, but old is not scaled. We don't replace anything.
+		if ( $old_scaled === $new_scaled || ( ! $old_scaled && $new_scaled ) ) {
+			return true;
+		}
+
+		// Delete the old scaled version and replace scaled URLs with non-scaled URLs.
+		if ( $old_scaled && ! $new_scaled ) {
+			$main_file_url   = $this->attachment->get_main_url();
+			$unscaled_file   = $this->attachment->get_filename_with_ext();
+			$old_scaled_file = $this->attachment->get_filename_with_ext( true );
+			$old_scaled_url  = str_replace( $unscaled_file, $old_scaled_file, $main_file_url );
+
+			$replacer->replace( $old_scaled_url, $main_file_url );
+
+			// replace the old year/month/item-scaled.ext with the new year/month/item.ext
+			$scaled_path = str_replace( $unscaled_file, $old_scaled_file, $this->attachment->get_source_file_path() );
+			if ( file_exists( $scaled_path ) ) {
+				$wp_filesystem->delete( $scaled_path );
+			}
+
+			update_attached_file( $this->attachment_id, sprintf( '%s/%s', $this->attachment->get_metadata_prefix_path(), $unscaled_file ) );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Replace image sizes links.
+	 *
+	 * @param array $new_sizes New sizes.
+	 * @param array $old_sizes_urls Old sizes URLs.
+	 *
+	 * @return void
+	 */
+	private function replace_image_sizes_links( $new_sizes, $old_sizes_urls ) {
+		$replacer = new Optml_Attachment_Db_Renamer( true );
+
+		foreach ( $old_sizes_urls as $size => $old_url ) {
+			// If the size is not in the new sizes, we need to use the original URL.
+			if ( ! isset( $new_sizes[ $size ], $new_sizes[ $size ]['file'] ) ) {
+				$replacer->replace( $old_url, $this->attachment->get_main_url() );
+				continue;
+			}
+
+			// If the size is in the new sizes, we need to use the new URL.
+			$new_url = str_replace( $this->attachment->get_filename_with_ext(), $new_sizes[ $size ]['file'], $this->attachment->get_main_url() );
+			$replacer->replace( $old_url, $new_url );
+		}
+	}
+}
diff --git a/optimole-wp.php b/optimole-wp.php
index cca58a2a..48011b0a 100644
--- a/optimole-wp.php
+++ b/optimole-wp.php
@@ -26,7 +26,7 @@ function optml_autoload( $class_name ) {
 	if ( strpos( $class_name, $prefix ) !== 0 ) {
 		return;
 	}
-	foreach ( [ '/inc/', '/inc/traits/', '/inc/image_properties/', '/inc/asset_properties/', '/inc/compatibilities/', '/inc/conflicts/', '/inc/cli/' ] as $folder ) {
+	foreach ( [ '/inc/', '/inc/traits/', '/inc/image_properties/', '/inc/asset_properties/', '/inc/compatibilities/', '/inc/conflicts/', '/inc/cli/', '/inc/media_rename/' ] as $folder ) {
 		$file = str_replace( $prefix . '_', '', $class_name );
 		$file = strtolower( $file );
 		$file = __DIR__ . $folder . $file . '.php';
diff --git a/tests/assets/large-1.jpg b/tests/assets/large-1.jpg
new file mode 100644
index 00000000..1865b519
Binary files /dev/null and b/tests/assets/large-1.jpg differ
diff --git a/tests/assets/large-2.jpg b/tests/assets/large-2.jpg
new file mode 100644
index 00000000..fc3e4cbd
Binary files /dev/null and b/tests/assets/large-2.jpg differ
diff --git a/tests/assets/rename-scaled.jpg b/tests/assets/rename-scaled.jpg
new file mode 100644
index 00000000..f6205498
Binary files /dev/null and b/tests/assets/rename-scaled.jpg differ
diff --git a/tests/assets/rename-unscaled.jpg b/tests/assets/rename-unscaled.jpg
new file mode 100644
index 00000000..7f76c90f
Binary files /dev/null and b/tests/assets/rename-unscaled.jpg differ
diff --git a/tests/assets/small-1.jpg b/tests/assets/small-1.jpg
new file mode 100644
index 00000000..72613675
Binary files /dev/null and b/tests/assets/small-1.jpg differ
diff --git a/tests/assets/small-2.jpg b/tests/assets/small-2.jpg
new file mode 100644
index 00000000..e4556798
Binary files /dev/null and b/tests/assets/small-2.jpg differ
diff --git a/tests/media_rename/test-attachment-edit.php b/tests/media_rename/test-attachment-edit.php
new file mode 100644
index 00000000..87e43bcc
--- /dev/null
+++ b/tests/media_rename/test-attachment-edit.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Test class for Optml_Attachment_Edit.
+ */
+
+/**
+ * Class Test_Attachment_Edit.
+ */
+class Test_Attachment_Edit extends WP_UnitTestCase {
+	/**
+	 * Test instance
+	 *
+	 * @var Optml_Attachment_Edit
+	 */
+	private $instance;
+
+	/**
+	 * Setup test
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->instance = new Optml_Attachment_Edit();
+	}
+
+	/**
+	 * Test prepare attachment filename
+	 */
+	public function test_prepare_attachment_filename() {
+		$attachment = self::factory()->post->create_and_get( [
+			'post_type'      => 'attachment',
+			'post_mime_type' => 'image/jpeg',
+		] );
+
+		$post_data = [
+			'ID'                 => $attachment->ID,
+			'optml_rename_nonce' => wp_create_nonce( 'optml_rename_media_nonce' ),
+			'optml_rename_file'  => 'test-file',
+			'post_type'          => 'attachment',
+		];
+
+		$result = $this->instance->prepare_attachment_filename( $post_data, (array) $attachment );
+
+		foreach ( $post_data as $key => $value ) {
+			if ( $key == 'optml_rename_nonce' ) {
+				continue;
+			}
+			$this->assertEquals( $value, $result[ $key ] );
+		}
+
+		$this->assertEquals( 'test-file', get_post_meta( $attachment->ID, '_optml_pending_rename', true ) );
+	}
+}
diff --git a/tests/media_rename/test-attachment-model.php b/tests/media_rename/test-attachment-model.php
new file mode 100644
index 00000000..85277f26
--- /dev/null
+++ b/tests/media_rename/test-attachment-model.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Test class for Optml_Attachment_Model.
+ */
+
+/**
+ * Class Test_Attachment_Model.
+ */
+class Test_Attachment_Model extends WP_UnitTestCase {
+	protected static $unscaled_id;
+	protected static $scaled_id;
+	protected static $remote_id;
+
+	protected static $unscaled_model;
+	protected static $scaled_model;
+	protected static $remote_model;
+
+	const MOCK_REMOTE_ATTACHMENT = [
+		'url'  => 'https://cloudUrlTest.test/w:auto/h:auto/q:auto/id:b1b12ee03bf3945d9d9bb963ce79cd4f/https://test-site.test/9.jpg',
+		'meta' =>
+			[
+				'originalHeight' => 1800,
+				'originalWidth'  => 1200,
+				'updateTime'     => 1688553629048,
+				'resourceS3'     => 'randomHashForImage1',
+				'mimeType'       => 'image/jpeg',
+				'userKey'        => 'mlckcuxuuuyb',
+				'fileSize'       => 171114,
+				'originURL'      => 'https://test-site.test/wp-content/uploads/2023/07/9.jpg',
+				'domain_hash'    => 'dWwtcG9sZWNhdC15dWtpLmluc3Rhd3AueHl6',
+			],
+	];
+
+	public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
+		self::$unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' );
+		self::$scaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/3000x3000.jpg' );
+		
+		$plugin = Optml_Main::instance();
+		self::$remote_id = $plugin->dam->insert_attachments( [ self::MOCK_REMOTE_ATTACHMENT ] )[0];
+
+		self::$unscaled_model = new Optml_Attachment_Model( self::$unscaled_id );
+		self::$scaled_model = new Optml_Attachment_Model( self::$scaled_id );
+		self::$remote_model = new Optml_Attachment_Model( self::$remote_id );
+	}
+
+	public static function tear_down_after_class() {
+		wp_delete_post( self::$unscaled_id, true );
+		wp_delete_post( self::$scaled_id, true );
+		wp_delete_post( self::$remote_id, true );
+		parent::tear_down_after_class();
+	}
+
+	public function test_barebones() {
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$unscaled_id ) );
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$scaled_id ) );
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$remote_id ) );
+	}
+
+	public function test_models() {
+		$this->test_model( self::$unscaled_id, self::$unscaled_model );
+		$this->test_model( self::$scaled_id, self::$scaled_model, true );
+		$this->test_model( self::$remote_id, self::$remote_model, false, true );
+	}
+
+	private function test_model( $id, $model, $scaled = false, $remote = false ) {
+		$this->test_basic_getters( $id, $model );
+		$this->test_filename_methods( $model );
+		$this->test_image_sizes_methods( $model );
+		$this->test_metadata_prefix_path( $model );
+		$this->assertEquals( $scaled, $model->is_scaled() );
+		$this->assertIsBool( $model->can_be_renamed_or_replaced() );
+		$this->assertEquals( ! $remote, $model->can_be_renamed_or_replaced() );
+	}
+
+	/**
+	 * Test basic model getters.
+	 *
+	 * @param int $id Post ID.
+	 * @param Optml_Attachment_Model $model The model to test.
+	 *
+	 * @return void
+	 */
+	private function test_basic_getters( $id, $model ) {
+		$this->assertEquals( $id, $model->get_attachment_id() );
+		$this->assertNotEmpty( $model->get_attachment_metadata() );
+
+		if( $model->can_be_renamed_or_replaced() ) {
+			$this->assertNotEmpty( $model->get_main_url() );
+			$this->assertNotEmpty( $model->get_source_file_path() );
+			$this->assertNotEmpty( $model->get_dir_path() );
+			$this->assertEquals( 'jpg', $model->get_extension() );
+		} else {
+			$this->assertFalse( $model->get_main_url() );
+			$this->assertEmpty( $model->get_source_file_path() );
+			$this->assertEmpty( $model->get_dir_path() );
+			$this->assertEmpty( $model->get_extension() );
+		}
+	}
+
+	private function test_filename_methods( $model ) {
+		if( ! $model->can_be_renamed_or_replaced() ) {
+			return;
+		}
+		$this->assertNotEmpty( $model->get_filename_no_ext() );
+		$this->assertEquals( $model->get_filename_with_ext(), $model->get_filename_no_ext() . '.' . $model->get_extension() );
+		$this->assertEquals( $model->get_filename_with_ext( true ), $model->get_filename_no_ext() . '-scaled.' . $model->get_extension() );
+		$this->assertNotEmpty( $model->get_filename_with_ext() );
+		$this->assertStringContainsString( '-scaled', $model->get_filename_with_ext(true) );
+	}
+
+	private function test_image_sizes_methods( $model ) {
+		$sizes_paths = $model->get_all_image_sizes_paths();
+		$sizes_urls  = $model->get_all_image_sizes_urls();
+
+		$this->assertIsArray( $sizes_paths );
+		$this->assertIsArray( $sizes_urls );
+
+		foreach ( $sizes_paths as $size => $path ) {
+			$this->assertNotEmpty( $path );
+			$this->assertArrayHasKey( $size, $sizes_urls );
+			$this->assertNotEmpty( $sizes_urls[ $size ] );
+
+			$this->assertArrayHasKey( $size, $sizes_paths );
+			$this->assertNotEmpty( $path );
+		}
+	}
+
+	private function test_metadata_prefix_path( $model ) {
+		$prefix_path = $model->get_metadata_prefix_path();
+		$this->assertNotEmpty( $prefix_path );
+		$this->assertIsString( $prefix_path );
+
+		$metadata = $model->get_attachment_metadata();
+		$this->assertArrayHasKey( 'file', $metadata );
+		$this->assertStringContainsString( $prefix_path, $metadata['file'] );
+	}
+}
diff --git a/tests/media_rename/test-attachment-rename.php b/tests/media_rename/test-attachment-rename.php
new file mode 100644
index 00000000..f6c069c3
--- /dev/null
+++ b/tests/media_rename/test-attachment-rename.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Test class for Optml_Attachment_Rename.
+ */
+
+/**
+ * Class Test_Attachment_Rename.
+ */
+class Test_Attachment_Rename extends WP_UnitTestCase {
+	protected static $scaled_id;
+	protected static $unscaled_id;
+
+	protected static $scaled_model;
+	protected static $unscaled_model;
+
+	public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
+		self::$scaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/rename-scaled.jpg' );
+		self::$unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/rename-unscaled.jpg' );
+
+		self::$scaled_model = new Optml_Attachment_Model( self::$scaled_id );
+		self::$unscaled_model = new Optml_Attachment_Model( self::$unscaled_id );
+	}
+
+	public static function tear_down_after_class() {
+		wp_delete_post( self::$scaled_id, true );
+		wp_delete_post( self::$unscaled_id, true );
+		parent::tear_down_after_class();
+	}
+
+	public function test_barebones() {
+		$this->assertInstanceOf( 'WP_Post' , get_post( self::$scaled_id ) );
+		$this->assertInstanceOf( 'WP_Post' , get_post( self::$unscaled_id ) );
+
+		$this->assertEquals('attachment', get_post_type( self::$scaled_id ) );
+		$this->assertEquals('attachment', get_post_type( self::$unscaled_id ) );
+	}
+
+	public function test_renames() {
+		$this->test_rename( self::$unscaled_id, self::$unscaled_model, 'renamed-image' );
+		$this->test_rename( self::$scaled_id, self::$scaled_model, 'big-file-rename', true );
+	}
+
+	private function test_rename( $id, $model, $new_filename, $scaled = false ) {
+		$renamer = new Optml_Attachment_Rename( $id, $new_filename );
+		$result = $renamer->rename();
+
+		$this->assertTrue( $result );
+
+		$new_model = new Optml_Attachment_Model( $id );
+
+		$this->assertStringContainsString( $new_filename,  $new_model->get_filename_no_ext() );
+		$this->check_rename_with_models( $new_model, $model, $scaled );
+	}
+
+	private function check_rename_with_models( $new, $old, $scaled = false ) {
+		$this->assertNotEquals( $old->get_filename_no_ext(), $new->get_filename_no_ext() );
+
+		$old_meta = $old->get_attachment_metadata();
+		$new_meta = $new->get_attachment_metadata();
+
+		$this->assertStringContainsString( $new->get_filename_with_ext($scaled), $new_meta['file'] );
+		$this->assertStringContainsString( $old->get_filename_with_ext($scaled), $old_meta['file'] );
+
+		foreach ( $old_meta['sizes'] as $id => $size_args ) {
+			$this->assertArrayHasKey( $id, $new_meta['sizes'] );
+			$this->assertNotEquals( $size_args['file'], $new_meta['sizes'][ $id ]['file'] );
+			$this->assertStringContainsString( $new->get_filename_no_ext(), $new_meta['sizes'][ $id ]['file'] );
+			$this->assertStringContainsString( $old->get_filename_no_ext(), $old_meta['sizes'][ $id ]['file'] );
+		}
+
+		// check actual files.
+		$new_src = $new->get_source_file_path();
+		$old_src = $old->get_source_file_path();
+
+		$this->assertTrue( file_exists( $new_src ) );
+		$this->assertFalse( file_exists( $old_src ) );
+	}
+}
diff --git a/tests/media_rename/test-attachment-replace.php b/tests/media_rename/test-attachment-replace.php
new file mode 100644
index 00000000..fa170670
--- /dev/null
+++ b/tests/media_rename/test-attachment-replace.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Test class for Optml_Attachment_Replace.
+ */
+
+/**
+ * Class Test_Attachment_Replace.
+ */
+class Test_Attachment_Replace extends WP_UnitTestCase {
+	protected static $scaled_unscaled_id; // scaled -> unscaled
+	protected static $unscaled_scaled_id; // unscaled -> scaled
+	protected static $scaled_scaled_id;   // scaled -> scaled
+	protected static $unscaled_unscaled_id; // unscaled -> unscaled
+
+	const FILESTASH = OPTML_PATH . 'tests/assets/filestash/';
+
+	public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
+		if ( ! function_exists( 'WP_Filesystem' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+
+		WP_Filesystem();
+		global $wp_filesystem;
+
+		// Ensure filestash exists and is empty
+		if ( $wp_filesystem->exists( self::FILESTASH ) ) {
+			$wp_filesystem->rmdir( self::FILESTASH, true );
+		}
+		$wp_filesystem->mkdir( self::FILESTASH );
+
+		// Copy replacement files
+		$wp_filesystem->copy( OPTML_PATH . 'tests/assets/large-1.jpg', self::FILESTASH . 'replace-scaled.jpg' );
+		$wp_filesystem->copy( OPTML_PATH . 'tests/assets/large-2.jpg', self::FILESTASH . 'replace-scaled-alt.jpg' ); // Different file for scaled->scaled
+		$wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', self::FILESTASH . 'replace-unscaled.jpg' );
+		$wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-2.jpg', self::FILESTASH . 'replace-unscaled-alt.jpg' ); // Different file for unscaled->unscaled
+
+
+		// Create initial attachments for each test case
+		self::$scaled_unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/3000x3000.jpg' ); // Scaled
+		self::$unscaled_scaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); // Unscaled
+		self::$scaled_scaled_id   = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/3000x3000.jpg' ); // Scaled
+		self::$unscaled_unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); // Unscaled
+	}
+
+	public static function tear_down_after_class() {
+		// Delete all created attachments
+		wp_delete_post( self::$scaled_unscaled_id, true );
+		wp_delete_post( self::$unscaled_scaled_id, true );
+		wp_delete_post( self::$scaled_scaled_id, true );
+		wp_delete_post( self::$unscaled_unscaled_id, true );
+
+		global $wp_filesystem;
+		$wp_filesystem->rmdir( self::FILESTASH, true );
+
+		parent::tear_down_after_class();
+	}
+
+	public function test_barebones() {
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$scaled_unscaled_id ) );
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$unscaled_scaled_id ) );
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$scaled_scaled_id ) );
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$unscaled_unscaled_id ) );
+	}
+
+	public function test_replacements() {
+		$this->test_replace_scaled_to_unscaled();
+		$this->test_replace_unscaled_to_scaled();
+		$this->test_replace_scaled_to_scaled();
+		$this->test_replace_unscaled_to_unscaled();
+	}
+
+	private function test_replace_scaled_to_unscaled() {
+		$replace_file = [
+			'name' => 'replace-unscaled.jpg',
+			'type' => 'image/jpeg',
+			'tmp_name' => self::FILESTASH . 'replace-unscaled.jpg',
+		];
+
+		$this->do_replace_test( self::$scaled_unscaled_id, $replace_file, true, false );
+	}
+
+	private function test_replace_unscaled_to_scaled() {
+		$replace_file = [
+			'name' => 'replace-scaled.jpg',
+			'type' => 'image/jpeg',
+			'tmp_name' => self::FILESTASH . 'replace-scaled.jpg',
+		];
+
+		$this->do_replace_test( self::$unscaled_scaled_id, $replace_file, false, true );
+	}
+
+	private function test_replace_scaled_to_scaled() {
+		$replace_file = [
+			'name' => 'replace-scaled-alt.jpg', // Using the alt file
+			'type' => 'image/jpeg',
+			'tmp_name' => self::FILESTASH . 'replace-scaled-alt.jpg',
+		];
+
+		$this->do_replace_test( self::$scaled_scaled_id, $replace_file, true, true );
+	}
+
+	private function test_replace_unscaled_to_unscaled() {
+		$replace_file = [
+			'name' => 'replace-unscaled-alt.jpg', // Using the alt file
+			'type' => 'image/jpeg',
+			'tmp_name' => self::FILESTASH . 'replace-unscaled-alt.jpg',
+		];
+
+		$this->do_replace_test( self::$unscaled_unscaled_id, $replace_file, false, false );
+	}
+
+	private function do_replace_test( $id_to_replace, $replace_file, $source_scaled, $result_scaled ) {
+		// Removed var_dump
+
+		$model = new Optml_Attachment_Model( $id_to_replace );
+		$metadata = $model->get_attachment_metadata();
+
+		// Store original size for comparison
+		$original_size = $metadata['filesize'];
+
+		// Assert initial scaled status
+		$this->assertTrue( $model->is_scaled() === $source_scaled, 'Initial scaled status mismatch.' );
+
+		// Perform the replacement
+		$replacer = new Optml_Attachment_Replace( $id_to_replace, $replace_file );
+		$result = $replacer->replace();
+		$this->assertTrue( $result, 'Replacement operation failed.' );
+
+		// Get the model and metadata after replacement
+		$new_model = new Optml_Attachment_Model( $id_to_replace );
+		$new_metadata = $new_model->get_attachment_metadata();
+		$new_size = $new_metadata['filesize'];
+
+		// Assert final scaled status
+		$this->assertTrue( $new_model->is_scaled() === $result_scaled, 'Resulting scaled status mismatch.' );
+
+		// Assert file size changed (assuming replacement files have different sizes)
+		$this->assertNotEquals( $original_size, $new_size, 'File size did not change after replacement.' );
+	}
+}
diff --git a/tests/media_rename/test-db-renamer.php b/tests/media_rename/test-db-renamer.php
new file mode 100644
index 00000000..d98cc731
--- /dev/null
+++ b/tests/media_rename/test-db-renamer.php
@@ -0,0 +1,323 @@
+<?php
+/**
+ * Test class for Optml_Attachment_Db_Renamer.
+ */
+// Removed: require_once 'attachment_edit_utils.php';
+
+/**
+ * Class Test_Attachment_Db_Renamer.
+ */
+class Test_Attachment_Db_Renamer extends WP_UnitTestCase {
+	// Removed: use Attachment_Edit_Utils;
+
+	const POST_CONTENT = '<!-- wp:image {"id":77,"sizeSlug":"full","linkDestination":"none","align":"center"} -->
+<figure class="wp-block-image aligncenter size-full"><img src="http://om-wp.test/wp-content/uploads/2025/03/optimole-logo.svg" alt="" class="wp-image-77"/></figure>
+<!-- /wp:image -->
+
+<!-- wp:video {"id":85} -->
+<figure class="wp-block-video"><video controls src="http://om-wp.test/wp-content/uploads/2025/03/video-file.mp4"></video></figure>
+<!-- /wp:video -->
+
+<!-- wp:group {"layout":{"type":"grid","minimumColumnWidth":"19rem"}} -->
+<div class="wp-block-group"><!-- wp:image {"id":89,"sizeSlug":"full","linkDestination":"none"} -->
+<figure class="wp-block-image size-full"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure>
+<!-- /wp:image -->
+
+<!-- wp:image {"id":89,"sizeSlug":"large","linkDestination":"none"} -->
+<figure class="wp-block-image size-large"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh-scaled.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure>
+<!-- /wp:image -->
+
+<!-- wp:image {"id":89,"sizeSlug":"medium","linkDestination":"none"} -->
+<figure class="wp-block-image size-medium"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh-300x200.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure>
+<!-- /wp:image -->
+
+<!-- wp:image {"id":89,"sizeSlug":"thumbnail","linkDestination":"none"} -->
+<figure class="wp-block-image size-thumbnail"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh-150x150.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure>
+<!-- /wp:image --></div>
+<!-- /wp:group -->';
+
+	protected static $attachment_id;
+	protected static $attachment_model;
+	protected static $post_id;
+	protected static $replacer;
+	protected static $replace_method; // ReflectionMethod
+	protected static $replacer_skip_sizes;
+	protected static $replace_skip_sizes_method; // ReflectionMethod
+
+	public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
+		self::$post_id = $factory->post->create( [
+			'post_title'   => 'Test Post',
+			'post_content' => self::POST_CONTENT,
+		] );
+
+		self::$attachment_id    = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' );
+		self::$attachment_model = new Optml_Attachment_Model( self::$attachment_id );
+
+		self::$replacer       = new Optml_Attachment_Db_Renamer();
+		self::$replace_method = new ReflectionMethod( 'Optml_Attachment_Db_Renamer', 'replace_urls_in_value' );
+		self::$replace_method->setAccessible( true );
+
+		self::$replacer_skip_sizes       = new Optml_Attachment_Db_Renamer( true );
+		self::$replace_skip_sizes_method = new ReflectionMethod( 'Optml_Attachment_Db_Renamer', 'replace_urls_in_value' );
+		self::$replace_skip_sizes_method->setAccessible( true );
+	}
+
+	public static function tear_down_after_class() {
+		wp_delete_post( self::$attachment_id, true );
+		wp_delete_post( self::$post_id, true );
+		parent::tear_down_after_class();
+	}
+
+	public function test_general() {
+		$this->assertInstanceOf( 'WP_Post', get_post( self::$attachment_id ) );
+		$this->assertInstanceOf( 'Optml_Attachment_Model', self::$attachment_model );
+		$this->assertInstanceOf( 'Optml_Attachment_Db_Renamer', self::$replacer );
+	}
+
+	/**
+	 * Test replace method with valid URLs
+	 */
+	public function test_replace() {
+		$old_url = self::$attachment_model->get_main_url();
+		$new_url = str_replace( 'sample-test', 'new-test-image', $old_url );
+
+		$count = self::$replacer->replace( $old_url, $new_url );
+		$this->assertGreaterThan( 0, $count );
+	}
+
+	/**
+	 * Test replace method with identical URLs
+	 */
+	public function test_replace_with_identical_urls() {
+		$url = self::$attachment_model->get_main_url();
+
+		$count = self::$replacer->replace( $url, $url );
+		$this->assertEquals( 0, $count );
+	}
+
+	/**
+	 * Test replace method with empty URLs
+	 */
+	public function test_replace_with_empty_urls() {
+		$count = self::$replacer->replace( '', '' );
+		$this->assertEquals( 0, $count );
+
+		$count = self::$replacer->replace( 'http://example.com', '' );
+		$this->assertEquals( 0, $count );
+
+		$count = self::$replacer->replace( '', 'http://example.com' );
+		$this->assertEquals( 0, $count );
+	}
+
+	/**
+	 * Test replace method with null URLs
+	 */
+	public function test_replace_with_null_urls() {
+		$count = self::$replacer->replace( null, null );
+		$this->assertEquals( 0, $count );
+
+		$count = self::$replacer->replace( 'http://example.com', null );
+		$this->assertEquals( 0, $count );
+
+		$count = self::$replacer->replace( null, 'http://example.com' );
+		$this->assertEquals( 0, $count );
+	}
+
+	public function test_simple_replacement() {
+		$value   = 'http://example.com';
+		$old_url = 'http://example.com';
+		$new_url = 'http://example.org';
+
+		$replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url );
+		$this->assertEquals( $new_url, $replaced );
+	}
+
+	public function test_multiple_replacement() {
+		$value   = 'http://example.com http://example.com http://example.com';
+		$old_url = 'http://example.com';
+		$new_url = 'http://example.org';
+
+		$replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url );
+		$this->assertStringNotContainsString( $old_url, $replaced );
+		$this->assertStringContainsString( $new_url, $replaced );
+	}
+
+	public function test_replacing_scaled_urls() {
+		$value = "<img src=\"http://example.com/wp-content/uploads/2020/01/image-150x150.jpg\" /><img src=\"http://example.com/wp-content/uploads/2020/01/image-300x300.jpg\" /><img src=\"http://example.com/wp-content/uploads/2020/01/image-scaled.jpg\" /><img src=\"http://example.com/wp-content/uploads/2020/01/image.jpg\" />";
+
+		$old_url = 'http://example.com/wp-content/uploads/2020/01/image.jpg';
+		$new_url = 'http://example.com/wp-content/uploads/2020/01/new-url.jpg';
+
+		$replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url );
+
+		$this->assertStringNotContainsString( $old_url, $replaced );
+		$this->assertStringContainsString( $new_url, $replaced );
+
+		$this->assertStringContainsString( 'http://example.com/wp-content/uploads/2020/01/new-url-150x150.jpg', $replaced );
+		$this->assertStringContainsString( 'http://example.com/wp-content/uploads/2020/01/new-url-300x300.jpg', $replaced );
+		$this->assertStringContainsString( 'http://example.com/wp-content/uploads/2020/01/new-url-scaled.jpg', $replaced );
+	}
+
+	public function test_static__replacing_serialized_content() {
+		$array = [
+			'image' => 'http://example.com/wp-content/uploads/2020/01/image.jpg',
+			'thumb' => 'http://example.com/wp-content/uploads/2020/01/thumb.jpg',
+		];
+		$value = serialize( $array );
+		// Validate serialized content.
+		$this->assertCount( 2, unserialize( $value ) );
+
+		$old_url = 'http://example.com/wp-content/uploads/2020/01/image.jpg';
+		$new_url = 'http://example.com/wp-content/uploads/2020/01/new-url.jpg';
+
+		$replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url );
+		// Validate serialized content.
+		$this->assertCount( 2, unserialize( $replaced ) );
+
+		$this->assertStringNotContainsString( $old_url, $replaced );
+		$this->assertStringContainsString( $new_url, $replaced );
+		$this->assertStringContainsString( 'thumb.jpg', $replaced );
+	}
+
+	public function test_static__replacing_in_json() {
+		$array = [
+			'image' => 'http://example.com/wp-content/uploads/2020/01/image.jpg',
+			'thumb' => 'http://example.com/wp-content/uploads/2020/01/thumb.jpg',
+		];
+
+		$value = json_encode( $array );
+
+		// Validate JSON.
+		$this->assertCount( 2, json_decode( $value, true ) );
+
+		$old_url   = 'http://example.com/wp-content/uploads/2020/01/image.jpg';
+		$new_url   = 'http://example.com/wp-content/uploads/2020/01/new-url.jpg';
+		$thumb_url = 'http://example.com/wp-content/uploads/2020/01/thumb.jpg';
+
+		$expected_new_url = 'http:\/\/example.com\/wp-content\/uploads\/2020\/01\/new-url.jpg';
+
+		$replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url );
+		// Validate JSON.
+		$this->assertCount( 2, json_decode( $replaced, true ) );
+
+		$this->assertStringNotContainsString( $old_url, $replaced );
+		$this->assertStringContainsString( $expected_new_url, $replaced );
+		$this->assertStringContainsString( 'thumb.jpg', $replaced );
+		$this->assertEquals( 1, substr_count( $replaced, $expected_new_url ) );
+
+		$replaced_2 = self::$replace_method->invoke( self::$replacer, $replaced, $thumb_url, $new_url );
+		// Validate JSON.
+		$this->assertCount( 2, json_decode( $replaced_2, true ) );
+
+		$this->assertStringNotContainsString( $thumb_url, $replaced_2 );
+		$this->assertStringContainsString( $expected_new_url, $replaced_2 );
+		$this->assertEquals( 2, substr_count( $replaced_2, $expected_new_url ) );
+	}
+
+	public function test_static__skip_sizes() {
+		$content = self::POST_CONTENT;
+
+		$old_url = 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg';
+		$new_url = 'http://om-wp.test/wp-content/uploads/2025/03/new-asdfgh.jpg';
+
+		$not_removed = [
+			'http://om-wp.test/wp-content/uploads/2025/03/asdfgh-scaled.jpg',
+			'http://om-wp.test/wp-content/uploads/2025/03/asdfgh-300x200.jpg',
+			'http://om-wp.test/wp-content/uploads/2025/03/asdfgh-150x150.jpg',
+		];
+
+		$replaced = self::$replace_skip_sizes_method->invoke( self::$replacer_skip_sizes, $content, $old_url, $new_url );
+
+		$this->assertStringNotContainsString( $old_url, $replaced );
+		$this->assertStringContainsString( $new_url, $replaced );
+
+		foreach ( $not_removed as $url ) {
+			$this->assertStringContainsString( $url, $replaced );
+		}
+	}
+
+	public function test_static__ensure_sizes() {
+		$content = self::POST_CONTENT;
+
+		$old_url = 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg';
+		$new_url = 'http://om-wp.test/wp-content/uploads/2025/03/properly_named_image.jpg';
+
+		$replaced = self::$replace_method->invoke( self::$replacer, $content, $old_url, $new_url );
+
+		$this->assertStringNotContainsString( $old_url, $replaced );
+		$this->assertStringContainsString( $new_url, $replaced );
+
+		$should_exist = [
+			'properly_named_image-scaled.jpg',
+			'properly_named_image-300x200.jpg',
+			'properly_named_image-150x150.jpg',
+		];
+
+		$should_not_exist = [
+			'asdfgh-scaled.jpg',
+			'asdfgh-300x200.jpg',
+			'asdfgh-150x150.jpg',
+		];
+
+		foreach ( $should_exist as $url ) {
+			$this->assertStringContainsString( $url, $replaced );
+		}
+
+		foreach ( $should_not_exist as $url ) {
+			$this->assertStringNotContainsString( $url, $replaced );
+		}
+	}
+
+	public function test_db__post_content_video_replacement() {
+		$initial_url = 'http://om-wp.test/wp-content/uploads/2025/03/video-file.mp4';
+		$new_url     = 'http://example.com/wp-content/uploads/2020/01/new-home-made-tutorial.mp4';
+
+		self::$replacer->replace( $initial_url, $new_url );
+
+		$post = get_post( self::$post_id );
+
+		$this->assertStringContainsString( $new_url, $post->post_content );
+		$this->assertStringNotContainsString( $initial_url, $post->post_content );
+	}
+
+	public function test_db__svg_replacement() {
+		$initial_url = 'http://om-wp.test/wp-content/uploads/2025/03/optimole-logo.svg';
+		$new_url     = 'http://example.com/wp-content/uploads/2020/01/new-logo.svg';
+
+		self::$replacer->replace( $initial_url, $new_url );
+
+		$post = get_post( self::$post_id );
+
+		$this->assertStringContainsString( $new_url, $post->post_content );
+		$this->assertStringNotContainsString( $initial_url, $post->post_content );
+	}
+
+	public function test_db__post_content_sizes_replacement() {
+		$initial_url = 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg';
+		$new_url     = 'http://example.com/wp-content/uploads/2020/01/new-proper_image.jpg';
+
+		self::$replacer->replace( $initial_url, $new_url );
+
+		$post = get_post( self::$post_id );
+
+		$should_exist = [
+			'new-proper_image-scaled.jpg',
+			'new-proper_image-300x200.jpg',
+			'new-proper_image-150x150.jpg',
+		];
+
+		$should_not_exist = [
+			'asdfgh-scaled.jpg',
+			'asdfgh-300x200.jpg',
+			'asdfgh-150x150.jpg',
+		];
+
+		foreach ( $should_exist as $url ) {
+			$this->assertStringContainsString( $url, $post->post_content );
+		}
+
+		foreach ( $should_not_exist as $url ) {
+			$this->assertStringNotContainsString( $url, $post->post_content );
+		}
+	}
+}