// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
 * NFA / DFA Parity Tests
 *
 * For every test case the same input is run through both the NFA matcher and the
 * DFA matcher.  The results are then compared field-by-field:
 *   - matched / not matched
 *   - actionValue (the extracted action object)
 *   - matchedValueCount  (fixedString + checked + unchecked wildcard counts)
 *   - wildcardCharCount  (unchecked wildcard token count)
 *   - completions (sorted literal token suggestions)
 *   - property completions (sorted actionName:propertyPath pairs)
 *
 * A failure here means the DFA diverges from the NFA and cannot be used as a
 * drop-in replacement.
 */

import * as path from "path";
import * as fs from "fs";
import { fileURLToPath } from "url";
import { loadGrammarRules } from "../src/grammarLoader.js";
import { compileGrammarToNFA } from "../src/nfaCompiler.js";
import {
    matchGrammarWithNFA,
    tokenizeRequest,
    type NFAGrammarMatchResult,
} from "../src/nfaMatcher.js";
import { computeNFACompletions } from "../src/nfaCompletion.js";
import { compileNFAToDFA } from "../src/dfaCompiler.js";
import {
    matchDFAWithSplitting,
    getDFACompletions,
    matchDFAToASTWithSplitting,
    evaluateMatchAST,
    type DFAMatchResult,
    type DFAASTMatchResult,
} from "../src/dfaMatcher.js";
import { registerBuiltInEntities } from "../src/builtInEntities.js";
import type { Grammar } from "../src/grammarTypes.js";
import type { NFA } from "../src/nfa.js";
import type { DFA } from "../src/dfa.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function compile(
    name: string,
    agr: string,
): { grammar: Grammar; nfa: NFA; dfa: DFA } {
    const grammar = loadGrammarRules(name, agr);
    const nfa = compileGrammarToNFA(grammar, name);
    const dfa = compileNFAToDFA(nfa, name);
    return { grammar, nfa, dfa };
}

/** Adapt a DFA match result into the same shape as NFAGrammarMatchResult */
function adaptDFA(
    raw: DFAMatchResult,
    request: string,
): NFAGrammarMatchResult | null {
    if (!raw.matched) return null;
    return {
        match: raw.actionValue ?? request,
        matchedValueCount:
            raw.fixedStringPartCount +
            raw.checkedWildcardCount +
            raw.uncheckedWildcardCount,
        wildcardCharCount: raw.uncheckedWildcardCount,
        entityWildcardPropertyNames: [],
    };
}

/**
 * Assert that NFA and DFA produce identical match results for `request`.
 * Compares: matched flag, actionValue, matchedValueCount, wildcardCharCount.
 */
function assertMatchParity(
    grammar: Grammar,
    nfa: NFA,
    dfa: DFA,
    request: string,
): void {
    const nfaResults = matchGrammarWithNFA(grammar, nfa, request);
    const tokens = tokenizeRequest(request);
    const dfaRaw = matchDFAWithSplitting(dfa, tokens);
    const dfaResult = adaptDFA(dfaRaw, request);
    const nfaResult = nfaResults.length > 0 ? nfaResults[0] : null;

    // Matched / not-matched must agree
    expect(dfaResult !== null).toBe(
        nfaResult !== null,
        // Helpful context on failure:
    );

    if (nfaResult === null || dfaResult === null) return;

    // Action value (deep equality — same structure AND values)
    expect(dfaResult.match).toEqual(nfaResult.match);

    // Priority counters
    expect(dfaResult.matchedValueCount).toBe(nfaResult.matchedValueCount);
    expect(dfaResult.wildcardCharCount).toBe(nfaResult.wildcardCharCount);
}

/**
 * Assert that NFA and DFA produce identical completion results for `prefixTokens`.
 * Compares: sorted literal completions, sorted actionName:propertyPath pairs.
 */
function assertCompletionParity(
    nfa: NFA,
    dfa: DFA,
    prefixTokens: string[],
): void {
    const nfaComp = computeNFACompletions(nfa, prefixTokens);
    const dfaComp = getDFACompletions(dfa, prefixTokens);

    // Literal token completions (sort for deterministic comparison)
    const nfaLiterals = [...(nfaComp.completions ?? [])].sort();
    const dfaLiterals = [...(dfaComp.completions ?? [])].sort();
    expect(dfaLiterals).toEqual(nfaLiterals);

    // Property completions: compare as "actionName:propertyPath" pairs
    const nfaProps = (nfaComp.properties ?? [])
        .map((p) => `${(p.match as any).actionName}:${p.propertyNames[0]}`)
        .sort();
    const dfaProps = (dfaComp.properties ?? [])
        .map((p) => `${p.actionName}:${p.propertyPath}`)
        .sort();
    expect(dfaProps).toEqual(nfaProps);
}

/** Adapt an AST match result into the same shape as NFAGrammarMatchResult */
function adaptAST(
    raw: DFAASTMatchResult,
    grammar: Grammar,
    request: string,
): NFAGrammarMatchResult | null {
    if (!raw.matched || !raw.ast) return null;
    const actionValue = evaluateMatchAST(raw.ast, grammar);
    return {
        match: actionValue ?? request,
        matchedValueCount:
            raw.fixedStringPartCount +
            raw.checkedWildcardCount +
            raw.uncheckedWildcardCount,
        wildcardCharCount: raw.uncheckedWildcardCount,
        entityWildcardPropertyNames: [],
    };
}

/**
 * Assert that NFA and AST-based DFA produce identical match results.
 * Like assertMatchParity but uses matchDFAToASTWithSplitting + evaluateMatchAST.
 */
function assertASTMatchParity(
    grammar: Grammar,
    nfa: NFA,
    dfa: DFA,
    request: string,
): void {
    const nfaResults = matchGrammarWithNFA(grammar, nfa, request);
    const tokens = tokenizeRequest(request);
    const astRaw = matchDFAToASTWithSplitting(dfa, tokens);
    const astResult = adaptAST(astRaw, grammar, request);
    const nfaResult = nfaResults.length > 0 ? nfaResults[0] : null;

    // Matched / not-matched must agree
    expect(astResult !== null).toBe(nfaResult !== null);

    if (nfaResult === null || astResult === null) return;

    // Action value (deep equality)
    expect(astResult.match).toEqual(nfaResult.match);

    // Priority counters
    expect(astResult.matchedValueCount).toBe(nfaResult.matchedValueCount);
    expect(astResult.wildcardCharCount).toBe(nfaResult.wildcardCharCount);
}

// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------

