/**
 * Canonical Markdown Mirror Subsystem
 *
 * Filesystem mirror covering the first-class Neotoma data objects:
 *   - entities (one .md per entity)
 *   - relationships (one .md per canonical key)
 *   - sources (one .md per source metadata)
 *   - timeline events (one consolidated .md per day)
 *   - schemas (one .md per entity_type)
 *
 * SQLite remains the sole source of truth. The mirror is a derived,
 * read-only artifact regenerated on write:
 *   - Writes go through `mirrorEntity`, `mirrorRelationship`, etc. which are
 *     guarded by the `mirror.enabled` config and the per-kind `mirror.kinds`
 *     list. Disabled → no-ops.
 *   - Deletes are handled via `removeMirrorEntity`, `removeMirrorRelationship`,
 *     `removeMirrorSource` (sources never bring the raw blob out of SQLite).
 *   - `rebuildMirror(options)` reads from SQLite and regenerates targeted
 *     scope from scratch. `--clean` removes stale files within scope.
 *
 * Determinism:
 *   - Content-hash no-op: if rendered output matches current file content,
 *     the write is skipped. Avoids mtime/git churn.
 *   - Slugs derive from canonical_name with a short id-hash suffix on collision.
 *   - Index pages are regenerated from the DB, not from cumulative state,
 *     so re-runs are stable.
 *
 * CLI surface lives in `src/cli/commands/mirror.ts`.
 */
import { createHash } from "node:crypto";
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { db } from "../db.js";
import { config } from "../config.js";
import { schemaRegistry } from "./schema_registry.js";
import { getEntityDisplayName } from "../shared/entity_display_name.js";
import { getRecordDisplaySummary } from "../shared/record_display_summary.js";
import { renderEntityMarkdown, renderRelationshipMarkdown, renderSourceMarkdown, renderTimelineDayMarkdown, renderSchemaMarkdown, renderIndexMarkdown, } from "./canonical_markdown.js";
export const ALL_MIRROR_KINDS = [
    "entities",
    "relationships",
    "sources",
    "timeline",
    "schemas",
];
function readUserConfig() {
    const home = process.env.HOME || process.env.USERPROFILE;
    if (!home)
        return null;
    const configPath = path.join(home, ".config", "neotoma", "config.json");
    try {
        const raw = fsSync.readFileSync(configPath, "utf8");
        return JSON.parse(raw);
    }
    catch {
        return null;
    }
}
function writeUserConfig(patch) {
    const home = process.env.HOME || process.env.USERPROFILE;
    if (!home)
        return;
    const dir = path.join(home, ".config", "neotoma");
    const configPath = path.join(dir, "config.json");
    fsSync.mkdirSync(dir, { recursive: true });
    let current = {};
    try {
        current = JSON.parse(fsSync.readFileSync(configPath, "utf8"));
    }
    catch {
        current = {};
    }
    const merged = { ...current, ...patch };
    fsSync.writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
}
/**
 * Effective mirror config. Priority (low → high):
 *   1. Defaults (disabled, dataDir/mirror, all kinds).
 *   2. `~/.config/neotoma/config.json` → `mirror` key.
 *   3. Environment variables:
 *      - `NEOTOMA_MIRROR_ENABLED=true`
 *      - `NEOTOMA_MIRROR_PATH=/path/to/mirror`
 *      - `NEOTOMA_MIRROR_KINDS=entities,relationships`
 *      - `NEOTOMA_MIRROR_GIT_ENABLED=true`
 *      - `NEOTOMA_MIRROR_MEMORY_EXPORT_ENABLED=true`
 *      - `NEOTOMA_MIRROR_MEMORY_EXPORT_PATH=MEMORY.md`
 *      - `NEOTOMA_MIRROR_MEMORY_EXPORT_LIMIT_LINES=200`
 */
export function getMirrorConfig() {
    const defaults = {
        enabled: false,
        path: path.join(config.dataDir, "mirror"),
        kinds: [...ALL_MIRROR_KINDS],
        git_enabled: false,
        memory_export: {
            enabled: false,
            path: "MEMORY.md",
            limit_lines: 200,
        },
        profiles: [
            {
                id: "neotoma-plans",
                entity_type: "plan",
                filter: {},
                output_path: "docs/plans",
                exclude_fields: ["user_id", "repository_root"],
                filename_template: "{title}.md",
                index_enabled: true,
                allow_git_commit: true,
                render_mode: "frontmatter_content",
                content_field: "body",
                // Compact metadata in frontmatter; content fields (overview, scope, etc.) render as ## sections in the body
                frontmatter_fields: [
                    "title",
                    "status",
                    "priority",
                    "phase",
                    "target_release",
                    "repository",
                    "file_path",
                    "tags",
                    "todos_completed",
                    "todos_pending",
                ],
            },
        ],
    };
    const user = readUserConfig()?.mirror;
    const merged = {
        enabled: user?.enabled ?? defaults.enabled,
        path: user?.path ?? defaults.path,
        kinds: user?.kinds ?? defaults.kinds,
        git_enabled: user?.git_enabled ?? defaults.git_enabled,
        memory_export: {
            enabled: user?.memory_export?.enabled ?? defaults.memory_export.enabled,
            path: user?.memory_export?.path ?? defaults.memory_export.path,
            limit_lines: user?.memory_export?.limit_lines ?? defaults.memory_export.limit_lines,
        },
        profiles: user?.profiles ?? defaults.profiles,
    };
    if (process.env.NEOTOMA_MIRROR_ENABLED !== undefined) {
        merged.enabled = process.env.NEOTOMA_MIRROR_ENABLED.toLowerCase() === "true";
    }
    if (process.env.NEOTOMA_MIRROR_PATH)
        merged.path = process.env.NEOTOMA_MIRROR_PATH;
    if (process.env.NEOTOMA_MIRROR_KINDS) {
        merged.kinds = process.env.NEOTOMA_MIRROR_KINDS.split(",")
            .map((s) => s.trim())
            .filter((k) => ALL_MIRROR_KINDS.includes(k));
    }
    if (process.env.NEOTOMA_MIRROR_GIT_ENABLED !== undefined) {
        merged.git_enabled = process.env.NEOTOMA_MIRROR_GIT_ENABLED.toLowerCase() === "true";
    }
    if (process.env.NEOTOMA_MIRROR_MEMORY_EXPORT_ENABLED !== undefined) {
        merged.memory_export.enabled =
            process.env.NEOTOMA_MIRROR_MEMORY_EXPORT_ENABLED.toLowerCase() === "true";
    }
    if (process.env.NEOTOMA_MIRROR_MEMORY_EXPORT_PATH) {
        merged.memory_export.path = process.env.NEOTOMA_MIRROR_MEMORY_EXPORT_PATH;
    }
    if (process.env.NEOTOMA_MIRROR_MEMORY_EXPORT_LIMIT_LINES) {
        const n = parseInt(process.env.NEOTOMA_MIRROR_MEMORY_EXPORT_LIMIT_LINES, 10);
        if (Number.isFinite(n) && n > 0)
            merged.memory_export.limit_lines = n;
    }
    return merged;
}
export function setMirrorConfig(patch) {
    const current = getMirrorConfig();
    const next = {
        ...current,
        ...patch,
        memory_export: {
            ...current.memory_export,
            ...(patch.memory_export ?? {}),
        },
        // Preserve existing profiles unless explicitly overridden.
        profiles: patch.profiles !== undefined ? patch.profiles : current.profiles,
    };
    writeUserConfig({ mirror: next });
    return next;
}
function kindEnabled(kind, cfg = getMirrorConfig()) {
    return cfg.enabled && cfg.kinds.includes(kind);
}
// ============================================================================
// Profile helpers
// ============================================================================
/**
 * Returns true if the entity's snapshot satisfies every entry in `profile.filter`.
 * A filter value of string means exact equality; an array means "one of".
 */
