<?php
/**
 * Abstract File Reader Tool.
 *
 * Base class for file reading tools with multiple read 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://maximum.software/
 *
 * 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' ) ) {
	return;
}

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

	/**
	 * Get usage instructions for the LLM
	 *
	 * Provides detailed guidance on the three read modes.
	 *
	 * @return string Mode instructions.
	 * @since     1.0.0
	 */
	public function get_instructions() {
		return <<<'INST'
Supports three read modes:
- line mode (default): read lines by range using start_line/end_line (1-based inclusive).
- byte mode: read a chunk at a byte offset using content_offset/content_max_size, with token-boundary alignment for LLM-safe chunking.
- search mode: find lines matching a literal substring or regex pattern using search (set is_regex=true for regex), returning context blocks around each match with limit/offset pagination.
Use line mode for code inspection, byte mode for iterating through large files, and search mode to locate specific content.
INST;
	}

	/**
	 * Get common parameters for file reading tools.
	 *
	 * 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.',
				),
				'start_line'       => array(
					'type'        => 'integer',
					'description' => 'Line mode: Start line number (1-based, inclusive). Default: 1.',
				),
				'end_line'         => array(
					'type'        => 'integer',
					'description' => 'Line mode: End line number (1-based, inclusive). Default: last line.',
				),
				'content_offset'   => array(
					'type'        => 'integer',
					'description' => <<<'DESC'
Byte mode: Byte offset to start reading from.
Use 0 for the first chunk, then content_offset + content_size from the response for the next chunk.
Presence of this parameter switches to byte mode.
DESC
					,
				),
				'content_max_size' => array(
					'type'        => 'integer',
					'description' => 'Byte mode: Maximum chunk size in bytes (default: 8192). Actual size may differ due to token-boundary alignment.',
				),
				'search'           => array(
					'type'        => 'string',
					'description' => <<<'DESC'
Search mode: Literal substring to find, or a PCRE regex pattern when is_regex=true.
Presence of this parameter switches to search mode.
DESC
					,
				),
				'is_regex'         => array(
					'type'        => 'boolean',
					'description' => <<<'DESC'
Search mode: When true, treat search as a PCRE regex pattern (e.g. "/function\s+get_/").
When false (default), search is matched as a literal substring.
DESC
					,
				),
				'case_sensitive'   => array(
					'type'        => 'boolean',
					'description' => 'Search mode: Whether matching is case-sensitive (default: false). For regex mode, this adds/removes the i flag.',
				),
				'context_lines'    => array(
					'type'        => 'integer',
					'description' => 'Search mode: Number of lines of context to show before and after each match (default: 2).',
				),
				'limit'            => array(
					'type'        => 'integer',
					'description' => 'Search mode: Maximum number of context blocks to return (default: 50).',
				),
				'offset'           => array(
					'type'        => 'integer',
					'description' => 'Search mode: Number of context blocks to skip for pagination (default: 0).',
				),
			),
			'required'   => array( 'file' ),
		);
	}

	/**
	 * All file reader tools are read-only.
	 *
	 * @return bool True.
	 * @since     1.0.0
	 */
	public function is_read_only() {
		return true;
	}

	/**
	 * File tools require super admin privileges because files are shared across sites in a WordPress multisite network.
	 *
	 * @return bool
	 * @since     1.0.0
	 */
	public function current_user_can_execute() {
		return is_super_admin();
	}

	/**
	 * Execute a mode-based read operation on a file.
	 *
	 * Detects mode from args: search param → search mode,
	 * content_offset param → byte mode, otherwise → line mode.
	 *
	 * @param string $file_path  Absolute path to the file.
	 * @param array  $args       Tool arguments.
	 * @param array  $extra_data Additional data to merge into the response.
	 * @return array Data array from the read operation.
	 * @throws \InvalidArgumentException If input validation fails.
	 * @throws \RuntimeException If the read operation fails.
	 * @since     1.0.0
	 */
	protected function execute_mode( $file_path, $args, $extra_data = array() ) {
		if ( isset( $args['search'] ) ) {
			return $this->read_search( $file_path, $args, $extra_data );
		}

		if ( isset( $args['content_offset'] ) ) {
			return $this->read_byte_chunk( $file_path, $args, $extra_data );
		}

		return $this->read_line_range( $file_path, $args, $extra_data );
	}

	/**
	 * Read file in line-based mode.
	 *
	 * Uses SplFileObject for memory-efficient line-by-line reading.
	 *
	 * @param string $file_path  Absolute path to the file.
	 * @param array  $args       Tool arguments.
	 * @param array  $extra_data Additional data to merge into the response.
	 * @return array Data array with file content and line info.
	 * @since     1.0.0
	 */
	protected function read_line_range( $file_path, $args, $extra_data = array() ) {
		$start_line = $args['start_line'] ?? 1;
		$end_line   = $args['end_line'] ?? null;

		$file = new \SplFileObject( $file_path, 'r' );
		$file->setFlags( \SplFileObject::DROP_NEW_LINE );

		// Count total lines efficiently
		$file->seek( PHP_INT_MAX );
		$total_lines = $file->key() + 1;
		$file->rewind();

		$actual_end_line = $end_line ?? $total_lines;
		$lines           = array();

		foreach ( $file as $line_num => $line_content ) {
			$current_line = $line_num + 1;

			if ( $current_line < $start_line ) {
				continue;
			}
			if ( $current_line > $actual_end_line ) {
				break;
			}

			$lines[] = $line_content;
		}

		return array_merge(
			$extra_data,
			array(
				'size'           => filesize( $file_path ),
				'total_lines'    => $total_lines,
				'lines_returned' => count( $lines ),
				'start_line'     => $start_line,
				'end_line'       => $actual_end_line,
				'content'        => implode( "\n", $lines ),
			)
		);
	}

	/**
	 * Read file in byte-chunking mode.
	 *
	 * Uses fopen/fseek/fread for streaming I/O with token-boundary alignment.
	 *
	 * @param string $file_path  Absolute path to the file.
	 * @param array  $args       Tool arguments.
	 * @param array  $extra_data Additional data to merge into the response.
	 * @return array Data array with file content and byte info.
	 * @throws \RuntimeException If the file cannot be read.
	 * @since     1.0.0
	 */
	protected function read_byte_chunk( $file_path, $args, $extra_data = array() ) {
		$content_offset   = isset( $args['content_offset'] ) ? intval( $args['content_offset'] ) : 0;
		$content_max_size = isset( $args['content_max_size'] ) ? intval( $args['content_max_size'] ) : 8192;
		$file_size        = filesize( $file_path );

		if ( $content_offset < 0 ) {
			$content_offset = 0;
		}

		if ( $content_offset >= $file_size ) {
			return array_merge(
				$extra_data,
				array(
					'size'               => $file_size,
					'content'            => '',
					'content_total_size' => $file_size,
					'content_offset'     => $content_offset,
					'content_size'       => 0,
					'content_has_more'   => false,
				)
			);
		}

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

		if ( $content_offset > 0 ) {
			if ( fseek( $fp, $content_offset ) !== 0 ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
				fclose( $fp );
				throw new \RuntimeException( 'Failed to seek to offset' );
			}
		}

		// Read extra bytes for token-boundary adjustment
		$bytes_to_read = min( $content_max_size + 100, $file_size - $content_offset );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- TODO: possibly migrate to WP_Filesystem later
		$chunk = fread( $fp, $bytes_to_read );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- TODO: possibly migrate to WP_Filesystem later
		fclose( $fp );

		if ( false === $chunk ) {
			throw new \RuntimeException( 'Failed to read file chunk' );
		}

		$content_chunk     = String_Utils::split_on_token_boundary( $chunk, 0, $content_max_size );
		$actual_chunk_size = strlen( $content_chunk['content'] );
		$has_more          = ( $content_offset + $actual_chunk_size ) < $file_size;

		return array_merge(
			$extra_data,
			array(
				'size'               => $file_size,
				'content'            => $content_chunk['content'],
				'content_total_size' => $file_size,
				'content_offset'     => $content_offset,
				'content_size'       => $actual_chunk_size,
				'content_has_more'   => $has_more,
			)
		);
	}

	/**
	 * Read file in search mode.
	 *
	 * Searches for literal substring or regex pattern matches line-by-line and
	 * returns context blocks around each match. Uses SplFileObject for memory efficiency.
	 *
	 * @param string $file_path  Absolute path to the file.
	 * @param array  $args       Tool arguments.
	 * @param array  $extra_data Additional data to merge into the response.
	 * @return array Data array with search results and block info.
	 * @throws \InvalidArgumentException If the regex pattern is invalid.
	 * @since     1.0.0
	 */
	protected function read_search( $file_path, $args, $extra_data = array() ) {
		$search         = $args['search'];
		$is_regex       = $args['is_regex'] ?? false;
		$case_sensitive = $args['case_sensitive'] ?? false;
		$context_lines  = isset( $args['context_lines'] ) ? max( 0, intval( $args['context_lines'] ) ) : 2;
		$limit          = isset( $args['limit'] ) ? max( 1, intval( $args['limit'] ) ) : 50;
		$offset         = isset( $args['offset'] ) ? max( 0, intval( $args['offset'] ) ) : 0;

		// Build the regex pattern
		if ( $is_regex ) {
			$pattern = $search;
			// If the pattern isn't valid as-is, wrap in slashes (user omitted delimiters)
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- testing regex validity before use
			if ( @preg_match( $pattern, '' ) === false ) {
				$pattern = '/' . str_replace( '/', '\\/', $pattern ) . '/';
			}
		} else {
			$pattern = '/' . preg_quote( $search, '/' ) . '/';
		}

		// Ensure required flags are present: u (UTF-8), m (multiline), and i (case-insensitive when needed)
		$delimiter  = $pattern[0];
		$last_delim = strrpos( $pattern, $delimiter, 1 );
		$flags      = substr( $pattern, $last_delim + 1 );

		if ( ! $case_sensitive && strpos( $flags, 'i' ) === false ) {
			$flags .= 'i';
		} elseif ( $case_sensitive && strpos( $flags, 'i' ) !== false ) {
			$flags = str_replace( 'i', '', $flags );
		}
		if ( strpos( $flags, 'u' ) === false ) {
			$flags .= 'u';
		}
		if ( strpos( $flags, 'm' ) === false ) {
			$flags .= 'm';
		}

		$pattern = substr( $pattern, 0, $last_delim + 1 ) . $flags;

		// Validate the final pattern
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Intentional: testing if pattern is valid before use
		if ( @preg_match( $pattern, '' ) === false ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \InvalidArgumentException( 'Invalid regex pattern: ' . preg_last_error_msg() );
		}

		$file = new \SplFileObject( $file_path, 'r' );
		$file->setFlags( \SplFileObject::DROP_NEW_LINE );

		// Count total lines efficiently
		$file->seek( PHP_INT_MAX );
		$total_lines = $file->key() + 1;
		$file->rewind();

		// First pass: find all matching line numbers (0-based)
		$all_match_lines = array();
		foreach ( $file as $line_num => $line_content ) {
			if ( preg_match( $pattern, $line_content ) ) {
				$all_match_lines[] = $line_num;
			}
		}

		if ( empty( $all_match_lines ) ) {
			return array_merge(
				$extra_data,
				array(
					'size'           => filesize( $file_path ),
					'total_lines'    => $total_lines,
					'search'         => $search,
					'is_regex'       => $is_regex,
					'case_sensitive' => $case_sensitive,
					'blocks'         => array(),
					'count'          => 0,
					'total'          => 0,
					'limit'          => $limit,
					'offset'         => $offset,
					'has_more'       => false,
				)
			);
		}

		// Build context ranges for ALL matches, then merge overlapping/touching
		$ranges = array();
		foreach ( $all_match_lines as $line_num ) {
			$ranges[] = array(
				max( 0, $line_num - $context_lines ),
				min( $total_lines - 1, $line_num + $context_lines ),
			);
		}

		$all_blocks   = array( $ranges[0] );
		$ranges_count = count( $ranges );
		for ( $i = 1; $i < $ranges_count; $i++ ) {
			$last = &$all_blocks[ count( $all_blocks ) - 1 ];
			if ( $ranges[ $i ][0] <= $last[1] + 1 ) {
				$last[1] = max( $last[1], $ranges[ $i ][1] );
			} else {
				$all_blocks[] = $ranges[ $i ];
			}
			unset( $last );
		}

		// Paginate the merged blocks
		$total            = count( $all_blocks );
		$paginated_blocks = array_slice( $all_blocks, $offset, $limit );

		if ( empty( $paginated_blocks ) ) {
			return array_merge(
				$extra_data,
				array(
					'size'           => filesize( $file_path ),
					'total_lines'    => $total_lines,
					'search'         => $search,
					'is_regex'       => $is_regex,
					'case_sensitive' => $case_sensitive,
					'blocks'         => array(),
					'count'          => 0,
					'total'          => $total,
					'limit'          => $limit,
					'offset'         => $offset,
					'has_more'       => false,
				)
			);
		}

		$has_more = ( $offset + count( $paginated_blocks ) ) < $total;

		// Collect line numbers we need to read for paginated blocks only
		$lines_needed = array();
		foreach ( $paginated_blocks as $range ) {
			for ( $i = $range[0]; $i <= $range[1]; $i++ ) {
				$lines_needed[ $i ] = true;
			}
		}

		// Second pass: read only the lines we need
		$max_needed_line = max( array_keys( $lines_needed ) );
		$file->rewind();
		$line_cache = array();
		foreach ( $file as $line_num => $line_content ) {
			if ( isset( $lines_needed[ $line_num ] ) ) {
				$line_cache[ $line_num ] = $line_content;
			}
			if ( $line_num >= $max_needed_line ) {
				break;
			}
		}

		// Build block objects from paginated ranges
		$blocks = array();
		foreach ( $paginated_blocks as $range ) {
			$block_lines = array();
			for ( $i = $range[0]; $i <= $range[1]; $i++ ) {
				$block_lines[] = $line_cache[ $i ] ?? '';
			}
			$blocks[] = array(
				'start_line' => $range[0] + 1,
				'end_line'   => $range[1] + 1,
				'num_lines'  => $range[1] - $range[0] + 1,
				'content'    => implode( "\n", $block_lines ),
			);
		}

		return array_merge(
			$extra_data,
			array(
				'size'           => filesize( $file_path ),
				'total_lines'    => $total_lines,
				'search'         => $search,
				'is_regex'       => $is_regex,
				'case_sensitive' => $case_sensitive,
				'blocks'         => $blocks,
				'count'          => count( $blocks ),
				'total'          => $total,
				'limit'          => $limit,
				'offset'         => $offset,
				'has_more'       => $has_more,
			)
		);
	}
}
