/**
 * @fileoverview Canvas lifecycle registry. Keys canvases by (tenantId, canvasId),
 * generates crypto-secure 10-char URL-safe IDs, enforces a sliding 24h TTL
 * with a 7-day absolute cap, runs a periodic sweeper, and caps active canvases
 * per tenant (default 100). Also tracks per-table TTLs; the sweep loop drops
 * expired tables before evaluating canvas-level expiry. Public access is gated
 * by {@link DataCanvas}.
 * @module src/services/canvas/core/CanvasRegistry
 */
import { conflict, notFound, rateLimited } from '../../../types-global/errors.js';
import { logger } from '../../../utils/internal/logger.js';
import { requestContextService } from '../../../utils/internal/requestContext.js';
import { IdGenerator } from '../../../utils/security/idGenerator.js';
/**
 * Canvas ID character set — URL-safe alphabet matching `nanoid`'s default
 * (A-Z, a-z, 0-9, `-`, `_`). 10 chars × 64 alphabet ≈ 1.15 × 10^18 keyspace.
 */
const CANVAS_ID_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const CANVAS_ID_LENGTH = 10;
const CANVAS_ID_REGEX = /^[A-Za-z0-9_-]{10}$/;
/**
 * Default lifecycle constants. Mirrored in the config schema; exported so
 * tests and downstream tooling can reference the same numbers.
 */
export const DEFAULT_CANVAS_REGISTRY_OPTIONS = {
    ttlMs: 24 * 60 * 60 * 1000,
    absoluteCapMs: 7 * 24 * 60 * 60 * 1000,
    maxCanvasesPerTenant: 100,
    sweeperIntervalMs: 60 * 1000,
};
/**
 * Tracks active canvases for a single process. Not multi-process safe — tokens
 * issued by one process are not portable to another.
 */
