import { __decorate, __metadata } from "tslib";
import { AsyncLocalStorage } from 'node:async_hooks';
import { Mutex } from 'async-mutex';
import { GraphQLError } from 'graphql';
import { Injectable, Scope } from 'graphql-modules';
import { resolveWriteTargetBusinessId } from '../../shared/helpers/auth-scope.js';
import { AuthContextProvider } from '../auth/providers/auth-context.provider.js';
import { DBProvider } from './db.provider.js';
/**
 * TenantAwareDBClient enforces Row-Level Security (RLS) by setting PostgreSQL
 * session variables for every database transaction.
 
 *
 * RLS Enforcement:
 * - app.current_business_id: Set to the authenticated user's active business
 * - app.current_user_id: Set to the authenticated user's ID (or NULL for API keys)
 * - app.auth_type: Set to 'jwt' or 'apiKey'
 *
 * **Usage:**
 * Inject into Operation-scoped providers via constructor DI:
 *
 * @example
 * @Injectable({ scope: Scope.Operation })
 * class BusinessesProvider {
 *   constructor(private db: TenantAwareDBClient) {}
 *
 *   async getBusinesses() {
 *     return this.db.query('SELECT * FROM businesses')
 *   }
 * }
 *
 * Transaction Management:
 * - Supports nested transactions via SAVEPOINTs
 * - Automatically rolls back on error
 * - Automatically releases connection on dispose
 *
 * **DO NOT** access from Yoga context - use DI injection instead.
 *
 * @throws {GraphQLError} UNAUTHENTICATED if auth context is null
 */
