/**
 * Schema Registry Service for Schema Registry Service (FU-057)
 *
 * Manages config-driven entity schemas, versions, and merge policies.
 */
import { db } from "../db.js";
import { checkPluralEntityType, enforceEntityTypeGuards, suggestSingular, } from "./entity_type_guard.js";
import { prepareEntitySnapshotWithEmbedding, upsertEntitySnapshotWithEmbedding, } from "./entity_snapshot_embedding.js";
import { spawn } from "child_process";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
function logSchemaRegistryInfo(message) {
    process.stderr.write(`${message}\n`);
}
async function applyBuiltInSchemaIdentityDefaults(entry) {
    if (!entry)
        return entry;
    const { ENTITY_SCHEMAS } = await import("./schema_definitions.js");
    const builtIn = ENTITY_SCHEMAS[entry.entity_type];
    const builtInDefinition = builtIn?.schema_definition;
    /** Code-defined default; DB row wins when it already sets guest_access_policy. */
    function mergeGuestAccessFromCode(base) {
        const guestFromCode = builtIn?.metadata?.guest_access_policy;
        if (!guestFromCode || base.metadata?.guest_access_policy) {
            return base;
        }
        return {
            ...base,
            metadata: { ...(base.metadata ?? {}), guest_access_policy: guestFromCode },
        };
    }
    if (!builtInDefinition) {
        return mergeGuestAccessFromCode(entry);
    }
    const schemaDefinition = { ...entry.schema_definition };
    let changed = false;
    if (!schemaDefinition.canonical_name_fields && builtInDefinition.canonical_name_fields) {
        schemaDefinition.canonical_name_fields = builtInDefinition.canonical_name_fields;
        changed = true;
    }
    if (!schemaDefinition.name_collision_policy && builtInDefinition.name_collision_policy) {
        schemaDefinition.name_collision_policy = builtInDefinition.name_collision_policy;
        changed = true;
    }
    if (!schemaDefinition.identity_opt_out && builtInDefinition.identity_opt_out) {
        schemaDefinition.identity_opt_out = builtInDefinition.identity_opt_out;
        changed = true;
    }
    const withDef = changed ? { ...entry, schema_definition: schemaDefinition } : entry;
    return mergeGuestAccessFromCode(withDef);
}
/** Known opt-out tokens for {@link SchemaDefinition.identity_opt_out}. */
const VALID_IDENTITY_OPT_OUT_TOKENS = new Set(["heuristic_canonical_name"]);
/**
 * Valid values for the `observation_source` field on observations (kept in
 * sync with the Zod enum in `src/shared/action_schemas.ts` and the OpenAPI
 * `Observation.observation_source` enum). Declared here so the schema
 * registry can validate `observation_source_priority` without importing
 * the Zod module and creating a cycle.
 */
export const OBSERVATION_SOURCE_RANK_VALUES = [
    "sensor",
    "llm_summary",
    "workflow_state",
    "human",
    "import",
    "sync",
];
/**
 * Default ranking applied by the reducer when a `source_priority` tie
 * remains and the schema does not declare its own
 * `observation_source_priority`. Sensors (ground-truth emissions)
 * outrank deterministic workflow transitions, which outrank LLM
 * summaries, which outrank direct human writes (lower ranked because
 * humans should use {@link ../services/correction.ts} for authoritative
 * overrides), which outrank batch imports.
 */
export const DEFAULT_OBSERVATION_SOURCE_PRIORITY = [
    "sensor",
    "workflow_state",
    "llm_summary",
    "human",
    "import",
    "sync",
];
export async function loadCodeDefinedSchemaEntry(entityType) {
    const { ENTITY_SCHEMAS } = await import("./schema_definitions.js");
    const normalized = normalizeEntityTypeForSchema(entityType);
    const schema = ENTITY_SCHEMAS[entityType] ?? ENTITY_SCHEMAS[normalized];
    if (!schema)
        return null;
    return {
        id: "",
        entity_type: schema.entity_type,
        schema_version: schema.schema_version,
        schema_definition: schema.schema_definition,
        reducer_config: schema.reducer_config,
        active: true,
        created_at: new Date(0).toISOString(),
        user_id: null,
        scope: "global",
        metadata: schema.metadata ?? {},
    };
}
/**
 * R4: derive the minimum set of identity-bearing payload keys a caller must
 * supply for a given entity_type, straight out of the schema registry. Used
 * by `store_structured` to populate structured `issues[].hint` on
 * `ERR_STORE_RESOLUTION_FAILED` responses and by `tool_definitions.ts` to
 * surface the list in the MCP tool description. Returns null when the entity
 * type has no registered schema (unseeded — heuristic fallback applies).
 *
 * Schema-agnostic by construction: all behavior is driven by
 * `canonical_name_fields` + `name_collision_policy` on the schema. Adding a
 * new reject-policy schema automatically extends the contract.
 */
export async function deriveRequiredIdentityFields(entityType, userId) {
    const normalized = normalizeEntityTypeForSchema(entityType);
    const entry = await schemaRegistry.loadActiveSchema(normalized, userId ?? undefined);
    // R1/R2 safety net: same reasoning as the resolver's schema-load fallback
    // in `src/services/entity_resolution.ts` — when the registry has no row for
    // this entity_type (fresh DB, test fixture, or never-registered type) the
    // code-defined default in `ENTITY_SCHEMAS` still carries the identity
    // contract, so the caller-facing hint must come from there instead of
    // silently returning null.
    let def = entry?.schema_definition;
    if (!def) {
        try {
            const { ENTITY_SCHEMAS } = await import("./schema_definitions.js");
            const codeSchema = ENTITY_SCHEMAS[normalized] ?? ENTITY_SCHEMAS[entityType];
            if (codeSchema) {
                def = codeSchema.schema_definition;
            }
        }
        catch {
            // defensive
        }
    }
    if (!def)
        return null;
    const rules = def.canonical_name_fields ?? [];
    const anyOfFields = [];
    const compositeFields = [];
    for (const rule of rules) {
        if (typeof rule === "string") {
            anyOfFields.push(rule);
        }
        else if (rule && typeof rule === "object" && Array.isArray(rule.composite)) {
            compositeFields.push([...rule.composite]);
        }
    }
    return {
        entityType: normalized,
        required: def.name_collision_policy === "reject",
        anyOfFields,
        compositeFields,
    };
}
/** Normalize entity type for schema registry: snake_case, safe characters, max length */
export function normalizeEntityTypeForSchema(raw) {
    const normalized = raw
        .trim()
        .toLowerCase()
        .replace(/\s+/g, "_")
        .replace(/[^a-z0-9_]/g, "_")
        .replace(/_+/g, "_")
        .replace(/^_|_$/g, "") || "generic";
    return normalized.slice(0, 64);
}
/** Infer schema field type from a value (for schema-from-extraction). */
function inferFieldType(value) {
    if (value === null || value === undefined)
        return "string";
    if (typeof value === "number")
        return "number";
    if (typeof value === "boolean")
        return "boolean";
    if (Array.isArray(value))
        return "array";
    if (typeof value === "object")
        return "object";
    if (typeof value === "string") {
        const s = value.trim();
        if (/^\d{4}-\d{2}-\d{2}/.test(s) || !Number.isNaN(Date.parse(s)))
            return "date";
        return "string";
    }
    return "string";
}
/**
 * Build schema definition and reducer config from extracted entity data.
 * Used when the entity type has no known schema so a user-scoped schema can be created.
 */
