<?php declare(strict_types=1);
/**
 * @package messenger
 * @author kputyr
 * @date 17.04.2025
 */
namespace IMATHUZH\Qfq\Core\Messenger\Hooks;

require_once(__DIR__ . '/../../../../vendor/autoload.php');
require_once(__DIR__ . '/../../Constants.php');

use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Helper\Path;
use IMATHUZH\Qfq\Core\Store\Store;

// Set absolute app path for hooks running outside TYPO3 context
// Find app root by looking for typo3conf in the current path
$hookDir = __DIR__;
$typo3confPos = strpos($hookDir, '/typo3conf/');
if ($typo3confPos !== false) {
    Path::setAbsoluteApp(substr($hookDir, 0, $typo3confPos));
}

/**
 * An implementation of the helper request class for nchan hooks
 *
 * This class handles authentication for nchan WebSocket connections
 * by checking the QFQ session and optionally channel-specific access via SQL.
 */
class NchanRequest extends AbstractNchanRequest
{
    private ?int $feUserUid = null;
    private ?string $feUser = null;
    private ?string $feUserGroup = null;

    /**
     * Check if the current request is authorized.
     *
     * Authorization rules:
     * - Public channels (prefix 'public-'): No authentication required
     * - All other channels: Require valid QFQ session with feUserUid
     * - If messengerChannelAccessSql is configured: Check channel-specific access via SQL
     *
     * @return bool True if authorized, false otherwise
     */
    public function authorized(): bool
    {
        $channel = $this->getChannel();

        // Public channels - no authentication required
        if ($this->isPublicChannel($channel)) {
            $this->log("Public channel access granted: $channel");
            return true;
        }

        // Protected channels require login
        $this->initSession();

        if (empty($this->feUserUid)) {
            $this->log("Authorization denied: No feUserUid for protected channel '$channel'");
            return false;
        }

        // Check channel-specific access if SQL is configured
        if (!$this->checkChannelAccess($channel)) {
            $this->log("Authorization denied: User '{$this->feUser}' has no access to channel '$channel'");
            return false;
        }

        $this->log("Authorization granted for user: {$this->feUser} (UID: {$this->feUserUid}) on channel '$channel'");
        return true;
    }

    /**
     * Check if current user has access to the specific channel.
     *
     * If messengerChannelAccessSql is configured in qfq config, executes that SQL
     * to check access. Otherwise, allows access by default.
     *
     * @param string|null $channel
     * @return bool
     */
    private function checkChannelAccess(?string $channel): bool
    {
        if ($channel === null || empty($this->feUser)) {
            return true; // No channel or user - skip SQL check
        }

        try {
            $store = Store::getInstance();
            $accessSql = $store::getVar(SYSTEM_MESSENGER_CHANNEL_ACCESS_SQL, STORE_SYSTEM);

            if (empty($accessSql)) {
                // No SQL configured - allow access by default (backwards compatibility)
                $this->log("No channel access SQL configured, allowing access");
                return true;
            }

            $db = new Database();

            // Variable substitution (escape values)
            $escapedFeUser = $db->realEscapeString($this->feUser);
            $escapedChannel = $db->realEscapeString($channel);

            $sql = preg_replace('/\{\{feUser(:[A-Z0-9]+)?\}\}/', $escapedFeUser, $accessSql);
            $sql = preg_replace('/\{\{channelName(:[A-Z0-9]+)?\}\}/', $escapedChannel, $sql);

            $result = $db->sql($sql);
            $hasAccess = !empty($result);

            $this->log("Channel access check for '{$this->feUser}' on '$channel': " . ($hasAccess ? 'granted' : 'denied'));
            return $hasAccess;

        } catch (\Throwable $e) {
            $this->log("Channel access check failed: " . $e->getMessage());
            return false;
        }
    }

    /**
     * Check if a channel is public (no authentication required).
     *
     * Public channels use the 'public-' prefix naming convention.
     *
     * @param string|null $channel
     * @return bool
     */
    private function isPublicChannel(?string $channel): bool
    {
        if ($channel === null) {
            return false;
        }

        return str_starts_with($channel, MESSENGER_PUBLIC_CHANNEL_PREFIX);
    }

    /**
     * Initialize PHP session and read QFQ session data.
     */
    private function initSession(): void
    {
        // Don't start session if already active
        if (session_status() === PHP_SESSION_ACTIVE) {
            $this->readSessionData();
            return;
        }

        // Get session ID from cookie or query parameter (for WebSocket auth where cookie path may not match)
        $sessionId = $_COOKIE[SESSION_NAME] ?? $_GET[SESSION_NAME] ?? $this->getTokenFromOriginalUri();

        if (empty($sessionId)) {
            $this->log("No session cookie or token found");
            return;
        }

        // Configure session
        session_name(SESSION_NAME);

        // Set session ID from cookie or query parameter
        session_id($sessionId);

        // Start session in read-only mode (no write needed)
        session_start([
            'read_and_close' => true,
        ]);

        $this->readSessionData();
    }