export function profileMatchesEntity(profile, entityType, snapshot) {
    if (profile.entity_type !== entityType)
        return false;
    for (const [field, expected] of Object.entries(profile.filter)) {
        const actual = snapshot[field];
        if (Array.isArray(expected)) {
            if (!expected.includes(actual))
                return false;
        }
        else {
            if (actual !== expected)
                return false;
        }
    }
    return true;
}
/**
 * Resolve the output file path for an entity under a profile.
 * Supports filename_template tokens: {slug}, {entity_id}, {canonical_name}.
 */
export function profileEntityFilePath(profile, entityId, canonicalName, snapshot) {
    // Truncate the slug to keep filenames within OS PATH_MAX limits.
    const rawSlug = entitySlug(entityId, canonicalName);
    const slug = rawSlug.length > 100 ? rawSlug.slice(0, 100) : rawSlug;
    const template = profile.filename_template ?? "{slug}.md";
    // Replace well-known tokens first, then any remaining {field} tokens from the snapshot.
    let filename = template
        .replace("{slug}", slug)
        .replace("{entity_id}", entityId)
        .replace("{canonical_name}", slugify(canonicalName ?? ""));
    if (snapshot) {
        filename = filename.replace(/\{(\w+)\}/g, (_match, field) => {
            const value = snapshot[field];
            if (value != null) {
                // Truncate long field values to keep filenames within OS limits.
                return slugify(String(value)).slice(0, 80);
            }
            // When the snapshot field is absent, fall back to the entity slug so that
            // entities without the templated field still get a unique, stable filename.
            return slug;
        });
    }
    // Ensure the filename ends with .md.
    if (!filename.endsWith(".md")) {
        filename = `${filename}.md`;
    }
    return path.join(profile.output_path, filename);
}
/**
 * Applies include_fields / exclude_fields from a profile to a snapshot copy.
 * Returns a new snapshot object with only the desired fields.
 */
export function applyProfileFieldFilter(profile, snapshot) {
    if (profile.include_fields && profile.include_fields.length > 0) {
        const filtered = {};
        for (const field of profile.include_fields) {
            if (field in snapshot)
                filtered[field] = snapshot[field];
        }
        return filtered;
    }
    if (profile.exclude_fields && profile.exclude_fields.length > 0) {
        const filtered = { ...snapshot };
        for (const field of profile.exclude_fields) {
            delete filtered[field];
        }
        return filtered;
    }
    return snapshot;
}
/**
 * Checks whether a given output_path appears to be inside a git repository.
 * Uses a simple heuristic: walk parent dirs looking for a `.git` entry.
 * Returns the git root if found, null otherwise.
 */
export function detectGitRoot(outputPath) {
    let dir = path.isAbsolute(outputPath) ? outputPath : path.resolve(outputPath);
    // Walk up to filesystem root.
    for (let i = 0; i < 64; i++) {
        const gitDir = path.join(dir, ".git");
        try {
            const stat = fsSync.statSync(gitDir);
            if (stat.isDirectory() || stat.isFile())
                return dir;
        }
        catch {
            // Not found at this level.
        }
        const parent = path.dirname(dir);
        if (parent === dir)
            break; // Reached root.
        dir = parent;
    }
    return null;
}
/**
 * Validates a profile's git safety constraint. Returns a warning string when
 * the profile's output_path is inside a git repo but allow_git_commit is not
 * set to true. Returns null when the profile is safe.
 */
