/**
 * Live schema source for the memory-write / memory-update validator.
 *
 * Three sources, one decision rule:
 *
 *   liveLabels      — `db.labels()` snapshot from Neo4j (refreshed every 60s
 *                     by SchemaCache from platform/lib/graph-mcp).
 *   declaredLabels  — labels declared in `platform/neo4j/schema.cypher`
 *                     (constraint + index FOR (alias:Label) forms).
 *   markdownLabels  — labels with documented required-property groups in
 *                     `platform/plugins/memory/references/schema-*.md`.
 *
 *   recognised = liveLabels ∪ declaredLabels         ← gates label existence
 *   markdown documented? → run required-property check, else skip
 *
 * Why a per-process cache rather than a shared one. The graph-mcp proxy and
 * the memory plugin run as separate stdio MCP servers spawned by Claude Code
 * (each with its own Neo4j driver). "In-process" brief means
 * "no per-decision tool round-trip" — *not* a shared cache instance, which
 * would require IPC. Two SchemaCache instances polling the same Neo4j every
 * 60s costs roughly two `db.labels()` calls per minute and avoids the IPC
 * surface entirely.
 *
 * Why drift runs from the cache's emit hook. SchemaCache uses single-flight
 * refresh — concurrent `refresh()` calls share an in-flight promise. A
 * parallel timer could race against an in-progress refresh and read a stale
 * snapshot during the comparison. The cache's own `emit()` callback fires
 * synchronously after a successful refresh swap, so tapping it guarantees
 * the snapshot we read is the one the refresh just produced.
 *
 * Bootstrap posture. Fresh installs have no nodes yet, so `db.labels()`
 * returns []. The validator must still accept the very first
 * `LocalBusiness` write because that label is *declared* in schema.cypher
 * even when no node carries it. The recognised-set union resolves this
 * directly; no fail-open branch needed for that case.
 *
 *   db.labels()     declared in cypher    recognised?
 *   ─────────       ──────────────────    ───────────
 *   []              [LocalBusiness]       LocalBusiness  ✓ (bootstrap)
 *   [LocalBusiness] [LocalBusiness]       LocalBusiness  ✓ (steady state)
 *   [Person]        [LocalBusiness]       Person + LocalBusiness  ✓
 *   []              []                    {}             reject all (Neo4j
 *                                                         unreachable AND
 *                                                         schema.cypher
 *                                                         unreadable — both
 *                                                         logged loudly)
 */
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { SchemaCache, neo4jSchemaFetcher, } from "../../../../../lib/graph-mcp/dist/schema-cache.js";
import { parseLabelsFromSchemaCypher } from "../../../../../lib/graph-mcp/dist/schema-cypher-parser.js";
/**
 * Build the live schema source plus its backing cache.
 *
 * The caller (memory MCP `index.ts`) owns lifecycle: it must `await
 * runtime.ready` (or fire-and-forget) and call `runtime.cache.stop()` on
 * shutdown. Tests pass a stub fetcher and a synthetic interval.
 */
export function buildLiveSchemaSource(options) {
    const declared = new Set(loadDeclaredLabels(options.schemaCypherPath));
    const markdown = new Set(options.markdownLabels);
    const emit = options.emit ?? defaultEmit;
    const cache = new SchemaCache(options.fetcher, {
        refreshIntervalMs: options.refreshIntervalMs,
        emit: (line) => {
            // Forward the cache's own log line first so operators see refresh
            // outcomes even when drift is empty. Then run the drift comparison
            // against the fresh snapshot — single-flight in SchemaCache means the
            // snapshot is final by the time emit fires.
            emit(line);
            runDriftCheck({
                live: cache.snapshot().labels,
                declared,
                markdown,
                emit,
            });
        },
    });
    const ready = cache.start();
    const source = {
        recognisedLabels() {
            return new Set([...cache.snapshot().labels, ...declared]);
        },
        liveLabels() {
            return new Set(cache.snapshot().labels);
        },
        declaredLabels() {
            return new Set(declared);
        },
        liveReady() {
            return cache.ready();
        },
    };
    return { source, cache, ready };
}
/** Re-export the fetcher factory so index.ts can wire one up cleanly. */
export { neo4jSchemaFetcher };
/**
 * Compute drift entries and emit one summary line per non-empty kind.
 *
 * Three drift kinds:
 *   live-not-declared : a label that nodes carry but schema.cypher does not
 *                       declare a constraint/index for. Migration-drift
 *                       signal — someone created a node with a fresh label
 *                       without adding the canonical declaration.
 *   declared-not-live : declared in schema.cypher but no node carries it
 *                       yet. Expected on fresh installs (bootstrap labels);
 *                       suspicious on populated graphs (dead declaration).
 *   markdown-orphan   : documented in schema-*.md but missing from both
 *                       live and declared. Stale-doc signal — markdown
 *                       describing a label that no longer exists in the
 *                       graph or its constraint declarations.
 *
 * Log shape (one line per non-empty kind):
 *   [schema-validator] drift kind=<kind> count=<n> tokens=<csv≤10>[+<rest>]
 *
 * Per-token lines (one per drifting label) are emitted *after* the summary
 * lines only when MEMORY_MCP_VERBOSE_DRIFT=1. Default off because drift sets
 * of 50+ tokens × 2 kinds buried real signals in mcp-memory-stderr (Task 167).
 *
 * Returns the drift entries (unchanged shape) for callers that need them.
 */
