<?php
/**
 * Protected storage and signed download URLs for Basic Forms uploads.
 *
 * @package BasicForms
 * @since 1.0.0
 */

namespace BasicForms;

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

/**
 * Handles wpps-basic-forms upload paths and secure download links.
 */
final class FormFileStorage {

	public const UPLOAD_SUBDIR = 'wpps-basic-forms';
	private const DOWNLOAD_ACTION = 'wpps_basic_forms_download_file';
	private const DEFAULT_LINK_TTL = WEEK_IN_SECONDS;

	/**
	 * @return string Relative path under the uploads basedir (no leading slash).
	 */
	public static function getUploadSubdir( int $form_id ): string {
		$segment = self::UPLOAD_SUBDIR . '/' . max( 0, $form_id );

		return (string) apply_filters( 'wp_powersuite_basic_forms_upload_subdir', $segment, $form_id );
	}

	/**
	 * Ensure upload root exists with directory listing blocked.
	 */
	public static function ensureUploadRoot(): void {
		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return;
		}

		$root = trailingslashit( $upload_dir['basedir'] ) . self::UPLOAD_SUBDIR;
		if ( ! wp_mkdir_p( $root ) ) {
			return;
		}

		$index_file = $root . '/index.php';
		if ( ! is_file( $index_file ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $index_file, "<?php\n// Silence is golden.\n" );
		}