export function checkProfileGitSafety(profile) {
    const gitRoot = detectGitRoot(profile.output_path);
    if (gitRoot && !profile.allow_git_commit) {
        return (`Profile "${profile.id}" writes to "${profile.output_path}" which is inside ` +
            `a git repository at "${gitRoot}". Set allow_git_commit: true on the profile ` +
            `to acknowledge that these files may be committed.`);
    }
    return null;
}
// ============================================================================
// Filesystem helpers
// ============================================================================
async function ensureDir(dir) {
    await fs.mkdir(dir, { recursive: true });
}
async function writeFileIfChanged(filePath, content) {
    await ensureDir(path.dirname(filePath));
    try {
        const existing = await fs.readFile(filePath, "utf8");
        if (existing === content)
            return "unchanged";
    }
    catch {
        // Not present — fall through to write.
    }
    await fs.writeFile(filePath, content, "utf8");
    return "written";
}
async function removeIfPresent(filePath) {
    try {
        await fs.unlink(filePath);
        return "removed";
    }
    catch (err) {
        const e = err;
        if (e.code === "ENOENT")
            return "absent";
        throw err;
    }
}
// Deterministic slug: slug-case canonical_name; short-hash disambiguator on collision.
function slugify(input) {
    const s = input
        .toLowerCase()
        .normalize("NFKD")
        .replace(/[\u0300-\u036f]/g, "")
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-+|-+$/g, "");
    return s || "untitled";
}
function shortHash(input) {
    return createHash("sha1").update(input).digest("hex").slice(0, 8);
}
/** Deterministic, URL-safe slug for an entity: `<canonical-slug>-<id-hash>`. */
export function entitySlug(entityId, canonicalName) {
    const base = slugify(canonicalName ?? "");
    const suffix = shortHash(entityId);
    return base ? `${base}-${suffix}` : suffix;
}
/** Deterministic, filesystem-safe slug for a relationship key. */
export function relationshipSlug(relationshipKey, sourceId, targetId, relationshipType) {
    const normalized = `${relationshipType}__${slugify(sourceId)}__${slugify(targetId)}`;
    const suffix = shortHash(relationshipKey);
    return `${normalized}-${suffix}`;
}
// ============================================================================
// Paths
// ============================================================================
export function mirrorPaths(cfg = getMirrorConfig()) {
    const root = cfg.path;
    return {
        root,
        entities: path.join(root, "entities"),
        relationships: path.join(root, "relationships"),
        sources: path.join(root, "sources"),
        timeline: path.join(root, "timeline"),
        schemas: path.join(root, "schemas"),
        topIndex: path.join(root, "index.md"),
    };
}
function entityFilePath(entityType, entityId, canonicalName, cfg = getMirrorConfig()) {
    const p = mirrorPaths(cfg);
    return path.join(p.entities, slugify(entityType), `${entitySlug(entityId, canonicalName)}.md`);
}
function relationshipFilePath(relationshipKey, relationshipType, sourceEntityId, targetEntityId, cfg = getMirrorConfig()) {
    const p = mirrorPaths(cfg);
    return path.join(p.relationships, slugify(relationshipType), `${relationshipSlug(relationshipKey, sourceEntityId, targetEntityId, relationshipType)}.md`);
}
function sourceFilePath(sourceId, cfg = getMirrorConfig()) {
    const p = mirrorPaths(cfg);
    return path.join(p.sources, `${sourceId}.md`);
}
function timelineFilePath(date, cfg = getMirrorConfig()) {
    const p = mirrorPaths(cfg);
    const year = date.slice(0, 4) || "unknown";
    return path.join(p.timeline, year, `${date}.md`);
}
function schemaFilePath(entityType, cfg = getMirrorConfig()) {
    const p = mirrorPaths(cfg);
    return path.join(p.schemas, `${slugify(entityType)}.md`);
}
export async function mirrorEntity(entity, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("entities", c))
        return;
    const canonicalName = entity.canonical_name ??
        (typeof entity.snapshot?.canonical_name === "string"
            ? entity.snapshot.canonical_name
            : entity.entity_id);
    const filePath = entityFilePath(entity.entity_type, entity.entity_id, canonicalName, c);
    // Best-effort: load schema to get stable field order.
    let schemaFieldOrder = [];
    try {
        const schema = await schemaRegistry.loadActiveSchema(entity.entity_type, entity.user_id ?? undefined);
        if (schema) {
            schemaFieldOrder = Object.keys(schema.schema_definition?.fields ?? {});
        }
    }
    catch {
        // Ignore — ordering falls back to alphabetical.
    }
    const input = {
        entity_id: entity.entity_id,
        entity_type: entity.entity_type,
        schema_version: entity.schema_version,
        snapshot: entity.snapshot ?? {},
        computed_at: entity.computed_at ?? null,
        observation_count: entity.observation_count ?? null,
        last_observation_at: entity.last_observation_at ?? null,
        provenance: entity.provenance ?? undefined,
    };
    const rendered = renderEntityMarkdown(input, schemaFieldOrder, {
        includeProvenance: true,
    });
    await writeFileIfChanged(filePath, rendered);
    await regenerateEntityTypeIndex(entity.entity_type, c);
    await regenerateEntitiesRootIndex(c);
    await regenerateTopIndex(c);
    // Write to any matching profiles.
    if (c.profiles && c.profiles.length > 0) {
        for (const profile of c.profiles) {
            if (!profileMatchesEntity(profile, entity.entity_type, entity.snapshot ?? {}))
                continue;
            const warn = checkProfileGitSafety(profile);
            if (warn) {
                process.stderr.write(`[mirror profile warning] ${warn}\n`);
            }
            const filteredSnapshot = applyProfileFieldFilter(profile, entity.snapshot ?? {});
            const renderMode = profile.render_mode ?? "entity";
            let profileRendered;
            if (renderMode === "frontmatter_content" || renderMode === "content_only") {
                const { renderProfileEntity } = await import("./canonical_markdown.js");
                profileRendered = renderProfileEntity(filteredSnapshot, {
                    entity_id: entity.entity_id,
                    entity_type: entity.entity_type,
                    schema_version: entity.schema_version,
                    computed_at: entity.computed_at ?? null,
                    last_observation_at: entity.last_observation_at ?? null,
                    observation_count: entity.observation_count ?? null,
                }, {
                    render_mode: renderMode,
                    frontmatter_fields: profile.frontmatter_fields,
                    content_field: profile.content_field,
                });
            }
            else {
                const profileInput = {
                    ...input,
                    snapshot: filteredSnapshot,
                };
                profileRendered = renderEntityMarkdown(profileInput, schemaFieldOrder, {
                    includeProvenance: false,
                });
            }
            const profilePath = profileEntityFilePath(profile, entity.entity_id, canonicalName, entity.snapshot ?? {});
            await writeFileIfChanged(profilePath, profileRendered);
            if (profile.index_enabled !== false) {
                await regenerateProfileIndex(profile, c);
            }
        }
    }
}
export async function removeMirrorEntity(entityType, entityId, canonicalName, cfg, entitySnapshot) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("entities", c))
        return;
    await removeIfPresent(entityFilePath(entityType, entityId, canonicalName, c));
    await regenerateEntityTypeIndex(entityType, c);
    await regenerateEntitiesRootIndex(c);
    await regenerateTopIndex(c);
    // Remove from any matching profiles.
    if (c.profiles && c.profiles.length > 0 && entitySnapshot) {
        for (const profile of c.profiles) {
            if (!profileMatchesEntity(profile, entityType, entitySnapshot))
                continue;
            const profilePath = profileEntityFilePath(profile, entityId, canonicalName, entitySnapshot);
            await removeIfPresent(profilePath);
            if (profile.index_enabled !== false) {
                await regenerateProfileIndex(profile, c);
            }
        }
    }
}
async function regenerateEntityTypeIndex(entityType, cfg) {
    const p = mirrorPaths(cfg);
    const dir = path.join(p.entities, slugify(entityType));
    const indexPath = path.join(dir, "index.md");
    const { data, error } = await db
        .from("entity_snapshots")
        .select("*")
        .eq("entity_type", entityType);
    if (error)
        return;
    const entries = (data ?? [])
        .map((row) => {
        const snapshot = row.snapshot ?? {};
        const canonicalName = snapshot.canonical_name ?? row.entity_id;
        const slug = entitySlug(row.entity_id, canonicalName);
        const label = getEntityDisplayName({
            entity_type: entityType,
            canonical_name: canonicalName,
            snapshot,
        });
        const summary = getRecordDisplaySummary("entities", {
            entity_type: entityType,
            canonical_name: canonicalName,
        });
        return { label, href: `${slug}.md`, summary };
    })
        .sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
    const rendered = renderIndexMarkdown(`Entities — ${entityType}`, entries);
    await writeFileIfChanged(indexPath, rendered);
}
async function regenerateEntitiesRootIndex(cfg) {
    const p = mirrorPaths(cfg);
    const indexPath = path.join(p.entities, "index.md");
    const { data, error } = await db.from("entity_snapshots").select("entity_type");
    if (error)
        return;
    const counts = new Map();
    for (const row of data ?? []) {
        const t = row.entity_type;
        if (!t)
            continue;
        counts.set(t, (counts.get(t) ?? 0) + 1);
    }
    const entries = [...counts.entries()]
        .map(([type, count]) => ({
        label: type,
        href: `${slugify(type)}/index.md`,
        summary: `${count} entit${count === 1 ? "y" : "ies"}`,
    }))
        .sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
    const rendered = renderIndexMarkdown("Entities", entries);
    await writeFileIfChanged(indexPath, rendered);
}
/**
 * Regenerates index.md in a profile's output_path, listing all entities in
 * that directory (by scanning the DB for matching entities rather than scanning
 * the filesystem, to stay deterministic).
 */
