<?php
/**
 * @license GPL-2.0-or-later
 *
 * Modified by GravityKit using {@see https://github.com/BrianHenryIE/strauss}.
 */

namespace GravityKit\GravityExport\Foundation\Components;

use Exception;
use GravityKit\GravityExport\Foundation\Helpers\WP;
use GravityKit\GravityExport\Foundation\Encryption\Encryption;

/**
 * Secure file download component that allows the creation of encrypted download links.
 *
 * @since 1.3.0
 */
class SecureDownload {
	/**
	 * Component ID.
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	const ID = 'secure_download';

	/**
	 * Ajax action name.
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	const AJAX_ACTION = 'gk_download';

	/**
	 * Rewrite endpoint.
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	const REWRITE_ENDPOINT = 'gk-download';

	/**
	 * Class instance.
	 *
	 * @since 1.3.0
	 *
	 * @var SecureDownload|null
	 */
	private static $_instance = null;

	/**
	 * Encryption instance.
	 *
	 * @since 1.3.0
	 *
	 * @var Encryption
	 */
	private $encryption;

	/**
	 * Buffer size for file streaming (1MB).
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const BUFFER_SIZE = 1048576;

	/**
	 * Maximum buffer size for large files (16MB).
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const MAX_BUFFER_SIZE = 16777216;

	/**
	 * Minimum buffer size for small files (64KB).
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const MIN_BUFFER_SIZE = 65536;

	/**
	 * Flush frequency (every N chunks).
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const FLUSH_FREQUENCY = 4;

	/**
	 * Gets the optimal flush frequency based on buffer size.
	 *
	 * @since 1.3.0
	 *
	 * @param int $buffer_size The buffer size in bytes.
	 *
	 * @return int The optimal flush frequency.
	 */
	private function get_optimal_flush_frequency( $buffer_size ) {
		if ( $buffer_size >= 16777216 ) {      // 16MB+ chunks: flush every 8 chunks (128MB).
			return 8;
		} elseif ( $buffer_size >= 4194304 ) { // 4MB+ chunks: flush every 6 chunks (24MB).
			return 6;
		} else {                               // Smaller chunks: flush every 4 chunks.
			return self::FLUSH_FREQUENCY;
		}
	}

	/**
	 * Default token expiration time (1 hour).
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const DEFAULT_EXPIRATION = HOUR_IN_SECONDS;

	/**
	 * Default cache expiration time for file downloads (1 month).
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const DEFAULT_CACHE_EXPIRATION = MONTH_IN_SECONDS;

	/**
	 * Maximum download history entries per token.
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const MAX_HISTORY_PER_TOKEN = 100;

	/**
	 * Length of short token IDs.
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	const SHORT_ID_LENGTH = 12;

	/**
	 * History key prefix for download tracking.
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	const HISTORY_KEY_PREFIX = 'gk_download_history_';

	/**
	 * Class constructor.
	 *
	 * @since 1.3.0
	 */
	private function __construct() {
		$this->encryption = Encryption::get_instance(
			'',
			[ 'base64_variant' => SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ]
		);
	}

	/**
	 * Returns class instance.
	 *
	 * @since 1.3.0
	 *
	 * @return SecureDownload
	 */
	public static function get_instance() {
		if ( is_null( self::$_instance ) ) {
			self::$_instance = new self();
		}

		return self::$_instance;
	}

