<?php
/**
 * Structure Viewer Pro — FlatPack
 *
 * Extracts files from a plugin or theme ZIP, optionally filters them,
 * flattens them into a single directory with collision-safe naming,
 * and delivers a downloadable ZIP.
 *
 * Workflow:
 *   1. analyze()  — extract ALL files from the ZIP, store raw paths in a
 *                   transient, return the full file list to the front-end.
 *   2. extract()  — retrieve the stored paths, apply the user's chosen
 *                   file-type filter, build the flat mapping, create the ZIP.
 *
 * This split lets the front-end show a live-filtered preview (client-side)
 * while the server always operates on the authoritative, fully-scanned list.
 *
 * Smart naming rules:
 *   • Unique basenames → keep their original name.
 *   • Duplicate basenames → prefixed with their relative folder path
 *     (e.g. assets/css/style.css  →  "assets-css-style.css").
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit( 'Direct access denied.' );
}

// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- SV_ is the established plugin prefix used consistently across all plugin classes.
class SV_Pro_FlatPack {

    // ── Public API ────────────────────────────────────────────────

    /**
     * Analyze an uploaded ZIP: extract ALL files (no filter applied here),
     * cache the raw file list in a transient, and return preview data.
     *
     * @param  array  $file  $_FILES entry for the uploaded ZIP.
     * @return array  Preview payload or [ 'error' => '...' ].
     */
    public static function analyze( $file ) {
        if ( ! isset( $file['error'] ) || $file['error'] !== UPLOAD_ERR_OK ) {
            return [ 'error' => 'No file uploaded or upload error' ];
        }

        $file_type = wp_check_filetype( $file['name'] );
        if ( $file_type['ext'] !== 'zip' ) {
            return [ 'error' => 'Only ZIP files are allowed' ];
        }

        // Sweep old FlatPack artifacts on every entry. Keeps wp-content/uploads
        // from accumulating orphan temp dirs and download ZIPs forever — e.g.
        // when a user uploads but never completes the extract step.
        self::cleanup_old_artifacts();

        $upload_dir = wp_upload_dir();
        // Random temp dir name — time() was predictable within a few seconds
        // of the request, which gave a guessable URL for the brief window
        // before the .htaccess/index.php guards were written below.
        $temp_extract_dir = $upload_dir['basedir'] . '/sv-fp-temp-' . wp_generate_password( 20, false );

        if ( ! wp_mkdir_p( $temp_extract_dir ) ) {
            return [ 'error' => 'Failed to create temporary directory' ];
        }

        // Write the guards BEFORE unzip so the extracted tree isn't
        // reachable over HTTP even for the milliseconds it takes to scan.
        @file_put_contents( $temp_extract_dir . '/.htaccess', "deny from all\n" ); // phpcs:ignore
        @file_put_contents( $temp_extract_dir . '/index.php', '<?php // Silence is golden.' ); // phpcs:ignore

        WP_Filesystem();
        $unzip_result = unzip_file( $file['tmp_name'], $temp_extract_dir );

        if ( is_wp_error( $unzip_result ) ) {
            self::delete_directory( $temp_extract_dir );
            return [ 'error' => 'Failed to extract ZIP: ' . $unzip_result->get_error_message() ];
        }

        // Collect every file — no filter at this stage.
        $all_files = self::get_all_files( $temp_extract_dir );

        if ( empty( $all_files ) ) {
            self::delete_directory( $temp_extract_dir );
            return [ 'error' => 'The ZIP appears to be empty' ];
        }

        // Build total size
        $total_size = 0;
        foreach ( $all_files as $path ) {
            $total_size += @filesize( $path ) ?: 0; // phpcs:ignore
        }

        // Build preview data for the front-end (all files, with ext metadata
        // so the JS can live-filter without another round-trip)
        $preview_files = [];
        foreach ( $all_files as $path ) {
            $ext             = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
            $rel             = ltrim( str_replace( trailingslashit( $temp_extract_dir ), '', $path ), '/' );
            $preview_files[] = [
                'original' => $rel,
                'name'     => basename( $path ),
                'size'     => size_format( @filesize( $path ) ?: 0 ), // phpcs:ignore
                'type'     => $ext,
            ];
        }

        // Store raw file paths for extraction; do NOT store the mapping yet —
        // that is built at extract() time once the filter is known.
        $temp_id = 'sv_fp_' . md5( $temp_extract_dir . uniqid( '', true ) );
        set_transient( $temp_id, [
            'temp_dir'     => $temp_extract_dir,
            'all_files'    => $all_files,
            'zip_filename' => sanitize_file_name( pathinfo( $file['name'], PATHINFO_FILENAME ) ),
        ], HOUR_IN_SECONDS );

        return [
            'files'       => $preview_files,
            'total_files' => count( $preview_files ),
            'total_size'  => size_format( $total_size ),
            'temp_id'     => $temp_id,
        ];
    }

    /**
     * Apply the user's chosen filter to the previously stored file list,
     * build the flat mapping, create the ZIP, clean up temp dirs.
     *
     * @param  string $temp_id           Transient key returned by analyze().
     * @param  string $filter_type       'all' | 'php' | 'css' | 'js' | 'images' | 'custom'.
     * @param  string $custom_extensions Comma-separated extensions (custom mode only).
     * @return array  [ 'download_url' => '...', ... ] or [ 'error' => '...' ].
     */
    public static function extract( $temp_id, $filter_type = 'all', $custom_extensions = '' ) {
        if ( empty( $temp_id ) ) {
            return [ 'error' => 'Invalid request' ];
        }

        $temp_data = get_transient( $temp_id );
        if ( ! $temp_data ) {
            return [ 'error' => 'Session expired. Please upload the file again.' ];
        }

        $temp_extract_dir = $temp_data['temp_dir'];
        $all_files        = $temp_data['all_files'];
        $zip_filename     = $temp_data['zip_filename'];

        // Apply the filter to the full file list
        $filtered_files = self::filter_files( $all_files, $filter_type, $custom_extensions );

        if ( empty( $filtered_files ) ) {
            self::delete_directory( $temp_extract_dir );
            delete_transient( $temp_id );
            return [ 'error' => 'No files match the selected filter' ];
        }

        // Build the flat mapping only for the filtered subset; this gives
        // clean deduplication prefixes scoped to the files actually included.
        $file_mapping = self::build_file_mapping( $filtered_files, $temp_extract_dir );

        $upload_dir  = wp_upload_dir();
        // Random staging dir name — see note above about time()-based names.
        $staging_dir = $upload_dir['basedir'] . '/sv-fp-output-' . wp_generate_password( 20, false );

        if ( ! wp_mkdir_p( $staging_dir ) ) {
            return [ 'error' => 'Failed to create output directory' ];
        }

        $processed = 0;
        foreach ( $file_mapping as $source_path => $dest_name ) {
            if ( @copy( $source_path, $staging_dir . '/' . $dest_name ) ) { // phpcs:ignore
                $processed++;
            }
        }

        // Build output ZIP in a protected per-request download directory.
        // This directory is guarded by .htaccess + index.php so the ZIP is not
        // reachable via a guessable public URL. Downloads are served through
        // our own admin-post handler which checks the nonce and deletes the
        // file after streaming.
        $download_root = self::get_download_root();
        if ( ! wp_mkdir_p( $download_root ) ) {
            self::delete_directory( $staging_dir );
            return [ 'error' => 'Failed to create download directory' ];
        }
        self::ensure_download_root_guarded( $download_root );

        // Token MUST be lowercase-hex only. The download handler runs the
        // token through sanitize_key() which lowercases — if we generated
        // mixed-case tokens the transient lookup and nonce check would both
        // fail because they were stored against the original casing.
        // random_bytes() also gives us a CSPRNG source (wp_generate_password
        // is built on mt_rand, which is not cryptographically secure).
        try {
            $token = bin2hex( random_bytes( 16 ) ); // 32 lowercase hex chars
        } catch ( Exception $e ) { // phpcs:ignore
            self::delete_directory( $staging_dir );
            return [ 'error' => 'Could not generate secure token' ];
        }
        $token_dir     = $download_root . '/' . $token;
        if ( ! wp_mkdir_p( $token_dir ) ) {
            self::delete_directory( $staging_dir );
            return [ 'error' => 'Failed to create download slot' ];
        }

        $final_zip_name = sanitize_file_name( $zip_filename . '-flatpack.zip' );
        $final_zip_path = $token_dir . '/' . $final_zip_name;
        $zip_created    = self::create_zip( $staging_dir, $final_zip_path );

        // Cleanup — the staging dir and temp extract dir are always removed.
        // The final ZIP is kept only until downloaded (or swept after TTL).
        self::delete_directory( $temp_extract_dir );
        self::delete_directory( $staging_dir );
        delete_transient( $temp_id );

        if ( ! $zip_created ) {
            self::delete_directory( $token_dir );
            return [ 'error' => 'Failed to create ZIP file' ];
        }

        // Record the token so the download handler can verify & locate the file
        // without trusting any path from the client.
        set_transient(
            'sv_fp_dl_' . $token,
            [ 'file' => $final_zip_name, 'created' => time() ],
            DAY_IN_SECONDS
        );

        $download_url = add_query_arg(
            [
                'action' => 'sv_fp_download',
                'token'  => $token,
                '_wpnonce' => wp_create_nonce( 'sv_fp_download_' . $token ),
            ],
            admin_url( 'admin-post.php' )
        );

        return [
            'download_url'    => $download_url,
            'processed_files' => $processed,
            'total_files'     => count( $file_mapping ),
        ];
    }

    // ── Internal helpers ──────────────────────────────────────────

    /**
     * Recursively collect every file beneath $dir.
     */
    private static function get_all_files( $dir ) {
        $files = [];
        if ( ! is_dir( $dir ) ) {
            return $files;
        }
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
            RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ( $iterator as $item ) {
            if ( ! $item->isFile() ) {
                continue;
            }
            // Skip the guard files we write into the temp dir before unzip.
            // They exist at the root only; nested .htaccess / index.php in a
            // user's ZIP are preserved as part of the content.
            $rel = ltrim( str_replace( $dir, '', $item->getPathname() ), '/\\' );
            if ( $rel === '.htaccess' || $rel === 'index.php' ) {
                continue;
            }
            $files[] = $item->getPathname();
        }
        return $files;
    }

    /**
     * Filter a flat list of absolute paths by file-type preset or custom list.
     */
    private static function filter_files( $files, $filter_type, $custom_extensions = '' ) {
        if ( $filter_type === 'all' ) {
            return $files;
        }

        switch ( $filter_type ) {
            case 'php':
                $allowed = [ 'php' ];
                break;
            case 'css':
                $allowed = [ 'css', 'scss', 'less' ];
                break;
            case 'js':
                $allowed = [ 'js', 'jsx', 'ts', 'tsx' ];
                break;
            case 'images':
                $allowed = [ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico' ];
                break;
            case 'custom':
                $allowed = ! empty( $custom_extensions )
                    ? array_map( 'trim', explode( ',', strtolower( $custom_extensions ) ) )
                    : [];
                break;
            default:
                $allowed = [];
        }

        if ( empty( $allowed ) ) {
            return $files;
        }

        return array_values( array_filter( $files, static function ( $path ) use ( $allowed ) {
            return in_array( strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ), $allowed, true );
        } ) );
    }

    /**
     * Build a source-path → flat-destination-name map.
     * Unique basenames keep their name; duplicates get a folder prefix.
     */
    private static function build_file_mapping( $files, $base_dir ) {
        $base_dir = trailingslashit( $base_dir );

        // Pass 1: count basename occurrences
        $occurrences = [];
        foreach ( $files as $path ) {
            $occurrences[ basename( $path ) ][] = $path;
        }

        // Pass 2: assign flat destination names
        $mapping = [];
        $used    = [];

        foreach ( $files as $path ) {
            $name    = basename( $path );
            $rel_dir = ltrim( str_replace( $base_dir, '', dirname( $path ) ), '/\\' );

            $dest = ( count( $occurrences[ $name ] ) > 1 )
                ? self::folder_prefix( $rel_dir ) . '-' . $name
                : $name;

            // Collision guard
            $original = $dest;
            $counter  = 1;
            while ( isset( $used[ $dest ] ) ) {
                $info = pathinfo( $original );
                $dest = $info['filename'] . '-' . $counter
                    . ( ! empty( $info['extension'] ) ? '.' . $info['extension'] : '' );
                $counter++;
            }

            $mapping[ $path ] = $dest;
            $used[ $dest ]    = true;
        }

        return $mapping;
    }

    /**
     * Convert a relative directory path to a safe, flat prefix string.
     */
    private static function folder_prefix( $rel_path ) {
        if ( empty( $rel_path ) ) {
            return 'root';
        }
        return sanitize_file_name( str_replace( [ '/', '\\' ], '-', $rel_path ) );
    }

    /**
     * Create a flat ZIP archive from all files directly inside $staging_dir.
     */
    private static function create_zip( $staging_dir, $zip_path ) {
        if ( ! extension_loaded( 'zip' ) ) {
            return false;
        }
        $zip = new ZipArchive();
        if ( $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) !== true ) {
            return false;
        }
        $files = @scandir( $staging_dir ); // phpcs:ignore
        if ( $files ) {
            foreach ( array_diff( $files, [ '.', '..' ] ) as $file ) {
                $file_path = $staging_dir . '/' . $file;
                if ( is_file( $file_path ) ) {
                    $zip->addFile( $file_path, $file );
                }
            }
        }
        $zip->close();
        return file_exists( $zip_path );
    }

    /**
     * Recursively delete a directory and all its contents.
     */
    public static function delete_directory( $dir ) {
        if ( ! is_dir( $dir ) ) {
            return;
        }
        $files = array_diff( @scandir( $dir ) ?: [], [ '.', '..' ] ); // phpcs:ignore
        foreach ( $files as $file ) {
            $path = $dir . '/' . $file;
            is_dir( $path ) ? self::delete_directory( $path ) : @unlink( $path ); // phpcs:ignore
        }
        @rmdir( $dir ); // phpcs:ignore
    }

    // ── Download hardening ────────────────────────────────────────

    /** Returns absolute path to the guarded per-user download root. */
    public static function get_download_root() {
        $upload = wp_upload_dir();
        return $upload['basedir'] . '/sv-fp-downloads';
    }

    /**
     * Idempotently write .htaccess + index.php into the download root so
     * direct HTTP access is blocked (on Apache) and directory listing is
     * blocked (on nginx, index.php being present matters less but we still
     * write it for consistency).
     */
    public static function ensure_download_root_guarded( $dir ) {
        if ( ! is_dir( $dir ) ) {
            return;
        }
        if ( ! file_exists( $dir . '/.htaccess' ) ) {
            @file_put_contents( $dir . '/.htaccess', "deny from all\n" ); // phpcs:ignore
        }
        if ( ! file_exists( $dir . '/index.php' ) ) {
            @file_put_contents( $dir . '/index.php', '<?php // Silence is golden.' ); // phpcs:ignore
        }
    }

    /**
     * Sweep orphaned FlatPack artifacts in wp-content/uploads older than
     * a TTL. Covers: sv-fp-temp-* and sv-fp-output-* dirs from interrupted
     * runs, and expired download tokens whose transients have lapsed.
     *
     * Also sweeps legacy pre-v2.2.1 *-flatpack-*.zip files at the uploads
     * root created by earlier plugin versions.
     */
    public static function cleanup_old_artifacts() {
        $upload = wp_upload_dir();
        $base   = $upload['basedir'];
        $ttl    = DAY_IN_SECONDS; // 24h
        $now    = time();

        // Legacy sweep: *-flatpack-*.zip files at uploads root (pre-v2.2.1)
        foreach ( (array) glob( $base . '/*-flatpack-*.zip' ) as $zip ) {
            if ( @filemtime( $zip ) && ( $now - filemtime( $zip ) ) > $ttl ) { // phpcs:ignore
                @unlink( $zip ); // phpcs:ignore
            }
        }

        // Temp / output dirs from interrupted runs
        foreach ( [ 'sv-fp-temp-*', 'sv-fp-output-*' ] as $pattern ) {
            foreach ( (array) glob( $base . '/' . $pattern, GLOB_ONLYDIR ) as $dir ) {
                if ( @filemtime( $dir ) && ( $now - filemtime( $dir ) ) > $ttl ) { // phpcs:ignore
                    self::delete_directory( $dir );
                }
            }
        }

        // Expired per-token download dirs
        $root = self::get_download_root();
        if ( is_dir( $root ) ) {
            foreach ( (array) glob( $root . '/*', GLOB_ONLYDIR ) as $dir ) {
                $token = basename( $dir );
                // If transient is gone, the token has expired → purge.
                if ( get_transient( 'sv_fp_dl_' . $token ) === false ) {
                    self::delete_directory( $dir );
                }
            }
        }
    }

    /**
     * Stream a download for a valid token, then delete the file.
     * Registered via admin_post_sv_fp_download in flatpack-ajax.php.
     */
    public static function serve_download() {
        if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
            wp_die( 'Permission denied.', 403 );
        }

        $token = isset( $_GET['token'] ) ? sanitize_key( $_GET['token'] ) : ''; // phpcs:ignore
        if ( ! $token || ! preg_match( '/^[a-z0-9]{32}$/', $token ) ) {
            wp_die( 'Invalid token.', 400 );
        }

        check_admin_referer( 'sv_fp_download_' . $token );

        $record = get_transient( 'sv_fp_dl_' . $token );
        if ( ! $record || empty( $record['file'] ) ) {
            wp_die( 'Download link has expired.', 410 );
        }

        $root      = self::get_download_root();
        $token_dir = $root . '/' . $token;
        $real_root = realpath( $root );
        $real_file = realpath( $token_dir . '/' . $record['file'] );

        // Path sandbox: must resolve inside the guarded download root.
        if ( ! $real_root || ! $real_file || strpos( $real_file, $real_root . DIRECTORY_SEPARATOR ) !== 0 ) {
            wp_die( 'File not found.', 404 );
        }
        if ( ! is_file( $real_file ) ) {
            wp_die( 'File not found.', 404 );
        }

        // Stream it, then delete. We use an explicit chunked fread loop
        // rather than readfile() for two reasons:
        //   1. Plugin Check (WPCS) flags readfile() as a "direct filesystem
        //      operation". WP_Filesystem provides no streaming-to-output
        //      equivalent, and loading multi-MB ZIPs via get_contents()
        //      would be memory-hostile.
        //   2. Explicit chunk size + flush() gives predictable streaming
        //      behaviour through reverse proxies and buffering layers.
        nocache_headers();
        header( 'Content-Type: application/zip' );
        header( 'Content-Disposition: attachment; filename="' . rawurlencode( $record['file'] ) . '"' );
        header( 'Content-Length: ' . filesize( $real_file ) );
        header( 'X-Content-Type-Options: nosniff' );

        // Drop any output buffers so the response streams cleanly.
        while ( ob_get_level() ) {
            ob_end_clean();
        }

        // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.Security.EscapeOutput.OutputNotEscaped -- Streaming binary file content to HTTP response; WP_Filesystem has no streaming equivalent and HTML escaping would corrupt the ZIP.
        $handle = @fopen( $real_file, 'rb' );
        if ( $handle ) {
            while ( ! feof( $handle ) ) {
                echo fread( $handle, 8192 );
                if ( function_exists( 'flush' ) ) {
                    @flush(); // phpcs:ignore
                }
            }
            fclose( $handle );
        }
        // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.Security.EscapeOutput.OutputNotEscaped

        // One-time download — remove the file and its transient.
        @unlink( $real_file ); // phpcs:ignore
        self::delete_directory( $token_dir );
        delete_transient( 'sv_fp_dl_' . $token );
        exit;
    }
}