/**
 * Markdown service for Studiograph
 *
 * Handles parsing and writing markdown files with YAML frontmatter,
 * extracting wikilinks, and validating relationships.
 */
import { readFileSync, mkdirSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync, } from 'fs';
import { dirname } from 'path';
import matter from 'gray-matter';
import { glob } from 'glob';
/**
 * Normalize a wikilink display name to a kebab-case entity ID for comparison.
 *
 * Wikilinks in Obsidian-style vaults use human-readable display names
 * (e.g. [[Richard Attias & Associates]]) while Studiograph entity IDs are
 * kebab-case slugs (richard-attias-and-associates).  This function converts
 * the display form so that backlink lookups can match both styles.
 */
export function slugifyWikilink(link) {
    return link
        .toLowerCase()
        .replace(/&/g, 'and') // & → and
        .replace(/[^a-z0-9\s_-]/g, '') // remove non-slug chars (except space/_/-)
        .replace(/[\s_]+/g, '-') // spaces/underscores → hyphens
        .replace(/-{2,}/g, '-') // collapse multiple hyphens
        .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
export class MarkdownService {
    /**
     * Parse markdown file from path
     */
    parseFile(filePath) {
        if (!existsSync(filePath)) {
            throw new Error(`File not found: ${filePath}`);
        }
        const fileContent = readFileSync(filePath, 'utf-8');
        return this.parseContent(fileContent);
    }
    /**
     * Parse markdown content string
     */
    parseContent(content) {
        const { data, content: markdownContent } = matter(content);
        // Extract wikilinks from content
        const wikilinks = this.extractWikilinks(markdownContent, data);
        return {
            frontmatter: data,
            content: markdownContent,
            wikilinks: [...new Set(wikilinks)], // Dedupe
        };
    }
    /**
     * Extract wikilinks from markdown content and frontmatter
     */
    extractWikilinks(content, frontmatter) {
        const wikilinks = [];
        // Extract from content: [[entity-id]] or [[entity-id|Display Name]]
        const wikilinkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
        let match;
        while ((match = wikilinkRegex.exec(content)) !== null) {
            // `[[user:<id>]]` account refs are not graph entities — skip them so they
            // don't form edges or register as broken links.
            if (!match[1].startsWith('user:'))
                wikilinks.push(match[1]);
        }
        // Extract from frontmatter (check string values and arrays)
        this.extractWikilinksFromObject(frontmatter, wikilinks);
        return wikilinks;
    }
    /**
     * Recursively extract wikilinks from object (frontmatter)
     */
    extractWikilinksFromObject(obj, wikilinks) {
        if (!obj || typeof obj !== 'object') {
            return;
        }
        if (Array.isArray(obj)) {
            obj.forEach(item => {
                if (typeof item === 'string') {
                    const match = item.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/);
                    if (match && !match[1].startsWith('user:')) {
                        wikilinks.push(match[1]);
                    }
                }
                else {
                    this.extractWikilinksFromObject(item, wikilinks);
                }
            });
        }
        else {
            Object.values(obj).forEach(value => {
                if (typeof value === 'string') {
                    const match = value.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/);
                    if (match && !match[1].startsWith('user:')) {
                        wikilinks.push(match[1]);
                    }
                }
                else {
                    this.extractWikilinksFromObject(value, wikilinks);
                }
            });
        }
    }
    /**
     * Write markdown file with frontmatter.
     *
     * Atomic on POSIX: write to a tmp sibling, fsync, rename. ENOSPC or EIO
     * mid-write leaves the tmp file truncated and the target untouched — the
     * caller observes either the new content or the old, never a partial-write
     * with broken YAML. The Phase-2 disk-first ordering relies on this: if
     * step 2 (writeFile) fails, the disk MUST be unchanged so the Y.Doc isn't
     * applied to a corrupt baseline.
     *
     * Parent-dir fsync is best-effort — many filesystems reject fsync on a
     * directory handle. The rename is still durable enough on every platform
     * we ship to; the parent fsync just bounds the power-loss window.
     */
    writeFile(filePath, doc) {
        const { content } = doc;
        // Strip undefined values — js-yaml (used by gray-matter) can't serialize them
        const frontmatter = Object.fromEntries(Object.entries(doc.frontmatter).filter(([_, v]) => v !== undefined));
        // Generate markdown with frontmatter. Collapse trailing newlines to a
        // single `\n` — gray-matter appends `\n` even for empty content (so
        // body-less entries get `---\n\n` at the end), and built-in body
        // templates often end with `\n\n`. Either way, files end up with an
        // extra blank line below the body that surfaces in the editor.
        const output = matter.stringify(content, frontmatter).replace(/\n+$/, '\n');
        // Ensure directory exists
        const dir = dirname(filePath);
        if (!existsSync(dir)) {
            mkdirSync(dir, { recursive: true });
        }
        atomicWriteFileSync(filePath, output);
    }
    /**
     * Write markdown content from separate frontmatter and content
     */
    write(filePath, frontmatter, content) {
        this.writeFile(filePath, { frontmatter, content, wikilinks: [] });
    }
    /**
     * Validate wikilinks against a repo or set of repos
     */
    validateWikilinks(wikilinks, searchPaths) {
        const valid = [];
        const broken = [];
        for (const link of wikilinks) {
            // Try to find the entity file
            const found = this.findEntity(link, searchPaths);
            if (found) {
                valid.push(link);
            }
            else {
                broken.push(link);
            }
        }
        return { valid, broken };
    }
    /**
     * Find entity file by entity ID across multiple search paths
     */
    findEntity(entityId, searchPaths) {
        // Clean entity ID (remove repo prefix if present)
        // Format: [[repo/entity-id]] or [[entity-id]]
        const parts = entityId.split('/');
        const cleanId = parts.length === 2 ? parts[1] : parts[0];
        // Search for {plural-type}/{entityId}.md in all search paths
        for (const searchPath of searchPaths) {
            if (!existsSync(searchPath)) {
                continue;
            }
            // Search in all subdirectories for {cleanId}.md
            const pattern = `**/${cleanId}.md`;
            const files = glob.sync(pattern, {
                cwd: searchPath,
                absolute: true,
                nodir: true,
            });
            if (files.length > 0) {
                return files[0]; // Return first match
            }
        }
        return null;
    }
    /**
     * Find all entities of a given type in a directory
     */
    findEntitiesByType(searchPath, entityType) {
        if (!existsSync(searchPath)) {
            return [];
        }
        const files = [];
        // Recursively search for markdown files
        const pattern = '**/*.md';
        const allFiles = glob.sync(pattern, {
            cwd: searchPath,
            absolute: true,
            nodir: true,
        });
        // Filter by entity type in frontmatter
        for (const file of allFiles) {
            try {
                const doc = this.parseFile(file);
                if (doc.frontmatter.entity_type === entityType) {
                    files.push(file);
                }
            }
            catch {
                // Skip files that can't be parsed
                continue;
            }
        }
        return files;
    }
    /**
     * Find all markdown files in a directory (recursive)
     */
    findAllEntities(searchPath) {
        if (!existsSync(searchPath)) {
            return [];
        }
        const pattern = '**/*.md';
        return glob.sync(pattern, {
            cwd: searchPath,
            absolute: true,
            nodir: true,
        });
    }
    /**
     * Search entities by content or frontmatter
     */
    search(searchPaths, query, options = {}) {
        const { entityType, caseSensitive = false, searchContent = true, searchFrontmatter = true, limit = 100, } = options;
        const results = [];
        const pattern = caseSensitive ? query : query.toLowerCase();
        // Search all paths
        for (const searchPath of searchPaths) {
            if (!existsSync(searchPath)) {
                continue;
            }
            const files = this.findAllEntities(searchPath);
            for (const file of files) {
                if (results.length >= limit) {
                    break;
                }
                try {
                    const doc = this.parseFile(file);
                    // Filter by entity type if specified
                    if (entityType && doc.frontmatter.entity_type !== entityType) {
                        continue;
                    }
                    // Search frontmatter
                    let found = false;
                    if (searchFrontmatter) {
                        const frontmatterStr = JSON.stringify(doc.frontmatter);
                        const searchStr = caseSensitive ? frontmatterStr : frontmatterStr.toLowerCase();
                        if (searchStr.includes(pattern)) {
                            found = true;
                        }
                    }
                    // Search content
                    if (!found && searchContent) {
                        const searchStr = caseSensitive ? doc.content : doc.content.toLowerCase();
                        if (searchStr.includes(pattern)) {
                            found = true;
                        }
                    }
                    if (found) {
                        results.push(doc);
                    }
                }
                catch {
                    // Skip files that can't be parsed
                    continue;
                }
            }
            if (results.length >= limit) {
                break;
            }
        }
        return results;
    }
    /**
     * Get entity relationships (entities that link to this entity)
     */
    findBacklinks(entityId, searchPaths) {
        const backlinks = [];
        for (const searchPath of searchPaths) {
            if (!existsSync(searchPath)) {
                continue;
            }
            const files = this.findAllEntities(searchPath);
            for (const file of files) {
                try {
                    const doc = this.parseFile(file);
                    // Check if this document links to the target entity.
                    // Match on both the raw wikilink text (already a kebab-case entity
                    // ID) and its slugified form (human-readable display names like
                    // "Richard Attias & Associates" → "richard-attias-and-associates").
                    const normalizedTarget = slugifyWikilink(entityId);
                    const linksToTarget = doc.wikilinks.some(link => {
                        const cleanLink = link.split('/').pop() || link;
                        return cleanLink === entityId || slugifyWikilink(cleanLink) === normalizedTarget;
                    });
                    if (linksToTarget) {
                        backlinks.push({ file, doc });
                    }
                }
                catch {
                    // Skip files that can't be parsed
                    continue;
                }
            }
        }
        return backlinks;
    }
    /**
     * Update wikilinks in a file (useful for refactoring entity IDs)
     */
    updateWikilinks(filePath, oldEntityId, newEntityId) {
        if (!existsSync(filePath)) {
            throw new Error(`File not found: ${filePath}`);
        }
        const doc = this.parseFile(filePath);
        // Update content
        let updatedContent = doc.content;
        const wikilinkPattern = new RegExp(`\\[\\[${escapeRegex(oldEntityId)}(\\|[^\\]]+)?\\]\\]`, 'g');
        updatedContent = updatedContent.replace(wikilinkPattern, `[[${newEntityId}$1]]`);
        // Update frontmatter (recursively)
        const updatedFrontmatter = this.updateWikilinksInObject(doc.frontmatter, oldEntityId, newEntityId);
        // Write back
        this.writeFile(filePath, {
            frontmatter: updatedFrontmatter,
            content: updatedContent,
            wikilinks: [],
        });
    }
    /**
     * Recursively update wikilinks in an object (frontmatter)
     */
    updateWikilinksInObject(obj, oldEntityId, newEntityId) {
        if (!obj || typeof obj !== 'object') {
            return obj;
        }
        if (Array.isArray(obj)) {
            return obj.map(item => {
                if (typeof item === 'string') {
                    const wikilinkPattern = new RegExp(`\\[\\[${escapeRegex(oldEntityId)}(\\|[^\\]]+)?\\]\\]`, 'g');
                    return item.replace(wikilinkPattern, `[[${newEntityId}$1]]`);
                }
                return this.updateWikilinksInObject(item, oldEntityId, newEntityId);
            });
        }
        const updated = {};
        for (const [key, value] of Object.entries(obj)) {
            if (typeof value === 'string') {
                const wikilinkPattern = new RegExp(`\\[\\[${escapeRegex(oldEntityId)}(\\|[^\\]]+)?\\]\\]`, 'g');
                updated[key] = value.replace(wikilinkPattern, `[[${newEntityId}$1]]`);
            }
            else {
                updated[key] = this.updateWikilinksInObject(value, oldEntityId, newEntityId);
            }
        }
        return updated;
    }
}
/**
 * Escape special regex characters
 */
