"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.applyProjectContextWriteback = exports.loadProjectContextFile = exports.serializeProjectContextFile = exports.PROJECT_CONTEXT_FILE_HEADER = exports.mergeProjectContextEntry = exports.projectContextEntrySchema = exports.formatAiProjectContextObjectRef = exports.serializeAiProjectContextObjectRef = exports.aiProjectContextObjectRefSchema = exports.aiProjectContextTypedObjectRefSchema = exports.PROJECT_CONTEXT_FILE_VERSION = exports.projectContextEntryKinds = void 0;
const tslib_1 = require("tslib");
const ajv_1 = tslib_1.__importDefault(require("ajv"));
const ajv_errors_1 = tslib_1.__importDefault(require("ajv-errors"));
const better_ajv_errors_1 = tslib_1.__importDefault(require("better-ajv-errors"));
const yaml = tslib_1.__importStar(require("js-yaml"));
const yaml_1 = require("yaml");
const zod_1 = require("zod");
const lightdash_project_context_2_0_json_1 = tslib_1.__importDefault(require("../../schemas/json/lightdash-project-context-2.0.json"));
const errors_1 = require("../../types/errors");
const assertUnreachable_1 = tslib_1.__importDefault(require("../../utils/assertUnreachable"));
// Two kinds, by retrieval intent: `definition` is term-triggered (acronyms,
// vocabulary, "X means Y"); `context` is the object-scoped catch-all
// (routing/join rules, guidance, durable facts). Splitting out more kinds later
// is additive.
exports.projectContextEntryKinds = ['definition', 'context'];
// Top-level file version. The file is `{ version, entries }`; bumping this is
// the escape hatch for a future hard schema break, without per-entry churn.
exports.PROJECT_CONTEXT_FILE_VERSION = 2;
exports.aiProjectContextTypedObjectRefSchema = zod_1.z.discriminatedUnion('type', [
    zod_1.z
        .object({
        type: zod_1.z.literal('explore'),
        name: zod_1.z.string().min(1),
    })
        .strict(),
    zod_1.z
        .object({
        type: zod_1.z.literal('field'),
        explore: zod_1.z.string().min(1),
        fieldId: zod_1.z.string().min(1),
    })
        .strict(),
]);
exports.aiProjectContextObjectRefSchema = zod_1.z.union([zod_1.z.string().min(1), exports.aiProjectContextTypedObjectRefSchema]);
const typedProjectContextObjectRefsSchema = zod_1.z.array(exports.aiProjectContextTypedObjectRefSchema);
const sanitizeLegacyProjectContextEntry = (entry) => {
    if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
        return entry;
    }
    const sanitized = { ...entry };
    if (!typedProjectContextObjectRefsSchema.safeParse(sanitized.objects)
        .success) {
        sanitized.objects = [];
    }
    return sanitized;
};
const normalizeLegacyProjectContext = (loaded) => {
    if (Array.isArray(loaded)) {
        return loaded.map(sanitizeLegacyProjectContextEntry);
    }
    if (typeof loaded !== 'object' || loaded === null) {
        return loaded;
    }
    const document = loaded;
    if (document.version !== 1 && document.version !== '1') {
        return loaded;
    }
    return {
        ...document,
        version: exports.PROJECT_CONTEXT_FILE_VERSION,
        entries: Array.isArray(document.entries)
            ? document.entries.map(sanitizeLegacyProjectContextEntry)
            : document.entries,
    };
};
const serializeAiProjectContextObjectRef = (ref) => {
    if (typeof ref === 'string') {
        return ref;
    }
    switch (ref.type) {
        case 'explore':
            return ref.name;
        case 'field':
            return `${ref.explore}\n${ref.fieldId}`;
        default:
            return (0, assertUnreachable_1.default)(ref, 'Unknown AI project context object ref type');
    }
};
exports.serializeAiProjectContextObjectRef = serializeAiProjectContextObjectRef;
const formatAiProjectContextObjectRef = (ref) => {
    if (typeof ref === 'string') {
        return ref;
    }
    switch (ref.type) {
        case 'explore':
            return `explore "${ref.name}"`;
        case 'field':
            return `field "${ref.fieldId}" in explore "${ref.explore}"`;
        default:
            return (0, assertUnreachable_1.default)(ref, 'Unknown AI project context object ref type');
    }
};
exports.formatAiProjectContextObjectRef = formatAiProjectContextObjectRef;
// Canonical, post-ingest entry: `id` is always present (derived if the file
// omitted it). This is what selection, the cache, and the API speak.
exports.projectContextEntrySchema = zod_1.z.object({
    id: zod_1.z.string().min(1),
    kind: zod_1.z.enum(exports.projectContextEntryKinds),
    content: zod_1.z.string().min(1),
    terms: zod_1.z.array(zod_1.z.string()).default([]),
    objects: zod_1.z.array(exports.aiProjectContextObjectRefSchema).default([]),
});
const slugifyId = (value) => value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
/**
 * Apply a judge-emitted entry to the current file entries, by id. A create with
 * no id derives a stable id from the first term/content (suffixed on collision);
 * an explicit id that already exists updates in place (dedup backstop).
 */
