<?php
/**
 * Shared ZIP Handler Utility
 *
 * Provides robust ZIP functionality for all download modules.
 * Incorporates battle-tested techniques from plugin/theme ZIP downloads.
 *
 * @package Switchboard
 * @since 1.1.0
 */

// Prevent direct access
if (!defined('ABSPATH')) exit;

class Switchboard_Zip_Handler {

    /**
     * ZIP file expiry time in seconds (1 hour)
     */
    const EXPIRY_TIME = 3600;

    /**
     * Maximum file size for ZIP creation (2GB)
     * Large enough for most uploads folders while still preventing server issues
     */
    const MAX_SIZE = 2147483648;

    /**
     * Warning threshold for large files (500MB)
     * Show a warning but allow creation
     */
    const SIZE_WARNING_THRESHOLD = 524288000;

    /**
     * Downloads directory name
     */
    const DOWNLOAD_DIR = 'switchboard-downloads';

    /**
     * Chunk size for file streaming (64KB)
     */
    const CHUNK_SIZE = 65536;

    /**
     * Singleton instance
     */
    private static $instance = null;

    /**
     * Get singleton instance
     */
    public static function get_instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Private constructor for singleton
     */
    private function __construct() {}

    /**
     * Get the downloads directory path
     *
     * @return string Full path to downloads directory
     */
    public function get_download_dir() {
        $upload_dir = wp_upload_dir();
        return trailingslashit($upload_dir['basedir']) . self::DOWNLOAD_DIR;
    }

    /**
     * Get the downloads directory URL
     *
     * @return string Full URL to downloads directory
     */
    public function get_download_url() {
        $upload_dir = wp_upload_dir();
        return trailingslashit($upload_dir['baseurl']) . self::DOWNLOAD_DIR;
    }

    /**
     * Ensure downloads directory exists with security files
     *
     * @return bool True if directory exists/created, false on failure
     */
    public function ensure_download_dir() {
        $zip_dir = $this->get_download_dir();

        if (!file_exists($zip_dir)) {
            if (!wp_mkdir_p($zip_dir)) {
                return false;
            }
            // Security: Prevent directory listing and direct PHP execution
            file_put_contents($zip_dir . '/index.php', '<?php // Silence is golden');
            file_put_contents($zip_dir . '/.htaccess', "Options -Indexes\n<FilesMatch \"\.php$\">\nOrder Allow,Deny\nDeny from all\n</FilesMatch>");
        }

        return true;
    }

    /**
     * Check system requirements for ZIP creation
     *
     * @return bool True if requirements are met
     */
    public function check_system_requirements() {
        // Check for RecursiveIteratorIterator (required for directory iteration)
        if (!class_exists('RecursiveIteratorIterator')) {
            return false;
        }

        // Check for zlib support (required for PclZip)
        if (!function_exists('gzopen')) {
            return false;
        }

        return true;
    }

    /**
     * Check if ZIP extension is available
     *
     * @return array ['available' => bool, 'method' => string]
     */
    public function check_zip_support() {
        if (extension_loaded('zip') && class_exists('ZipArchive')) {
            return ['available' => true, 'method' => 'ZipArchive'];
        }

        if (!class_exists('PclZip')) {
            require_once(ABSPATH . 'wp-admin/includes/class-pclzip.php');
        }

        if (class_exists('PclZip')) {
            return ['available' => true, 'method' => 'PclZip'];
        }

        return ['available' => false, 'method' => null];
    }

    /**
     * Validate ZIP ID format to prevent path traversal
     *
     * @param string $zip_id The ZIP ID to validate
     * @return bool True if valid, false otherwise
     */
    public function validate_zip_id($zip_id) {
        // Only allow alphanumeric characters, hyphens, and underscores
        return (bool) preg_match('/^[a-zA-Z0-9_-]+$/', $zip_id);
    }

