Advertisement

← vibesdk

worker/agents/inferutils/schemaFormatters.ts

66.9 KB Download file
import { z } from 'zod';
// Markdown Parser: unified/remark
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import { toString as mdastToString } from 'mdast-util-to-string';
import type {
    Root, Content, Node as UnistNode,
    Heading, List, ListItem, Code, ThematicBreak
} from 'mdast';
import { createLogger, StructuredLogger } from '../../logger';

// --- Existing Types and Logger Setup ---
export type SchemaFormat = 'markdown';
export type FormatterOptions = {
    rootTagName?: string;
    headingLevel?: number;
    debug?: boolean;
};

// ReturnType<typeof createLogger> removed - using structured logger throughout
const logger = createLogger('SchemaFormatter');


// --- Helper: Simple Singularization ---
/**
 * Basic singularization heuristic. Converts plural nouns ending in 's' to singular.
 * Handles simple cases like 'files' -> 'File', 'phases' -> 'Phase'.
 * Capitalizes the first letter. Returns capitalized original if no 's' ending.
 * @param word The plural word (usually the Zod key).
 * @returns The singularized and capitalized word.
 */
function singularize(word: string): string {
    let singularBase = word;
    // Basic heuristic: remove trailing 's' if it's likely plural and not possessive/double-s
    if (word.toLowerCase().endsWith('s') && !word.toLowerCase().endsWith('ss') && word.length > 1) {
        // Avoid removing 's' if the word before it is also 's' (e.g. 'process') - simple check
        if (word.length < 2 || word.charAt(word.length - 2).toLowerCase() !== 's') {
            singularBase = word.slice(0, -1);
        }
    }
    // Capitalize the first letter
    return singularBase.charAt(0).toUpperCase() + singularBase.slice(1);
}


// --- UPDATED Markdown Formatter ---
export function formatSchemaAsMarkdown<T extends z.ZodRawShape>(schema: z.ZodObject<T>, options: FormatterOptions = {}): string {
    const headingLevel = options.headingLevel || 2;
    const headingMarker = '#'.repeat(headingLevel);
    // Add instructions preamble
//     const instructions = `# Instructions for Generation

// Please fill out the following structure.
// - Use the headings provided (e.g., \`## name\`, \`#### filePath\`).
// - Place the value for each field inside triple backticks (\`\`\`) on the line(s) following the heading and description.
// - For arrays (like \`files\`), use a heading like \`### File 1\`, \`### File 2\`, etc., for each item in the list, where "File" is the singular form of the array name.
// - Provide all required fields. Optional sections can be omitted entirely or have an empty code block if explicitly empty.
// - Ensure the content within the code blocks is raw text/code as appropriate for the field.

// ---

// `;
    return formatZodSchemaAsMarkdownFields(schema, headingMarker, undefined, false, 'template');
}

/**
 * Formats a JavaScript object into a Markdown string based on a Zod schema.
 * @param data The JavaScript object/data to format.
 * @param schema The Zod schema defining the structure.
 * @param options Formatting options.
 * @returns A Markdown string representing the data.
 */
 function formatDataAsMarkdown<T extends z.ZodRawShape>(
    data: z.infer<z.ZodObject<T>>,
    schema: z.ZodObject<T>,
    options: FormatterOptions = {}
): string {
    const headingLevel = options.headingLevel || 2;
    const headingMarker = '#'.repeat(headingLevel);
    const debug = options.debug ?? false;

    if (debug) logger.debug("--- Starting Data Formatting to Markdown ---");
    
    // Validate (for logging/diagnostics only). We intentionally avoid merging defaults
    // so that serialization does not introduce placeholder/template scaffolding.
    const validation = schema.safeParse(data);
    if (!validation.success) {
        logger.error("Input data failed Zod validation before formatting:", validation.error);
        logger.error("Original data:", data);
    }

    // Always serialize using the provided data object (no default merging),
    // and render in strict data mode (no template placeholders).
    return formatZodSchemaAsMarkdownFields(schema, headingMarker, data, debug, 'data');
}

// ── zod 4 compat helpers ───────────────────────────────────────────
// zod 4 moved schema internals out of `_def` (array element, object shape,
// wrapper inner type, enum values, default value all relocated). These
// helpers centralize the casts so the rest of this formatter keeps reading
// like the original v3 introspection code.
function zInnerType(schema: z.ZodTypeAny): z.ZodTypeAny {
    return (schema as unknown as { def: { innerType: z.ZodTypeAny } }).def.innerType;
}
function zArrayElement(schema: z.ZodTypeAny): z.ZodTypeAny {
    return (schema as unknown as { element: z.ZodTypeAny }).element;
}
function zObjectShape(schema: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
    return (schema as unknown as { shape: Record<string, z.ZodTypeAny> }).shape;
}
function zEnumValues(schema: z.ZodTypeAny): (string | number)[] {
    return (schema as unknown as { options?: (string | number)[] }).options ?? [];
}
function zDefaultValue(schema: z.ZodTypeAny): unknown {
    const dv = (schema as unknown as { def: { defaultValue: unknown } }).def.defaultValue;
    return typeof dv === 'function' ? (dv as () => unknown)() : dv;
}
function zStringFormat(schema: z.ZodTypeAny): string | undefined {
    const checks =
        (schema as unknown as {
            _zod?: { def?: { checks?: Array<{ _zod?: { def?: { format?: string; check?: string } } }> } };
        })._zod?.def?.checks ?? [];
    for (const c of checks) {
        const fmt = c?._zod?.def?.format ?? c?._zod?.def?.check;
        if (fmt) return fmt;
    }
    return undefined;
}

/**
 * Recursive helper to format fields of an object.
 */
function formatZodSchemaAsMarkdownFields(
    schema: z.ZodObject<any>,
    headingPrefix: string,
    dataObject?: Record<string, any>,
    debug: boolean = false,
    mode: 'template' | 'data' = dataObject ? 'data' : 'template'
): string {
    const shape = zObjectShape(schema);
    let result = '';

    if (debug) logger.debug(`[Format Data Path: object] Formatting object with keys: ${Object.keys(shape).join(', ')}`);

    // Iterate through the keys defined in the SCHEMA's shape
    for (const key of Object.keys(shape)) {
        const fieldSchema = shape[key] as z.ZodTypeAny;
        const value = dataObject ? dataObject[key] : undefined; // Get value from data

        if (debug) logger.debug(`[Format Data Path: object.${key}] Processing key "${key}"`);

        if (dataObject) {
            // Skip formatting optional fields if the value is undefined
            let isOptional = false;
            let checkSchema = fieldSchema;
            while (checkSchema instanceof z.ZodOptional || checkSchema instanceof z.ZodNullable || checkSchema instanceof z.ZodDefault) {
                if (checkSchema instanceof z.ZodOptional) isOptional = true;
                if (checkSchema instanceof z.ZodNullable) isOptional = true;
                // Consider Default as optional for skipping if value is exactly undefined
                if (checkSchema instanceof z.ZodDefault && value === undefined) isOptional = true;
                checkSchema = zInnerType(checkSchema);
            }
    
            if (isOptional && (value === undefined || value === null || value === '') ) {
                if (debug) logger.debug(`[Format Data Path: object.${key}] Skipping optional field with undefined value.`);
                continue; // Skip this field entirely
            }
        }
        result += formatZodTypeAsMarkdown(key, fieldSchema, headingPrefix, value, mode);
    }
    return result;
}