async function regenerateProfileIndex(profile, _cfg) {
    const indexPath = path.join(profile.output_path, "index.md");
    const query = db.from("entity_snapshots").select("*").eq("entity_type", profile.entity_type);
    const { data, error } = await query;
    if (error)
        return;
    const entries = [];
    for (const row of (data ?? [])) {
        const snapshot = row.snapshot ?? {};
        if (!profileMatchesEntity(profile, profile.entity_type, snapshot))
            continue;
        const canonicalName = snapshot.canonical_name ?? row.entity_id;
        const slug = entitySlug(row.entity_id, canonicalName);
        const label = getEntityDisplayName({
            entity_type: profile.entity_type,
            canonical_name: canonicalName,
            snapshot,
        });
        const template = profile.filename_template ?? "{slug}.md";
        const href = template
            .replace("{slug}", slug)
            .replace("{entity_id}", row.entity_id)
            .replace("{canonical_name}", slugify(canonicalName));
        entries.push({ label, href, summary: "" });
    }
    entries.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
    const rendered = renderIndexMarkdown(`${profile.entity_type} — ${profile.id}`, entries);
    await writeFileIfChanged(indexPath, rendered);
}
export async function mirrorRelationship(rel, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("relationships", c))
        return;
    const filePath = relationshipFilePath(rel.relationship_key, rel.relationship_type, rel.source_entity_id, rel.target_entity_id, c);
    const input = {
        relationship_key: rel.relationship_key,
        relationship_type: rel.relationship_type,
        source_entity_id: rel.source_entity_id,
        target_entity_id: rel.target_entity_id,
        schema_version: rel.schema_version,
        snapshot: rel.snapshot ?? {},
        computed_at: rel.computed_at ?? null,
        observation_count: rel.observation_count ?? null,
        last_observation_at: rel.last_observation_at ?? null,
        provenance: rel.provenance ?? undefined,
    };
    const rendered = renderRelationshipMarkdown(input, {
        includeProvenance: true,
        resolveEntityDisplay: await buildEntityDisplayResolver([
            rel.source_entity_id,
            rel.target_entity_id,
        ]),
    });
    await writeFileIfChanged(filePath, rendered);
    await regenerateRelationshipTypeIndex(rel.relationship_type, c);
    await regenerateRelationshipsRootIndex(c);
    await regenerateTopIndex(c);
}
export async function removeMirrorRelationship(relationshipKey, relationshipType, sourceEntityId, targetEntityId, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("relationships", c))
        return;
    await removeIfPresent(relationshipFilePath(relationshipKey, relationshipType, sourceEntityId, targetEntityId, c));
    await regenerateRelationshipTypeIndex(relationshipType, c);
    await regenerateRelationshipsRootIndex(c);
    await regenerateTopIndex(c);
}
async function buildEntityDisplayResolver(entityIds) {
    const ids = [...new Set(entityIds)].filter(Boolean);
    if (ids.length === 0)
        return () => null;
    const { data } = await db
        .from("entity_snapshots")
        .select("entity_id, entity_type, snapshot")
        .in("entity_id", ids);
    const map = new Map();
    for (const row of data ?? []) {
        const r = row;
        map.set(r.entity_id, { entity_type: r.entity_type, snapshot: r.snapshot ?? {} });
    }
    return (entityId) => {
        const hit = map.get(entityId);
        if (!hit)
            return null;
        return {
            entity_type: hit.entity_type,
            canonical_name: hit.snapshot?.canonical_name ?? entityId,
            snapshot: hit.snapshot,
        };
    };
}
async function regenerateRelationshipTypeIndex(relationshipType, cfg) {
    const p = mirrorPaths(cfg);
    const dir = path.join(p.relationships, slugify(relationshipType));
    const indexPath = path.join(dir, "index.md");
    const { data, error } = await db
        .from("relationship_snapshots")
        .select("relationship_key, relationship_type, source_entity_id, target_entity_id")
        .eq("relationship_type", relationshipType);
    if (error)
        return;
    const entries = (data ?? [])
        .map((row) => {
        const key = row.relationship_key;
        const slug = relationshipSlug(key, row.source_entity_id, row.target_entity_id, relationshipType);
        const label = `${row.source_entity_id} → ${row.target_entity_id}`;
        return { label, href: `${slug}.md` };
    })
        .sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
    const rendered = renderIndexMarkdown(`Relationships — ${relationshipType}`, entries);
    await writeFileIfChanged(indexPath, rendered);
}
async function regenerateRelationshipsRootIndex(cfg) {
    const p = mirrorPaths(cfg);
    const indexPath = path.join(p.relationships, "index.md");
    const { data, error } = await db.from("relationship_snapshots").select("relationship_type");
    if (error)
        return;
    const counts = new Map();
    for (const row of data ?? []) {
        const t = row.relationship_type;
        if (!t)
            continue;
        counts.set(t, (counts.get(t) ?? 0) + 1);
    }
    const entries = [...counts.entries()]
        .map(([type, count]) => ({
        label: type,
        href: `${slugify(type)}/index.md`,
        summary: `${count} relationship${count === 1 ? "" : "s"}`,
    }))
        .sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
    const rendered = renderIndexMarkdown("Relationships", entries);
    await writeFileIfChanged(indexPath, rendered);
}
export async function mirrorSource(source, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("sources", c))
        return;
    const input = {
        id: source.id,
        content_hash: source.content_hash,
        mime_type: source.mime_type,
        file_name: source.file_name ?? null,
        original_filename: source.original_filename ?? null,
        byte_size: source.byte_size ?? null,
        source_type: source.source_type,
        source_agent_id: source.source_agent_id ?? null,
        source_metadata: source.source_metadata ?? null,
        created_at: source.created_at ?? null,
        storage_status: source.storage_status ?? null,
    };
    const rendered = renderSourceMarkdown(input, { apiBase: config.apiBase });
    await writeFileIfChanged(sourceFilePath(source.id, c), rendered);
    await regenerateSourcesIndex(c);
    await regenerateTopIndex(c);
}
export async function removeMirrorSource(sourceId, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("sources", c))
        return;
    await removeIfPresent(sourceFilePath(sourceId, c));
    await regenerateSourcesIndex(c);
    await regenerateTopIndex(c);
}
async function regenerateSourcesIndex(cfg) {
    const p = mirrorPaths(cfg);
    const indexPath = path.join(p.sources, "index.md");
    const { data, error } = await db
        .from("sources")
        .select("id, mime_type, file_name, original_filename, created_at");
    if (error)
        return;
    const entries = (data ?? [])
        .map((row) => {
        const id = row.id;
        const label = getRecordDisplaySummary("sources", {
            original_filename: row.original_filename,
            file_name: row.file_name,
            mime_type: row.mime_type,
        }) || id;
        return { label: `${label} (${id.slice(0, 8)}…)`, href: `${id}.md` };
    })
        .sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
    const rendered = renderIndexMarkdown("Sources", entries);
    await writeFileIfChanged(indexPath, rendered);
}
// ============================================================================
// Timeline hooks
// ============================================================================
export async function mirrorTimelineDay(date, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("timeline", c))
        return;
    // Fetch all events for this date.
    const start = `${date}T00:00:00.000Z`;
    const next = nextDay(date);
    const end = `${next}T00:00:00.000Z`;
    const { data, error } = await db
        .from("timeline_events")
        .select("*")
        .gte("event_timestamp", start)
        .lt("event_timestamp", end);
    if (error)
        return;
    const events = (data ?? []).map((row) => ({
        id: row.id,
        event_type: row.event_type,
        event_timestamp: row.event_timestamp,
        source_id: row.source_id ?? null,
        source_field: row.source_field ?? null,
        entity_ids: Array.isArray(row.entity_ids) ? row.entity_ids : null,
        metadata: row.metadata ?? null,
    }));
    const filePath = timelineFilePath(date, c);
    if (events.length === 0) {
        await removeIfPresent(filePath);
    }
    else {
        const rendered = renderTimelineDayMarkdown(date, events);
        await writeFileIfChanged(filePath, rendered);
    }
    await regenerateTimelineIndex(c);
    await regenerateTopIndex(c);
}
function nextDay(date) {
    // date is YYYY-MM-DD
    const d = new Date(`${date}T00:00:00.000Z`);
    d.setUTCDate(d.getUTCDate() + 1);
    return d.toISOString().slice(0, 10);
}
async function regenerateTimelineIndex(cfg) {
    const p = mirrorPaths(cfg);
    const indexPath = path.join(p.timeline, "index.md");
    const { data, error } = await db.from("timeline_events").select("event_timestamp");
    if (error)
        return;
    const days = new Map();
    for (const row of data ?? []) {
        const ts = row.event_timestamp;
        if (!ts)
            continue;
        const date = ts.slice(0, 10);
        days.set(date, (days.get(date) ?? 0) + 1);
    }
    const entries = [...days.entries()]
        .map(([date, count]) => ({
        label: date,
        href: `${date.slice(0, 4)}/${date}.md`,
        summary: `${count} event${count === 1 ? "" : "s"}`,
    }))
        .sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
    const rendered = renderIndexMarkdown("Timeline", entries);
    await writeFileIfChanged(indexPath, rendered);
}
export async function mirrorSchema(schema, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("schemas", c))
        return;
    const input = {
        entity_type: schema.entity_type,
        schema_version: schema.schema_version,
        schema_definition: schema.schema_definition,
        active: schema.active,
        created_at: schema.created_at,
        metadata: schema.metadata,
    };
    const rendered = renderSchemaMarkdown(input);
    await writeFileIfChanged(schemaFilePath(schema.entity_type, c), rendered);
    await regenerateSchemasIndex(c);
    await regenerateTopIndex(c);
}
export async function removeMirrorSchema(entityType, cfg) {
    const c = cfg ?? getMirrorConfig();
    if (!kindEnabled("schemas", c))
        return;
    await removeIfPresent(schemaFilePath(entityType, c));
    await regenerateSchemasIndex(c);
    await regenerateTopIndex(c);
}
async function regenerateSchemasIndex(cfg) {
    const p = mirrorPaths(cfg);
    const indexPath = path.join(p.schemas, "index.md");
    const { data, error } = await db
        .from("schema_registry")
        .select("entity_type, schema_version, active, metadata")
        .eq("active", true);
    if (error)
        return;
    const entries = (data ?? [])
        .map((row) => {
        const et = row.entity_type;
        const metadata = row.metadata;
        const label = metadata?.label ?? et;
        return {
            label,
            href: `${slugify(et)}.md`,
            summary: metadata?.description,
        };
    })
        .sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
    const rendered = renderIndexMarkdown("Schemas", entries);
    await writeFileIfChanged(indexPath, rendered);
}
// ============================================================================
// Top-level index
// ============================================================================
async function regenerateTopIndex(cfg) {
    const p = mirrorPaths(cfg);
    const entries = cfg.kinds.map((kind) => ({
        label: kind,
        href: `${kind}/index.md`,
    }));
    const rendered = renderIndexMarkdown("Neotoma mirror", entries);
    await writeFileIfChanged(p.topIndex, rendered);
}
function emptyReport() {
    const counts = {};
    for (const k of ALL_MIRROR_KINDS) {
        counts[k] = { written: 0, unchanged: 0, removed: 0 };
    }
    return { kinds: [], counts, profiles: {} };
}
export async function rebuildMirror(options = {}) {
    const cfg = getMirrorConfig();
    const report = emptyReport();
    await ensureDir(cfg.path);
    const kinds = !options.kind || options.kind === "all"
        ? cfg.kinds
        : cfg.kinds.includes(options.kind)
            ? [options.kind]
            : [];
    report.kinds = kinds;
    // If a profileId is targeted, only rebuild that profile and skip the regular mirror.
    if (options.profileId) {
        const profile = (cfg.profiles ?? []).find((p) => p.id === options.profileId);
        if (profile) {
            await rebuildProfile(cfg, profile, options, report);
        }
        return report;
    }
    if (kinds.includes("entities")) {
        await rebuildEntities(cfg, options, report);
    }
    if (kinds.includes("relationships")) {
        await rebuildRelationships(cfg, options, report);
    }
    if (kinds.includes("sources")) {
        await rebuildSources(cfg, options, report);
    }
    if (kinds.includes("timeline")) {
        await rebuildTimeline(cfg, options, report);
    }
    if (kinds.includes("schemas")) {
        await rebuildSchemas(cfg, options, report);
    }
    // Also rebuild all profiles when doing a full or entities rebuild.
    if (!options.kind || options.kind === "all" || options.kind === "entities") {
        if (cfg.profiles && cfg.profiles.length > 0) {
            for (const profile of cfg.profiles) {
                await rebuildProfile(cfg, profile, options, report);
            }
        }
    }
    await regenerateTopIndex(cfg);
    // Phase 3: if git is enabled, ensure a repo exists and land either the
    // initial commit (first rebuild) or a deterministic batch commit covering
    // this rebuild pass. Import lazily to avoid loading simple-git when git is
    // disabled.
    if (cfg.git_enabled) {
        try {
            const { initMirrorRepo, ensureInitialCommit, commitMirrorBatch } = await import("./canonical_mirror_git.js");
            await initMirrorRepo(cfg);
            const initRes = await ensureInitialCommit(cfg);
            if (!initRes.committed) {
                // Repo already had a HEAD; record this rebuild as a batch commit.
                await commitMirrorBatch({
                    trigger: "rebuild",
                    kinds,
                }, cfg);
            }
        }
        catch {
            // Git failures must not break the mirror; the mirror is still a valid
            // filesystem artifact without git.
        }
    }
    // Phase 5: optional MEMORY.md auto-regeneration. Runs only when the mirror
    // is itself enabled and `memory_export.enabled` is set, so a user who opts
    // into the mirror can also opt into a single-file export without having to
    // remember to run `neotoma memory-export` manually. Failures are swallowed
    // for the same reason as git: the mirror is still a valid artifact.
    if (cfg.enabled && cfg.memory_export.enabled) {
        try {
            const { exportMemory } = await import("./memory_export.js");
            await exportMemory({
                path: cfg.memory_export.path,
                limit_lines: cfg.memory_export.limit_lines,
                order: "recency",
            });
        }
        catch {
            // Intentionally swallow.
        }
    }
    return report;
}
/**
 * Rebuild a single profile: query all entities of the profile's type, filter
 * by the profile's filter, render with field exclusion, and write to output_path.
 * Emits git-safety warnings (or throws when opts.strict is set).
 */