export function buildSchemaFromExtractedFields(entityData) {
    const fields = {
        schema_version: { type: "string", required: true },
    };
    const merge_policies = {};
    const skipKeys = new Set(["entity_type", "type", "schema_version"]);
    for (const [key, value] of Object.entries(entityData)) {
        if (skipKeys.has(key))
            continue;
        const safeKey = key
            .trim()
            .toLowerCase()
            .replace(/\s+/g, "_")
            .replace(/[^a-z0-9_]/g, "_")
            .replace(/_+/g, "_")
            .replace(/^_|_$/g, "");
        if (!safeKey)
            continue;
        const fieldType = inferFieldType(value);
        fields[safeKey] = { type: fieldType, required: false };
        merge_policies[safeKey] = { strategy: "last_write" };
    }
    return {
        schema_definition: { fields },
        reducer_config: { merge_policies },
    };
}
export class SchemaRegistryService {
    /**
     * Register a new schema version
     */
    async register(config) {
        // Entity-type naming guards (forbidden-pattern + plural). Warns in dev,
        // throws in production unless force: true.
        enforceEntityTypeGuards(config.entity_type, {
            force: config.force,
            context: "register_schema",
        });
        // Validate schema definition
        this.validateSchemaDefinition(config.schema_definition);
        // Validate reducer config
        this.validateReducerConfig(config.reducer_config, config.schema_definition);
        const scope = config.user_specific ? "user" : "global";
        const { data, error } = await db
            .from("schema_registry")
            .insert({
            entity_type: config.entity_type,
            schema_version: config.schema_version,
            schema_definition: config.schema_definition,
            reducer_config: config.reducer_config,
            active: config.activate || false, // New schemas start inactive unless specified
            user_id: config.user_specific ? config.user_id || null : null,
            scope: scope,
            metadata: config.metadata || {},
        })
            .select()
            .single();
        if (error) {
            throw new Error(`Failed to register schema: ${error.message}`);
        }
        const registeredSchema = data;
        // When activating a new schema version, deactivate all previously active
        // versions for the same entity_type and scope so only one row ever has
        // active = true. Without this, `loadGlobalSchema` / `loadUserSpecificSchema`
        // use `.single()` which errors (or non-deterministically returns the old
        // version) when multiple active rows exist, causing new fields to be routed
        // to raw_fragments instead of the top-level snapshot. (Fixes #142)
        if (config.activate) {
            let deactivateQuery = db
                .from("schema_registry")
                .update({ active: false })
                .eq("entity_type", config.entity_type)
                .eq("active", true)
                .not("id", "eq", registeredSchema.id);
            if (scope === "user" && config.user_specific && config.user_id) {
                deactivateQuery = deactivateQuery.eq("scope", "user").eq("user_id", config.user_id);
            }
            else {
                deactivateQuery = deactivateQuery.eq("scope", "global").is("user_id", null);
            }
            const { error: deactivateError } = await deactivateQuery;
            if (deactivateError) {
                logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Warning: failed to deactivate prior active schemas for ` +
                    `${config.entity_type}: ${deactivateError.message}`);
            }
        }
        // Auto-generate icon if not provided (non-blocking)
        this.generateIconAsync(registeredSchema.entity_type, config.metadata);
        // Automatically export schema snapshots (non-blocking)
        this.exportSnapshotsAsync();
        return registeredSchema;
    }
    /**
     * Load active entity schema for entity type
     * Supports user-specific schemas: tries user-specific first, then falls back to global
     */
    async loadActiveSchema(entityType, userId) {
        // 1. Try user-specific schema first if userId provided
        if (userId) {
            const userSchema = await this.loadUserSpecificSchema(entityType, userId);
            if (userSchema) {
                return await applyBuiltInSchemaIdentityDefaults(userSchema);
            }
        }
        // 2. Fall back to global schema
        return await applyBuiltInSchemaIdentityDefaults(await this.loadGlobalSchema(entityType));
    }
    /**
     * List all active schemas available for refinement/candidates: global schemas
     * plus user-scoped schemas when userId is provided. Use when scoring against
     * a dynamic set of types (e.g. field-based type refinement).
     */
    async listActiveSchemas(userId) {
        const base = db
            .from("schema_registry")
            .select("id, entity_type, schema_version, schema_definition, reducer_config, active, created_at, user_id, scope, metadata")
            .eq("active", true);
        const query = userId
            ? base.or(`scope.eq.global,and(scope.eq.user,user_id.eq.${userId})`)
            : base.eq("scope", "global");
        const { data, error } = await query;
        if (error) {
            throw new Error(`Failed to list active schemas: ${error.message}`);
        }
        const rows = (data ?? []);
        const applied = await Promise.all(rows.map((row) => applyBuiltInSchemaIdentityDefaults(row)));
        return applied.filter((e) => e != null);
    }
    /**
     * Emit a one-time startup log of every active schema that has opted out
     * of strong identity via {@link SchemaDefinition.identity_opt_out} (R2).
     *
     * Opt-outs are loud by design: if a schema that should have an identifier
     * accidentally landed on the heuristic path, the regression is visible in
     * server/CLI boot logs before any writes happen.
     *
     * Returns the list of opt-out entity_types so callers (tests, CLIs) can
     * assert or display them. Set `silent: true` to compute the list without
     * writing to stderr (used by the CLI when rendering stats tables).
     */
    async logIdentityOptOutsAtStartup(options) {
        const schemas = await this.listActiveSchemas(options?.userId);
        const optOuts = [];
        for (const s of schemas) {
            const def = s.schema_definition;
            if (def?.identity_opt_out) {
                optOuts.push({
                    entity_type: s.entity_type,
                    reason: def.identity_opt_out,
                });
            }
        }
        optOuts.sort((a, b) => a.entity_type.localeCompare(b.entity_type));
        if (!options?.silent && optOuts.length > 0) {
            const label = optOuts.length === 1 ? "1 schema" : `${optOuts.length} schemas`;
            logSchemaRegistryInfo(`ℹ️  [SCHEMA_REGISTRY] R2: ${label} opted out of canonical_name_fields ` +
                `(identity_opt_out): ${optOuts.map((o) => o.entity_type).join(", ")}. ` +
                "These resolve via the heuristic canonical_name path. See " +
                "docs/foundation/schema_agnostic_design_rules.md.");
        }
        return { opt_outs: optOuts };
    }
    /**
     * Emit a one-time startup log of every active schema whose entity_type name
     * looks plural (per {@link checkPluralEntityType}), advising the operator to
     * run `neotoma schemas repair-plural-types` to migrate or alias them.
     *
     * Returns the list of plural entity_types. Set `silent: true` to compute the
     * list without writing to stderr (e.g. for CLI stat tables).
     */
    async logPluralTypesAtStartup(options) {
        const schemas = await this.listActiveSchemas(options?.userId);
        const pluralTypes = [];
        for (const s of schemas) {
            const guard = checkPluralEntityType(s.entity_type);
            if (guard.reason === "looks_plural") {
                const singular = suggestSingular(s.entity_type);
                if (singular) {
                    pluralTypes.push({ entity_type: s.entity_type, suggested_singular: singular });
                }
            }
        }
        pluralTypes.sort((a, b) => a.entity_type.localeCompare(b.entity_type));
        if (!options?.silent && pluralTypes.length > 0) {
            const label = pluralTypes.length === 1 ? "1 entity type" : `${pluralTypes.length} entity types`;
            logSchemaRegistryInfo(`⚠️  [SCHEMA_REGISTRY] ${label} appear to use plural names: ` +
                `${pluralTypes.map((t) => t.entity_type).join(", ")}. ` +
                "Run `neotoma schemas repair-plural-types` to merge or alias them. " +
                "See docs/foundation/schema_agnostic_design_rules.md.");
        }
        return { plural_types: pluralTypes };
    }
    /**
     * Load user-specific schema for entity type
     */
    async loadUserSpecificSchema(entityType, userId) {
        const { data, error } = await db
            .from("schema_registry")
            .select("*")
            .eq("entity_type", entityType)
            .eq("user_id", userId)
            .eq("scope", "user")
            .eq("active", true)
            .single();
        if (error) {
            if (error.code === "PGRST116") {
                // Not found
                return null;
            }
            throw new Error(`Failed to load user-specific schema: ${error.message}`);
        }
        return data;
    }
    /**
     * Load global schema for entity type
     */
    async loadGlobalSchema(entityType) {
        const { data, error } = await db
            .from("schema_registry")
            .select("*")
            .eq("entity_type", entityType)
            .eq("scope", "global")
            .eq("active", true)
            .single();
        if (error) {
            if (error.code === "PGRST116") {
                // Not found
                return null;
            }
            throw new Error(`Failed to load global schema: ${error.message}`);
        }
        return applyBuiltInSchemaIdentityDefaults(data);
    }
    /**
     * Ensure a schema exists for an extracted entity type; if not, create one from extracted fields.
     * Registers a user-scoped schema so observations can be created for unknown entity types.
     * Returns the schema to use (existing or newly created), or null if creation is disabled or fails.
     */
    async ensureSchemaForExtractedEntity(entityType, entityData, userId, options) {
        const createIfMissing = options?.create_if_missing !== false;
        const normalizedType = normalizeEntityTypeForSchema(entityType);
        if (!normalizedType)
            return null;
        const schema = await this.loadActiveSchema(normalizedType, userId);
        if (schema)
            return schema;
        if (!createIfMissing)
            return null;
        const { schema_definition, reducer_config } = buildSchemaFromExtractedFields(entityData);
        if (Object.keys(schema_definition.fields).length <= 1) {
            return null;
        }
        try {
            const registered = await this.register({
                entity_type: normalizedType,
                schema_version: "1.0",
                schema_definition,
                reducer_config,
                user_id: userId,
                user_specific: true,
                activate: true,
                metadata: {
                    label: normalizedType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
                    description: `User-created schema from extraction (entity type: ${entityType})`,
                },
            });
            return registered;
        }
        catch {
            return null;
        }
    }
    /**
     * Incrementally update schema by adding fields
     *
     * IMPORTANT: After activation, the new schema immediately applies to all new data.
     * The interpretation process automatically uses loadActiveSchema(), so new observations
     * will use the updated schema fields immediately. migrate_existing is only for
     * backfilling historical data stored before the schema update.
     */
    async updateSchemaIncremental(options) {
        // Entity-type naming guards (forbidden-pattern + plural).
        enforceEntityTypeGuards(options.entity_type, {
            force: options.force,
            context: "update_schema_incremental",
        });
        const activateSchema = options.activate !== false; // Default to true
        // 1. Load current active schema (user-specific or global), then fall back to
        // code-defined ENTITY_SCHEMAS so incremental updates can materialize the
        // first registry row for built-in types (e.g. conversation_message).
        let currentSchema = await this.loadActiveSchema(options.entity_type, options.user_id);
        if (!currentSchema) {
            const normalized = normalizeEntityTypeForSchema(options.entity_type);
            const codeDefined = (await loadCodeDefinedSchemaEntry(options.entity_type)) ??
                (await loadCodeDefinedSchemaEntry(normalized));
            if (!codeDefined) {
                throw new Error(`No active schema found for entity type: ${options.entity_type}`);
            }
            currentSchema = codeDefined;
            logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Incremental update: no active registry row for ${options.entity_type}; ` +
                `using code-defined baseline v${codeDefined.schema_version}`);
        }
        // 2. Determine change type and increment version
        const changeType = this.determineChangeType({
            fields_to_add: options.fields_to_add,
            fields_to_remove: options.fields_to_remove,
            converters_to_add: options.converters_to_add,
        });
        const newVersion = options.schema_version || this.incrementVersion(currentSchema.schema_version, changeType);
        // 3. Merge fields (add new fields to existing schema)
        const mergedFields = {
            ...currentSchema.schema_definition.fields,
        };
        // Add new fields
        for (const field of options.fields_to_add || []) {
            // Skip if field already exists
            if (mergedFields[field.field_name]) {
                logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Field ${field.field_name} already exists in schema, skipping`);
                continue;
            }
            mergedFields[field.field_name] = {
                type: field.field_type,
                required: field.required || false,
            };
        }
        // Add converters to existing fields
        for (const converterSpec of options.converters_to_add || []) {
            if (!mergedFields[converterSpec.field_name]) {
                throw new Error(`Field "${converterSpec.field_name}" does not exist in schema for entity type "${options.entity_type}". ` +
                    `Add the field first before adding a converter to it.`);
            }
            const existingField = mergedFields[converterSpec.field_name];
            const existingConverters = Array.isArray(existingField.converters)
                ? [...existingField.converters]
                : [];
            // Deduplicate by function name — adding the same converter twice is a no-op
            const alreadyPresent = existingConverters.some((c) => c.function === converterSpec.converter.function);
            if (!alreadyPresent) {
                existingConverters.push(converterSpec.converter);
            }
            mergedFields[converterSpec.field_name] = {
                ...existingField,
                converters: existingConverters,
            };
        }
        // Remove fields (schema-projection: data preserved in observations, just
        // excluded from future snapshots via reducer projection filtering)
        for (const fieldName of options.fields_to_remove || []) {
            if (!mergedFields[fieldName]) {
                logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Field ${fieldName} not found in schema, skipping removal`);
                continue;
            }
            delete mergedFields[fieldName];
        }
        // Validate that at least one field remains after removal — but only if
        // fields were actually removed. An empty schema that started empty is fine.
        const fieldsWereRemoved = (options.fields_to_remove || []).length > 0;
        if (fieldsWereRemoved && Object.keys(mergedFields).length === 0) {
            throw new Error(`Cannot remove all fields from schema for entity type: ${options.entity_type}. At least one field must remain.`);
        }
        // 4. Merge reducer configs
        const mergedReducerPolicies = {
            ...currentSchema.reducer_config.merge_policies,
        };
        // Add reducer policies for new fields
        for (const field of options.fields_to_add || []) {
            if (!mergedReducerPolicies[field.field_name]) {
                mergedReducerPolicies[field.field_name] = {
                    strategy: field.reducer_strategy || "last_write",
                    tie_breaker: "observed_at",
                };
            }
        }
        // Remove reducer policies for removed fields
        for (const fieldName of options.fields_to_remove || []) {
            delete mergedReducerPolicies[fieldName];
        }
        // Handle default user ID: convert to undefined for optional parameter
        const defaultUserId = "00000000-0000-0000-0000-000000000000";
        const userId = options.user_id && options.user_id !== defaultUserId ? options.user_id : undefined;
        // Preserve schema-level declarations that aren't field-maps (canonical_name_fields,
        // temporal_fields, reference_fields, aliases) across incremental updates.
        // Prune any removed field names from these lists so they don't reference
        // deleted fields after a major version bump.
        const preserved = { ...currentSchema.schema_definition };
        delete preserved.fields;
        const removalSet = new Set(options.fields_to_remove || []);
        if (preserved.canonical_name_fields) {
            const prunedRules = preserved.canonical_name_fields
                .map((rule) => {
                if (typeof rule === "string") {
                    return removalSet.has(rule) ? null : rule;
                }
                const remaining = rule.composite.filter((f) => !removalSet.has(f));
                if (remaining.length === 0)
                    return null;
                if (remaining.length === rule.composite.length)
                    return rule;
                return { composite: remaining };
            })
                .filter((r) => r !== null);
            if (prunedRules.length === 0) {
                delete preserved.canonical_name_fields;
            }
            else {
                preserved.canonical_name_fields = prunedRules;
            }
        }
        if (preserved.temporal_fields) {
            preserved.temporal_fields = preserved.temporal_fields.filter((t) => !removalSet.has(t.field));
            if (preserved.temporal_fields.length === 0) {
                delete preserved.temporal_fields;
            }
        }
        if (preserved.reference_fields) {
            preserved.reference_fields = preserved.reference_fields.filter((r) => !removalSet.has(r.field));
            if (preserved.reference_fields.length === 0) {
                delete preserved.reference_fields;
            }
        }
        // 5. Register new version (start inactive, we'll activate separately if needed)
        const newSchema = await this.register({
            entity_type: options.entity_type,
            schema_version: newVersion,
            schema_definition: { ...preserved, fields: mergedFields },
            reducer_config: { merge_policies: mergedReducerPolicies },
            user_id: userId,
            user_specific: options.user_specific,
            activate: false, // Register as inactive first
        });
        // 6. Activate new version if requested (this deactivates other versions)
        if (activateSchema) {
            await this.activate(options.entity_type, newVersion);
        }
        logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Incrementally updated schema for ${options.entity_type} to version ${newVersion}`);
        // 7. Migrate raw_fragments if requested (historical data backfill only)
        if (options.migrate_existing) {
            logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Migrating existing raw_fragments for ${options.entity_type}`);
            const fieldNamesToMigrate = [
                ...(options.fields_to_add?.map((f) => f.field_name) || []),
                ...(options.converters_to_add?.map((c) => c.field_name) || []),
            ];
            if (fieldNamesToMigrate.length > 0) {
                await this.migrateRawFragmentsToObservations({
                    entity_type: options.entity_type,
                    field_names: fieldNamesToMigrate,
                    // migrate_user_id takes precedence: for global schemas the caller passes
                    // the authenticated user's id here while options.user_id is undefined.
                    user_id: options.migrate_user_id ?? options.user_id,
                });
            }
        }
        return newSchema;
    }
    /**
     * Migrate raw_fragments to observations for updated schema
     * This is for backfilling historical data only - new data automatically uses the updated schema
     */
    async migrateRawFragmentsToObservations(options) {
        const BATCH_SIZE = 100; // Smaller batch size for safety
        let totalMigrated = 0;
        logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Starting migration for fields: ${options.field_names.join(", ")}`);
        // For each field, get raw_fragments and create observations
        for (const fieldName of options.field_names) {
            let offset = 0;
            let hasMore = true;
            while (hasMore) {
                // Get batch of raw_fragments
                let query = db
                    .from("raw_fragments")
                    .select("*")
                    .eq("entity_type", options.entity_type)
                    .eq("fragment_key", fieldName);
                // Handle user_id properly: check both default UUID and null
                // NOTE: .or() must be called before .range()
                const defaultUserId = "00000000-0000-0000-0000-000000000000";
                if (options.user_id) {
                    if (options.user_id === defaultUserId) {
                        query = query.or(`user_id.eq.${defaultUserId},user_id.is.null`);
                    }
                    else {
                        query = query.eq("user_id", options.user_id);
                    }
                }
                else {
                    query = query.or(`user_id.is.null,user_id.eq.${defaultUserId}`);
                }
                // Apply range after or() filter
                query = query.range(offset, offset + BATCH_SIZE - 1);
                const { data: fragments, error: fetchError } = await query;
                if (fetchError || !fragments || fragments.length === 0) {
                    hasMore = false;
                    continue; // No more fragments to migrate
                }
                logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Processing batch of ${fragments.length} fragments for field ${fieldName}`);
                // Group fragments by (source_id, interpretation_id) to find entities
                // Fragments from the same source+interpretation belong to the same entity
                const fragmentGroups = new Map();
                for (const fragment of fragments) {
                    const groupKey = `${fragment.source_id || "null"}:${fragment.interpretation_id || "null"}`;
                    if (!fragmentGroups.has(groupKey)) {
                        fragmentGroups.set(groupKey, []);
                    }
                    fragmentGroups.get(groupKey).push(fragment);
                }
                // Process each group (represents one entity)
                // eslint-disable-next-line @typescript-eslint/no-unused-vars
                for (const [_groupKey, groupFragments] of fragmentGroups.entries()) {
                    if (groupFragments.length === 0)
                        continue;
                    const firstFragment = groupFragments[0];
                    const sourceId = firstFragment.source_id;
                    const interpretationId = firstFragment.interpretation_id;
                    // Prefer explicit fragment ownership. Legacy fragments only carry
                    // source_id + entity_type, which is ambiguous when one source creates
                    // multiple entities of the same type.
                    let entityId = typeof firstFragment.entity_id === "string" ? firstFragment.entity_id : null;
                    if (!entityId && sourceId) {
                        let obsQuery = db
                            .from("observations")
                            .select("entity_id")
                            .eq("source_id", sourceId)
                            .eq("entity_type", options.entity_type);
                        // If interpretation_id exists, also match on it; otherwise match any interpretation_id
                        if (interpretationId) {
                            obsQuery = obsQuery.eq("interpretation_id", interpretationId);
                        }
                        else {
                            // For null interpretation_id, match observations with null interpretation_id
                            obsQuery = obsQuery.is("interpretation_id", null);
                        }
                        const { data: existingObs } = await obsQuery;
                        const entityIds = new Set((existingObs || [])
                            .map((obs) => obs.entity_id)
                            .filter((id) => typeof id === "string"));
                        if (entityIds.size === 1) {
                            entityId = Array.from(entityIds)[0];
                        }
                    }
                    if (!entityId) {
                        // No existing observation found - skip this group
                        // This can happen if fragments were created but observations weren't
                        console.warn(`[SCHEMA_REGISTRY] No entity found for source ${sourceId}, interpretation ${interpretationId}, skipping migration`);
                        continue;
                    }
                    // Load current schema to get version
                    const currentSchema = await this.loadActiveSchema(options.entity_type, options.user_id);
                    if (!currentSchema) {
                        console.error(`[SCHEMA_REGISTRY] No active schema found for ${options.entity_type}`);
                        continue;
                    }
                    // Collect all promoted fields for this entity from this group
                    const promotedFields = {};
                    for (const fragment of groupFragments) {
                        if (options.field_names.includes(fragment.fragment_key)) {
                            promotedFields[fragment.fragment_key] = fragment.fragment_value;
                        }
                    }
                    if (Object.keys(promotedFields).length === 0) {
                        continue; // No fields to migrate for this entity
                    }
                    try {
                        // Create new observation with promoted fields
                        // Use the same source_id and interpretation_id for provenance
                        const observedAt = new Date().toISOString();
                        const { error: obsError } = await db.from("observations").insert({
                            entity_id: entityId,
                            entity_type: options.entity_type,
                            schema_version: currentSchema.schema_version,
                            source_id: sourceId,
                            interpretation_id: interpretationId,
                            observed_at: observedAt,
                            specificity_score: 0.8, // Medium specificity for migrated fields
                            source_priority: 0, // Same priority as original interpretation
                            fields: promotedFields,
                            user_id: firstFragment.user_id,
                        });
                        if (obsError) {
                            // Check if it's a duplicate (idempotence) - that's okay
                            if (obsError.code !== "23505") {
                                console.error(`[SCHEMA_REGISTRY] Failed to create observation for entity ${entityId}:`, obsError.message);
                                continue;
                            }
                        }
                        else {
                            totalMigrated += Object.keys(promotedFields).length;
                            logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Migrated ${Object.keys(promotedFields).length} fields for entity ${entityId}`);
                            // Recompute snapshot to include migrated fields
                            try {
                                const { observationReducer } = await import("../reducers/observation_reducer.js");
                                const { data: allObservations } = await db
                                    .from("observations")
                                    .select("*")
                                    .eq("entity_id", entityId)
                                    .order("observed_at", { ascending: false });
                                if (allObservations && allObservations.length > 0) {
                                    const snapshot = await observationReducer.computeSnapshot(entityId, allObservations);
                                    if (!snapshot) {
                                        console.warn(`[SCHEMA_REGISTRY] computeSnapshot returned null for entity ${entityId}`);
                                        continue;
                                    }
                                    const rowWithEmbedding = await prepareEntitySnapshotWithEmbedding({
                                        entity_id: snapshot.entity_id,
                                        entity_type: snapshot.entity_type,
                                        schema_version: snapshot.schema_version,
                                        snapshot: snapshot.snapshot,
                                        computed_at: snapshot.computed_at,
                                        observation_count: snapshot.observation_count,
                                        last_observation_at: snapshot.last_observation_at,
                                        provenance: snapshot.provenance,
                                        user_id: snapshot.user_id,
                                    });
                                    await upsertEntitySnapshotWithEmbedding(rowWithEmbedding);
                                }
                            }
                            catch (snapshotError) {
                                console.warn(`[SCHEMA_REGISTRY] Failed to recompute snapshot for entity ${entityId}:`, snapshotError.message);
                                // Continue - snapshot will be recomputed on next observation creation
                            }
                        }
                    }
                    catch (error) {
                        console.error(`[SCHEMA_REGISTRY] Failed to migrate fields for entity ${entityId}:`, error.message);
                        // Continue with next group - don't fail entire migration
                    }
                }
                offset += BATCH_SIZE;
                // Safety check - don't migrate more than 10,000 fragments at once
                if (totalMigrated >= 10000) {
                    console.warn(`[SCHEMA_REGISTRY] Migration limit reached (10,000 fragments), stopping`);
                    break;
                }
            }
        }
        logSchemaRegistryInfo(`[SCHEMA_REGISTRY] Migration complete. Total fragments processed: ${totalMigrated}`);
        return { migrated_count: totalMigrated };
    }
    /**
     * Determine change type for schema update
     *
     * Breaking changes (major):
     * - Removing fields
     * - Changing field types (not just adding converters)
     * - Changing field from optional to required
     * - Removing converters
     *
     * Minor changes (minor):
     * - Adding new optional fields
     * - Adding converters to existing fields
     * - Changing reducer strategies
     *
     * Patch changes (patch):
     * - Documentation updates
     * - Non-functional changes
     */
    determineChangeType(options) {
        // Breaking changes (major version)
        if (options.fields_to_remove && options.fields_to_remove.length > 0) {
            return "major";
        }
        if (options.fields_to_modify && options.fields_to_modify.length > 0) {
            for (const mod of options.fields_to_modify) {
                // Changing field type is breaking
                if (mod.old_type && mod.new_type && mod.old_type !== mod.new_type) {
                    return "major";
                }
                // Changing from optional to required is breaking
                if (mod.old_required === false && mod.new_required === true) {
                    return "major";
                }
            }
        }
        // Minor changes (additive, backward compatible)
        if ((options.fields_to_add && options.fields_to_add.length > 0) ||
            (options.converters_to_add && options.converters_to_add.length > 0)) {
            return "minor";
        }
        // Patch changes (no functional changes)
        return "patch";
    }
    /**
     * Increment schema version using semantic versioning (major.minor.patch)
     *
     * - Major: Breaking changes (removing fields, changing types, making fields required)
     * - Minor: Additive changes (new fields, new converters) - backward compatible
     * - Patch: Non-functional changes (documentation, formatting)
     *
     * Examples:
     * - 1.0.0 -> 1.1.0 (add new optional field)
     * - 1.1.0 -> 1.1.1 (patch change)
     * - 1.1.1 -> 2.0.0 (breaking change - remove field)
     */
    incrementVersion(currentVersion, changeType = "minor") {
        // Parse version (support both "1.0" and "1.0.0" formats)
        const parts = currentVersion.split(".");
        const major = parseInt(parts[0] || "1", 10);
        const minor = parseInt(parts[1] || "0", 10);
        const patch = parseInt(parts[2] || "0", 10);
        switch (changeType) {
            case "major":
                // Breaking change: increment major, reset minor and patch
                return `${major + 1}.0.0`;
            case "minor":
                // Additive change: increment minor, reset patch
                return `${major}.${minor + 1}.0`;
            case "patch":
                // Non-functional change: increment patch
                return `${major}.${minor}.${patch + 1}`;
            default:
                // Default to minor for backward compatibility
                return `${major}.${minor + 1}.0`;
        }
    }
    /**
     * Activate schema version
     * Supports user-specific schemas: deactivates other versions for same entity_type and user_id/scope
     */
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    async activate(entityType, version, _userId) {
        // Load the schema to get its scope and user_id
        const { data: schema } = await db
            .from("schema_registry")
            .select("scope, user_id")
            .eq("entity_type", entityType)
            .eq("schema_version", version)
            .single();
        if (!schema) {
            throw new Error(`Schema not found: ${entityType} version ${version}`);
        }
        const scope = schema.scope || "global";
        const schemaUserId = schema.user_id;
        // Deactivate all other versions for this entity type and scope/user_id
        let deactivateQuery = db
            .from("schema_registry")
            .update({ active: false })
            .eq("entity_type", entityType)
            .eq("active", true);
        if (scope === "user" && schemaUserId) {
            deactivateQuery = deactivateQuery.eq("user_id", schemaUserId);
        }
        else {
            deactivateQuery = deactivateQuery.eq("scope", "global").is("user_id", null);
        }
        await deactivateQuery;
        // Activate specified version
        const { error } = await db
            .from("schema_registry")
            .update({ active: true })
            .eq("entity_type", entityType)
            .eq("schema_version", version);
        if (error) {
            throw new Error(`Failed to activate schema: ${error.message}`);
        }
        // Automatically export schema snapshots (non-blocking)
        this.exportSnapshotsAsync();
    }
    /**
     * Deactivate schema version
     */
    async deactivate(entityType, version) {
        const { error } = await db
            .from("schema_registry")
            .update({ active: false })
            .eq("entity_type", entityType)
            .eq("schema_version", version);
        if (error) {
            throw new Error(`Failed to deactivate schema: ${error.message}`);
        }
        // Automatically export schema snapshots (non-blocking)
        this.exportSnapshotsAsync();
    }
    /**
     * Get all entity schema versions for entity type
     */
    async getSchemaVersions(entityType) {
        const { data, error } = await db
            .from("schema_registry")
            .select("*")
            .eq("entity_type", entityType)
            .order("created_at", { ascending: false });
        if (error) {
            throw new Error(`Failed to get schema versions: ${error.message}`);
        }
        return (data || []);
    }
    /**
     * Generate searchable text for an entity schema (for embedding generation)
     */
    generateSearchableText(schema) {
        const parts = [schema.entity_type];
        // Add field names
        const fieldNames = Object.keys(schema.schema_definition.fields || {});
        parts.push(...fieldNames);
        // Add field types for context (e.g., "date", "amount", "name")
        for (const [fieldName, fieldDef] of Object.entries(schema.schema_definition.fields || {})) {
            if (fieldDef.type === "date" && fieldName.includes("date")) {
                parts.push("date", "time", "timestamp");
            }
            if (fieldDef.type === "number" &&
                (fieldName.includes("amount") || fieldName.includes("price") || fieldName.includes("cost"))) {
                parts.push("amount", "price", "cost", "money", "financial");
            }
            if (fieldName.includes("name") || fieldName.includes("title")) {
                parts.push("name", "title", "label");
            }
        }
        return parts.join(" ");
    }
    /**
     * Calculate cosine similarity between two vectors
     */
    cosineSimilarity(a, b) {
        if (a.length !== b.length)
            return 0;
        let dotProduct = 0;
        let normA = 0;
        let normB = 0;
        for (let i = 0; i < a.length; i++) {
            dotProduct += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        if (normA === 0 || normB === 0)
            return 0;
        return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    }
    /**
     * List all active entity types, optionally filtered by keyword with vector search fallback
     */
    async listEntityTypes(keyword) {
        // Get all active schemas from database
        const query = db
            .from("schema_registry")
            .select("entity_type, schema_version, schema_definition")
            .eq("active", true);
        const { data: dbSchemas } = await query;
        // Fallback to code-defined schemas if database is empty or error
        const { ENTITY_SCHEMAS } = await import("./schema_definitions.js");
        const allSchemas = new Map();
        if (dbSchemas && dbSchemas.length > 0) {
            for (const schema of dbSchemas) {
                allSchemas.set(schema.entity_type, schema);
            }
        }
        else {
            // Use code-defined schemas as fallback
            for (const [entityType, schema] of Object.entries(ENTITY_SCHEMAS)) {
                allSchemas.set(entityType, {
                    id: "",
                    entity_type: schema.entity_type,
                    schema_version: schema.schema_version,
                    schema_definition: schema.schema_definition,
                    reducer_config: schema.reducer_config,
                    active: true,
                    created_at: new Date().toISOString(),
                });
            }
        }
        const allSchemasArray = Array.from(allSchemas.values());
        // If no keyword, return all
        if (!keyword) {
            return allSchemasArray.map((schema) => {
                const fieldNames = Object.keys(schema.schema_definition.fields || {});
                const fieldSummary = {};
                for (const [fieldName, fieldDef] of Object.entries(schema.schema_definition.fields || {})) {
                    fieldSummary[fieldName] = {
                        type: fieldDef.type,
                        required: fieldDef.required || false,
                    };
                }
                return {
                    entity_type: schema.entity_type,
                    schema_version: schema.schema_version,
                    field_names: fieldNames,
                    field_summary: fieldSummary,
                };
            });
        }
        const keywordLower = keyword.toLowerCase();
        // Step 1: Try keyword matching first (deterministic, strong consistency)
        const keywordMatches = [];
        for (const schema of allSchemasArray) {
            let score = 0;
            // Exact entity type match (highest score)
            if (schema.entity_type.toLowerCase() === keywordLower) {
                score = 10;
            }
            // Entity type contains keyword
            else if (schema.entity_type.toLowerCase().includes(keywordLower)) {
                score = 5;
            }
            // Field name matches
            const fieldNames = Object.keys(schema.schema_definition.fields || {});
            for (const fieldName of fieldNames) {
                if (fieldName.toLowerCase() === keywordLower) {
                    score += 3;
                }
                else if (fieldName.toLowerCase().includes(keywordLower)) {
                    score += 1;
                }
            }
            if (score > 0) {
                keywordMatches.push({ schema, score });
            }
        }
        // If we have good keyword matches (score >= 3), return those
        const goodKeywordMatches = keywordMatches.filter((m) => m.score >= 3);
        if (goodKeywordMatches.length > 0) {
            return goodKeywordMatches
                .sort((a, b) => b.score - a.score)
                .map(({ schema, score }) => {
                const fieldNames = Object.keys(schema.schema_definition.fields || {});
                const fieldSummary = {};
                for (const [fieldName, fieldDef] of Object.entries(schema.schema_definition.fields || {})) {
                    fieldSummary[fieldName] = {
                        type: fieldDef.type,
                        required: fieldDef.required || false,
                    };
                }
                return {
                    entity_type: schema.entity_type,
                    schema_version: schema.schema_version,
                    field_names: fieldNames,
                    field_summary: fieldSummary,
                    similarity_score: score / 10, // Normalize to 0-1 range
                    match_type: "keyword",
                };
            });
        }
        // Step 2: Fallback to vector search (semantic matching, bounded eventual consistency)
        const { generateEmbedding } = await import("../embeddings.js");
        const queryEmbedding = await generateEmbedding(keyword);
        if (!queryEmbedding) {
            // If embeddings not available, return keyword matches even if low score
            return keywordMatches
                .sort((a, b) => b.score - a.score)
                .map(({ schema, score }) => {
                const fieldNames = Object.keys(schema.schema_definition.fields || {});
                const fieldSummary = {};
                for (const [fieldName, fieldDef] of Object.entries(schema.schema_definition.fields || {})) {
                    fieldSummary[fieldName] = {
                        type: fieldDef.type,
                        required: fieldDef.required || false,
                    };
                }
                return {
                    entity_type: schema.entity_type,
                    schema_version: schema.schema_version,
                    field_names: fieldNames,
                    field_summary: fieldSummary,
                    similarity_score: score / 10,
                    match_type: "keyword",
                };
            });
        }
        // Generate embeddings for all schemas and calculate similarity
        const schemaEmbeddings = [];
        for (const schema of allSchemasArray) {
            const searchableText = this.generateSearchableText(schema);
            const schemaEmbedding = await generateEmbedding(searchableText);
            if (schemaEmbedding) {
                const similarity = this.cosineSimilarity(queryEmbedding, schemaEmbedding);
                schemaEmbeddings.push({ schema, embedding: schemaEmbedding, similarity });
            }
        }
        // Sort by similarity and return top matches
        const vectorMatches = schemaEmbeddings
            .filter((item) => item.similarity > 0.3) // Threshold for relevance
            .sort((a, b) => b.similarity - a.similarity)
            .slice(0, 20) // Limit results
            .map(({ schema, similarity }) => {
            const fieldNames = Object.keys(schema.schema_definition.fields || {});
            const fieldSummary = {};
            for (const [fieldName, fieldDef] of Object.entries(schema.schema_definition.fields || {})) {
                fieldSummary[fieldName] = {
                    type: fieldDef.type,
                    required: fieldDef.required || false,
                };
            }
            return {
                entity_type: schema.entity_type,
                schema_version: schema.schema_version,
                field_names: fieldNames,
                field_summary: fieldSummary,
                similarity_score: similarity,
                match_type: "vector",
            };
        });
        // If vector search found results, return them
        if (vectorMatches.length > 0) {
            return vectorMatches;
        }
        // Final fallback: return keyword matches even if low score
        return keywordMatches
            .sort((a, b) => b.score - a.score)
            .map(({ schema, score }) => {
            const fieldNames = Object.keys(schema.schema_definition.fields || {});
            const fieldSummary = {};
            for (const [fieldName, fieldDef] of Object.entries(schema.schema_definition.fields || {})) {
                fieldSummary[fieldName] = {
                    type: fieldDef.type,
                    required: fieldDef.required || false,
                };
            }
            return {
                entity_type: schema.entity_type,
                schema_version: schema.schema_version,
                field_names: fieldNames,
                field_summary: fieldSummary,
                similarity_score: score / 10,
                match_type: "keyword",
            };
        });
    }
    /**
     * Validate schema definition
     */
    validateSchemaDefinition(definition) {
        if (!definition.fields || typeof definition.fields !== "object") {
            throw new Error("Schema definition must have fields object");
        }
        // R2: every schema must either declare `canonical_name_fields` or opt
        // out explicitly via `identity_opt_out`. This moves the failure from
        // first-write runtime ("why did this entity collapse into a
        // heuristic-named duplicate?") to schema-registration time, where it
        // is visible before any data is written.
        if (definition.canonical_name_fields === undefined &&
            definition.identity_opt_out === undefined) {
            throw new Error("Schema definition must declare `canonical_name_fields` OR " +
                '`identity_opt_out: "heuristic_canonical_name"`. See ' +
                "docs/foundation/schema_agnostic_design_rules.md (R2).");
        }
        if (definition.identity_opt_out !== undefined) {
            if (typeof definition.identity_opt_out !== "string") {
                throw new Error('identity_opt_out must be a string token (e.g. "heuristic_canonical_name")');
            }
            if (!VALID_IDENTITY_OPT_OUT_TOKENS.has(definition.identity_opt_out)) {
                throw new Error(`identity_opt_out has unknown value: ${definition.identity_opt_out}. ` +
                    `Allowed: ${Array.from(VALID_IDENTITY_OPT_OUT_TOKENS).join(", ")}`);
            }
        }
        if (definition.name_collision_policy !== undefined) {
            const policy = definition.name_collision_policy;
            if (policy !== "merge" && policy !== "warn" && policy !== "reject") {
                throw new Error(`name_collision_policy has unknown value: ${String(policy)}. ` +
                    "Allowed: merge | warn | reject.");
            }
            if (policy !== "merge" && definition.identity_opt_out !== undefined) {
                throw new Error('name_collision_policy cannot be set to "warn" or "reject" on a ' +
                    "schema that also declares identity_opt_out. Either declare " +
                    "canonical_name_fields so identity can be matched deterministically, " +
                    'or keep the default policy ("merge").');
            }
            if (policy === "reject" && definition.canonical_name_fields === undefined) {
                throw new Error('name_collision_policy: "reject" requires a reachable ' +
                    "canonical_name_fields declaration so callers have a deterministic " +
                    "path to resolve identity. See docs/foundation/schema_agnostic_design_rules.md (R2).");
            }
        }
        if (definition.canonical_name_fields !== undefined) {
            if (!Array.isArray(definition.canonical_name_fields)) {
                throw new Error("canonical_name_fields must be an array of field names or { composite: string[] } rules");
            }
            for (const entry of definition.canonical_name_fields) {
                if (typeof entry === "string") {
                    if (!entry.trim()) {
                        throw new Error("canonical_name_fields entries must be non-empty strings");
                    }
                    if (!definition.fields[entry]) {
                        throw new Error(`canonical_name_fields references unknown field: ${entry}`);
                    }
                    continue;
                }
                if (!entry ||
                    typeof entry !== "object" ||
                    !Array.isArray(entry.composite)) {
                    throw new Error("canonical_name_fields entries must be strings or { composite: string[] } objects");
                }
                const compositeFields = entry.composite;
                if (compositeFields.length === 0) {
                    throw new Error("canonical_name_fields composite rule must declare at least one field");
                }
                for (const name of compositeFields) {
                    if (typeof name !== "string" || !name.trim()) {
                        throw new Error("canonical_name_fields composite fields must be non-empty strings");
                    }
                    if (!definition.fields[name]) {
                        throw new Error(`canonical_name_fields references unknown field: ${name}`);
                    }
                }
            }
        }
        if (definition.temporal_fields !== undefined) {
            if (!Array.isArray(definition.temporal_fields)) {
                throw new Error("temporal_fields must be an array");
            }
            for (const entry of definition.temporal_fields) {
                if (!entry || typeof entry !== "object" || typeof entry.field !== "string") {
                    throw new Error("temporal_fields entries must be { field, event_type? }");
                }
                if (!definition.fields[entry.field]) {
                    throw new Error(`temporal_fields references unknown field: ${entry.field}`);
                }
            }
        }
        if (definition.reference_fields !== undefined) {
            if (!Array.isArray(definition.reference_fields)) {
                throw new Error("reference_fields must be an array");
            }
            for (const entry of definition.reference_fields) {
                if (!entry ||
                    typeof entry !== "object" ||
                    typeof entry.field !== "string" ||
                    typeof entry.target_entity_type !== "string") {
                    throw new Error("reference_fields entries must be { field, target_entity_type, relationship_type? }");
                }
                if (!definition.fields[entry.field]) {
                    throw new Error(`reference_fields references unknown field: ${entry.field}`);
                }
            }
        }
        if (definition.aliases !== undefined) {
            if (!Array.isArray(definition.aliases)) {
                throw new Error("aliases must be an array of strings");
            }
            for (const alias of definition.aliases) {
                if (typeof alias !== "string" || !alias.trim()) {
                    throw new Error("aliases entries must be non-empty strings");
                }
            }
        }
        const validTypes = ["string", "number", "date", "boolean", "array", "object"];
        for (const [fieldName, fieldDef] of Object.entries(definition.fields)) {
            if (!validTypes.includes(fieldDef.type)) {
                throw new Error(`Invalid field type for ${fieldName}: ${fieldDef.type}`);
            }
            // Validate converters if defined
            if (fieldDef.converters) {
                if (!Array.isArray(fieldDef.converters)) {
                    throw new Error(`Converters for ${fieldName} must be an array`);
                }
                for (const converter of fieldDef.converters) {
                    // Validate required properties
                    if (!converter.from || !converter.to || !converter.function) {
                        throw new Error(`Converter for ${fieldName} must have from, to, and function properties`);
                    }
                    // Validate deterministic property
                    if (converter.deterministic !== true) {
                        throw new Error(`Converter for ${fieldName} must be deterministic (deterministic: true)`);
                    }
                    // Validate from/to types
                    if (!validTypes.includes(converter.from)) {
                        throw new Error(`Invalid converter from type for ${fieldName}: ${converter.from}`);
                    }
                    if (!validTypes.includes(converter.to)) {
                        throw new Error(`Invalid converter to type for ${fieldName}: ${converter.to}`);
                    }
                    // Validate that converter 'to' type matches field type
                    if (converter.to !== fieldDef.type) {
                        throw new Error(`Converter for ${fieldName} must convert to field type ${fieldDef.type}, not ${converter.to}`);
                    }
                    // Validate converter function name format (basic check)
                    if (typeof converter.function !== "string" || converter.function.length === 0) {
                        throw new Error(`Converter function name for ${fieldName} must be a non-empty string`);
                    }
                }
            }
        }
        if (definition.agent_instructions !== undefined) {
            if (typeof definition.agent_instructions !== "string" ||
                definition.agent_instructions.trim().length === 0) {
                throw new Error("agent_instructions must be a non-empty string when present");
            }
        }
    }
    /**
     * Validate reducer config
     */
    validateReducerConfig(config, schemaDefinition) {
        if (!config.merge_policies || typeof config.merge_policies !== "object") {
            throw new Error("Reducer config must have merge_policies object");
        }
        const validStrategies = ["last_write", "highest_priority", "most_specific", "merge_array"];
        const validTieBreakers = ["observed_at", "source_priority"];
        for (const [fieldName, policy] of Object.entries(config.merge_policies)) {
            if (!schemaDefinition.fields[fieldName]) {
                throw new Error(`Merge policy references unknown field: ${fieldName}`);
            }
            if (!validStrategies.includes(policy.strategy)) {
                throw new Error(`Invalid merge strategy for ${fieldName}: ${policy.strategy}`);
            }
            if (policy.tie_breaker && !validTieBreakers.includes(policy.tie_breaker)) {
                throw new Error(`Invalid tie breaker for ${fieldName}: ${policy.tie_breaker}`);
            }
        }
        if (config.observation_source_priority !== undefined) {
            if (!Array.isArray(config.observation_source_priority)) {
                throw new Error("observation_source_priority must be an array of observation_source values");
            }
            const seen = new Set();
            for (const value of config.observation_source_priority) {
                if (!OBSERVATION_SOURCE_RANK_VALUES.includes(value)) {
                    throw new Error(`Invalid observation_source in observation_source_priority: ${value}`);
                }
                if (seen.has(value)) {
                    throw new Error(`Duplicate observation_source in observation_source_priority: ${value}`);
                }
                seen.add(value);
            }
        }
    }
    /**
     * Update icon metadata for a schema
     */
    async updateIconMetadata(entityType, iconMetadata, userId) {
        // Find the active schema for this entity type
        const schema = await this.loadActiveSchema(entityType, userId);
        if (!schema) {
            console.warn(`[SCHEMA_REGISTRY] No active schema found for ${entityType}, cannot update icon`);
            return;
        }
        // Update metadata with icon
        const updatedMetadata = {
            ...(schema.metadata || {}),
            icon: iconMetadata,
        };
        const { error } = await db
            .from("schema_registry")
            .update({ metadata: updatedMetadata })
            .eq("id", schema.id);
        if (error) {
            console.error(`[SCHEMA_REGISTRY] Failed to update icon metadata for ${entityType}:`, error);
        }
    }
    /**
     * Merge partial metadata fields into the active schema row for an entity type.
     * Preserves existing metadata keys that are not in the patch.
     */
    async updateMetadata(entityType, patch, userId) {
        const schema = await this.loadActiveSchema(entityType, userId);
        if (!schema) {
            throw new Error(`No active schema found for entity type "${entityType}"; cannot update metadata.`);
        }
        const updatedMetadata = {
            ...(schema.metadata || {}),
            ...patch,
        };
        const { error } = await db
            .from("schema_registry")
            .update({ metadata: updatedMetadata })
            .eq("id", schema.id);
        if (error) {
            throw new Error(`Failed to update metadata for ${entityType}: ${error.message}`);
        }
    }
    /**
     * Generate icon for a schema asynchronously (non-blocking)
     */
    async generateIconAsync(entityType, metadata) {
        // Only generate if icon doesn't already exist
        if (metadata?.icon) {
            return;
        }
        try {
            // Import icon service dynamically to avoid circular dependencies
            const { generateIconForEntityType } = await import("./schema_icon_service.js");
            // Generate icon
            const iconMetadata = await generateIconForEntityType(entityType, metadata);
            // Update schema with icon
            await this.updateIconMetadata(entityType, iconMetadata);
        }
        catch (error) {
            // Don't fail schema registration if icon generation fails
            console.warn(`[SCHEMA_REGISTRY] Failed to generate icon for ${entityType}:`, error);
        }
    }
    /**
     * Export schema snapshots asynchronously (non-blocking)
     * Automatically exports snapshots after schema changes without blocking operations
     */
    exportSnapshotsAsync() {
        // Only export if we're in a Node.js environment (not browser)
        if (typeof process === "undefined" || !process.env) {
            return;
        }
        // Skip export in test environments to avoid side effects
        if (process.env.NODE_ENV === "test" || process.env.VITEST) {
            return;
        }
        // Run export script asynchronously (fire-and-forget)
        const scriptPath = join(__dirname, "../../scripts/export_schema_snapshots.ts");
        const child = spawn("tsx", [scriptPath], {
            stdio: "ignore", // Suppress output to avoid cluttering logs
            detached: true,
            env: { ...process.env, NODE_ENV: process.env.NODE_ENV || "development" },
        });
        // Don't wait for completion - let it run in background
        child.unref();
        // Handle errors silently (don't fail schema operations if export fails)
        child.on("error", (error) => {
            // Silently ignore export errors - schema operations should not fail
            // if snapshot export fails (e.g., in CI/CD or restricted environments)
            if (process.env.NODE_ENV === "development") {
                console.warn(`[Schema Registry] Failed to export snapshots: ${error.message}`);
            }
        });
    }
}
export const schemaRegistry = new SchemaRegistryService();
//# sourceMappingURL=schema_registry.js.map