	/**
	 * Initializes the component.
	 *
	 * @since 1.3.0
	 *
	 * @return void
	 */
	public function init() {
		add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'handle_download_request' ) );
		add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION, array( $this, 'handle_download_request' ) );

		add_action( 'init', array( $this, 'register_rewrite_rules' ) );
		add_filter( 'query_vars', array( $this, 'add_query_vars' ) );
		add_action( 'template_redirect', array( $this, 'handle_rewrite_request' ) );
	}

	/**
	 * Generates a short ID from a token.
	 *
	 * @since 1.3.0
	 *
	 * @param string $token The encrypted token.
	 *
	 * @return string The short token ID.
	 */
	private function get_token_id( $token ) {
		$full_hash = $this->encryption->hash( $token );

		return substr( $full_hash, 0, self::SHORT_ID_LENGTH );
	}

	/**
	 * Generates a secure download URL for a file.
	 *
	 * @since 1.3.0
	 *
	 * @param string $file_path The absolute path to the file.
	 * @param array  $args {
	 *     Optional arguments for the download URL.
	 *
	 *     @type int          $expires_in          Time in seconds until the link expires. Default 3600 (1 hour). Set to 0 for no expiration.
	 *     @type int          $limit               Maximum number of downloads allowed. 0 = unlimited. Default 0.
	 *     @type array        $capabilities        Array of capabilities required to download. Default empty.
	 *     @type string|array $ips                 Single IP or array of IPs allowed to download. Default empty (no restriction).
	 *     @type int|array    $users               Single user ID or array of user IDs allowed to download. Default empty (no restriction).
	 *     @type bool|array   $track               Track downloads. true = track with history, array = track specific data (e.g. ['ip', 'user_agent', 'history']). Default false.
	 *     @type array        $meta                Additional metadata to include in the token. Default empty.
	 *     @type string       $filename            Custom filename to use when downloading. Default is the original filename.
	 *     @type int          $cache_duration      Cache duration in seconds. 0 = no cache (private), > 0 = specific duration. If not set, auto-detects based on file type.
	 * }
	 *
	 * @return array {
	 *     Download URL and token information.
	 *
	 *     @type string $url The secure download URL.
	 *     @type string $id  Short hash identifier for the token (first 12 chars).
	 * }
	 *
	 * @throws Exception If the file path is invalid or encryption fails.
	 */
	public function generate_download_url( $file_path, $args = [] ) {
		$defaults = [
			'expires_in'   => self::DEFAULT_EXPIRATION,
			'limit'        => 0,
			'capabilities' => [],
			'ips'          => [],
			'users'        => [],
			'track'        => false,
			'meta'         => [],
			'filename'     => '',
		];

		$args = wp_parse_args( $args, $defaults );

		// Basic security check for directory traversal attempts.
		if ( strpos( $file_path, '..' ) !== false ) {
			throw new Exception( esc_html__( 'Invalid file path provided.', 'gk-gravityexport' ) );
		}

		// Convert relative path to absolute.
		if ( substr( $file_path, 0, 1 ) !== '/' ) {
			$file_path = ABSPATH . ltrim( $file_path, '/' );
		}

		// Verify file exists.
		if ( ! file_exists( $file_path ) ) {
			throw new Exception( esc_html__( 'File not found.', 'gk-gravityexport' ) );
		}

		// Normalize IP and user restrictions to arrays.
		if ( ! empty( $args['ips'] ) && ! is_array( $args['ips'] ) ) {
			$args['ips'] = [ $args['ips'] ];
		}
		if ( ! empty( $args['users'] ) && ! is_array( $args['users'] ) ) {
			$args['users'] = [ $args['users'] ];
		}

		// Create token data.
		$token_data = [
			'file'         => $file_path,
			'expires'      => $args['expires_in'] > 0 ? time() + $args['expires_in'] : 0,
			'limit'        => $args['limit'],
			'capabilities' => $args['capabilities'],
			'ips'          => $args['ips'],
			'users'        => $args['users'],
			'track'        => $args['track'],
			'meta'         => $args['meta'],
			'filename'     => $args['filename'],
		];

		if ( isset( $args['cache_duration'] ) ) {
			$token_data['cache_duration'] = $args['cache_duration'];
		}

		/**
		 * Filters the token data before encryption.
		 *
		 * @filter `gk/foundation/secure-download/token-data`
		 *
		 * @since 1.3.0
		 *
		 * @param array  $token_data The token data.
		 * @param string $file_path  The absolute path to the file.
		 * @param array  $args       The arguments passed to generate_download_url.
		 */
		$token_data = apply_filters( 'gk/foundation/secure-download/token-data', $token_data, $file_path, $args );

		$json_data = wp_json_encode( $token_data );

		if ( false === $json_data ) {
			throw new Exception( esc_html__( 'Failed to encode token data.', 'gk-gravityexport' ) );
		}

		$token = $this->encryption->encrypt( $json_data );

		if ( ! $token ) {
			throw new Exception( esc_html__( 'Failed to generate secure token.', 'gk-gravityexport' ) );
		}

		$token_id = $this->get_token_id( $token );

		// Mark token for single-use or limited-use tracking with minimal storage.
		if ( $args['limit'] > 0 ) {
			// Store token ID for tracking.
			$token_data['token_id'] = $token_id;
		}

		$download_url = add_query_arg(
			[
				'action' => self::AJAX_ACTION,
				'token'  => $token,
			],
			admin_url( 'admin-ajax.php' )
		);

		$result = [
			'url' => $download_url,
			'id'  => $token_id,
		];

		return $result;
	}

	/**
	 * Handles the download request via Ajax.
	 *
	 * @since 1.3.0
	 *
	 * @throws Exception If token is invalid or file access is denied.
	 *
	 * @return void
	 */
	public function handle_download_request() {
		try {
			// We don't need to check for nonce here since the token is already encrypted and validated.
			$token = isset( $_REQUEST['token'] ) ? sanitize_text_field( $_REQUEST['token'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

			if ( empty( $token ) ) {
				throw new Exception( esc_html__( 'No download token provided.', 'gk-gravityexport' ), 400 );
			}

			// Validate and decrypt token.
			$token_data = $this->validate_token( $token );

			if ( ! $token_data ) {
				throw new Exception( esc_html__( 'Invalid or expired download token.', 'gk-gravityexport' ), 403 );
			}

			$token_data['token_id'] = $this->get_token_id( $token );

			/**
			 * Fires before a file download starts.
			 *
			 * @action `gk/foundation/secure-download/before-download`
			 *
			 * @since 1.3.0
			 *
			 * @param string $file_path  The absolute path to the file.
			 * @param int    $user_id    The user ID downloading the file.
			 * @param array  $token_data The decrypted token data.
			 */
			do_action( 'gk/foundation/secure-download/before-download', $token_data['file'], get_current_user_id(), $token_data );

			$this->stream_file( $token_data['file'], $token_data );

			$this->record_download( $token_data );

			/**
			 * Fires after a file download completes.
			 *
			 * @action `gk/foundation/secure-download/after-download`
			 *
			 * @since 1.3.0
			 *
			 * @param string $file_path  The absolute path to the file.
			 * @param int    $user_id    The user ID that downloaded the file.
			 * @param array  $token_data The decrypted token data.
			 */
			do_action( 'gk/foundation/secure-download/after-download', $token_data['file'], get_current_user_id(), $token_data );

			exit;
		} catch ( Exception $e ) {
			$error_code    = $e->getCode() ?: 500;
			$error_message = $e->getMessage();

			/**
			 * Filters the download error response.
			 * Return null to indicate the error has been handled and prevent default processing.
			 *
			 * @filter `gk/foundation/secure-download/error-response`
			 *
			 * @since 1.3.0
			 *
			 * @param array|null $error_response {
			 *     Error response data or null if handled.
			 *
			 *     @type int    $code    HTTP status code.
			 *     @type string $message Error message.
			 * }
			 * @param Exception $exception The exception that was thrown.
			 * @param string    $token     The download token that was provided.
			 */
			$error_response = apply_filters(
                'gk/foundation/secure-download/error-response',
                [
					'code'    => $error_code,
					'message' => $error_message,
                ],
                $e,
                $token
            );

			// If null is returned, the error was handled by a filter.
			if ( null === $error_response ) {
				exit;
			}

			/**
			 * Fires when a download error occurs.
			 *
			 * @action `gk/foundation/secure-download/error`
			 *
			 * @since 1.3.0
			 *
			 * @param Exception $exception The exception that was thrown.
			 * @param string    $token     The download token that was provided.
			 */
			do_action( 'gk/foundation/secure-download/error', $e, $token );

			// Default error handling.
			if ( is_array( $error_response ) && isset( $error_response['code'], $error_response['message'] ) ) {
				status_header( $error_response['code'] );

				wp_die( esc_html( $error_response['message'] ) );
			} else {
				// Fallback if filter returns invalid data.
				status_header( $error_code );

				wp_die( esc_html( $error_message ) );
			}
		}
	}

	/**
	 * Validates and decrypts a download token.
	 *
	 * @since 1.3.0
	 *
	 * @param string $token The encrypted token.
	 *
	 * @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing
	 *
	 * @return array|false The decrypted token data or false if invalid.
	 */
	public function validate_token( $token ) {
		$token_data        = null;
		$validation_result = false;
		$exception         = null;
		$failure_code      = null;

		try {
			$decrypted = $this->encryption->decrypt( $token );

			if ( ! $decrypted ) {
				throw new Exception( 'failed_decryption' );
			}

			$decoded = json_decode( $decrypted, true );

			if ( ! is_array( $decoded ) ) {
				throw new Exception( 'invalid_format' );
			}

			$token_data = $decoded;

			// Check expiration (0 means no expiration).
			if ( isset( $token_data['expires'] ) && $token_data['expires'] > 0 && time() > $token_data['expires'] ) {
				throw new Exception( 'expired' );
			}

			// Check allowed users restriction.
			if ( ! empty( $token_data['users'] ) ) {
				$current_user_id = get_current_user_id();

				if ( ! in_array( $current_user_id, $token_data['users'], true ) ) {
					throw new Exception( 'user_not_allowed' );
				}
			}

			// Check allowed IPs restriction.
			if ( ! empty( $token_data['ips'] ) ) {
				$current_ip = $this->get_visitor_ip();

				if ( ! in_array( $current_ip, $token_data['ips'], true ) ) {
					throw new Exception( 'ip_not_allowed' );
				}
			}

			// Check capabilities.
			if ( ! empty( $token_data['capabilities'] ) ) {
				foreach ( $token_data['capabilities'] as $capability ) {
					if ( ! current_user_can( $capability ) ) {
						throw new Exception( 'missing_capability' );
					}
				}
			}

			// Check single-use or limited-use token.
			if ( ! empty( $token_data['limit'] ) ) {
				$token_id    = $this->get_token_id( $token );
				$history_key = self::HISTORY_KEY_PREFIX . $token_id;

				$history = is_multisite()
					? WP::get_site_transient( $history_key )
					: WP::get_transient( $history_key );

				$download_count = is_array( $history ) ? count( $history ) : 0;

				// Check if limit exceeded.
				if ( $download_count >= $token_data['limit'] ) {
					throw new Exception( 'download_limit_exceeded' );
				}
			}

			$validation_result = $token_data;
		} catch ( Exception $e ) {
			$failure_code = $e->getMessage();

			$exception = $e;
		}

		/**
		 * Allows overriding the token validation result.
		 *
		 * @filter `gk/foundation/secure-download/validate-token`
		 *
		 * @since 1.3.0
		 *
		 * @param array|false     $validation_result The validation result (token data array or false).
		 * @param array|null      $token_data        Raw token data array (may be invalid or partial).
		 * @param string          $token             The original encrypted token.
		 * @param string|null     $failure_code      Optional machine-readable failure code (e.g. 'expired').
		 * @param Exception|null  $exception         The thrown exception instance, if any.
		 */
		return apply_filters(
			'gk/foundation/secure-download/validate-token',
			$validation_result,
			$token_data,
			$token,
			$failure_code,
			$exception
		);
	}

	/**
	 * Gets the optimal buffer size based on file size.
	 *
	 * @since 1.3.0
	 *
	 * @param int $file_size The file size in bytes.
	 *
	 * @return int The optimal buffer size.
	 */
	private function get_optimal_buffer_size( $file_size ) {
		if ( $file_size < 1048576 ) {          // < 1MB: use 64KB chunks.
			return self::MIN_BUFFER_SIZE;
		} elseif ( $file_size < 104857600 ) {  // < 100MB: use 1MB chunks.
			return self::BUFFER_SIZE;
		} elseif ( $file_size < 1073741824 ) { // < 1GB: use 4MB chunks.
			return 4194304;
		} else {                               // > 1GB: use 16MB chunks.
			return self::MAX_BUFFER_SIZE;
		}
	}

	/**
	 * Builds HTTP headers for file download response.
	 *
	 * @since 1.3.0
	 *
	 * @param array $context {
	 *     Context data required to build headers.
	 *
	 *     @type string  $file_path       The absolute path to the file.
	 *     @type string  $file_name       Filename to use in response.
	 *     @type string  $mime_type       MIME type of the file.
	 *     @type int     $file_size       Total file size in bytes.
	 *     @type int     $range_start     Byte offset to start from.
	 *     @type int     $range_end       Byte offset to end at.
	 *     @type array   $token_data      Token payload data.
	 *     @type bool    $partial_content Whether this is a partial content response.
	 * }
	 *
	 * @return array Array of headers to be sent.
	 */
	private function build_download_headers( array $context ) {
		$file_path       = $context['file_path'] ?? '';
		$file_name       = $context['file_name'] ?? '';
		$mime_type       = $context['mime_type'] ?? 'application/octet-stream';
		$file_size       = $context['file_size'] ?? 0;
		$range_start     = $context['range_start'] ?? 0;
		$range_end       = $context['range_end'] ?? $file_size;
		$token_data      = $context['token_data'] ?? [];
		$partial_content = $context['partial_content'] ?? false;

		$headers = [];

		// Range header for partial content.
		if ( $partial_content ) {
			$headers['Content-Range'] = "bytes $range_start-$range_end/$file_size";
		}

		// Basic download headers with UTF-8 filename support.
		$headers['Content-Type']        = $mime_type;
		$headers['Content-Disposition'] = 'attachment; filename="' . rawurlencode( $file_name ) . '"; filename*=UTF-8\'\'' . rawurlencode( $file_name );
		$headers['Content-Length']      = (string) ( $range_end - $range_start + 1 );
		$headers['Accept-Ranges']       = 'bytes';

		// Security headers.
		$headers['X-Content-Type-Options']  = 'nosniff';
		$headers['Content-Security-Policy'] = "default-src 'none';";

		// Determine cache strategy based on cache_duration.
		if ( ! isset( $token_data['cache_duration'] ) ) {
			// Auto-detect cache duration based on file type.
			$cache_duration = $this->get_default_cache_duration( $file_path );
		} else {
			$cache_duration = $token_data['cache_duration'];
		}

		if ( $cache_duration > 0 ) {
			// Public caching with specified duration.
			$headers['Cache-Control'] = 'public, max-age=' . $cache_duration . ', immutable';
			$headers['Expires']       = gmdate( 'D, d M Y H:i:s', time() + $cache_duration ) . ' GMT';
		} else {
			// Private (no caching).
			$headers['Cache-Control'] = 'no-store, no-cache, must-revalidate';
			$headers['Pragma']        = 'no-cache';
			$headers['Expires']       = '0';
		}

		/**
		 * Filters the headers sent for a secure file download.
		 *
		 * @filter `gk/foundation/secure-download/headers`
		 *
		 * @since 1.3.0
		 *
		 * @param array $headers Array of headers to be sent.
		 * @param array $context {
		 *     Context data for the download.
		 *
		 *     @type string $file_path       The absolute path to the file.
		 *     @type string $file_name       Filename to use in response.
		 *     @type string $mime_type       MIME type of the file.
		 *     @type int    $file_size       Total file size in bytes.
		 *     @type int    $range_start     Byte offset to start from.
		 *     @type int    $range_end       Byte offset to end at.
		 *     @type array  $token_data      Token payload data.
		 *     @type bool   $partial_content Whether this is a partial content response.
		 * }
		 */
		return apply_filters( 'gk/foundation/secure-download/headers', $headers, $context );
	}

	/**
	 * Gets the default cache duration for a file based on its extension.
	 *
	 * @since 1.3.0
	 *
	 * @param string $file_path The absolute path to the file.
	 *
	 * @return int Cache duration in seconds.
	 */
	private function get_default_cache_duration( $file_path ) {
		$extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );

		if ( in_array( $extension, [ 'woff', 'woff2', 'ttf', 'otf', 'eot' ], true ) ) {
			return MONTH_IN_SECONDS * 6;
		}

		if ( in_array( $extension, [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp', 'tiff', 'tif', 'avif', 'heic', 'heif', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv', 'm4v', '3gp', 'ogv', 'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'oga', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz' ], true ) ) {
			return MONTH_IN_SECONDS * 3;
		}

		if ( in_array( $extension, [ 'pdf', 'txt', 'md', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf', 'css', 'js' ], true ) ) {
			return self::DEFAULT_CACHE_EXPIRATION;
		}

		if ( in_array( $extension, [ 'html', 'htm', 'xml', 'json', 'csv', 'php', 'py', 'ts', 'jsx', 'tsx', 'vue', 'svelte' ], true ) ) {
			return WEEK_IN_SECONDS;
		}

		return self::DEFAULT_CACHE_EXPIRATION;
	}

	/**
	 * Streams a file to the browser with support for large files and range requests.
	 *
	 * @since 1.3.0
	 *
	 * @param string $file_path  The absolute path to the file.
	 * @param array  $token_data Optional. The token data containing custom filename.
	 *
	 * @return void
	 */
	public function stream_file( $file_path, $token_data = [] ) {
		// Clean any output buffers.
		while ( ob_get_level() ) {
			ob_end_clean();
		}

		if ( headers_sent() ) {
			wp_die( esc_html__( 'Cannot stream file: headers already sent.', 'gk-gravityexport' ) );
		}

		// Disable WordPress output buffering for large downloads.
		if ( function_exists( 'wp_ob_end_flush_all' ) ) {
			wp_ob_end_flush_all();
		}

		// Optimize for large downloads.
		set_time_limit( 0 );

		if ( function_exists( 'ini_set' ) ) {
			// phpcs:ignore WordPress.PHP.IniSet.Risky, WordPress.PHP.NoSilencedErrors.Discouraged
			@ini_set( 'zlib.output_compression', 'Off' );
		}

		// Get file info.
		clearstatcache( true, $file_path );

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- suppressing warnings is intentional here for race condition handling.
		$file_size = @filesize( $file_path );

		if ( false === $file_size ) {
			// File might have been deleted between token validation and now.
			wp_die( esc_html__( 'File not found or cannot be accessed.', 'gk-gravityexport' ), 404 );
		}
		$file_name = ! empty( $token_data['filename'] ) ? $token_data['filename'] : basename( $file_path );
		$mime_type = wp_check_filetype( $file_path );
		$mime_type = $mime_type['type'] ?: 'application/octet-stream';

		// Handle range requests.
		$range_start     = 0;
		$range_end       = $file_size - 1;
		$partial_content = false;

		if ( isset( $_SERVER['HTTP_RANGE'] ) ) {
			$range_header = $_SERVER['HTTP_RANGE'];

			if ( preg_match( '/bytes=(\d+)-(\d*)/', $range_header, $matches ) ) {
				$range_start = (int) $matches[1];

				if ( ! empty( $matches[2] ) ) {
					$range_end = (int) $matches[2];
				}

				// Validate range bounds.
				if ( $range_start >= $file_size || $range_start > $range_end ) {
					status_header( 416 ); // Range Not Satisfiable.

					header( "Content-Range: bytes */$file_size" );

					exit;
				}

				// Clamp range_end to file size.
				if ( $range_end >= $file_size ) {
					$range_end = $file_size - 1;
				}

				$partial_content = true;
			}
		}

		// Set status header.
		if ( $partial_content ) {
			status_header( 206 );
		} else {
			status_header( 200 );
		}

		// Build and apply headers.
		$headers = $this->build_download_headers(
            [
				'file_path'       => $file_path,
				'file_name'       => $file_name,
				'mime_type'       => $mime_type,
				'file_size'       => $file_size,
				'range_start'     => $range_start,
				'range_end'       => $range_end,
				'token_data'      => $token_data,
				'partial_content' => $partial_content,
			]
        );

		foreach ( $headers as $header_name => $header_value ) {
			header( $header_name . ': ' . $header_value );
		}

		// For HEAD requests, we only need to send headers, not the file content.
		if ( 'HEAD' === ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
			return;
		}

		// Use direct file operations for performance reasons.
		// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_read_fopen
		// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_read_fread
		// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_read_fclose
		$handle = fopen( $file_path, 'rb' );

		if ( ! $handle ) {
			wp_die( esc_html__( 'File not found or cannot be opened.', 'gk-gravityexport' ), 404 );
		}

		// Seek to start position.
		if ( $range_start > 0 ) {
			fseek( $handle, $range_start );
		}

		// Stream file in chunks.
		$bytes_remaining    = $range_end - $range_start + 1;
		$optimal_buffer     = $this->get_optimal_buffer_size( $bytes_remaining );
		$optimal_flush_freq = $this->get_optimal_flush_frequency( $optimal_buffer );
		$chunk_count        = 0;

		while ( $bytes_remaining > 0 && ! feof( $handle ) ) {
			$chunk_size = min( $optimal_buffer, $bytes_remaining );

			// Ensure chunk size is positive for fread().
			if ( $chunk_size <= 0 ) {
				break;
			}

			$chunk = fread( $handle, $chunk_size );

			if ( false === $chunk ) {
				// Log error for debugging.
				error_log( sprintf( 'SecureDownload: Failed to read chunk at position %d for file %s', ftell( $handle ), $file_path ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log

				fclose( $handle );

				wp_die(
					esc_html__( 'An error occurred while reading the file. Please try again.', 'gk-gravityexport' ),
					500,
					[ 'response' => 500 ]
				);
			}

			if ( 0 === strlen( $chunk ) && ! feof( $handle ) ) {
				// Unexpected empty read when not at EOF - file may have been truncated.
				error_log( sprintf( 'SecureDownload: Unexpected empty read at position %d for file %s', ftell( $handle ), $file_path ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log

				// Clean up resources.
				fclose( $handle );

				// Inform the user about the corruption.
				wp_die(
					esc_html__( 'The file appears to be corrupted or was modified during download.', 'gk-gravityexport' ),
					500,
					[ 'response' => 500 ]
				);
			}

			echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

			$bytes_remaining -= strlen( $chunk );
			$chunk_count++;

			// Check for client disconnect after each chunk for better resource management.
			if ( connection_aborted() ) {
				fclose( $handle );

				exit;
			}

			// Optimized flushing - only flush every N chunks.
			if ( 0 === $chunk_count % $optimal_flush_freq ) {
				flush();
			}
		}

		// Final flush.
		flush();

		fclose( $handle );
		// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_read_fopen
		// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_read_fread
		// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_read_fclose
	}

	/**
	 * Records a download for tracking purposes.
	 *
	 * @since 1.3.0
	 *
	 * @param array $token_data The token data.
	 *
	 * @return void
	 */
	public function record_download( $token_data ) {
		$history_record = [
			'token_id'  => isset( $token_data['token_id'] ) ? $token_data['token_id'] : '',
			'file'      => $token_data['file'],
			'user_id'   => get_current_user_id(),
			'timestamp' => current_time( 'timestamp' ), // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
			'meta'      => isset( $token_data['meta'] ) ? $token_data['meta'] : [],
		];

		if ( empty( $token_data['token_id'] ) ) {
			return;
		}

		// Handle built-in tracking based on 'track' parameter.
		if ( ! empty( $token_data['track'] ) ) {
			$track_options = is_array( $token_data['track'] ) ? $token_data['track'] : [ 'ip', 'user_agent', 'history' ];

			// Track IP address if requested.
			if ( in_array( 'ip', $track_options, true ) ) {
				$history_record['ip_address'] = $this->get_visitor_ip();
			}

			// Track user agent if requested.
			if ( in_array( 'user_agent', $track_options, true ) ) {
				$history_record['user_agent'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
			}

			// Track referrer if requested.
			if ( in_array( 'referrer', $track_options, true ) ) {
				$history_record['referrer'] = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : '';
			}
		}

		/**
		 * Filters download information to allow adding tracking data.
		 *
		 * @filter `gk/foundation/secure-download/history-record`
		 *
		 * @since 1.3.0
		 *
		 * @param array $history_record Minimal download information.
		 * @param array $token_data    The token data.
		 */
		$history_record = apply_filters( 'gk/foundation/secure-download/history-record', $history_record, $token_data );

		/**
		 * Filters whether to store download history.
		 *
		 * @filter `gk/foundation/secure-download/save-history`
		 *
		 * @since 1.3.0
		 *
		 * @param bool  $should_save Whether to record the download. Default false.
		 * @param array $history_record The download record to save.
		 */
		$should_save = apply_filters( 'gk/foundation/secure-download/save-history', false, $history_record );

		// Auto-enable history if 'history' is in track options.
		if ( ! empty( $token_data['track'] ) ) {
			$track_options = is_array( $token_data['track'] ) ? $token_data['track'] : [ 'ip', 'user_agent', 'history' ];

			if ( in_array( 'history', $track_options, true ) ) {
				$should_save = true;
			}
		}

		// Determine if we need to store anything.
		$has_limit = ! empty( $token_data['limit'] );

		if ( ! $should_save && ! $has_limit ) {
			return;
		}

		// Store download history per token.
		$history_key = self::HISTORY_KEY_PREFIX . $token_data['token_id'];
		$history     = is_multisite() ? WP::get_site_transient( $history_key ) : WP::get_transient( $history_key );

		if ( ! is_array( $history ) ) {
			$history = [];
		}

		// Add download record.
		if ( $should_save ) {
			// Store full download info when history is enabled.
			$history[] = $history_record;
		} else {
			// Store minimal data when only tracking count for limits.
			$history[] = [
				'timestamp' => $history_record['timestamp'],
			];
		}

		/**
		 * Filters the maximum number of history entries to keep per token.
		 *
		 * @filter `gk/foundation/secure-download/history-length`
		 *
		 * @since 1.3.0
		 *
		 * @param int    $max_history Maximum number of history entries to keep. Default 100.
		 * @param string $token_id    The token ID for the download.
		 * @param array  $token_data  The full token data array.
		 */
		$max_history = apply_filters( 'gk/foundation/secure-download/history-length', self::MAX_HISTORY_PER_TOKEN, $token_data['token_id'], $token_data );

		if ( ! $max_history ) {
			return;
		}

		$history = array_slice( $history, -$max_history );

		// Calculate transient expiration.
		if ( isset( $token_data['expires'] ) && $token_data['expires'] > 0 ) {
			$expires_in = $token_data['expires'] - time();
		} else {
			$expires_in = 0;
		}

		if ( is_multisite() ) {
			WP::set_site_transient( $history_key, $history, $expires_in );
		} else {
			WP::set_transient( $history_key, $history, $expires_in );
		}
	}

	/**
	 * Gets download history for a specific token.
	 *
	 * @since 1.3.0
	 *
	 * @param string|null $token Optional. Full token or short ID to filter by. If null, returns history for all tokens.
	 * @param array       $args {
	 *     Optional arguments for filtering results.
	 *
	 *     @type int        $user_id    Filter by user ID.
	 *     @type string|int $after      Return downloads after this date (Y-m-d H:i:s string or Unix timestamp).
	 *     @type string|int $before     Return downloads before this date (Y-m-d H:i:s string or Unix timestamp).
	 *     @type int        $limit      Maximum number of results to return.
	 * }
	 *
	 * @return array Array of download records.
	 */
	public function get_download_history( $token = null, $args = [] ) {
		$defaults = [
			'user_id' => null,
			'after'   => null,
			'before'  => null,
			'limit'   => null,
		];

		$args = wp_parse_args( $args, $defaults );

		if ( is_null( $token ) ) {
			return $this->get_all_download_history( $args );
		}

		// Get token ID.
		if ( strlen( $token ) > self::SHORT_ID_LENGTH ) {
			$token_id = $this->get_token_id( $token );
		} else {
			$token_id = $token;
		}

		// Get history for this token.
		$history_key = self::HISTORY_KEY_PREFIX . $token_id;
		$history     = is_multisite() ? WP::get_site_transient( $history_key ) : WP::get_transient( $history_key );

		if ( ! is_array( $history ) ) {
			return [];
		}

		// Filter by user ID.
		if ( ! is_null( $args['user_id'] ) ) {
			$history = array_filter(
				$history,
				function( $record ) use ( $args ) {
					return isset( $record['user_id'] ) && $record['user_id'] === $args['user_id'];
				}
			);
		}

		// Filter by date range.
		if ( ! is_null( $args['after'] ) ) {
			$after_timestamp = is_numeric( $args['after'] ) ? $args['after'] : strtotime( $args['after'] );

			$history = array_filter(
				$history,
				function( $record ) use ( $after_timestamp ) {
					return isset( $record['timestamp'] ) && $record['timestamp'] > $after_timestamp;
				}
			);
		}

		if ( ! is_null( $args['before'] ) ) {
			$before_timestamp = is_numeric( $args['before'] ) ? $args['before'] : strtotime( $args['before'] );

			$history = array_filter(
				$history,
				function( $record ) use ( $before_timestamp ) {
					return isset( $record['timestamp'] ) && $record['timestamp'] < $before_timestamp;
				}
			);
		}

		// Sort by timestamp descending (newest first).
		usort(
			$history,
			function( $a, $b ) {
				$time_a = isset( $a['timestamp'] ) ? $a['timestamp'] : 0;
				$time_b = isset( $b['timestamp'] ) ? $b['timestamp'] : 0;

				return $time_b - $time_a;
			}
		);

		// Apply limit.
		if ( ! is_null( $args['limit'] ) && $args['limit'] > 0 ) {
			$history = array_slice( $history, 0, $args['limit'] );
		}

		return array_values( $history );
	}

	/**
	 * Get download history across all tokens.
	 *
	 * @since 1.3.0
	 *
	 * @param array $args {
	 *     Optional arguments for filtering results.
	 *
	 *     @type int        $user_id    Filter by user ID.
	 *     @type string|int $after      Return downloads after this date (Y-m-d H:i:s string or Unix timestamp).
	 *     @type string|int $before     Return downloads before this date (Y-m-d H:i:s string or Unix timestamp).
	 *     @type int        $limit      Maximum number of results to return.
	 * }
	 *
	 * @return array Array of download records from all tokens.
	 */
	public function get_all_download_history( $args = [] ) {
		global $wpdb;

		// Get all download history transients.
		$history_prefix = self::HISTORY_KEY_PREFIX;

		// Build LIMIT clause if needed.
		$limit_clause = '';

		if ( ! empty( $args['limit'] ) ) {
			// Fetch extra records to account for post-retrieval filtering.
			$fetch_limit  = absint( $args['limit'] ) * 3; // 3x to ensure we have enough after filtering.
			$limit_clause = $wpdb->prepare( ' LIMIT %d', $fetch_limit );
		}

		if ( is_multisite() ) {
			if ( ! empty( $limit_clause ) ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $limit_clause is already prepared.
				$results = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT meta_key, meta_value
						 FROM {$wpdb->sitemeta}
						 WHERE meta_key LIKE %s AND site_id = %d
						 ORDER BY meta_id DESC",
						$wpdb->esc_like( $history_prefix ) . '%',
						get_current_network_id()
					) . $limit_clause
				);
			} else {
				$results = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT meta_key, meta_value
						 FROM {$wpdb->sitemeta}
						 WHERE meta_key LIKE %s AND site_id = %d
						 ORDER BY meta_id DESC",
						$wpdb->esc_like( $history_prefix ) . '%',
						get_current_network_id()
					)
				);
			}
		} else {
			if ( ! empty( $limit_clause ) ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $limit_clause is already prepared.
				$results = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT option_name, option_value
						 FROM {$wpdb->options}
						 WHERE option_name LIKE %s
						 ORDER BY option_id DESC",
						$wpdb->esc_like( '_transient_' . $history_prefix ) . '%'
					) . $limit_clause
				);
			} else {
				$results = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT option_name, option_value
						 FROM {$wpdb->options}
						 WHERE option_name LIKE %s
						 ORDER BY option_id DESC",
						$wpdb->esc_like( '_transient_' . $history_prefix ) . '%'
					)
				);
			}
		}

		$all_history = [];

		foreach ( $results as $result ) {
			$value         = is_multisite() ? $result->meta_value : $result->option_value;
			$token_history = maybe_unserialize( $value );

			if ( is_array( $token_history ) ) {
				// Add token_id to each record for reference.
				$key      = is_multisite() ? $result->meta_key : $result->option_name;
				$token_id = str_replace( [ '_transient_' . $history_prefix, $history_prefix ], '', $key );

				foreach ( $token_history as &$record ) {
					$record['token_id'] = $token_id;
				}

				$all_history = array_merge( $all_history, $token_history );
			}
		}

		// Filter by user ID.
		if ( ! is_null( $args['user_id'] ) ) {
			$all_history = array_filter(
				$all_history,
				function( $record ) use ( $args ) {
					return isset( $record['user_id'] ) && $record['user_id'] === $args['user_id'];
				}
			);
		}

		// Filter by date range.
		if ( ! is_null( $args['after'] ) ) {
			$after_timestamp = is_numeric( $args['after'] ) ? $args['after'] : strtotime( $args['after'] );

			$all_history = array_filter(
				$all_history,
				function( $record ) use ( $after_timestamp ) {
					return isset( $record['timestamp'] ) && $record['timestamp'] > $after_timestamp;
				}
			);
		}

		if ( ! is_null( $args['before'] ) ) {
			$before_timestamp = is_numeric( $args['before'] ) ? $args['before'] : strtotime( $args['before'] );

			$all_history = array_filter(
				$all_history,
				function( $record ) use ( $before_timestamp ) {
					return isset( $record['timestamp'] ) && $record['timestamp'] < $before_timestamp;
				}
			);
		}

		// Sort by timestamp descending (newest first).
		usort(
			$all_history,
			function( $a, $b ) {
				$time_a = isset( $a['timestamp'] ) ? $a['timestamp'] : 0;
				$time_b = isset( $b['timestamp'] ) ? $b['timestamp'] : 0;

				return $time_b - $time_a;
			}
		);

		// Apply limit.
		if ( ! is_null( $args['limit'] ) && $args['limit'] > 0 ) {
			$all_history = array_slice( $all_history, 0, $args['limit'] );
		}

		return array_values( $all_history );
	}

	/**
	 * Registers rewrite rules for pretty download URLs.
	 *
	 * @since 1.3.0
	 *
	 * @return void
	 */
	public function register_rewrite_rules() {
		add_rewrite_rule(
			'^' . self::REWRITE_ENDPOINT . '/([^/]+)/?$',
			'index.php?gk_download_token=$matches[1]',
			'top'
		);
	}

	/**
	 * Adds query variables for rewrite rules.
	 *
	 * @since 1.3.0
	 *
	 * @param array $query_vars Existing query variables.
	 *
	 * @return array Modified query variables.
	 */
	public function add_query_vars( $query_vars ) {
		$query_vars[] = 'gk_download_token';

		return $query_vars;
	}

	/**
	 * Handles download requests from rewrite rules.
	 *
	 * @since 1.3.0
	 *
	 * @return void
	 */
	public function handle_rewrite_request() {
		$token = get_query_var( 'gk_download_token' );

		if ( ! empty( $token ) ) {
			$_REQUEST['token'] = $token;

			$this->handle_download_request();
		}
	}

	/**
	 * Gets the visitor IP address.
	 *
	 * @since 1.3.0
	 *
	 * @return string The visitor IP address.
	 */
	private function get_visitor_ip() {
		$ip = '';

		// Check for various IP headers in order of preference.
		if ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
			$ip = $_SERVER['HTTP_X_REAL_IP'];
		} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
			// X-Forwarded-For can contain multiple IPs, get the first one.
			$ips = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
			$ip  = trim( $ips[0] );
		} elseif ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
			$ip = $_SERVER['HTTP_CLIENT_IP'];
		} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			$ip = $_SERVER['REMOTE_ADDR'];
		}

		/**
		 * Filters the visitor IP address.
		 *
		 * @filter `gk/foundation/secure-download/visitor-ip`
		 *
		 * @since 1.3.0
		 *
		 * @param string $ip The detected IP address.
		 */
		$ip = apply_filters( 'gk/foundation/secure-download/visitor-ip', $ip );

		return $ip;
	}
}
