<?php
/**
 * DownloadableUploadService — secure file storage for downloadable products.
 *
 * Mirrors ImageService's wp_handle_upload pattern but writes into a SEPARATE
 * upload root (wp-content/uploads/titancart-downloads/) protected by a
 * .htaccess Deny-all rule, an IIS web.config deny rule, and an index.php
 * silencer. Files stored here are NEVER directly web-accessible — they are
 * served exclusively through the Phase 3 signed-URL streaming endpoint
 * (which calls validate_key() before reading the file off disk) and the
 * Phase 2 admin "Test download" endpoint (gated behind manage_options).
 *
 * Per-file path layout:
 *   {YYYY}/{MM}/{8-char random prefix}-{slugified-name}.{ext}
 *
 * The random prefix prevents directory enumeration even if the Deny-all
 * rule is somehow bypassed (e.g. a misconfigured nginx ignoring .htaccess).
 *
 * file_path stored in tc_downloadable_files is RELATIVE to the upload root,
 * so the deployment can move the upload directory without touching the DB.
 *
 * @package TitanCart\Modules\Downloadables
 */

namespace TitanCart\Modules\Downloadables;

defined( 'ABSPATH' ) || exit;

class DownloadableUploadService {

    /**
     * Sub-directory inside wp-content/uploads/ that holds protected downloads.
     * Note the trailing slash is intentionally omitted — callers join it.
     */
    private const UPLOAD_SUBDIR = 'titancart-downloads';

    /**
     * File extensions that are NEVER allowed regardless of intent.
     * Defense-in-depth on top of the Deny-all rule — these would also be
     * blocked from execution by Apache config, but we don't want them on
     * disk at all.
     */
    private const BLOCKED_EXTENSIONS = [
        'php', 'phtml', 'phar', 'php3', 'php4', 'php5', 'php7', 'php8',
        'pl', 'cgi', 'sh', 'bash', 'zsh',
        'exe', 'bat', 'cmd', 'com', 'msi',
        'htaccess', 'htpasswd', 'ht',
    ];

    /* ──────────────────────────────────────────────────────────────────────────
     * Public API — upload / replace / delete
     * ────────────────────────────────────────────────────────────────────────── */