const DRIFT_KINDS_IN_ORDER = [
    "declared-not-live",
    "live-not-declared",
    "markdown-orphan",
];
const DRIFT_TOKEN_PREVIEW_LIMIT = 10;
export function runDriftCheck(input) {
    const drifts = [];
    for (const token of input.live) {
        if (!input.declared.has(token)) {
            drifts.push({ kind: "live-not-declared", token });
        }
    }
    for (const token of input.declared) {
        if (!input.live.has(token)) {
            drifts.push({ kind: "declared-not-live", token });
        }
    }
    for (const token of input.markdown) {
        if (!input.live.has(token) && !input.declared.has(token)) {
            drifts.push({ kind: "markdown-orphan", token });
        }
    }
    drifts.sort((a, b) => {
        if (a.kind !== b.kind)
            return a.kind.localeCompare(b.kind);
        return a.token.localeCompare(b.token);
    });
    // Read env per-call (not at module-load) so operators can toggle verbose
    // without restarting the manager unit.
    const verbose = process.env.MEMORY_MCP_VERBOSE_DRIFT === "1";
    for (const kind of DRIFT_KINDS_IN_ORDER) {
        const tokens = drifts.filter((d) => d.kind === kind).map((d) => d.token);
        if (tokens.length === 0)
            continue;
        const preview = tokens.slice(0, DRIFT_TOKEN_PREVIEW_LIMIT).join(",");
        const remainder = tokens.length - DRIFT_TOKEN_PREVIEW_LIMIT;
        const suffix = remainder > 0 ? `+${remainder}` : "";
        input.emit(`[schema-validator] drift kind=${kind} count=${tokens.length} tokens=${preview}${suffix}`);
        if (verbose) {
            for (const token of tokens) {
                input.emit(`[schema-validator] drift kind=${kind} token=${token}`);
            }
        }
    }
    return drifts;
}
// ---------------------------------------------------------------------------
// schema.cypher loading
// ---------------------------------------------------------------------------
function loadDeclaredLabels(schemaCypherPath) {
    try {
        const text = readFileSync(schemaCypherPath, "utf-8");
        return parseLabelsFromSchemaCypher(text);
    }
    catch (err) {
        // schema.cypher unreadable is a deployment-shape problem — surface it
        // loudly. Rather than throwing, return [] so the validator falls back to
        // pure live-snapshot mode. The memory server still starts; operators see
        // the warning and can fix the deployment without taking the agent down.
        const msg = err instanceof Error ? err.message : String(err);
        process.stderr.write(`[schema-validator] schema.cypher unreadable path=${schemaCypherPath} error="${msg.replace(/"/g, "'")}" declared=[]\n`);
        return [];
    }
}
/**
 * Resolve the absolute path to `platform/neo4j/schema.cypher`.
 *
 * Layout (compiled and source paths are siblings, so the offset is the same):
 *   platform/plugins/memory/mcp/{src,dist}/lib/live-schema-source.{ts,js}
 *   platform/neo4j/schema.cypher
 *
 * From `import.meta.dirname` (mcp/{src,dist}/lib/) the relative path is
 * `../../../../../neo4j/schema.cypher` — 5 hops to platform/, then down.
 * `PLATFORM_ROOT` overrides for production where the layout may differ.
 */
export function defaultSchemaCypherPath() {
    if (process.env.PLATFORM_ROOT) {
        return resolve(process.env.PLATFORM_ROOT, "neo4j/schema.cypher");
    }
    return resolve(import.meta.dirname, "../../../../../neo4j/schema.cypher");
}
function defaultEmit(line) {
    process.stderr.write(`${line}\n`);
}
//# sourceMappingURL=live-schema-source.js.map