/**
 * Schema validator — validates memory-write inputs against two sources of
 * truth:
 *
 *   1. `LiveSchemaSource` — recognised-label set = `db.labels()` snapshot
 *      ∪ `schema.cypher` constraint/index declarations. This is the gate.
 *      A label is recognised iff it appears in either set.
 *   2. `LoadedSchema` (markdown sidecar) — required-property and naming-rule
 *      metadata for documented labels. Markdown is no longer a label-existence
 *      gate; required-property checks are skipped (with a log line) when the
 *      label is recognised by the live source but absent from markdown.
 *
 * Checks run in order; first failure throws with a descriptive message.
 *
 *   1. accountId            — non-empty trimmed string
 *   2. label existence      — at least one input label is in live ∪ declared.
 *                             Rejection cites both sources and the nearest
 *                             documented match (Levenshtein) when there is one
 *   3. property synonym     — no property key is a known wrong-name alias.
 *                             Runs before required-props so a `firstName`
 *                             typo is reported as "rename firstName →
 *                             givenName" rather than "missing givenName"
 *   3b. forbidden property. Per-label deny-list parsed from the
 *                             "Forbidden Properties" table in schema markdown.
 *                             Fires AFTER the synonym pass so `firstName`
 *                             still rewrites to `givenName` rather than
 *                             rejecting on the wrong rule. First row:
 *                             `Person | name`, eliminating the divergence
 *                             trap where `name` truncated to `givenName`
 *                             ("Dan") while the canonical pair held the full
 *                             identity ("Dan Brett")
 *   4. required properties  — for the primary label, fires only when the
 *                             label is documented in markdown. Skipped (with
 *                             a structured log line) when the label is
 *                             recognised but undocumented — that's a
 *                             stale-doc signal, not a write error
 *
 * The `[schema-validator]` log lines are the single observability surface.
 * Operators reading server.log can distinguish "validation didn't run" from
 * "validation passed" from "validation rejected" without source access.
 *
 * "Present" semantics for required-property values: defined, not null, and
 * not a whitespace-only string. Numeric `0`, boolean `false`, and empty
 * arrays count as present — the agent may legitimately persist those.
 */
import { nearestLabel } from "../../../../../lib/graph-mcp/dist/schema-cypher-parser.js";
const BASE_SOURCE_FILE = "schema-base.md";
/**
 * A markdown rule is in scope for a write iff its sourceFile is the base
 * schema OR matches the write's resolved vertical. A null resolved vertical
 * narrows scope to base only — Maxy's non-vertical install enforces no
 * vertical-specific rules.
 */
function isRuleInScope(sourceFile, resolvedVertical) {
    if (sourceFile === BASE_SOURCE_FILE)
        return true;
    if (resolvedVertical === null)
        return false;
    return sourceFile === `${resolvedVertical}.md`;
}
/**
 * Validate a write. Throws on the first violation. Caller is responsible
 * for logging the thrown error in addition to the structured outcome lines
 * this function emits — this stays a (mostly) pure unit so test fixtures
 * can capture both the throw path and the emit stream.
 */
