<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Settings;

use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Settings;
use Ekesto\CacheAutopilot\Support\SupportLog;
use Ekesto\CacheAutopilot\AccessControl;

/**
 * REST controller for support debug operations.
 *
 * Separate from the main settings controller because support debug
 * operates on a different option and has immediate side effects
 * (file creation/deletion) that do not participate in the save/reset lifecycle.
 */
final class SupportDebugRestController
{
    /** @var string REST route base. */
    private const ROUTE = '/support-debug';

    /** @var string REST route for log file operations. */
    private const LOG_ROUTE = '/support-debug/log';

    /** @var string REST route for downloading the log file. */
    private const DOWNLOAD_ROUTE = '/support-debug/download';

    /** @var string REST route for minting a short-lived view token. */
    private const VIEW_TOKEN_ROUTE = '/support-debug/view-token';

    /** @var string REST route for viewing log files via tokenized URL. */
    private const VIEW_ROUTE = '/support-debug/view';

    /** @var int Chunk size for streaming downloads (64 KB). */
    private const STREAM_CHUNK_SIZE = 65536;

    /** @var int View token time-to-live in seconds (30 minutes). */
    private const VIEW_TOKEN_TTL_SECONDS = 1800;

    /**
     * Register REST API routes for support debug operations.
     *
     * @return void
     */
    public function registerRestRoutes(): void
    {
        if (defined('LSCWP_V')) {
            add_filter('rest_pre_dispatch', [$this, 'preventLscCaching'], 10, 3);
        }

        register_rest_route(Settings::REST_NAMESPACE, self::ROUTE, [
            [
                'methods' => 'GET',
                'callback' => [$this, 'getStatus'],
                'permission_callback' => static fn(): bool => AccessControl::userCanManage(),
            ],
            [
                'methods' => 'POST',
                'callback' => [$this, 'toggle'],
                'permission_callback' => static fn(): bool => AccessControl::userCanManage(),
                'args' => [
                    'enabled' => [
                        'type' => 'boolean',
                        'required' => true,
                        'sanitize_callback' => static fn($val): bool => (bool) $val,
                    ],
                ],
            ],
        ]);

        register_rest_route(Settings::REST_NAMESPACE, self::DOWNLOAD_ROUTE, [
            'methods' => 'GET',
            'callback' => [$this, 'download'],
            'permission_callback' => static fn(): bool => AccessControl::userCanManage(),
            'args' => [
                'inline' => [
                    'type' => 'boolean',
                    'required' => false,
                    'default' => false,
                    'sanitize_callback' => static fn($val): bool => (bool) $val,
                ],
            ],
        ]);

        register_rest_route(Settings::REST_NAMESPACE, self::LOG_ROUTE, [
            'methods' => 'DELETE',
            'callback' => [$this, 'deleteLog'],
            'permission_callback' => static fn(): bool => AccessControl::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, self::VIEW_TOKEN_ROUTE, [
            'methods' => 'POST',
            'callback' => [$this, 'createViewToken'],
            'permission_callback' => static fn(): bool => is_user_logged_in() && AccessControl::userCanManage(),
        ]);

        register_rest_route(Settings::REST_NAMESPACE, self::VIEW_ROUTE, [
            'methods' => 'GET',
            'callback' => [$this, 'viewViaToken'],
            // This route is opened via direct browser navigation in a new tab, not apiFetch.
            // Keep the route permission permissive so REST nonce/auth does not block tokenized requests
            // before they reach viewViaToken(), which owns all security checks for this endpoint.
            'permission_callback' => '__return_true',
            'args' => [
                'token' => [
                    'type' => 'string',
                    'required' => true,
                    'sanitize_callback' => static fn($value): string => (string) $value,
                ],
            ],
        ]);
    }

    /**
     * Prevent LiteSpeed Cache from caching support debug REST responses.
     *
     * @param mixed            $result  Response to replace the requested version with.
     * @param \WP_REST_Server  $server  Server instance.
     * @param \WP_REST_Request $request Request used to generate the response.
     * @return mixed Unmodified $result.
     */
    public function preventLscCaching($result, $server, $request)
    {
        $route = $request->get_route();
        if (str_starts_with($route, '/' . Settings::REST_NAMESPACE . '/support-debug')) {
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache third-party control hook.
            do_action('litespeed_control_set_nocache', 'ekesto-cache-invalidator support-debug REST');
        }
        return $result;
    }

    /**
     * GET /support-debug — return current status.
     *
     * @param \WP_REST_Request $request REST request object.
     * @return \WP_REST_Response
     */
    public function getStatus(\WP_REST_Request $request): \WP_REST_Response
    {
        $status = SupportLog::getStatusForUi();

        return $this->applyNoCache(new \WP_REST_Response($status, 200));
    }

    /**
     * POST /support-debug — toggle support debug mode.
     *
     * @param \WP_REST_Request $request REST request object.
     * @return \WP_REST_Response
     */
    public function toggle(\WP_REST_Request $request): \WP_REST_Response
    {
        $enabled = (bool) $request->get_param('enabled');

        if ($enabled) {
            SupportLog::enable();
        } else {
            SupportLog::disable();
        }

        $status = SupportLog::getStatusForUi();

        return $this->applyNoCache(new \WP_REST_Response($status, 200));
    }

    /**
     * GET /support-debug/download — stream the log file as a download.
     *
     * @param \WP_REST_Request $request REST request object.
     * @return \WP_REST_Response|\WP_Error
     */
    public function download(\WP_REST_Request $request)
    {
        $logPath = SupportLog::getActiveLogPath();

        if ($logPath === null || !is_file($logPath)) {
            return new \WP_Error(
                'no_log_file',
                __('No log file available for download.', 'cache-autopilot'),
                ['status' => 404]
            );
        }

        $inline = (bool) $request->get_param('inline');
        $fileSize = (int) filesize($logPath);
        $filename = basename($logPath);
        $disposition = $inline ? 'inline' : 'attachment';

        // Stream the file in chunks to avoid memory spikes.
        header('Content-Type: text/plain; charset=utf-8');
        header('Content-Disposition: ' . $disposition . '; filename="' . $filename . '"');
        header('Content-Length: ' . $fileSize);
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, private');
        header('Pragma: no-cache');
        header('Expires: 0');
        header('X-Content-Type-Options: nosniff');

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
        $handle = fopen($logPath, 'rb');
        if ($handle === false) {
            return new \WP_Error(
                'file_read_error',
                __('Unable to read log file.', 'cache-autopilot'),
                ['status' => 500]
            );
        }

        // Flush output buffer if active.
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
        while (!feof($handle)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
            $chunk = fread($handle, self::STREAM_CHUNK_SIZE);
            if ($chunk === false) {
                break;
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary file stream
            echo $chunk;
            flush();
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
        fclose($handle);

        exit;
    }

    /**
     * DELETE /support-debug/log — delete the log file without disabling debug mode.
     *
     * @param \WP_REST_Request $request REST request object.
     * @return \WP_REST_Response
     */
    public function deleteLog(\WP_REST_Request $request): \WP_REST_Response
    {
        SupportLog::deleteLogFile();

        $status = SupportLog::getStatusForUi();

        return $this->applyNoCache(new \WP_REST_Response($status, 200));
    }

    /**
     * POST /support-debug/view-token — mint a short-lived token for refreshable view URLs.
     *
     * @param \WP_REST_Request $request REST request object.
     * @return \WP_REST_Response
     */
    public function createViewToken(\WP_REST_Request $request): \WP_REST_Response
    {
        unset($request);

        $userId = get_current_user_id();
        $expiresAt = time() + self::VIEW_TOKEN_TTL_SECONDS;
        $claims = [
            'uid' => $userId,
            'exp' => $expiresAt,
        ];
        $sig = $this->signViewTokenClaims($claims);

        $payload = [
            'uid' => $claims['uid'],
            'exp' => $claims['exp'],
            'sig' => $sig,
        ];

        $token = $this->encodeViewTokenPayload($payload);
        if ($token === '') {
            return new \WP_REST_Response(
                [
                    'code' => 'token_generation_failed',
                    'message' => __('Failed to create view token.', 'cache-autopilot'),
                ],
                500
            );
        }

        return $this->applyNoCache(
            new \WP_REST_Response(
                [
                    'token' => $token,
                    'expires_in' => self::VIEW_TOKEN_TTL_SECONDS,
                ],
                200
            )
        );
    }

    /**
     * GET /support-debug/view — validate token and stream inline log for browser refresh support.
     *
     * @param \WP_REST_Request $request REST request object.
     * @return \WP_REST_Response|\WP_Error
     */
    public function viewViaToken(\WP_REST_Request $request)
    {
        $token = (string) $request->get_param('token');
        if ($token === '') {
            return new \WP_Error(
                'missing_view_token',
                __('Missing view token.', 'cache-autopilot'),
                ['status' => 403]
            );
        }

        $payload = $this->decodeViewTokenPayload($token);
        if ($payload === null || !isset($payload['uid'], $payload['exp'], $payload['sig'])) {
            return new \WP_Error(
                'invalid_view_token',
                __('Invalid view token.', 'cache-autopilot'),
                ['status' => 403]
            );
        }

        $userId = (int) $payload['uid'];
        $expiresAt = (int) $payload['exp'];
        $signature = (string) $payload['sig'];

        if (time() > $expiresAt) {
            return new \WP_Error(
                'expired_view_token',
                __('View token expired.', 'cache-autopilot'),
                ['status' => 403]
            );
        }

        $expected = $this->signViewTokenClaims([
            'uid' => $userId,
            'exp' => $expiresAt,
        ]);
        if (!hash_equals($expected, $signature)) {
            return new \WP_Error(
                'invalid_view_token_signature',
                __('Invalid view token signature.', 'cache-autopilot'),
                ['status' => 403]
            );
        }

        $validatedCookieUserId = (int) wp_validate_auth_cookie('', 'logged_in');
        if ($validatedCookieUserId <= 0 || $validatedCookieUserId !== $userId) {
            return new \WP_Error(
                'unauthorized_view_token_user',
                __('Unauthorized user context.', 'cache-autopilot'),
                ['status' => 403]
            );
        }

        if (!AccessControl::userCanManage((int) $validatedCookieUserId)) {
            return new \WP_Error(
                'forbidden_view_token_capability',
                __('You do not have permission to view this log.', 'cache-autopilot'),
                ['status' => 403]
            );
        }

        nocache_headers();
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        header('Expires: 0');
        header('X-Accel-Expires: 0');

        $request->set_param('inline', true);
        return $this->download($request);
    }

    /**
     * Encode a token payload as base64 JSON.
     *
     * @param array<string, int|string> $payload Token payload.
     * @return string
     */
    private function encodeViewTokenPayload(array $payload): string
    {
        $json = wp_json_encode($payload);
        if (!is_string($json)) {
            return '';
        }

        return base64_encode($json);
    }

    /**
     * Decode a base64 JSON token payload.
     *
     * @param string $token Encoded token payload.
     * @return array<string, mixed>|null
     */
    private function decodeViewTokenPayload(string $token): ?array
    {
        $decoded = base64_decode($token, true);
        if (!is_string($decoded) || $decoded === '') {
            return null;
        }

        $payload = json_decode($decoded, true);
        if (!is_array($payload)) {
            return null;
        }

        return $payload;
    }

    /**
     * Sign token claims to prevent tampering.
     *
     * @param array{uid:int,exp:int} $claims Token claims.
     * @return string
     */
    private function signViewTokenClaims(array $claims): string
    {
        $json = wp_json_encode($claims);
        if (!is_string($json)) {
            return '';
        }

        return hash_hmac('sha256', $json, wp_salt('auth'));
    }

    /**
     * Apply no-cache headers to a REST response.
     *
     * @param \WP_REST_Response $response Response to harden.
     * @return \WP_REST_Response
     */
    private function applyNoCache(\WP_REST_Response $response): \WP_REST_Response
    {
        if (!defined('DONOTCACHEPAGE')) {
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress/cache-plugin global constant used to prevent caching of authenticated REST responses.
            define('DONOTCACHEPAGE', true);
        }

        add_filter('rest_send_nocache_headers', '__return_true', PHP_INT_MAX);

        $response->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, private');
        $response->header('Pragma', 'no-cache');
        $response->header('Expires', '0');

        $headers = $response->get_headers();
        $vary = isset($headers['Vary']) ? (string) $headers['Vary'] : '';
        if ($vary === '') {
            $response->header('Vary', 'Cookie');
        } elseif (stripos($vary, 'Cookie') === false) {
            $response->header('Vary', $vary . ', Cookie');
        }

        return $response;
    }
}
