"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.OtpService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const crypto_1 = require("crypto");
const integration_config_service_1 = require("../integrations/integration-config.service");
const otp_types_1 = require("./otp.types");
let OtpService = class OtpService {
    config;
    integrations;
    challenges = new Map();
    runtime = {};
    constructor(config, integrations) {
        this.config = config;
        this.integrations = integrations;
    }
    async sendOtp(phone, name) {
        const providers = this.providerOrder();
        if (!providers.length) {
            throw new common_1.BadRequestException('At least one OTP provider must be enabled.');
        }
        const code = providers[0] === otp_types_1.OtpDeliveryProvider.DEV ? this.devCode() : this.generateCode();
        const ttlSeconds = this.ttlSeconds();
        const message = this.messageFor(code, name);
        const failures = [];
        for (const provider of providers) {
            try {
                await this.sendWithProvider(provider, phone, code, message);
                this.challenges.set(phone, {
                    hash: this.hash(phone, code),
                    expiresAt: Date.now() + ttlSeconds * 1000,
                    attempts: 0
                });
                return {
                    message: 'OTP sent',
                    provider,
                    providerPriority: providers,
                    expiresInSeconds: ttlSeconds,
                    ...(this.shouldExposeDevCode(provider) ? { devOtp: code } : {})
                };
            }
            catch (error) {
                failures.push(`${provider}: ${this.errorMessage(error)}`);
            }
        }
        throw new common_1.BadGatewayException(`All enabled OTP providers failed: ${failures.join('; ')}`);
    }
    verifyOtp(phone, otp) {
        const normalizedOtp = otp.trim();
        const challenge = this.challenges.get(phone);
        if (challenge) {
            if (Date.now() > challenge.expiresAt) {
                this.challenges.delete(phone);
                return false;
            }
            challenge.attempts += 1;
            if (challenge.attempts > this.maxAttempts()) {
                this.challenges.delete(phone);
                return false;
            }
            const valid = challenge.hash === this.hash(phone, normalizedOtp);
            if (valid) {
                this.challenges.delete(phone);
            }
            return valid;
        }
        return this.enabledProviders().includes(otp_types_1.OtpDeliveryProvider.DEV) && normalizedOtp === this.devCode();
    }
    getRuntimeConfig() {
        const provider = this.provider();
        const enabledProviders = this.enabledProviders();
        const providerPriority = this.providerOrder();
        return {
            provider,
            enabledProviders,
            providerPriority,
            availableProviders: Object.values(otp_types_1.OtpDeliveryProvider),
            providers: Object.values(otp_types_1.OtpDeliveryProvider).map((item) => ({
                provider: item,
                enabled: enabledProviders.includes(item),
                priority: providerPriority.includes(item) ? providerPriority.indexOf(item) + 1 : null,
                configured: this.providerConfigured(item),
                activePrimary: item === provider
            })),
            codeLength: this.codeLength(),
            expiresInSeconds: this.ttlSeconds(),
            maxAttempts: this.maxAttempts(),
            devOtpExposed: this.shouldExposeDevCode(provider),
            termii: {
                configured: this.hasValue(this.termiiApiKey()),
                baseUrl: this.termiiBaseUrl(),
                senderId: this.termiiSenderId(),
                channel: this.termiiChannel()
            },
            africasTalking: {
                configured: this.hasValue(this.africasTalkingApiKey()),
                baseUrl: this.africasTalkingBaseUrl(),
                username: this.africasTalkingUsername(),
                senderId: this.africasTalkingSenderId()
            },
            customHttp: {
                configured: this.hasValue(this.customHttpUrl()),
                url: this.maskUrl(this.customHttpUrl()),
                method: this.customHttpMethod(),
                hasHeaders: this.hasValue(this.customHttpHeadersJson()),
                hasBodyTemplate: this.hasValue(this.customHttpBodyTemplate())
            },
            note: 'Runtime changes are in-memory. Mirror production values into .env before deployment or restart.'
        };
    }
    updateRuntimeConfig(dto) {
        this.assertProviders(dto.enabledProviders, 'enabledProviders');
        this.assertProviders(dto.providerPriority, 'providerPriority');
        if (dto.enabledProviders && dto.enabledProviders.length === 0) {
            throw new common_1.BadRequestException('At least one OTP provider must be enabled.');
        }
        const previousEnabled = this.enabledProviders();
        const previousPriority = this.providerPriority();
        Object.entries(dto).forEach(([key, value]) => {
            if (value !== undefined && value !== null && value !== '') {
                this.runtime[key] = value;
            }
        });
        if (dto.provider && !dto.enabledProviders) {
            this.runtime.enabledProviders = Array.from(new Set([dto.provider, ...previousEnabled]));
        }
        if (dto.provider && !dto.providerPriority) {
            this.runtime.providerPriority = [dto.provider, ...previousPriority.filter((item) => item !== dto.provider)];
        }
        return this.getRuntimeConfig();
    }
    provider() {
        return this.providerOrder()[0] ?? this.configuredPrimaryProvider();
    }
    configuredPrimaryProvider() {
        const value = this.runtime.provider ?? this.config.get('OTP_DELIVERY_PROVIDER', otp_types_1.OtpDeliveryProvider.DEV);
        return this.toProvider(value) ?? otp_types_1.OtpDeliveryProvider.DEV;
    }
    enabledProviders() {
        const providers = this.runtime.enabledProviders ??
            this.parseProviders(this.config.get('OTP_ENABLED_PROVIDERS', '')) ??
            [this.configuredPrimaryProvider()];
        return this.uniqueProviders(providers);
    }
    providerPriority() {
        const providers = this.runtime.providerPriority ??
            this.parseProviders(this.config.get('OTP_PROVIDER_PRIORITY', '')) ??
            Object.values(otp_types_1.OtpDeliveryProvider);
        return this.uniqueProviders(providers);
    }
    providerOrder() {
        const enabledProviders = this.enabledProviders();
        const enabled = new Set(enabledProviders);
        const priority = this.providerPriority().filter((item) => enabled.has(item));
        return [...priority, ...enabledProviders.filter((item) => !priority.includes(item))];
    }
    parseProviders(value) {
        if (!value) {
            return undefined;
        }
        const parsed = value
            .split(',')
            .map((item) => this.toProvider(item.trim()))
            .filter((item) => Boolean(item));
        return parsed.length ? parsed : undefined;
    }
    uniqueProviders(providers) {
        return Array.from(new Set(providers));
    }
    toProvider(value) {
        if (Object.values(otp_types_1.OtpDeliveryProvider).includes(value)) {
            return value;
        }
        return undefined;
    }
    assertProviders(providers, field) {
        if (!providers) {
            return;
        }
        const invalid = providers.filter((item) => !this.toProvider(item));
        if (invalid.length) {
            throw new common_1.BadRequestException(`${field} contains unsupported OTP providers: ${invalid.join(', ')}`);
        }
    }
    ttlSeconds() {
        return this.positiveInt(this.runtime.ttlSeconds, this.config.get('OTP_CODE_TTL_SECONDS', 300), 300);
    }
    codeLength() {
        const length = this.positiveInt(this.runtime.codeLength, this.config.get('OTP_CODE_LENGTH', 6), 6);
        return Math.min(Math.max(length, 4), 8);
    }
    maxAttempts() {
        return this.positiveInt(this.runtime.maxAttempts, this.config.get('OTP_MAX_ATTEMPTS', 5), 5);
    }
    positiveInt(runtimeValue, configValue, fallback) {
        const parsed = Number(runtimeValue ?? configValue ?? fallback);
        return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
    }
    devCode() {
        return this.config.get('OTP_DEV_CODE', '123456');
    }
    shouldExposeDevCode(provider) {
        const value = this.runtime.exposeDevCode ?? this.config.get('OTP_EXPOSE_DEV_CODE', 'true');
        return provider === otp_types_1.OtpDeliveryProvider.DEV || value === true || String(value).toLowerCase() === 'true';
    }
    generateCode() {
        const length = this.codeLength();
        const min = 10 ** (length - 1);
        const max = 10 ** length - 1;
        return String((0, crypto_1.randomInt)(min, max + 1));
    }
    hash(phone, code) {
        const secret = this.config.get('JWT_ACCESS_SECRET', 'otp-secret');
        return (0, crypto_1.createHash)('sha256').update(`${phone}:${code}:${secret}`).digest('hex');
    }
    messageFor(code, name) {
        const prefix = name ? `Hi ${name}, ` : '';
        return `${prefix}your Smart Estate login code is ${code}. It expires in ${Math.ceil(this.ttlSeconds() / 60)} minutes.`;
    }
    async sendWithProvider(provider, phone, code, message) {
        if (provider === otp_types_1.OtpDeliveryProvider.DEV) {
            return;
        }
        if (provider === otp_types_1.OtpDeliveryProvider.TERMII) {
            return this.sendViaTermii(phone, message);
        }
        if (provider === otp_types_1.OtpDeliveryProvider.AFRICAS_TALKING) {
            return this.sendViaAfricasTalking(phone, message);
        }
        return this.sendViaCustomHttp(phone, code, message);
    }
    async sendViaTermii(phone, message) {
        const apiKey = this.termiiApiKey();
        const senderId = this.termiiSenderId();
        if (!apiKey || !senderId) {
            throw new common_1.BadRequestException('Termii OTP is not configured. Set TERMII_API_KEY and TERMII_SENDER_ID.');
        }
        await this.postJson(`${this.termiiBaseUrl().replace(/\/$/, '')}/api/sms/send`, {
            api_key: apiKey,
            to: phone.replace(/[^\d]/g, ''),
            from: senderId,
            sms: message,
            type: 'plain',
            channel: this.termiiChannel()
        });
    }
    async sendViaAfricasTalking(phone, message) {
        const username = this.africasTalkingUsername();
        const apiKey = this.africasTalkingApiKey();
        if (!username || !apiKey) {
            throw new common_1.BadRequestException("Africa's Talking OTP is not configured. Set AFRICASTALKING_USERNAME and AFRICASTALKING_API_KEY.");
        }
        const body = new URLSearchParams({
            username,
            to: phone,
            message
        });
        const senderId = this.africasTalkingSenderId();
        if (senderId) {
            body.set('from', senderId);
        }
        await this.fetchOrThrow(this.africasTalkingBaseUrl(), {
            method: 'POST',
            headers: {
                Accept: 'application/json',
                'Content-Type': 'application/x-www-form-urlencoded',
                apiKey
            },
            body
        });
    }
    async sendViaCustomHttp(phone, code, message) {
        const url = this.customHttpUrl();
        if (!url) {
            throw new common_1.BadRequestException('Custom OTP HTTP provider is not configured. Set CUSTOM_OTP_URL.');
        }
        const headers = {
            'Content-Type': 'application/json',
            ...this.parseHeaders(this.customHttpHeadersJson())
        };
        const body = this.renderCustomBody(phone, code, message);
        await this.fetchOrThrow(url, {
            method: this.customHttpMethod(),
            headers,
            body: JSON.stringify(body)
        });
    }
    async postJson(url, body) {
        await this.fetchOrThrow(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
            body: JSON.stringify(body)
        });
    }
    async fetchOrThrow(url, init) {
        try {
            const response = await fetch(url, init);
            if (!response.ok) {
                const text = await response.text();
                throw new common_1.BadGatewayException(`OTP provider rejected the request: ${response.status} ${text.slice(0, 250)}`);
            }
        }
        catch (error) {
            if (error instanceof common_1.BadGatewayException) {
                throw error;
            }
            throw new common_1.BadGatewayException(`OTP provider request failed: ${error instanceof Error ? error.message : String(error)}`);
        }
    }
    renderCustomBody(phone, code, message) {
        const template = this.customHttpBodyTemplate();
        if (!template) {
            return { to: phone, code, message };
        }
        const rendered = this.renderTemplate(template, phone, code, message);
        try {
            return JSON.parse(rendered);
        }
        catch {
            throw new common_1.BadRequestException('CUSTOM_OTP_BODY_TEMPLATE must render to valid JSON.');
        }
    }
    parseHeaders(value) {
        if (!value) {
            return {};
        }
        try {
            const parsed = JSON.parse(value);
            return Object.fromEntries(Object.entries(parsed).map(([key, headerValue]) => [key, String(headerValue)]));
        }
        catch {
            throw new common_1.BadRequestException('CUSTOM_OTP_HEADERS_JSON must be valid JSON.');
        }
    }
    renderTemplate(template, phone, code, message) {
        return template
            .replaceAll('{{phone}}', phone)
            .replaceAll('{{otp}}', code)
            .replaceAll('{{code}}', code)
            .replaceAll('{{message}}', message);
    }
    termiiBaseUrl() {
        return this.runtime.termiiBaseUrl ?? this.integrations.getValue('TERMII', 'baseUrl', 'TERMII_BASE_URL', 'https://api.ng.termii.com');
    }
    termiiApiKey() {
        return this.runtime.termiiApiKey ?? this.integrations.getValue('TERMII', 'apiKey', 'TERMII_API_KEY', '');
    }
    termiiSenderId() {
        return this.runtime.termiiSenderId ?? this.integrations.getValue('TERMII', 'senderId', 'TERMII_SENDER_ID', '');
    }
    termiiChannel() {
        return this.runtime.termiiChannel ?? this.integrations.getValue('TERMII', 'channel', 'TERMII_CHANNEL', 'dnd');
    }
    africasTalkingBaseUrl() {
        return (this.runtime.africasTalkingBaseUrl ??
            this.integrations.getValue('AFRICAS_TALKING', 'baseUrl', 'AFRICASTALKING_BASE_URL', 'https://api.africastalking.com/version1/messaging'));
    }
    africasTalkingUsername() {
        return this.runtime.africasTalkingUsername ?? this.integrations.getValue('AFRICAS_TALKING', 'username', 'AFRICASTALKING_USERNAME', '');
    }
    africasTalkingApiKey() {
        return this.runtime.africasTalkingApiKey ?? this.integrations.getValue('AFRICAS_TALKING', 'apiKey', 'AFRICASTALKING_API_KEY', '');
    }
    africasTalkingSenderId() {
        return this.runtime.africasTalkingSenderId ?? this.integrations.getValue('AFRICAS_TALKING', 'senderId', 'AFRICASTALKING_SENDER_ID', '');
    }
    customHttpUrl() {
        return this.runtime.customHttpUrl ?? this.integrations.getValue('CUSTOM_HTTP', 'url', 'CUSTOM_OTP_URL', '');
    }
    customHttpMethod() {
        return (this.runtime.customHttpMethod ?? this.integrations.getValue('CUSTOM_HTTP', 'method', 'CUSTOM_OTP_METHOD', 'POST')).toUpperCase();
    }
    customHttpHeadersJson() {
        return this.runtime.customHttpHeadersJson ?? this.integrations.getValue('CUSTOM_HTTP', 'headersJson', 'CUSTOM_OTP_HEADERS_JSON', '');
    }
    customHttpBodyTemplate() {
        return this.runtime.customHttpBodyTemplate ?? this.integrations.getValue('CUSTOM_HTTP', 'bodyTemplate', 'CUSTOM_OTP_BODY_TEMPLATE', '');
    }
    hasValue(value) {
        return Boolean(value && !value.includes('REPLACE_ME'));
    }
    providerConfigured(provider) {
        if (provider === otp_types_1.OtpDeliveryProvider.DEV) {
            return true;
        }
        if (provider === otp_types_1.OtpDeliveryProvider.TERMII) {
            return this.hasValue(this.termiiApiKey()) && this.hasValue(this.termiiSenderId());
        }
        if (provider === otp_types_1.OtpDeliveryProvider.AFRICAS_TALKING) {
            return this.hasValue(this.africasTalkingUsername()) && this.hasValue(this.africasTalkingApiKey());
        }
        return this.hasValue(this.customHttpUrl());
    }
    errorMessage(error) {
        return error instanceof Error ? error.message : String(error);
    }
    maskUrl(value) {
        if (!value) {
            return '';
        }
        try {
            const url = new URL(value);
            return `${url.origin}${url.pathname}`;
        }
        catch {
            return value;
        }
    }
};
exports.OtpService = OtpService;
exports.OtpService = OtpService = __decorate([
    (0, common_1.Injectable)(),
    __metadata("design:paramtypes", [config_1.ConfigService,
        integration_config_service_1.IntegrationConfigService])
], OtpService);
//# sourceMappingURL=otp.service.js.map