<?php
/**
 * Abstract File Writer Tool.
 *
 * Base class for file writing tools with backup system and multiple write
 * modes. Provides memory-efficient operations for gigabyte-scale files.
 *
 * @package   Agenistrator
 * @since     1.0.0
 * @author    Alex Ruban <alex@maximum.guru>
 * @author    Maximum Software Inc <contact@maximum.software>
 * @license   GPL-3.0-only
 * @link      https://agenistrator.com/
 *
 * SPDX-FileCopyrightText: 2025-2026 Alex Ruban <alex@maximum.guru>
 * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc <contact@maximum.software>
 * SPDX-License-Identifier: GPL-3.0-only
 */

namespace Agenistrator;

// Direct access prevention
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Abstract_File_Writer_Tool class.
 */
abstract class Abstract_File_Writer_Tool extends Tool {

	/**
	 * Get usage instructions for the LLM
	 *
	 * Provides detailed guidance on the write modes.
	 *
	 * @return string Mode instructions.
	 * @since     1.0.0
	 */
	public function get_instructions() {
		return 'Supports four write modes detected by parameter presence:
- search/replace mode: plaintext (literal, not regex) find and replace of all occurrences, non-recursive and single-pass (requires search and replace parameters; use the read tool for regex search).
- start_line/end_line mode: replace a range of lines (requires start_line, end_line, and content parameters; 1-based inclusive).
- content_offset mode: splice at a byte position (requires content_offset and content parameters; optional replace_length - 0 = pure insert, N = replace N bytes).
- replace mode (default): replace entire file content (requires content parameter).
- append mode: append content to end of file (requires content and append=true parameters).
All modes create an automatic backup that is restored on failure.
Handles gigabyte-scale files efficiently using streaming I/O.';
	}

	/**
	 * Get common parameters for file writing tools.
	 *
	 * Returns the standard parameter schema for mode-based file writing.
	 * Derived classes should call parent::get_parameters() and merge their
	 * own parameters into the 'properties' array.
	 *
	 * @return array Parameter schema.
	 * @since     1.0.0
	 */
	public function get_parameters() {
		return array(
			'type'       => 'object',
			'properties' => array(
				'file'           => array(
					'type'        => 'string',
					'description' => 'File path relative to the base directory in which this tool operates (see tool description for details). Use ' . DIRECTORY_SEPARATOR . ' as directory separator.',
				),
				'content'        => array(
					'type'        => 'string',
					'description' => 'New content to write.',
				),
				'append'         => array(
					'type'        => 'boolean',
					'description' => 'When true and no mode-specific parameters are present, append content to end of file instead of replacing. Default: false.',
				),
				'start_line'     => array(
					'type'        => 'integer',
					'description' => 'Line range mode: Start line number (1-based, inclusive). Presence of start_line or end_line switches to line range mode.',
				),
				'end_line'       => array(
					'type'        => 'integer',
					'description' => 'Line range mode: End line number (1-based, inclusive).',
				),
				'search'         => array(
					'type'        => 'string',
					'description' => 'Search/replace mode: Exact literal text to find (plaintext match, not regex). All occurrences are replaced. Presence of search switches to search/replace mode.',
				),
				'replace'        => array(
					'type'        => 'string',
					'description' => 'Search/replace mode: Literal replacement text. Inserted verbatim wherever search is found.',
				),
				'content_offset' => array(
					'type'        => 'integer',
					'description' => 'Offset mode: Byte position (0-based) to splice at. Presence of content_offset switches to offset mode.',
				),
				'replace_length' => array(
					'type'        => 'integer',
					'description' => 'Offset mode: Number of bytes to remove before inserting content. 0 (default) = pure insert, N = replace N bytes.',
				),
			),
			'required'   => array( 'file' ),
		);
	}

	/**
	 * All file writer tools are read-write (have side effects).
	 *
	 * @return bool False.
	 * @since     1.0.0
	 */
	public function is_read_only() {
		return false;
	}

	/**
	 * File writer tools operate within the per-site uploads and system temp
	 * directories (see WordPress_Utils::get_allowed_save_paths()). Site admins
	 * are scoped to their own site by wp_upload_dir() in multisite.
	 *
	 * @return bool
	 * @since     1.0.0
	 */
	public function current_user_can_execute() {
		return current_user_can( 'manage_options' );
	}

