<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }

/**
 * REST API proxy - serves attachment files securely via signed HMAC tokens.
 *
 * Route: GET /wp-json/tnc-flipbook-3d/v1/serve/{flipbook_id}
 * Params: file (pdf|image|video|media|audio), index (int), token (string), exp (int)
 */
class TNC_Flipbook_3D_REST_Proxy {

    const NAMESPACE = 'tnc-flipbook-3d/v1';
    const ROUTE     = '/serve/(?P<flipbook_id>\d+)';

    const RATE_LIMIT_MINUTE = 60;
    const RATE_LIMIT_HOUR   = 600;

    public static function init() {
        add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
    }

    /**
     * Per-IP rate limit (minute + hour tiers) to throttle token brute-forcing
     * and bandwidth amplification. Filterable via tncfb3d_proxy_rate_limit.
     */
    protected static function check_rate_limit() {
        $limits = apply_filters( 'tncfb3d_proxy_rate_limit', array(
            'minute' => self::RATE_LIMIT_MINUTE,
            'hour'   => self::RATE_LIMIT_HOUR,
        ) );

        $ip      = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown';
        $ip_hash = md5( $ip );

        $key_min  = 'tncfb3d_px_rl_m_' . $ip_hash;
        $key_hour = 'tncfb3d_px_rl_h_' . $ip_hash;

        $count_min  = (int) get_transient( $key_min );
        $count_hour = (int) get_transient( $key_hour );

        if ( $count_min >= (int) $limits['minute'] || $count_hour >= (int) $limits['hour'] ) {
            return false;
        }

        set_transient( $key_min, $count_min + 1, MINUTE_IN_SECONDS );
        set_transient( $key_hour, $count_hour + 1, HOUR_IN_SECONDS );
        return true;
    }

    public static function register_routes() {
        register_rest_route( self::NAMESPACE, self::ROUTE, array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => array( __CLASS__, 'serve_file' ),
            'permission_callback' => '__return_true', // Token-based auth, no login required
            'args'                => array(
                'flipbook_id' => array(
                    'required'          => true,
                    'validate_callback' => function( $v ) { return is_numeric( $v ) && absint( $v ) > 0; },
                    'sanitize_callback' => 'absint',
                ),
                'file' => array(
                    'required'          => true,
                    'sanitize_callback' => 'sanitize_key',
                ),
                'index' => array(
                    'required'          => true,
                    'sanitize_callback' => 'absint',
                    'default'           => 0,
                ),
                'token' => array(
                    'required'          => true,
                    'sanitize_callback' => 'sanitize_text_field',
                ),
                'exp' => array(
                    'required'          => true,
                    'sanitize_callback' => 'absint',
                ),
            ),
        ) );