function getMarkdownPlaceholderValue(key: string, field: z.ZodTypeAny, headingPrefix: string): string {
    let value = '';
    // Base type formatting
    if (field instanceof z.ZodArray) {
        const innerType = zArrayElement(field);
        let example = '';
        // *** CHANGE: Use singularize for item name ***
        const singularKeyName = singularize(key);
        const itemHeadingPrefix = headingPrefix + '#'; // Increase heading level for items

        if (innerType instanceof z.ZodObject) {
            // Generate example items using the singularized name
            example = `${itemHeadingPrefix} ${singularKeyName} 1\n\n${formatZodSchemaAsMarkdownFields(innerType, itemHeadingPrefix + '#')}\n`; // Note: recursive call uses deeper heading
            example += `${itemHeadingPrefix} ${singularKeyName} 2\n\n${formatZodSchemaAsMarkdownFields(innerType, itemHeadingPrefix + '#')}\n`;
        } else {
            // Example for arrays of primitives
            const simpleDesc = getSimpleTypeDescription(innerType);
            example = `\n\nExample items (one per line or in code block):\n\n`
            example += `- [${simpleDesc} 1]\n`;
            example += `- [${simpleDesc} 2]\n\n`;
            example += `Or for multi-line content, use code blocks like this:\n\n`;
            example += "```\n[Multi-line content for item 1]\n```\n\n";
            example += "```\n[Multi-line content for item 2]\n```\n";
        }
        // Add extra newline after array example for spacing
        // return `${heading}${description}\n\n${example}\n`;
        return example;
    }
    else if (field instanceof z.ZodObject) {
        // Recurse with increased heading level
        return formatZodSchemaAsMarkdownFields(field, headingPrefix + '#');
    } else if (field instanceof z.ZodString) {
        const stringFormat = zStringFormat(field);
        value = stringFormat === 'uuid' ? '[UUID string]' :
        stringFormat === 'email' ? '[email string]' :
        stringFormat === 'url' ? 'https://example.com/string' :
                    '[String content]';
    }
    else if (field instanceof z.ZodNumber) {
        value = '[Numeric value]';
    }
    else if (field instanceof z.ZodBoolean) {
        value = 'true or false';
    }
    else if (field instanceof z.ZodEnum) {
        const values = zEnumValues(field);
        const placeholder = `[One of: ${values.join(', ')}]`;
        value = placeholder;
    }
    else if (field instanceof z.ZodUnion) {
        value = '[Content based on one of the allowed types]';
    }
    else if (field instanceof z.ZodOptional) {
        // Handle optional fields
        const innerType = zInnerType(field);
        if (innerType instanceof z.ZodObject) {
            return formatZodSchemaAsMarkdownFields(innerType, headingPrefix + '#');
        } else {
            value = `[Optional content for ${key}]`;
        }
    }
    else {
        // Fallback for other types
        value = `[Content for ${key}]`;
    }

    return `\`\`\`\n${value}\n\`\`\`\n`
}


function formatZodTypeAsMarkdown(
    key: string, 
    field: z.ZodTypeAny, 
    headingPrefix = '##',
    value?: unknown,
    mode: 'template' | 'data' = 'template',
): string {
    let optionalMarker = '';
    let baseField = field;
    let isOptional = false;

    // Determine optionality and get base type
    while (baseField instanceof z.ZodOptional || baseField instanceof z.ZodNullable || baseField instanceof z.ZodDefault) {
        // Treat Nullable and Default as optional in the schema representation for clarity to the LLM
        if (baseField instanceof z.ZodOptional || baseField instanceof z.ZodDefault || baseField instanceof z.ZodNullable) {
            isOptional = true;
        }
        baseField = zInnerType(baseField);
    }
    // Add the marker directly to the heading text if optional
    if (isOptional && mode === 'template') {
        // Show optional marker only in template mode
        optionalMarker = ' (Optional section)';
    }

    // Determine if the field actually has a value (type-aware)
    let hasValue: boolean;
    if (value === undefined || value === null) {
        hasValue = false;
    } else if (baseField instanceof z.ZodString) {
        hasValue = String(value).trim().length > 0;
    } else if (baseField instanceof z.ZodArray) {
        hasValue = Array.isArray(value) && value.length > 0;
    } else {
        // Numbers, booleans, enums, objects, etc. are considered present if not null/undefined
        hasValue = true;
    }

    // Only include schema field descriptions when generating templates
    const description = (mode === 'template' && !hasValue && field.description) ? `\n\n*Description: ${field.description}*` : '';

    // Generate heading
    const heading = `${headingPrefix} ${key}${optionalMarker}`; // Optional marker added here

    if (!hasValue) {
        // Template mode -> emit helpful placeholders. Data mode -> avoid scaffolding
        if (mode === 'template') {
            const placeholder = getMarkdownPlaceholderValue(key, field, headingPrefix);
            return `${heading}${description}\n\n${placeholder}\n`;
        }
        // DATA MODE
        // - For arrays/objects with no content, skip the entire section
        if (baseField instanceof z.ZodArray || baseField instanceof z.ZodObject) {
            return '';
        }
        // - For primitives, emit an empty code fence to represent "no value" without placeholders
        return `${heading}\n\n\`\`\`\n\n\`\`\`\n\n`;
    } else if (baseField instanceof z.ZodObject) {
        if (typeof value === 'object') {
            // logger.debug(`[Format Data Path: ...${key}] Formatting object with ${Object.keys(value).length} keys.`);
            // Ensure value is not null before recursing
            return `${heading}${description}\n\n${formatZodSchemaAsMarkdownFields(baseField, headingPrefix + '#', value as Record<string, any>, false, mode)}\n`;
        } else {
            // Handle cases where data is missing or not an object for a required object schema
            if (mode === 'template') {
                logger.warn(`[Format Data Path: ...${key}] Expected object but got ${typeof value}. Rendering empty section.`);
                return `${heading}${description}\n\n\`\`\`\n[Missing or invalid object data]\n\`\`\`\n\n`;
            }
            // Data mode: skip invalid object sections
            return '';
        }
    }
    // --- Array Formatting ---
    else if (baseField instanceof z.ZodArray) {
        const itemSchema = zArrayElement(baseField);
        const singularKeyName = singularize(key);
        const itemHeadingPrefix = headingPrefix + '#';
        let itemsMarkdown = '';

        // logger.debug(`[Format Data Path: ...${key}] Formatting array with ${Array.isArray(value) ? value.length : 0} items.`);

        if (Array.isArray(value) && value.length > 0) {
            itemsMarkdown += `\n`;
            value.forEach((item, index) => {
                const itemHeading = `${itemHeadingPrefix} ${singularKeyName} ${index + 1}`;
                // logger.debug(`[Format Data Path: ...${key}[${index}]] Formatting object item: ${item}, schema: ${itemSchema}, heading: ${itemHeading}`);
                // Check if array items are objects
                if (itemSchema instanceof z.ZodObject) {
                    if (item && typeof item === 'object') {
                        // console.debug(`[Format Data Path: ...${key}[${index}]] Formatting object item with ${Object.keys(item).length} keys.`);
                        itemsMarkdown += `${itemHeading}\n\n${formatZodSchemaAsMarkdownFields(itemSchema, itemHeadingPrefix + '#', item as Record<string, any>, false, mode)}\n`;
                    } else {
                        if (mode === 'template') {
                            logger.warn(`[Format Data Path: ...${key}[${index}]] Expected object item but got ${typeof item}. Rendering empty item.`);
                            itemsMarkdown += `${itemHeading}\n\n\`\`\`\n[Missing or invalid object item data]\n\`\`\`\n\n`;
                        }
                    }
                } else {
                    // Handle arrays of primitives (e.g., strings, numbers)
                    // Option: Format as a list or multi-line code block
                    // Current choice: Format each primitive item in its own section (less ideal, but fits pattern)
                    // Better: Format as list or single code block
                    // logger.warn(`[Format Data Path: ...${key}[${index}]] Formatting array of primitives - current output might be suboptimal.`);
                    // Simple primitive formatting (could be improved to list/code block)
                    const primitiveValueStr = String(item ?? ''); // Handle null/undefined primitives
                    // itemsMarkdown += `${itemHeading}\n\n*Description: ${itemSchema.description ?? `Value for ${singularKeyName} ${index + 1}`}*\n\n\`\`\`\n${primitiveValueStr}\n\`\`\`\n\n`;
                    itemsMarkdown += `- ${primitiveValueStr}\n`;
                }
            });
        } else {
            // Template mode may include a helpful note; Data mode omits empty arrays entirely
            if (mode === 'template') {
                itemsMarkdown = '\n\n[No items provided for this list]\n\n';
            } else {
                return '';
            }
        }
        return `${heading}${description}\n${itemsMarkdown}\n`; // Add extra newline after array section
    } else {
        // Handle null specifically for nullable fields
        const valueStr = String(value ?? '');
        return `${heading}${description}\n\n\`\`\`\n${valueStr}\n\`\`\`\n\n`;
    }
}


