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

// Mark this as a messenger hook context - prevents T3Handler::updateT3QfqConfig() from running
// (TYPO3's ConfigurationManager uses its own path logic that doesn't work outside T3 context)
define('QFQ_MESSENGER_HOOK', true);

// Find app root by looking for typo3conf in the current path
$hookDir = __DIR__;
$typo3confPos = strpos($hookDir, '/typo3conf/');
$appRoot = null;
if ($typo3confPos !== false) {
    $appRoot = substr($hookDir, 0, $typo3confPos);
    // Change working directory to app root for relative path resolution
    chdir($appRoot);
}

// Load Path class FIRST (before autoload triggers other classes)
require_once(__DIR__ . '/../../Helper/Path.php');

// Set the app path BEFORE autoload loads other classes that might call absoluteApp()
if ($appRoot !== null) {
    \IMATHUZH\Qfq\Core\Helper\Path::setAbsoluteApp($appRoot);
}

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

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

/**
 * 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
     * - Channel access is controlled via SIP tokens (user can only access channels in their SIP)
     * - For multi-channel subscriptions: ALL channels must be authorized
     *
     * @return bool True if authorized, false otherwise
     */
    public function authorized(): bool
    {
        $channelString = $this->getChannel();

        // Split multi-channel subscriptions (e.g., "notification,imap,issue")
        $channels = $channelString ? explode(',', $channelString) : [];

        if (empty($channels)) {
            $this->log("Authorization denied: No channels specified");
            return false;
        }

        // Check if ALL channels are public
        $allPublic = true;
        foreach ($channels as $channel) {
            if (!$this->isPublicChannel($channel)) {
                $allPublic = false;
                break;
            }
        }

        if ($allPublic) {
            $this->log("Public channel access granted: $channelString");
            return true;
        }

        // At least one protected channel - require login
        $this->initSession();

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

        // Check channel-specific access for each non-public channel
        foreach ($channels as $channel) {
            if ($this->isPublicChannel($channel)) {
                continue; // Public channels don't need SQL check
            }

            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 channels '$channelString'");
        return true;
    }

    /**
     * Check if current user has access to the specific channel.
     *
     * Channel access is now controlled via SIP tokens - users can only access
     * channels that are encoded in their SIP. This method allows access for
     * any authenticated user as a fallback for non-SIP based requests.
     *
     * @param string|null $channel
     * @return bool
     */
    private function checkChannelAccess(?string $channel): bool
    {
        // SIP-based authentication: if user is logged in, allow access
        // The SIP token already contains the authorized channels
        return true;
    }

    /**
     * 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, query parameter, X-Qfq-Token header, or X-Original-URI
        // Priority: cookie > GET param > X-Qfq-Token header > X-Original-URI query string
        $sessionId = $_COOKIE[SESSION_NAME]
            ?? $_GET[SESSION_NAME]
            ?? ($_SERVER['HTTP_X_QFQ_TOKEN'] ?? null)
            ?? $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 = '';
    protected $baseUrl = '';

    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';
        }

        // Get baseUrl from Store for constructing publish URLs
        try {
            $store = Store::getInstance();
            $this->baseUrl = rtrim($store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM) ?? '', '/');
        } catch (\Throwable $e) {
            $this->baseUrl = '';
        }

        $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 $this->baseUrl . '/messenger/' . str_replace(
            ['$GROUP', '$CHANNEL'],
            [$this->group, $this->responseChannel],
            MESSENGER_PUBLISHER_CHANNEL
        );
    }

    public function getControllerUrl() {
        return $this->baseUrl . '/messenger/' . 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;
}
