import Ajv from 'ajv';
import AjvErrors from 'ajv-errors';
import betterAjvErrors from 'better-ajv-errors';
import * as yaml from 'js-yaml';
import { isMap, isSeq, parseDocument } from 'yaml';
import { z } from 'zod';
import lightdashProjectContextSchema from '../../schemas/json/lightdash-project-context-1.0.json';
import { ParseError } from '../../types/errors';
// 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.
export const 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.
export const PROJECT_CONTEXT_FILE_VERSION = 1;
// Canonical, post-ingest entry: `id` is always present (derived if the file
// omitted it). This is what selection, the cache, and the API speak.
export const projectContextEntrySchema = z.object({
    id: z.string().min(1),
    kind: z.enum(projectContextEntryKinds),
    content: z.string().min(1),
    terms: z.array(z.string()).default([]),
    objects: z.array(z.string()).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).
 */
export 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 };
};
export const serializeProjectContextFile = (entries) => {
    if (entries.length === 0) {
        return '';
    }
    return yaml.dump({ version: PROJECT_CONTEXT_FILE_VERSION, entries }, { lineWidth: -1 });
};
// 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.
 */
export const loadProjectContextFile = (yamlContents) => {
    if (yamlContents.trim() === '') {
        return [];
    }
    let loaded;
    try {
        loaded = yaml.load(yamlContents);
    }
    catch (e) {
        throw new ParseError(`Invalid lightdash.project_context.yml: ${e instanceof Error ? e.message : 'failed to parse YAML'}`);
    }
    const ajv = new Ajv({
        coerceTypes: true,
        allErrors: true,
        allowUnionTypes: true,
    });
    AjvErrors(ajv);
    const validate = ajv.compile(lightdashProjectContextSchema);
    if (!validate(loaded)) {
        const errors = betterAjvErrors(lightdashProjectContextSchema, loaded, validate.errors || [], { indent: 2 });
        throw new ParseError(`Invalid lightdash.project_context.yml with errors:\n${errors}`);
    }
    const rawEntries = (Array.isArray(loaded)
        ? loaded
        : loaded.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;
};
/**
 * 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.
 */
export const applyProjectContextWriteback = (existingContent, judgeEntry) => {
    const { entries, entryId, op } = mergeProjectContextEntry(loadProjectContextFile(existingContent), judgeEntry);
    if (existingContent.trim() !== '') {
        const doc = parseDocument(existingContent);
        const entriesNode = doc.get('entries');
        if (doc.errors.length === 0 && isSeq(entriesNode)) {
            if (doc.get('version') === undefined) {
                doc.set('version', 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) => 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,
            };
        }
    }
    return { content: serializeProjectContextFile(entries), entryId, op };
};
//# sourceMappingURL=projectContext.js.map