function getSimpleTypeDescription(field: z.ZodTypeAny): string {
    if (field instanceof z.ZodString) return 'string value';
    if (field instanceof z.ZodNumber) return 'numeric value';
    if (field instanceof z.ZodBoolean) return 'true or false';
    if (field instanceof z.ZodEnum) return 'enum value';
    if (field instanceof z.ZodOptional || field instanceof z.ZodNullable || field instanceof z.ZodDefault) {
        return getSimpleTypeDescription(zInnerType(field)) + " (optional/nullable)";
    }
    return 'value';
}

// --- Shared Helper Functions ---
function getDefaultValue(field: z.ZodTypeAny): unknown {
    if (field instanceof z.ZodOptional) return undefined;
    if (field instanceof z.ZodNullable) return null;
    if (field instanceof z.ZodDefault) {
        try {
            return zDefaultValue(field);
        } catch (e) {
            logger.warn(`Error getting default value for field: ${e instanceof Error ? e.message : String(e)}`);
            let inner = zInnerType(field);
            while (inner instanceof z.ZodDefault) inner = zInnerType(inner);
            return getDefaultValue(inner); // Try getting default from inner type
        }
    }
    // Base type defaults
    if (field instanceof z.ZodString) return '';
    if (field instanceof z.ZodNumber) return 0;
    if (field instanceof z.ZodBoolean) return false;
    if (field instanceof z.ZodArray) return [];
    if (field instanceof z.ZodObject) {
        const shape = zObjectShape(field);
        const defaultObj: Record<string, unknown> = {};
        for (const key in shape) {
            // Use the actual field definition from the shape to get the correct default
            defaultObj[key] = getDefaultValue(shape[key] as z.ZodTypeAny);
        }
        return defaultObj;
    }
    // Enums, Unions, etc., don't have a clear universal default
    if (field instanceof z.ZodEnum) return undefined;
    if (field instanceof z.ZodUnion) return undefined;
    // Add other types as needed
    return undefined;
}

