/**
 * CAS-backed seek materialization cache adapter.
 *
 * Implements SeekCachePort using @git-stunts/git-cas for persistent storage
 * of serialized WarpState snapshots. Each cached state is stored as a CAS
 * asset (chunked blobs + manifest tree), and an index ref tracks the mapping
 * from cache keys to tree OIDs.
 *
 * Index ref: `refs/warp/<graphName>/seek-cache` → blob containing JSON index.
 *
 * Blobs are loose Git objects — `git gc` prunes them using the configured
 * prune expiry (default ~2 weeks). Use vault pinning for GC-safe persistence.
 *
 * **Requires Node >= 22.0.0** (inherited from `@git-stunts/git-cas`).
 *
 * @module infrastructure/adapters/CasSeekCacheAdapter
 */
import SeekCachePort from "../../ports/SeekCachePort.js";
import { buildSeekCacheRef } from "../../domain/utils/RefLayout.js";
import { createLazyCas } from "./lazyCasInit.js";
import { createCdcCasStore } from "./CasStoreFactory.js";
import CacheError from "../../domain/errors/CacheError.js";
import { textEncode, textDecode, concatBytes } from "../../domain/utils/bytes.js";
import CasContentEncryptionPolicy, { mapCasContentEncryptionError, } from "./CasContentEncryptionPolicy.js";
import { Readable } from 'node:stream';
const DEFAULT_MAX_ENTRIES = 200;
const INDEX_SCHEMA_VERSION = 1;
const MAX_CAS_RETRIES = 3;
function _emptyEntries() {
    return Object.create(null);
}
function _emptyIndex() {
    return { schemaVersion: INDEX_SCHEMA_VERSION, entries: _emptyEntries() };
}
function _isObjectRecord(value) {
    return typeof value === 'object' && value !== null;
}
function _normalizeIndexEntries(entries) {
    const normalized = _emptyEntries();
    if (!_isObjectRecord(entries)) {
        return normalized;
    }
    for (const [key, value] of Object.entries(entries)) {
        if (_isObjectRecord(value)) {
            normalized[key] = value;
        }
    }
    return normalized;
}
function _parseIndexBlob(buf) {
    const parsed = JSON.parse(textDecode(buf));
    const candidate = parsed;
    if (typeof parsed === 'object' &&
        parsed !== null &&
        candidate.schemaVersion === INDEX_SCHEMA_VERSION) {
        return {
            schemaVersion: INDEX_SCHEMA_VERSION,
            entries: _normalizeIndexEntries(candidate.entries),
        };
    }
    return _emptyIndex();
}
export default class CasSeekCacheAdapter extends SeekCachePort {
    _persistence;
    _plumbing;
    _maxEntries;
    _ref;
    _encryptionKey;
    _contentEncryption;
    _logger;
    _getCas;
    constructor({ persistence, plumbing, graphName, maxEntries, encryptionKey, contentEncryption, logger }) {
        super();
        this._persistence = persistence;
        this._plumbing = plumbing;
        this._maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
        this._ref = buildSeekCacheRef(graphName);
        this._encryptionKey = encryptionKey;
        this._contentEncryption = resolveContentEncryption(contentEncryption, this._encryptionKey);
        this._logger = logger;
        this._getCas = createLazyCas(() => this._initCas());
    }
    async _initCas() {
        return await createCdcCasStore({
            plumbing: this._plumbing,
            logger: this._logger,
        });
    }
    // ---------------------------------------------------------------------------
    // Index management
    // ---------------------------------------------------------------------------
    async _readIndex() {
        const oid = await this._persistence.readRef(this._ref);
        if (typeof oid !== 'string' || oid.length === 0) {
            return _emptyIndex();
        }
        try {
            const buf = await this._persistence.readBlob(oid);
            return _parseIndexBlob(buf);
        }
        catch {
            return _emptyIndex();
        }
    }
    async _writeIndex(index) {
        const json = JSON.stringify(index);
        const oid = await this._persistence.writeBlob(textEncode(json));
        await this._persistence.updateRef(this._ref, oid);
    }
    async _mutateIndex(mutate) {
        let lastErr;
        for (let attempt = 0; attempt < MAX_CAS_RETRIES; attempt++) {
            const index = await this._readIndex();
            const mutated = mutate(index);
            try {
                await this._writeIndex(mutated);
                return mutated;
            }
            catch (err) {
                lastErr = err;
                if (attempt === MAX_CAS_RETRIES - 1) {
                    throw new CacheError(`CasSeekCacheAdapter: index update failed after retries: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
                }
            }
        }
        /* c8 ignore next - unreachable */
        throw new CacheError('CasSeekCacheAdapter: index update failed');
    }
    _entryTimestamp(entry) {
        if (typeof entry.lastAccessedAt === 'string' && entry.lastAccessedAt.length > 0) {
            return entry.lastAccessedAt;
        }
        if (typeof entry.createdAt === 'string' && entry.createdAt.length > 0) {
            return entry.createdAt;
        }
        return '';
    }
    _enforceMaxEntries(index) {
        const keys = Object.keys(index.entries);
        if (keys.length <= this._maxEntries) {
            return index;
        }
        const sorted = keys.sort((a, b) => {
            const entryA = index.entries[a];
            const entryB = index.entries[b];
            if (entryA === undefined || entryB === undefined) {
                return 0;
            }
            const ta = this._entryTimestamp(entryA);
            const tb = this._entryTimestamp(entryB);
            if (ta < tb) {
                return -1;
            }
            return ta > tb ? 1 : 0;
        });
        const toEvict = sorted.slice(0, keys.length - this._maxEntries);
        for (const k of toEvict) {
            delete index.entries[k];
        }
        return index;
    }
    _parseKey(key) {
        const colonIdx = key.indexOf(':');
        const rest = colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
        const dashIdx = rest.indexOf('-');
        const ceiling = parseInt(rest.slice(1, dashIdx), 10);
        const frontierHash = rest.slice(dashIdx + 1);
        return { ceiling, frontierHash };
    }
    // ---------------------------------------------------------------------------
    // Restore helpers
    // ---------------------------------------------------------------------------
    async _restoreBuffer(cas, restoreOpts) {
        if (typeof cas.restoreStream === 'function') {
            const stream = cas.restoreStream(restoreOpts);
            const chunks = [];
            for await (const chunk of stream) {
                chunks.push(chunk);
            }
            if (chunks.length === 1 && chunks[0] !== undefined) {
                return chunks[0];
            }
            return concatBytes(...chunks);
        }
        const { buffer } = await cas.restore(restoreOpts);
        return buffer;
    }
    // ---------------------------------------------------------------------------
    // SeekCachePort implementation
    // ---------------------------------------------------------------------------
    async get(key) {
        const cas = await this._getCas();
        const index = await this._readIndex();
        const entry = index.entries[key];
        if (entry === null || entry === undefined) {
            return null;
        }
        try {
            return await this._getEntry(cas, key, entry);
        }
        catch (err) {
            const encryptionError = mapCasContentEncryptionError(err, 'seek-cache');
            if (encryptionError !== null) {
                throw encryptionError;
            }
            await this._mutateIndex((idx) => {
                delete idx.entries[key];
                return idx;
            });
            return null;
        }
    }
    async _getEntry(cas, key, entry) {
        const manifest = await cas.readManifest({ treeOid: entry.treeOid });
        const restoreOpts = {
            manifest,
            ...this._contentEncryption.toRestoreOptions(),
        };
        const buffer = await this._restoreBuffer(cas, restoreOpts);
        await this._mutateIndex((idx) => {
            const tracked = idx.entries[key];
            if (tracked !== null && tracked !== undefined) {
                tracked.lastAccessedAt = new Date().toISOString();
            }
            return idx;
        });
        const result = { buffer };
        if (typeof entry.indexTreeOid === 'string' && entry.indexTreeOid.length > 0) {
            result.indexTreeOid = entry.indexTreeOid;
        }
        return result;
    }
    async set(key, buffer, options) {
        const cas = await this._getCas();
        const { ceiling, frontierHash } = this._parseKey(key);
        const { treeOid } = await this._storeCasAsset(cas, key, buffer);
        await this._mutateIndex((index) => {
            const entry = {
                treeOid,
                createdAt: new Date().toISOString(),
                ceiling,
                frontierHash,
                sizeBytes: buffer.length,
                codec: 'cbor-v1',
                schemaVersion: INDEX_SCHEMA_VERSION,
            };
            if (typeof options?.indexTreeOid === 'string' && options.indexTreeOid.length > 0) {
                entry.indexTreeOid = options.indexTreeOid;
            }
            index.entries[key] = entry;
            return this._enforceMaxEntries(index);
        });
    }
    async _storeCasAsset(cas, key, buffer) {
        const source = Readable.from([buffer]);
        const storeOpts = {
            source,
            slug: key,
            filename: 'state.cbor',
            ...this._contentEncryption.toStoreOptions(),
        };
        const manifest = await cas.store(storeOpts);
        const treeOid = await cas.createTree({ manifest });
        return { manifest, treeOid };
    }
    async has(key) {
        const index = await this._readIndex();
        return Object.hasOwn(index.entries, key);
    }
    async keys() {
        const index = await this._readIndex();
        return Object.keys(index.entries);
    }
    async delete(key) {
        let existed = false;
        await this._mutateIndex((index) => {
            existed = Object.hasOwn(index.entries, key);
            delete index.entries[key];
            return index;
        });
        return existed;
    }
    async clear() {
        try {
            await this._persistence.deleteRef(this._ref);
        }
        catch {
            // Ref may not exist — that's fine
        }
    }
}
function resolveContentEncryption(contentEncryption, encryptionKey) {
    if (contentEncryption !== undefined) {
        return contentEncryption;
    }
    if (encryptionKey !== undefined) {
        return CasContentEncryptionPolicy.fromInternalResolvedKey({ encryptionKey });
    }
    return CasContentEncryptionPolicy.disabled();
}