    /**
     * Read user data from QFQ session.
     */
    private function readSessionData(): void
    {
        if (isset($_SESSION[SESSION_NAME][SESSION_FE_USER_UID])) {
            $this->feUserUid = (int)$_SESSION[SESSION_NAME][SESSION_FE_USER_UID];
        }
        if (isset($_SESSION[SESSION_NAME][SESSION_FE_USER])) {
            $this->feUser = (string)$_SESSION[SESSION_NAME][SESSION_FE_USER];
        }
        if (isset($_SESSION[SESSION_NAME][SESSION_FE_USER_GROUP])) {
            $this->feUserGroup = (string)$_SESSION[SESSION_NAME][SESSION_FE_USER_GROUP];
        }

        $this->log("Session data: feUserUid={$this->feUserUid}, feUser={$this->feUser}");
    }

    /**
     * Get a unique client ID based on session and timestamp.
     *
     * @return string Unique client identifier
     */
    public function getClientId(): string
    {
        $base = $this->feUserUid ?? bin2hex(random_bytes(4));
        return $base . '-' . (string)time();
    }

    /**
     * Get the current user's UID.
     *
     * @return int|null
     */
    public function getFeUserUid(): ?int
    {
        return $this->feUserUid;
    }

    /**
     * Get the current user's username.
     *
     * @return string|null
     */
    public function getFeUser(): ?string
    {
        return $this->feUser;
    }

    /**
     * Get the current user's groups.
     *
     * @return string|null
     */
    public function getFeUserGroup(): ?string
    {
        return $this->feUserGroup;
    }

    /**
     * Extract session token from X-Original-URI header query string.
     * Fallback when query params aren't passed directly to the hook.
     *
     * @return string|null
     */
    private function getTokenFromOriginalUri(): ?string
    {
        if (empty($this->requestUri)) {
            return null;
        }

        $queryString = parse_url($this->requestUri, PHP_URL_QUERY);
        if (empty($queryString)) {
            return null;
        }

        parse_str($queryString, $params);
        return $params[SESSION_NAME] ?? null;
    }

    /**
     * Log a message for debugging.
     *
     * @param string $message
     */
    private function log(string $message): void
    {
        file_put_contents(
            MESSENGER_LOG_FILE,
            date('Y-m-d H:i:s') . " - " . $message . "\n",
            FILE_APPEND
        );
    }
}

/**
 * A base class for nchan requests
 */
abstract class AbstractNchanRequest
{
    public $nchanVersion;
    public $requestUri;
    public $subscriberType;
    protected $responseUrl = '';
    protected $group = '';
    protected $responseChannel = '';

    public function __construct(array $headers = null) {
        if (!$headers) $headers = $_SERVER;

        $this->nchanVersion = $headers[MESSENGER_HEADER_NCHAN_VERSION] ?? null;
        $this->requestUri = $headers[MESSENGER_HEADER_ORIGINAL_URI] ?? null;
        $this->subscriberType = $headers[MESSENGER_HEADER_SUBSCRIBER_TYPE] ?? null;

        // Handle case where we're testing directly
        if ($this->requestUri === null) {
            $this->requestUri = '/messenger/default/sub-channel1';
        }

        $this->generateUrls();
    }

    public function close(int $code, string $msg='') {
        http_response_code($code);
        if ($msg) echo $msg;

        // Only call fastcgi_finish_request if it exists
        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        }
    }

    protected function generateUrls() {
        // Find the channel group and the main subscriber channel
        if ($this->requestUri !== null && preg_match(MESSENGER_CHANNEL_PATTERN, $this->requestUri, $matches)) {
            $this->group = $matches[1];

            // Handle different URL formats
            if (isset($matches[3])) {
                // Format: /messenger/group/pub-channels/sub-channels
                $this->responseChannel = $matches[3];
            } else {
                // Format: /messenger/group/sub-channels
                $this->responseChannel = $matches[2] ?? null;
            }
        } else {
            // If we can't parse the URL, use defaults
            $this->group = 'default';
            $this->responseChannel = 'channel1';
        }
    }

    /**
     * Get the channel name from the request URI.
     *
     * @return string|null
     */
    public function getChannel(): ?string
    {
        return $this->responseChannel;
    }

    /**
     * Get the group name from the request URI.
     *
     * @return string
     */
    public function getGroup(): string
    {
        return $this->group;
    }

    public function getReplyUrl() {
        if (empty($this->responseChannel)) {
            $this->responseChannel = 'default';
        }

        return MESSENGER_LOCALHOST_BASE . str_replace(
            ['$GROUP', '$CHANNEL'],
            [$this->group, $this->responseChannel],
            MESSENGER_PUBLISHER_CHANNEL
        );
    }

    public function getControllerUrl() {
        return MESSENGER_LOCALHOST_BASE . str_replace('$GROUP', $this->group, MESSENGER_CONTROLLER_CHANNEL);
    }

    public function send(array $payload, string $url) {
        @file_get_contents($url, false, stream_context_create(['http' => [
            'method' => 'POST',
            'ignore_errors' => true,
            'header' => "Accept: text/json\nContent-Type: text/json",
            'content' => json_encode($payload)
        ]]));
    }

    public function respond(array $payload) {
        $this->send($payload, $this->getReplyUrl());
    }

    public function notifyController(array $payload) {
        $this->send($payload, $this->getControllerUrl());
    }

    abstract public function authorized(): bool;
    abstract public function getClientId(): string;
}
