import * as crypto from "node:crypto";

const SENSITIVE_HEADERS = new Set(['authorization', 'x-rainbow-api-key', 'x-rainbow-app-auth']);

function _sanitizeHeaders(headers) {
    const out = {};
    for (const [k, v] of Object.entries(headers)) {
        const lower = k.toLowerCase();
        if (lower === 'authorization') {
            const scheme = String(v).split(' ')[0];
            out[k] = `${scheme} [REDACTED]`;
        } else if (SENSITIVE_HEADERS.has(lower)) {
            out[k] = '[REDACTED]';
        } else {
            out[k] = v;
        }
    }
    return out;
}

export class Rest {
    constructor(program, logger, services) {
        this.program = program;
        this.logger = logger;
        this.configManager = services["configManager"];
        this.services = services;
        services["rest"] = this;

        // Cache des règles de headers additionnels (API multi-région)
        this._apiSettings = { rules: [], expiresAt: 0 };
        // Callback optionnel pour tracer les appels API (mode --trace de ucs listen)
        this._traceCallback = null;
    }

    urlBuilder(host, endpoint, query_parameters = []) {
        this.logger.debug('Rest.urlBuilder');
        let url;
        if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
            url = new URL(endpoint);
        }
        else {
            url = new URL(`https://${host}${endpoint}`);
        }
        url.search = new URLSearchParams(query_parameters);