function escapeRegex(str) {
    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
 * Atomic file write: tmp + fsync(tmp) + rename + best-effort fsync(parent dir).
 *
 * Synchronous so the caller (graph.update body-write step) can rely on
 * post-call ordering without await yields. Mirrors the pattern in
 * src/server/yjs-persistence.ts:267-289.
 *
 * On failure mid-write: deletes the tmp file (best-effort), rethrows the
 * underlying error. The target path is either fully replaced or untouched.
 */
function atomicWriteFileSync(filePath, data) {
    const dir = dirname(filePath);
    const tmpPath = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
    let fd = null;
    let renamed = false;
    try {
        fd = openSync(tmpPath, 'w');
        writeSync(fd, data, 0, 'utf-8');
        fsyncSync(fd);
        closeSync(fd);
        fd = null;
        renameSync(tmpPath, filePath);
        renamed = true;
        // Best-effort parent-dir fsync. macOS APFS and many Linux filesystems
        // accept this; some (FAT, network mounts) reject — non-fatal.
        try {
            const dirFd = openSync(dir, 'r');
            try {
                fsyncSync(dirFd);
            }
            finally {
                closeSync(dirFd);
            }
        }
        catch { /* parent fsync unsupported on this fs */ }
    }
    finally {
        if (fd !== null) {
            try {
                closeSync(fd);
            }
            catch { /* already broken */ }
        }
        if (!renamed) {
            try {
                unlinkSync(tmpPath);
            }
            catch { /* tmp may not exist if open failed */ }
        }
    }
}
//# sourceMappingURL=markdown.js.map