"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.RabbitMqSubscriber = void 0;
const common_1 = require("@nestjs/common");
const amqp = __importStar(require("amqplib"));
const common_2 = require("./common");
class ConsumerProcessingTimeoutError extends Error {
    constructor(queueName, messageId, timeoutMs) {
        super(`Subscriber processing timed out after ${timeoutMs}ms for queue ${queueName} and messageId ${messageId}`);
        this.queueName = queueName;
        this.messageId = messageId;
        this.timeoutMs = timeoutMs;
        this.name = 'ConsumerProcessingTimeoutError';
    }
}
class RabbitMqSubscriber {
    constructor(mqMessageService, mqMessageQueueService) {
        this.mqMessageService = mqMessageService;
        this.mqMessageQueueService = mqMessageQueueService;
        this.connection = null;
        this.channel = null;
        this.consumerTag = null;
        this.reconnectPromise = null;
        this.reconnectAttempt = 0;
        this.stopping = false;
        this.url = process.env.QUEUES_RABBIT_MQ_URL;
        this.serviceRole = process.env.QUEUES_SERVICE_ROLE || 'both';
        if (!this.url) {
            this.logger.debug('RabbitMqPublisher url is not defined in the environment variables');
        }
        if (!process.env.QUEUES_SERVICE_ROLE) {
            this.logger.debug('QUEUES_SERVICE_ROLE is not defined. Defaulting RabbitMqSubscriber service role to "both".');
        }
    }
    get loggerContext() {
        return this.constructor.name;
    }
    get logger() {
        if (!this._loggerInstance) {
            this._loggerInstance = new common_1.Logger(this.loggerContext);
        }
        return this._loggerInstance;
    }
    shouldPersistToDatabase() {
        return this.options().persistToDatabase ?? true;
    }
    async establishConnection() {
        const url = new URL(this.url);
        const connection = await amqp.connect({
            protocol: url.protocol.replace(':', ''),
            hostname: url.hostname,
            port: parseInt(url.port),
            username: url.username,
            password: decodeURIComponent(url.password),
            frameMax: 131072,
            heartbeat: 30,
        });
        return connection;
    }
    async onModuleInit() {
        const defaultBroker = process.env.QUEUES_DEFAULT_BROKER || 'database';
        const solidCliRunning = process.env.SOLID_CLI_RUNNING || "false";
        const queueNameRegex = (process.env.QUEUES_QUEUE_NAME_REGEX_TO_ENABLE || '').trim();
        const roleAllowed = ['both', 'subscriber'].includes(this.serviceRole);
        if (!roleAllowed) {
            this.logger.log(`RabbitMqSubscriber is disabled because QUEUES_SERVICE_ROLE is "${this.serviceRole}". Expected "both" or "subscriber".`);
            return;
        }
        if (this.url && solidCliRunning === "false" && defaultBroker === 'rabbitmq') {
            const options = this.options();
            const queueName = options.queueName;
            if (queueNameRegex && queueNameRegex !== "all") {
                try {
                    const regex = new RegExp(queueNameRegex);
                    if (!regex.test(queueName)) {
                        this.logger.log(`RabbitMqSubscriber for queue ${queueName} is disabled because it does not match QUEUES_QUEUE_NAME_REGEX_TO_ENABLE=${queueNameRegex}`);
                        return;
                    }
                }
                catch (error) {
                    this.logger.error(`Invalid QUEUES_QUEUE_NAME_REGEX_TO_ENABLE regex "${queueNameRegex}". Subscriber for queue ${queueName} will not start.`);
                    return;
                }
            }
            const namespacedQueueName = (0, common_2.buildNamespacedQueueName)(queueName);
            try {
                await this.connectAndConsume(namespacedQueueName);
            }
            catch (err) {
                this.logger.error(`Failed to connect to RabbitMQ for queue ${namespacedQueueName}: ${err.message}`, err.stack);
                this.triggerReconnect(namespacedQueueName, 'initial connection failure');
            }
            this.logger.log(`RabbitMqSubscriber ready to consume messages: ${JSON.stringify(options)} and url: ${this.url}`);
        }
    }
    async connectAndConsume(queueName) {
        await this.cleanup();
        this.logger.log(`RabbitMqSubscriber in connectAndConsume for queue: ${queueName} and url: ${this.url}`);
        const options = this.options();
        const prefetch = options.prefetch ?? 1;
        if (prefetch < 1) {
            throw new Error(`RabbitMqSubscriber prefetch must be >= 1 for queue ${queueName}`);
        }
        const processingTimeoutMs = this.resolveProcessingTimeoutMs();
        if (processingTimeoutMs > 0) {
            this.logger.log(`RabbitMqSubscriber using processing timeout ${processingTimeoutMs}ms for queue ${queueName}`);
        }
        let connection;
        try {
            connection = await this.establishConnection();
        }
        catch (err) {
            this.logger.error(`Failed to connect to RabbitMQ for queue ${queueName}: ${err.message}`, err.stack);
            throw err;
        }
        this.connection = connection;
        connection.on('error', (err) => {
            if (connection !== this.connection)
                return;
            this.logger.error(`RabbitMqSubscriber connection error for queue ${queueName}: ${err.message}`);
        });
        connection.on('close', () => {
            if (connection !== this.connection)
                return;
            this.logger.warn(`RabbitMqSubscriber connection closed for queue ${queueName}`);
            this.triggerReconnect(queueName, 'connection closed');
        });
        const channel = await connection.createChannel();
        this.channel = channel;
        channel.on('error', (err) => {
            if (channel !== this.channel)
                return;
            this.logger.error(`RabbitMqSubscriber channel error for queue ${queueName}: ${err.message}`);
        });
        channel.on('close', () => {
            if (channel !== this.channel)
                return;
            this.logger.warn(`RabbitMqSubscriber channel closed for queue ${queueName}`);
            this.triggerReconnect(queueName, 'channel closed');
        });
        await channel.prefetch(prefetch);
        const exchangeName = `${queueName}.exchange`;
        const routingKey = `${queueName}.routing-key`;
        const retryQueue = `${queueName}.retry`;
        const failedQueue = `${queueName}.failed`;
        await channel.assertExchange(exchangeName, 'direct', {});
        await channel.assertQueue(queueName, {});
        await channel.bindQueue(queueName, exchangeName, routingKey);
        await channel.assertQueue(retryQueue, {
            arguments: {
                'x-dead-letter-exchange': exchangeName,
                'x-dead-letter-routing-key': routingKey,
            }
        });
        await channel.assertQueue(failedQueue, {});
        const consumeResult = await channel.consume(queueName, async (rawMessage) => {
            if (!rawMessage) {
                return;
            }
            let message = null;
            try {
                const messageContentString = rawMessage.content.toString();
                message = JSON.parse(messageContentString);
                this.logger.debug(`rabbitmq subscriber received message with id: ${message.messageId} for queue ${queueName}`);
            }
            catch (error) {
                this.logger.error(`Invalid JSON message on queue ${queueName}: ${error.message}`);
                await this.publishToFailedQueue(queueName, rawMessage.content, channel, error);
                channel.ack(rawMessage);
                return;
            }
            if (!message.retryCount)
                message.retryCount = 0;
            if (!message.retryInterval)
                message.retryInterval = 1000;
            if (!message.currentRetry)
                message.currentRetry = 0;
            try {
                await this.processMessage(message, rawMessage, channel, queueName);
            }
            catch (error) {
                await this.handleProcessingError(message, rawMessage, channel, error, queueName);
            }
        }, { noAck: false });
        this.consumerTag = consumeResult.consumerTag;
    }
    async handleProcessingError(message, rawMessage, channel, error, queueName) {
        const errorMessage = error?.message || String(error);
        this.logger.error(`Error processing message on queue ${queueName}: ${errorMessage}`, error?.stack);
        if (message.currentRetry < message.retryCount) {
            if (this.shouldPersistToDatabase()) {
                await this.updateStatusInDatabase('retrying', message);
            }
            message.currentRetry++;
            const retryQueue = `${queueName}.retry`;
            const payload = Buffer.from(JSON.stringify(message));
            channel.sendToQueue(retryQueue, payload, {
                expiration: String(message.retryInterval || 1000),
                headers: {
                    'x-error': errorMessage,
                }
            });
            channel.ack(rawMessage);
            this.logger.warn(`Retrying message (${message.currentRetry}/${message.retryCount}) after ${message.retryInterval}ms on queue ${queueName}`);
            return;
        }
        if (this.shouldPersistToDatabase()) {
            await this.updateStatusInDatabase('failed', message, errorMessage, '');
        }
        channel.ack(rawMessage);
        await this.publishToFailedQueue(queueName, Buffer.from(JSON.stringify(message)), channel, error);
        this.logger.error(`Message failed after ${message.retryCount} attempts on queue ${queueName}: ${errorMessage}`);
    }
    async publishToFailedQueue(queueName, payload, channel, error) {
        const failedQueue = `${queueName}.failed`;
        const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
        const errorMessage = error?.message || String(error || '');
        try {
            channel.sendToQueue(failedQueue, body, errorMessage ? {
                headers: { 'x-error': errorMessage }
            } : undefined);
        }
        catch (err) {
            this.logger.error(`Failed to publish to failed queue ${failedQueue}: ${err.message}`);
        }
    }
    triggerReconnect(queueName, reason) {
        if (this.stopping)
            return;
        if (this.reconnectPromise)
            return;
        this.reconnectPromise = this.reconnectLoop(queueName, reason)
            .finally(() => {
            this.reconnectPromise = null;
        });
    }
    async reconnectLoop(queueName, reason) {
        this.logger.warn(`RabbitMqSubscriber reconnecting for queue ${queueName}: ${reason}`);
        while (!this.stopping) {
            try {
                await this.connectAndConsume(queueName);
                this.reconnectAttempt = 0;
                this.logger.log(`RabbitMqSubscriber reconnected for queue ${queueName}`);
                return;
            }
            catch (err) {
                this.reconnectAttempt += 1;
                const delay = this.backoff();
                this.logger.warn(`RabbitMqSubscriber reconnect failed for queue ${queueName}; retrying in ${delay}ms`);
                await this.sleep(delay);
            }
        }
    }
    async cleanup() {
        const channel = this.channel;
        const connection = this.connection;
        const consumerTag = this.consumerTag;
        this.channel = null;
        this.connection = null;
        this.consumerTag = null;
        if (channel) {
            try {
                if (consumerTag) {
                    await channel.cancel(consumerTag);
                }
            }
            catch (_) {
            }
            try {
                await channel.close();
            }
            catch (_) {
            }
        }
        if (connection) {
            try {
                await connection.close();
            }
            catch (_) {
            }
        }
    }
    sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    backoff() {
        const baseMs = 1000;
        const maxMs = 30_000;
        const exp = Math.min(maxMs, baseMs * Math.pow(2, this.reconnectAttempt));
        const jitter = Math.floor(Math.random() * (exp * 0.2));
        return Math.min(maxMs, exp + jitter);
    }
    async processMessage(message, rawMessage, channel, queueName) {
        if (this.shouldPersistToDatabase()) {
            await this.updateStatusInDatabase('started', message);
        }
        const result = await this.subscribeWithTimeout(message, queueName);
        channel.ack(rawMessage);
        if (this.shouldPersistToDatabase()) {
            await this.updateStatusInDatabase('succeeded', message, '', result ? JSON.stringify(result, null, 2) : '');
        }
    }
    async updateStatusInDatabase(stage, message, error = '', result = '') {
        try {
            const mqMessage = await this.mqMessageService.repo.findOne({
                where: {
                    messageId: message.messageId,
                }
            });
            if (mqMessage) {
                const updatedFields = {
                    stage: stage
                };
                if (stage === 'failed' || stage === 'succeeded') {
                    updatedFields['finishedAt'] = new Date();
                    updatedFields['elapsedMillis'] = updatedFields['finishedAt'].getTime() - mqMessage.startedAt.getTime();
                }
                if (stage === 'succeeded') {
                    updatedFields['output'] = result;
                }
                if (stage === 'failed') {
                    updatedFields['error'] = error;
                }
                await this.mqMessageService.repo.update(mqMessage.id, updatedFields);
            }
        }
        catch (error) {
            this.logger.error(error.message, error.stack);
        }
    }
    resolveProcessingTimeoutMs() {
        const brokerTimeoutMs = this.parsePositiveInt(process.env.QUEUES_RABBITMQ_CONSUMER_ACK_TIMEOUT_MS, 30 * 60 * 1000);
        const defaultSoftTimeoutMs = Math.max(1_000, brokerTimeoutMs - 30_000);
        return this.parsePositiveInt(process.env.QUEUES_RABBITMQ_SUBSCRIBER_PROCESSING_TIMEOUT_MS, defaultSoftTimeoutMs);
    }
    parsePositiveInt(value, fallback) {
        if (!value)
            return fallback;
        const parsed = Number.parseInt(value, 10);
        return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
    }
    async subscribeWithTimeout(message, queueName) {
        const timeoutMs = this.resolveProcessingTimeoutMs();
        const messageId = message?.messageId || 'unknown';
        if (timeoutMs <= 0) {
            return this.subscribe(message);
        }
        let timedOut = false;
        let timeoutHandle = null;
        const subscribePromise = Promise.resolve()
            .then(() => this.subscribe(message))
            .catch((error) => {
            if (timedOut) {
                this.logger.error(`Subscriber promise rejected after timeout for queue ${queueName} and messageId ${messageId}: ${error?.message || String(error)}`, error?.stack);
                return undefined;
            }
            throw error;
        });
        const timeoutPromise = new Promise((_, reject) => {
            timeoutHandle = setTimeout(() => {
                timedOut = true;
                reject(new ConsumerProcessingTimeoutError(queueName, messageId, timeoutMs));
            }, timeoutMs);
        });
        try {
            return await Promise.race([subscribePromise, timeoutPromise]);
        }
        catch (error) {
            const errorMessage = error?.message || String(error);
            this.logger.error(`Subscriber execution failed for queue ${queueName} and messageId ${messageId}: ${errorMessage}`, error?.stack);
            throw error;
        }
        finally {
            if (timeoutHandle) {
                clearTimeout(timeoutHandle);
            }
        }
    }
}
exports.RabbitMqSubscriber = RabbitMqSubscriber;
//# sourceMappingURL=rabbitmq-subscriber.service.js.map