export class CanvasRegistry {
    provider;
    options;
    clock;
    idGenerator = new IdGenerator();
    canvases = new Map();
    /** Per-tenant index for cap enforcement and listing. */
    byTenant = new Map();
    sweeperTimer;
    isShuttingDown = false;
    constructor(provider, options = DEFAULT_CANVAS_REGISTRY_OPTIONS, 
    /** Injected for tests. Defaults to `Date.now`. */
    clock = Date.now) {
        this.provider = provider;
        this.options = options;
        this.clock = clock;
        if (options.sweeperIntervalMs > 0) {
            this.sweeperTimer = setInterval(() => void this.sweep(), options.sweeperIntervalMs);
            this.sweeperTimer.unref?.();
        }
    }
    /**
     * Resolve an existing canvas or create a new one when `maybeId` is omitted
     * or the supplied id is unknown for the caller's tenant.
     *
     * - Omitted id → create fresh, return `isNew: true`.
     * - Unknown id → throw `NotFound` (`reason: 'canvas_not_found'` + recovery hint).
     * - Known id under wrong tenant → throw `NotFound` (uniform with unknown
     *   to avoid leaking existence across tenants).
     * - Known + own tenant → touch (extend TTL), return `isNew: false`.
     */
    async acquire(maybeId, tenantId, context) {
        if (this.isShuttingDown) {
            throw notFound('Canvas registry is shutting down.', { tenantId });
        }
        if (maybeId !== undefined) {
            const record = this.lookup(maybeId, tenantId);
            if (!record) {
                throw canvasNotFound(maybeId);
            }
            this.touch(record);
            return {
                canvasId: record.canvasId,
                tenantId: record.tenantId,
                isNew: false,
                expiresAt: new Date(record.expiresAt).toISOString(),
            };
        }
        this.enforceTenantCap(tenantId);
        const canvasId = this.mintId();
        const now = this.clock();
        const record = {
            canvasId,
            tenantId,
            createdAt: now,
            lastAccessedAt: now,
            expiresAt: now + this.options.ttlMs,
            tableTtl: new Map(),
        };
        this.canvases.set(canvasId, record);
        this.indexByTenant(tenantId, canvasId);
        await this.provider.initCanvas(canvasId, context);
        logger.debug('Canvas created.', {
            ...context,
            canvasId,
            tenantId,
            provider: this.provider.name,
        });
        return {
            canvasId,
            tenantId,
            isNew: true,
            expiresAt: new Date(record.expiresAt).toISOString(),
        };
    }
    /**
     * Validate that `canvasId` belongs to `tenantId` and is not expired, then
     * extend its TTL and return the resolved expiry. Used by {@link CanvasInstance}
     * before every op so individual operations slide the canvas window.
     */
    touchOrThrow(canvasId, tenantId) {
        const record = this.lookup(canvasId, tenantId);
        if (!record) {
            throw canvasNotFound(canvasId);
        }
        this.touch(record);
        return new Date(record.expiresAt).toISOString();
    }
    /**
     * Slide the canvas TTL **and** the per-table TTL for `tableName` (if it has
     * one). Throws `NotFound` when the canvas is gone or expired. Used by
     * {@link CanvasInstance} on table-scoped ops (`registerTable`, `query`).
     */
    touchWithTable(canvasId, tenantId, tableName) {
        const record = this.lookup(canvasId, tenantId);
        if (!record) {
            throw canvasNotFound(canvasId);
        }
        this.touch(record);
        this.touchTableRecord(record, tableName);
        return new Date(record.expiresAt).toISOString();
    }
    /**
     * Slide per-table TTLs for every table name that appears as a word-boundary
     * match in `sqlText`, in addition to the canvas-level touch and an explicit
     * `primaryTable` slide. Used by `query()` to slide all tables referenced in
     * the SQL, not just the `registerAs` target.
     */
    touchWithSqlTables(canvasId, tenantId, primaryTable, sqlText) {
        const record = this.lookup(canvasId, tenantId);
        if (!record) {
            throw canvasNotFound(canvasId);
        }
        this.touch(record);
        if (primaryTable !== undefined) {
            this.touchTableRecord(record, primaryTable);
        }
        for (const tableName of record.tableTtl.keys()) {
            if (tableName !== primaryTable && wordBoundaryMatch(sqlText, tableName)) {
                this.touchTableRecord(record, tableName);
            }
        }
        return new Date(record.expiresAt).toISOString();
    }
    /**
     * Register (or replace) a per-table TTL entry. Called by {@link CanvasInstance}
     * after a successful `registerTable` with `ttlMs` set.
     */
    registerTableTtl(canvasId, tenantId, tableName, ttlMs) {
        const record = this.lookup(canvasId, tenantId);
        if (!record)
            return; // canvas gone between registerTable and this call — no-op
        const now = this.clock();
        record.tableTtl.set(tableName, { ttlMs, expiresAt: now + ttlMs });
    }
    /**
     * Remove per-table TTL bookkeeping for `tableName`. Called when a table is
     * explicitly dropped via {@link CanvasInstance.drop} so the sweep loop does
     * not attempt to drop a table that no longer exists.
     */
    dropTableBookkeeping(canvasId, tenantId, tableName) {
        const record = this.lookup(canvasId, tenantId);
        if (!record)
            return;
        record.tableTtl.delete(tableName);
    }
    /**
     * Clear all per-table TTL bookkeeping for a canvas. Called when
     * {@link CanvasInstance.clear} drops all tables so stale entries don't
     * accumulate in the map.
     */
    clearTableBookkeeping(canvasId, tenantId) {
        const record = this.lookup(canvasId, tenantId);
        if (!record)
            return;
        record.tableTtl.clear();
    }
    /**
     * Merge per-table `expiresAt` annotations into a `TableInfo[]` array
     * returned by the provider's `describe()`. Tables without a per-table TTL
     * are returned as-is; tables with one get an `expiresAt` field injected.
     */
    annotateDescribeResult(canvasId, tenantId, tables) {
        const record = this.lookup(canvasId, tenantId);
        if (!record || record.tableTtl.size === 0)
            return tables;
        return tables.map((t) => {
            const entry = record.tableTtl.get(t.name);
            if (!entry)
                return t;
            return { ...t, expiresAt: new Date(entry.expiresAt).toISOString() };
        });
    }
    /**
     * Drop a canvas explicitly (e.g. tenant-initiated cleanup). Returns true
     * when the canvas existed and was destroyed.
     */
    async drop(canvasId, tenantId, context) {
        const record = this.lookup(canvasId, tenantId);
        if (!record)
            return false;
        await this.destroy(record, context);
        return true;
    }
    /** Active canvas count for a tenant (used by tests and metrics surfaces). */
    countForTenant(tenantId) {
        return this.byTenant.get(tenantId)?.size ?? 0;
    }
    /** Total active canvases (used by tests and metrics surfaces). */
    totalActive() {
        return this.canvases.size;
    }
    /** Stop the sweeper and tear down every active canvas. Idempotent. */
    async shutdown(context) {
        if (this.isShuttingDown)
            return;
        this.isShuttingDown = true;
        if (this.sweeperTimer) {
            clearInterval(this.sweeperTimer);
            this.sweeperTimer = undefined;
        }
        const records = Array.from(this.canvases.values());
        await Promise.allSettled(records.map((r) => this.destroy(r, context)));
        await this.provider.shutdown();
    }
    /**
     * Alias for {@link shutdown} with a synthesized sweep context. Enables
     * `await using registry = new CanvasRegistry(...)` with TypeScript's explicit
     * resource management (ES2022 `await using` declarations).
     */
    async [Symbol.asyncDispose]() {
        const context = requestContextService.createRequestContext({
            operation: 'CanvasRegistry.asyncDispose',
        });
        await this.shutdown(context);
    }
    /**
     * Run one sweep pass. Per-table expiry is checked first: expired tables are
     * dropped via `provider.drop()` and their bookkeeping cleared. Then the
     * existing canvas-level check runs — the two clocks are fully independent;
     * a canvas survives a table drop as long as its own window is still live.
     *
     * @internal Test/diagnostic hook — also called by the periodic sweeper timer.
     */
    async sweep() {
        if (this.isShuttingDown)
            return;
        const now = this.clock();
        const sweepContext = requestContextService.createRequestContext({
            operation: 'CanvasRegistry.sweep',
        });
        // --- Pass 1: per-table expiry ---
        // Collect expired entries before mutating the map to avoid iterator skips.
        const expiredTables = [];
        for (const record of this.canvases.values()) {
            for (const [tableName, entry] of record.tableTtl) {
                if (now >= entry.expiresAt) {
                    expiredTables.push({ canvasId: record.canvasId, tableName });
                }
            }
        }
        let droppedTableCount = 0;
        for (const { canvasId, tableName } of expiredTables) {
            const record = this.canvases.get(canvasId);
            if (!record)
                continue; // canvas already destroyed by an earlier iteration
            try {
                await this.provider.drop(canvasId, tableName, sweepContext);
                record.tableTtl.delete(tableName);
                droppedTableCount += 1;
            }
            catch (err) {
                // Bookkeeping is kept so the next sweep pass retries the drop.
                logger.warning('Provider drop failed during per-table sweep.', {
                    ...sweepContext,
                    canvasId,
                    tableName,
                    error: err instanceof Error ? err.message : String(err),
                });
            }
        }
        if (droppedTableCount > 0) {
            logger.debug('Canvas sweeper dropped expired tables.', {
                ...sweepContext,
                droppedTableCount,
            });
        }
        // --- Pass 2: canvas-level expiry (unchanged semantics) ---
        const expired = [];
        for (const record of this.canvases.values()) {
            if (now >= record.expiresAt || now - record.createdAt >= this.options.absoluteCapMs) {
                expired.push(record);
            }
        }
        if (expired.length === 0)
            return;
        await Promise.allSettled(expired.map((r) => this.destroy(r, sweepContext)));
        logger.debug('Canvas sweeper destroyed expired canvases.', {
            ...sweepContext,
            destroyedCount: expired.length,
        });
    }
    // ---------------------------------------------------------------------
    // Internals
    // ---------------------------------------------------------------------
    lookup(canvasId, tenantId) {
        if (!CANVAS_ID_REGEX.test(canvasId))
            return;
        const record = this.canvases.get(canvasId);
        if (!record)
            return;
        if (record.tenantId !== tenantId)
            return;
        const now = this.clock();
        if (now >= record.expiresAt || now - record.createdAt >= this.options.absoluteCapMs) {
            return;
        }
        return record;
    }
    touch(record) {
        const now = this.clock();
        record.lastAccessedAt = now;
        const slidingExpiry = now + this.options.ttlMs;
        const absoluteExpiry = record.createdAt + this.options.absoluteCapMs;
        record.expiresAt = Math.min(slidingExpiry, absoluteExpiry);
    }
    touchTableRecord(record, tableName) {
        const entry = record.tableTtl.get(tableName);
        if (!entry)
            return;
        entry.expiresAt = this.clock() + entry.ttlMs;
    }
    enforceTenantCap(tenantId) {
        const count = this.byTenant.get(tenantId)?.size ?? 0;
        if (count >= this.options.maxCanvasesPerTenant) {
            throw rateLimited(`Tenant has reached the active canvas cap (${this.options.maxCanvasesPerTenant}). Drop unused canvases or wait for the sliding TTL to expire them.`, { tenantId, activeCount: count, cap: this.options.maxCanvasesPerTenant });
        }
    }
    mintId() {
        for (let i = 0; i < 5; i += 1) {
            const id = this.idGenerator.generateRandomString(CANVAS_ID_LENGTH, CANVAS_ID_CHARSET);
            if (!this.canvases.has(id))
                return id;
        }
        throw conflict('Failed to generate a unique canvas ID after 5 attempts.');
    }
    indexByTenant(tenantId, canvasId) {
        let set = this.byTenant.get(tenantId);
        if (!set) {
            set = new Set();
            this.byTenant.set(tenantId, set);
        }
        set.add(canvasId);
    }
    async destroy(record, context) {
        this.canvases.delete(record.canvasId);
        // Clear per-table bookkeeping so stale entries don't survive a re-use.
        record.tableTtl.clear();
        const set = this.byTenant.get(record.tenantId);
        if (set) {
            set.delete(record.canvasId);
            if (set.size === 0)
                this.byTenant.delete(record.tenantId);
        }
        try {
            await this.provider.destroyCanvas(record.canvasId, context);
        }
        catch (err) {
            logger.warning('Provider destroyCanvas failed during sweep.', {
                ...context,
                canvasId: record.canvasId,
                error: err instanceof Error ? err.message : String(err),
            });
        }
    }
}
// ---------------------------------------------------------------------------
// Module-level helpers
// ---------------------------------------------------------------------------
/**
 * Structured `NotFound` for a missing or expired canvas. Carries
 * `reason: 'canvas_not_found'` plus a default `recovery.hint` so consumer
 * tools that declare a `canvas_not_found` error contract surface the reason
 * and recovery on the wire — the throw happens inside the framework, before
 * handler code runs, so this is the only place the fields can originate (#261).
 * The hint deliberately avoids suggesting omission of `canvas_id`: omission
 * mints a fresh, empty canvas — rarely what a describe/query-style tool wants
 * with a stale id — and a tool that requires the parameter would reject the
 * retry, looping the agent.
 */
function canvasNotFound(canvasId) {
    return notFound('Canvas not found or expired.', {
        reason: 'canvas_not_found',
        canvasId,
        recovery: {
            hint: 'Re-run the tool that produced this canvas_id to stage fresh data, or verify the id was copied correctly.',
        },
    });
}
/**
 * Return true when `tableName` appears in `sqlText` as a whole word (i.e.
 * surrounded by non-identifier characters or at text boundaries). A false
 * positive (a name appearing inside a string literal) merely slides the table
 * TTL unnecessarily — harmless. A SQL parser is not warranted.
 */
function wordBoundaryMatch(sqlText, tableName) {
    // Word characters for SQL identifiers: letters, digits, underscore.
    const pattern = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(tableName)}(?![A-Za-z0-9_])`);
    return pattern.test(sqlText);
}
function escapeRegExp(s) {
    return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
//# sourceMappingURL=CanvasRegistry.js.map