let TenantAwareDBClient = class TenantAwareDBClient {
    dbProvider;
    authContextProvider;
    mutex = new Mutex();
    storage = new AsyncLocalStorage();
    activeClient = null;
    transactionDepth = 0;
    isDisposed = false;
    initializationPromise = null;
    authContext = null;
    authContextInitialized = false;
    constructor(dbProvider, authContextProvider) {
        this.dbProvider = dbProvider;
        this.authContextProvider = authContextProvider;
    }
    /**
     * Execute a query with RLS enforcement.
     * If a transaction is already active, uses it.
     * If not, starts a new transaction/session, executes the query, and commits.
     */
    async query(text, params) {
        this.ensureNotDisposed();
        await this.ensureAuthContext();
        if (!this.authContext) {
            throw new GraphQLError('Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } });
        }
        if (this.storage.getStore() && this.activeClient) {
            const result = await this.activeClient.query(text, params);
            return { ...result, rowCount: result.rowCount ?? 0 };
        }
        return this.transaction(async (client) => {
            const result = await client.query(text, params);
            return { ...result, rowCount: result.rowCount ?? 0 };
        });
    }
    /**
     * Execute a function within a transaction block.
     * Handles nested transactions using SAVEPOINTs.
     */
    async transaction(fn) {
        this.ensureNotDisposed();
        await this.ensureAuthContext();
        if (!this.authContext) {
            throw new GraphQLError('Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } });
        }
        if (this.storage.getStore()) {
            return this.executeTransactionInternal(fn);
        }
        return this.mutex.runExclusive(() => {
            this.ensureNotDisposed();
            return this.storage.run(true, () => {
                return this.executeTransactionInternal(fn);
            });
        });
    }
    async executeTransactionInternal(fn) {
        // 1. Wait for initialization if in progress
        if (this.initializationPromise) {
            try {
                await this.initializationPromise;
            }
            catch {
                // Initialization failed.
                // We proceed to check (!this.activeClient) which will re-attempt or fail.
            }
        }
        // 2. Initialize if needed
        if (!this.activeClient) {
            this.initializationPromise = (async () => {
                const client = await this.dbProvider.pool.connect();
                try {
                    await client.query('BEGIN');
                    await this.setRLSVariables(client);
                    this.activeClient = client;
                }
                catch (error) {
                    client.release();
                    throw error;
                }
            })();
            try {
                await this.initializationPromise;
            }
            finally {
                this.initializationPromise = null;
            }
        }
        // Guard: activeClient must be set by now
        if (!this.activeClient) {
            throw new Error('Failed to initialize database client');
        }
        const client = this.activeClient;
        this.transactionDepth++;
        let success = false;
        try {
            let result;
            // Use a savepoint for all nested scopes (depth > 1) to isolate failures
            // and allow partial success/failure within the shared transaction.
            if (this.transactionDepth > 1) {
                const savepointName = `sp_${this.transactionDepth}`;
                try {
                    await client.query(`SAVEPOINT ${savepointName}`);
                    result = await fn(client);
                    await client.query(`RELEASE SAVEPOINT ${savepointName}`);
                }
                catch (error) {
                    await client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
                    throw error;
                }
            }
            else {
                // Root scope (depth === 1) runs directly in the main transaction
                result = await fn(client);
            }
            success = true;
            return result;
        }
        catch (error) {
            // If we are the last active scope and an error occurred, we deliberately ROLLBACK.
            // Note: If depth > 1, the inner try/catch already handled the savepoint rollback,
            // so this block only handles the root transaction failure or unhandled critical errors.
            if (this.transactionDepth === 1) {
                // Ensure we don't try to rollback if already disposed or closed
                try {
                    await client.query('ROLLBACK');
                }
                catch {
                    // Ignore rollback errors (e.g. if connection closed)
                }
            }
            throw error;
        }
        finally {
            this.transactionDepth--;
            // If we are the last scope to exit, we are responsible for cleanup.
            if (this.transactionDepth === 0) {
                if (success && !this.isDisposed) {
                    try {
                        await client.query('COMMIT');
                    }
                    catch (commitError) {
                        console.error('Failed to commit transaction:', commitError);
                    }
                }
                client.release();
                this.activeClient = null;
            }
        }
    }
    /**
     * Set PostgreSQL session variables for Row-Level Security.
     */
    async setRLSVariables(client) {
        if (!this.authContext) {
            throw new GraphQLError('Unauthenticated', {
                extensions: {
                    code: 'UNAUTHENTICATED',
                },
            });
        }
        const { tenant, user, authType, activeReadScope } = this.authContext ?? {};
        // Write-target: the single business this request owns / writes to, derived
        // from the primary tenant business and the active scope. The auth context
        // already re-points `tenant.businessId` to this value; resolving it here
        // again keeps the RLS session correct as defense-in-depth.
        const businessIdValue = resolveWriteTargetBusinessId(tenant?.businessId, activeReadScope);
        if (!businessIdValue) {
            throw new Error('Missing businessId in AuthContext');
        }
        // API keys use a non-UUID identifier (e.g. "api-key:<id>") for app-level tracing.
        // The DB helper get_current_user_id() casts app.current_user_id to UUID and handles
        // empty string via NULLIF(..., ''), so we pass '' for API key sessions to avoid a
        // runtime cast error while explicitly clearing the setting.
        const userIdValue = authType === 'apiKey' ? '' : (user?.userId ?? null);
        // Read scope: the businesses this request may read from, serialized as a
        // Postgres array literal ('{uuid1,uuid2}') for get_current_business_scope().
        // When empty/absent we pass '' so the DB helper falls back to the single
        // write-target business. Writes remain pinned to app.current_business_id.
        const readScopeValue = activeReadScope && activeReadScope.businessIds.length > 0
            ? `{${activeReadScope.businessIds.map(id => `"${id.replace(/"/g, '\\"')}"`).join(',')}}`
            : '';
        await client.query(`
      SELECT
        set_config('app.current_business_id', $1, true),
        set_config('app.current_user_id', $2, true),
        set_config('app.auth_type', $3, true),
        set_config('app.current_business_scope', $4, true);
      `, [businessIdValue, userIdValue, authType, readScopeValue]);
    }
    /**
     * Manually dispose the client.
     */
    async dispose() {
        if (this.isDisposed)
            return;
        // In normal operation, activeClient should already be null because
        // executeTransactionInternal's finally block releases it.
        // If it's null, we can skip the expensive mutex acquisition.
        if (!this.activeClient) {
            this.isDisposed = true;
            return;
        }
        // If we get here, activeClient is not null, which means the transaction
        // didn't clean up properly. This is an abnormal situation (e.g., connection leak,
        // error in finally block, or dispose called prematurely).
        console.warn('TenantAwareDBClient.dispose() called with activeClient still set. Forcing cleanup.');
        // Use a timeout or race to prevent hanging indefinitely during disposal
        // If a query is stuck, we don't want to block the entire server request handler.
        const TIMEOUT_MS = 5000;
        let release;
        try {
            release = await Promise.race([
                this.mutex.acquire(),
                new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout acquiring mutex')), TIMEOUT_MS)),
            ]);
        }
        catch (e) {
            console.warn('Timeout acquiring mutex during TenantAwareDBClient disposal. Connection may be in use.', e);
            // Don't force cleanup - let executeTransactionInternal's finally block handle it.
            // Forcing cleanup here creates a race condition where we destroy a connection
            // that's actively being used, causing query failures.
            // However, mark as disposed to prevent further usage of this instance.
            this.isDisposed = true;
            return;
        }
        try {
            if (this.isDisposed)
                return;
            if (this.activeClient) {
                let hadRollbackError = false;
                try {
                    // Attempt rollback, but catch errors if connection is busy/closed
                    await Promise.race([
                        this.activeClient.query('ROLLBACK'),
                        new Promise((_, reject) => setTimeout(() => reject(new Error('Rollback timeout')), 1000)),
                    ]);
                }
                catch (error) {
                    hadRollbackError = true;
                    console.error('Error disposing TenantAwareDBClient (rollback failed):', error);
                }
                finally {
                    try {
                        // Only destroy the connection (release(true)) if there was an error.
                        // For normal disposal, return it to the pool (release()) to avoid pool exhaustion.
                        // pg's release(true) removes the connection from the pool permanently.
                        this.activeClient.release(hadRollbackError);
                    }
                    catch (e) {
                        console.error('Error releasing client during disposal:', e);
                    }
                    this.activeClient = null;
                }
            }
            this.isDisposed = true;
        }
        finally {
            if (release) {
                release();
            }
        }
    }
    ensureNotDisposed() {
        if (this.isDisposed) {
            throw new Error('TenantAwareDBClient is already disposed');
        }
    }
    /**
     * Lazy initialization of auth context on first use.
     * This ensures the async provider is called only when needed.
     */
    async ensureAuthContext() {
        if (this.authContextInitialized) {
            return;
        }
        if (!this.authContextProvider) {
            throw new GraphQLError('Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } });
        }
        this.authContext = await this.authContextProvider.getAuthContext();
        this.authContextInitialized = true;
    }
};
TenantAwareDBClient = __decorate([
    Injectable({
        scope: Scope.Operation,
        global: true,
    }),
    __metadata("design:paramtypes", [DBProvider,
        AuthContextProvider])
], TenantAwareDBClient);
export { TenantAwareDBClient };
//# sourceMappingURL=tenant-db-client.js.map