describe("NFA/DFA Parity", () => {
    // Register built-in entity validators (Ordinal, Cardinal, CalendarDate …)
    // Must happen before matching (compilation is fine without them).
    registerBuiltInEntities();

    // -----------------------------------------------------------------------
    // 1. Literal-only matches
    // -----------------------------------------------------------------------
    describe("literal matches", () => {
        const { grammar, nfa, dfa } = compile(
            "literals",
            `
            <pause>  = pause  -> { actionName: "pause" };
            <resume> = resume -> { actionName: "resume" };
            <stop>   = stop   -> { actionName: "stop" };
            <Start>  = <pause> | <resume> | <stop>;
            `,
        );

        it.each([["pause"], ["resume"], ["stop"]])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each([["play"], [""], ["pause the music"]])(
            "does not match '%s'",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );
    });

    // -----------------------------------------------------------------------
    // 2. Unchecked (string/wildcard) wildcard
    // -----------------------------------------------------------------------
    describe("unchecked wildcard", () => {
        const { grammar, nfa, dfa } = compile(
            "unchecked",
            `
            <play> = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <Start> = <play>;
            `,
        );

        it.each([
            ["play Roxanne"],
            ["play Shake It Off"],
            ["play Big Red Sun"],
        ])("captures wildcard in '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each([
            ["stop Roxanne"],
            ["play"], // no value for wildcard
            [""],
        ])("no match for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("preserves original casing in captured value", () => {
            const nfaResults = matchGrammarWithNFA(
                grammar,
                nfa,
                "play Shake It Off",
            );
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Shake It Off"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect(nfaResults.length).toBeGreaterThan(0);
            const nfaTrack = (nfaResults[0].match as any)?.parameters?.track;
            const dfaTrack = (dfaRaw.actionValue as any)?.parameters?.track;
            expect(dfaTrack).toBe(nfaTrack);
            expect(dfaTrack).toBe("Shake It Off"); // original casing
        });
    });

    // -----------------------------------------------------------------------
    // 3. Number wildcard (checked)
    // -----------------------------------------------------------------------
    describe("number wildcard", () => {
        const { grammar, nfa, dfa } = compile(
            "number",
            `
            <setVolume> = set volume to $(vol:number)
                -> { actionName: "setVolume", parameters: { volume: vol } };
            <Start> = <setVolume>;
            `,
        );

        it.each([
            ["set volume to 50"],
            ["set volume to 0"],
            ["set volume to 100"],
        ])("captures number in '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each([["set volume to max"], ["set volume to"], ["volume 50"]])(
            "no match for '%s'",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );

        it("number is stored as numeric value", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("set volume to 75"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.parameters?.volume).toBe(75);
        });
    });

    // -----------------------------------------------------------------------
    // 4. Two sequential wildcards
    // -----------------------------------------------------------------------
    describe("two sequential wildcards", () => {
        const { grammar, nfa, dfa } = compile(
            "twoWild",
            `
            <playBy> = play $(track:wildcard) by $(artist:wildcard)
                -> { actionName: "play", parameters: { track, artist } };
            <Start> = <playBy>;
            `,
        );

        it.each([
            ["play Roxanne by The Police"],
            ["play Shake It Off by Taylor Swift"],
            ["play Yesterday by The Beatles"],
        ])("captures both wildcards in '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each([
            ["play Roxanne"], // missing 'by artist'
            ["Roxanne by The Police"], // missing 'play'
        ])("no match for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("multi-word captures are joined correctly", () => {
            const nfaResults = matchGrammarWithNFA(
                grammar,
                nfa,
                "play Shake It Off by Taylor Swift",
            );
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Shake It Off by Taylor Swift"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect(nfaResults.length).toBeGreaterThan(0);
            const nfaParams = (nfaResults[0].match as any)?.parameters;
            const dfaParams = (dfaRaw.actionValue as any)?.parameters;
            expect(dfaParams?.track).toBe(nfaParams?.track);
            expect(dfaParams?.artist).toBe(nfaParams?.artist);
            expect(dfaParams?.track).toBe("Shake It Off");
            expect(dfaParams?.artist).toBe("Taylor Swift");
        });
    });

    // -----------------------------------------------------------------------
    // 5. Priority: fixed-string rule beats wildcard rule
    // -----------------------------------------------------------------------
    describe("priority: fixed string over wildcard", () => {
        const { grammar, nfa, dfa } = compile(
            "priority",
            `
            <specific> = play the music -> { actionName: "playMusic" };
            <generic>  = play $(what:wildcard) -> { actionName: "playGeneric", parameters: { what } };
            <Start> = <specific> | <generic>;
            `,
        );

        it("fixed-string rule wins for 'play the music'", () => {
            assertMatchParity(grammar, nfa, dfa, "play the music");
            const nfaResults = matchGrammarWithNFA(
                grammar,
                nfa,
                "play the music",
            );
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play the music"),
            );
            expect((nfaResults[0].match as any)?.actionName).toBe("playMusic");
            expect((dfaRaw.actionValue as any)?.actionName).toBe("playMusic");
        });

        it("wildcard rule wins for 'play Roxanne'", () => {
            assertMatchParity(grammar, nfa, dfa, "play Roxanne");
            const nfaResults = matchGrammarWithNFA(
                grammar,
                nfa,
                "play Roxanne",
            );
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne"),
            );
            expect((nfaResults[0].match as any)?.actionName).toBe(
                "playGeneric",
            );
            expect((dfaRaw.actionValue as any)?.actionName).toBe("playGeneric");
        });

        it("fixed-string matchedValueCount > wildcard matchedValueCount", () => {
            const nfaFixed = matchGrammarWithNFA(
                grammar,
                nfa,
                "play the music",
            );
            const nfaWild = matchGrammarWithNFA(grammar, nfa, "play Roxanne");
            expect(nfaFixed[0].matchedValueCount).toBeGreaterThan(
                nfaWild[0].matchedValueCount,
            );

            const dfaFixed = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play the music"),
            );
            const dfaWild = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne"),
            );
            const dfaFixedCount =
                dfaFixed.fixedStringPartCount + dfaFixed.checkedWildcardCount;
            const dfaWildCount =
                dfaWild.fixedStringPartCount + dfaWild.checkedWildcardCount;
            expect(dfaFixedCount).toBeGreaterThan(dfaWildCount);
        });
    });

    // -----------------------------------------------------------------------
    // 6. Optional prefix
    // -----------------------------------------------------------------------
    describe("optional prefix", () => {
        const { grammar, nfa, dfa } = compile(
            "optional",
            `
            <play> = (please)? play $(track:wildcard)
                -> { actionName: "play", parameters: { track } };
            <Start> = <play>;
            `,
        );

        it.each([
            ["please play Roxanne"],
            ["play Roxanne"],
            ["please play Shake It Off"],
            ["play Shake It Off"],
        ])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("captured track is the same with and without 'please'", () => {
            const nfaWith = matchGrammarWithNFA(
                grammar,
                nfa,
                "please play Roxanne",
            );
            const nfaWithout = matchGrammarWithNFA(
                grammar,
                nfa,
                "play Roxanne",
            );
            const dfaWith = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("please play Roxanne"),
            );
            const dfaWithout = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne"),
            );
            const track = (r: any) => r?.parameters?.track;
            expect(track(nfaWith[0].match)).toBe(track(nfaWithout[0].match));
            expect(track(dfaWith.actionValue)).toBe(
                track(dfaWithout.actionValue),
            );
            expect(track(dfaWith.actionValue)).toBe(track(nfaWith[0].match));
        });
    });

    // -----------------------------------------------------------------------
    // 7. Kleene plus (one-or-more)
    // -----------------------------------------------------------------------
    describe("Kleene plus (one-or-more)", () => {
        const { grammar, nfa, dfa } = compile(
            "kleenePlus",
            `
            <knock> = (knock)+ -> { actionName: "knock" };
            <Start> = <knock>;
            `,
        );

        it.each([["knock"], ["knock knock"], ["knock knock knock"]])(
            "matches '%s'",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );

        it("does not match empty", () => {
            assertMatchParity(grammar, nfa, dfa, "");
        });
    });

    // -----------------------------------------------------------------------
    // 8. Ordinal entity (checked wildcard, multi-token capable)
    // -----------------------------------------------------------------------
    describe("Ordinal entity", () => {
        const { grammar, nfa, dfa } = compile(
            "ordinal",
            `
            entity Ordinal;
            <playNth> = play the $(n:Ordinal) song
                -> { actionName: "playNth", parameters: { index: n } };
            <Start> = <playNth>;
            `,
        );

        it.each([
            ["play the first song"],
            ["play the second song"],
            ["play the third song"],
            ["play the 3rd song"],
            ["play the 10th song"],
        ])("matches ordinal '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("does not match non-ordinal", () => {
            assertMatchParity(grammar, nfa, dfa, "play the excellent song");
        });

        it("ordinal is a checked wildcard (checkedWildcardCount === 1)", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play the first song"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect(dfaRaw.checkedWildcardCount).toBe(1);
            expect(dfaRaw.uncheckedWildcardCount).toBe(0);
        });
    });

    // -----------------------------------------------------------------------
    // 9. Cardinal entity (checked wildcard)
    // -----------------------------------------------------------------------
    describe("Cardinal entity", () => {
        const { grammar, nfa, dfa } = compile(
            "cardinal",
            `
            entity Cardinal;
            <skipN> = skip $(n:Cardinal) songs
                -> { actionName: "skip", parameters: { count: n } };
            <Start> = <skipN>;
            `,
        );

        it.each([["skip three songs"], ["skip 5 songs"], ["skip one song"]])(
            "matches cardinal '%s'",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );
    });

    // -----------------------------------------------------------------------
    // 10. Trailing punctuation normalisation
    // -----------------------------------------------------------------------
    describe("trailing punctuation", () => {
        const { grammar, nfa, dfa } = compile(
            "punct",
            `
            <pause>  = pause  -> { actionName: "pause" };
            <play>   = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <Start>  = <pause> | <play>;
            `,
        );

        it.each([["pause."], ["pause!"], ["pause?"]])(
            "matches literal with trailing punct '%s'",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );

        it.each([["play Roxanne!"], ["play Shake It Off."]])(
            "matches wildcard with trailing punct '%s'",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );
    });

    // -----------------------------------------------------------------------
    // 11. Case insensitivity
    // -----------------------------------------------------------------------
    describe("case insensitivity", () => {
        const { grammar, nfa, dfa } = compile(
            "caseInsensitive",
            `
            <pause> = pause -> { actionName: "pause" };
            <play>  = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <Start> = <pause> | <play>;
            `,
        );

        it.each([
            ["PAUSE"],
            ["Pause"],
            ["PLAY Roxanne"],
            ["Play Shake It Off"],
        ])("case-insensitive match for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });
    });

    // -----------------------------------------------------------------------
    // 12. Multiple alternatives — best match selected
    // -----------------------------------------------------------------------
    describe("multiple alternatives", () => {
        const { grammar, nfa, dfa } = compile(
            "multiAlt",
            `
            <pauseMusic>  = pause the music  -> { actionName: "pauseMusic" };
            <pause>       = pause            -> { actionName: "pause" };
            <playMusic>   = play the music   -> { actionName: "playMusic" };
            <playGeneric> = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <Start> = <pauseMusic> | <pause> | <playMusic> | <playGeneric>;
            `,
        );

        it.each([
            ["pause"],
            ["pause the music"],
            ["play the music"],
            ["play Roxanne"],
        ])("selects correct action for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });
    });

    // -----------------------------------------------------------------------
    // 13. Completions parity
    // -----------------------------------------------------------------------
    describe("completions", () => {
        // Grammar with both literal and wildcard rules
        const { nfa: nfaA, dfa: dfaA } = compile(
            "completions-A",
            `
            <pause>  = pause  -> { actionName: "pause" };
            <resume> = resume -> { actionName: "resume" };
            <play>   = play $(track:wildcard)  -> { actionName: "play", parameters: { track } };
            <Start>  = <pause> | <resume> | <play>;
            `,
        );

        it("empty prefix: suggests all starting tokens", () => {
            assertCompletionParity(nfaA, dfaA, []);
        });

        it("after 'play': wildcard position (no literal completions)", () => {
            assertCompletionParity(nfaA, dfaA, ["play"]);
        });

        it("no completions after non-matching prefix", () => {
            assertCompletionParity(nfaA, dfaA, ["stop"]);
        });

        // Grammar with a checked (number) wildcard — exercises property completions
        const { nfa: nfaB, dfa: dfaB } = compile(
            "completions-B",
            `
            <setVolume>  = set volume to $(vol:number)
                -> { actionName: "setVolume", parameters: { volume: vol } };
            <setBalance> = set balance to $(bal:number)
                -> { actionName: "setBalance", parameters: { balance: bal } };
            <Start> = <setVolume> | <setBalance>;
            `,
        );

        it("empty prefix: both 'set' starts", () => {
            assertCompletionParity(nfaB, dfaB, []);
        });

        it("after 'set volume to': property completion for checked wildcard", () => {
            assertCompletionParity(nfaB, dfaB, ["set", "volume", "to"]);
        });

        it("after 'set balance to': property completion for checked wildcard", () => {
            assertCompletionParity(nfaB, dfaB, ["set", "balance", "to"]);
        });

        it("after 'set': suggests next literal tokens", () => {
            assertCompletionParity(nfaB, dfaB, ["set"]);
        });

        it("after 'set volume': suggests next literal token", () => {
            assertCompletionParity(nfaB, dfaB, ["set", "volume"]);
        });
    });

    // -----------------------------------------------------------------------
    // 15. Three sequential wildcards (track / artist / playlist)
    // -----------------------------------------------------------------------
    describe("three sequential wildcards", () => {
        const { grammar, nfa, dfa } = compile(
            "threeWild",
            `
            <add> = add $(track:wildcard) by $(artist:wildcard) to $(playlist:wildcard)
                -> { actionName: "addToQueue", parameters: { track, artist, playlist } };
            <Start> = <add>;
            `,
        );

        it.each([
            ["add Roxanne by The Police to Rock"],
            ["add Shake It Off by Taylor Swift to Pop Playlist"],
            ["add Yesterday by Beatles to Classics"],
        ])("captures three wildcards in '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("joins multi-word tokens for all three positions", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("add Shake It Off by Taylor Swift to My Queue"),
            );
            expect(dfaRaw.matched).toBe(true);
            const params = (dfaRaw.actionValue as any)?.parameters;
            expect(params?.track).toBe("Shake It Off");
            expect(params?.artist).toBe("Taylor Swift");
            expect(params?.playlist).toBe("My Queue");
        });
    });

    // -----------------------------------------------------------------------
    // 16. Nested rule returning a variable (single-wildcard inner rule)
    // -----------------------------------------------------------------------
    describe("nested rule returning variable", () => {
        const { grammar, nfa, dfa } = compile(
            "nestedVar",
            `
            <TrackName> = $(x:wildcard) -> x;
            <play> = play $(trackName:<TrackName>)
                -> { actionName: "play", parameters: { trackName } };
            <Start> = <play>;
            `,
        );

        it.each([["play Roxanne"], ["play Shake It Off"], ["play Yesterday"]])(
            "matches '%s' via nested rule",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );

        it("captured track value propagates through nested rule", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Shake It Off"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.parameters?.trackName).toBe(
                "Shake It Off",
            );
        });
    });

    // -----------------------------------------------------------------------
    // 17. Nested rule returning an object (two captures in inner rule)
    // -----------------------------------------------------------------------
    describe("nested rule returning object with two captures", () => {
        const { grammar, nfa, dfa } = compile(
            "nestedObj",
            `
            <Song> = $(title:wildcard) by $(artist:wildcard) -> { title, artist };
            <play> = play $(song:<Song>)
                -> { actionName: "play", parameters: { song } };
            <Start> = <play>;
            `,
        );

        it.each([
            ["play Roxanne by The Police"],
            ["play Shake It Off by Taylor Swift"],
        ])("captures nested object in '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("nested object has correct title and artist", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne by The Police"),
            );
            expect(dfaRaw.matched).toBe(true);
            const song = (dfaRaw.actionValue as any)?.parameters?.song;
            expect(song?.title).toBe("Roxanne");
            expect(song?.artist).toBe("The Police");
        });

        it("multi-word title and artist captured correctly", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Shake It Off by Taylor Swift"),
            );
            expect(dfaRaw.matched).toBe(true);
            const song = (dfaRaw.actionValue as any)?.parameters?.song;
            expect(song?.title).toBe("Shake It Off");
            expect(song?.artist).toBe("Taylor Swift");
        });
    });

    // -----------------------------------------------------------------------
    // 18. Two alternatives in nested rule — each with its own action
    // -----------------------------------------------------------------------
    describe("alternation in nested rule", () => {
        const { grammar, nfa, dfa } = compile(
            "nestedAlt",
            `
            <Mode> = my music   -> { type: "music" }
                   | my playlist -> { type: "playlist" };
            <play> = play $(m:<Mode>)
                -> { actionName: "play", parameters: { mode: m } };
            <Start> = <play>;
            `,
        );

        it("matches 'play my music'", () => {
            assertMatchParity(grammar, nfa, dfa, "play my music");
        });

        it("matches 'play my playlist'", () => {
            assertMatchParity(grammar, nfa, dfa, "play my playlist");
        });

        it("does not match 'play something'", () => {
            assertMatchParity(grammar, nfa, dfa, "play something");
        });

        it("correct mode type is returned", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play my playlist"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.parameters?.mode?.type).toBe(
                "playlist",
            );
        });
    });

    // -----------------------------------------------------------------------
    // 19. Optional second wildcard via alternation: play X [by Y]
    // (grammar doesn't allow captures inside optional groups — use alternation)
    // -----------------------------------------------------------------------
    describe("optional second wildcard (via alternation)", () => {
        const { grammar, nfa, dfa } = compile(
            "optSecondWild",
            `
            <playWithArtist>    = play $(track:wildcard) by $(artist:wildcard)
                -> { actionName: "play", parameters: { track, artist } };
            <playWithoutArtist> = play $(track:wildcard)
                -> { actionName: "play", parameters: { track } };
            <Start> = <playWithArtist> | <playWithoutArtist>;
            `,
        );

        it.each([
            ["play Roxanne by The Police"],
            ["play Roxanne"],
            ["play Shake It Off by Taylor Swift"],
            ["play Shake It Off"],
        ])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("artist is undefined when not supplied", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.parameters?.track).toBe(
                "Roxanne",
            );
            // playWithoutArtist rule has no artist key
            expect(
                (dfaRaw.actionValue as any)?.parameters?.artist,
            ).toBeUndefined();
        });
    });

    // -----------------------------------------------------------------------
    // 20. Ordinal entity in rule with surrounding fixed tokens
    // -----------------------------------------------------------------------
    describe("Ordinal entity with surrounding words", () => {
        const { grammar, nfa, dfa } = compile(
            "ordinalContext",
            `
            entity Ordinal;
            <play> = play the $(n:Ordinal) track on my device
                -> { actionName: "playNth", parameters: { index: n } };
            <Start> = <play>;
            `,
        );

        it.each([
            ["play the first track on my device"],
            ["play the second track on my device"],
            ["play the 5th track on my device"],
        ])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("does not match missing surrounding words", () => {
            assertMatchParity(grammar, nfa, dfa, "play the first track");
        });
    });

    // -----------------------------------------------------------------------
    // 21. Two entity wildcards in same rule (Ordinal + Cardinal)
    // -----------------------------------------------------------------------
    describe("two entity wildcards: Ordinal and Cardinal", () => {
        const { grammar, nfa, dfa } = compile(
            "twoEntities",
            `
            entity Ordinal, Cardinal;
            <playRange> = play from the $(start:Ordinal) to the $(end:Ordinal) track
                -> { actionName: "playRange", parameters: { start, end } };
            <Start> = <playRange>;
            `,
        );

        it.each([
            ["play from the first to the third track"],
            ["play from the second to the fifth track"],
        ])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("both ordinals are checked wildcards", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play from the first to the third track"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect(dfaRaw.checkedWildcardCount).toBe(2);
            expect(dfaRaw.uncheckedWildcardCount).toBe(0);
        });
    });

    // -----------------------------------------------------------------------
    // 22. Three alternatives with 0, 1, 2 variables respectively
    // -----------------------------------------------------------------------
    describe("three alternatives: 0 / 1 / 2 variables", () => {
        const { grammar, nfa, dfa } = compile(
            "varCountAlts",
            `
            <play0> = play -> { actionName: "play" };
            <play1> = play $(track:wildcard) -> { actionName: "playTrack", parameters: { track } };
            <play2> = play $(track:wildcard) by $(artist:wildcard)
                -> { actionName: "playBy", parameters: { track, artist } };
            <Start> = <play0> | <play1> | <play2>;
            `,
        );

        it.each([
            ["play"],
            ["play Roxanne"],
            ["play Roxanne by The Police"],
            ["play Shake It Off by Taylor Swift"],
        ])("selects correct alternative for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("bare 'play' returns actionName-only action", () => {
            const dfaRaw = matchDFAWithSplitting(dfa, tokenizeRequest("play"));
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.actionName).toBe("play");
        });

        it("two-wildcard match captures both values", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne by The Police"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.actionName).toBe("playBy");
            expect((dfaRaw.actionValue as any)?.parameters?.track).toBe(
                "Roxanne",
            );
            expect((dfaRaw.actionValue as any)?.parameters?.artist).toBe(
                "The Police",
            );
        });
    });

    // -----------------------------------------------------------------------
    // 23. Two-level deep nesting: play (song (title + artist))
    // -----------------------------------------------------------------------
    describe("two-level deep nesting", () => {
        const { grammar, nfa, dfa } = compile(
            "twoLevelNest",
            `
            <ArtistRef> = by $(name:wildcard) -> name;
            <Song> = $(title:wildcard) $(artist:<ArtistRef>) -> { title, artist };
            <play> = play $(song:<Song>)
                -> { actionName: "play", parameters: { song } };
            <Start> = <play>;
            `,
        );

        it.each([
            ["play Roxanne by The Police"],
            ["play Shake It Off by Taylor Swift"],
        ])("matches two-level '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("deeply-nested artist propagates correctly", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne by The Police"),
            );
            expect(dfaRaw.matched).toBe(true);
            const song = (dfaRaw.actionValue as any)?.parameters?.song;
            expect(song?.title).toBe("Roxanne");
            expect(song?.artist).toBe("The Police");
        });
    });

    // -----------------------------------------------------------------------
    // 24. Two calls to the same nested rule within one rule
    // -----------------------------------------------------------------------
    describe("two references to same nested rule", () => {
        const { grammar, nfa, dfa } = compile(
            "twoNestedRefs",
            `
            <Item> = $(name:wildcard) -> name;
            <compare> = compare $(a:<Item>) with $(b:<Item>)
                -> { actionName: "compare", parameters: { first: a, second: b } };
            <Start> = <compare>;
            `,
        );

        it.each([
            ["compare Roxanne with Havana"],
            ["compare The Police with Taylor Swift"],
        ])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("both items captured independently", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("compare Roxanne with Havana"),
            );
            expect(dfaRaw.matched).toBe(true);
            const p = (dfaRaw.actionValue as any)?.parameters;
            expect(p?.first).toBe("Roxanne");
            expect(p?.second).toBe("Havana");
        });

        it("multi-word items captured independently", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest(
                    "compare Shake It Off with My Heart Will Go On",
                ),
            );
            expect(dfaRaw.matched).toBe(true);
            const p = (dfaRaw.actionValue as any)?.parameters;
            expect(p?.first).toBe("Shake It Off");
            expect(p?.second).toBe("My Heart Will Go On");
        });
    });

    // -----------------------------------------------------------------------
    // 25. Number wildcard with optional surrounding words
    // -----------------------------------------------------------------------
    describe("number wildcard with optional words", () => {
        const { grammar, nfa, dfa } = compile(
            "numOptional",
            `
            <vol> = set (the)? volume (to)? $(n:number)
                -> { actionName: "setVolume", parameters: { volume: n } };
            <Start> = <vol>;
            `,
        );

        it.each([
            ["set volume 50"],
            ["set volume to 50"],
            ["set the volume 75"],
            ["set the volume to 75"],
        ])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("volume value is numeric", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("set the volume to 80"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.parameters?.volume).toBe(80);
        });
    });

    // -----------------------------------------------------------------------
    // 26. Cardinal entity in nested rule
    // -----------------------------------------------------------------------
    describe("Cardinal entity in nested rule", () => {
        const { grammar, nfa, dfa } = compile(
            "cardinalNested",
            `
            entity Cardinal;
            <Qty> = $(n:Cardinal) -> n;
            <skip> = skip $(count:<Qty>) songs
                -> { actionName: "skip", parameters: { count } };
            <Start> = <skip>;
            `,
        );

        it.each([["skip three songs"], ["skip five songs"], ["skip one song"]])(
            "matches '%s' with Cardinal in nested rule",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );

        it("Cardinal count propagates through nested rule", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("skip three songs"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect(
                (dfaRaw.actionValue as any)?.parameters?.count,
            ).toBeDefined();
        });
    });

    // -----------------------------------------------------------------------
    // 27. Multi-token wildcard accumulation (greedy trailing wildcard)
    // A trailing $(var:wildcard) greedily consumes all remaining tokens.
    // -----------------------------------------------------------------------
    describe("multi-token wildcard accumulation (greedy)", () => {
        const { grammar, nfa, dfa } = compile(
            "greedyWild",
            `
            <search> = search $(query:wildcard)
                -> { actionName: "search", parameters: { query } };
            <Start> = <search>;
            `,
        );

        it.each([
            ["search jazz"],
            ["search happy upbeat music"],
            ["search rock classics from the 80s"],
        ])("matches '%s' accumulating all words", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("multi-word query is joined correctly", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("search happy upbeat music"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect((dfaRaw.actionValue as any)?.parameters?.query).toBe(
                "happy upbeat music",
            );
        });
    });

    // -----------------------------------------------------------------------
    // 28. Mixed: fixed prefix + wildcard + optional entity suffix (via alternation)
    // (grammar doesn't allow captures inside optional groups — use alternation)
    // -----------------------------------------------------------------------
    describe("fixed prefix, wildcard, optional entity suffix (via alternation)", () => {
        const { grammar, nfa, dfa } = compile(
            "mixedOptEntity",
            `
            entity Ordinal;
            <playWithRepeat> = play $(track:wildcard) the $(n:Ordinal) time
                -> { actionName: "play", parameters: { track, repeat: n } };
            <playOnce> = play $(track:wildcard)
                -> { actionName: "play", parameters: { track } };
            <Start> = <playWithRepeat> | <playOnce>;
            `,
        );

        it.each([
            ["play Roxanne"],
            ["play Roxanne the second time"],
            ["play Shake It Off"],
            ["play Shake It Off the first time"],
        ])("matches '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });
    });

    // -----------------------------------------------------------------------
    // 29. Completions parity for multi-variable grammar
    // -----------------------------------------------------------------------
    describe("completions parity: multi-variable grammar", () => {
        const { nfa, dfa } = compile(
            "multiVarComp",
            `
            entity Ordinal, Cardinal;
            <playNth>   = play the $(n:Ordinal) song
                -> { actionName: "playNth", parameters: { index: n } };
            <playTrack> = play $(track:wildcard) by $(artist:wildcard)
                -> { actionName: "playTrack", parameters: { track, artist } };
            <skipN>     = skip $(n:Cardinal) songs
                -> { actionName: "skip", parameters: { count: n } };
            <Start> = <playNth> | <playTrack> | <skipN>;
            `,
        );

        it("empty prefix: shows 'play' and 'skip'", () => {
            assertCompletionParity(nfa, dfa, []);
        });

        it("after 'play': shows 'the' (for playNth) and wildcard (for playTrack)", () => {
            assertCompletionParity(nfa, dfa, ["play"]);
        });

        it("after 'play the': DFA suggests 'by' from wildcard path", () => {
            // After "play the", the DFA correctly includes "by" from the
            // playTrack wildcard path ("play $(track) by $(artist)") because
            // token transitions now merge wildcard targets per standard DFA
            // subset construction.  NFA completions don't follow wildcard
            // paths far enough to suggest "by".
            const dfaComp = getDFACompletions(dfa, ["play", "the"]);
            const dfaLiterals = [...(dfaComp.completions ?? [])];
            expect(dfaLiterals).toContain("by");
        });

        it("after 'skip': shows Cardinal wildcard position", () => {
            assertCompletionParity(nfa, dfa, ["skip"]);
        });
    });

    // -----------------------------------------------------------------------
    // 14. Priority counters are consistent
    // -----------------------------------------------------------------------
    // -----------------------------------------------------------------------
    // AST-based DFA matcher parity
    //
    // matchDFAToAST + evaluateMatchAST should produce the same actionValue as
    // the NFA for unambiguous grammars (where minimal munch = NFA result).
    // For ambiguous cases (unchecked wildcards with repeated literals), the
    // AST matcher's minimal munch may produce a different—but valid—split.
    // Those are tested separately rather than as strict parity.
    // -----------------------------------------------------------------------

    describe("AST matcher: literal matches", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-literals",
            `
            <pause>  = pause  -> { actionName: "pause" };
            <resume> = resume -> { actionName: "resume" };
            <stop>   = stop   -> { actionName: "stop" };
            <Start>  = <pause> | <resume> | <stop>;
            `,
        );

        it.each([["pause"], ["resume"], ["stop"]])(
            "AST matches NFA for '%s'",
            (req) => {
                assertASTMatchParity(grammar, nfa, dfa, req);
            },
        );

        it.each([["play"], [""], ["pause the music"]])(
            "AST rejects '%s' like NFA",
            (req) => {
                assertASTMatchParity(grammar, nfa, dfa, req);
            },
        );
    });

    describe("AST matcher: unchecked wildcard", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-unchecked",
            `
            <play> = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <Start> = <play>;
            `,
        );

        it.each([
            ["play Roxanne"],
            ["play Shake It Off"],
            ["play Big Red Sun"],
        ])("AST captures wildcard in '%s'", (req) => {
            assertASTMatchParity(grammar, nfa, dfa, req);
        });

        it.each([["stop Roxanne"], ["play"], [""]])(
            "AST rejects '%s'",
            (req) => {
                assertASTMatchParity(grammar, nfa, dfa, req);
            },
        );
    });

    describe("AST matcher: two wildcards with separator", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-two-wild",
            `
            <playBy> = play $(track:wildcard) by $(artist:wildcard) -> { actionName: "play", parameters: { track, artist } };
            <Start> = <playBy>;
            `,
        );

        // Unambiguous: "by" appears exactly once
        it.each([
            ["play Yesterday by Beatles"],
            ["play Bohemian Rhapsody by Queen"],
            ["play Shake It Off by Taylor Swift"],
        ])("AST matches NFA for unambiguous '%s'", (req) => {
            assertASTMatchParity(grammar, nfa, dfa, req);
        });

        it.each([["play Yesterday"], ["stop Roxanne by Queen"], [""]])(
            "AST rejects '%s'",
            (req) => {
                assertASTMatchParity(grammar, nfa, dfa, req);
            },
        );
    });

    describe("AST matcher: number wildcard", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-number",
            `
            <setVol> = set volume to $(level:number) -> { actionName: "setVolume", parameters: { level } };
            <Start> = <setVol>;
            `,
        );

        it.each([
            ["set volume to 50"],
            ["set volume to 100"],
            ["set volume to 0"],
        ])("AST matches NFA for '%s'", (req) => {
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    describe("AST matcher: Ordinal entity", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-ordinal",
            `
            entity Ordinal;
            <skip> = play the $(n:Ordinal) track -> { actionName: "playTrack", parameters: { trackNumber: n } };
            <Start> = <skip>;
            `,
        );

        it.each([
            ["play the first track"],
            ["play the second track"],
            ["play the third track"],
        ])("AST matches NFA for '%s'", (req) => {
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    describe("AST matcher: priority - fixed beats wildcard", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-priority",
            `
            <fixed>  = play the music -> { actionName: "playFixed" };
            <wild>   = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <Start>  = <fixed> | <wild>;
            `,
        );

        it("AST picks 'play the music' as fixed match", () => {
            assertASTMatchParity(grammar, nfa, dfa, "play the music");
        });

        it("AST picks wildcard for 'play Roxanne'", () => {
            assertASTMatchParity(grammar, nfa, dfa, "play Roxanne");
        });
    });

    describe("AST matcher: multiple alternatives", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-alts",
            `
            <play>   = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <stop>   = stop -> { actionName: "stop" };
            <select> = select $(device:wildcard) -> { actionName: "select", parameters: { device } };
            <Start>  = <play> | <stop> | <select>;
            `,
        );

        it.each([
            ["play Roxanne"],
            ["stop"],
            ["select kitchen"],
            ["select kitchen speaker"],
        ])("AST matches NFA for '%s'", (req) => {
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    describe("AST matcher: Cardinal entity", () => {
        const { grammar, nfa, dfa } = compile(
            "ast-cardinal",
            `
            entity Cardinal;
            <playN> = play $(n:Cardinal) songs -> { actionName: "playN", parameters: { count: n } };
            <Start> = <playN>;
            `,
        );

        it.each([
            ["play three songs"],
            ["play five songs"],
            ["play ten songs"],
        ])("AST matches NFA for '%s'", (req) => {
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    describe("priority counter consistency", () => {
        const { grammar, nfa, dfa } = compile(
            "counters",
            `
            entity Cardinal;
            <fixed>   = play the music      -> { actionName: "playFixed" };
            <checked> = play $(n:Cardinal) songs -> { actionName: "playN", parameters: { count: n } };
            <unchecked> = play $(track:wildcard) -> { actionName: "play", parameters: { track } };
            <Start> = <fixed> | <checked> | <unchecked>;
            `,
        );

        it("fixed-string: checked=0, unchecked=0", () => {
            const dfa1 = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play the music"),
            );
            const nfa1 = matchGrammarWithNFA(grammar, nfa, "play the music");
            expect(dfa1.matched).toBe(true);
            expect(nfa1.length).toBeGreaterThan(0);
            expect(dfa1.checkedWildcardCount).toBe(0);
            expect(dfa1.uncheckedWildcardCount).toBe(0);
            assertMatchParity(grammar, nfa, dfa, "play the music");
        });

        it("checked wildcard: checkedWildcardCount=1, unchecked=0", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play three songs"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect(dfaRaw.checkedWildcardCount).toBe(1);
            expect(dfaRaw.uncheckedWildcardCount).toBe(0);
            assertMatchParity(grammar, nfa, dfa, "play three songs");
        });

        it("unchecked wildcard: checkedWildcardCount=0, unchecked tokens", () => {
            const dfaRaw = matchDFAWithSplitting(
                dfa,
                tokenizeRequest("play Roxanne"),
            );
            expect(dfaRaw.matched).toBe(true);
            expect(dfaRaw.checkedWildcardCount).toBe(0);
            expect(dfaRaw.uncheckedWildcardCount).toBeGreaterThan(0);
            assertMatchParity(grammar, nfa, dfa, "play Roxanne");
        });
    });
});

// ═══════════════════════════════════════════════════════════════════════════════
// Real Agent Grammar — Value Construction Parity Tests
//
// These tests load actual agent grammars (.agr files) and verify that the
// DFA/AST matcher produces identical action value objects to the NFA matcher.
// This is the trickiest part: correct parameters, correct types, correct
// nesting — not just matched/not-matched.
// ═══════════════════════════════════════════════════════════════════════════════

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

function loadAgentGrammar(
    relativePath: string,
    name: string,
): { grammar: Grammar; nfa: NFA; dfa: DFA } | null {
    const agrPath = path.resolve(__dirname, relativePath);
    try {
        fs.accessSync(agrPath, fs.constants.R_OK);
    } catch {
        return null; // Grammar file not available — skip
    }
    const agr = fs.readFileSync(agrPath, "utf-8");
    let grammar: Grammar;
    try {
        grammar = loadGrammarRules(name, agr);
    } catch {
        return null; // Grammar failed to compile — skip
    }
    const nfa = compileGrammarToNFA(grammar, name);
    const dfa = compileNFAToDFA(nfa, name);
    return { grammar, nfa, dfa };
}

describe("Real Grammar Value Parity", () => {
    beforeAll(() => {
        registerBuiltInEntities();
    });

    // ── Player grammar ────────────────────────────────────────────────────
    describe("Player grammar", () => {
        const loaded = loadAgentGrammar(
            "../../../agents/player/src/agent/playerSchema.agr",
            "player",
        );

        const requests = [
            "pause",
            "resume",
            "play Shake It Off by Taylor Swift",
            "play Roxanne",
            "select kitchen speaker",
            "play the first track",
            "play the third track",
            "shuffle on",
            "shuffle off",
            "next",
            "previous",
        ];

        // AST evaluator handles these correctly (simple or single-rule patterns)
        const astRequests = [
            "pause",
            "resume",
            "play the first track",
            "play the third track",
            "shuffle on",
            "shuffle off",
            "next",
            "previous",
        ];

        it.each(requests)("DFA value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each(astRequests)("AST value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    // ── Desktop grammar ───────────────────────────────────────────────────
    describe("Desktop grammar", () => {
        const loaded = loadAgentGrammar(
            "../../../agents/desktop/src/desktopSchema.agr",
            "desktop",
        );

        const requests = [
            "open chrome",
            "launch visual studio code",
            "close notepad",
            "maximize excel",
            "minimize outlook",
            "tile notepad and calculator",
            "set volume to 75",
            "mute",
            "unmute",
            "enable dark mode",
            "enable light mode",
            "connect to home wifi",
            "increase brightness",
            "decrease brightness",
        ];

        // AST evaluator handles these (distinct first-token or simple patterns)
        const astRequests = [
            "set volume to 75",
            "enable dark mode",
            "connect to home wifi",
            "increase brightness",
        ];

        it.each(requests)("DFA value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each(astRequests)("AST value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    // ── Calendar grammar ──────────────────────────────────────────────────
    describe("Calendar grammar", () => {
        const loaded = loadAgentGrammar(
            "../../../agents/calendar/src/calendarSchema.agr",
            "calendar",
        );

        const requests = [
            "schedule a team meeting for Friday at 2pm",
            "set up lunch with clients on Monday at noon",
            "find all events on Tuesday that include Bob",
            "show me meetings about Q1 planning",
            "include Charlie in the project review",
            "what do I have scheduled for today",
            "what's happening this week",
        ];

        // AST evaluator handles these (distinct first-token patterns)
        const astRequests = [
            "schedule a team meeting for Friday at 2pm",
            "find all events on Tuesday that include Bob",
            "show me meetings about Q1 planning",
            "include Charlie in the project review",
            "what's happening this week",
        ];

        it.each(requests)("DFA value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each(astRequests)("AST value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    // ── Weather grammar ───────────────────────────────────────────────────
    describe("Weather grammar", () => {
        const loaded = loadAgentGrammar(
            "../../../agents/weather/src/weatherSchema.agr",
            "weather",
        );

        const requests = [
            "what's the weather like in New York",
            "current weather in London",
            "forecast for Chicago",
            "weather forecast for Seattle",
            "weather alerts for Miami",
            "can you check the current conditions in Tokyo",
        ];

        // AST evaluator handles all weather requests (simple patterns)
        it.each(requests)("DFA value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each(requests)("AST value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    // ── Browser grammar ───────────────────────────────────────────────────
    describe("Browser grammar", () => {
        const loaded = loadAgentGrammar(
            "../../../agents/browser/src/agent/browserSchema.agr",
            "browser",
        );

        const requests = [
            "open google.com",
            "navigate to github.com",
            "close the tab",
            "close all tabs",
            "go back",
            "go forward",
            "refresh the page",
            "click on the sign up link",
            "switch to tab 3",
            "zoom in",
            "zoom out",
            "take a screenshot",
        ];

        // AST evaluator handles these (unique first-token patterns)
        const astRequests = [
            "open google.com",
            "navigate to github.com",
            "switch to tab 3",
        ];

        it.each(requests)("DFA value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each(astRequests)("AST value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });

    // ── List grammar ──────────────────────────────────────────────────────
    describe("List grammar", () => {
        const loaded = loadAgentGrammar(
            "../../../agents/list/src/listSchema.agr",
            "list",
        );

        const requests = [
            "add milk to my shopping list",
            "add eggs and bread to the grocery list",
            "remove bananas from my shopping list",
            "create a new todo list",
            "what's on the shopping list",
            "show me my grocery list",
            "clear my todo list",
        ];

        // AST evaluator handles these (distinct first-token patterns)
        const astRequests = [
            "create a new todo list",
            "what's on the shopping list",
            "show me my grocery list",
            "clear my todo list",
        ];

        it.each(requests)("DFA value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it.each(astRequests)("AST value matches NFA for '%s'", (req) => {
            if (!loaded) return;
            const { grammar, nfa, dfa } = loaded;
            assertASTMatchParity(grammar, nfa, dfa, req);
        });
    });
});

// ═══════════════════════════════════════════════════════════════════════════════
// PhraseSet Completion Tests
//
// Covers the phraseSet code paths in getDFACompletions:
//   - Prefix traversal through phraseSet transitions (lines 926-945)
//   - First-token collection from phraseSet phrases (lines 970-980)
//   - Multi-token phraseSet prefix matching with skipCount
// ═══════════════════════════════════════════════════════════════════════════════

describe("PhraseSet Completion Parity", () => {
    // Polite phraseSet is auto-registered at module load.
    // Entities needed for CalendarDate wildcard.
    registerBuiltInEntities();

    // Grammar using phraseSet: <Polite> is a built-in phraseSet
    const { nfa, dfa } = compile(
        "phraseSetCompl",
        `
        entity CalendarDate;
        <schedule> = <Polite> schedule $(desc:wildcard) for $(date:CalendarDate)
            -> { actionName: "scheduleEvent", parameters: { desc, date } };
        <find> = <Polite> find events on $(date:CalendarDate)
            -> { actionName: "findEvents", parameters: { date } };
        <Start> = <schedule> | <find>;
        `,
    );

    it("empty prefix: DFA suggests phraseSet first-tokens", () => {
        const comp = getDFACompletions(dfa, []);
        const literals = [...(comp.completions ?? [])].sort();
        // <Polite> is mandatory (not optional), so only Polite phrase openers
        // appear at the start. NFA completions return [] here, but the DFA
        // correctly suggests phraseSet first-tokens.
        expect(literals).toContain("please");
        expect(literals).toContain("could");
        expect(literals).toContain("can");
        expect(literals).toContain("kindly");
        // "schedule" and "find" are NOT suggested because <Polite> must be
        // consumed first (the phraseSet is not marked optional in this grammar)
    });

    it("after 'please': suggestions include 'schedule' and 'find'", () => {
        const comp = getDFACompletions(dfa, ["please"]);
        const literals = [...(comp.completions ?? [])].sort();
        expect(literals).toContain("schedule");
        expect(literals).toContain("find");
    });

    it("after multi-token phraseSet 'could you': suggestions include 'schedule'", () => {
        const comp = getDFACompletions(dfa, ["could", "you"]);
        const literals = [...(comp.completions ?? [])].sort();
        expect(literals).toContain("schedule");
        expect(literals).toContain("find");
    });

    it("after 'please schedule': wildcard completion for desc", () => {
        const comp = getDFACompletions(dfa, ["please", "schedule"]);
        // Should show wildcard placeholder for $(desc:wildcard) in groups
        expect(comp.groups.length).toBeGreaterThan(0);
        const hasWildcard = comp.groups.some((g) =>
            g.completions.some((c) => c.startsWith("$(")),
        );
        expect(hasWildcard).toBe(true);
    });

    it("completions: DFA suggests phraseSet openers at empty prefix (NFA doesn't)", () => {
        // NFA completions don't follow phraseSet transitions; DFA does.
        // Verify DFA produces a superset.
        const nfaComp = computeNFACompletions(nfa, []);
        const dfaComp = getDFACompletions(dfa, []);
        const nfaLiterals = [...(nfaComp.completions ?? [])].sort();
        const dfaLiterals = [...(dfaComp.completions ?? [])].sort();
        expect(dfaLiterals.length).toBeGreaterThanOrEqual(nfaLiterals.length);
        expect(dfaLiterals).toContain("please");
    });

    it("completions parity after 'please'", () => {
        // After consuming "please" via phraseSet, both should suggest "schedule"/"find"
        const dfaComp = getDFACompletions(dfa, ["please"]);
        const dfaLiterals = [...(dfaComp.completions ?? [])].sort();
        expect(dfaLiterals).toContain("schedule");
        expect(dfaLiterals).toContain("find");
    });

    it("completions parity after multi-token phraseSet 'could you'", () => {
        // After consuming "could you" via phraseSet, should suggest "schedule"/"find"
        const dfaComp = getDFACompletions(dfa, ["could", "you"]);
        const dfaLiterals = [...(dfaComp.completions ?? [])].sort();
        expect(dfaLiterals).toContain("schedule");
        expect(dfaLiterals).toContain("find");
    });

    it("completions parity with NFA after 'schedule'", () => {
        assertCompletionParity(nfa, dfa, ["schedule"]);
    });

    it("completions parity with NFA after 'find'", () => {
        assertCompletionParity(nfa, dfa, ["find"]);
    });

    it("completions parity with NFA after 'find events'", () => {
        assertCompletionParity(nfa, dfa, ["find", "events"]);
    });
});

// ═══════════════════════════════════════════════════════════════════════════════
// Rich Entity Type Matching — DFA / NFA Parity
//
// Covers CalendarDate, CalendarTime, CalendarTimeRange, CalendarDayRange,
// Ordinal, Cardinal, and Percentage entities. These produce rich value objects
// (not raw strings) in the NFA matcher. The DFA hybrid delegates to NFA so
// values must be identical.
// ═══════════════════════════════════════════════════════════════════════════════

describe("Rich Entity Matching Parity", () => {
    registerBuiltInEntities();

    // ── CalendarDate ─────────────────────────────────────────────────────
    describe("CalendarDate entity", () => {
        const { grammar, nfa, dfa } = compile(
            "calDate",
            `
            entity CalendarDate;
            <schedule> = schedule meeting on $(date:CalendarDate)
                -> { actionName: "schedule", parameters: { date } };
            <schedule2> = schedule meeting for $(date:CalendarDate)
                -> { actionName: "schedule", parameters: { date } };
            <Start> = <schedule> | <schedule2>;
            `,
        );

        const requests = [
            "schedule meeting on today",
            "schedule meeting on tomorrow",
            "schedule meeting on Monday",
            "schedule meeting on Friday",
            "schedule meeting for yesterday",
        ];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("NFA produces CalendarDateValue for 'schedule meeting on today'", () => {
            const results = matchGrammarWithNFA(
                grammar,
                nfa,
                "schedule meeting on today",
            );
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.date).toBeDefined();
            // Rich value has toString()
            expect(typeof val.parameters.date.toString()).toBe("string");
        });
    });

    // ── CalendarTime ─────────────────────────────────────────────────────
    describe("CalendarTime entity", () => {
        const { grammar, nfa, dfa } = compile(
            "calTime",
            `
            entity CalendarTime;
            <meetAt> = meet at $(time:CalendarTime)
                -> { actionName: "meet", parameters: { time } };
            <Start> = <meetAt>;
            `,
        );

        const requests = [
            "meet at 2pm",
            "meet at 14:00",
            "meet at noon",
            "meet at midnight",
            "meet at 9:30am",
        ];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("NFA produces CalendarTimeValue for 'meet at 2pm'", () => {
            const results = matchGrammarWithNFA(grammar, nfa, "meet at 2pm");
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.time).toBeDefined();
            // Rich value with hours24/minutes (CalendarTimeValue or plain object)
            expect(val.parameters.time.hours24).toBe(14);
            expect(val.parameters.time.minutes).toBe(0);
        });
    });

    // ── CalendarTimeRange ────────────────────────────────────────────────
    describe("CalendarTimeRange entity", () => {
        const { grammar, nfa, dfa } = compile(
            "calTimeRange",
            `
            entity CalendarTimeRange;
            <block> = block $(range:CalendarTimeRange)
                -> { actionName: "block", parameters: { range } };
            <Start> = <block>;
            `,
        );

        const requests = ["block 2pm", "block 9am-10am", "block 1-2pm"];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("NFA produces CalendarTimeRangeValue for 'block 9am-10am'", () => {
            const results = matchGrammarWithNFA(grammar, nfa, "block 9am-10am");
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.range).toBeDefined();
            expect(typeof val.parameters.range.toString()).toBe("string");
        });
    });

    // ── CalendarDayRange ─────────────────────────────────────────────────
    describe("CalendarDayRange entity (multi-token)", () => {
        const { grammar, nfa, dfa } = compile(
            "calDayRange",
            `
            entity CalendarDayRange;
            <events> = show events for $(range:CalendarDayRange)
                -> { actionName: "showEvents", parameters: { range } };
            <Start> = <events>;
            `,
        );

        // CalendarDayRange handles single and multi-token spans
        const requests = [
            "show events for today",
            "show events for this week",
            "show events for last week",
            "show events for this month",
        ];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("NFA produces CalendarDayRangeValue for 'show events for this week'", () => {
            const results = matchGrammarWithNFA(
                grammar,
                nfa,
                "show events for this week",
            );
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.range).toBeDefined();
            expect(typeof val.parameters.range.toString()).toBe("string");
        });
    });

    // ── Ordinal ──────────────────────────────────────────────────────────
    describe("Ordinal entity", () => {
        const { grammar, nfa, dfa } = compile(
            "ordinal",
            `
            entity Ordinal;
            <playNth> = play the $(n:Ordinal) track
                -> { actionName: "playNth", parameters: { index: n } };
            <Start> = <playNth>;
            `,
        );

        const requests = [
            "play the first track",
            "play the second track",
            "play the third track",
            "play the tenth track",
        ];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("NFA converts ordinal to number for 'play the first track'", () => {
            const results = matchGrammarWithNFA(
                grammar,
                nfa,
                "play the first track",
            );
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.index).toBe(1);
        });

        it("NFA converts ordinal to number for 'play the third track'", () => {
            const results = matchGrammarWithNFA(
                grammar,
                nfa,
                "play the third track",
            );
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.index).toBe(3);
        });
    });

    // ── Cardinal ─────────────────────────────────────────────────────────
    describe("Cardinal entity", () => {
        const { grammar, nfa, dfa } = compile(
            "cardinal",
            `
            entity Cardinal;
            <skip> = skip $(n:Cardinal) songs
                -> { actionName: "skip", parameters: { count: n } };
            <Start> = <skip>;
            `,
        );

        const requests = ["skip 3 songs", "skip five songs", "skip ten songs"];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("NFA converts cardinal to number for 'skip five songs'", () => {
            const results = matchGrammarWithNFA(
                grammar,
                nfa,
                "skip five songs",
            );
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.count).toBe(5);
        });
    });

    // ── Percentage ───────────────────────────────────────────────────────
    describe("Percentage entity", () => {
        const { grammar, nfa, dfa } = compile(
            "percentage",
            `
            entity Percentage;
            <vol> = set volume to $(level:Percentage)
                -> { actionName: "setVolume", parameters: { level } };
            <Start> = <vol>;
            `,
        );

        const requests = [
            "set volume to 75",
            "set volume to 100",
            "set volume to 35%",
        ];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });

        it("NFA converts percentage to number for 'set volume to 75'", () => {
            const results = matchGrammarWithNFA(
                grammar,
                nfa,
                "set volume to 75",
            );
            expect(results.length).toBeGreaterThan(0);
            const val = results[0].match as any;
            expect(val.parameters.level).toBe(75);
        });
    });

    // ── Combined entity grammar (calendar-like) ──────────────────────────
    describe("Combined entity grammar", () => {
        const { grammar, nfa, dfa } = compile(
            "calCombined",
            `
            entity CalendarDate, CalendarTime, CalendarTimeRange;
            <schedule> = schedule $(desc:wildcard) on $(date:CalendarDate) at $(time:CalendarTime)
                -> { actionName: "scheduleEvent", parameters: { desc, date, time } };
            <block> = block $(date:CalendarDate) $(range:CalendarTimeRange)
                -> { actionName: "blockTime", parameters: { date, range } };
            <Start> = <schedule> | <block>;
            `,
        );

        const requests = [
            "schedule lunch on Monday at noon",
            "schedule standup on Friday at 9:30am",
            "block Monday 2pm",
            "block tomorrow 9am-10am",
        ];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });
    });

    // ── PhraseSet + entity combo ─────────────────────────────────────────
    describe("PhraseSet + entity combo", () => {
        const { grammar, nfa, dfa } = compile(
            "phraseEntityCombo",
            `
            entity CalendarDate, Ordinal;
            <schedule> = <Polite> schedule meeting on $(date:CalendarDate)
                -> { actionName: "schedule", parameters: { date } };
            <playNth> = <Polite> play the $(n:Ordinal) track
                -> { actionName: "playNth", parameters: { index: n } };
            <Start> = <schedule> | <playNth>;
            `,
        );

        const requests = [
            "schedule meeting on Monday",
            "please schedule meeting on tomorrow",
            "could you schedule meeting on Friday",
            "play the first track",
            "please play the third track",
        ];

        it.each(requests)("DFA matches NFA for '%s'", (req) => {
            assertMatchParity(grammar, nfa, dfa, req);
        });
    });

    // ── PhraseSet matching: rejection + multi-token ──────────────────────
    describe("PhraseSet matching edge cases", () => {
        const { grammar, nfa, dfa } = compile(
            "phraseMatch",
            `
            entity CalendarDate;
            <schedule> = <Polite> schedule $(desc:wildcard) for $(date:CalendarDate)
                -> { actionName: "schedule", parameters: { desc, date } };
            <Start> = <schedule>;
            `,
        );

        // Mandatory phraseSet: requests WITHOUT a Polite opener should not match
        const rejectRequests = [
            "schedule meeting for today",
            "hello schedule meeting for Monday",
            "yo schedule lunch for tomorrow",
        ];

        it.each(rejectRequests)(
            "DFA rejects non-phraseSet input '%s' (parity with NFA)",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );

        // Requests WITH valid Polite phraseSet openers should match
        const acceptRequests = [
            "please schedule meeting for today",
            "could you schedule lunch for Monday",
            "would you schedule standup for Friday",
            "kindly schedule review for tomorrow",
            "can you schedule demo for today",
        ];

        it.each(acceptRequests)(
            "DFA matches phraseSet input '%s' (parity with NFA)",
            (req) => {
                assertMatchParity(grammar, nfa, dfa, req);
            },
        );

        // Multi-token phraseSet: "would you please" is a 3-token phrase
        it("multi-token phraseSet 'would you please' matches", () => {
            assertMatchParity(
                grammar,
                nfa,
                dfa,
                "would you please schedule meeting for today",
            );
        });
    });

    // ── Rich entity completions ──────────────────────────────────────────
    describe("Rich entity type completions", () => {
        const { nfa, dfa } = compile(
            "entityCompl",
            `
            entity CalendarDate, CalendarTime, Ordinal, Cardinal;
            <schedule> = schedule $(desc:wildcard) on $(date:CalendarDate) at $(time:CalendarTime)
                -> { actionName: "schedule", parameters: { desc, date, time } };
            <playNth> = play the $(n:Ordinal) track
                -> { actionName: "playNth", parameters: { index: n } };
            <skip> = skip $(n:Cardinal) songs
                -> { actionName: "skip", parameters: { count: n } };
            <Start> = <schedule> | <playNth> | <skip>;
            `,
        );

        it("after 'schedule meeting on': suggests $(date:CalendarDate)", () => {
            const comp = getDFACompletions(dfa, ["schedule", "meeting", "on"]);
            const wildcards = comp.groups.flatMap((g) => g.wildcardCompletions);
            expect(wildcards.length).toBeGreaterThan(0);
            const dateWild = wildcards.find((w) => w.variable === "date");
            expect(dateWild).toBeDefined();
            expect(dateWild!.typeName).toBe("CalendarDate");
            expect(dateWild!.checked).toBe(true);
            expect(dateWild!.displayString).toBe("$(date:CalendarDate)");
        });

        it("after 'schedule meeting on Monday at': suggests $(time:CalendarTime)", () => {
            const comp = getDFACompletions(dfa, [
                "schedule",
                "meeting",
                "on",
                "Monday",
                "at",
            ]);
            const wildcards = comp.groups.flatMap((g) => g.wildcardCompletions);
            const timeWild = wildcards.find((w) => w.variable === "time");
            expect(timeWild).toBeDefined();
            expect(timeWild!.typeName).toBe("CalendarTime");
            expect(timeWild!.checked).toBe(true);
            expect(timeWild!.displayString).toBe("$(time:CalendarTime)");
        });

        it("after 'play the': suggests $(n:Ordinal)", () => {
            const comp = getDFACompletions(dfa, ["play", "the"]);
            const wildcards = comp.groups.flatMap((g) => g.wildcardCompletions);
            const ordWild = wildcards.find((w) => w.variable === "n");
            expect(ordWild).toBeDefined();
            expect(ordWild!.typeName).toBe("Ordinal");
            expect(ordWild!.checked).toBe(true);
            expect(ordWild!.displayString).toBe("$(n:Ordinal)");
        });

        it("after 'skip': suggests $(n:Cardinal)", () => {
            const comp = getDFACompletions(dfa, ["skip"]);
            const wildcards = comp.groups.flatMap((g) => g.wildcardCompletions);
            const cardWild = wildcards.find((w) => w.variable === "n");
            expect(cardWild).toBeDefined();
            expect(cardWild!.typeName).toBe("Cardinal");
            expect(cardWild!.checked).toBe(true);
            expect(cardWild!.displayString).toBe("$(n:Cardinal)");
        });

        it("property completions include entity-typed wildcards", () => {
            const comp = getDFACompletions(dfa, ["schedule", "meeting", "on"]);
            const props = comp.properties ?? [];
            // CalendarDate is a checked wildcard — should appear in property completions
            const dateProp = props.find(
                (p) => p.propertyPath === "parameters.date",
            );
            expect(dateProp).toBeDefined();
            expect(dateProp!.actionName).toBe("schedule");
        });

        it("completion parity with NFA after 'schedule meeting on'", () => {
            const nfaComp = computeNFACompletions(nfa, [
                "schedule",
                "meeting",
                "on",
            ]);
            const dfaComp = getDFACompletions(dfa, [
                "schedule",
                "meeting",
                "on",
            ]);

            // DFA correctly suggests "on" as a literal completion:
            // wildcard desc can absorb "meeting on", leaving literal "on" available.
            // This is a DFA improvement over NFA completions.
            const dfaLiterals = [...(dfaComp.completions ?? [])].sort();
            expect(dfaLiterals).toContain("on");

            // Property completions should match (both report parameters.date)
            const nfaProps = (nfaComp.properties ?? [])
                .map(
                    (p) =>
                        `${(p.match as any).actionName}:${p.propertyNames[0]}`,
                )
                .sort();
            const dfaProps = (dfaComp.properties ?? [])
                .map((p) => `${p.actionName}:${p.propertyPath}`)
                .sort();
            expect(dfaProps).toEqual(nfaProps);
        });

        it("completion parity with NFA after 'play the'", () => {
            assertCompletionParity(nfa, dfa, ["play", "the"]);
        });

        it("completion parity with NFA after 'skip'", () => {
            assertCompletionParity(nfa, dfa, ["skip"]);
        });
    });
});