    /**
     * Verify file is within downloads directory (path traversal protection)
     *
     * @param string $file_path Full path to the file
     * @return bool True if safe, false otherwise
     */
    public function verify_file_path($file_path) {
        $zip_dir = $this->get_download_dir();
        $real_zip_dir = realpath($zip_dir);

        if (!$real_zip_dir) {
            return false;
        }

        $real_file_dir = realpath(dirname($file_path));
        if (!$real_file_dir) {
            // Directory doesn't exist yet, check parent
            $real_file_dir = realpath(dirname(dirname($file_path)));
            if (!$real_file_dir) {
                return false;
            }
        }

        $real_file_path = $real_file_dir . '/' . basename($file_path);

        return strpos($real_file_path, $real_zip_dir) === 0;
    }

    /**
     * Find best writable temp directory
     * Tries multiple locations for compatibility with various hosting environments
     *
     * @param int $required_space Required disk space in bytes
     * @return string|WP_Error Path to writable temp directory or WP_Error
     */
    public function find_writable_temp_dir($required_space = 0) {
        $upload_dir = wp_upload_dir();

        // Try multiple temp directory locations for different hosting environments
        $temp_dirs = [
            'switchboard_downloads' => $this->get_download_dir(),
            'sys_temp' => sys_get_temp_dir(),
            'upload_tmp_dir' => ini_get('upload_tmp_dir'),
            'wp_uploads' => $upload_dir['basedir'],
            'wp_uploads_hardcoded' => WP_CONTENT_DIR . '/uploads',
            'wp_content' => WP_CONTENT_DIR
        ];

        // Last resort: try open_basedir if set (for very restricted hosts)
        $open_basedir = ini_get('open_basedir');
        if (!empty($open_basedir)) {
            $basedir_paths = preg_split('/[:;]/', $open_basedir);
            foreach ($basedir_paths as $basedir_path) {
                $basedir_path = trim($basedir_path);
                if (!empty($basedir_path) && is_dir($basedir_path)) {
                    $temp_dirs['open_basedir_' . md5($basedir_path)] = $basedir_path;
                }
            }
        }

        // Filter out empty values
        $temp_dirs = array_filter($temp_dirs);

        $tried_locations = [];

        foreach ($temp_dirs as $name => $temp_dir) {
            // Skip if directory doesn't exist or isn't writable
            if (empty($temp_dir) || !is_dir($temp_dir) || !is_writable($temp_dir)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
                $tried_locations[] = sprintf('%s: not writable or doesn\'t exist', $name);
                continue;
            }

            // Check disk space if required (disk_free_space may be disabled on some hosts)
            if ($required_space > 0 && function_exists('disk_free_space')) {
                $free_space = @disk_free_space($temp_dir);
                if ($free_space !== false && $free_space < $required_space) {
                    $tried_locations[] = sprintf('%s: insufficient disk space (need %s, have %s)', $name, size_format($required_space), size_format($free_space));
                    continue;
                }
            }

            // Test if we can write to this location
            $test_file = trailingslashit($temp_dir) . 'switchboard-test-' . wp_generate_password(8, false) . '.tmp';
            if (@touch($test_file)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
                wp_delete_file($test_file);
                return $temp_dir;
            }

            $tried_locations[] = sprintf('%s: write test failed', $name);
        }

        return new WP_Error(
            'no_writable_temp_dir',
            sprintf(
                // translators: %s: list of tried directory locations.
                __('Cannot find writable temp directory. Tried locations: %s. Please contact your hosting provider to check directory permissions.', 'wpswitchboard'),
                implode('; ', $tried_locations)
            )
        );
    }

    /**
     * Calculate directory size using RecursiveIteratorIterator
     *
     * @param string $path Path to file or directory
     * @return int Size in bytes
     */
    public function get_directory_size($path) {
        if (is_file($path)) {
            return filesize($path);
        }

        $size = 0;
        try {
            $files = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
                RecursiveIteratorIterator::LEAVES_ONLY
            );

            foreach ($files as $file) {
                if ($file->isFile() && $file->isReadable()) {
                    $size += $file->getSize();
                }
            }
        } catch (Exception $e) {
            // If we can't calculate size, return 0 to allow download attempt
            return 0;
        }