export function validateWrite(input, env, options = {}) {
    const mode = options.mode ?? "create";
    const verticalContext = options.verticalContext ?? {
        resolvedVertical: null,
        source: "brand-default",
    };
    const resolvedVertical = verticalContext.resolvedVertical;
    const startedMs = Date.now();
    const { labels, properties, accountId } = input;
    const emit = env.emit ?? defaultEmit;
    // Surface the resolved vertical once per write — every other validator
    // line trails this so an operator reading server.log can read top-down
    // and know which rules were in scope before any accept/reject outcome.
    emit(`[schema-validator] resolved-vertical=${resolvedVertical ?? "none"} source=${verticalContext.source} accountId=${accountId ?? "<none>"}`);
    // ---------------------------------------------------------------------
    // 1. accountId non-empty
    // ---------------------------------------------------------------------
    if (typeof accountId !== "string" || accountId.trim().length === 0) {
        throw new Error("memory-write: accountId is required and must be a non-empty string");
    }
    // ---------------------------------------------------------------------
    // 2. At least one label is recognised by the live source
    //    (recognised = live ∪ declared)
    // ---------------------------------------------------------------------
    if (!Array.isArray(labels) || labels.length === 0) {
        throw new Error("memory-write: labels is required and must contain at least one Neo4j label");
    }
    const live = env.liveSource.liveLabels();
    const declared = env.liveSource.declaredLabels();
    const recognised = env.liveSource.recognisedLabels();
    const hits = [];
    for (const label of labels) {
        if (recognised.has(label))
            hits.push(label);
    }
    if (hits.length === 0) {
        const liveList = [...live].sort().join(", ");
        const declaredList = [...declared].sort().join(", ");
        const suggestion = nearestLabel(labels[0], recognised);
        const suggestionFragment = suggestion
            ? ` Did you mean '${suggestion}'?`
            : "";
        const labelStr = labels.join(", ");
        emit(`[schema-validator] check label=${primaryForLog(labels)} outcome=rejected source=neither ms=${Date.now() - startedMs}`);
        throw new Error(`memory-write: unknown Neo4j label(s) [${labelStr}].${suggestionFragment} Live db.labels(): [${liveList}]. Declared in schema.cypher: [${declaredList}].`);
    }
    const primaryLabel = hits[0];
    const inLive = live.has(primaryLabel);
    const inDeclared = declared.has(primaryLabel);
    const recognisedSource = inLive && inDeclared ? "both" : inLive ? "live" : "declared";
    // ---------------------------------------------------------------------
    // 3. No property key is a known Schema.org synonym (wrong name)
    //
    // Synonym map is independent of label — `firstName` is wrong on every
    // Person variant. Runs even when the label is markdown-undocumented;
    // the synonyms apply to property semantics, not to specific labels.
    // ---------------------------------------------------------------------
    for (const key of Object.keys(properties)) {
        const hit = env.schema.wrongToCorrect.get(key);
        if (hit && isRuleInScope(hit.sourceFile, resolvedVertical)) {
            throw new Error(`memory-write: property name '${key}' is not Schema.org — use '${hit.correct}' instead (${hit.sourceFile} property naming rules)`);
        }
    }
    // ---------------------------------------------------------------------
    // 3b. Forbidden-property check — fires AFTER the synonym
    //     pass so a `firstName` write still rewrites to `givenName` first
    //     rather than being mis-rejected. The map is per-primary-label:
    //     `name` is forbidden on `Person` only; other labels keep `name`
    //     as a canonical property. Skipped when the label has no entry,
    //     which is the case for every label until a "Forbidden Properties"
    //     row is added in schema-base.md (or a vertical schema).
    // ---------------------------------------------------------------------
    const forbidden = env.schema.forbiddenByLabel.get(primaryLabel);
    if (forbidden) {
        for (const key of Object.keys(properties)) {
            const hit = forbidden.get(key);
            if (hit && isRuleInScope(hit.sourceFile, resolvedVertical)) {
                emit(`[schema-validator] outcome=rejected label=${primaryLabel} property=${key} reason=forbidden writer=${pickWriter(properties)}`);
                throw new Error(`memory-write: '${key}' is not a valid ${primaryLabel} property — ${hit.rationale} (${hit.sourceFile} Forbidden Properties).`);
            }
        }
    }
    // ---------------------------------------------------------------------
    // 4. Required-property check — only when the primary label is documented
    //    in markdown. Markdown-undocumented labels (e.g. AdminUser,
    //    LocalBusiness, KnowledgeDocument as of 2026-04-25) are accepted by
    //    label existence but their property shape is the agent's responsibility
    //    until a markdown row is added.
    // ---------------------------------------------------------------------
    const definition = env.schema.markdownLabels.get(primaryLabel);
    if (!definition) {
        emit(`[schema-validator] check label=${primaryLabel} mode=${mode} outcome=accepted source=${recognisedSource} ms=${Date.now() - startedMs}`);
        emit(`[schema-validator] markdown-undocumented label=${primaryLabel}`);
        return;
    }
    // The label is documented somewhere in the merged markdown set, but the
    // documenting file may be a vertical other than the one active for this
    // write. Treat that the same as markdown-undocumented from the validator's
    // perspective — the rule simply does not apply — but log a distinct
    // reason so operators can spot out-of-scope-vertical writes that may
    // benefit from a base-schema row.
    if (!isRuleInScope(definition.sourceFile, resolvedVertical)) {
        emit(`[schema-validator] check label=${primaryLabel} mode=${mode} outcome=accepted source=${recognisedSource} ms=${Date.now() - startedMs}`);
        emit(`[schema-validator] markdown-undocumented label=${primaryLabel} reason=out-of-scope-vertical definedIn=${definition.sourceFile} active=${resolvedVertical ?? "none"}`);
        return;
    }
    // `mode: "update"` skips the required-property loop only.
    // Synonym + forbidden-property checks above already ran. The accept-path
    // log line carries `mode=update` so partial writes are distinguishable in
    // forensics from full creates.
    if (mode === "update") {
        emit(`[schema-validator] check label=${primaryLabel} mode=update outcome=accepted source=${recognisedSource} ms=${Date.now() - startedMs}`);
        return;
    }
    const effectiveProperties = {
        ...properties,
        accountId,
    };
    for (const group of definition.required) {
        if (!groupIsSatisfied(group, effectiveProperties)) {
            emit(`[schema-validator] required-props label=${primaryLabel} outcome=missing-group group=${formatGroupForLog(group)}`);
            throw new Error(`memory-write: missing required property ${formatGroup(group)} for label ${primaryLabel} (${definition.sourceFile})`);
        }
    }
    emit(`[schema-validator] required-props label=${primaryLabel} outcome=satisfied`);
    emit(`[schema-validator] check label=${primaryLabel} mode=${mode} outcome=accepted source=${recognisedSource} ms=${Date.now() - startedMs}`);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isPresent(value) {
    if (value === undefined || value === null)
        return false;
    if (typeof value === "string")
        return value.trim().length > 0;
    return true;
}
function groupIsSatisfied(group, properties) {
    if (group.kind === "simple") {
        return isPresent(properties[group.name]);
    }
    return group.names.some((name) => isPresent(properties[name]));
}
function formatGroup(group) {
    if (group.kind === "simple")
        return `'${group.name}'`;
    return group.names.map((n) => `'${n}'`).join(" or ");
}
function formatGroupForLog(group) {
    if (group.kind === "simple")
        return group.name;
    return group.names.join("|");
}
/** Pick a representative label for the rejection log line. */
function primaryForLog(labels) {
    return labels[0] ?? "<empty>";
}
/**
 * Best-effort writer attribution for the forbidden-property log line. Reads
 * `createdByAgent` (the canonical writer field) and falls back to
 * `createdByTool` so memory-write callers — which set the latter — still
 * surface in the log. Unknown writes log `writer=unknown` rather than
 * suppressing the line; the rate-by-writer signal stays usable.
 */
function pickWriter(properties) {
    const agent = properties.createdByAgent;
    if (typeof agent === "string" && agent.length > 0)
        return agent;
    const tool = properties.createdByTool;
    if (typeof tool === "string" && tool.length > 0)
        return tool;
    return "unknown";
}
function defaultEmit(line) {
    process.stderr.write(`${line}\n`);
}
//# sourceMappingURL=schema-validator.js.map