<?php
/**
 * Structure Viewer Pro — File Handler
 *
 * Handles ZIP extraction, single-file uploads, directory scanning,
 * and transient-based session storage for the Comparator feature.
 */

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_File_Handler {

    // ── Upload directory ──────────────────────────────────────────

    public static function get_upload_dir() {
        $upload = wp_upload_dir();
        return trailingslashit( $upload['basedir'] ) . 'sv-temp/';
    }

    /**
     * Ensure the temp directory exists and is protected.
     * Called on admin_init (not just activation) so it self-heals.
     */
    public static function ensure_upload_dir() {
        $dir = self::get_upload_dir();
        if ( ! file_exists( $dir ) ) {
            wp_mkdir_p( $dir );
        }
        $htaccess = $dir . '.htaccess';
        if ( ! file_exists( $htaccess ) ) {
            file_put_contents( $htaccess, 'deny from all' ); // phpcs:ignore
        }
        $index = $dir . 'index.php';
        if ( ! file_exists( $index ) ) {
            file_put_contents( $index, '<?php // Silence is golden.' ); // phpcs:ignore
        }
    }

    // ── Transient-based sessions ──────────────────────────────────

    public static function get_session( $key ) {
        return get_transient( 'sv_comp_' . $key );
    }

    public static function set_session( $key, $data, $expiry = 3600 ) {
        set_transient( 'sv_comp_' . $key, $data, $expiry );
    }

    public static function delete_session( $key ) {
        delete_transient( 'sv_comp_' . $key );
    }

    // ── Resource limits ───────────────────────────────────────────

    public static function increase_limits() {
        @ini_set( 'memory_limit', '512M' );      // phpcs:ignore
        @ini_set( 'max_execution_time', '300' ); // phpcs:ignore
        @set_time_limit( 300 );                  // phpcs:ignore
    }

    // ── File type detection ───────────────────────────────────────

    public static function is_text_file( $file ) {
        static $text_exts = [
            'php', 'js', 'jsx', 'ts', 'tsx', 'css', 'scss', 'sass', 'less',
            'html', 'htm', 'twig', 'vue', 'txt', 'md', 'json', 'xml',
            'yml', 'yaml', 'ini', 'conf', 'sql', 'htaccess', 'pot', 'po',
            'svg', 'sh', 'bat', 'log', 'env', 'gitignore', 'editorconfig',
        ];
        return in_array( strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ), $text_exts, true );
    }

    // ── Directory scanning ────────────────────────────────────────

    public static function scan_directory( $dir, $base = null ) {
        $files = [];
        if ( $base === null ) {
            $base = $dir;
        }
        if ( ! is_dir( $dir ) ) {
            return $files;
        }
        $items = @scandir( $dir ); // phpcs:ignore
        if ( ! $items ) {
            return $files;
        }
        foreach ( array_diff( $items, [ '.', '..' ] ) as $item ) {
            $path     = rtrim( $dir, '/' ) . '/' . $item;
            $relative = ltrim( str_replace( $base, '', $path ), '/' );
            if ( is_dir( $path ) ) {
                $files = array_merge( $files, self::scan_directory( $path, $base ) );
            } else {
                $files[ $relative ] = [
                    'path' => $path,
                    'size' => @filesize( $path ) ?: 0,    // phpcs:ignore
                    'hash' => @md5_file( $path ) ?: '',   // phpcs:ignore
                ];
            }
        }
        return $files;
    }

    /**
     * Strip leading root folder from paths (e.g. "akismet/includes/X" → "includes/X").
     */
    public static function normalize_paths( $files, $remove_root = true ) {
        if ( ! $remove_root ) {
            return $files;
        }
        $normalized = [];
        foreach ( $files as $path => $info ) {
            $parts = explode( '/', $path );
            if ( count( $parts ) > 1 ) {
                array_shift( $parts );
            }
            $new_path = implode( '/', $parts );
            if ( ! empty( $new_path ) ) {
                $normalized[ $new_path ] = $info;
            }
        }
        return $normalized;
    }

    // ── ZIP upload & extraction ───────────────────────────────────

    public static function upload_zip( $file, $prefix ) {
        self::increase_limits();

        if ( ! isset( $file['error'] ) || $file['error'] !== UPLOAD_ERR_OK ) {
            $msgs = [
                UPLOAD_ERR_INI_SIZE   => 'File exceeds upload_max_filesize',
                UPLOAD_ERR_FORM_SIZE  => 'File exceeds MAX_FILE_SIZE',
                UPLOAD_ERR_PARTIAL    => 'File was partially uploaded',
                UPLOAD_ERR_NO_FILE    => 'No file was uploaded',
                UPLOAD_ERR_NO_TMP_DIR => 'Missing temp folder',
                UPLOAD_ERR_CANT_WRITE => 'Failed to write file',
                UPLOAD_ERR_EXTENSION  => 'Upload blocked by extension',
            ];
            $code    = isset( $file['error'] ) ? $file['error'] : 0;
            $message = isset( $msgs[ $code ] ) ? $msgs[ $code ] : 'Upload failed';
            return [ 'error' => $message ];
        }

        if ( strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ) !== 'zip' ) {
            return [ 'error' => 'File must be a ZIP archive' ];
        }

        $extract_dir = self::get_upload_dir() . $prefix . '_' . uniqid() . '/';
        if ( ! wp_mkdir_p( $extract_dir ) ) {
            return [ 'error' => 'Cannot create directory' ];
        }

        $zip_path = $extract_dir . 'archive.zip';
        if ( ! @move_uploaded_file( $file['tmp_name'], $zip_path ) ) { // phpcs:ignore
            self::delete_directory( $extract_dir );
            return [ 'error' => 'Cannot move uploaded file' ];
        }

        if ( ! class_exists( 'ZipArchive' ) ) {
            @unlink( $zip_path ); // phpcs:ignore
            self::delete_directory( $extract_dir );
            return [ 'error' => 'ZipArchive extension is not available on this server' ];
        }

        $zip    = new ZipArchive();
        $opened = $zip->open( $zip_path );
        if ( $opened !== true ) {
            @unlink( $zip_path ); // phpcs:ignore
            self::delete_directory( $extract_dir );
            return [ 'error' => 'Cannot open ZIP file' ];
        }

        $total = $zip->numFiles;

        // Zip-slip guard: reject entries whose path would escape $extract_dir
        // via traversal, absolute path, or null-byte truncation. Check every
        // entry BEFORE extracting anything so a malicious ZIP can't write a
        // single file before we bail.
        for ( $i = 0; $i < $total; $i++ ) {
            $entry = $zip->getNameIndex( $i );
            if ( $entry === false ) {
                $zip->close();
                @unlink( $zip_path ); // phpcs:ignore
                self::delete_directory( $extract_dir );
                return [ 'error' => 'ZIP contains an unreadable entry' ];
            }
            // Normalise separators for the check
            $normalised = str_replace( '\\', '/', $entry );
            if ( strpos( $normalised, "\0" ) !== false
                || strpos( $normalised, '../' ) !== false
                || strpos( $normalised, '/..' ) === 0
                || $normalised === '..'
                || ( isset( $normalised[0] ) && $normalised[0] === '/' )
                || preg_match( '#^[a-zA-Z]:[/\\\\]#', $normalised ) ) {
                $zip->close();
                @unlink( $zip_path ); // phpcs:ignore
                self::delete_directory( $extract_dir );
                return [ 'error' => 'ZIP contains unsafe path: ' . $entry ];
            }
        }

        for ( $i = 0; $i < $total; $i++ ) {
            $zip->extractTo( $extract_dir, [ $zip->getNameIndex( $i ) ] );
            if ( $i % 100 === 0 ) {
                @set_time_limit( 60 ); // phpcs:ignore
            }
        }
        $zip->close();
        @unlink( $zip_path ); // phpcs:ignore

        return [
            'success' => true,
            'path'    => $extract_dir,
            'name'    => pathinfo( $file['name'], PATHINFO_FILENAME ),
        ];
    }

    // ── Single file upload ────────────────────────────────────────

    public static function upload_single_file( $file, $prefix ) {
        self::increase_limits();

        if ( ! isset( $file['error'] ) || $file['error'] !== UPLOAD_ERR_OK ) {
            return [ 'error' => 'File upload failed' ];
        }

        $dir = self::get_upload_dir() . $prefix . '_' . uniqid() . '/';
        if ( ! wp_mkdir_p( $dir ) ) {
            return [ 'error' => 'Cannot create directory' ];
        }

        $filename = sanitize_file_name( $file['name'] );
        $filepath = $dir . $filename;

        if ( ! @move_uploaded_file( $file['tmp_name'], $filepath ) ) { // phpcs:ignore
            self::delete_directory( $dir );
            return [ 'error' => 'Cannot move uploaded file' ];
        }

        return [
            'success' => true,
            'path'    => $filepath,
            'dir'     => $dir,
            'name'    => $filename,
            'size'    => filesize( $filepath ),
        ];
    }

    // ── Cleanup ───────────────────────────────────────────────────

    public static function delete_directory( $dir ) {
        if ( ! file_exists( $dir ) ) {
            return true;
        }
        if ( ! is_dir( $dir ) ) {
            return @unlink( $dir ); // phpcs:ignore
        }
        $files = @scandir( $dir ); // phpcs:ignore
        if ( ! $files ) {
            return false;
        }
        foreach ( array_diff( $files, [ '.', '..' ] ) as $file ) {
            $path = $dir . '/' . $file;
            is_dir( $path ) ? self::delete_directory( $path ) : @unlink( $path ); // phpcs:ignore
        }
        return @rmdir( $dir ); // phpcs:ignore
    }
}