        return url;
    }

    async call_api(endpoint, method, query_parameters, body = {}, headers = {}, retryCount = 0) {
        this.logger.debug('Rest.call_api');

        const credentials = await this.configManager.getFullCredentials();

        const req_headers = { ...headers };
        if (endpoint !== '/api/rainbow/authentication/v1.0/login') {
            switch (credentials.authMethod) {
                case 'API_KEY':

                    // calculate challenge
                    const challenge = crypto.randomBytes(20).toString('hex');

                    // calculate app auth
                    const appAuth = 'Basic ' +
                        Buffer.from(
                            credentials.applicationId + ':'
                            + crypto.createHash('sha256')
                                .update(credentials.applicationSecret + challenge)
                                .digest('hex')
                        ).toString('base64');

                    this.logger.debug(`challenge: ${challenge}`);
                    this.logger.debug(`app_auth: [REDACTED]`);

                    req_headers['x-rainbow-api-key'] = credentials.apiKey;
                    req_headers['x-rainbow-challenge'] = challenge;
                    req_headers['x-rainbow-app-auth'] = appAuth;
                    break;
                case 'RAINBOW':
                case 'OAUTH': {
                    // A LOGIN MUST BE DONE IF NO TOKEN EXISTS OR IF RETRYING AFTER JWT EXPIRY
                    let tokenToUse = credentials.token;
                    if (!tokenToUse || retryCount > 0) {
                        this.logger.debug('no token or JWT expired, login needed');
                        try {
                            const loginResult = await this.services["authentication"].login({ 'nooutput': true }, credentials);
                            if (!loginResult?.token) {
                                throw 'Login succeeded but returned no token';
                            }
                            tokenToUse = loginResult.token;
                            await this.configManager.setToken(loginResult.token);
                            if (loginResult.refresh_token) {
                                await this.configManager.setRefreshToken(loginResult.refresh_token);
                            }
                            if (loginResult.companyId) {
                                this.configManager.setCompanyId(loginResult.companyId);
                            }
                        }
                        catch (error) {
                            this.logger.error(error);
                            throw error;
                        }
                    }

                    req_headers['Authorization'] = `Bearer ${tokenToUse}`;
                    break;
                }
            }
        }

        // Add content-type header if body is present and content-type is not already set
        if (body && Object.keys(body).length > 0 && !this.hasPropertyInsensitive(req_headers, 'content-type')) {
            req_headers['Content-Type'] = 'application/json';
        }

        // Injecter les headers additionnels issus des API settings (routing multi-région, etc.)
        // Exclut l'endpoint de login et l'endpoint des settings lui-même (anti-récursion)
        if (endpoint !== '/api/rainbow/authentication/v1.0/login' &&
            endpoint !== '/api/rainbow/enduser/v1.0/settings/apis') {
            await this._ensureApiSettings();
            const extra = this._matchApiHeaders(method, endpoint);
            for (const [k, v] of Object.entries(extra)) {
                if (!this.hasPropertyInsensitive(req_headers, k)) req_headers[k] = v;
            }
        }

        const init = {
            headers: req_headers,
            method: method,
            // include body only if body is not empty and method is not GET or HEAD (which should not have a body)
            body: ((method !== 'GET' && method !== 'HEAD') && body && Object.keys(body).length > 0 ? this.stringify(req_headers, body) : undefined)
        };

        // Proxy : si configuré dans le profil, on injecte un ProxyAgent comme dispatcher
        if (credentials.proxy?.host) {
            const { host, port, username } = credentials.proxy;
            let proxyUrl;
            if (username && credentials.proxyPassword) {
                proxyUrl = `http://${encodeURIComponent(username)}:${encodeURIComponent(credentials.proxyPassword)}@${host}:${port}`;
            } else {
                proxyUrl = `http://${host}:${port}`;
            }
            this.logger.debug(`using proxy: ${host}:${port}`);
            const { ProxyAgent, fetch: undiciFetch } = await import('undici');
            init.dispatcher = new ProxyAgent(proxyUrl);
            init._fetchFn = undiciFetch;
        }

        this.logger.debug(init);
        try {
            this.logger.debug(`endpoint ${endpoint}`);

            const url = this.urlBuilder(credentials.host, endpoint, query_parameters);
            this.logger.debug(`url ${url}`);
            const fetchFn = init._fetchFn ?? fetch;
            delete init._fetchFn;
            const response = await fetchFn(this.urlBuilder(credentials.host, endpoint, query_parameters), init);

            if (response.status < 200 || response.status > 226) {
                this.logger.error(`status ${response.status} - ${response.statusText}`);
                // content-type is json
                let error_details = null;
                try {
                    error_details = await response.json();
                }
                catch (e) {
                    this.logger.debug('error response is not JSON', e);
                }

                this.logger.debug(`error details: ${error_details ? JSON.stringify(error_details) : 'N/A'}`);
                if (error_details && error_details.errorDetailsCode === 401202) {
                    // jwt expired
                    this.logger.debug('JWT expired');
                    if (retryCount >= 1) {
                        throw `status ${response.status} - JWT expired and re-login failed`;
                    }
                    return this.call_api(endpoint, method, query_parameters, body, headers, retryCount + 1);
                }

                if (error_details) {
                    throw `status ${response.status} - ${error_details.errorDetailsCode} - ${error_details.errorMsg} - ${error_details.errorDetails}`;
                }
                throw `status ${response.status} - ${response.statusText}`;
            }
            if (this._traceCallback && endpoint !== '/api/rainbow/enduser/v1.0/settings/apis') {
                const reqBody = (method !== 'GET' && method !== 'HEAD' && init.body)
                    ? String(init.body).substring(0, 400) : null;
                const headers = _sanitizeHeaders(req_headers);
                const ts = new Date().toISOString();
                response.clone().json()
                    .then(json => this._traceCallback({ type: 'api-trace', method, endpoint, status: response.status, snippet: JSON.stringify(json).substring(0, 400), requestBody: reqBody, headers, ts }))
                    .catch(() => this._traceCallback({ type: 'api-trace', method, endpoint, status: response.status, requestBody: reqBody, headers, ts }));
            }
            return response;
        }
        catch (error) {
            throw `exception during API call: ${error} - ${error.type ? error.type : 'no error type'} - ${error.stack ? error.stack : 'no stack trace'}`;
        }
    }

    async mfaValidationRequest(mfaUrl, mfaCode, tempToken) {
        this.logger.debug('Rest.mfaValidationRequest');
        const url = new URL('', mfaUrl);
        const init = {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${tempToken}`,
                'accept': 'application/json',
                'content-type': 'application/json'
            },
            body: JSON.stringify({
                'mfaTotpCode': mfaCode
                // 'trustedApp' indicates if MFA policy is configured to authorize trusted app to not ask for totp code at next login
            })
        }
        return fetch(url, init);
    }

    stringify(headers, body) {
        this.logger.debug('Rest.stringify');

        // Extract content-type from headers (case-insensitive) and strip parameters like charset.
        const contentTypeHeader = this.getValueInsensitive(headers, 'content-type');
        if (!contentTypeHeader) {
            this.logger.error('no content-type');
            throw 'no content-type';
        }
        const mediaType = contentTypeHeader.split(';')[0].trim().toLowerCase();
        this.logger.debug(`content-type: ${mediaType}`);

        switch (mediaType) {
            case 'application/json':
                return JSON.stringify(body);
            case 'application/x-www-form-urlencoded':
                // Convert object to URL-encoded string if body is an object
                if (typeof body === 'object' && body !== null) {
                    return new URLSearchParams(body).toString();
                }
                return body;
            case 'multipart/form-data':
                this.logger.warning('unsupported content-type');
                return body;
            case 'text/plain':
            case 'text/csv':
                return body;
            default:
                return body;
        }
    }

    /**
     * Vérifie si une propriété existe dans un objet, en ignorant la casse.
     * @param {Object} obj - L'objet à inspecter.
     * @param {string} keyToFind - La clé à rechercher (par exemple 'content-type').
     * @returns {boolean} - True si la propriété est trouvée, False sinon.
     */
    hasPropertyInsensitive(obj, keyToFind) {
        // 1. Normaliser la clé que l'on recherche
        const lowerKeyToFind = keyToFind.toLowerCase();

        // 2. Parcourir toutes les clés de l'objet
        return Object.keys(obj).some(key => {
            // 3. Comparer en ignorant la casse
            return key.toLowerCase() === lowerKeyToFind;
        });
    }

    /**
     * Récupère la valeur d'une propriété en ignorant la casse.
     * @param {Object} obj - L'objet à inspecter.
     * @param {string} keyToFind - La clé à rechercher.
     * @returns {*} - La valeur de la propriété ou undefined si non trouvée.
     */
    getValueInsensitive(obj, keyToFind) {
        const lowerKeyToFind = keyToFind.toLowerCase();

        // Trouver la clé réelle qui correspond
        const realKey = Object.keys(obj).find(key =>
            key.toLowerCase() === lowerKeyToFind
        );

        // Renvoyer la valeur en utilisant la clé réelle trouvée
        return realKey ? obj[realKey] : undefined;
    }

    // -----------------------------------------------------------------------------------
    // API settings : headers additionnels (routing multi-région)
    // -----------------------------------------------------------------------------------

    // Convertit un pattern URL (avec *) en RegExp, compilée une seule fois.
    _patternToRegex(pattern) {
        const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
        return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$');
    }

    // Retourne les headers additionnels à injecter pour un appel donné.
    _matchApiHeaders(method, endpoint) {
        // Normaliser l'endpoint : extraire uniquement le path si c'est une URL complète
        let path = endpoint;
        if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
            try { path = new URL(endpoint).pathname; } catch { /* garder tel quel */ }
        }

        const extra = {};
        for (const rule of this._apiSettings.rules) {
            for (const m of rule.matches) {
                const methodOk = m.method === '*' || m.method.toUpperCase() === method.toUpperCase();
                if (methodOk && m.regex.test(path)) {
                    for (const h of rule.headers) extra[h.key] = h.value;
                    break; // première règle correspondante appliquée, on passe à la suivante
                }
            }
        }
        return extra;
    }

    // Charge (ou rafraîchit) les API settings depuis le serveur, selon le TTL.
    async _ensureApiSettings() {
        if (Date.now() < this._apiSettings.expiresAt) return;

        try {
            const response = await this.call_api(
                '/api/rainbow/enduser/v1.0/settings/apis', 'GET', [], {}, {}
            );
            const json = await response.json();
            const data = json?.data;
            if (!data) return;

            const ttlMs = (data.ttl ?? 1440) * 60 * 1000;
            const rules = (data.additionalHeaders ?? []).map(rule => ({
                matches: (rule.match ?? []).map(m => ({
                    method: m.method,
                    regex:  this._patternToRegex(m.url),
                })),
                headers: rule.headers ?? [],
            }));

            this._apiSettings = { rules, expiresAt: Date.now() + ttlMs };
            this.logger.debug(`API settings: ${rules.length} règle(s) chargée(s), TTL=${data.ttl}min`);
        } catch (err) {
            this.logger.debug(`API settings indisponibles (non bloquant): ${err}`);
            // Réessayer dans 5 minutes
            this._apiSettings.expiresAt = Date.now() + 5 * 60 * 1000;
        }
    }

    // -----------------------------------------------------------------------------------

    async get(endpoint, query_parameters, headers = {}) {
        return await this.call_api(endpoint, 'GET', query_parameters, {}, headers);
    }

    async post(endpoint, query_parameters, body = {}, headers = {}) {
        return await this.call_api(endpoint, 'POST', query_parameters, body, headers);
    }

    async put(endpoint, query_parameters, body, headers = {}) {
        return await this.call_api(endpoint, 'PUT', query_parameters, body, headers);
    }

    async del(endpoint, query_parameters, body = {}, headers = {}) {
        return await this.call_api(endpoint, 'DELETE', query_parameters, body, headers);
    }

    async head(endpoint, query_parameters, headers = {}) {
        return await this.call_api(endpoint, 'HEAD', query_parameters, {}, headers);
    }

    // -----------------------------------------------------------------------------------

    async restCall(method, endpoint, query_parameters, body = {}, headers = {}) {
        this.logger.debug('Rest.restCall');

        try {
            const result = await this.call_api(endpoint, method, query_parameters, body, headers);
            let json;
            try {
                json = await result.json();
            }
            catch (e) {
                this.logger.error('response is not valid JSON');
                throw e;
            }
            this.logger.debug(json);
            return json;
        }
        catch (error) {
            this.logger.error(error);
            throw error;
        }
    }
}