        do_action( 'tncfb3d_rest_routes' );
    }

    // ── Main handler ──────────────────────────────────────────────────────────

    public static function serve_file( WP_REST_Request $request ) {
        if ( ! self::check_rate_limit() ) {
            return new WP_Error( 'rate_limited', __( 'Too many requests. Please slow down.', 'tnc-flipbook-3d-lite' ), array( 'status' => 429 ) );
        }

        $flipbook_id = $request->get_param( 'flipbook_id' );
        $file_type   = $request->get_param( 'file' );
        $index       = $request->get_param( 'index' );
        $token       = $request->get_param( 'token' );
        $exp         = $request->get_param( 'exp' );

        // 1. Validate HMAC token
        if ( ! TNC_Flipbook_3D_Helpers::verify_token( $flipbook_id, $file_type, $index, $token, $exp ) ) {
            return new WP_Error( 'invalid_token', __( 'Invalid or expired token.', 'tnc-flipbook-3d-lite' ), array( 'status' => 403 ) );
        }

        // 1b. Referer check - block direct URL access from address bar
        // Only enforce when content protection is enabled for this flipbook
        $cp_enabled = get_post_meta( $flipbook_id, '_tncfb3d_protection_enabled', true );
        if ( $cp_enabled ) {
            $referer = wp_get_raw_referer();
            if ( ! $referer || false === strpos( $referer, home_url() ) ) {
                return new WP_Error( 'forbidden', __( 'Direct access not allowed.', 'tnc-flipbook-3d-lite' ), array( 'status' => 403 ) );
            }
        }

        // 2. Check flipbook post exists
        $post = get_post( $flipbook_id );
        if ( ! $post || TNC_FLIPBOOK_3D_CPT !== $post->post_type ) {
            return new WP_Error( 'not_found', __( 'FlipBook not found.', 'tnc-flipbook-3d-lite' ), array( 'status' => 404 ) );
        }

        // 2a. WP REST requires a nonce for cookie auth to count; without it,
        //     the request is anonymous even when the browser sent a valid
        //     auth cookie. That breaks our access check (e.g. a customer who
        //     just purchased still sees preview because check_access sees
        //     them as logged out). The proxy is a read-only file serve and
        //     already gated by the HMAC token, so there is no CSRF surface
        //     to protect — we can safely promote the cookie to a current_user.
        if ( ! is_user_logged_in() && defined( 'LOGGED_IN_COOKIE' ) && ! empty( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
            // The auth cookie is `user|expiry|token|hmac` (no tags/whitespace), so
            // sanitize_text_field() leaves a valid cookie intact while satisfying
            // input-sanitization requirements; wp_validate_auth_cookie() then verifies it.
            $maybe_user = wp_validate_auth_cookie( sanitize_text_field( wp_unslash( $_COOKIE[ LOGGED_IN_COOKIE ] ) ), 'logged_in' );
            if ( $maybe_user ) {
                wp_set_current_user( $maybe_user );
            }
        }

        // 2b. Content access gate (WooCommerce preview-mode hardening).
        //     Returns one of:
        //       array( 'mode' => 'full' )                       — full access, proceed normally
        //       array( 'mode' => 'preview-file', 'path' => ... ) — serve a substitute file (preview.pdf)
        //       array( 'mode' => 'preview-deny' )               — refuse with 403
        //     For non-gated FlipBooks (no woo_product_id) the check returns 'full' immediately,
        //     so there's effectively no cost on the hot path.
        $access = self::check_preview_access( $flipbook_id, $file_type, $index );
        if ( 'preview-deny' === $access['mode'] ) {
            return new WP_Error( 'preview_locked', __( 'This content is only available to purchasers.', 'tnc-flipbook-3d-lite' ), array( 'status' => 403 ) );
        }

        // 3. Resolve attachment ID from meta
        $attachment_id = self::resolve_attachment_id( $flipbook_id, $file_type, $index );
        if ( ! $attachment_id ) {
            return new WP_Error( 'not_found', __( 'File not found.', 'tnc-flipbook-3d-lite' ), array( 'status' => 404 ) );
        }

        // 4. Get server path
        $file_path = get_attached_file( $attachment_id );
        if ( ! $file_path || ! file_exists( $file_path ) ) {
            return new WP_Error( 'not_found', __( 'File not found on server.', 'tnc-flipbook-3d-lite' ), array( 'status' => 404 ) );
        }

        // 4b. If preview mode wants to substitute a different file (e.g. preview.pdf),
        //     swap the source path now. Track the original filename so Content-Disposition
        //     can be set to {name}-preview.pdf when streamed.
        $download_basename = null;
        if ( 'preview-file' === $access['mode'] && ! empty( $access['path'] ) && file_exists( $access['path'] ) ) {
            $orig_name         = pathinfo( $file_path, PATHINFO_FILENAME );
            $orig_ext          = pathinfo( $file_path, PATHINFO_EXTENSION );
            $download_basename = $orig_name . '-preview.' . $orig_ext;
            $file_path         = $access['path'];
        }

        // 5. Security: must be within uploads directory
        if ( ! TNC_Flipbook_3D_Helpers::path_is_in_uploads( $file_path ) ) {
            return new WP_Error( 'forbidden', __( 'Access denied.', 'tnc-flipbook-3d-lite' ), array( 'status' => 403 ) );
        }

        // 6. Validate MIME type
        $filetype = wp_check_filetype( basename( $file_path ) );
        $mime     = $filetype['type'] ?? '';
        // Fallback: use the MIME type stored in the attachment post if wp_check_filetype fails
        if ( ! $mime ) {
            $mime = get_post_mime_type( $attachment_id );
        }
        if ( ! $mime || ! in_array( $mime, TNC_Flipbook_3D_Helpers::allowed_mime_types(), true ) ) {
            return new WP_Error( 'forbidden', __( 'File type not allowed.', 'tnc-flipbook-3d-lite' ), array( 'status' => 403 ) );
        }

        /**
         * @hook tncfb3d_serve_file_path
         * @param string $file_path Server path to the file being served.
         * @param int    $post_id   Flipbook post ID.
         * @param string $file_type File type (pdf, image, video).
         * @param int    $index     File index within its type group.
         * @return string
         */
        $file_path = apply_filters( 'tncfb3d_serve_file_path', $file_path, $flipbook_id, $file_type, $index );

        /**
         * @hook tncfb3d_before_serve_file
         * @param int    $post_id   Flipbook post ID.
         * @param string $file_type File type (pdf, image, video).
         * @param int    $index     File index within its type group.
         */
        do_action( 'tncfb3d_before_serve_file', $flipbook_id, $file_type, $index );

        // 7. Stream the file
        self::stream_file( $file_path, $mime, $flipbook_id, $file_type, $download_basename );
        exit; // Stream exits; WP_REST_Response never reached
    }

    /**
     * Decide whether the current request is allowed to receive the requested file
     * in full, should receive a substitute (preview) file, or be refused.
     *
     * Returns an array shaped as one of:
     *   array( 'mode' => 'full' )                       — proceed normally
     *   array( 'mode' => 'preview-file', 'path' => ... ) — serve $path instead
     *   array( 'mode' => 'preview-deny' )               — refuse with 403
     *
     * Optimisation: short-circuits to 'full' when no WooCommerce gating is
     * configured on the FlipBook, so non-restricted FlipBooks pay essentially
     * no cost on the hot path.
     *
     * @param int    $flipbook_id
     * @param string $file_type   'pdf' | 'image' | 'video' | 'audio' | 'media'
     * @param int    $index       Index within the type group (0-based).
     * @return array
     */
    private static function check_preview_access( $flipbook_id, $file_type, $index ) {
        $p = TNC_FLIPBOOK_3D_PREFIX;

        // The access resolver belongs to the gating layer (WooCommerce, leadcap,
        // EDD, PMP, …). Builds without it — notably Lite, which ships no gating
        // providers — have nothing to gate, so every file is fully accessible.
        // Guard here so the shared proxy never fatals on the missing class.
        if ( ! class_exists( 'TNC_Flipbook_3D_Access' ) ) {
            return array( 'mode' => 'full' );
        }

        // Ask the central resolver; any registered provider (Woo, leadcap,
        // EDD, PMP, …) gets a chance to declare 'preview'.
        $access = TNC_Flipbook_3D_Access::resolve( $flipbook_id );
        if ( 'preview' !== $access['status'] || $access['previewPages'] <= 0 ) {
            return array( 'mode' => 'full' );
        }
        $preview_pages = (int) $access['previewPages'];

        if ( 'pdf' === $file_type ) {
            $source_type = get_post_meta( $flipbook_id, $p . 'source_type', true );

            // Multi-PDF preview support is not yet implemented. Refuse access to
            // every PDF in a multi-PDF book under preview mode; the client-side
            // gating overlay continues to handle UX.
            if ( 'multi_pdf' === $source_type ) {
                return array( 'mode' => 'preview-deny' );
            }

            // Single PDF: look for the cached preview PDF generated by the
            // preview-pdf-builder on save. If it exists, substitute it for
            // the source PDF. If it doesn't (e.g. preview not yet built),
            // refuse rather than leak the source.
            $preview_path = self::get_preview_pdf_path( $flipbook_id );
            if ( $preview_path && file_exists( $preview_path ) ) {
                return array( 'mode' => 'preview-file', 'path' => $preview_path );
            }
            return array( 'mode' => 'preview-deny' );
        }

        if ( 'image' === $file_type ) {
            // Image-source FlipBook: each image is a separate file. Refuse any
            // image whose index is past the preview cap.
            if ( $index >= $preview_pages ) {
                return array( 'mode' => 'preview-deny' );
            }
            return array( 'mode' => 'full' );
        }

        if ( 'video' === $file_type ) {
            // Page videos are attached to specific pages via page_videos[].
            // Refuse the video file if the page it's anchored to is past the
            // preview cap.
            $videos = get_post_meta( $flipbook_id, $p . 'page_videos', true );
            if ( is_array( $videos ) ) {
                $vids    = array_values( $videos );
                $page_no = isset( $vids[ $index ]['page'] ) ? absint( $vids[ $index ]['page'] ) : 0;
                if ( $page_no > $preview_pages ) {
                    return array( 'mode' => 'preview-deny' );
                }
            }
            return array( 'mode' => 'full' );
        }

        // Other file types: leave the full-access path. TTS audio gating has
        // its own dedicated endpoint and is not routed through this method.
        return array( 'mode' => 'full' );
    }

    /**
     * Resolve the on-disk path of the cached preview PDF for a FlipBook.
     * Returns null if not built yet.
     */
    public static function get_preview_pdf_path( $flipbook_id ) {
        $uploads = wp_upload_dir();
        if ( empty( $uploads['basedir'] ) ) {
            return null;
        }
        $path = trailingslashit( $uploads['basedir'] ) . 'tncfb3d-cache/' . absint( $flipbook_id ) . '/preview.pdf';
        return $path;
    }

    // ── File resolution ───────────────────────────────────────────────────────

    /**
     * Map file_type + index → attachment ID stored in post meta.
     */
    private static function resolve_attachment_id( $flipbook_id, $file_type, $index ) {
        $p = TNC_FLIPBOOK_3D_PREFIX;

        if ( 'pdf' === $file_type ) {
            $source_type = get_post_meta( $flipbook_id, $p . 'source_type', true );
            if ( 'multi_pdf' === $source_type ) {
                $pdf_ids = get_post_meta( $flipbook_id, $p . 'pdf_ids', true );
                if ( is_array( $pdf_ids ) && isset( $pdf_ids[ $index ]['id'] ) ) {
                    return absint( $pdf_ids[ $index ]['id'] );
                }
                return 0;
            }
            return absint( get_post_meta( $flipbook_id, $p . 'pdf_id', true ) );
        }

        if ( 'image' === $file_type ) {
            $image_ids = get_post_meta( $flipbook_id, $p . 'image_ids', true );
            if ( is_array( $image_ids ) ) {
                $ids = array_values( $image_ids );
                return isset( $ids[ $index ] ) ? absint( $ids[ $index ] ) : 0;
            }
            return 0;
        }

        if ( 'video' === $file_type ) {
            $videos = get_post_meta( $flipbook_id, $p . 'page_videos', true );
            if ( is_array( $videos ) ) {
                $vids = array_values( $videos );
                return isset( $vids[ $index ]['video_id'] ) ? absint( $vids[ $index ]['video_id'] ) : 0;
            }
            return 0;
        }

        // 'media' and 'audio' are general attachment IDs stored in meta
        // (used for TTS audio etc.) - not currently mapped; return 0
        return 0;
    }

    // ── Streaming ─────────────────────────────────────────────────────────────

    /**
     * Stream a file with appropriate headers.
     * Supports HTTP range requests for large PDFs.
     *
     * @param string      $path              On-disk path to file being streamed.
     * @param string      $mime              MIME type for Content-Type header.
     * @param int         $post_id           Flipbook post ID (passed through to filters).
     * @param string      $file_type         File-type slug (pdf/image/video).
     * @param string|null $download_basename Optional filename to expose in
     *                                       Content-Disposition. When set (e.g. for
     *                                       preview PDFs), tells the browser to
     *                                       save under {basename} instead of the
     *                                       on-disk filename.
     */
    private static function stream_file( $path, $mime, $post_id = 0, $file_type = '', $download_basename = null ) {
        $file_size = filesize( $path );
        $start     = 0;
        $end       = $file_size - 1;
        $status    = 200;

        // Default disposition is 'inline' so the browser displays the file via
        // <iframe>/<embed>. When a download_basename is provided (preview PDFs),
        // expose it so save-as picks up the {name}-preview.pdf filename.
        $disposition = 'inline';
        if ( ! empty( $download_basename ) ) {
            $safe_name   = preg_replace( '/[^A-Za-z0-9._-]/', '_', $download_basename );
            $disposition = sprintf( 'inline; filename="%s"', $safe_name );
        }

        // Security headers
        $headers = array(
            'X-Content-Type-Options'  => 'nosniff',
            'X-Frame-Options'         => 'SAMEORIGIN',
            'Content-Security-Policy' => "default-src 'none'",
            'Cache-Control'           => 'private, no-store, must-revalidate, max-age=0',
            'Pragma'                  => 'no-cache',
            'Content-Type'            => $mime,
            'Content-Disposition'     => $disposition,
        );

        /**
         * @hook tncfb3d_serve_headers
         * @param array  $headers   Associative array of HTTP response headers.
         * @param int    $post_id   Flipbook post ID.
         * @param string $file_type File type (pdf, image, video).
         * @return array
         */
        $headers = apply_filters( 'tncfb3d_serve_headers', $headers, $post_id, $file_type );

        foreach ( $headers as $header_name => $header_value ) {
            header( $header_name . ': ' . $header_value );
        }

        // Range request handling
        if ( isset( $_SERVER['HTTP_RANGE'] ) ) {
            $range = sanitize_text_field( wp_unslash( $_SERVER['HTTP_RANGE'] ) );
            if ( preg_match( '/bytes=(\d+)-(\d*)/', $range, $m ) ) {
                $start = (int) $m[1];
                $end   = ! empty( $m[2] ) ? (int) $m[2] : $file_size - 1;
                $end   = min( $end, $file_size - 1 );

                if ( $start > $end || $start >= $file_size ) {
                    header( 'HTTP/1.1 416 Range Not Satisfiable' );
                    header( 'Content-Range: bytes */' . $file_size );
                    exit;
                }
                $status = 206;
                header( 'HTTP/1.1 206 Partial Content' );
                header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $file_size );
            }
        }

        $length = $end - $start + 1;
        header( 'Accept-Ranges: bytes' );
        header( 'Content-Length: ' . $length );

        // Disable buffering for streaming
        if ( ob_get_level() ) {
            ob_end_clean();
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming binary PDF with byte-range support; WP_Filesystem reads whole file into memory.
        $fp = fopen( $path, 'rb' );
        if ( false === $fp ) {
            header( 'HTTP/1.1 500 Internal Server Error' );
            exit;
        }

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

        $remaining = $length;
        $chunk     = 8192;

        while ( ! feof( $fp ) && $remaining > 0 ) {
            $read = min( $chunk, $remaining );
            // phpcs:ignore WordPress.Security.EscapeOutput,WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Streaming binary PDF chunk.
            echo fread( $fp, $read );
            $remaining -= $read;
            if ( connection_aborted() ) {
                break;
            }
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the streaming handle opened above.
        fclose( $fp );
    }
}