const mergeProjectContextEntry = (existing, judgeEntry) => {
    const ids = new Set(existing.map((e) => e.id));
    if (judgeEntry.op === 'update' && !judgeEntry.id) {
        throw new Error('Project context update requires an entry id');
    }
    let entryId;
    if (judgeEntry.id) {
        entryId = judgeEntry.id;
    }
    else {
        const base = slugifyId(judgeEntry.terms[0] ?? judgeEntry.content) ||
            judgeEntry.kind;
        entryId = base;
        let suffix = 1;
        while (ids.has(entryId)) {
            entryId = `${base}-${suffix}`;
            suffix += 1;
        }
    }
    const index = existing.findIndex((e) => e.id === entryId);
    const op = index >= 0 ? 'update' : 'create';
    const merged = {
        id: entryId,
        kind: judgeEntry.kind,
        content: judgeEntry.content,
        terms: judgeEntry.terms,
        objects: judgeEntry.objects,
    };
    const entries = index >= 0
        ? existing.map((e, i) => (i === index ? merged : e))
        : [...existing, merged];
    return { entries, entryId, op };
};
exports.mergeProjectContextEntry = mergeProjectContextEntry;
// Prepended when the file is first generated so the artifact a reviewer is asked
// to merge explains itself. Survives later edits because the writeback path
// preserves leading comments.
exports.PROJECT_CONTEXT_FILE_HEADER = [
    '# What your AI agents read before they answer.',
    '# Add things here to teach them your terms and how your data fits together.',
    '# Merge a change to this file and your future answers get better.',
    '# Managed from the Reviews tab under Ask AI.',
].join('\n');
const serializeProjectContextFile = (entries) => {
    if (entries.length === 0) {
        return '';
    }
    const body = yaml.dump({ version: exports.PROJECT_CONTEXT_FILE_VERSION, entries }, { lineWidth: -1 });
    return `${exports.PROJECT_CONTEXT_FILE_HEADER}\n${body}`;
};
exports.serializeProjectContextFile = serializeProjectContextFile;
// Derive a stable id from the first term (or content) when the author omitted
// one, suffixing on collision so ids stay unique within the file.
const deriveEntryId = (entry, used) => {
    const base = entry.id ?? slugifyId(entry.terms[0] ?? entry.content);
    const fallback = base === '' ? 'entry' : base;
    let id = fallback;
    let suffix = 1;
    while (used.has(id)) {
        id = `${fallback}-${suffix}`;
        suffix += 1;
    }
    return id;
};
/**
 * Load project_context YAML contents. Accepts the canonical `{ version, entries }`
 * shape and a legacy bare-array file. Invalid files throw with schema-backed
 * errors, and `id` is derived when absent.
 */