function convertToPrimitive(value: any, schema: z.ZodTypeAny, debugInfo?: { path: string | string[], logger: any, debug: boolean }): any {
    const { path = '?', logger: currentLogger = logger, debug = false } = debugInfo || {};
    const pathStr = Array.isArray(path) ? path.join('.') : path; // Ensure path is string for logs

    // 1. Handle non-string inputs (pass-through or basic conversion)
    if (typeof value !== 'string') {
        if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Input is not a string (${typeof value}). Value: ${JSON.stringify(value)}`);
        // Allow null/undefined through if schema permits
        if (value === null && schema instanceof z.ZodNullable) return null;
        if (value === undefined && schema instanceof z.ZodOptional) return undefined;
        if (value === null && schema instanceof z.ZodOptional) return undefined; // Treat null as undefined for optional

        // Handle defaults if input is null/undefined
        if ((value === null || value === undefined) && schema instanceof z.ZodDefault) {
            if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Non-string null/undefined for ZodDefault. Returning default.`);
            return getDefaultValue(schema);
        }
        // Handle null for ZodNullable when input is undefined
        if (value === undefined && schema instanceof z.ZodNullable) {
            if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Non-string undefined for ZodNullable. Returning null.`);
            return null;
        }

        // If type already matches base schema type, pass through (validation happens later)
        let baseSchema = schema;
        while (baseSchema instanceof z.ZodOptional || baseSchema instanceof z.ZodNullable || baseSchema instanceof z.ZodDefault) {
            baseSchema = zInnerType(baseSchema);
        }
        if (baseSchema instanceof z.ZodString && typeof value === 'string') return value; // Already handled string case above
        if (baseSchema instanceof z.ZodNumber && typeof value === 'number') return value;
        if (baseSchema instanceof z.ZodBoolean && typeof value === 'boolean') return value;
        // If type doesn't match, convert to string and proceed with string parsing logic
        if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Non-string type (${typeof value}) doesn't match schema ${baseSchema.constructor.name}. Converting to string.`);
        value = String(value); // Convert to string for further processing
    }

    // 2. Handle string input (trimming)
    const stringValue = value.trim();
    if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Processing trimmed string value: "${stringValue.substring(0, 100)}..."`);


    // 3. Unwrap Zod modifiers (Optional, Nullable, Default) for processing empty/nullish strings
    if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault) {
        const innerSchema = zInnerType(schema);
        const isEmpty = stringValue === '';
        // More robust check for common "empty" values from LLMs
        const isNullish = isEmpty || ['null', 'none', 'n/a', 'undefined', 'nil', 'empty', 'missing', '[no value]', '[not applicable]'].includes(stringValue.toLowerCase());

        if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Checking modifiers: Optional=${schema instanceof z.ZodOptional}, Nullable=${schema instanceof z.ZodNullable}, Default=${schema instanceof z.ZodDefault}. IsEmpty=${isEmpty}, IsNullish=${isNullish}`);

        // Handle based on the *outer* modifier first if the string is considered nullish
        if (isNullish) {
            if (schema instanceof z.ZodDefault) {
                if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Empty/nullish string for ZodDefault. Returning default value.`);
                return getDefaultValue(schema); // Return default *before* attempting inner parse
            }
            if (schema instanceof z.ZodOptional) {
                if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Empty/nullish string for ZodOptional. Returning undefined.`);
                return undefined;
            }
            if (schema instanceof z.ZodNullable) {
                if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Empty/nullish string for ZodNullable. Returning null.`);
                return null;
            }
        }
        // If not empty/nullish, or if modifiers didn't result in a return, parse using the inner type
        if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Passing value to inner schema: ${innerSchema.constructor.name}`);
        return convertToPrimitive(stringValue, innerSchema, debugInfo);
    }

    // 4. Base type conversions from non-empty string
    if (schema instanceof z.ZodString) {
        // Basic XML unescape - harmless for plain text
        const unescaped = stringValue
            .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
            .replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&apos;/g, "'");
        if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Returning string value: "${unescaped.substring(0, 100)}..."`);
        return unescaped;
    } else if (schema instanceof z.ZodNumber) {
        const cleanValue = stringValue.replace(/[, $£€]/g, ''); // Remove common currency/grouping chars
        if (cleanValue === '') {
            // This case should ideally be caught by ZodOptional/Nullable/Default handling above
            if (debug) currentLogger.warn(`[Convert Path: ${pathStr}] Empty string reached number conversion. Returning default (0).`);
            return getDefaultValue(z.number()); // Use number default (0) - pass base type
        }
        const num = Number(cleanValue);
        if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Parsed number: ${num}`);
        // Let Zod handle NaN/Infinity validation during the final parse step
        return num;
    } else if (schema instanceof z.ZodBoolean) {
        const lower = stringValue.toLowerCase();
        if (['true', 'yes', '1', 'on'].includes(lower)) { // Added 'on'
            if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Parsed boolean: true`);
            return true;
        }
        if (['false', 'no', '0', 'off'].includes(lower)) { // Added 'off'
            if (debug) currentLogger.debug(`[Convert Path: ${pathStr}] Parsed boolean: false`);
            return false;
        }
        // This case should ideally be caught by ZodOptional/Nullable/Default handling above
        if (debug) currentLogger.warn(`[Convert Path: ${pathStr}] Could not parse "${stringValue}" as boolean. Returning default (false).`);
        return getDefaultValue(z.boolean()); // Use boolean default (false) - pass base type
    } else if (schema instanceof z.ZodEnum) {
        const enumValues = zEnumValues(schema) as (string | number)[];
        // Try direct match first
        if (enumValues.includes(stringValue)) return stringValue;
        // Try matching number value
        const numValue = Number(stringValue);
        if (!isNaN(numValue) && enumValues.includes(numValue)) return numValue;
        // Try case-insensitive match for string enums
        const lowerValue = stringValue.toLowerCase();
        const matchedEnum = enumValues.find((enumVal) => typeof enumVal === 'string' && enumVal.toLowerCase() === lowerValue);
        if (matchedEnum !== undefined) return matchedEnum;

        if (debug) currentLogger.warn(`[Convert Path: ${pathStr}] Value "${stringValue}" not in enum [${enumValues.join(', ')}]. Returning undefined.`);
        return undefined; // No clear default for enum, let Zod validation handle it
    } else {
        // Fallback for unknown Zod types during conversion
        if (debug) currentLogger.warn(`[Convert Path: ${pathStr}] Unhandled Zod type ${schema.constructor.name} in convertToPrimitive. Returning raw string value.`);
        return stringValue;
    }
}


// --- MARKDOWN PARSING ---

// Define MdNode type
type MdNode = Content; // Use the imported Content type from mdast

// Setup Markdown parser (unified/remark)
const mdParser = unified().use(remarkParse).use(remarkGfm);

/* Helper: Get last element of array */
const last = <T,>(arr: readonly T[]): T | undefined =>
    arr.length ? arr[arr.length - 1] : undefined;

function normalize(text: string): string {
    if (!text) return '';
    let normalized = text.toLowerCase();

    // Remove common parenthetical modifiers from the end of the string.
    // This helps treat "heading (optional)" as "heading".
    // Extend the list of keywords in the regex as needed.
    const commonModifiersRegex = /\s*\((?:optional|required|note|info|beta|new|deprecated|experimental|advanced|basic|default|example|eg|ie|important|warning|tip|hint|faq|todo|fixme|bug|issue|ref|see|compare|contrast|aka|viz|etc|misc|other)\)\s*$/i;
    normalized = normalized.replace(commonModifiersRegex, '');

    // General punctuation removal (keeps alphanumeric, underscores, and spaces)
    // \w in JavaScript regex includes [A-Za-z0-9_], so underscores are preserved.
    normalized = normalized
        .replace(/[^\w\s]/g, '') // Remove characters that are NOT word characters or whitespace.
        .replace(/\s+/g, ' ')    // Normalize multiple spaces to a single space
        .trim();

    return normalized;
}

/* Helper: Type guards for mdast nodes */
function isCode(node: UnistNode): node is Code {
    return node.type === 'code';
}
function isList(node: UnistNode): node is List {
    return node.type === 'list';
}
function isListItem(node: UnistNode): node is ListItem {
    return node.type === 'listItem';
}
function isHeading(node: UnistNode): node is Heading {
    return node.type === 'heading';
}
function isThematicBreak(node: UnistNode): node is ThematicBreak {
    return node.type === 'thematicBreak';
}


/* Helper: Extract primitive value from a section's nodes */
function extractPrimitiveValueFromNodes(nodes: MdNode[], debugInfo?: { path: string, logger: StructuredLogger, debug: boolean }): string {
    const { path = '?', logger: currentLogger = logger, debug = false } = debugInfo || {};
    if (!nodes || nodes.length === 0) {
        if (debug) currentLogger.debug(`[Extract Path: ${path}] No nodes provided.`);
        return '';
    }

    // Strategy 1: Find the *first* code block and return its value.
    const firstCodeBlock = nodes.find(isCode);
    if (firstCodeBlock) {
        if (debug) currentLogger.debug(`[Extract Path: ${path}] Strategy 1: Found code block. Returning its value.`);
        return firstCodeBlock.value.trim();
    }

    // Strategy 2: If no code block, look for a single list (less common for primitives, but possible).
    const listNodes = nodes.filter(isList);
    if (nodes.length === 1 && listNodes.length === 1) {
        if (debug) currentLogger.debug(`[Extract Path: ${path}] Strategy 2: Found a single list node.`);
        const list = listNodes[0] as List;
        // Join list items, assuming primitive content within them
        return list.children.map(li => mdastToString(li).trim()).join('\n');
    }

    // Strategy 3: If no code block and not a single list, stringify all paragraph/text content.
    // Filter out descriptions/instructions if possible (heuristics)
    if (debug) currentLogger.debug(`[Extract Path: ${path}] Strategy 3: No code block or single list found. Stringifying relevant nodes.`);
    const relevantNodes = nodes.filter(node =>
        node.type === 'paragraph' || node.type === 'text' || node.type === 'inlineCode'
        // Avoid including descriptions that might be paragraphs
        // This simple check assumes descriptions are italicized paragraphs
        // !(node.type === 'paragraph' && node.children?.[0]?.type === 'emphasis')
    );
    const tempRoot: Root = { type: 'root', children: relevantNodes };
    const stringified = mdastToString(tempRoot).trim();

    // Avoid returning just the description if it was the only paragraph
    if (stringified.startsWith('Description:')) {
        if (debug) currentLogger.debug(`[Extract Path: ${path}] Stringified value seems to be only the description. Returning empty string.`);
        return '';
    }

    if (debug) currentLogger.debug(`[Extract Path: ${path}] Stringified value: "${stringified.substring(0, 100)}..."`);
    return stringified;
}


/* Helper: Build a tree structure based on Markdown headings */
interface Section { heading: Heading | null; nodes: MdNode[]; children: Section[] }

function buildSectionTree(root: Root): Section {
    const top: Section = { heading: null, nodes: [], children: [] };
    const stack: Section[] = [top];

    for (const node of root.children as MdNode[]) {
        if (isHeading(node)) { // Use type guard
            const headingNode = node as Heading;
            const headingText = mdastToString(headingNode);
            // Ignore headings that are empty or just whitespace/markers after normalization
            if (normalize(headingText) === '') {
                continue;
            }
            const sec: Section = { heading: headingNode, nodes: [], children: [] };
            // Adjust stack based on heading depth
            while (stack.length > 1 && (last(stack)!.heading?.depth ?? 0) >= headingNode.depth) {
                stack.pop();
            }
            // Add section to the correct parent
            if (stack.length > 0) {
                last(stack)!.children.push(sec);
                stack.push(sec); // Push the new section onto the stack
            } else {
                // This case should ideally not happen with a valid root node
                logger.error("Stack became empty unexpectedly during tree build for heading:", headingText);
                stack.push(top); // Attempt recovery?
                top.children.push(sec);
                stack.push(sec);
            }

        } else {
            // Add non-heading nodes to the current section at the top of the stack
            if (stack.length > 0) {
                // Ignore nodes that are just whitespace or thematic breaks between sections
                const nodeString = mdastToString(node).trim();
                if (nodeString !== '' && !isThematicBreak(node)) {
                    last(stack)!.nodes.push(node);
                }
            } else {
                // Content before the first heading
                const nodeString = mdastToString(node).trim();
                if (nodeString !== '' && !isThematicBreak(node)) {
                    logger.warn("Node found outside any section (before first heading?)", { 
                        nodeType: node.type, 
                        content: nodeString.substring(0, 50) 
                    });
                    top.nodes.push(node); // Add to the top-level node list
                }
            }
        }
    }
    return top;
}

/* --- Preprocessing Function --- */
function preprocessMarkdown(markdown: string, debug: boolean): string {
    if (debug) logger.debug("--- Preprocessing Markdown ---");
    let processed = markdown.trim();

    // Remove outer ```markdown ... ``` fences or similar
    // Make language tag optional and handle potential leading/trailing whitespace better
    const outerFenceRegex = /^```(?:\w*\s*)?\n([\s\S]*?)\n```$/i;
    const match = processed.match(outerFenceRegex);
    if (match && match[1]) {
        // Heuristic: Only strip if content looks like our structured markdown (contains ##)
        // This avoids stripping fences from single code block outputs meant for a simple string field.
        if (match[1].includes('##')) {
            if (debug) logger.debug("Removed outer ```...``` fences (heuristic match).");
            processed = match[1].trim();
        } else {
            if (debug) logger.debug("Outer ```...``` fences found, but content doesn't look like markdown structure. Keeping fences.");
        }
    }

    // Remove potential preamble (like the instructions) before the first real heading
    // Make this more robust: find the first line starting with ##
    const lines = processed.split('\n');
    const firstHeadingIndex = lines.findIndex(line => line.trim().startsWith('##'));

    if (firstHeadingIndex > 0) {
        // Check if lines *before* the first heading contain other headings - if so, don't strip
        const preamble = lines.slice(0, firstHeadingIndex).join('\n');
        if (!preamble.includes('\n##')) {
            if (debug) logger.debug("Removing potential preamble before first H2 heading.");
            processed = lines.slice(firstHeadingIndex).join('\n');
        } else {
            if (debug) logger.debug("Content found before first H2, but it contains other headings. Not removing preamble.");
        }
    } else if (firstHeadingIndex === -1 && processed.length > 0 && !processed.startsWith('##')) {
        // Handle case where there's content but NO H2 headings at all (might be invalid input)
        if (debug) logger.debug("No H2 headings found in input. Preprocessing might not apply.");
    }


    // Optional: Uncomment headings within code fences (use with caution)
    // processed = processed.replace(/```\s*(##+.*)\s*```/g, '$1');

    if (debug) logger.debug("--- Preprocessing Complete ---");
    return processed;
}


/* --- Public Markdown Parser Entry Point --- */
export function parseMarkdownContent<OutputSchema extends z.ZodObject>(
    markdownInput: string,
    schema: OutputSchema,
    options: FormatterOptions = {}
): z.infer<OutputSchema> {
    const { debug = false } = options;
    // Pass debug flag to logger if necessary (or handle globally)
    // logger.setDebug(debug); // Example if logger supports it

    if (debug) logger.debug("--- Starting Markdown Parsing ---");

    // 1. Preprocess Markdown
    const cleanedMarkdown = preprocessMarkdown(markdownInput, debug);
    if (debug) {
        logger.debug("--- Cleaned Markdown Input ---");
        console.log(cleanedMarkdown); // Use console.log for multi-line visibility
        logger.debug("--- End Cleaned Markdown Input ---");
    }

    // 2. Parse Markdown to AST
    let root: Root;
    try {
        root = mdParser.parse(cleanedMarkdown) as Root;
        if (debug) {
            logger.debug("--- MDAST Root ---");
            // console.log(JSON.stringify(root, null, 2)); // Full AST can be huge
            console.log(JSON.stringify(root, (key, value) => key === 'position' ? undefined : value, 2)); // Cleaner log
            logger.debug("--- End MDAST Root ---");
        }
    } catch (error) {
        logger.error("Markdown parsing failed:", error);
        // Return default value of the schema on critical parsing failure
        return getDefaultValue(schema) as z.infer<OutputSchema>;
    }


    // 3. Build Section Tree
    let sectionTree: Section;
    try {
        sectionTree = buildSectionTree(root);
        if (debug) {
            logger.debug("--- Section Tree ---");
            // console.log(JSON.stringify(sectionTree, null, 2)); // Full tree can be huge
            console.log(JSON.stringify(sectionTree, (key, value) => key === 'position' ? undefined : value, 2)); // Cleaner log
            logger.debug("--- End Section Tree ---");
        }
    } catch (error) {
        logger.error("Building section tree failed:", error);
        return getDefaultValue(schema) as z.infer<OutputSchema>;
    }


    // 4. Map AST/Tree to Zod Schema Structure
    let draftData: any;
    try {
        // Pass the original schema (including wrappers) to the mapping function
        draftData = mapSectionToSchema(sectionTree, schema, [], debug, schema); // Pass root schema
        if (debug) {
            logger.debug("--- Draft Data (Before Validation) ---");
            console.log(JSON.stringify(draftData, null, 2));
            logger.debug("--- End Draft Data ---");
        }
    } catch (error) {
        logger.error("Mapping section tree to schema failed:", error);
        // Return partially parsed data if available, otherwise default
        return draftData ?? getDefaultValue(schema);
    }


    // 5. Validate with Zod Schema
    const validationResult = schema.safeParse(draftData);

    if (!validationResult.success) {
        logger.warn('--- Zod Validation Failed ---');
        // Use console.error for better visibility of the error object
        console.error(JSON.stringify(validationResult.error.format(), null, 2));
        logger.warn('--- End Zod Validation Failure ---');
        // Return the draft data even if validation fails, allowing partial results
        return draftData;
    }

    if (debug) logger.debug('--- Markdown Parsed & Validated Successfully ---');
    return validationResult.data;
}
/**
 * A list of common English stop words.
 * This list can be expanded for better results.
 */
const STOP_WORDS: Set<string> = new Set([
    'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he',
    'in', 'is', 'it', 'its', 'of', 'on', 'that', 'the', 'to', 'was', 'were',
    'will', 'with', 'i', 'you', 'me', 'my', 'your', 'they', 'them', 'their',
    'this', 'these', 'those', 'am', 'if', 'or', 'but', 'not', 's', 't', 'can',
    'mr', 'mrs', 'ms', 'dr', 'prof'
    // Add more domain-specific stop words if necessary
]);

/**
 * Performs basic stemming on a word.
 * This is a very simplified stemmer. For more accuracy, a proper stemming algorithm
 * (like Porter stemmer) would be needed, but that adds complexity/dependencies.
 * @param word The input word.
 * @returns The stemmed word.
 */
function simpleStem(word: string): string {
    if (word.length < 3) return word;

    const suffixes: string[] = ['s', 'es', 'ed', 'ing', 'ly', 'er', 'est'];
    for (const suffix of suffixes) {
        if (word.endsWith(suffix)) {
            // Be careful not to over-stem, e.g., "address" -> "addre" if "ss" is a suffix
            // This simple version just removes it if the remaining part is long enough.
            if (word.length - suffix.length >= 2) {
                 return word.slice(0, -suffix.length);
            }
        }
    }
    // A common case: plural 's'
    if (word.endsWith('s') && word.length > 1 && !word.endsWith('ss') && !word.endsWith('us')) {
        return word.slice(0, -1);
    }
    return word;
}


/**
 * Tokenizes a string into words, removes stop words, and optionally stems them.
 * @param text The input string (should be pre-normalized).
 * @param useStemming Whether to apply simple stemming.
 * @returns An array of processed tokens.
 */
function preprocessText(text: string, useStemming: boolean = true): string[] {
    if (!text) return [];
    return text
        .split(' ')
        .filter(token => token.length > 0 && !STOP_WORDS.has(token))
        .map(token => (useStemming ? simpleStem(token) : token));
}

/**
 * Generates N-grams from an array of tokens.
 * @param tokens The input array of tokens.
 * @param n The size of the N-grams (e.g., 1 for unigrams, 2 for bigrams).
 * @returns A Set of N-grams.
 */
function generateNgrams(tokens: string[], n: number): Set<string> {
    const ngrams: Set<string> = new Set();
    if (n <= 0 || tokens.length === 0) return ngrams;

    for (let i = 0; i <= tokens.length - n; i++) {
        ngrams.add(tokens.slice(i, i + n).join(' '));
    }
    return ngrams;
}

/**
 * Calculates the Jaccard Index between two sets.
 * Jaccard Index = |Intersection(A, B)| / |Union(A, B)|
 * @param setA The first set.
 * @param setB The second set.
 * @returns The Jaccard Index (a value between 0 and 1).
 */
function jaccardIndex<T>(setA: Set<T>, setB: Set<T>): number {
    if (setA.size === 0 && setB.size === 0) return 1.0; // Both empty, considered identical
    if (setA.size === 0 || setB.size === 0) return 0.0; // One empty, no similarity

    const intersection: Set<T> = new Set();
    setA.forEach(elem => {
        if (setB.has(elem)) {
            intersection.add(elem);
        }
    });

    const unionSize = setA.size + setB.size - intersection.size;
    return intersection.size / unionSize;
}


/**
 * Calculates a semantic similarity score between two text strings.
 * This implementation is fast and does not use LLMs.
 * It relies on normalization, stop word removal, N-grams, and Jaccard index.
 *
 * @param text1 The first text string.
 * @param text2 The second text string.
 * @param options Optional configuration for the similarity calculation.
 * @returns A similarity score between 0 (no similarity) and 1 (identical).
 */
function getSemanticSimilarity(
    text1: string,
    text2: string,
    options?: {
        useStemming?: boolean;      // Whether to apply simple stemming (default: true)
        ngramMinSize?: number;    // Minimum N-gram size (default: 1, i.e., unigrams)
        ngramMaxSize?: number;    // Maximum N-gram size (default: 2, i.e., bigrams)
        weights?: { [key: string]: number }; // Optional: weights for different N-gram sizes (e.g., {unigram: 0.4, bigram: 0.6})
    }
): number {
    // Default options
    const config = {
        useStemming: options?.useStemming ?? true,
        ngramMinSize: options?.ngramMinSize ?? 1,
        ngramMaxSize: options?.ngramMaxSize ?? 2,
        weights: options?.weights // If not provided, will average scores or use only max N-gram
    };

    if (!text1 && !text2) return 1.0; // Both empty, consider them identical
    if (!text1 || !text2) return 0.0; // One is empty, no similarity

    // 1. Normalize text
    const normalizedText1 = normalize(text1);
    const normalizedText2 = normalize(text2);

    if (normalizedText1 === normalizedText2) return 1.0; // Identical after normalization

    // 2. Preprocess (tokenize, remove stop words, optionally stem)
    const tokens1 = preprocessText(normalizedText1, config.useStemming);
    const tokens2 = preprocessText(normalizedText2, config.useStemming);

    if (tokens1.length === 0 && tokens2.length === 0) return 1.0; // Both become empty after preprocessing
    if (tokens1.length === 0 || tokens2.length === 0) return 0.0; // One becomes empty

    let totalSimilarityScore = 0;
    let totalWeight = 0;
    const scoresByNgramSize: { [n: number]: number } = {};

    // 3. Generate N-grams and calculate Jaccard Index for each N-gram size
    for (let n = config.ngramMinSize; n <= config.ngramMaxSize; n++) {
        if (n <= 0) continue; // Skip invalid N-gram sizes

        const ngrams1 = generateNgrams(tokens1, n);
        const ngrams2 = generateNgrams(tokens2, n);

        const similarity = jaccardIndex(ngrams1, ngrams2);
        scoresByNgramSize[n] = similarity;

        // Apply weighting if provided
        if (config.weights) {
            const weightKey = n === 1 ? 'unigram' : n === 2 ? 'bigram' : `ngram${n}`;
            const weight = config.weights[weightKey] || (n === config.ngramMaxSize ? 1 : 0); // Default to weight 1 for max N-gram if not specified
            totalSimilarityScore += similarity * weight;
            totalWeight += weight;
        }
    }

    if (config.weights) {
        return totalWeight > 0 ? totalSimilarityScore / totalWeight : 0;
    } else {
        // If no weights, average the scores of the N-grams calculated,
        // or simply return the score for the largest N-gram size if only one size is effectively used.
        // For simplicity, let's average if multiple N-gram sizes were processed.
        const NgramSizesProcessed = Object.keys(scoresByNgramSize).length;
        if (NgramSizesProcessed === 0) return 0.0;

        let sumOfScores = 0;
        for (const n in scoresByNgramSize) {
            sumOfScores += scoresByNgramSize[n];
        }
        return sumOfScores / NgramSizesProcessed;
    }
}

/* --- Core Recursive Mapper: Section Tree -> Zod Schema (REVISED) --- */
function mapSectionToSchema(
    section: Section,
    schema: z.ZodTypeAny, // The schema for the *current* level being processed
    path: string[],
    debug: boolean,
    rootSchema: z.ZodObject<any> // Keep a reference to the top-level schema if needed
): any {
    const currentPath = path.join('.') || '<root>';
    const sectionHeadingText = section.heading ? mdastToString(section.heading) : '<root>'; // Use <root> for top level
    if (debug) logger.debug(`[Map Path: ${currentPath}] > Section: "${sectionHeadingText}", Target Schema: ${schema.constructor.name}`);

    // --- 1. Handle Schema Modifiers (Extract Base Schema) ---
    let baseSchema = schema;
    let isOptional = false;
    let isNullable = false;
    let hasDefault = false;
    // Need to check the original schema for modifiers
    let checkSchema = schema;
    while (checkSchema instanceof z.ZodOptional || checkSchema instanceof z.ZodNullable || checkSchema instanceof z.ZodDefault) {
        if (checkSchema instanceof z.ZodOptional) isOptional = true;
        if (checkSchema instanceof z.ZodNullable) isNullable = true;
        if (checkSchema instanceof z.ZodDefault) hasDefault = true;
        baseSchema = zInnerType(checkSchema); // Keep updating baseSchema here
        checkSchema = zInnerType(checkSchema); // Continue unwrapping checkSchema
    }
    // If no modifiers were found, baseSchema is the same as the original schema
    if (!isOptional && !isNullable && !hasDefault) {
        baseSchema = schema;
    }


    const schemaInfo = `Base: ${baseSchema.constructor.name}, Optional=${isOptional}, Nullable=${isNullable}, Default=${hasDefault}`;
    if (debug && baseSchema !== schema) logger.debug(`[Map Path: ${currentPath}] Schema Info: ${schemaInfo}`);

    // --- 2. Handle Base Schema Types ---

    /* --- 2a. ZodObject --- */
    if (baseSchema instanceof z.ZodObject) {
        if (debug) logger.debug(`[Map Path: ${currentPath}] Handling ZodObject.`);
        const shape = baseSchema.shape;
        const outputObject: Record<string, any> = {};

        // Initialize with default values for all keys in the schema
        // Important: Use the field definition from the shape for getDefaultValue
        const remainingKeys: Record<string, boolean> = {};
        for (const key of Object.keys(shape)) {
            remainingKeys[key] = true; // Track remaining keys
        }
        for (const key of Object.keys(shape)) {
            outputObject[key] = getDefaultValue(shape[key] as z.ZodTypeAny);
        }
        if (debug) logger.debug(`[Map Path: ${currentPath}] Initialized object with defaults`, { 
            path: currentPath, 
            defaults: outputObject 
        });


        // Iterate through the *actual* children found in the Markdown section
        if (debug) logger.debug(`[Map Path: ${currentPath}] Iterating through ${section.children.length} children of section "${sectionHeadingText}" to find matches...`);
        for (const childSection of section.children) {
            if (childSection.heading) {
                const childHeadingText = mdastToString(childSection.heading);
                const normalizedChildHeading = normalize(childHeadingText); // Normalize here
                if (debug) logger.debug(`[Map Path: ${currentPath}]  -- Checking child heading: "${childHeadingText}" (Normalized: "${normalizedChildHeading}")`);

                // Find the corresponding key in the Zod schema shape (case-insensitive on normalized)
                let matchedKey: string | undefined = undefined;

                // --- Fuzzy Matching Logic (Optional but Recommended) ---
                let minDistance = 3; // Example threshold
                for (const key of Object.keys(remainingKeys)) {
                    // Ignore already matched keys
                    // const keyDistance = levenshtein.get(normalize(key), normalizedChildHeading);
                    const keyDistance = 1.0 - getSemanticSimilarity(normalize(key), normalizedChildHeading, {
                        useStemming: true,
                        ngramMinSize: 1,
                        ngramMaxSize: 2,
                        // weights: { unigram: 0.4, bigram: 0.6 }
                    });
                    if (debug) logger.debug(`[Map Path: ${currentPath}]   - Comparing "${key}" (Normalized: "${normalize(key)}") with "${normalizedChildHeading}". Distance: ${keyDistance}`);
                    if (keyDistance < minDistance) {
                        minDistance = keyDistance;
                        matchedKey = key;
                    } else if (normalize(key) === normalizedChildHeading && keyDistance === 0) {
                        // Prioritize exact match even if a fuzzy match was found earlier
                        matchedKey = key;
                        break;
                    }
                }

                if (matchedKey) {
                    const fieldPath = [...path, matchedKey];
                    const fieldPathStr = fieldPath.join('.');
                    if (debug) logger.debug(`[Map Path: ${fieldPathStr}]   -- Normalized heading "${normalizedChildHeading}" matches schema key "${matchedKey}". Recursing.`);
                    // Recurse and update the output object, overwriting the default
                    // Pass the specific field schema from the shape
                    outputObject[matchedKey] = mapSectionToSchema(childSection, shape[matchedKey] as z.ZodTypeAny, fieldPath, debug, rootSchema);
                    // Remove the matched key from remainingKeys to avoid duplicates
                    delete remainingKeys[matchedKey];
                } else {
                    if (debug) logger.debug(`[Map Path: ${currentPath}] Normalized heading does not match schema shape`, { 
                        path: currentPath, 
                        normalizedHeading: normalizedChildHeading 
                    });
                }
            } else {
                if (debug) logger.debug(`[Map Path: ${currentPath}]  -- Skipping child section with no heading.`);
            }
        }
        if (debug) logger.debug(`[Map Path: ${currentPath}] Finished processing children`, { 
            path: currentPath, 
            outputObject 
        });
        return outputObject;
    }

    /* --- 2b. ZodArray --- */
    if (baseSchema instanceof z.ZodArray) {
        if (debug) logger.debug(`[Map Path: ${currentPath}] Handling ZodArray.`);
        const itemSchema = zArrayElement(baseSchema);
        const results: any[] = [];

        // *** CHANGE: Use singularize for item name matching ***
        const arrayKey = path.length > 0 ? path[path.length - 1] : ''; // Get the key name for this array
        const singularKeyName = singularize(arrayKey); // e.g., "File", "Phase"
        // Create a regex to match headings like "File 1", "File #2", "File" (optional number)
        // Make it case-insensitive
        const itemHeadingRegex = new RegExp(`^${singularKeyName.toLowerCase()}(?:\\s*(?:#|\\d+))?$`, 'i');


        // Strategy ①: Look for Markdown list items directly within the current section's nodes
        // This is less likely with the new format but kept as a fallback for simple arrays
        const directList = section.nodes.find(isList) as List | undefined;
        if (directList && !(itemSchema instanceof z.ZodObject)) { // Only use for primitive arrays
            if (debug) logger.debug(`[Map Path: ${currentPath}] Strategy 1: Found direct list in section nodes for primitive array (${directList.children.length} items).`);
            directList.children.forEach((listItem, i) => {
                if (isListItem(listItem)) {
                    const itemPath = [...path, String(i)];
                    // Create a temporary section for the list item content
                    const itemSection: Section = { heading: null, nodes: listItem.children as MdNode[], children: [] };
                    if (debug) logger.debug(`[Map Path: ${itemPath.join('.')}] Mapping list item ${i}.`);
                    // Pass the itemSchema for the primitive type
                    results.push(mapSectionToSchema(itemSection, itemSchema, itemPath, debug, rootSchema));
                }
            });
            if (results.length > 0) {
                if (debug) logger.debug(`[Map Path: ${currentPath}] Returning ${results.length} items from direct list.`);
                return results;
            }
        }

        // Strategy ②: Look for child sections indicating items (e.g., "### File 1", "### Phase 2")
        if (debug) logger.debug(`[Map Path: ${currentPath}] Strategy 2: Looking for item sections matching regex: ${itemHeadingRegex}`);

        const itemSections: Section[] = [];
        const itemMatchPath = currentPath + `[${singularKeyName} items]`; // For logging
        if (debug) logger.debug(`[ItemMatching Path: ${itemMatchPath}] Searching in parent section "${sectionHeadingText}" with ${section.children.length} children.`);

        for (const childSection of section.children) {
            if (childSection.heading) {
                const headingText = mdastToString(childSection.heading);
                // No need to normalize here, regex handles variations
                const match = itemHeadingRegex.test(headingText.trim()); // Test raw heading text
                if (debug) logger.debug(`[ItemMatching Path: ${itemMatchPath}]   - Checking child heading: "${headingText}". Regex match result: ${match}`);
                if (match) {
                    itemSections.push(childSection);
                }
            } else {
                if (debug) logger.debug(`[ItemMatching Path: ${itemMatchPath}]   - Child section has no heading. Skipping.`);
            }
        }


        if (itemSections.length > 0) {
            if (debug) logger.debug(`[Map Path: ${currentPath}] Strategy 2: Found ${itemSections.length} item sections based on headings matching '${singularKeyName} X'.`);
            return itemSections.map((itemSec, i) => {
                const itemPath = [...path, String(i)];
                if (debug) logger.debug(`[Map Path: ${itemPath.join('.')}] Mapping item section ${i} with heading "${mdastToString(itemSec.heading!)}".`);
                // Pass the item's section and the itemSchema for recursion
                return mapSectionToSchema(itemSec, itemSchema, itemPath, debug, rootSchema);
            });
        }

        // Strategy ③: Thematic breaks (---) separating items (Less likely with new format, but kept)
        const slices: MdNode[][] = [];
        let currentSlice: MdNode[] = [];
        for (const node of section.nodes) {
            if (isThematicBreak(node)) { // Use type guard
                // Only push if the slice has meaningful content (not just whitespace/description)
                const sliceContent = mdastToString({ type: 'root', children: currentSlice }).trim();
                if (currentSlice.length > 0 && sliceContent !== '' && !sliceContent.startsWith('*Description:')) {
                    slices.push(currentSlice);
                }
                currentSlice = [];
            } else {
                currentSlice.push(node);
            }
        }
        const lastSliceContent = mdastToString({ type: 'root', children: currentSlice }).trim();
        if (currentSlice.length > 0 && lastSliceContent !== '' && !lastSliceContent.startsWith('*Description:')) {
            slices.push(currentSlice);
        }

        if (slices.length > 0) {
            if (debug) logger.debug(`[Map Path: ${currentPath}] Strategy 3: Found ${slices.length} items based on thematic breaks.`);
            return slices.map((sliceNodes, i) => {
                const itemPath = [...path, String(i)];
                const itemSection: Section = { heading: null, nodes: sliceNodes, children: [] };
                if (debug) logger.debug(`[Map Path: ${itemPath.join('.')}] Mapping thematic break slice ${i}.`);
                return mapSectionToSchema(itemSection, itemSchema, itemPath, debug, rootSchema);
            });
        }

        // Strategy ④: Primitive Array Fallback (if itemSchema is not Object)
        if (!(itemSchema instanceof z.ZodObject)) {
            if (debug) logger.debug(`[Map Path: ${currentPath}] Strategy 4: Handling array of primitives fallback.`);
            // Extract value considering it might just be lines of text or a list
            const contentString = extractPrimitiveValueFromNodes(section.nodes, { path: currentPath, logger: logger as ReturnType<typeof createLogger>, debug });
            if (!contentString) {
                if (debug) logger.debug(`[Map Path: ${currentPath}] No content found for primitive array. Returning empty array.`);
                return [];
            }
            // Split by newline, trim, and filter empty lines
            const lines = contentString.split('\n').map(l => l.trim()).filter(Boolean);
            // Check if it looks like a markdown list
            const looksLikeList = lines.length > 0 && lines.every(l => /^\s*[-*+]\s/.test(l));

            if (looksLikeList) {
                if (debug) logger.debug(`[Map Path: ${currentPath}] Parsing primitive array from ${lines.length} list-like lines.`);
                return lines.map((ln, i) => {
                    const itemPath = [...path, String(i)];
                    const itemContent = ln.replace(/^\s*[-*+]\s+/, '').trim(); // Extract content from list item
                    return convertToPrimitive(itemContent, itemSchema, { path: itemPath, logger: logger as ReturnType<typeof createLogger>, debug });
                });
            } else if (lines.length > 0) {
                // If not a list, treat each non-empty line as an item (use with caution)
                if (debug) logger.debug(`[Map Path: ${currentPath}] Parsing primitive array by splitting into ${lines.length} non-empty lines (non-list format).`);
                return lines.map((ln, i) => {
                    const itemPath = [...path, String(i)];
                    return convertToPrimitive(ln, itemSchema, { path: itemPath, logger: logger as ReturnType<typeof createLogger>, debug });
                });
            }
        }

        // Strategy ⑤: Fallback - No items found
        if (debug) logger.debug(`[Map Path: ${currentPath}] No array items found using any strategy. Returning empty array.`);
        return [];
    }

    /* --- 2c. Primitive Types (and others like Union, Enum) --- */
    if (debug) logger.debug(`[Map Path: ${currentPath}] Handling Primitive/Other Schema (${baseSchema.constructor.name}).`);
    if (debug) {
        // Log nodes more concisely for primitives
        logger.debug(`[Map Path: ${currentPath}] Nodes for primitive extraction:`, section.nodes.map(n => ({ type: n.type, value: (n as any).value?.substring(0, 30) ?? mdastToString(n).substring(0, 30) + '...' })));
    }
    // Extract the raw string value from the section's nodes
    const extractedValue = extractPrimitiveValueFromNodes(section.nodes, { path: currentPath, logger: logger as ReturnType<typeof createLogger>, debug });
    if (debug) logger.debug(`[Map Path: ${currentPath}] Extracted raw value: "${extractedValue.substring(0, 100)}${extractedValue.length > 100 ? '...' : ''}"`);

    // Convert the extracted string using the *original* schema (which includes wrappers like optional/nullable/default)
    const convertedValue = convertToPrimitive(
        extractedValue,
        schema, // Pass the original schema with wrappers
        { path: path, logger: logger as ReturnType<typeof createLogger>, debug }
    );
    if (debug) logger.debug(`[Map Path: ${currentPath}] Converted value: ${JSON.stringify(convertedValue)}`);

    // Final checks (optional, as Zod validation catches most issues)
    // These checks compare the *result* of conversion against the *original* schema's modifiers
    if (convertedValue === undefined && !(schema instanceof z.ZodOptional || schema instanceof z.ZodDefault)) {
        // It's okay if it's nullable, null might be the intended conversion from empty/missing
        if (!(schema instanceof z.ZodNullable)) {
            if (debug) logger.warn(`[Map Path: ${currentPath}] CONVERTED UNDEFINED for non-optional/non-default field.`);
        }
    }
    if (convertedValue === null && !(schema instanceof z.ZodNullable || schema instanceof z.ZodDefault)) {
        // It's okay if it's optional, undefined might be the intended conversion
        if (!(schema instanceof z.ZodOptional)) {
            if (debug) logger.warn(`[Map Path: ${currentPath}] CONVERTED NULL for non-nullable/non-default field.`);
        }
    }

    return convertedValue;
}

// --- Template Registry (Update parsers) ---
interface TemplateRegistryEntry {
    template: (schema: z.ZodObject, options?: FormatterOptions) => string;
    serialize: <OutputSchema extends z.ZodObject>(data: z.infer<OutputSchema>, schema: OutputSchema, options?: FormatterOptions) => string;
    prompt: (template: string) => string;
    parser: <OutputSchema extends z.ZodObject>(content: string, schema: OutputSchema, options?: FormatterOptions) => z.infer<OutputSchema>;
}

const markdownPrompt = (template: string) => `
<OUTPUT FORMAT>
Output format: Structured Markdown based schema

Please fill out the following structure.
    - Use the headings provided (e.g., \`## name\`, \`#### filePath\`) as is. These are schema field names and must not be changed.
    - Place the value for each field inside triple backticks (\`\`\`) on the line(s) following the heading.
    - For arrays (like \`files\`), use a heading like \`### File 1\`, \`### File 2\`, etc., for each item in the list, where "File" is the singular form of the array name.
    - Provide all required fields. Optional sections can be omitted entirely or have an empty code block if explicitly empty.
    - Ensure the content within the code blocks is raw text/code as appropriate for the field.
    - Do not include any XML tags in the formatting. This is a pure Markdown format.
    - Do not include any additional text or explanations outside of the specified format.
    - All fields are required no matter how trivial unless specified as optional in the schema.
    
Please output your response **strictly** in the custom Markdown-based format with the **exact** structure as the following template **without the \`Description\` fields**:

${template}

</OUTPUT FORMAT>
`;
export const TemplateRegistry: Record<SchemaFormat, TemplateRegistryEntry> = {
    markdown: {
        template: formatSchemaAsMarkdown,
        serialize: formatDataAsMarkdown,
        prompt: markdownPrompt,
        parser: parseMarkdownContent,
    },
};


export function generateTemplateForSchema(
    schema: z.ZodObject, 
    schemaFormat: SchemaFormat,
    options?: FormatterOptions
): string {
    const template = TemplateRegistry[schemaFormat].template(schema, options);
    const formatInstructions = TemplateRegistry[schemaFormat].prompt(template);
    return formatInstructions;
}

export function parseContentForSchema<OutputSchema extends z.ZodObject>(content: string, schemaFormat: SchemaFormat, schema: OutputSchema, options?: FormatterOptions): z.infer<OutputSchema> {
    const parser = TemplateRegistry[schemaFormat].parser;
    if (!parser) {
        throw new Error(`No parser function found for format: ${schemaFormat}`);
    }
    return parser(content, schema, options);
}