<?php
/**
 * Indexer: walks the Gravity Forms upload base and keeps the file registry in
 * sync. Read-only on the filesystem (it never moves, renames or deletes an
 * upload); the registry exists so download links can carry a row id instead of
 * a path, and so the audit has a stable inventory to report against.
 *
 * @package Upload_Vault
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class Uvgf_Indexer
 */
class Uvgf_Indexer {

	const MAX_FILES = 20000; // Hard walk cap — keeps a pathological tree from exhausting the request.

	/**
	 * Files/dirs the indexer itself (or protection) creates — never inventory.
	 *
	 * @param string $basename File basename.
	 * @return bool
	 */
	private static function is_housekeeping( $basename ) {
		return in_array( strtolower( $basename ), array( '.htaccess', 'index.html', 'index.php', 'web.config' ), true );
	}

	/**
	 * Walk the upload base. Returns file records keyed by relative path.
	 *
	 * @return array[] Each: relpath, form_id, size, mtime.
	 */
	public static function walk() {
		$base  = Uvgf_Locator::base_dir();
		$found = array();

		if ( ! is_dir( $base ) ) {
			return $found;
		}

		try {
			$iter = new RecursiveIteratorIterator(
				new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
				RecursiveIteratorIterator::LEAVES_ONLY,
				RecursiveIteratorIterator::CATCH_GET_CHILD
			);
		} catch ( Exception $e ) {
			return $found;
		}

		$count = 0;
		foreach ( $iter as $file ) {
			if ( ! $file->isFile() ) {
				continue;
			}
			if ( self::is_housekeeping( $file->getBasename() ) ) {
				continue;
			}
			$count++;
			if ( $count > self::MAX_FILES ) {
				break;
			}

			$abs     = wp_normalize_path( $file->getPathname() );
			$relpath = ltrim( substr( $abs, strlen( wp_normalize_path( $base ) ) ), '/' );

			$found[ $relpath ] = array(
				'relpath' => $relpath,
				'form_id' => self::form_id_from_relpath( $relpath ),
				'size'    => (int) $file->getSize(),
				'mtime'   => (int) $file->getMTime(),
			);
		}

		return $found;
	}

	/**
	 * Gravity Forms names per-form folders "{form_id}-{hash}".
	 *
	 * @param string $relpath Relative path.
	 * @return int Form id, 0 when the segment doesn't match.
	 */
	public static function form_id_from_relpath( $relpath ) {
		$segment = strtok( $relpath, '/' );
		if ( is_string( $segment ) && preg_match( '/^(\d+)-[0-9a-f]+$/i', $segment, $m ) ) {
			return (int) $m[1];
		}
		return 0;
	}

	/**
	 * Top-level form folders (relative names) with aggregate counts.
	 *
	 * @return array[] Keyed by folder name: {form_id, files, bytes, sample_relpath}.
	 */
	public static function form_folders() {
		$folders = array();
		foreach ( self::walk() as $rec ) {
			$top = strtok( $rec['relpath'], '/' );
			if ( ! is_string( $top ) || '' === $top ) {
				continue;
			}
			if ( ! isset( $folders[ $top ] ) ) {
				$folders[ $top ] = array(
					'folder'         => $top,
					'form_id'        => self::form_id_from_relpath( $rec['relpath'] ),
					'files'          => 0,
					'bytes'          => 0,
					'sample_relpath' => $rec['relpath'],
					'sample_size'    => (int) $rec['size'],
				);
			}
			$folders[ $top ]['files']++;
			$folders[ $top ]['bytes'] += $rec['size'];
			// Prefer the smallest non-empty file as the loopback sample (cheapest probe).
			if ( $rec['size'] > 0 && ( 0 === $folders[ $top ]['sample_size'] || $rec['size'] < $folders[ $top ]['sample_size'] ) ) {
				$folders[ $top ]['sample_relpath'] = $rec['relpath'];
				$folders[ $top ]['sample_size']    = (int) $rec['size'];
			}
		}
		ksort( $folders );
		return $folders;
	}