	/**
	 * Hook invoked after a successful write, before the backup is removed.
	 *
	 * Default implementation is a no-op. Derived classes may override to
	 * enforce content-level constraints (e.g. MIME sniffing).
	 * On throw, the write method restores the original file from backup
	 * (or removes the newly-created file) and re-raises the exception.
	 *
	 * @param string $file_path Absolute path to the written file.
	 * @param string $mode      Mode used for the write (full_replace, append, line_range_replace, search_replace, offset).
	 * @throws \InvalidArgumentException If the written content is not acceptable.
	 * @since     1.0.1
	 */
	protected function validate_write( $_file_path, $_mode ) { }

	/**
	 * Restore the original file state after a write-validation failure.
	 *
	 * If a backup exists, restores it. Otherwise (new file case) removes the
	 * written file so a rejected write leaves nothing behind.
	 *
	 * @param string $file_path Absolute path to the written file.
	 * @since     1.0.1
	 */
	protected function rollback_write( $file_path ) {
		if ( file_exists( $file_path . '.bak' ) ) {
			$this->restore_backup( $file_path );
			return;
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- best-effort cleanup; TODO: possibly migrate to WP_Filesystem later
		@unlink( $file_path );
	}

	/**
	 * Detect write mode from arguments.
	 *
	 * Strictly validates that all required parameters for a mode are present
	 * before selecting it. Throws if the mode cannot be determined or if
	 * orphan parameters are detected.
	 *
	 * @param array $args Tool arguments.
	 * @return string Detected mode: search_replace, line_range_replace, offset, or full_replace.
	 * @throws \InvalidArgumentException If required parameters are missing or orphaned.
	 * @since     1.0.0
	 */
	protected function detect_mode( $args ) {
		$has_search         = isset( $args['search'] );
		$has_replace        = isset( $args['replace'] );
		$has_start_line     = isset( $args['start_line'] );
		$has_end_line       = isset( $args['end_line'] );
		$has_content_offset = isset( $args['content_offset'] );
		$has_content        = isset( $args['content'] );

		// Detect orphan parameters that indicate a malformed request
		if ( $has_replace && ! $has_search ) {
			throw new \InvalidArgumentException( '`replace` provided without search, did you mean to use search/replace mode?' );
		}

		// Search/replace mode: requires both search and replace
		if ( $has_search && $has_replace ) {
			return 'search_replace';
		}
		if ( $has_search && ! $has_replace ) {
			throw new \InvalidArgumentException( '`search` provided without replace, both are required for search/replace mode' );
		}

		// Line range mode: requires start_line, end_line, and content
		if ( $has_start_line || $has_end_line ) {
			if ( ! $has_start_line || ! $has_end_line ) {
				throw new \InvalidArgumentException( '`start_line` and `end_line` are both required for line range mode' );
			}
			if ( ! $has_content ) {
				throw new \InvalidArgumentException( '`content` is required for line range mode' );
			}
			return 'line_range_replace';
		}

		// Offset mode: requires content_offset and content
		if ( $has_content_offset ) {
			if ( ! $has_content ) {
				throw new \InvalidArgumentException( '`content` is required for offset mode' );
			}
			return 'offset';
		}

		// Full replace / append mode: requires content
		if ( $has_content ) {
			return 'full_replace';
		}

		throw new \InvalidArgumentException( 'Cannot determine write mode, no valid combination of parameters provided' );
	}

	/**
	 * Validate mode-specific parameter constraints (ranges, values).
	 *
	 * Called after detect_mode() has already verified required parameters
	 * are present. This method only checks value constraints.
	 *
	 * @param string $mode Detected mode.
	 * @param array  $args Tool arguments.
	 * @throws \InvalidArgumentException If value constraints are violated.
	 * @since     1.0.0
	 */
	protected function validate_mode_parameters( $mode, $args ) {
		if ( 'line_range_replace' === $mode ) {
			if ( (int) $args['start_line'] < 1 ) {
				throw new \InvalidArgumentException( 'start_line must be >= 1' );
			}
			if ( (int) $args['end_line'] < (int) $args['start_line'] ) {
				throw new \InvalidArgumentException( 'end_line must be >= start_line' );
			}
		}

		if ( 'offset' === $mode ) {
			if ( (int) $args['content_offset'] < 0 ) {
				throw new \InvalidArgumentException( 'content_offset must be >= 0' );
			}
			if ( isset( $args['replace_length'] ) && (int) $args['replace_length'] < 0 ) {
				throw new \InvalidArgumentException( 'replace_length must be >= 0' );
			}
		}
	}

	/**
	 * Execute a mode-based write operation on a file.
	 *
	 * Detects mode from args and routes to the appropriate write method.
	 * Derived classes call this after resolving the file path.
	 *
	 * @param string $file_path Absolute path to the file.
	 * @param array  $args      Tool arguments.
	 * @return array Data array from the write operation.
	 * @throws \InvalidArgumentException If mode detection or write validation fails.
	 * @throws \RuntimeException If the write operation fails.
	 * @since     1.0.0
	 */
	protected function execute_mode( $file_path, $args ) {
		$mode = $this->detect_mode( $args );
		$this->validate_mode_parameters( $mode, $args );

		switch ( $mode ) {
			case 'full_replace':
				$append = ! empty( $args['append'] );
				return $this->write_full_replace( $file_path, $args['content'], $append );
			case 'line_range_replace':
				return $this->write_line_range_replace( $file_path, (int) $args['start_line'], (int) $args['end_line'], $args['content'] );
			case 'search_replace':
				return $this->write_search_replace( $file_path, $args['search'], $args['replace'] );
			case 'offset':
				$content_offset = (int) $args['content_offset'];
				$replace_length = isset( $args['replace_length'] ) ? (int) $args['replace_length'] : 0;
				return $this->write_offset( $file_path, $args['content'], $content_offset, $replace_length );
			default:
				throw new \RuntimeException( 'Unknown write mode' );
		}
	}

	/**
	 * Write content to file using full_replace mode.
	 *
	 * Replaces entire file content (or appends) with new content.
	 *
	 * @param string $file_path Absolute path to the file.
	 * @param string $content   New content for the file.
	 * @param bool   $append    Whether to append instead of replace (default: false).
	 * @return array Data array with mode and size.
	 * @throws \RuntimeException If the write operation fails.
	 * @throws \InvalidArgumentException If write validation fails.
	 * @since     1.0.0
	 */
	protected function write_full_replace( $file_path, $content, $append = false ) {
		// Create backup before modifying (only if file exists)
		if ( file_exists( $file_path ) ) {
			$this->create_backup( $file_path );
		}

		// Write new content
		$flags = $append ? FILE_APPEND : 0;
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- TODO: possibly migrate to WP_Filesystem later
		$result = file_put_contents( $file_path, $content, $flags );
		if ( false === $result ) {
			$this->restore_backup( $file_path );
			throw new \RuntimeException( 'Failed to write file' );
		}

		$mode = $append ? 'append' : 'full_replace';

		try {
			$this->validate_write( $file_path, $mode );
		} catch ( \Throwable $e ) {
			$this->rollback_write( $file_path );
			throw $e;
		}

		// Clean up backup on success
		$this->delete_backup( $file_path );

		return array(
			'mode' => $mode,
			'size' => filesize( $file_path ),
		);
	}

	/**
	 * Write content to file using line_range_replace mode.
	 *
	 * Replaces specific line range with new content.
	 * Uses SplFileObject for memory-efficient line-by-line reading.
	 *
	 * @param string $file_path  Absolute path to the file.
	 * @param int    $start_line Start line number (1-based, inclusive).
	 * @param int    $end_line   End line number (1-based, inclusive).
	 * @param string $content    New content to replace the line range.
	 * @return array Data array with mode and line info.
	 * @throws \RuntimeException If the write operation fails.
	 * @throws \InvalidArgumentException If write validation fails.
	 * @since     1.0.0
	 */
	protected function write_line_range_replace( $file_path, $start_line, $end_line, $content ) {
		// Create backup before modifying
		$this->create_backup( $file_path );

		// Create temporary file for writing
		$temp_file = $file_path . '.tmp';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- TODO: possibly migrate to WP_Filesystem later
		$temp_fp = fopen( $temp_file, 'w' );
		if ( false === $temp_fp ) {
			$this->restore_backup( $file_path );
			throw new \RuntimeException( 'Failed to create temporary file' );
		}

		// Read original file line by line using SplFileObject
		$file_obj = new \SplFileObject( $file_path, 'r' );
		$file_obj->setFlags( \SplFileObject::DROP_NEW_LINE );

		$current_line = 1;

		// Process file line by line
		while ( ! $file_obj->eof() ) {
			$line = $file_obj->fgets();

			if ( $current_line < $start_line || $current_line > $end_line ) {
				// Keep lines outside the replacement range
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
				fwrite( $temp_fp, $line . "\n" );
			} elseif ( $current_line === $start_line ) {
				// Insert new content at start line
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
				fwrite( $temp_fp, $content );
				if ( substr( $content, -1 ) !== "\n" ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
					fwrite( $temp_fp, "\n" );
				}
			}
			// Skip lines within the replacement range (except start line)

			++$current_line;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
		fclose( $temp_fp );

		// Replace original file with temp file
		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- TODO: possibly migrate to WP_Filesystem later
		if ( ! rename( $temp_file, $file_path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- cleanup on error; TODO: possibly migrate to WP_Filesystem later
			@unlink( $temp_file );
			$this->restore_backup( $file_path );
			throw new \RuntimeException( 'Failed to replace original file' );
		}

		try {
			$this->validate_write( $file_path, 'line_range_replace' );
		} catch ( \Throwable $e ) {
			$this->rollback_write( $file_path );
			throw $e;
		}

		// Clean up backup on success
		$this->delete_backup( $file_path );

		return array(
			'mode'           => 'line_range_replace',
			'start_line'     => $start_line,
			'end_line'       => $end_line,
			'lines_replaced' => $end_line - $start_line + 1,
		);
	}

	/**
	 * Write content to file using search_replace mode.
	 *
	 * Non-recursive find and replace of all occurrences. Each line is processed
	 * independently with a single-pass str_replace, the replacement text is
	 * never re-scanned for further matches.
	 *
	 * @param string $file_path Absolute path to the file.
	 * @param string $search    Text to search for.
	 * @param string $replace   Replacement text.
	 * @return array Data array with mode and replacement count.
	 * @throws \RuntimeException If the write operation fails.
	 * @throws \InvalidArgumentException If write validation fails.
	 * @since     1.0.0
	 */
	protected function write_search_replace( $file_path, $search, $replace ) {
		// Create backup before modifying
		$this->create_backup( $file_path );

		// Create temporary file for writing
		$temp_file = $file_path . '.tmp';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- TODO: possibly migrate to WP_Filesystem later
		$temp_fp = fopen( $temp_file, 'w' );
		if ( false === $temp_fp ) {
			$this->restore_backup( $file_path );
			throw new \RuntimeException( 'Failed to create temporary file' );
		}

		// Read original file line by line using SplFileObject
		$file_obj = new \SplFileObject( $file_path, 'r' );
		$file_obj->setFlags( \SplFileObject::DROP_NEW_LINE );

		$replacements_made = 0;

		// Single-pass, non-recursive: str_replace never re-scans replaced text
		while ( ! $file_obj->eof() ) {
			$line               = $file_obj->fgets();
			$replacements_made += substr_count( $line, $search );
			$line               = str_replace( $search, $replace, $line );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
			fwrite( $temp_fp, $line . "\n" );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
		fclose( $temp_fp );

		// Replace original file with temp file
		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- TODO: possibly migrate to WP_Filesystem later
		if ( ! rename( $temp_file, $file_path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- cleanup on error; TODO: possibly migrate to WP_Filesystem later
			@unlink( $temp_file );
			$this->restore_backup( $file_path );
			throw new \RuntimeException( 'Failed to replace original file' );
		}

		try {
			$this->validate_write( $file_path, 'search_replace' );
		} catch ( \Throwable $e ) {
			$this->rollback_write( $file_path );
			throw $e;
		}

		// Clean up backup on success
		$this->delete_backup( $file_path );

		return array(
			'mode'              => 'search_replace',
			'replacements_made' => $replacements_made,
		);
	}

	/**
	 * Write content to file using offset/splice mode.
	 *
	 * Wraps write_at_offset() with backup support.
	 *
	 * @param string $file_path      Absolute path to the file.
	 * @param string $content        Content to insert.
	 * @param int    $content_offset Byte offset (0-based).
	 * @param int    $replace_length Bytes to remove before inserting (default 0 = pure insert).
	 * @return array Data array with mode and offset info.
	 * @throws \RuntimeException If the splice operation fails.
	 * @throws \InvalidArgumentException If write validation fails.
	 * @since     1.0.0
	 */
	protected function write_offset( $file_path, $content, $content_offset, $replace_length = 0 ) {
		// Create backup before modifying
		if ( file_exists( $file_path ) ) {
			$this->create_backup( $file_path );
		}

		// Perform the splice, propagates \RuntimeException on failure
		try {
			$bytes_written = $this->write_at_offset( $file_path, $content, $content_offset, $replace_length );
		} catch ( \RuntimeException $e ) {
			$this->restore_backup( $file_path );
			throw $e;
		}

		try {
			$this->validate_write( $file_path, 'offset' );
		} catch ( \Throwable $e ) {
			$this->rollback_write( $file_path );
			throw $e;
		}

		// Clean up backup on success
		$this->delete_backup( $file_path );

		return array(
			'mode'           => 'offset',
			'content_offset' => $content_offset,
			'replace_length' => $replace_length,
			'bytes_written'  => $bytes_written,
			'new_size'       => filesize( $file_path ),
		);
	}

	/**
	 * Splice content at a byte offset with optional replacement.
	 *
	 * Low-level helper. Removes replace_length bytes starting at offset, then
	 * inserts content at that position. Uses streaming I/O with a temp file
	 * so only 8KB is held in memory at a time, safe for gigabyte-scale files.
	 *
	 * @param string $file_path      Absolute path to the file.
	 * @param string $content        Content to insert.
	 * @param int    $offset         Byte offset (0-based).
	 * @param int    $replace_length Bytes to remove before inserting (default 0 = pure insert).
	 * @return int Number of bytes written (content length).
	 * @throws \RuntimeException If the splice operation fails.
	 * @since     1.0.0
	 */
	protected function write_at_offset( $file_path, $content, $offset, $replace_length = 0 ) {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- TODO: possibly migrate to WP_Filesystem later
		$fp = fopen( $file_path, file_exists( $file_path ) ? 'r+b' : 'w+b' );
		if ( false === $fp ) {
			throw new \RuntimeException( 'Failed to open file for writing' );
		}

		$file_size  = fstat( $fp )['size'];
		$tail_start = $offset + $replace_length;
		$has_tail   = $tail_start < $file_size;

		// If content length equals replace_length and no tail, simple in-place overwrite
		$content_len = strlen( $content );
		if ( $content_len === $replace_length && ! $has_tail ) {
			// Writing at or past end, just seek and write
			fseek( $fp, $offset );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
			$bytes = fwrite( $fp, $content );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
			fclose( $fp );
			if ( false === $bytes ) {
				throw new \RuntimeException( 'Failed to write content at offset' );
			}
			return $bytes;
		}

		if ( $content_len === $replace_length ) {
			// Same-size replacement, no tail shuffling needed
			fseek( $fp, $offset );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
			$bytes = fwrite( $fp, $content );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
			fclose( $fp );
			if ( false === $bytes ) {
				throw new \RuntimeException( 'Failed to write content at offset' );
			}
			return $bytes;
		}

		// Different-size splice: buffer tail to temp file, write content, restore tail
		$tmp = tmpfile();
		if ( false === $tmp ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
			fclose( $fp );
			throw new \RuntimeException( 'Failed to create temporary file for splice' );
		}

		// Stream tail to temp file in 8KB chunks
		if ( $has_tail ) {
			fseek( $fp, $tail_start );
			while ( ! feof( $fp ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- TODO: possibly migrate to WP_Filesystem later
				$chunk = fread( $fp, 8192 );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
				if ( false === $chunk || fwrite( $tmp, $chunk ) === false ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
					fclose( $tmp );
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
					fclose( $fp );
					throw new \RuntimeException( 'Failed to buffer file tail during splice' );
				}
			}
		}

		// Write new content at offset
		fseek( $fp, $offset );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
		$bytes = fwrite( $fp, $content );
		if ( false === $bytes ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
			fclose( $tmp );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
			fclose( $fp );
			throw new \RuntimeException( 'Failed to write content at offset' );
		}

		// Stream tail back from temp file
		if ( $has_tail ) {
			rewind( $tmp );
			while ( ! feof( $tmp ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- TODO: possibly migrate to WP_Filesystem later
				$chunk = fread( $tmp, 8192 );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
				if ( false === $chunk || fwrite( $fp, $chunk ) === false ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
					fclose( $tmp );
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
					fclose( $fp );
					throw new \RuntimeException( 'Failed to restore file tail during splice' );
				}
			}
		}

		// Truncate if new content is shorter than replaced block
		$new_size = $file_size - $replace_length + $content_len;
		ftruncate( $fp, $new_size );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
		fclose( $tmp );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
		fclose( $fp );

		return $bytes;
	}

	/**
	 * Create backup of file using streaming (memory-efficient for large files).
	 *
	 * Creates a .bak file by copying the original file in 8KB chunks.
	 * This ensures gigabyte-scale files can be backed up without loading into memory.
	 *
	 * @param string $file_path Absolute path to the file to backup.
	 * @throws \RuntimeException If the backup cannot be created.
	 * @since     1.0.0
	 */
	protected function create_backup( $file_path ) {
		$backup_path = $file_path . '.bak';

		// Open source file for reading in binary mode
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- TODO: possibly migrate to WP_Filesystem later
		$source = fopen( $file_path, 'rb' );
		if ( false === $source ) {
			throw new \RuntimeException( 'Failed to open original file for backup' );
		}

		// Open destination file for writing in binary mode
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- TODO: possibly migrate to WP_Filesystem later
		$dest = fopen( $backup_path, 'wb' );
		if ( false === $dest ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
			fclose( $source );
			throw new \RuntimeException( 'Failed to create backup file' );
		}

		// Stream copy in 8KB chunks (memory-efficient)
		while ( ! feof( $source ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- TODO: possibly migrate to WP_Filesystem later
			$chunk = fread( $source, 8192 );
			if ( false === $chunk ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
				fclose( $source );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
				fclose( $dest );
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- cleanup on error; TODO: possibly migrate to WP_Filesystem later
				@unlink( $backup_path );
				throw new \RuntimeException( 'Failed to read from original file' );
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- TODO: possibly migrate to WP_Filesystem later
			if ( fwrite( $dest, $chunk ) === false ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
				fclose( $source );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
				fclose( $dest );
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- cleanup on error; TODO: possibly migrate to WP_Filesystem later
				@unlink( $backup_path );
				throw new \RuntimeException( 'Failed to write to backup file' );
			}
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
		fclose( $source );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
		fclose( $dest );
	}

	/**
	 * Restore backup file.
	 *
	 * Renames the .bak file back to the original filename.
	 * Called when file modification fails to rollback changes.
	 *
	 * @param string $file_path Absolute path to the original file.
	 * @since     1.0.0
	 */
	protected function restore_backup( $file_path ) {
		$backup_path = $file_path . '.bak';
		if ( file_exists( $backup_path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- graceful recovery attempt; TODO: possibly migrate to WP_Filesystem later
			@rename( $backup_path, $file_path );
		}
	}

	/**
	 * Delete backup file.
	 *
	 * Removes the .bak file after successful modification.
	 *
	 * @param string $file_path Absolute path to the original file.
	 * @since     1.0.0
	 */
	protected function delete_backup( $file_path ) {
		$backup_path = $file_path . '.bak';
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- graceful cleanup; TODO: possibly migrate to WP_Filesystem later
		@unlink( $backup_path );
	}
}