        return $size;
    }

    /**
     * Create ZIP file from directory using RecursiveIteratorIterator
     *
     * @param string $source Source directory path
     * @param string $zip_path Full path for the ZIP file
     * @param string $root_folder Folder name to use as root in ZIP
     * @return true|WP_Error True on success, WP_Error on failure
     */
    public function create_zip_from_directory($source, $zip_path, $root_folder = '') {
        $support = $this->check_zip_support();

        if (!$support['available']) {
            return new WP_Error(
                'zip_not_available',
                __('ZIP extension is not available on this server. Please contact your hosting provider to enable the PHP ZIP extension or zlib support.', 'wpswitchboard')
            );
        }

        // Increase limits for large operations
        @ini_set('memory_limit', '512M'); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_ini_set, Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for ZIP file operations
        @set_time_limit(600); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for ZIP file operations

        // Normalize source path
        $source_normalized = realpath($source);
        if ($source_normalized === false) {
            $source_normalized = str_replace('\\', '/', $source);
        } else {
            $source_normalized = str_replace('\\', '/', $source_normalized);
        }

        if ($support['method'] === 'ZipArchive') {
            return $this->create_zip_from_dir_ziparchive($zip_path, $source_normalized, $root_folder);
        } else {
            return $this->create_zip_from_dir_pclzip($zip_path, $source_normalized, $root_folder);
        }
    }

    /**
     * Create ZIP from directory using ZipArchive
     */
    private function create_zip_from_dir_ziparchive($zip_path, $source, $root_folder) {
        $zip = new ZipArchive();

        if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            return new WP_Error(
                'zip_create_failed',
                __('Failed to create ZIP archive.', 'wpswitchboard')
            );
        }

        $file_count = 0;
        $skipped_files = [];

        try {
            $files = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
                RecursiveIteratorIterator::LEAVES_ONLY,
                RecursiveIteratorIterator::CATCH_GET_CHILD
            );

            foreach ($files as $file) {
                // Skip if we can't access the file
                if (!$file->isReadable()) {
                    $skipped_files[] = $file->getPathname();
                    continue;
                }

                if (!$file->isDir()) {
                    $file_path = $file->getRealPath();

                    // Normalize paths for cross-platform compatibility
                    $file_path = str_replace('\\', '/', $file_path);

                    // Security: Ensure file is within source directory
                    if ($file_path === false || strpos($file_path, $source) !== 0) {
                        continue;
                    }

                    $relative_path = substr($file_path, strlen($source) + 1);
                    if (!empty($root_folder)) {
                        $relative_path = $root_folder . '/' . $relative_path;
                    }

                    // Attempt to add file
                    if (@$zip->addFile($file_path, $relative_path)) {
                        $file_count++;
                    } else {
                        $skipped_files[] = $relative_path;
                    }
                }
            }
        } catch (Exception $e) {
            $zip->close();
            wp_delete_file($zip_path);
            return new WP_Error(
                'directory_iteration_failed',
                // translators: %s: error message from directory read failure.
                sprintf(__('Failed to read directory: %s', 'wpswitchboard'), $e->getMessage())
            );
        }

        if ($file_count === 0) {
            $zip->close();
            wp_delete_file($zip_path);
            return new WP_Error(
                'no_files_added',
                __('No files could be added to the ZIP archive.', 'wpswitchboard')
            );
        }

        $zip->close();
        return true;
    }

    /**
     * Create ZIP from directory using PclZip
     */
    private function create_zip_from_dir_pclzip($zip_path, $source, $root_folder) {
        if (!class_exists('PclZip')) {
            require_once(ABSPATH . 'wp-admin/includes/class-pclzip.php');
        }

        $zip = new PclZip($zip_path);
        $files_to_add = [];

        try {
            $files = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
                RecursiveIteratorIterator::LEAVES_ONLY,
                RecursiveIteratorIterator::CATCH_GET_CHILD
            );

            foreach ($files as $file) {
                if (!$file->isDir() && $file->isReadable()) {
                    $file_path = $file->getRealPath();

                    if ($file_path !== false) {
                        $file_path = str_replace('\\', '/', $file_path);

                        // Security: Ensure file is within source directory
                        if (strpos($file_path, $source) === 0) {
                            $files_to_add[] = $file_path;
                        }
                    }
                }
            }
        } catch (Exception $e) {
            wp_delete_file($zip_path);
            return new WP_Error(
                'directory_iteration_failed',
                // translators: %s: error message from directory read failure.
                sprintf(__('Failed to read directory: %s', 'wpswitchboard'), $e->getMessage())
            );
        }

        if (empty($files_to_add)) {
            return new WP_Error(
                'no_files_added',
                __('No files could be added to the ZIP archive.', 'wpswitchboard')
            );
        }

        // Create with proper path handling
        if (!empty($root_folder)) {
            $result = $zip->create($files_to_add, PCLZIP_OPT_REMOVE_PATH, $source, PCLZIP_OPT_ADD_PATH, $root_folder);
        } else {
            $result = $zip->create($files_to_add, PCLZIP_OPT_REMOVE_PATH, $source);
        }

        if ($result == 0) {
            return new WP_Error(
                'zip_create_failed',
                // translators: %s: PclZip error message.
                sprintf(__('Failed to create ZIP: %s', 'wpswitchboard'), $zip->errorInfo(true))
            );
        }

        return true;
    }

    /**
     * Create ZIP file from array of file paths
     *
     * @param array $files Array of ['source' => path, 'name' => name_in_zip]
     * @param string $zip_path Full path for the ZIP file
     * @return true|WP_Error True on success, WP_Error on failure
     */
    public function create_zip($files, $zip_path) {
        $support = $this->check_zip_support();

        if (!$support['available']) {
            return new WP_Error(
                'zip_not_available',
                __('ZIP extension is not available on this server.', 'wpswitchboard')
            );
        }

        // Increase limits for large operations
        @ini_set('memory_limit', '512M'); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_ini_set, Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for ZIP file operations
        @set_time_limit(600); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for ZIP file operations

        if ($support['method'] === 'ZipArchive') {
            return $this->create_zip_ziparchive($files, $zip_path);
        } else {
            return $this->create_zip_pclzip($files, $zip_path);
        }
    }

    /**
     * Create ZIP using ZipArchive
     */
    private function create_zip_ziparchive($files, $zip_path) {
        $zip = new ZipArchive();

        if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            return new WP_Error(
                'zip_create_failed',
                __('Failed to create ZIP archive.', 'wpswitchboard')
            );
        }

        $file_count = 0;
        $used_names = [];

        foreach ($files as $file) {
            $source = $file['source'];
            $name = $file['name'];

            if (!file_exists($source) || !is_readable($source)) {
                continue;
            }

            // Handle duplicate names
            if (isset($used_names[$name])) {
                $used_names[$name]++;
                $ext = pathinfo($name, PATHINFO_EXTENSION);
                $base = pathinfo($name, PATHINFO_FILENAME);
                $dir = dirname($name);
                $name = ($dir !== '.' ? $dir . '/' : '') . $base . '-' . $used_names[$name] . '.' . $ext;
            } else {
                $used_names[$name] = 1;
            }

            if (@$zip->addFile($source, $name)) {
                $file_count++;
            }
        }

        if ($file_count === 0) {
            $zip->close();
            wp_delete_file($zip_path);
            return new WP_Error(
                'no_files_added',
                __('No files could be added to the ZIP archive.', 'wpswitchboard')
            );
        }

        $zip->close();
        return true;
    }

    /**
     * Create ZIP using PclZip
     */
    private function create_zip_pclzip($files, $zip_path) {
        $zip = new PclZip($zip_path);
        $sources = [];

        foreach ($files as $file) {
            if (file_exists($file['source']) && is_readable($file['source'])) {
                $sources[] = $file['source'];
            }
        }

        if (empty($sources)) {
            return new WP_Error(
                'no_files_added',
                __('No files could be added to the ZIP archive.', 'wpswitchboard')
            );
        }

        $result = $zip->create($sources, PCLZIP_OPT_REMOVE_ALL_PATH);

        if ($result == 0) {
            return new WP_Error(
                'zip_create_failed',
                // translators: %s: PclZip error message.
                sprintf(__('Failed to create ZIP: %s', 'wpswitchboard'), $zip->errorInfo(true))
            );
        }

        return true;
    }

    /**
     * Stream file to browser with chunked download
     * Best for direct downloads without server storage
     *
     * @param string $zip_path Path to the ZIP file
     * @param string $filename Name for the downloaded file
     * @param bool $delete_after Whether to delete file after streaming
     */
    public function stream_file_to_browser($zip_path, $filename, $delete_after = true) {
        if (!file_exists($zip_path)) {
            wp_die(esc_html__('File not found.', 'wpswitchboard'));
        }

        header('Content-Type: application/octet-stream');
        header('Content-Disposition: attachment; filename=' . urlencode($filename));
        header('Content-Description: File Transfer');
        header('Content-Transfer-Encoding: binary');
        header('Expires: 0');
        header('Cache-Control: must-revalidate');
        header('Pragma: public');
        header('Content-Length: ' . filesize($zip_path));

        // Use output buffering to ensure clean download
        if (ob_get_level()) {
            ob_end_clean();
        }

        flush();

        // Read and output file in chunks for better memory management
        if (false !== ($fp = fopen($zip_path, 'r'))) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- File download
            while (!feof($fp)) {
                echo fread($fp, self::CHUNK_SIZE); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary file download
                flush();
            }
            fclose($fp); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
        } else {
            wp_die(esc_html__('Unexpected error reading file', 'wpswitchboard'));
        }

        // Clean up
        if ($delete_after) {
            wp_delete_file($zip_path);
        }

        exit;
    }

    /**
     * Delete a ZIP file safely
     *
     * @param string $zip_id The ZIP ID
     * @return bool True on success
     */
    public function delete_zip($zip_id) {
        if (!$this->validate_zip_id($zip_id)) {
            return false;
        }

        $zip_dir = $this->get_download_dir();
        $zip_file = $zip_dir . '/' . $zip_id . '.zip';
        $meta_file = $zip_dir . '/' . $zip_id . '.json';

        if (!$this->verify_file_path($zip_file)) {
            return false;
        }

        $deleted = false;

        if (file_exists($zip_file)) {
            wp_delete_file($zip_file);
            $deleted = true;
        }

        if (file_exists($meta_file)) {
            wp_delete_file($meta_file);
        }

        return $deleted;
    }

    /**
     * Clean up expired files from temp directories
     * Used by download plugin/theme modules
     */
    public function cleanup_expired_files() {
        $upload_dir = wp_upload_dir();
        $temp_dirs = [
            sys_get_temp_dir(),
            $upload_dir['basedir'],
            WP_CONTENT_DIR . '/uploads',
            WP_CONTENT_DIR
        ];

        foreach ($temp_dirs as $temp_dir) {
            if (!is_dir($temp_dir) || !is_readable($temp_dir)) {
                continue;
            }

            $files = @glob($temp_dir . '/*-*.zip');
            if (!$files) {
                continue;
            }

            foreach ($files as $file) {
                // Delete files older than 1 hour
                if (file_exists($file) && (time() - filemtime($file)) > self::EXPIRY_TIME) {
                    wp_delete_file($file);
                }
            }
        }

        // Also cleanup from our downloads directory
        $this->cleanup_expired_zips();
    }

    /**
     * Clean up expired ZIP files
     *
     * @param string|null $pattern Optional glob pattern to filter files
     * @return int Number of files deleted
     */
    public function cleanup_expired_zips($pattern = null) {
        $zip_dir = $this->get_download_dir();

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

        $glob_pattern = $pattern ? $zip_dir . '/' . $pattern : $zip_dir . '/*.zip';
        $files = @glob($glob_pattern);

        if (!$files) {
            return 0;
        }

        $deleted = 0;

        foreach ($files as $file) {
            if (!file_exists($file)) {
                continue;
            }

            $file_age = time() - filemtime($file);
            $zip_id = basename($file, '.zip');
            $meta_file = $zip_dir . '/' . $zip_id . '.json';

            // Check metadata for auto_delete setting
            if (file_exists($meta_file)) {
                $meta = json_decode(file_get_contents($meta_file), true);
                if (isset($meta['auto_delete']) && $meta['auto_delete'] === false) {
                    continue;
                }
            }

            // Delete if expired
            if ($file_age > self::EXPIRY_TIME) {
                wp_delete_file($file);
                if (!file_exists($file)) {
                    $deleted++;
                }
                if (file_exists($meta_file)) {
                    wp_delete_file($meta_file);
                }
            }
        }

        return $deleted;
    }

    /**
     * Calculate total size of files
     *
     * @param array $file_paths Array of file paths
     * @return int Total size in bytes
     */
    public function calculate_total_size($file_paths) {
        $total = 0;
        foreach ($file_paths as $path) {
            if (file_exists($path)) {
                $total += filesize($path);
            }
        }
        return $total;
    }

    /**
     * Check if total size exceeds limit
     *
     * @param int $size Size in bytes
     * @return bool True if within limit
     */
    public function is_within_size_limit($size) {
        return $size <= self::MAX_SIZE;
    }

    /**
     * Check available disk space
     *
     * @param int $required_space Required space in bytes
     * @return bool True if enough space available
     */
    public function has_disk_space($required_space) {
        // disk_free_space() may be disabled on some hosts for security
        if (!function_exists('disk_free_space')) {
            return true; // Can't determine, assume OK
        }

        $zip_dir = $this->get_download_dir();
        $this->ensure_download_dir();

        $free_space = @disk_free_space($zip_dir);

        if ($free_space === false) {
            return true; // Can't determine, assume OK
        }

        // Require 2x the space for safety
        return $free_space >= ($required_space * 2);
    }

    /**
     * Get file count from ZIP
     *
     * @param string $zip_path Full path to ZIP file
     * @return int Number of files in ZIP
     */
    public function get_zip_file_count($zip_path) {
        if (!extension_loaded('zip') || !class_exists('ZipArchive')) {
            return 0;
        }

        $zip = new ZipArchive();
        if ($zip->open($zip_path) === true) {
            $count = $zip->numFiles;
            $zip->close();
            return $count;
        }

        return 0;
    }

    /**
     * Calculate expiry information for a file
     *
     * @param string $file_path Full path to file
     * @return array ['expires_in' => string, 'is_expired' => bool, 'time_remaining' => int]
     */
    public function get_expiry_info($file_path) {
        if (!file_exists($file_path)) {
            return [
                'expires_in' => 'Unknown',
                'is_expired' => true,
                'time_remaining' => 0
            ];
        }

        $file_time = filemtime($file_path);
        $time_remaining = ($file_time + self::EXPIRY_TIME) - time();

        return [
            'expires_in' => $time_remaining > 0 ? human_time_diff(time(), $file_time + self::EXPIRY_TIME) : 'Expired',
            'is_expired' => $time_remaining <= 0,
            'time_remaining' => max(0, $time_remaining)
        ];
    }

    /**
     * Generate unique ZIP ID
     *
     * @param string $prefix Prefix for the ID
     * @return string Unique ZIP ID
     */
    public function generate_zip_id($prefix) {
        return sanitize_file_name($prefix) . '-' . gmdate('Y-m-d') . '-' . wp_generate_password(8, false);
    }

    /**
     * Save ZIP metadata
     *
     * @param string $zip_id The ZIP ID
     * @param array $metadata Metadata to save
     * @return bool True on success
     */
    public function save_metadata($zip_id, $metadata) {
        if (!$this->validate_zip_id($zip_id)) {
            return false;
        }

        $zip_dir = $this->get_download_dir();
        $meta_file = $zip_dir . '/' . $zip_id . '.json';

        $metadata['created'] = time();

        return (bool) file_put_contents($meta_file, json_encode($metadata));
    }

    /**
     * Get ZIP metadata
     *
     * @param string $zip_id The ZIP ID
     * @return array|null Metadata array or null if not found
     */
    public function get_metadata($zip_id) {
        if (!$this->validate_zip_id($zip_id)) {
            return null;
        }

        $zip_dir = $this->get_download_dir();
        $meta_file = $zip_dir . '/' . $zip_id . '.json';

        if (!file_exists($meta_file)) {
            return null;
        }

        return json_decode(file_get_contents($meta_file), true);
    }
}
