/*
 * Copyright (c) 2025, Salesforce, Inc.
 * SPDX-License-Identifier: Apache-2
 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
 */
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import protobuf from 'protobufjs';
import { OAuthStrategy } from '../auth/oauth.js';
import { getLogger } from '../logging/logger.js';
import { globalMiddlewareRegistry } from './middleware-registry.js';
const require = createRequire(import.meta.url);
const packageRoot = path.dirname(require.resolve('@salesforce/b2c-tooling-sdk/package.json'));
/** Default CIP Avatica host. */
export const DEFAULT_CIP_HOST = 'jdbc.analytics.commercecloud.salesforce.com';
/** Default CIP Avatica host for staging/non-production analytics. */
export const DEFAULT_CIP_STAGING_HOST = 'jdbc.stg.analytics.commercecloud.salesforce.com';
const CIP_CLIENT_VERSION = '2.11.0';
const PROTOBUF_PREVIEW_BYTES = 48;
const CIP_PROTO_FILES = [
    path.join(packageRoot, 'data/cip-proto/common.proto'),
    path.join(packageRoot, 'data/cip-proto/requests.proto'),
    path.join(packageRoot, 'data/cip-proto/responses.proto'),
];
const REP = {
    PRIMITIVE_BOOLEAN: 0,
    BOOLEAN: 8,
    STRING: 21,
    LONG: 13,
    INTEGER: 12,
    PRIMITIVE_INT: 4,
    PRIMITIVE_LONG: 5,
    NUMBER: 22,
    BIG_INTEGER: 25,
    FLOAT: 14,
    DOUBLE: 15,
    PRIMITIVE_FLOAT: 6,
    PRIMITIVE_DOUBLE: 7,
    BIG_DECIMAL: 26,
    BYTE_STRING: 20,
    JAVA_SQL_DATE: 18,
    JAVA_SQL_TIME: 16,
    JAVA_SQL_TIMESTAMP: 17,
    JAVA_UTIL_DATE: 19,
    ARRAY: 27,
};
function hasOwn(value, key) {
    return typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, key);
}
function normalizeCipHost(host) {
    if (!host) {
        return DEFAULT_CIP_HOST;
    }
    const withoutProtocol = host.replace(/^https?:\/\//u, '');
    return withoutProtocol.replace(/\/.*$/u, '');
}
function toNumber(value) {
    if (typeof value === 'number') {
        return value;
    }
    if (typeof value === 'bigint') {
        return Number(value);
    }
    if (typeof value === 'object' && value !== null && 'toNumber' in value) {
        return Number(value.toNumber());
    }
    return Number(value ?? 0);
}
/**
 * CIP Avatica client with protobuf transport.
 *
 * Use this client for raw SQL execution against B2C Commerce Intelligence
 * (CIP/CCAC) data.
 *
 * See {@link createCipClient} for the recommended construction helper.
 *
 * @example
 * ```ts
 * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth';
 * import {createCipClient} from '@salesforce/b2c-tooling-sdk/clients';
 *
 * const auth = new OAuthStrategy({
 *   clientId: process.env.SFCC_CLIENT_ID!,
 *   clientSecret: process.env.SFCC_CLIENT_SECRET!,
 * });
 *
 * const cip = createCipClient({instance: 'zzxy_prd'}, auth);
 *
 * const result = await cip.query(
 *   'SELECT submit_date, num_orders FROM ccdw_aggr_sales_summary LIMIT 10',
 *   {fetchSize: 500},
 * );
 *
 * console.log(result.rowCount, result.columns, result.rows[0]);
 * ```
 */
export class CipClient {
    config;
    auth;
    baseUrl;
    middlewareRegistry;
    protoRoot;
    connectionId;
    sessionId;
    signatureByStatementId = new Map();
    constructor(config, auth) {
        this.config = config;
        this.auth = auth;
        this.baseUrl = `https://${normalizeCipHost(config.host)}/${config.instance}`;
        this.middlewareRegistry = config.middlewareRegistry ?? globalMiddlewareRegistry;
    }
    /**
     * Opens a new Avatica connection.
     */
    async openConnection(info = {}) {
        if (this.connectionId) {
            throw new Error('CIP connection is already open');
        }
        const connectionId = randomUUID();
        await this.sendRequest('OpenConnectionRequest', { connectionId, info });
        this.connectionId = connectionId;
    }
    /**
     * Closes the current Avatica connection (no-op if not open).
     */
    async closeConnection() {
        if (!this.connectionId) {
            return;
        }
        const connectionId = this.connectionId;
        try {
            await this.sendRequest('CloseConnectionRequest', { connectionId });
        }
        finally {
            this.connectionId = undefined;
            this.sessionId = undefined;
            this.signatureByStatementId.clear();
        }
    }
    /**
     * Creates a statement in the currently open connection.
     */
    async createStatement() {
        this.requireConnection();
        const response = (await this.sendRequest('CreateStatementRequest', {
            connectionId: this.connectionId,
        }));
        if (typeof response.statementId !== 'number') {
            throw new Error('CIP create statement failed: missing statement id');
        }
        return response.statementId;
    }
    /**
     * Closes a statement.
     */
    async closeStatement(statementId) {
        this.requireConnection();
        try {
            await this.sendRequest('CloseStatementRequest', {
                connectionId: this.connectionId,
                statementId,
            });
        }
        finally {
            this.signatureByStatementId.delete(statementId);
        }
    }
    /**
     * Executes SQL and returns the first decoded frame.
     */
    async execute(statementId, sql, firstFrameMaxSize = 1000) {
        this.requireConnection();
        getLogger().debug({ statementId, sql }, `[CIP SQL] statement=${statementId}`);
        const response = (await this.sendRequest('PrepareAndExecuteRequest', {
            connectionId: this.connectionId,
            statementId,
            sql,
            maxRowCount: firstFrameMaxSize,
            firstFrameMaxSize,
        }));
        const result = response.results?.[0];
        if (!result) {
            return { statementId };
        }
        const resolvedStatementId = result.statementId ?? statementId;
        if (result.signature) {
            this.signatureByStatementId.set(resolvedStatementId, result.signature);
        }
        return {
            statementId: resolvedStatementId,
            frame: this.decodeFrame(result.signature ?? this.signatureByStatementId.get(resolvedStatementId), result.firstFrame),
        };
    }
    /**
     * Fetches an additional frame for an existing statement.
     */
    async fetch(statementId, offset, fetchMaxRowCount = 1000) {
        this.requireConnection();
        const response = (await this.sendRequest('FetchRequest', {
            connectionId: this.connectionId,
            statementId,
            offset,
            fetchMaxRowCount,
        }));
        return {
            frame: this.decodeFrame(this.signatureByStatementId.get(statementId), response.frame),
        };
    }
    /**
     * Executes SQL and returns the full decoded row set.
     *
     * This helper opens and closes the connection automatically.
     */
    async query(sql, options = {}) {
        const fetchSize = options.fetchSize ?? 1000;
        const rows = [];
        let columns = [];
        await this.openConnection(options.connectionProperties);
        const statementId = await this.createStatement();
        try {
            const executeResponse = await this.execute(statementId, sql, fetchSize);
            let frame = executeResponse.frame;
            if (frame) {
                rows.push(...frame.rows);
                columns = frame.columns.map((column) => column.label);
            }
            while (frame && !frame.done) {
                const nextOffset = frame.offset + frame.rows.length;
                const fetchResponse = await this.fetch(executeResponse.statementId, nextOffset, fetchSize);
                frame = fetchResponse.frame;
                if (frame) {
                    rows.push(...frame.rows);
                    if (columns.length === 0) {
                        columns = frame.columns.map((column) => column.label);
                    }
                }
            }
            return {
                columns,
                rows,
                rowCount: rows.length,
            };
        }
        finally {
            await this.closeStatement(statementId).catch((error) => {
                const message = error instanceof Error ? error.message : String(error);
                getLogger().debug({ error: message }, 'Failed to close CIP statement during cleanup');
            });
            await this.closeConnection().catch((error) => {
                const message = error instanceof Error ? error.message : String(error);
                getLogger().debug({ error: message }, 'Failed to close CIP connection during cleanup');
            });
        }
    }
    requireConnection() {
        if (!this.connectionId) {
            throw new Error('No CIP connection is open. Call openConnection() first.');
        }
    }
    getMiddleware() {
        return this.middlewareRegistry.getMiddleware('cip');
    }
    async getProtoRoot() {
        if (!this.protoRoot) {
            this.protoRoot = await protobuf.load(CIP_PROTO_FILES);
        }
        return this.protoRoot;
    }
    async sendRequest(requestTypeName, payload) {
        const logger = getLogger();
        const root = await this.getProtoRoot();
        const requestType = root.lookupType(requestTypeName);
        const requestMessage = requestType.create(payload);
        const serializedRequest = requestType.encode(requestMessage).finish();
        const wireType = root.lookupType('WireMessage');
        const wireRequest = wireType.create({
            name: `org.apache.calcite.avatica.proto.Requests$${requestTypeName}`,
            wrappedMessage: serializedRequest,
        });
        const serializedWireRequest = wireType.encode(wireRequest).finish();
        let request = new Request(this.baseUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-protobuf',
                'X-Client-Version': CIP_CLIENT_VERSION,
                InstanceId: this.config.instance,
                ...(this.sessionId ? { 'x-session-id': this.sessionId } : {}),
            },
            body: serializedWireRequest,
        });
        const middleware = this.getMiddleware();
        const middlewareParams = {
            request,
            schemaPath: '/cip',
            options: { baseUrl: this.baseUrl },
            params: {},
            id: 'cip',
        };
        for (const middlewareItem of middleware) {
            if (!middlewareItem.onRequest)
                continue;
            const nextRequest = await middlewareItem.onRequest(middlewareParams);
            if (nextRequest instanceof Request) {
                request = nextRequest;
                middlewareParams.request = nextRequest;
            }
        }
        let body = serializedWireRequest;
        if (request.body) {
            body = await request.clone().arrayBuffer();
        }
        const requestStartTime = Date.now();
        logger.debug({ type: requestTypeName, url: request.url }, `[CIP REQ] ${requestTypeName}`);
        logger.trace({
            method: request.method,
            url: request.url,
            headers: this.headersToObject(request.headers),
            body: this.formatProtobufBody(body, {
                requestTypeName,
                wireMessageName: `org.apache.calcite.avatica.proto.Requests$${requestTypeName}`,
                payload,
            }),
        }, `[CIP REQ BODY] ${request.method} ${request.url}`);
        let response = await this.auth.fetch(request.url, {
            method: request.method,
            headers: request.headers,
            body,
        });
        const duration = Date.now() - requestStartTime;
        const responseParams = {
            ...middlewareParams,
            request,
            response,
        };
        for (const middlewareItem of middleware) {
            if (!middlewareItem.onResponse)
                continue;
            const nextResponse = await middlewareItem.onResponse(responseParams);
            if (nextResponse instanceof Response) {
                response = nextResponse;
                responseParams.response = nextResponse;
            }
        }
        const sessionId = response.headers.get('x-session-id');
        if (sessionId) {
            this.sessionId = sessionId;
        }
        if (!response.ok) {
            const bodyText = await response.text();
            logger.debug({ method: request.method, url: request.url, status: response.status, duration }, `[CIP RESP] ${requestTypeName} ${response.status} ${duration}ms`);
            logger.trace({
                method: request.method,
                url: request.url,
                status: response.status,
                headers: this.headersToObject(response.headers),
                body: bodyText,
            }, `[CIP RESP BODY] ${request.method} ${request.url}`);
            throw new Error(`CIP Avatica request failed (${response.status} ${response.statusText}): ${bodyText}`);
        }
        const responseBytes = new Uint8Array(await response.arrayBuffer());
        const wireResponse = wireType.decode(responseBytes);
        const responseClassName = wireResponse.name ?? '';
        const responseTypeName = responseClassName.split('$').pop() ?? '';
        const wrappedResponse = wireResponse.wrappedMessage;
        logger.debug({ method: request.method, url: request.url, status: response.status, duration, responseTypeName }, `[CIP RESP] ${requestTypeName} ${response.status} ${duration}ms`);
        logger.trace({
            method: request.method,
            url: request.url,
            status: response.status,
            headers: this.headersToObject(response.headers),
            body: this.formatProtobufBody(responseBytes, {
                wireMessageName: wireResponse.name,
                responseTypeName,
            }),
        }, `[CIP RESP BODY] ${request.method} ${request.url}`);
        if (!wrappedResponse) {
            throw new Error('CIP Avatica response did not contain a wrapped protobuf message');
        }
        if (responseTypeName === 'ErrorResponse') {
            const errorType = root.lookupType('ErrorResponse');
            const decodedError = errorType.decode(wrappedResponse);
            const errorMessage = decodedError.errorMessage ?? 'Unknown Avatica error';
            const sqlState = decodedError.sqlState ? ` SQLState=${decodedError.sqlState}` : '';
            const errorCode = decodedError.errorCode !== undefined ? ` ErrorCode=${decodedError.errorCode}` : '';
            throw new Error(`CIP Avatica error: ${errorMessage}${sqlState}${errorCode}`);
        }
        const responseType = root.lookupType(responseTypeName);
        return responseType.decode(wrappedResponse);
    }
    decodeFrame(signature, frame) {
        if (!frame) {
            return undefined;
        }
        const columns = this.getColumnsFromSignature(signature);
        const rows = (frame.rows ?? []).map((row) => {
            const values = Array.isArray(row.value) ? row.value : [];
            const decoded = {};
            for (let index = 0; index < columns.length; index++) {
                const column = columns[index];
                const value = values[index];
                const columnMeta = signature?.columns?.[index];
                decoded[column.label] = this.decodeColumnValue(value, columnMeta);
            }
            return decoded;
        });
        return {
            offset: toNumber(frame.offset),
            done: Boolean(frame.done),
            columns,
            rows,
        };
    }
    getColumnsFromSignature(signature) {
        const columns = signature?.columns;
        if (!columns || columns.length === 0) {
            return [];
        }
        return columns.map((column, index) => {
            const label = typeof column.label === 'string' && column.label.length > 0 ? column.label : undefined;
            const columnName = typeof column.columnName === 'string' && column.columnName.length > 0 ? column.columnName : undefined;
            return {
                label: label ?? columnName ?? `column_${index + 1}`,
                name: columnName,
                typeName: this.getColumnTypeName(column),
            };
        });
    }
    getColumnTypeName(column) {
        if (typeof column.type === 'object' &&
            column.type !== null &&
            typeof column.type.name === 'string') {
            return column.type.name;
        }
        return undefined;
    }
    decodeColumnValue(columnValue, columnMetadata) {
        if (!columnValue) {
            return null;
        }
        if (columnValue.hasArrayValue === true && Array.isArray(columnValue.arrayValue)) {
            return columnValue.arrayValue.map((value) => this.decodeTypedValue(value, columnMetadata));
        }
        if (typeof columnValue.scalarValue === 'object' && columnValue.scalarValue !== null) {
            return this.decodeTypedValue(columnValue.scalarValue, columnMetadata);
        }
        if (Array.isArray(columnValue.value) && columnValue.value.length > 0) {
            return this.decodeTypedValue(columnValue.value[0], columnMetadata);
        }
        return null;
    }
    decodeTypedValue(typedValue, columnMetadata) {
        if (typeof typedValue !== 'object' || typedValue === null) {
            return null;
        }
        const typed = typedValue;
        const rep = hasOwn(typed, 'type') ? toNumber(typed.type) : undefined;
        if (typed.null === true || typed.implicitlyNull === true) {
            return null;
        }
        if (rep === REP.ARRAY && Array.isArray(typed.arrayValue)) {
            return typed.arrayValue.map((value) => this.decodeTypedValue(value, undefined));
        }
        if (rep === REP.BOOLEAN || rep === REP.PRIMITIVE_BOOLEAN) {
            return Boolean(typed.boolValue);
        }
        if (rep === REP.STRING) {
            return hasOwn(typed, 'stringValue') ? typed.stringValue : '';
        }
        if (rep === REP.LONG ||
            rep === REP.INTEGER ||
            rep === REP.PRIMITIVE_INT ||
            rep === REP.PRIMITIVE_LONG ||
            rep === REP.NUMBER ||
            rep === REP.BIG_INTEGER) {
            return this.decodeNumericValue(typed.numberValue, columnMetadata);
        }
        if (rep === REP.FLOAT || rep === REP.DOUBLE || rep === REP.PRIMITIVE_FLOAT || rep === REP.PRIMITIVE_DOUBLE) {
            return hasOwn(typed, 'doubleValue') ? Number(typed.doubleValue) : Number(typed.numberValue ?? 0);
        }
        if (rep === REP.BIG_DECIMAL) {
            if (hasOwn(typed, 'stringValue') && typeof typed.stringValue === 'string') {
                return Number.parseFloat(typed.stringValue);
            }
            return Number(typed.doubleValue ?? typed.numberValue ?? 0);
        }
        if (rep === REP.BYTE_STRING && typed.bytesValue instanceof Uint8Array) {
            return Buffer.from(typed.bytesValue).toString('base64');
        }
        if (rep === REP.JAVA_SQL_DATE || rep === REP.JAVA_UTIL_DATE || rep === REP.JAVA_SQL_TIMESTAMP) {
            return new Date(toNumber(typed.numberValue));
        }
        if (rep === REP.JAVA_SQL_TIME) {
            return toNumber(typed.numberValue);
        }
        if (hasOwn(typed, 'stringValue')) {
            return typed.stringValue;
        }
        if (hasOwn(typed, 'doubleValue')) {
            return Number(typed.doubleValue);
        }
        if (hasOwn(typed, 'numberValue')) {
            return Number(typed.numberValue);
        }
        if (hasOwn(typed, 'boolValue')) {
            return Boolean(typed.boolValue);
        }
        return null;
    }
    decodeNumericValue(value, columnMetadata) {
        const numeric = toNumber(value);
        if (typeof columnMetadata === 'object' && columnMetadata !== null) {
            const column = columnMetadata;
            const typeName = typeof column.type?.name === 'string' ? column.type.name.toUpperCase() : undefined;
            const typeId = column.type?.id !== undefined ? toNumber(column.type.id) : undefined;
            if (typeName === 'DATE' || typeId === 91) {
                return new Date(numeric * 24 * 60 * 60 * 1000);
            }
            if (typeName === 'TIMESTAMP' || typeId === 93) {
                return new Date(numeric);
            }
        }
        return numeric;
    }
    formatProtobufBody(body, metadata) {
        if (!body) {
            return undefined;
        }
        const bytes = body instanceof Uint8Array ? body : new Uint8Array(body);
        const preview = bytes.subarray(0, PROTOBUF_PREVIEW_BYTES);
        return {
            kind: 'protobuf',
            sizeBytes: bytes.byteLength,
            previewHex: Buffer.from(preview).toString('hex'),
            previewBase64: Buffer.from(preview).toString('base64'),
            previewSize: preview.byteLength,
            truncated: bytes.byteLength > preview.byteLength,
            ...metadata,
        };
    }
    headersToObject(headers) {
        const result = {};
        headers.forEach((value, key) => {
            result[key] = value;
        });
        return result;
    }
}
/**
 * Creates a CIP client and ensures the required CIP scope on OAuth strategies.
 *
 * @example
 * ```ts
 * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth';
 * import {createCipClient} from '@salesforce/b2c-tooling-sdk/clients';
 *
 * const auth = new OAuthStrategy({
 *   clientId: process.env.SFCC_CLIENT_ID!,
 *   clientSecret: process.env.SFCC_CLIENT_SECRET!,
 * });
 *
 * const cip = createCipClient({instance: 'zzxy_prd'}, auth);
 * const query = await cip.query('SELECT submit_date FROM ccdw_aggr_sales_summary LIMIT 1');
 * ```
 */
export function createCipClient(config, auth) {
    const cipScope = `SALESFORCE_COMMERCE_API:${config.instance}`;
    const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes([cipScope]) : auth;
    return new CipClient(config, scopedAuth);
}
//# sourceMappingURL=cip.js.map