/**
 * Schema loader — parses every `platform/plugins/memory/references/schema-*.md`
 * file at server startup and returns a structured LoadedSchema that the
 * validator consults on every write.
 *
 * Design decisions (see Task 197):
 *
 * - Header-keyed table parsing. The base schema has 5 columns
 *   (Entity | Neo4j Label | Schema.org Type | Interchange Standard |
 *   Required Properties) while vertical schemas have 4 columns (no
 *   Interchange Standard). We resolve "Neo4j Label" and "Required Properties"
 *   by header name, never by index.
 *
 * - Parenthetical annotations in the Required Properties column are type /
 *   enum / policy hints, not property names:
 *       customer (id)                 -> "customer"
 *       listingType (sale/let)        -> "listingType"
 *       status (scope must be `admin`) -> "status"
 *   We strip the first `(...)` suffix and its contents from every comma-split
 *   token before emitting the property name.
 *
 * - Disjunctions in the Required Properties column use the literal word `or`:
 *       `email` or `telephone`        -> ["email", "telephone"] (any-of)
 *   We split on `\s+or\s+` per token (not per cell) so that a row like
 *   "accountId, email or telephone" yields two groups: one simple, one any-of.
 *
 * - Inheritance sentinel. estate-agent's Applicant and hospitality's
 *   GuestProfile use the literal phrase "Base Person properties + …" in the
 *   required-properties column. A second pass resolves these by merging the
 *   referenced parent label's groups.
 *
 * - Property naming rules (schema-base.md only). The `| Correct | Wrong |`
 *   table yields a `wrongToCorrect` map. Rows whose wrong-column contains
 *   "(on" are context-dependent (e.g. `status` is wrong on Order but valid
 *   on Task/Lead) and are skipped to avoid false positives. A future
 *   per-label synonym system could reintroduce them.
 *
 * Failures throw. A memory server running with a half-parsed schema is
 * worse than one that fails to start.
 */
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripBackticks(token) {
    return token.replace(/`/g, "").trim();
}
/**
 * Strip the first parenthetical suffix and its contents from a token.
 * Handles nested/balanced parens conservatively by just cutting at the first `(`.
 *
 *   "customer (id)"            -> "customer"
 *   "status (scope must be `admin`)" -> "status"
 *   "name (question text)"     -> "name"
 *   "email"                    -> "email"
 */
function stripParentheticalHint(token) {
    const parenIdx = token.indexOf("(");
    if (parenIdx === -1)
        return token.trim();
    return token.slice(0, parenIdx).trim();
}
/**
 * Normalise a raw required-property token to a canonical property name.
 * Strips backticks, parenthetical hints, leading/trailing whitespace.
 */
function normalisePropName(raw) {
    return stripParentheticalHint(stripBackticks(raw));
}
/**
 * Parse one comma-separated token from the Required Properties cell into a
 * RequiredGroup. The token may be a simple name or a "X or Y" disjunction.
 */
function parseRequiredToken(token) {
    const trimmed = token.trim();
    if (!trimmed || trimmed === "—" || trimmed === "-")
        return null;
    // Detect disjunction: split on the literal word `or` surrounded by whitespace.
    // We check the RAW token (not post-strip) because backticks and parens may
    // surround the disjuncts: "`email` or `telephone`".
    const orParts = trimmed.split(/\s+or\s+/);
    if (orParts.length > 1) {
        const names = orParts
            .map((p) => normalisePropName(p))
            .filter((n) => n.length > 0);
        if (names.length === 0)
            return null;
        if (names.length === 1)
            return { kind: "simple", name: names[0] };
        return { kind: "any-of", names };
    }
    const name = normalisePropName(trimmed);
    if (!name)
        return null;
    return { kind: "simple", name };
}
/**
 * Split a Required Properties cell on commas, but only on commas that are
 * NOT inside a `(...)` parenthetical hint. Walks the string tracking paren
 * depth. Essential for rows like
 *
 *     `accountId`, `type` (gas safe, NICEIC, etc.), `issuedDate`
 *
 * where a naive `trimmed.split(",")` would shred the hint's internal commas
 * into bogus top-level tokens (`NICEIC`, `etc.)`) and produce unsatisfiable
 * required-property rules.
 *
 * Nested parens are supported defensively (depth counter, not a boolean),
 * though current schemas don't use them.
 */
function splitTopLevelCommas(cell) {
    const tokens = [];
    let depth = 0;
    let start = 0;
    for (let i = 0; i < cell.length; i++) {
        const ch = cell[i];
        if (ch === "(") {
            depth++;
        }
        else if (ch === ")") {
            if (depth > 0)
                depth--;
        }
        else if (ch === "," && depth === 0) {
            tokens.push(cell.slice(start, i));
            start = i + 1;
        }
    }
    tokens.push(cell.slice(start));
    return tokens;
}
/**
 * Parse a full Required Properties cell into an ordered list of groups.
 * A cell like
 *
 *     `accountId`, `givenName`, `familyName`, `email` or `telephone`
 *
 * yields four groups: three simple + one any-of.
 *
 * Returns `{ groups: [], inheritsFrom: "Person" }` for the sentinel form
 * "Base Person properties + budget, tenure, …" so the second pass can resolve it.
 */
function parseRequiredCell(cell) {
    const trimmed = cell.trim();
    if (!trimmed || trimmed === "—" || trimmed === "-") {
        return { groups: [], extras: [] };
    }
    // Inheritance sentinel. Accept "Base X properties + extras…" in any case.
    // The extras list is split with paren-awareness too, in case a future
    // inheritance row includes a hint like "Base Person properties + tier (bronze, silver, gold)".
    const inheritMatch = trimmed.match(/^Base\s+(\w+)\s+properties\s*\+\s*(.*)$/i);
    if (inheritMatch) {
        const parentLabel = inheritMatch[1];
        const extrasText = inheritMatch[2];
        const extras = [];
        for (const token of splitTopLevelCommas(extrasText)) {
            const group = parseRequiredToken(token);
            if (group)
                extras.push(group);
        }
        return { groups: [], inheritsFrom: parentLabel, extras };
    }
    // Regular comma-separated list — paren-aware so hint commas like
    // `(gas safe, NICEIC, etc.)` don't fragment the token stream.
    const groups = [];
    for (const token of splitTopLevelCommas(trimmed)) {
        const group = parseRequiredToken(token);
        if (group)
            groups.push(group);
    }
    return { groups, extras: [] };
}
/**
 * Find every markdown pipe-table in a file's content. Returns tables in the
 * order they appear. A table is recognised by a header row followed by a
 * separator row of dashes.
 */
function findTables(content) {
    const lines = content.split(/\r?\n/);
    const tables = [];
    for (let i = 0; i < lines.length - 1; i++) {
        const headerLine = lines[i];
        const sepLine = lines[i + 1];
        // A pipe table header has `|` on at least one side; the separator line
        // below consists of `|`, `-`, `:` and whitespace only.
        if (!headerLine.includes("|"))
            continue;
        if (!/^\s*\|?[\s:|-]+\|?\s*$/.test(sepLine))
            continue;
        if (!sepLine.includes("-"))
            continue;
        const headers = splitPipeRow(headerLine);
        // Sanity: header row must have at least two cells.
        if (headers.length < 2)
            continue;
        const rows = [];
        let j = i + 2;
        while (j < lines.length) {
            const row = lines[j];
            if (!row.trim() || !row.includes("|"))
                break;
            const cells = splitPipeRow(row);
            // Skip malformed rows that don't match header width — loudly.
            if (cells.length !== headers.length) {
                throw new Error(`schema-loader: table row on line ${j + 1} has ${cells.length} cells but header has ${headers.length}: ${row}`);
            }
            rows.push(cells);
            j++;
        }
        tables.push({ headers, rows });
        i = j - 1;
    }
    return tables;
}
/**
 * Split a pipe-delimited markdown row into cells, stripping the optional
 * leading/trailing pipes.
 */
function splitPipeRow(row) {
    let trimmed = row.trim();
    if (trimmed.startsWith("|"))
        trimmed = trimmed.slice(1);
    if (trimmed.endsWith("|"))
        trimmed = trimmed.slice(0, -1);
    return trimmed.split("|").map((c) => c.trim());
}
function findColumnIndex(headers, name) {
    return headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
}
function parseEntityTables(tables, sourceFile) {
    const labels = [];
    for (const table of tables) {
        const labelCol = findColumnIndex(table.headers, "Neo4j Label");
        const requiredCol = findColumnIndex(table.headers, "Required Properties");
        if (labelCol === -1 || requiredCol === -1)
            continue; // not an entity table
        for (const row of table.rows) {
            const labelCell = row[labelCol];
            const requiredCell = row[requiredCol];
            const label = stripBackticks(labelCell);
            if (!label)
                continue;
            const parsed = parseRequiredCell(requiredCell);
            const definition = {
                label,
                required: [...parsed.groups, ...parsed.extras],
                sourceFile,
            };
            if (parsed.inheritsFrom) {
                definition.inheritsFrom = parsed.inheritsFrom;
            }
            labels.push(definition);
        }
    }
    return labels;
}
function parseNamingRulesTable(tables, sourceFile) {
    const entries = [];
    for (const table of tables) {
        const correctCol = findColumnIndex(table.headers, "Correct");
        const wrongCol = findColumnIndex(table.headers, "Wrong");
        if (correctCol === -1 || wrongCol === -1)
            continue;
        for (const row of table.rows) {
            const correctCell = row[correctCol].trim();
            const wrongCell = row[wrongCol].trim();
            // Context-dependent rows ("status (on Order nodes)") are skipped —
            // `status` is valid elsewhere, so a global mapping would false-reject.
            if (wrongCell.includes("(on "))
                continue;
            // For error messaging, preserve the literal correct cell (including
            // slashes like "startDate / endDate"). This is human-readable and not
            // misleading when one wrong token maps to multiple correct options.
            const correctLabel = stripBackticks(correctCell);
            for (const token of wrongCell.split(",")) {
                const wrong = stripBackticks(token);
                if (!wrong)
                    continue;
                entries.push([
                    wrong,
                    { correct: correctLabel, sourceFile },
                ]);
            }
        }
    }
    return entries;
}
function parseFile(path, fileName) {
    const content = readFileSync(path, "utf-8");
    const tables = findTables(content);
    const labels = parseEntityTables(tables, fileName);
    // Naming rules live only in schema-base.md today; parsing every file is
    // cheap and would pick up rules added to other files later without new code.
    const wrongToCorrect = parseNamingRulesTable(tables, fileName);
    return { labels, wrongToCorrect };
}
// ---------------------------------------------------------------------------
// Second pass: resolve "Base X properties + extras" inheritance
// ---------------------------------------------------------------------------
function resolveInheritance(labelMap) {
    for (const def of labelMap.values()) {
        if (!def.inheritsFrom)
            continue;
        const parent = labelMap.get(def.inheritsFrom);
        if (!parent) {
            throw new Error(`schema-loader: label '${def.label}' (${def.sourceFile}) inherits from '${def.inheritsFrom}' but that label is not defined in any loaded schema file`);
        }
        // Merge: parent's required groups first, then this label's extras.
        // Duplicates are possible but harmless — the validator treats each group
        // independently. We de-dupe simple names to keep error messages clean.
        const merged = [];
        const seenSimple = new Set();
        const push = (g) => {
            if (g.kind === "simple") {
                if (seenSimple.has(g.name))
                    return;
                seenSimple.add(g.name);
            }
            merged.push(g);
        };
        for (const g of parent.required)
            push(g);
        for (const g of def.required)
            push(g);
        def.required = merged;
        delete def.inheritsFrom;
    }
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/**
 * Load every `schema-*.md` file from the memory plugin's references directory.
 *
 * The directory is resolved relative to this source file, matching the
 * pattern used by neo4j.ts for config paths. Both src/ and dist/ locations
 * end up four hops above the references directory
 * (src/lib/schema-loader.ts -> src/ -> mcp/ -> memory/ -> references/).
 */
export function loadSchema() {
    const referencesDir = resolve(import.meta.dirname, "../../../references");
    let fileNames;
    try {
        fileNames = readdirSync(referencesDir).filter((f) => f.startsWith("schema-") && f.endsWith(".md"));
    }
    catch (err) {
        throw new Error(`schema-loader: could not read references directory at ${referencesDir}: ${err instanceof Error ? err.message : String(err)}`);
    }
    if (fileNames.length === 0) {
        throw new Error(`schema-loader: no schema-*.md files found in ${referencesDir}`);
    }
    // Sort for deterministic load order — schema-base.md should load first so
    // inheritance targets are present, but the resolver does a second pass so
    // file order doesn't affect correctness.
    fileNames.sort();
    const sourceFiles = [];
    const labelMap = new Map();
    const wrongToCorrect = new Map();
    for (const fileName of fileNames) {
        const fullPath = resolve(referencesDir, fileName);
        sourceFiles.push(fullPath);
        const parsed = parseFile(fullPath, fileName);
        for (const def of parsed.labels) {
            if (labelMap.has(def.label)) {
                // Two files defining the same label is allowed only if the later
                // file is a vertical extending a base type. We keep the first
                // (base) definition and ignore the later one; the per-vertical
                // properties on the vertical-specific label should be documented
                // on that vertical label, not on the shared one.
                continue;
            }
            labelMap.set(def.label, def);
        }
        for (const [wrong, info] of parsed.wrongToCorrect) {
            if (wrongToCorrect.has(wrong))
                continue; // first file wins
            wrongToCorrect.set(wrong, info);
        }
    }
    resolveInheritance(labelMap);
    // Stderr log: satisfies the project's "Observability Is Non-Negotiable"
    // principle — operators can confirm from server.log alone which schemas
    // are live and how many labels are enforced.
    const labelCount = labelMap.size;
    const synonymCount = wrongToCorrect.size;
    process.stderr.write(`[schema-loader] loaded ${labelCount} labels and ${synonymCount} naming-rule synonyms from ${fileNames.length} files: ${fileNames.join(", ")}\n`);
    return {
        labels: labelMap,
        wrongToCorrect,
        sourceFiles,
    };
}
//# sourceMappingURL=schema-loader.js.map