import { DomainError } from './tags.js';
export class VersionService {
    db;
    // M17: lazily wired by index.ts after pluginHost.consolidate. When present,
    // entity services prefer captureSnapshot(...) over createVersion(...) so
    // entity_version.data carries the full snapshot (decyzja 4 — portable identity).
    snapshotDeps = null;
    constructor(db) {
        this.db = db;
    }
    /** M17: wire snapshot dependencies. Called once during server bootstrap. */
    configureSnapshot(reader, host) {
        this.snapshotDeps = { reader, host };
    }
    /**
     * M17: capture a deterministic snapshot of an entity into entity_version.
     * Falls back to `createVersion(..., entity, ...)` if snapshot deps not wired
     * yet (very early in bootstrap or for tests). For `op = 'delete'`, calls
     * host.snapshot BEFORE the caller deletes the row — pass `previousSnapshot`
     * explicitly to capture a tombstone with last-known data.
     */
    captureEntitySnapshot(type, entityId, op, actor, summary, serializerVersion) {
        if (!this.snapshotDeps) {
            // Fallback: deps not yet wired. Store legacy domain object via createVersion.
            return this.createVersion(type, entityId, null, actor, summary, op, serializerVersion);
        }
        // For all ops (including delete), snapshot the entity *as it currently is*
        // (callers must call this BEFORE the row is deleted from its table).
        const rawEntity = this.snapshotDeps.reader.getEntityById(type, entityId);
        if (!rawEntity) {
            // Entity not found — fall back to last-known snapshot so tombstone has
            // restorable data. This path is only hit if a caller calls capture
            // post-delete (anti-pattern but defensive).
            const last = this.getLatestVersionForEntity(type, entityId);
            return this.createVersion(type, entityId, last?.data ?? null, actor, summary, op, serializerVersion);
        }
        const ctx = { reader: this.snapshotDeps.reader, depth: 0, maxDepth: 1 };
        const snapshot = this.snapshotDeps.host.snapshot(type, rawEntity, ctx);
        return this.createVersion(type, entityId, snapshot, actor, summary, op, serializerVersion);
    }
    /**
     * Capture a new version. M17 augments the row with `op` and
     * `serializer_version`; `release_id` always starts NULL and is assigned by
     * `releaseService.createRelease()`.
     */
    createVersion(entityType, entityId, data, changedBy, changeSummary, op, serializerVersion) {
        const next = this.nextVersionNumber(entityType, entityId);
        const inferredOp = op ?? (data === null ? 'delete' : (next === 1 ? 'create' : 'update'));
        const info = this.db
            .prepare(`INSERT INTO entity_version
           (entity_type, entity_id, version, data, changed_by, change_summary, op, serializer_version)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
            .run(entityType, entityId, next, JSON.stringify(data), changedBy, changeSummary ?? null, inferredOp, serializerVersion ?? null);
        const row = this.db
            .prepare(`SELECT * FROM entity_version WHERE id = ?`)
            .get(info.lastInsertRowid);
        return this.toListItem(row);
    }
    listVersions(entityType, entityId) {
        const rows = this.db
            .prepare(`SELECT id, version, changed_by, change_summary, created_at, release_id, op
           FROM entity_version
          WHERE entity_type = ? AND entity_id = ?
          ORDER BY version DESC`)
            .all(entityType, entityId);
        return rows.map((r) => ({
            id: r.id,
            version: r.version,
            changedBy: r.changed_by,
            changeSummary: r.change_summary,
            createdAt: r.created_at,
            ...(r.release_id !== null ? { releaseId: r.release_id } : {}),
            ...(r.op ? { op: r.op } : {}),
        }));
    }
    getVersion(entityType, entityId, version) {
        const row = this.db
            .prepare(`SELECT * FROM entity_version
          WHERE entity_type = ? AND entity_id = ? AND version = ?`)
            .get(entityType, entityId, version);
        if (!row)
            return null;
        return this.toDetail(row);
    }
    /**
     * M17: latest captured version of an entity at-or-before a given release.
     * `releaseId === null` returns the latest unreleased capture (release_id IS NULL).
     * `releaseId === undefined` returns the latest overall (no release filter).
     */
    getLatestVersionForEntity(entityType, entityId, releaseId) {
        let row;
        if (releaseId === undefined) {
            row = this.db
                .prepare(`SELECT * FROM entity_version
            WHERE entity_type = ? AND entity_id = ?
            ORDER BY version DESC LIMIT 1`)
                .get(entityType, entityId);
        }
        else if (releaseId === null) {
            row = this.db
                .prepare(`SELECT * FROM entity_version
            WHERE entity_type = ? AND entity_id = ? AND release_id IS NULL
            ORDER BY version DESC LIMIT 1`)
                .get(entityType, entityId);
        }
        else {
            row = this.db
                .prepare(`SELECT * FROM entity_version
            WHERE entity_type = ? AND entity_id = ? AND release_id IS NOT NULL AND release_id <= ?
            ORDER BY version DESC LIMIT 1`)
                .get(entityType, entityId, releaseId);
        }
        return row ? this.toDetail(row) : null;
    }
    diff(entityType, entityId, fromVersion, toVersion) {
        const from = this.getVersion(entityType, entityId, fromVersion);
        const to = this.getVersion(entityType, entityId, toVersion);
        if (!from || !to)
            throw new DomainError('NOT_FOUND', 'version not found');
        return { from, to, changes: computeDiff(from.data, to.data) };
    }
    nextVersionNumber(entityType, entityId) {
        const row = this.db
            .prepare(`SELECT MAX(version) AS v FROM entity_version WHERE entity_type = ? AND entity_id = ?`)
            .get(entityType, entityId);
        return (row.v ?? 0) + 1;
    }
    toListItem(row) {
        return {
            id: row.id,
            version: row.version,
            changedBy: row.changed_by,
            changeSummary: row.change_summary,
            createdAt: row.created_at,
            ...(row.release_id !== null ? { releaseId: row.release_id } : {}),
            ...(row.op ? { op: row.op } : {}),
        };
    }
    toDetail(row) {
        return {
            id: row.id,
            entityType: row.entity_type,
            entityId: row.entity_id,
            version: row.version,
            data: safeParse(row.data),
            changedBy: row.changed_by,
            changeSummary: row.change_summary,
            createdAt: row.created_at,
            ...(row.release_id !== null ? { releaseId: row.release_id } : {}),
            ...(row.op ? { op: row.op } : {}),
        };
    }
}
function safeParse(raw) {
    try {
        return JSON.parse(raw);
    }
    catch {
        return raw;
    }
}
export function computeDiff(from, to, prefix = '') {
    if (deepEqual(from, to))
        return [];
    if (!isObj(from) || !isObj(to))
        return [{ path: prefix || '/', from, to }];
    const keys = new Set([...Object.keys(from), ...Object.keys(to)]);
    const changes = [];
    for (const key of keys) {
        const sub = prefix ? `${prefix}.${key}` : key;
        changes.push(...computeDiff(from[key], to[key], sub));
    }
    return changes;
}
function isObj(v) {
    return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function deepEqual(a, b) {
    return JSON.stringify(a) === JSON.stringify(b);
}
//# sourceMappingURL=versions.js.map