	/**
	 * Sync the walk into the registry table: insert new, update changed, delete
	 * rows whose file vanished. Returns counts.
	 *
	 * @return array {indexed:int, added:int, removed:int}
	 */
	public static function sync() {
		global $wpdb;

		$found = self::walk();
		$now   = current_time( 'mysql', true );

		$existing = $wpdb->get_results( "SELECT id, path_hash, relpath, size FROM {$wpdb->prefix}uvgf_files", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		$by_hash  = array();
		foreach ( (array) $existing as $row ) {
			$by_hash[ $row['path_hash'] ] = $row;
		}

		$added = 0;
		foreach ( $found as $rec ) {
			$hash  = md5( $rec['relpath'] );
			$mtime = gmdate( 'Y-m-d H:i:s', $rec['mtime'] );
			if ( isset( $by_hash[ $hash ] ) ) {
				if ( (int) $by_hash[ $hash ]['size'] !== (int) $rec['size'] ) {
					$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- reads/writes the plugin's own custom registry table; caching a live filesystem index would serve stale data.
						$wpdb->prefix . 'uvgf_files',
						array(
							'size'       => $rec['size'],
							'file_mtime' => $mtime,
						),
						array( 'id' => $by_hash[ $hash ]['id'] ),
						array( '%d', '%s' ),
						array( '%d' )
					);
				}
				unset( $by_hash[ $hash ] );
			} else {
				$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- writes the plugin's own custom registry table; no core API exists for custom tables.
					$wpdb->prefix . 'uvgf_files',
					array(
						'path_hash'  => $hash,
						'form_id'    => $rec['form_id'],
						'relpath'    => $rec['relpath'],
						'size'       => $rec['size'],
						'file_mtime' => $mtime,
						'status'     => 'unknown',
						'first_seen' => $now,
					),
					array( '%s', '%d', '%s', '%d', '%s', '%s', '%s' )
				);
				$added++;
			}
		}

		// Anything left in $by_hash no longer exists on disk.
		$removed = 0;
		foreach ( $by_hash as $gone ) {
			$wpdb->delete( $wpdb->prefix . 'uvgf_files', array( 'id' => $gone['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
			$removed++;
		}

		return array(
			'indexed' => count( $found ),
			'added'   => $added,
			'removed' => $removed,
		);
	}

	/**
	 * Registry row by id.
	 *
	 * @param int $id Row id.
	 * @return object|null
	 */
	public static function get( $id ) {
		global $wpdb;
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}uvgf_files WHERE id = %d", absint( $id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
	}

	/**
	 * Registry row by relative path.
	 *
	 * @param string $relpath Relative path.
	 * @return object|null
	 */
	public static function get_by_relpath( $relpath ) {
		global $wpdb;
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}uvgf_files WHERE path_hash = %s", md5( $relpath ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
	}

	/**
	 * Register a single file immediately (used for just-submitted uploads that
	 * the periodic sync hasn't seen yet). Returns the row or null.
	 *
	 * @param string $relpath Relative path (must exist on disk inside the base).
	 * @return object|null
	 */
	public static function register_file( $relpath ) {
		global $wpdb;

		$existing = self::get_by_relpath( $relpath );
		if ( $existing ) {
			return $existing;
		}

		$base = realpath( Uvgf_Locator::base_dir() );
		$abs  = $base ? realpath( $base . DIRECTORY_SEPARATOR . wp_normalize_path( $relpath ) ) : false;
		if ( false === $abs || ! is_file( $abs ) || 0 !== strpos( $abs, $base . DIRECTORY_SEPARATOR ) ) {
			return null;
		}

		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- writes the plugin's own custom registry table; no core API exists for custom tables.
			$wpdb->prefix . 'uvgf_files',
			array(
				'path_hash'  => md5( $relpath ),
				'form_id'    => self::form_id_from_relpath( $relpath ),
				'relpath'    => $relpath,
				'size'       => (int) filesize( $abs ),
				'file_mtime' => gmdate( 'Y-m-d H:i:s', (int) filemtime( $abs ) ),
				'status'     => 'unknown',
				'first_seen' => current_time( 'mysql', true ),
			),
			array( '%s', '%d', '%s', '%d', '%s', '%s', '%s' )
		);

		return self::get_by_relpath( $relpath );
	}

	/**
	 * Blanket-update the status column for every row in a top-level folder.
	 *
	 * @param string $folder Top-level folder name.
	 * @param string $status exposed|protected|unknown.
	 */
	public static function set_folder_status( $folder, $status ) {
		global $wpdb;
		$like = $wpdb->esc_like( $folder . '/' ) . '%';
		$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- updates the plugin's own custom registry table.
			$wpdb->prepare(
				"UPDATE {$wpdb->prefix}uvgf_files SET status = %s, last_check = %s WHERE relpath LIKE %s",
				$status,
				current_time( 'mysql', true ),
				$like
			)
		);
	}
}
