<?php
/**
 * Index Storage Service
 *
 * Handles file I/O for the compressed AI store index file.
 * Each profile has its own index file: products-ai-index-{profile_id}.toon.gz
 *
 * @package AIPT\Api\StoreIndex
 * @since 2.5.0
 * @updated 2.5.0 Added profile-specific file paths
 */

namespace AIPT\Api\StoreIndex;

/**
 * IndexStorage class
 *
 * Manages reading, writing, and deleting the gzip-compressed TOON index file.
 */
class IndexStorage {

	/**
	 * Base directory for index files within uploads.
	 */
	private const INDEX_DIR = 'ai-product-tools/ai-index';

	/**
	 * Profile ID.
	 *
	 * @var int
	 */
	private $profile_id;

	/**
	 * Maximum file size in bytes (10 MB).
	 */
	private const MAX_SIZE_BYTES = 10 * 1024 * 1024;

	/**
	 * Estimated compression ratio for TOON format with gzip level 9.
	 *
	 * Empirical test results (WooCommerce product catalogs):
	 * - Small catalogs (50-200 products):   5.5x - 6.5x compression
	 * - Medium catalogs (200-1000 products): 5.8x - 6.2x compression
	 * - Large catalogs (1000+ products):     5.9x - 6.1x compression
	 *
	 * Conservative value of 6x chosen to balance:
	 * - Avoiding premature trimming (underestimate = thinks file is bigger)
	 * - Preventing overflow (overestimate = thinks file is smaller)
	 *
	 * Safety: IndexBuilder uses TRIM_TARGET_THRESHOLD (90%) with estimateSize(),
	 * providing 10% buffer for ratio variance. For critical checks, use measureSize()
	 * which performs actual gzip compression.
	 *
	 * @see IndexBuilder::TRIM_TARGET_THRESHOLD Target threshold after trimming (0.90)
	 * @see IndexBuilder::TRIM_TRIGGER_THRESHOLD Threshold that triggers trimming (0.85)
	 * @see IndexStorage::measureSize() For accurate size measurement
	 */
	private const ESTIMATED_COMPRESSION_RATIO = 6;

	/**
	 * Constructor.
	 *
	 * @param int|null $profile_id Profile ID. If null/0, uses active profile.
	 */
	public function __construct( ?int $profile_id = null ) {
		if ( empty( $profile_id ) ) {

			$profile_manager = new ProfileManager();
			$active          = $profile_manager->getActive();

			if ( $active ) {
				$this->profile_id = (int) $active['id'];
			} else {

				$default          = $profile_manager->getDefault();
				$this->profile_id = $default ? (int) $default['id'] : 1;
			}
		} else {
			$this->profile_id = $profile_id;
		}
	}

	/**
	 * Get the profile ID.
	 *
	 * @return int
	 */
	public function getProfileId(): int {
		return $this->profile_id;
	}

	/**
	 * Get the full path to the index file.
	 *
	 * All profiles use profile-specific filenames.
	 *
	 * @return string Absolute path to the index file.
	 */
	public function getPath(): string {
		$upload_dir = wp_upload_dir();
		return $upload_dir['basedir'] . '/' . self::INDEX_DIR . '/products-ai-index-' . $this->profile_id . '.toon.gz';
	}

	/**
	 * Get the directory path for the index file.
	 *
	 * @return string Absolute path to the index directory.
	 */
	public function getDirectory(): string {
		return dirname( $this->getPath() );
	}

	/**
	 * Check if index file exists and is readable.
	 *
	 * @return bool
	 */
	public function exists(): bool {
		$path = $this->getPath();
		return file_exists( $path ) && is_readable( $path );
	}

	/**
	 * Read and decode the index file.
	 *
	 * @return array|null Decoded index data or null if unavailable.
	 * @throws \RuntimeException If file is corrupted.
	 */
	public function read(): ?array {
		if ( ! $this->exists() ) {
			return null;
		}

		$path = $this->getPath();

		$compressed = file_get_contents( $path );
		if ( $compressed === false ) {
			throw new \RuntimeException( __( 'Failed to read index file', 'ai-product-tools' ) );
		}

		$toon = @gzdecode( $compressed );
		if ( $toon === false ) {
			throw new \RuntimeException( __( 'Index file is corrupted - failed to decompress', 'ai-product-tools' ) );
		}

		$decoder = new ToonDecoder();
		try {
			$data = $decoder->decode( $toon );
		} catch ( \Exception $e ) {
			throw new \RuntimeException( __( 'Index file is corrupted - invalid TOON: ', 'ai-product-tools' ) . $e->getMessage() );
		}

		if ( ! isset( $data['meta'] ) || ! isset( $data['products'] ) || ! isset( $data['categories'] ) ) {
			throw new \RuntimeException( __( 'Index file is corrupted - missing required sections', 'ai-product-tools' ) );
		}

		return $data;
	}