    /**
     * Handle an uploaded file: validate, move to the secure directory, and
     * return the metadata needed to write a tc_downloadable_files row.
     *
     * Caller is responsible for inserting the DB row and recording the
     * relationship to a product/variation.
     *
     * @param  array $file  A single $_FILES entry: ['name', 'tmp_name', 'size', 'type', 'error']
     * @return array|\WP_Error  ['filename', 'original_name', 'file_path', 'file_size', 'mime_type']
     *                           or WP_Error on validation/upload failure.
     */
    public static function handle_upload( array $file ) {
        // 0. Make sure the protected directory + sentries exist before we move anything.
        self::deploy_protections();

        // 1. Validate file shape and extension.
        $validation = self::validate_upload( $file );
        if ( is_wp_error( $validation ) ) {
            return $validation;
        }

        // 2. Compute the unique destination path BEFORE handing off to wp_handle_upload.
        $original_name = isset( $file['name'] ) ? (string) $file['name'] : 'download.bin';
        $ext           = strtolower( pathinfo( $original_name, PATHINFO_EXTENSION ) );
        $slug          = self::slugify_filename( $original_name );
        $rand          = self::random_prefix();
        $year          = gmdate( 'Y' );
        $month         = gmdate( 'm' );
        $relative_dir  = $year . '/' . $month;
        $filename      = $rand . '-' . $slug . ( $ext ? '.' . $ext : '' );
        $relative_path = $relative_dir . '/' . $filename;

        // 3. Ensure the year/month sub-dir exists.
        $absolute_dir = self::get_upload_root() . '/' . $relative_dir;
        if ( ! is_dir( $absolute_dir ) ) {
            if ( ! wp_mkdir_p( $absolute_dir ) ) {
                return new \WP_Error(
                    'tc_dl_mkdir_failed',
                    __( 'Could not create the downloads sub-directory.', 'titancart-lite' ),
                    [ 'status' => 500 ]
                );
            }
        }

        // 4. Move the uploaded temp file into place via the WP file API
        // (S441, .org compliance: move_uploaded_file()/rename() are forbidden
        // in plugin code — mirror ImageService's wp_handle_upload +
        // upload_dir-filter pattern). A temporary upload_dir filter keeps the
        // deterministic destination (random prefix + slug) inside our
        // protected root, and unique_filename_callback pins the exact
        // filename computed above. test_type is false ON PURPOSE:
        // downloadables use a deny-list (BLOCKED_EXTENSIONS, enforced in
        // validate_upload above) + a Deny-all protected root — forcing WP's
        // media allowlist here would reject legitimate downloadable types
        // (.epub, .stl, vendor formats) and regress the product.
        $tmp_name = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
        if ( ! $tmp_name || ! file_exists( $tmp_name ) ) {
            return new \WP_Error(
                'tc_dl_tmp_missing',
                __( 'Upload temp file is missing.', 'titancart-lite' ),
                [ 'status' => 400 ]
            );
        }

        if ( ! function_exists( 'wp_handle_upload' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }

        $dir_filter = static function ( array $dirs ) use ( $absolute_dir, $relative_dir ) {
            $dirs['path']   = $absolute_dir;
            $dirs['subdir'] = '/' . $relative_dir;
            $dirs['url']    = $dirs['baseurl'] . '/' . self::UPLOAD_SUBDIR . '/' . $relative_dir;
            return $dirs;
        };
        $overrides = [
            'test_form'                => false,
            'test_type'                => false,
            'unique_filename_callback' => static function () use ( $filename ) {
                return $filename;
            },
        ];

        add_filter( 'upload_dir', $dir_filter );
        // Real HTTP uploads keep is_uploaded_file() semantics via
        // wp_handle_upload; already-moved temp files (REST tests, re-uploads)
        // go through wp_handle_sideload (copy + unlink — replaces the old
        // rename fallback).
        $upload = is_uploaded_file( $tmp_name )
            ? wp_handle_upload( $file, $overrides )
            : wp_handle_sideload( $file, $overrides );
        remove_filter( 'upload_dir', $dir_filter );

        if ( ! isset( $upload['file'] ) ) {
            return new \WP_Error(
                'tc_dl_move_failed',
                __( 'Could not save the uploaded file to the downloads directory.', 'titancart-lite' ),
                [
                    'status' => 500,
                    'detail' => isset( $upload['error'] ) ? (string) $upload['error'] : '',
                ]
            );
        }

        // Truth from disk: re-derive the stored names from the file that
        // actually landed (the callback pins the name, but basename-from-truth
        // keeps the DB row consistent with the filesystem by construction).
        $absolute_path = (string) $upload['file'];
        $filename      = basename( $absolute_path );
        $relative_path = $relative_dir . '/' . $filename;

        // Lock down permissions: 0644 readable by the web user, not executable.
        @chmod( $absolute_path, 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- 0644 hardening on a file this service just wrote; a WP_Filesystem round-trip is disproportionate and can demand FTP creds in REST context.

        // 5. Detect mime type from the moved file (more reliable than $file['type']).
        $mime_type = self::detect_mime_type( $absolute_path, $original_name );
        $file_size = (int) @filesize( $absolute_path );

        return [
            'filename'      => $filename,
            'original_name' => $original_name,
            'file_path'     => $relative_path,
            'file_size'     => $file_size,
            'mime_type'     => $mime_type,
        ];
    }

    /**
     * Replace an existing file with a new upload. Updates the underlying file
     * on disk while preserving the relative_path's parent directory so other
     * references (DB rows, key validations) keep working.
     *
     * Returns the new metadata so the caller can update the tc_downloadable_files row.
     *
     * @param  string $existing_relative_path  Currently stored file_path on the DB row.
     * @param  array  $new_file                A $_FILES entry for the replacement.
     * @return array|\WP_Error
     */
    public static function replace_file( string $existing_relative_path, array $new_file ) {
        // Upload the new file (gets a fresh random prefix — old file isn't overwritten in-place).
        $upload = self::handle_upload( $new_file );
        if ( is_wp_error( $upload ) ) {
            return $upload;
        }

        // Delete the old file off disk only AFTER the new one is safely in place.
        self::delete_file( $existing_relative_path );

        return $upload;
    }

    /**
     * Remove a file from disk. Idempotent — silently succeeds if already absent.
     *
     * @param  string $relative_path  Relative to the upload root.
     * @return bool   True if file was removed or already absent; false on permission error.
     */
    public static function delete_file( string $relative_path ): bool {
        if ( $relative_path === '' ) {
            return true;
        }
        $absolute = self::get_upload_root() . '/' . ltrim( $relative_path, '/' );

        // Guard: never delete outside the upload root (path traversal defense).
        $real_root = realpath( self::get_upload_root() );
        $real_file = realpath( $absolute );
        if ( $real_file && $real_root && strpos( $real_file, $real_root ) !== 0 ) {
            return false;
        }

        if ( ! file_exists( $absolute ) ) {
            return true;
        }
        // wp_delete_file() only returns bool on WP >= 6.7; re-check existence
        // so the success signal stays accurate on the 6.0 floor.
        wp_delete_file( $absolute );
        return ! file_exists( $absolute );
    }

    /* ──────────────────────────────────────────────────────────────────────────
     * Public API — read / stream
     * ────────────────────────────────────────────────────────────────────────── */

    /**
     * Resolve a stored relative path to an absolute filesystem path. Returns
     * null if the path escapes the upload root or the file doesn't exist.
     *
     * @param  string $relative_path
     * @return string|null
     */
    public static function get_absolute_path( string $relative_path ): ?string {
        $absolute = self::get_upload_root() . '/' . ltrim( $relative_path, '/' );

        // Path traversal guard.
        $real_root = realpath( self::get_upload_root() );
        $real_file = realpath( $absolute );
        if ( ! $real_file || ! $real_root ) {
            return null;
        }
        if ( strpos( $real_file, $real_root ) !== 0 ) {
            return null;
        }
        return $real_file;
    }

    /**
     * Stream a file to the browser as a download. Used by the Phase 2 admin
     * "Test download" endpoint and the Phase 3 customer streaming endpoint.
     *
     * Supports HTTP Range requests so large files can resume.
     *
     * IMPORTANT: this method exits the request — never returns.
     *
     * @param  string $relative_path
     * @param  string $original_name  Filename to suggest to the browser.
     * @param  string $mime_type
     * @return int    Bytes streamed (only relevant when Range is honored partially).
     */
    public static function stream_file( string $relative_path, string $original_name, string $mime_type ): int {
        $absolute = self::get_absolute_path( $relative_path );
        if ( ! $absolute || ! is_readable( $absolute ) ) {
            status_header( 404 );
            nocache_headers();
            exit;
        }

        $file_size = filesize( $absolute );
        $start     = 0;
        $end       = $file_size - 1;
        $http_206  = false;

        // Parse Range header for resume support.
        $range_header = isset( $_SERVER['HTTP_RANGE'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_RANGE'] ) ) : '';
        if ( '' !== $range_header && preg_match( '/bytes=(\d+)-(\d*)/', $range_header, $m ) ) {
            $start = (int) $m[1];
            if ( $m[2] !== '' ) {
                $end = (int) $m[2];
            }
            if ( $start > $end || $start >= $file_size ) {
                status_header( 416 );
                header( 'Content-Range: bytes */' . $file_size );
                exit;
            }
            $http_206 = true;
        }

        $length = $end - $start + 1;

        // Wipe any output buffers WP started so we send raw bytes.
        while ( ob_get_level() > 0 ) {
            ob_end_clean();
        }

        nocache_headers();
        if ( $http_206 ) {
            status_header( 206 );
            header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $file_size );
        } else {
            status_header( 200 );
        }
        header( 'Content-Type: ' . ( $mime_type ?: 'application/octet-stream' ) );
        header( 'Content-Length: ' . $length );
        header( 'Content-Disposition: attachment; filename="' . self::sanitize_filename_header( $original_name ) . '"' );
        header( 'Accept-Ranges: bytes' );
        header( 'X-Content-Type-Options: nosniff' );

        $fp = @fopen( $absolute, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- chunked binary streaming of protected downloads; WP_Filesystem has no streaming read API and buffering whole files would exhaust memory.
        if ( ! $fp ) {
            status_header( 500 );
            exit;
        }

        if ( $start > 0 ) {
            fseek( $fp, $start );
        }

        $bytes_sent = 0;
        $chunk_size = 1024 * 1024; // 1 MB
        $remaining  = $length;
        while ( $remaining > 0 && ! feof( $fp ) ) {
            $read = min( $chunk_size, $remaining );
            $buf  = fread( $fp, $read ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- chunked binary streaming; see fopen rationale above.
            if ( $buf === false ) {
                break;
            }
            echo $buf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw BINARY file stream (Content-Type set from the stored file, Content-Disposition attachment; access-gated upstream). Escaping functions are HTML-context transforms and would corrupt the bytes; there is no HTML context here. See WPORG-REVIEWER-NOTES.md.
            $bytes_sent += strlen( $buf );
            $remaining  -= strlen( $buf );
            if ( connection_aborted() ) {
                break;
            }
            flush();
        }
        fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the streaming handle opened above.
        return $bytes_sent;
    }

    /* ──────────────────────────────────────────────────────────────────────────
     * Public API — environment + protections
     * ────────────────────────────────────────────────────────────────────────── */

    /**
     * Absolute filesystem path to the protected upload root.
     *
     * @return string
     */
    public static function get_upload_root(): string {
        // WP.org R1 🅲: wp_upload_dir() only — its basedir always exists; the
        // former ABSPATH.'wp-content/uploads' fallback assumed a directory
        // layout WordPress does not guarantee.
        $wp_uploads = wp_upload_dir();
        $base       = rtrim( (string) ( $wp_uploads['basedir'] ?? '' ), '/\\' );
        return $base . '/' . self::UPLOAD_SUBDIR;
    }

    /**
     * Idempotently deploy the .htaccess + web.config + index.php sentries
     * to the protected upload root. Called from DownloadableModule::init()
     * and as a safety check before every upload.
     *
     * Existing files are left in place to avoid clobbering admin-customized
     * rules. Only writes when the file is absent.
     *
     * @return void
     */
    public static function deploy_protections(): void {
        $root = self::get_upload_root();
        if ( ! is_dir( $root ) ) {
            wp_mkdir_p( $root );
        }
        if ( ! is_dir( $root ) ) {
            return; // Can't proceed; permission issue.
        }

        // .htaccess — Apache deny-all (also covers LiteSpeed which honors .htaccess).
        $htaccess = $root . '/.htaccess';
        if ( ! file_exists( $htaccess ) ) {
            $rules = "# TitanCart — protected downloads directory.\n"
                   . "# Files in this tree are served exclusively via the TitanCart\n"
                   . "# REST endpoint after token validation. Direct HTTP access is denied.\n"
                   . "<IfModule mod_authz_core.c>\n"
                   . "    Require all denied\n"
                   . "</IfModule>\n"
                   . "<IfModule !mod_authz_core.c>\n"
                   . "    Order deny,allow\n"
                   . "    Deny from all\n"
                   . "</IfModule>\n"
                   . "Options -Indexes\n";
            @file_put_contents( $htaccess, $rules ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny self-owned .htaccess deny-all protection file inside the upload root; WP_Filesystem init is disproportionate and can demand FTP creds in REST context.
            @chmod( $htaccess, 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- 0644 on the protection file written above.
        }

        // web.config — IIS deny-all.
        $webconfig = $root . '/web.config';
        if ( ! file_exists( $webconfig ) ) {
            $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
                 . "<configuration>\n"
                 . "  <system.webServer>\n"
                 . "    <security>\n"
                 . "      <authorization>\n"
                 . "        <remove users=\"*\" roles=\"\" verbs=\"\" />\n"
                 . "        <add accessType=\"Deny\" users=\"*\" />\n"
                 . "      </authorization>\n"
                 . "    </security>\n"
                 . "    <directoryBrowse enabled=\"false\" />\n"
                 . "  </system.webServer>\n"
                 . "</configuration>\n";
            @file_put_contents( $webconfig, $xml ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny self-owned web.config (IIS deny-all) protection file; see .htaccess rationale above.
            @chmod( $webconfig, 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- 0644 on the protection file written above.
        }

        // index.php — silence directory listings on hosts that ignore Options -Indexes.
        $indexphp = $root . '/index.php';
        if ( ! file_exists( $indexphp ) ) {
            @file_put_contents( $indexphp, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny self-owned index.php listing-silencer; see .htaccess rationale above.
            @chmod( $indexphp, 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- 0644 on the protection file written above.
        }
    }

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

    /**
     * Validate an upload — checks for upload errors, blocked extensions,
     * and reasonable shape. Does NOT enforce a size limit beyond what PHP
     * itself enforces via upload_max_filesize.
     *
     * @param  array $file
     * @return null|\WP_Error
     */
    private static function validate_upload( array $file ): ?\WP_Error {
        if ( ! isset( $file['error'] ) || $file['error'] !== UPLOAD_ERR_OK ) {
            $error_code = isset( $file['error'] ) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE;
            return new \WP_Error(
                'tc_dl_upload_error',
                self::php_upload_error_message( $error_code ),
                [ 'status' => 400 ]
            );
        }

        $name = isset( $file['name'] ) ? (string) $file['name'] : '';
        if ( $name === '' ) {
            return new \WP_Error( 'tc_dl_no_name', __( 'Uploaded file has no name.', 'titancart-lite' ), [ 'status' => 400 ] );
        }

        $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
        if ( in_array( $ext, self::BLOCKED_EXTENSIONS, true ) ) {
            return new \WP_Error(
                'tc_dl_blocked_extension',
                sprintf(
                    /* translators: %s: file extension */
                    __( 'Files with the .%s extension are not allowed.', 'titancart-lite' ),
                    $ext
                ),
                [ 'status' => 400 ]
            );
        }

        // Block double-extensions like "evil.php.zip" by checking every dot-segment.
        $parts = array_map( 'strtolower', explode( '.', $name ) );
        foreach ( $parts as $segment ) {
            if ( in_array( $segment, self::BLOCKED_EXTENSIONS, true ) ) {
                return new \WP_Error(
                    'tc_dl_blocked_segment',
                    __( 'Filename contains a blocked extension segment.', 'titancart-lite' ),
                    [ 'status' => 400 ]
                );
            }
        }

        return null;
    }

    /**
     * Translate a PHP upload error code to a human-readable message.
     */
    private static function php_upload_error_message( int $code ): string {
        switch ( $code ) {
            case UPLOAD_ERR_INI_SIZE:
            case UPLOAD_ERR_FORM_SIZE:
                return __( 'The uploaded file exceeds the maximum size allowed by the server.', 'titancart-lite' );
            case UPLOAD_ERR_PARTIAL:
                return __( 'The uploaded file was only partially received. Please try again.', 'titancart-lite' );
            case UPLOAD_ERR_NO_FILE:
                return __( 'No file was uploaded.', 'titancart-lite' );
            case UPLOAD_ERR_NO_TMP_DIR:
                return __( 'Server is missing a temporary upload directory.', 'titancart-lite' );
            case UPLOAD_ERR_CANT_WRITE:
                return __( 'Server could not write the uploaded file to disk.', 'titancart-lite' );
            case UPLOAD_ERR_EXTENSION:
                return __( 'A PHP extension blocked the upload.', 'titancart-lite' );
            default:
                return __( 'Unknown upload error.', 'titancart-lite' );
        }
    }

    /**
     * Slugify a filename for the on-disk filename. Keeps the extension stripped
     * out — caller appends it. Lowercases, replaces non-alphanumeric runs with
     * single dashes, trims to 80 chars to avoid filesystem limits.
     *
     * @param  string $original
     * @return string
     */
    private static function slugify_filename( string $original ): string {
        $base = pathinfo( $original, PATHINFO_FILENAME );
        $base = strtolower( $base );
        $base = preg_replace( '/[^a-z0-9]+/', '-', $base );
        $base = trim( (string) $base, '-' );
        if ( $base === '' ) {
            $base = 'file';
        }
        if ( strlen( $base ) > 80 ) {
            $base = substr( $base, 0, 80 );
        }
        return $base;
    }

    /**
     * Generate an 8-char alphanumeric random prefix for the filename.
     *
     * @return string
     */
    private static function random_prefix(): string {
        try {
            return bin2hex( random_bytes( 4 ) ); // 8 hex chars
        } catch ( \Throwable $e ) {
            return wp_generate_password( 8, false, false );
        }
    }

    /**
     * Detect mime type using finfo when available, falling back to the
     * extension map.
     *
     * @param  string $absolute_path
     * @param  string $original_name
     * @return string
     */
    private static function detect_mime_type( string $absolute_path, string $original_name ): string {
        if ( function_exists( 'finfo_open' ) ) {
            $finfo = finfo_open( FILEINFO_MIME_TYPE );
            if ( $finfo ) {
                $mime = finfo_file( $finfo, $absolute_path );
                finfo_close( $finfo );
                if ( $mime ) {
                    return (string) $mime;
                }
            }
        }
        $ft = wp_check_filetype( $original_name );
        return ! empty( $ft['type'] ) ? (string) $ft['type'] : 'application/octet-stream';
    }

    /**
     * Sanitize a filename for inclusion in a Content-Disposition header —
     * strips quote-breaking characters that could cause header injection.
     *
     * @param  string $name
     * @return string
     */
    private static function sanitize_filename_header( string $name ): string {
        // Replace double-quotes and CR/LF, which could break the header.
        $name = str_replace( [ '"', "\r", "\n" ], [ '', '', '' ], $name );
        return $name;
    }
}
