import test from "node:test";
import assert from "node:assert/strict";
import { validateWrite } from "../schema-validator.js";
// ---------------------------------------------------------------------------
// Fixture builders. These avoid hitting the real schema-*.md files so the
// validator's behaviour can be asserted independently of the markdown
// contents. The shape is byte-identical to what loadSchema() produces.
// ---------------------------------------------------------------------------
function makeSchema(opts) {
    const markdownLabels = new Map();
    for (const entry of opts.documented ?? []) {
        markdownLabels.set(entry.label, {
            label: entry.label,
            required: entry.required,
            sourceFile: entry.sourceFile ?? "schema-base.md",
        });
    }
    const wrongToCorrect = new Map();
    for (const [wrong, correct, sourceFile] of opts.synonyms ?? []) {
        wrongToCorrect.set(wrong, {
            correct,
            sourceFile: sourceFile ?? "schema-base.md",
        });
    }
    const forbiddenByLabel = new Map();
    for (const entry of opts.forbidden ?? []) {
        let perLabel = forbiddenByLabel.get(entry.label);
        if (!perLabel) {
            perLabel = new Map();
            forbiddenByLabel.set(entry.label, perLabel);
        }
        perLabel.set(entry.property, {
            rationale: entry.rationale ?? "test rationale",
            sourceFile: entry.sourceFile ?? "schema-base.md",
        });
    }
    return {
        markdownLabels,
        wrongToCorrect,
        forbiddenByLabel,
        sourceFiles: ["schema-base.md"],
        brandVertical: opts.brandVertical ?? null,
    };
}
function makeLiveSource(opts) {
    const live = new Set(opts.live);
    const declared = new Set(opts.declared);
    return {
        recognisedLabels: () => new Set([...live, ...declared]),
        liveLabels: () => new Set(live),
        declaredLabels: () => new Set(declared),
        liveReady: () => true,
    };
}
function captureEmits() {
    const lines = [];
    return { lines, emit: (line) => lines.push(line) };
}
// ---------------------------------------------------------------------------
// (a) Live label not in markdown → accept, log markdown-skip
// ---------------------------------------------------------------------------
test("(a) live-only label, markdown-undocumented → accepted with markdown-undocumented log", () => {
    // KnowledgeDocument is in db.labels() but has no markdown row.
    const schema = makeSchema({});
    const liveSource = makeLiveSource({
        live: ["KnowledgeDocument", "Person"],
        declared: [],
    });
    const { lines, emit } = captureEmits();
    validateWrite({
        labels: ["KnowledgeDocument"],
        properties: { title: "x" },
        accountId: "acct-1",
    }, { schema, liveSource, emit });
    const accepted = lines.find((l) => l.includes("outcome=accepted"));
    assert.ok(accepted, `expected accepted log, got: ${lines.join(" | ")}`);
    assert.match(accepted, /source=live/);
    const skip = lines.find((l) => l.includes("markdown-undocumented"));
    assert.ok(skip, `expected markdown-undocumented log, got: ${lines.join(" | ")}`);
    assert.match(skip, /label=KnowledgeDocument/);
});
// ---------------------------------------------------------------------------
// (b) Declared-but-not-live (fresh-install bootstrap) → accept
// ---------------------------------------------------------------------------
test("(b) declared-but-not-live LocalBusiness → accepted (bootstrap regression)", () => {
    // The exact incident: fresh install, db.labels()=[AdminUser], schema.cypher
    // declares LocalBusiness, agent writes LocalBusiness — must succeed.
    const schema = makeSchema({});
    const liveSource = makeLiveSource({
        live: ["AdminUser"],
        declared: ["AdminUser", "LocalBusiness", "Person"],
    });
    const { lines, emit } = captureEmits();
    validateWrite({
        labels: ["LocalBusiness"],
        properties: { name: "Acme Plumbing" },
        accountId: "acct-1",
    }, { schema, liveSource, emit });
    const accepted = lines.find((l) => l.includes("outcome=accepted"));
    assert.ok(accepted);
    assert.match(accepted, /source=declared/);
});
// ---------------------------------------------------------------------------
// (c) Label absent from both → reject with structured error
// ---------------------------------------------------------------------------
test("(c) label absent from live and declared → reject with structured error", () => {
    const schema = makeSchema({});
    const liveSource = makeLiveSource({
        live: ["Person", "LocalBusiness"],
        declared: ["Person", "LocalBusiness", "AdminUser"],
    });
    const { lines, emit } = captureEmits();
    assert.throws(() => validateWrite({
        labels: ["Quokka"],
        properties: {},
        accountId: "acct-1",
    }, { schema, liveSource, emit }), (err) => {
        assert.match(err.message, /unknown Neo4j label/);
        assert.match(err.message, /\[Quokka\]/);
        assert.match(err.message, /Live db\.labels\(\): \[/);
        assert.match(err.message, /Declared in schema\.cypher: \[/);
        // Both source lists must include something so operators can grep.
        assert.match(err.message, /Person/);
        return true;
    });
    const rejected = lines.find((l) => l.includes("outcome=rejected"));
    assert.ok(rejected);
    assert.match(rejected, /source=neither/);
});
test("(c) typo gets Levenshtein 'did you mean?' suggestion", () => {
    const schema = makeSchema({});
    const liveSource = makeLiveSource({
        live: ["Person", "LocalBusiness"],
        declared: ["Person", "LocalBusiness"],
    });
    assert.throws(() => validateWrite(
    // capital-I-as-l typo per the incident's exact failure mode
    {
        labels: ["LocaIBusiness"],
        properties: {},
        accountId: "acct-1",
    }, { schema, liveSource }), (err) => {
        assert.match(err.message, /Did you mean 'LocalBusiness'\?/);
        return true;
    });
});
// ---------------------------------------------------------------------------
// (d) Required-property check fires only when label is documented
// ---------------------------------------------------------------------------
test("(d) required-property check runs for markdown-documented labels", () => {
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [{ kind: "simple", name: "givenName" }],
            },
        ],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { familyName: "Smith" }, // missing givenName
        accountId: "acct-1",
    }, { schema, liveSource }), /missing required property 'givenName' for label Person/);
});
test("(d) required-property check is SKIPPED for markdown-undocumented labels", () => {
    // LocalBusiness is recognised (declared) but has no markdown row.
    // Requiring properties that aren't documented would be impossible to
    // express; the validator must accept the write.
    const schema = makeSchema({});
    const liveSource = makeLiveSource({
        live: [],
        declared: ["LocalBusiness"],
    });
    // No throw expected — properties are agent's responsibility for
    // markdown-undocumented labels.
    validateWrite({
        labels: ["LocalBusiness"],
        properties: {}, // intentionally empty
        accountId: "acct-1",
    }, { schema, liveSource });
});
// ---------------------------------------------------------------------------
// (f) Synonym layer (wrongToCorrect) still rejects when triggered
// ---------------------------------------------------------------------------
test("(f) wrongToCorrect synonym still rejects firstName→givenName even on documented label", () => {
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [{ kind: "simple", name: "givenName" }],
            },
        ],
        synonyms: [["firstName", "givenName"]],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { firstName: "Jane" },
        accountId: "acct-1",
    }, { schema, liveSource }), /property name 'firstName' is not Schema.org — use 'givenName'/);
});
test("(f) synonym layer also rejects on markdown-undocumented labels", () => {
    // The synonym map is independent of label — firstName is wrong everywhere.
    const schema = makeSchema({
        synonyms: [["firstName", "givenName"]],
    });
    const liveSource = makeLiveSource({
        live: ["LocalBusiness"],
        declared: ["LocalBusiness"],
    });
    assert.throws(() => validateWrite({
        labels: ["LocalBusiness"],
        properties: { firstName: "x" },
        accountId: "acct-1",
    }, { schema, liveSource }), /property name 'firstName' is not Schema.org — use 'givenName'/);
});
// ---------------------------------------------------------------------------
// accountId boundary
// ---------------------------------------------------------------------------
test("rejects missing accountId before consulting live source", () => {
    const schema = makeSchema({});
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({ labels: ["Person"], properties: {} }, { schema, liveSource }), /accountId is required/);
});
test("rejects empty labels[] before consulting live source", () => {
    const schema = makeSchema({});
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({ labels: [], properties: {}, accountId: "acct-1" }, { schema, liveSource }), /labels is required/);
});
// ---------------------------------------------------------------------------
// forbidden-property check (Person.name eradication)
// ---------------------------------------------------------------------------
test("(849) Person + name is rejected with documented error and structured log line", () => {
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [{ kind: "simple", name: "givenName" }],
            },
        ],
        forbidden: [
            {
                label: "Person",
                property: "name",
                rationale: "use givenName + familyName; rendered display name composed at read time",
            },
        ],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    const { lines, emit } = captureEmits();
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: {
            givenName: "Test",
            familyName: "Person",
            name: "Test",
            createdByAgent: "memory-write",
        },
        accountId: "acct-1",
    }, { schema, liveSource, emit }), (err) => {
        // Error message names the property, the label, and the rationale.
        assert.match(err.message, /'name' is not a valid Person property/);
        assert.match(err.message, /givenName \+ familyName/);
        return true;
    });
    // Structured log line is grep-able as the brief specifies.
    const rejected = lines.find((l) => l.includes("[schema-validator] outcome=rejected"));
    assert.ok(rejected, `expected forbidden-property log line, got: ${lines.join(" | ")}`);
    assert.match(rejected, /label=Person/);
    assert.match(rejected, /property=name/);
    assert.match(rejected, /reason=forbidden/);
    assert.match(rejected, /writer=memory-write/);
});
test("(849) Organization + name is accepted — rule is Person-specific", () => {
    // Organization.name is canonical and must NOT be rejected even when the
    // forbidden map carries a Person+name row. Per-label scoping is the whole
    // point of the new map shape.
    const schema = makeSchema({
        forbidden: [{ label: "Person", property: "name" }],
    });
    const liveSource = makeLiveSource({
        live: ["Organization"],
        declared: ["Organization"],
    });
    validateWrite({
        labels: ["Organization"],
        properties: { name: "Acme Plumbing" },
        accountId: "acct-1",
    }, { schema, liveSource });
});
test("(849) Person without name is accepted (canonical pair only)", () => {
    // Sanity: the forbidden-property check must not reject writes that don't
    // include the forbidden key. givenName + familyName is the canonical
    // shape; a write that uses it must succeed.
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [{ kind: "simple", name: "givenName" }],
            },
        ],
        forbidden: [{ label: "Person", property: "name" }],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    validateWrite({
        labels: ["Person"],
        properties: { givenName: "Dan", familyName: "Brett" },
        accountId: "acct-1",
    }, { schema, liveSource });
});
test("(849) firstName → givenName synonym rewrites BEFORE forbidden check fires", () => {
    // Ordering invariant: if a Person write supplies `firstName`, the
    // synonym pass must reject with the rename hint; the forbidden-property
    // check must not preempt that error. Otherwise a `firstName` typo on a
    // label with a forbidden-property rule would mis-report the failure.
    const schema = makeSchema({
        synonyms: [["firstName", "givenName"]],
        forbidden: [{ label: "Person", property: "name" }],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { firstName: "Jane" },
        accountId: "acct-1",
    }, { schema, liveSource }), /property name 'firstName' is not Schema\.org — use 'givenName'/);
});
// ---------------------------------------------------------------------------
// `mode: "update"` partial-update behaviour
// ---------------------------------------------------------------------------
test('(893) mode:"update" skips required-property check (partial Person SET)', () => {
    // The personal-profile Person already has givenName/familyName from PIN
    // setup. A partial SET adding `homeLocation` only must accept — required
    // properties are not re-validated against the partial input. Without
    // mode:"update" the validator would reject for missing givenName.
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [
                    { kind: "simple", name: "givenName" },
                    { kind: "simple", name: "familyName" },
                    { kind: "any-of", names: ["email", "telephone"] },
                ],
            },
        ],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    const { lines, emit } = captureEmits();
    validateWrite({
        labels: ["Person"],
        properties: { homeLocation: "London" },
        accountId: "acct-1",
    }, { schema, liveSource, emit }, { mode: "update" });
    const accepted = lines.find((l) => l.includes("outcome=accepted"));
    assert.ok(accepted, `expected accepted log, got: ${lines.join(" | ")}`);
    assert.match(accepted, /mode=update/);
    // Required-props loop did not run, so its log line must NOT appear.
    const reqLine = lines.find((l) => l.includes("required-props"));
    assert.ok(!reqLine, `expected no required-props log under mode=update, got: ${lines.join(" | ")}`);
});
test('(893) mode:"update" still rejects forbidden Person.name', () => {
    // Skipping required-props must NOT skip the Forbidden Properties check.
    // the `Person | name` rule fires regardless of mode.
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [{ kind: "simple", name: "givenName" }],
            },
        ],
        forbidden: [{ label: "Person", property: "name", rationale: "use givenName + familyName" }],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { name: "Joel Smalley" },
        accountId: "acct-1",
    }, { schema, liveSource }, { mode: "update" }), /'name' is not a valid Person property/);
});
test('(893) mode:"update" still rejects synonyms (phone → telephone)', () => {
    // Skipping required-props must NOT skip the synonym pass either. A
    // partial Person update with the schema-synonym `phone` must reject
    // with the canonical-name hint.
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [{ kind: "simple", name: "givenName" }],
            },
        ],
        synonyms: [["phone", "telephone"]],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { phone: "+447111111111" },
        accountId: "acct-1",
    }, { schema, liveSource }, { mode: "update" }), /property name 'phone' is not Schema\.org — use 'telephone'/);
});
test('(893) mode defaults to "create" when omitted; required-props still fires', () => {
    // Backwards compatibility: existing callers (memoryWrite) pass no
    // options object; behaviour is unchanged.
    const schema = makeSchema({
        documented: [
            {
                label: "Person",
                required: [{ kind: "simple", name: "givenName" }],
            },
        ],
    });
    const liveSource = makeLiveSource({
        live: ["Person"],
        declared: ["Person"],
    });
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { homeLocation: "London" },
        accountId: "acct-1",
    }, { schema, liveSource }), /missing required property 'givenName'/);
});
// ---------------------------------------------------------------------------
// Vertical scoping (Task 193)
// ---------------------------------------------------------------------------
test("vertical scoping: base rule applies for every resolved vertical (including null)", () => {
    // schema-base.md rules — synonyms and forbidden — never filter out, even
    // when no brand vertical is declared.
    const schema = makeSchema({
        synonyms: [["firstName", "givenName", "schema-base.md"]],
    });
    const liveSource = makeLiveSource({ live: ["Person"], declared: [] });
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { firstName: "Sam" },
        accountId: "acct-1",
    }, { schema, liveSource }), /firstName.*givenName/);
});
test("vertical scoping: vertical synonym applies when its file is the active vertical", () => {
    const schema = makeSchema({
        synonyms: [["roomCount", "numberOfRooms", "schema-estate-agent.md"]],
    });
    const liveSource = makeLiveSource({ live: ["Property"], declared: [] });
    assert.throws(() => validateWrite({
        labels: ["Property"],
        properties: { roomCount: 3 },
        accountId: "acct-1",
    }, {
        schema,
        liveSource,
    }, {
        verticalContext: {
            resolvedVertical: "schema-estate-agent",
            source: "brand-default",
        },
    }), /roomCount.*numberOfRooms/);
});
test("vertical scoping: vertical synonym is filtered out when a different vertical is active", () => {
    // Estate-agent synonym must NOT fire when the LocalBusiness has overridden
    // to creator. The doctrine in the task: "the per-LocalBusiness override
    // wins". A write whose property name is wrong only in estate-agent should
    // pass for a creator-scope write.
    const schema = makeSchema({
        synonyms: [["roomCount", "numberOfRooms", "schema-estate-agent.md"]],
    });
    const liveSource = makeLiveSource({ live: ["Property"], declared: [] });
    assert.doesNotThrow(() => validateWrite({
        labels: ["Property"],
        properties: { roomCount: 3 },
        accountId: "acct-1",
    }, { schema, liveSource }, {
        verticalContext: {
            resolvedVertical: "schema-creator",
            source: "localbusiness-override",
        },
    }));
});
test("vertical scoping: vertical synonym is filtered out when no vertical is active (null)", () => {
    // Non-vertical install (Maxy default). Only base rules apply.
    const schema = makeSchema({
        synonyms: [["roomCount", "numberOfRooms", "schema-estate-agent.md"]],
    });
    const liveSource = makeLiveSource({ live: ["Property"], declared: [] });
    assert.doesNotThrow(() => validateWrite({
        labels: ["Property"],
        properties: { roomCount: 3 },
        accountId: "acct-1",
    }, { schema, liveSource }));
});
test("vertical scoping: forbidden-property rule respects active vertical", () => {
    const schema = makeSchema({
        forbidden: [
            {
                label: "Person",
                property: "agencyNotes",
                rationale: "Applicant-specific",
                sourceFile: "schema-estate-agent.md",
            },
        ],
    });
    const liveSource = makeLiveSource({ live: ["Person"], declared: [] });
    // Active vertical = estate-agent → forbidden fires.
    assert.throws(() => validateWrite({
        labels: ["Person"],
        properties: { agencyNotes: "x" },
        accountId: "acct-1",
    }, { schema, liveSource }, {
        verticalContext: {
            resolvedVertical: "schema-estate-agent",
            source: "brand-default",
        },
    }), /agencyNotes/);
    // Active vertical = creator → estate-agent forbidden rule out of scope.
    assert.doesNotThrow(() => validateWrite({
        labels: ["Person"],
        properties: { agencyNotes: "x" },
        accountId: "acct-1",
    }, { schema, liveSource }, {
        verticalContext: {
            resolvedVertical: "schema-creator",
            source: "localbusiness-override",
        },
    }));
});
test("vertical scoping: required-props from out-of-scope vertical are skipped (markdown-undocumented)", () => {
    // Applicant is defined in schema-estate-agent.md. A write on Applicant
    // under an active creator vertical should accept (label is recognised
    // by the live source) but log the out-of-scope-vertical reason.
    const schema = makeSchema({
        documented: [
            {
                label: "Applicant",
                required: [{ kind: "simple", name: "budget" }],
                sourceFile: "schema-estate-agent.md",
            },
        ],
    });
    const liveSource = makeLiveSource({ live: ["Applicant"], declared: [] });
    const { lines, emit } = captureEmits();
    validateWrite({
        labels: ["Applicant"],
        properties: { name: "x" },
        accountId: "acct-1",
    }, { schema, liveSource, emit }, {
        verticalContext: {
            resolvedVertical: "schema-creator",
            source: "localbusiness-override",
        },
    });
    const accept = lines.find((l) => l.includes("outcome=accepted"));
    assert.ok(accept, `expected accepted log, got ${lines.join(" | ")}`);
    const skip = lines.find((l) => l.includes("markdown-undocumented") &&
        l.includes("reason=out-of-scope-vertical"));
    assert.ok(skip, `expected out-of-scope-vertical log, got ${lines.join(" | ")}`);
    assert.match(skip, /definedIn=schema-estate-agent\.md/);
    assert.match(skip, /active=schema-creator/);
});
test("vertical scoping: required-props from active vertical fire", () => {
    const schema = makeSchema({
        documented: [
            {
                label: "Applicant",
                required: [{ kind: "simple", name: "budget" }],
                sourceFile: "schema-estate-agent.md",
            },
        ],
    });
    const liveSource = makeLiveSource({ live: ["Applicant"], declared: [] });
    assert.throws(() => validateWrite({
        labels: ["Applicant"],
        properties: { name: "x" },
        accountId: "acct-1",
    }, { schema, liveSource }, {
        verticalContext: {
            resolvedVertical: "schema-estate-agent",
            source: "brand-default",
        },
    }), /missing required property 'budget'/);
});
test("vertical scoping: resolved-vertical log line emits once per write", () => {
    const schema = makeSchema({});
    const liveSource = makeLiveSource({ live: ["Person"], declared: [] });
    const { lines, emit } = captureEmits();
    validateWrite({
        labels: ["Person"],
        properties: {},
        accountId: "acct-99",
    }, { schema, liveSource, emit }, {
        verticalContext: {
            resolvedVertical: "schema-estate-agent",
            source: "localbusiness-override",
        },
    });
    const verticalLines = lines.filter((l) => l.startsWith("[schema-validator] resolved-vertical="));
    assert.equal(verticalLines.length, 1);
    assert.match(verticalLines[0], /resolved-vertical=schema-estate-agent source=localbusiness-override accountId=acct-99/);
});
test("vertical scoping: resolved-vertical=none logged when no context is passed", () => {
    const schema = makeSchema({});
    const liveSource = makeLiveSource({ live: ["Person"], declared: [] });
    const { lines, emit } = captureEmits();
    validateWrite({
        labels: ["Person"],
        properties: {},
        accountId: "acct-1",
    }, { schema, liveSource, emit });
    const verticalLines = lines.filter((l) => l.startsWith("[schema-validator] resolved-vertical="));
    assert.equal(verticalLines.length, 1);
    assert.match(verticalLines[0], /resolved-vertical=none source=brand-default/);
});
//# sourceMappingURL=schema-validator.test.js.map