/**
 * src/knowledge/graphify.ts
 * Document knowledge graph — SQLite-backed.
 *
 * Replaces the original Neo4j-backed implementation. Same exported class shape
 * so unified.ts and other consumers don't change. Uses kg_entities (kind='document'),
 * kg_relationships, and kg_episodes — all sharing the kg_* schema with graphiti.
 *
 * Document-graph entities have customer_id=NULL (they're tenant-shared concepts:
 * Products, Services, Policies pulled from uploaded business docs). Each entity
 * tracks `source_files` so the dashboard can show "this product is mentioned in
 * KNOWLEDGE/products.md and KNOWLEDGE/policies.md".
 */
import { readFile, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { extractEntities } from './extraction.js';
const log = createChildLogger('knowledge:graphify');
const norm = (s) => s.toLowerCase().trim();
export class GraphifyClient {
    /** No-op for SQLite (the shim handles initialization). */
    async connect() {
        log.info('graphify: connected (sqlite-backed)');
    }
    /** No-op. */
    async close() {
        log.debug('graphify: close called (sqlite-backed, no-op)');
    }
    // ─── Internals ─────────────────────────────────────────────────────
    async upsertEntity(row) {
        const name_normalized = norm(row.name);
        const existing = await supabase
            .from('kg_entities')
            .select('*')
            .eq('tenant_id', row.tenant_id)
            .eq('kind', 'document')
            .is('customer_id', null)
            .eq('type', row.type)
            .eq('name_normalized', name_normalized)
            .maybeSingle();
        const now = new Date().toISOString();
        if (existing.data) {
            const ent = existing.data;
            const merged = { ...ent.properties, ...(row.properties ?? {}) };
            const sources = new Set(ent.source_files);
            if (row.source_file)
                sources.add(row.source_file);
            const sourceArr = Array.from(sources);
            await supabase
                .from('kg_entities')
                .update({
                properties: merged,
                source_files: sourceArr,
                episode_count: ent.episode_count + 1,
                updated_at: now,
            })
                .eq('id', ent.id);
            return { ...ent, properties: merged, source_files: sourceArr, episode_count: ent.episode_count + 1 };
        }
        const id = randomUUID();
        const fresh = {
            id,
            tenant_id: row.tenant_id,
            type: row.type,
            name: row.name,
            name_normalized,
            properties: row.properties ?? {},
            source_files: row.source_file ? [row.source_file] : [],
            episode_count: 1,
        };
        await supabase.from('kg_entities').insert({
            ...fresh,
            kind: 'document',
            customer_id: null,
            created_at: now,
            updated_at: now,
        });
        return fresh;
    }
    async upsertRelationship(row) {
        const existing = await supabase
            .from('kg_relationships')
            .select('id, properties')
            .eq('tenant_id', row.tenant_id)
            .eq('source_id', row.source_id)
            .eq('target_id', row.target_id)
            .eq('type', row.type)
            .maybeSingle();
        const now = new Date().toISOString();
        if (existing.data) {
            const merged = { ...existing.data.properties, ...(row.properties ?? {}) };
            await supabase
                .from('kg_relationships')
                .update({ properties: merged, updated_at: now })
                .eq('id', existing.data.id);
            return;
        }
        await supabase.from('kg_relationships').insert({
            id: randomUUID(),
            tenant_id: row.tenant_id,
            source_id: row.source_id,
            target_id: row.target_id,
            type: row.type,
            properties: row.properties ?? {},
            created_at: now,
            updated_at: now,
        });
    }
    // ─── Public API ────────────────────────────────────────────────────
    /**
     * Walk the tenant's documents directory, extract entities/relationships from
     * each text file, persist to kg_*. Returns counts for caller telemetry.
     */
    async buildDocumentGraph(tenantId, documentsDir) {
        const files = await listTextFiles(documentsDir);
        if (files.length === 0) {
            log.info({ tenantId, documentsDir }, 'No files in documents dir');
            return { filesProcessed: 0, entitiesCreated: 0 };
        }
        log.info({ tenantId, fileCount: files.length }, 'Building document graph');
        let totalEntities = 0;
        for (const filePath of files) {
            try {
                const content = await readFile(filePath, 'utf-8');
                if (content.trim().length < 20)
                    continue;
                const filename = filePath.replace(documentsDir, '').replace(/^\//, '');
                const created = await this.processDocument(tenantId, filename, content);
                totalEntities += created;
            }
            catch (err) {
                log.error({ tenantId, filePath, error: err.message }, 'Failed to process document');
            }
        }
        log.info({ tenantId, filesProcessed: files.length, totalEntities }, 'Document graph built');
        return { filesProcessed: files.length, entitiesCreated: totalEntities };
    }
    async processDocument(tenantId, filename, content) {
        const chunks = chunkText(content, 2000);
        // Episode log per document — fuels the dashboard timeline view
        await supabase.from('kg_episodes').insert({
            id: randomUUID(),
            tenant_id: tenantId,
            kind: 'document',
            customer_id: null,
            document_path: filename,
            content: content.slice(0, 8000),
            entities_extracted: 0,
            relationships_extracted: 0,
            created_at: new Date().toISOString(),
        });
        let entitiesCreated = 0;
        for (const chunk of chunks) {
            const extraction = await extractEntities(chunk);
            const nameToId = new Map();
            for (const e of extraction.entities) {
                const ent = await this.upsertEntity({
                    tenant_id: tenantId,
                    type: e.type,
                    name: e.name,
                    properties: e.properties ?? {},
                    source_file: filename,
                });
                nameToId.set(norm(e.name), ent.id);
                entitiesCreated++;
            }
            for (const r of extraction.relationships) {
                const sourceId = nameToId.get(norm(r.from));
                const targetId = nameToId.get(norm(r.to));
                if (!sourceId || !targetId)
                    continue;
                await this.upsertRelationship({
                    tenant_id: tenantId,
                    source_id: sourceId,
                    target_id: targetId,
                    type: r.type,
                    properties: r.properties ?? {},
                });
            }
        }
        return entitiesCreated;
    }
    /**
     * Search document entities by name (substring, case-insensitive).
     * Returns entity rows with source files attached.
     */
    async queryDocuments(tenantId, query) {
        const result = await supabase
            .from('kg_entities')
            .select('id, name, type, properties, source_files, episode_count')
            .eq('tenant_id', tenantId)
            .eq('kind', 'document')
            .ilike('name_normalized', `%${norm(query)}%`)
            .order('episode_count', { ascending: false })
            .limit(20);
        return (result.data ?? []).map(r => ({
            name: r.name,
            entityType: r.type,
            properties: r.properties,
            sourceFiles: r.source_files ?? [],
            score: 1.0,
        }));
    }
    /**
     * Detail view for a single product/entity from the document graph.
     * Includes related entities and source files.
     */
    async getProductDetails(tenantId, productName) {
        const product = await supabase
            .from('kg_entities')
            .select('*')
            .eq('tenant_id', tenantId)
            .eq('kind', 'document')
            .ilike('name_normalized', norm(productName))
            .maybeSingle();
        if (!product.data)
            return null;
        const productRow = product.data;
        const rels = await supabase
            .from('kg_relationships')
            .select('target_id, type')
            .eq('tenant_id', tenantId)
            .eq('source_id', productRow.id);
        const targetIds = (rels.data ?? []).map(r => r.target_id);
        const related = targetIds.length > 0
            ? await supabase.from('kg_entities').select('id, name, type, properties').in('id', targetIds)
            : { data: [] };
        const byId = new Map();
        for (const r of (related.data ?? [])) {
            byId.set(r.id, r);
        }
        const relatedEntities = (rels.data ?? []).map(r => {
            const t = byId.get(r.target_id);
            return t ? { entity: t.name, entityType: t.type, relType: r.type, properties: t.properties } : null;
        }).filter((x) => x !== null);
        return {
            name: productRow.name,
            entityType: productRow.type,
            properties: productRow.properties,
            sourceFiles: productRow.source_files,
            relatedEntities,
        };
    }
    /**
     * Wipe the document graph for a tenant — used before a full rebuild.
     * Conversation graph (kind='conversation') is preserved.
     */
    async refreshGraph(tenantId) {
        // Cascade through relationships via FK ON DELETE CASCADE on kg_entities.id.
        await supabase
            .from('kg_entities')
            .delete()
            .eq('tenant_id', tenantId)
            .eq('kind', 'document');
        await supabase
            .from('kg_episodes')
            .delete()
            .eq('tenant_id', tenantId)
            .eq('kind', 'document');
        log.info({ tenantId }, 'Document graph cleared for refresh');
    }
}
// ─── Helpers ─────────────────────────────────────────────────────────
async function listTextFiles(dir) {
    const out = [];
    async function walk(d) {
        let entries = [];
        try {
            entries = await readdir(d);
        }
        catch {
            return;
        }
        for (const name of entries) {
            const full = join(d, name);
            try {
                const s = await stat(full);
                if (s.isDirectory())
                    await walk(full);
                else if (/\.(md|txt|markdown)$/i.test(name))
                    out.push(full);
            }
            catch {
                // ignore inaccessible entries
            }
        }
    }
    await walk(dir);
    return out;
}
function chunkText(text, maxChunkSize) {
    if (text.length <= maxChunkSize)
        return [text];
    const chunks = [];
    let i = 0;
    while (i < text.length) {
        chunks.push(text.slice(i, i + maxChunkSize));
        i += maxChunkSize;
    }
    return chunks;
}
export const graphify = new GraphifyClient();
//# sourceMappingURL=graphify.js.map