	/**
	 * Write index data to file (atomic operation).
	 *
	 * Uses a temporary file and rename for atomicity to prevent partial reads.
	 *
	 * @param array $index Index data with meta, products, categories.
	 * @return bool True on success.
	 * @throws \RuntimeException If write fails.
	 * @throws \OverflowException If data exceeds size limit.
	 */
	public function write( array $index ): bool {
		$full_path = $this->getPath();
		$dir_path  = $this->getDirectory();

		if ( ! $this->ensureDirectory() ) {
			throw new \RuntimeException( 'Failed to create index directory' );
		}

		$encoder = new ToonEncoder();
		try {
			$toon = $encoder->encode( $index );
		} catch ( \Exception $e ) {

			throw new \RuntimeException( __( 'Failed to encode index data: ', 'ai-product-tools' ) . $e->getMessage() );
		}

		$compressed = gzencode( $toon, 9 );
		if ( $compressed === false ) {
			throw new \RuntimeException( 'Failed to compress index data' );
		}

		$size = strlen( $compressed );
		if ( $size > self::MAX_SIZE_BYTES ) {
			throw new \OverflowException(
				sprintf(
					/* translators: 1: Current size in bytes, 2: Maximum allowed size in bytes */
					__( 'Index exceeds size limit: %1$d bytes (max: %2$d bytes)', 'ai-product-tools' ),
					$size,
					self::MAX_SIZE_BYTES
				)
			);
		}

		$temp_path = $full_path . '.tmp.' . wp_generate_password( 8, false );

		$written = file_put_contents( $temp_path, $compressed );
		if ( false === $written ) {
			throw new \RuntimeException( __( 'Failed to write index to temporary file', 'ai-product-tools' ) );
		}

		$rename_success = $this->atomicRename( $temp_path, $full_path );
		if ( ! $rename_success ) {

			@unlink( $temp_path );
			throw new \RuntimeException( __( 'Failed to finalize index file', 'ai-product-tools' ) );
		}

		return true;
	}

	/**
	 * Delete the index file.
	 *
	 * @return bool True if deleted or didn't exist.
	 */
	public function delete(): bool {
		$path = $this->getPath();

		if ( ! file_exists( $path ) ) {
			return true;
		}

		return unlink( $path );
	}

	/**
	 * Get file size in bytes (compressed).
	 *
	 * @return int File size or 0 if file doesn't exist.
	 */
	public function getSize(): int {
		$path = $this->getPath();

		if ( ! file_exists( $path ) ) {
			return 0;
		}

		$size = filesize( $path );
		return $size !== false ? $size : 0;
	}

	/**
	 * Get maximum allowed file size in bytes.
	 *
	 * @return int Size limit (10 MB).
	 */
	public function getMaxSize(): int {
		return self::MAX_SIZE_BYTES;
	}

	/**
	 * Estimate the compressed size of index data.
	 *
	 * Used for pre-write size checks without actually compressing.
	 * Uses approximate 6x compression ratio for TOON format.
	 *
	 * @param array $index Index data array.
	 * @return int Estimated compressed size in bytes.
	 */
	public function estimateSize( array $index ): int {
		$encoder = new ToonEncoder();
		try {
			$toon = $encoder->encode( $index );
		} catch ( \Exception $e ) {
			return 0;
		}

		return (int) ceil( strlen( $toon ) / self::ESTIMATED_COMPRESSION_RATIO );
	}

	/**
	 * Measure actual compressed size of index data.
	 *
	 * Performs actual gzip compression to memory buffer for accurate
	 * size measurement during build. More accurate than estimateSize()
	 * but slightly more expensive.
	 *
	 * @param array $index Index data array.
	 * @return int Actual compressed size in bytes, or 0 on failure.
	 */
	public function measureSize( array $index ): int {
		$encoder = new ToonEncoder();
		try {
			$toon = $encoder->encode( $index );
		} catch ( \Exception $e ) {
			return 0;
		}

		$compressed = gzencode( $toon, 9 );
		if ( $compressed === false ) {
			return 0;
		}

		return strlen( $compressed );
	}

	/**
	 * Ensure the storage directory exists with proper permissions.
	 *
	 * @return bool True if directory exists or was created successfully.
	 */
	public function ensureDirectory(): bool {
		$dir_path = $this->getDirectory();

		if ( is_dir( $dir_path ) ) {
			return \wp_is_writable( $dir_path );
		}

		$created = wp_mkdir_p( $dir_path );

		if ( ! $created ) {
			return false;
		}

		$index_file = $dir_path . '/index.php';
		if ( ! file_exists( $index_file ) ) {

			file_put_contents( $index_file, '<?php exit;' );
		}

		$htaccess_file = $dir_path . '/.htaccess';
		if ( ! file_exists( $htaccess_file ) ) {

			file_put_contents( $htaccess_file, 'deny from all' );
		}

		return \wp_is_writable( $dir_path );
	}

	/**
	 * Check if the storage directory is writable.
	 *
	 * @return bool True if writable.
	 */
	public function isWritable(): bool {
		$dir_path = $this->getDirectory();

		if ( ! is_dir( $dir_path ) ) {

			$upload_dir = wp_upload_dir();
			return \wp_is_writable( $upload_dir['basedir'] );
		}

		return \wp_is_writable( $dir_path );
	}

	/**
	 * Perform atomic rename with Windows compatibility.
	 *
	 * On Windows, rename() fails with "Access is denied" if the target file exists.
	 * This method handles that case by first removing the target file with retry logic.
	 *
	 * @param string $source Source file path.
	 * @param string $target Target file path.
	 * @return bool True if rename succeeded.
	 */
	private function atomicRename( string $source, string $target ): bool {

		if ( @rename( $source, $target ) ) {
			return true;
		}

		if ( file_exists( $target ) ) {

			$max_retries = 3;
			$retry_delay_ms = 100;

			for ( $i = 0; $i < $max_retries; $i++ ) {

				if ( @unlink( $target ) ) {

					if ( @rename( $source, $target ) ) {
						return true;
					}

					break;
				}

				if ( $i < $max_retries - 1 ) {
					usleep( $retry_delay_ms * 1000 );
				}
			}
		}

		return @rename( $source, $target );
	}
}