		$htaccess = $root . '/.htaccess';
		if ( ! is_file( $htaccess ) ) {
			$rules = "# WP PowerSuite Basic Forms — block direct file access.\n";
			$rules .= "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n";
			$rules .= "<IfModule !mod_authz_core.c>\nOrder deny,allow\nDeny from all\n</IfModule>\n";
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $htaccess, $rules );
		}
	}

	/**
	 * @param array<string, string> $uploads WordPress upload directory array.
	 * @return array<string, string>
	 */
	public static function filterUploadDir( array $uploads, int $form_id ): array {
		if ( $form_id <= 0 || ! empty( $uploads['error'] ) ) {
			return $uploads;
		}

		$custom = trim( self::getUploadSubdir( $form_id ), '/' );
		if ( $custom === '' ) {
			return $uploads;
		}

		$uploads['subdir'] = '/' . $custom . ( $uploads['subdir'] ?? '' );
		$uploads['path']   = $uploads['basedir'] . $uploads['subdir'];
		// Public URL is not used for stored links; files are served via signed downloads.
		$uploads['url']    = $uploads['baseurl'] . $uploads['subdir'];

		return $uploads;
	}

	/**
	 * Build a structured file reference for JSON storage (no public URL).
	 *
	 * @return array{wpps_bf_file:true,path:string,name:string}
	 */
	public static function createReference( string $absolute_path, string $original_name ): array {
		$relative = self::toRelativePath( $absolute_path );

		return [
			'wpps_bf_file' => true,
			'path'         => $relative,
			'name'         => sanitize_file_name( $original_name ),
		];
	}

	/**
	 * @param mixed $value Stored field value.
	 */
	public static function isFileReference( mixed $value ): bool {
		return is_array( $value )
			&& ! empty( $value['wpps_bf_file'] )
			&& is_string( $value['path'] ?? null );
	}

	/**
	 * @param mixed $value Stored field value.
	 * @return list<array{wpps_bf_file:true,path:string,name:string}>
	 */
	public static function normalizeReferences( mixed $value ): array {
		if ( self::isFileReference( $value ) ) {
			return [ $value ];
		}

		if ( is_array( $value ) ) {
			$refs = [];
			foreach ( $value as $item ) {
				if ( self::isFileReference( $item ) ) {
					$refs[] = $item;
				}
			}
			return $refs;
		}

		return [];
	}

	/**
	 * @param mixed $value Stored field value.
	 */
	public static function formatValueForAdmin( mixed $value, int $entry_id ): string {
		$refs = self::normalizeReferences( $value );
		if ( $refs !== [] ) {
			$links = [];
			foreach ( $refs as $ref ) {
				$name = $ref['name'] ?? basename( $ref['path'] );
				$url  = self::buildDownloadUrl( $entry_id, $ref['path'] );
				$links[] = '<a href="' . esc_url( $url ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $name ) . '</a>';
			}

			return implode( '<br>', $links );
		}

		if ( is_array( $value ) ) {
			return esc_html( implode( ', ', array_map( 'strval', $value ) ) );
		}

		return esc_html( (string) $value );
	}

	/**
	 * Plain-text representation for notifications and CSV export.
	 *
	 * @param mixed $value Stored field value.
	 */
	public static function formatValuePlain( mixed $value, int $entry_id ): string {
		$refs = self::normalizeReferences( $value );
		if ( $refs !== [] ) {
			$parts = [];
			foreach ( $refs as $ref ) {
				$name = $ref['name'] ?? basename( $ref['path'] );
				$parts[] = $name . ' (' . self::buildDownloadUrl( $entry_id, $ref['path'] ) . ')';
			}

			return implode( ', ', $parts );
		}

		if ( is_array( $value ) ) {
			return implode( ', ', array_map( 'strval', $value ) );
		}

		return (string) $value;
	}

	/**
	 * Collect relative paths referenced in an entry payload.
	 *
	 * @param array<string, mixed> $payload
	 * @return list<string>
	 */
	public static function collectPathsFromPayload( array $payload ): array {
		$paths = [];
		foreach ( $payload as $value ) {
			foreach ( self::normalizeReferences( $value ) as $ref ) {
				$paths[] = $ref['path'];
			}
		}

		return array_values( array_unique( $paths ) );
	}

	/**
	 * Remove uploaded files referenced in an entry payload (refs and legacy URLs).
	 *
	 * @param array<string, mixed> $payload
	 */
	public static function deleteFilesForPayload( array $payload ): void {
		foreach ( self::collectPathsFromPayload( $payload ) as $path ) {
			self::deleteRelativePath( $path );
		}

		foreach ( $payload as $value ) {
			foreach ( self::extractLegacyUploadUrls( $value ) as $url ) {
				$relative = self::urlToUploadRelativePath( $url );
				if ( $relative !== '' ) {
					self::deletePathUnderUploads( $relative );
				}
			}
		}
	}

	/**
	 * Migrate legacy public upload URLs in a payload to protected file references.
	 *
	 * @param array<string, mixed> $payload
	 * @return array<string, mixed>
	 */
	public static function migratePayload( array $payload, int $entry_id ): array {
		foreach ( $payload as $key => $value ) {
			$payload[ $key ] = self::migrateFieldValue( $value, $entry_id );
		}

		return $payload;
	}

	/**
	 * Batch-migrate entries that still store plain upload URLs.
	 *
	 * @return array{migrated:int, done:bool}
	 */
	public static function migrateLegacyEntries( string $table_name, int $batch_size = 100 ): array {
		global $wpdb;

		if ( get_option( 'wpps_bf_legacy_files_migrated', false ) ) {
			return [ 'migrated' => 0, 'done' => true ];
		}

		$batch_size = max( 1, $batch_size );
		$last_id    = max( 0, (int) get_option( 'wpps_bf_file_migration_cursor', 0 ) );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is prefixed and controlled.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, payload FROM {$table_name} WHERE id > %d ORDER BY id ASC LIMIT %d",
				$last_id,
				$batch_size
			)
		);

		if ( ! is_array( $rows ) || $rows === [] ) {
			update_option( 'wpps_bf_legacy_files_migrated', 1, false );
			delete_option( 'wpps_bf_file_migration_cursor' );

			return [ 'migrated' => 0, 'done' => true ];
		}

		$migrated = 0;
		foreach ( $rows as $row ) {
			$entry_id = (int) ( $row->id ?? 0 );
			$payload  = json_decode( $row->payload ?? '{}', true );
			if ( ! is_array( $payload ) || $entry_id <= 0 ) {
				$last_id = $entry_id;
				continue;
			}

			$new_payload = self::migratePayload( $payload, $entry_id );
			if ( $new_payload !== $payload ) {
				$wpdb->update(
					$table_name,
					[ 'payload' => wp_json_encode( $new_payload ) ],
					[ 'id' => $entry_id ],
					[ '%s' ],
					[ '%d' ]
				);
				++$migrated;
			}

			$last_id = $entry_id;
		}

		update_option( 'wpps_bf_file_migration_cursor', $last_id, false );

		$done = count( $rows ) < $batch_size;
		if ( $done ) {
			update_option( 'wpps_bf_legacy_files_migrated', 1, false );
			delete_option( 'wpps_bf_file_migration_cursor' );
		}

		return [ 'migrated' => $migrated, 'done' => $done ];
	}

	/**
	 * @param mixed $value Stored field value.
	 */
	public static function migrateFieldValue( mixed $value, int $entry_id ): mixed {
		if ( self::normalizeReferences( $value ) !== [] ) {
			return $value;
		}

		if ( is_string( $value ) && self::looksLikeUploadUrl( $value ) ) {
			$ref = self::referenceFromUploadUrl( $value, $entry_id );

			return $ref ?? $value;
		}

		if ( ! is_array( $value ) ) {
			return $value;
		}

		$migrated = [];
		foreach ( $value as $item ) {
			if ( self::isFileReference( $item ) ) {
				$migrated[] = $item;
				continue;
			}
			if ( is_string( $item ) && self::looksLikeUploadUrl( $item ) ) {
				$ref = self::referenceFromUploadUrl( $item, $entry_id );
				if ( $ref ) {
					$migrated[] = $ref;
					continue;
				}
			}

			return $value;
		}

		if ( $migrated === [] ) {
			return $value;
		}

		return count( $migrated ) === 1 ? $migrated[0] : $migrated;
	}

	/**
	 * @param mixed $value Stored field value.
	 * @return list<string>
	 */
	private static function extractLegacyUploadUrls( mixed $value ): array {
		if ( is_string( $value ) && self::looksLikeUploadUrl( $value ) ) {
			return [ $value ];
		}

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

		$urls = [];
		foreach ( $value as $item ) {
			if ( is_string( $item ) && self::looksLikeUploadUrl( $item ) ) {
				$urls[] = $item;
			}
		}

		return $urls;
	}

	private static function looksLikeUploadUrl( string $value ): bool {
		if ( ! filter_var( $value, FILTER_VALIDATE_URL ) ) {
			return false;
		}

		return str_contains( $value, '/wp-content/uploads/' ) || str_contains( $value, '/uploads/' );
	}

	private static function referenceFromUploadUrl( string $url, int $entry_id ): ?array {
		$relative = self::urlToUploadRelativePath( $url );
		if ( $relative === '' ) {
			return null;
		}

		if ( str_starts_with( $relative, self::UPLOAD_SUBDIR . '/' ) ) {
			$absolute = self::toAbsolutePath( $relative );
			if ( $absolute === false ) {
				return null;
			}

			return [
				'wpps_bf_file' => true,
				'path'         => $relative,
				'name'         => sanitize_file_name( basename( $relative ) ),
			];
		}

		$source = self::absolutePathUnderUploads( $relative );
		if ( $source === false ) {
			return null;
		}

		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return null;
		}

		$dest_dir_relative = self::UPLOAD_SUBDIR . '/migrated/' . max( 0, $entry_id );
		$dest_dir          = trailingslashit( $upload_dir['basedir'] ) . $dest_dir_relative;
		wp_mkdir_p( $dest_dir );

		$filename      = sanitize_file_name( basename( $source ) );
		$unique_name   = wp_unique_filename( $dest_dir, $filename );
		$dest_relative = $dest_dir_relative . '/' . $unique_name;
		$dest_absolute = trailingslashit( $upload_dir['basedir'] ) . $dest_relative;

		if ( ! @rename( $source, $dest_absolute ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( ! copy( $source, $dest_absolute ) ) {
				return null;
			}
			wp_delete_file( $source );
		}

		return [
			'wpps_bf_file' => true,
			'path'         => $dest_relative,
			'name'         => $unique_name,
		];
	}

	private static function urlToUploadRelativePath( string $url ): string {
		$upload_dir = wp_upload_dir();
		$baseurl    = trailingslashit( $upload_dir['baseurl'] );

		if ( str_starts_with( $url, $baseurl ) ) {
			return ltrim( substr( $url, strlen( $baseurl ) ), '/' );
		}

		$path = wp_parse_url( $url, PHP_URL_PATH );
		if ( ! is_string( $path ) || $path === '' ) {
			return '';
		}

		$marker = '/wp-content/uploads/';
		$pos    = strpos( $path, $marker );
		if ( $pos === false ) {
			return '';
		}

		return ltrim( substr( $path, $pos + strlen( $marker ) ), '/' );
	}

	private static function deleteRelativePath( string $relative_path ): void {
		$absolute = self::toAbsolutePath( $relative_path );
		if ( $absolute !== false && is_file( $absolute ) ) {
			wp_delete_file( $absolute );
		}
	}

	private static function deletePathUnderUploads( string $relative_path ): void {
		$absolute = self::absolutePathUnderUploads( $relative_path );
		if ( $absolute !== false && is_file( $absolute ) ) {
			wp_delete_file( $absolute );
		}
	}

	/**
	 * @return string|false
	 */
	private static function absolutePathUnderUploads( string $relative_path ) {
		$relative_path = ltrim( wp_normalize_path( $relative_path ), '/' );
		if ( $relative_path === '' || str_contains( $relative_path, '..' ) ) {
			return false;
		}

		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return false;
		}

		$absolute = wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) . $relative_path );
		$real     = realpath( $absolute );
		$root     = realpath( $upload_dir['basedir'] );

		if ( $real === false || $root === false || ! str_starts_with( $real, $root ) || ! is_file( $real ) ) {
			return false;
		}

		return $real;
	}

	public static function buildDownloadUrl( int $entry_id, string $relative_path, int $expires = 0 ): string {
		$relative_path = self::sanitizeRelativePath( $relative_path );
		if ( $relative_path === '' || $entry_id <= 0 ) {
			return '';
		}

		$expires = $expires > 0 ? $expires : ( time() + (int) apply_filters( 'wp_powersuite_basic_forms_file_link_ttl', self::DEFAULT_LINK_TTL, $entry_id, $relative_path ) );
		$file_key = self::encodeFileKey( $relative_path );

		return add_query_arg(
			[
				'action'    => self::DOWNLOAD_ACTION,
				'entry_id'  => $entry_id,
				'file'      => $file_key,
				'expires'   => $expires,
				'sig'       => self::signDownload( $entry_id, $file_key, $expires ),
			],
			admin_url( 'admin-ajax.php' )
		);
	}

	public static function handleDownloadRequest(): void {
		$entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0;
		$file_key = isset( $_GET['file'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['file'] ) ) : '';
		$expires  = isset( $_GET['expires'] ) ? (int) $_GET['expires'] : 0;
		$sig      = isset( $_GET['sig'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['sig'] ) ) : '';

		if ( $entry_id <= 0 || $file_key === '' || $expires <= 0 || $sig === '' ) {
			wp_die( esc_html__( 'Invalid download link.', 'wp-powersuite' ), '', [ 'response' => 400 ] );
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to download this file.', 'wp-powersuite' ), '', [ 'response' => 403 ] );
		}

		if ( time() > $expires || ! hash_equals( self::signDownload( $entry_id, $file_key, $expires ), $sig ) ) {
			wp_die( esc_html__( 'This download link has expired. Open the entry in WP PowerSuite to generate a new link.', 'wp-powersuite' ), '', [ 'response' => 403 ] );
		}

		$relative_path = self::decodeFileKey( $file_key );
		if ( $relative_path === '' ) {
			wp_die( esc_html__( 'Invalid file reference.', 'wp-powersuite' ), '', [ 'response' => 400 ] );
		}

		if ( ! self::entryContainsPath( $entry_id, $relative_path ) ) {
			wp_die( esc_html__( 'File not found for this entry.', 'wp-powersuite' ), '', [ 'response' => 404 ] );
		}

		$absolute = self::toAbsolutePath( $relative_path );
		if ( $absolute === false || ! is_readable( $absolute ) ) {
			wp_die( esc_html__( 'File is missing on the server.', 'wp-powersuite' ), '', [ 'response' => 404 ] );
		}

		$filename = basename( $absolute );
		$mime     = wp_check_filetype( $filename );
		$type     = $mime['type'] ?: 'application/octet-stream';

		nocache_headers();
		header( 'Content-Type: ' . $type );
		header( 'Content-Disposition: attachment; filename="' . rawurlencode( $filename ) . '"' );
		header( 'Content-Length: ' . (string) filesize( $absolute ) );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		readfile( $absolute );
		exit;
	}

	/**
	 * @param array<string, mixed> $payload
	 */
	public static function entryContainsPath( int $entry_id, string $relative_path ): bool {
		global $wpdb;
		$table = $wpdb->prefix . 'wpps_form_entries';
		$row   = $wpdb->get_row( $wpdb->prepare( "SELECT payload FROM {$table} WHERE id = %d", $entry_id ) );
		if ( ! $row ) {
			return false;
		}

		$payload = json_decode( $row->payload ?? '{}', true );
		if ( ! is_array( $payload ) ) {
			return false;
		}

		$relative_path = self::sanitizeRelativePath( $relative_path );

		return in_array( $relative_path, self::collectPathsFromPayload( $payload ), true );
	}

	private static function signDownload( int $entry_id, string $file_key, int $expires ): string {
		$payload = $entry_id . '|' . $file_key . '|' . $expires;

		return hash_hmac( 'sha256', $payload, wp_salt( 'wpps_bf_file_download' ) );
	}

	private static function encodeFileKey( string $relative_path ): string {
		return rtrim( strtr( base64_encode( $relative_path ), '+/', '-_' ), '=' );
	}

	private static function decodeFileKey( string $file_key ): string {
		$padding = strlen( $file_key ) % 4;
		if ( $padding > 0 ) {
			$file_key .= str_repeat( '=', 4 - $padding );
		}

		$decoded = base64_decode( strtr( $file_key, '-_', '+/' ), true );

		return is_string( $decoded ) ? self::sanitizeRelativePath( $decoded ) : '';
	}

	private static function toRelativePath( string $absolute_path ): string {
		$upload_dir = wp_upload_dir();
		$base       = wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) );
		$absolute   = wp_normalize_path( $absolute_path );

		if ( str_starts_with( $absolute, $base ) ) {
			return self::sanitizeRelativePath( substr( $absolute, strlen( $base ) ) );
		}

		return self::sanitizeRelativePath( basename( $absolute ) );
	}

	/**
	 * @return string|false
	 */
	private static function toAbsolutePath( string $relative_path ) {
		$relative_path = self::sanitizeRelativePath( $relative_path );
		if ( $relative_path === '' ) {
			return false;
		}

		$upload_dir = wp_upload_dir();
		$absolute   = wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) . $relative_path );
		$real       = realpath( $absolute );
		$root       = realpath( trailingslashit( $upload_dir['basedir'] ) . self::UPLOAD_SUBDIR );

		if ( $real === false || $root === false || ! str_starts_with( $real, $root ) ) {
			return false;
		}

		return $real;
	}

	private static function sanitizeRelativePath( string $path ): string {
		$path = wp_normalize_path( $path );
		$path = ltrim( $path, '/' );

		if ( $path === '' || str_contains( $path, '..' ) ) {
			return '';
		}

		if ( ! str_starts_with( $path, self::UPLOAD_SUBDIR . '/' ) ) {
			return '';
		}

		return $path;
	}
}