const loadProjectContextFile = (yamlContents) => {
    if (yamlContents.trim() === '') {
        return [];
    }
    let loaded;
    try {
        loaded = yaml.load(yamlContents);
    }
    catch (e) {
        throw new errors_1.ParseError(`Invalid lightdash.project_context.yml: ${e instanceof Error ? e.message : 'failed to parse YAML'}`);
    }
    const normalized = normalizeLegacyProjectContext(loaded);
    const ajv = new ajv_1.default({
        coerceTypes: true,
        allErrors: true,
        allowUnionTypes: true,
    });
    (0, ajv_errors_1.default)(ajv);
    const validate = ajv.compile(lightdash_project_context_2_0_json_1.default);
    if (!validate(normalized)) {
        const errors = (0, better_ajv_errors_1.default)(lightdash_project_context_2_0_json_1.default, loaded, validate.errors || [], { indent: 2 });
        throw new errors_1.ParseError(`Invalid lightdash.project_context.yml with errors:\n${errors}`);
    }
    const rawEntries = (Array.isArray(normalized)
        ? normalized
        : normalized.entries);
    const entries = [];
    const usedIds = new Set();
    for (const item of rawEntries) {
        const entry = { ...item };
        const explicitId = entry.id;
        const terms = entry.terms ?? [];
        const objects = entry.objects ?? [];
        delete entry.id;
        delete entry.global;
        const id = deriveEntryId({ id: explicitId, terms, content: entry.content }, usedIds);
        usedIds.add(id);
        entries.push({ ...entry, id, terms, objects });
    }
    return entries;
};
exports.loadProjectContextFile = loadProjectContextFile;
/**
 * Apply a judge-emitted entry and return the new file contents, the entry id,
 * and whether it created or updated. For a canonical `{ version, entries }`
 * file this edits the parsed YAML document in place — appending the new entry
 * (or replacing the one matching `id`) — so the resulting diff is just the
 * touched entry, with comments, quoting and key order in the rest of the file
 * preserved. Empty or legacy (bare-array) files fall back to a fresh canonical
 * serialization.
 */
const applyProjectContextWriteback = (existingContent, judgeEntry) => {
    const { entries, entryId, op } = (0, exports.mergeProjectContextEntry)((0, exports.loadProjectContextFile)(existingContent), judgeEntry);
    if (existingContent.trim() !== '') {
        const doc = (0, yaml_1.parseDocument)(existingContent);
        const entriesNode = doc.get('entries');
        if (doc.errors.length === 0 &&
            (0, yaml_1.isSeq)(entriesNode) &&
            doc.get('version') === exports.PROJECT_CONTEXT_FILE_VERSION) {
            const node = doc.createNode({
                id: entryId,
                kind: judgeEntry.kind,
                content: judgeEntry.content,
                terms: judgeEntry.terms,
                objects: judgeEntry.objects,
            });
            const index = entriesNode.items.findIndex((item) => (0, yaml_1.isMap)(item) && item.get('id') === entryId);
            if (index >= 0) {
                entriesNode.set(index, node);
            }
            else {
                entriesNode.add(node);
            }
            // flowCollectionPadding:false keeps `[a, b]` from being re-emitted
            // as `[ a, b ]`, so untouched flow lines stay byte-identical and the
            // diff is just the changed entry.
            return {
                content: doc.toString({
                    lineWidth: 0,
                    flowCollectionPadding: false,
                }),
                entryId,
                op,
                upgradesFileToV2: false,
            };
        }
    }
    return {
        content: (0, exports.serializeProjectContextFile)(entries),
        entryId,
        op,
        upgradesFileToV2: existingContent.trim() !== '',
    };
};
exports.applyProjectContextWriteback = applyProjectContextWriteback;
//# sourceMappingURL=projectContext.js.map