async function rebuildProfile(cfg, profile, opts, report) {
    // Git safety check.
    const warn = checkProfileGitSafety(profile);
    if (warn) {
        if (opts.strict) {
            throw new Error(`[mirror profile error] ${warn}`);
        }
        process.stderr.write(`[mirror profile warning] ${warn}\n`);
    }
    if (!report.profiles[profile.id]) {
        report.profiles[profile.id] = { written: 0, unchanged: 0, removed: 0 };
    }
    const profileReport = report.profiles[profile.id];
    let rows = [];
    if (opts.apiClient) {
        const apiClientCast = opts.apiClient;
        const { data: apiData, error: apiError } = await apiClientCast.POST("/entities/query", {
            body: {
                entity_type: profile.entity_type,
                limit: 1000,
                include_snapshots: true,
                ...(opts.entityId ? { entity_id: opts.entityId } : {}),
            },
        });
        if (apiError) {
            process.stderr.write(`[mirror profile] API fetch failed for profile ${profile.id}: ${JSON.stringify(apiError)}\n`);
            return;
        }
        rows = (apiData?.entities ?? []);
    }
    else {
        let query = db.from("entity_snapshots").select("*").eq("entity_type", profile.entity_type);
        if (opts.entityId)
            query = query.eq("entity_id", opts.entityId);
        const { data, error } = await query;
        if (error)
            return;
        rows = (data ?? []);
    }
    const writtenPaths = new Set();
    for (const row of rows) {
        const snapshot = row.snapshot ?? {};
        if (!profileMatchesEntity(profile, profile.entity_type, snapshot))
            continue;
        const entityId = row.entity_id;
        let schemaFieldOrder = [];
        let schemaCanonicalNameFields;
        try {
            const schema = await schemaRegistry.loadActiveSchema(profile.entity_type, row.user_id ?? undefined);
            if (schema) {
                schemaFieldOrder = Object.keys(schema.schema_definition?.fields ?? {});
                schemaCanonicalNameFields = schema.schema_definition?.canonical_name_fields;
            }
        }
        catch {
            /* ignore */
        }
        // Derive canonical name from snapshot fields declared in the schema, then
        // fall back to the stored column, then entity_id.
        let canonicalName = snapshot.canonical_name ??
            row.canonical_name ??
            entityId;
        if ((!snapshot.canonical_name || !row.canonical_name) &&
            schemaCanonicalNameFields &&
            schemaCanonicalNameFields.length > 0) {
            for (const rule of schemaCanonicalNameFields) {
                if (typeof rule === "string") {
                    const val = snapshot[rule];
                    if (typeof val === "string" && val.trim().length > 0) {
                        canonicalName = val.trim();
                        break;
                    }
                }
                else if (typeof rule === "object" && "composite" in rule) {
                    const parts = rule.composite
                        .map((f) => snapshot[f])
                        .filter((v) => typeof v === "string" && v.trim().length > 0);
                    if (parts.length === rule.composite.length) {
                        canonicalName = parts.join("|");
                        break;
                    }
                }
            }
        }
        const filteredSnapshot = applyProfileFieldFilter(profile, snapshot);
        const renderMode = profile.render_mode ?? "entity";
        let rendered;
        if (renderMode === "frontmatter_content" || renderMode === "content_only") {
            const { renderProfileEntity } = await import("./canonical_markdown.js");
            rendered = renderProfileEntity(filteredSnapshot, {
                entity_id: entityId,
                entity_type: profile.entity_type,
                schema_version: row.schema_version ?? "1.0",
                computed_at: row.computed_at ?? null,
                last_observation_at: row.last_observation_at ?? null,
                observation_count: row.observation_count ?? null,
            }, {
                render_mode: renderMode,
                frontmatter_fields: profile.frontmatter_fields,
                content_field: profile.content_field,
            });
        }
        else {
            rendered = renderEntityMarkdown({
                entity_id: entityId,
                entity_type: profile.entity_type,
                schema_version: row.schema_version ?? "1.0",
                snapshot: filteredSnapshot,
                computed_at: row.computed_at ?? null,
                observation_count: row.observation_count ?? null,
                last_observation_at: row.last_observation_at ?? null,
                provenance: undefined,
            }, schemaFieldOrder, { includeProvenance: false });
        }
        const targetPath = profileEntityFilePath(profile, entityId, canonicalName, snapshot);
        writtenPaths.add(targetPath);
        const result = await writeFileIfChanged(targetPath, rendered);
        profileReport[result === "written" ? "written" : "unchanged"]++;
    }
    if (opts.clean) {
        const removed = await cleanStale(profile.output_path, writtenPaths, new Set(["index.md"]));
        profileReport.removed += removed;
    }
    if (profile.index_enabled !== false) {
        await regenerateProfileIndex(profile, cfg);
    }
}
async function rebuildEntities(cfg, opts, report) {
    const p = mirrorPaths(cfg);
    let query = db.from("entity_snapshots").select("*");
    if (opts.entityType)
        query = query.eq("entity_type", opts.entityType);
    if (opts.entityId)
        query = query.eq("entity_id", opts.entityId);
    const { data, error } = await query;
    if (error)
        return;
    const writtenPaths = new Set();
    for (const row of (data ?? [])) {
        const entity = {
            entity_id: row.entity_id,
            entity_type: row.entity_type,
            schema_version: row.schema_version,
            snapshot: row.snapshot ?? {},
            computed_at: row.computed_at ?? null,
            observation_count: row.observation_count ?? null,
            last_observation_at: row.last_observation_at ?? null,
            provenance: row.provenance ?? null,
            canonical_name: row.snapshot?.canonical_name ?? null,
            user_id: row.user_id ?? null,
        };
        const targetPath = entityFilePath(entity.entity_type, entity.entity_id, entity.canonical_name, cfg);
        writtenPaths.add(targetPath);
        // Use direct render path (avoid per-call index regeneration).
        let schemaFieldOrder = [];
        try {
            const schema = await schemaRegistry.loadActiveSchema(entity.entity_type, entity.user_id ?? undefined);
            if (schema) {
                schemaFieldOrder = Object.keys(schema.schema_definition?.fields ?? {});
            }
        }
        catch {
            /* ignore */
        }
        const rendered = renderEntityMarkdown({
            entity_id: entity.entity_id,
            entity_type: entity.entity_type,
            schema_version: entity.schema_version,
            snapshot: entity.snapshot,
            computed_at: entity.computed_at,
            observation_count: entity.observation_count,
            last_observation_at: entity.last_observation_at,
            provenance: entity.provenance ?? undefined,
        }, schemaFieldOrder, { includeProvenance: true });
        const result = await writeFileIfChanged(targetPath, rendered);
        report.counts.entities[result === "written" ? "written" : "unchanged"]++;
    }
    if (opts.clean) {
        const removed = await cleanStale(opts.entityType ? path.join(p.entities, slugify(opts.entityType)) : p.entities, writtenPaths, 
        /* preserveNames */ new Set(["index.md"]));
        report.counts.entities.removed += removed;
    }
    // Regenerate indexes once per affected type and root.
    const touchedTypes = new Set();
    for (const row of (data ?? [])) {
        touchedTypes.add(row.entity_type);
    }
    for (const t of touchedTypes)
        await regenerateEntityTypeIndex(t, cfg);
    await regenerateEntitiesRootIndex(cfg);
}
async function rebuildRelationships(cfg, opts, report) {
    const p = mirrorPaths(cfg);
    const query = db.from("relationship_snapshots").select("*");
    if (opts.entityId) {
        // We can't filter in one query; fetch then filter in memory for correctness.
    }
    const { data, error } = await query;
    if (error)
        return;
    const filtered = (data ?? []).filter((row) => {
        const r = row;
        if (opts.entityId &&
            r.source_entity_id !== opts.entityId &&
            r.target_entity_id !== opts.entityId) {
            return false;
        }
        return true;
    });
    const writtenPaths = new Set();
    const touchedTypes = new Set();
    for (const row of filtered) {
        const relRow = {
            relationship_key: row.relationship_key,
            relationship_type: row.relationship_type,
            source_entity_id: row.source_entity_id,
            target_entity_id: row.target_entity_id,
            schema_version: row.schema_version,
            snapshot: row.snapshot ?? {},
            computed_at: row.computed_at ?? null,
            observation_count: row.observation_count ?? null,
            last_observation_at: row.last_observation_at ?? null,
            provenance: row.provenance ?? null,
        };
        const targetPath = relationshipFilePath(relRow.relationship_key, relRow.relationship_type, relRow.source_entity_id, relRow.target_entity_id, cfg);
        writtenPaths.add(targetPath);
        touchedTypes.add(relRow.relationship_type);
        const rendered = renderRelationshipMarkdown({
            relationship_key: relRow.relationship_key,
            relationship_type: relRow.relationship_type,
            source_entity_id: relRow.source_entity_id,
            target_entity_id: relRow.target_entity_id,
            schema_version: relRow.schema_version,
            snapshot: relRow.snapshot,
            computed_at: relRow.computed_at,
            observation_count: relRow.observation_count,
            last_observation_at: relRow.last_observation_at,
            provenance: relRow.provenance ?? undefined,
        }, {
            includeProvenance: true,
            resolveEntityDisplay: await buildEntityDisplayResolver([
                relRow.source_entity_id,
                relRow.target_entity_id,
            ]),
        });
        const result = await writeFileIfChanged(targetPath, rendered);
        report.counts.relationships[result === "written" ? "written" : "unchanged"]++;
    }
    if (opts.clean) {
        const removed = await cleanStale(p.relationships, writtenPaths, new Set(["index.md"]));
        report.counts.relationships.removed += removed;
    }
    for (const t of touchedTypes)
        await regenerateRelationshipTypeIndex(t, cfg);
    await regenerateRelationshipsRootIndex(cfg);
}
async function rebuildSources(cfg, opts, report) {
    const p = mirrorPaths(cfg);
    const { data, error } = await db.from("sources").select("*");
    if (error)
        return;
    const writtenPaths = new Set();
    for (const row of (data ?? [])) {
        const source = {
            id: row.id,
            content_hash: row.content_hash ?? "",
            mime_type: row.mime_type ?? "application/octet-stream",
            file_name: row.file_name ?? null,
            original_filename: row.original_filename ?? null,
            byte_size: row.file_size ?? row.byte_size ?? null,
            source_type: row.source_type ?? "unknown",
            source_agent_id: row.source_agent_id ?? null,
            source_metadata: row.provenance ?? null,
            created_at: row.created_at ?? null,
            storage_status: row.storage_status ?? null,
        };
        const targetPath = sourceFilePath(source.id, cfg);
        writtenPaths.add(targetPath);
        const rendered = renderSourceMarkdown({
            id: source.id,
            content_hash: source.content_hash,
            mime_type: source.mime_type,
            file_name: source.file_name,
            original_filename: source.original_filename,
            byte_size: source.byte_size,
            source_type: source.source_type,
            source_agent_id: source.source_agent_id,
            source_metadata: source.source_metadata,
            created_at: source.created_at,
            storage_status: source.storage_status,
        }, { apiBase: config.apiBase });
        const result = await writeFileIfChanged(targetPath, rendered);
        report.counts.sources[result === "written" ? "written" : "unchanged"]++;
    }
    if (opts.clean) {
        const removed = await cleanStale(p.sources, writtenPaths, new Set(["index.md"]));
        report.counts.sources.removed += removed;
    }
    await regenerateSourcesIndex(cfg);
}
async function rebuildTimeline(cfg, opts, report) {
    const p = mirrorPaths(cfg);
    const { data, error } = await db.from("timeline_events").select("*");
    if (error)
        return;
    const byDay = new Map();
    for (const row of (data ?? [])) {
        const ts = row.event_timestamp ?? "";
        const date = ts.slice(0, 10);
        if (!date)
            continue;
        const evt = {
            id: row.id,
            event_type: row.event_type ?? "event",
            event_timestamp: ts,
            source_id: row.source_id ?? null,
            source_field: row.source_field ?? null,
            entity_ids: Array.isArray(row.entity_ids) ? row.entity_ids : null,
            metadata: row.metadata ?? null,
        };
        const list = byDay.get(date) ?? [];
        list.push(evt);
        byDay.set(date, list);
    }
    const writtenPaths = new Set();
    for (const [date, events] of byDay.entries()) {
        const target = timelineFilePath(date, cfg);
        writtenPaths.add(target);
        const rendered = renderTimelineDayMarkdown(date, events);
        const result = await writeFileIfChanged(target, rendered);
        report.counts.timeline[result === "written" ? "written" : "unchanged"]++;
    }
    if (opts.clean) {
        const removed = await cleanStale(p.timeline, writtenPaths, new Set(["index.md"]));
        report.counts.timeline.removed += removed;
    }
    await regenerateTimelineIndex(cfg);
}
async function rebuildSchemas(cfg, opts, report) {
    const p = mirrorPaths(cfg);
    let query = db.from("schema_registry").select("*").eq("active", true);
    if (opts.entityType)
        query = query.eq("entity_type", opts.entityType);
    const { data, error } = await query;
    if (error)
        return;
    const writtenPaths = new Set();
    for (const row of (data ?? [])) {
        const schema = {
            entity_type: row.entity_type,
            schema_version: row.schema_version ?? "1.0",
            schema_definition: row.schema_definition ?? {
                fields: {},
            },
            active: row.active,
            created_at: row.created_at,
            metadata: row.metadata,
        };
        const target = schemaFilePath(schema.entity_type, cfg);
        writtenPaths.add(target);
        const rendered = renderSchemaMarkdown({
            entity_type: schema.entity_type,
            schema_version: schema.schema_version,
            schema_definition: schema.schema_definition,
            active: schema.active,
            created_at: schema.created_at,
            metadata: schema.metadata,
        });
        const result = await writeFileIfChanged(target, rendered);
        report.counts.schemas[result === "written" ? "written" : "unchanged"]++;
    }
    if (opts.clean) {
        const removed = await cleanStale(p.schemas, writtenPaths, new Set(["index.md"]));
        report.counts.schemas.removed += removed;
    }
    await regenerateSchemasIndex(cfg);
}
/** Walk `dir` recursively; remove files not in `keep`. Returns count removed. */
async function cleanStale(dir, keep, preserveNames) {
    let removed = 0;
    let entries;
    try {
        entries = await fs.readdir(dir, { withFileTypes: true });
    }
    catch {
        return 0;
    }
    for (const entry of entries) {
        const full = path.join(dir, entry.name);
        if (entry.isDirectory()) {
            removed += await cleanStale(full, keep, preserveNames);
            // Remove empty dir.
            try {
                const remaining = await fs.readdir(full);
                if (remaining.length === 0)
                    await fs.rmdir(full);
            }
            catch {
                /* ignore */
            }
        }
        else if (entry.isFile()) {
            if (preserveNames.has(entry.name))
                continue;
            if (!full.endsWith(".md"))
                continue;
            if (keep.has(full))
                continue;
            await fs.unlink(full);
            removed++;
        }
    }
    return removed;
}
export async function getMirrorStatus(cfg) {
    const c = cfg ?? getMirrorConfig();
    const counts = {};
    const p = mirrorPaths(c);
    for (const kind of ALL_MIRROR_KINDS) {
        counts[kind] = await countMarkdownFiles(p[kind]);
    }
    return {
        enabled: c.enabled,
        path: c.path,
        kinds: c.kinds,
        git_enabled: c.git_enabled,
        counts,
    };
}
async function countMarkdownFiles(dir) {
    let count = 0;
    let entries;
    try {
        entries = await fs.readdir(dir, { withFileTypes: true });
    }
    catch {
        return 0;
    }
    for (const entry of entries) {
        const full = path.join(dir, entry.name);
        if (entry.isDirectory())
            count += await countMarkdownFiles(full);
        else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "index.md")
            count++;
    }
    return count;
}
//# sourceMappingURL=canonical_mirror.js.map