import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as https from 'https';
import { randomUUID } from 'crypto';
import { createEmbedder } from './embedder.js';
import { resolveNexusStateDir } from './runtime-registry.js';
import { computeSemanticScore } from './semantic-ranking.js';
const MAX_CHUNK_CHARS = 1200;
const DEFAULT_REMOTE_FETCH_TIMEOUT_MS = 15_000;
const COLLECTION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const RAG_SUPPORTED_EXTENSIONS = new Set([
    '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
    '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.cpp', '.h',
    '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
    '.html', '.css', '.scss', '.less', '.vue', '.svelte',
    '.sh', '.bash', '.zsh', '.sql', '.graphql',
]);
const RAG_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.cache', 'coverage', '.turbo']);
const RAG_MAX_FOLDER_FILES = 200;
function expandFolderInputs(input) {
    const folder = input.folderPath;
    if (!folder)
        return [input];
    const resolved = path.resolve(folder);
    if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory())
        return [];
    const globPattern = input.folderGlob?.trim() || '';
    const files = [];
    function walk(dir) {
        if (files.length >= RAG_MAX_FOLDER_FILES)
            return;
        for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
            if (files.length >= RAG_MAX_FOLDER_FILES)
                return;
            if (entry.name.startsWith('.'))
                continue;
            const full = path.join(dir, entry.name);
            if (entry.isDirectory()) {
                if (RAG_SKIP_DIRS.has(entry.name))
                    continue;
                walk(full);
            }
            else if (entry.isFile()) {
                const ext = path.extname(entry.name).toLowerCase();
                if (globPattern) {
                    // Simple glob: match extension pattern like "*.ts" or "*.{ts,js}"
                    if (globPattern.includes(ext) || globPattern === '*') {
                        files.push(full);
                    }
                }
                else if (RAG_SUPPORTED_EXTENSIONS.has(ext)) {
                    files.push(full);
                }
            }
        }
    }
    walk(resolved);
    return files.map((filePath) => ({
        filePath,
        label: input.label ? `${input.label}/${path.relative(resolved, filePath)}` : path.relative(resolved, filePath),
        tags: input.tags,
    }));
}
export class RagCollectionStore {
    rootDir;
    requestTimeoutMs;
    embedder;
    embeddingDimensions;
    constructor(stateRoot = resolveNexusStateDir(), options = {}) {
        this.rootDir = path.join(stateRoot, 'rag-collections');
        this.requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REMOTE_FETCH_TIMEOUT_MS);
        this.embedder = createEmbedder();
        this.embeddingDimensions = this.embedder.embedSync('nexus rag probe').length;
        fs.mkdirSync(this.rootDir, { recursive: true });
    }
    listCollections() {
        return fs.readdirSync(this.rootDir)
            .filter((entry) => entry.endsWith('.json'))
            .map((entry) => this.getCollection(entry.replace(/\.json$/, '')))
            .filter((collection) => Boolean(collection))
            .map((collection) => this.toSummary(collection))
            .sort((left, right) => right.updatedAt - left.updatedAt);
    }
    getCollection(collectionId) {
        const target = this.collectionPath(collectionId);
        if (!target)
            return undefined;
        if (!fs.existsSync(target))
            return undefined;
        try {
            const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
            const collection = {
                ...parsed,
                tags: parsed.tags ?? [],
                attachedRuntimeIds: parsed.attachedRuntimeIds ?? [],
                attachedSessionIds: parsed.attachedSessionIds ?? [],
                sources: parsed.sources ?? [],
                chunks: parsed.chunks ?? [],
            };
            if (this.backfillCollectionEmbeddings(collection)) {
                this.persist(collection);
            }
            return collection;
        }
        catch {
            return undefined;
        }
    }
    createCollection(input) {
        const now = Date.now();
        const collection = {
            collectionId: `rag_${randomUUID().slice(0, 8)}`,
            name: input.name.trim(),
            description: input.description?.trim(),
            createdAt: now,
            updatedAt: now,
            tags: dedupeStrings(input.tags ?? []),
            scope: input.scope ?? 'session',
            attachedRuntimeIds: [],
            attachedSessionIds: [],
            sources: [],
            chunks: [],
        };
        this.persist(collection);
        return collection;
    }
    async ingestCollection(collectionId, inputs) {
        const collection = this.getRequiredCollection(collectionId);
        let sourcesAdded = 0;
        let chunksAdded = 0;
        // Expand folder inputs into individual file inputs
        const expandedInputs = inputs.flatMap((input) => input.folderPath ? expandFolderInputs(input) : [input]);
        for (const input of expandedInputs) {
            const content = await this.readInputContent(input);
            if (!content.trim())
                continue;
            const sourceId = `src_${randomUUID().slice(0, 8)}`;
            const source = {
                sourceId,
                kind: input.filePath ? 'file' : input.url ? 'url' : 'text',
                label: input.label?.trim() || input.filePath || input.url || `note-${collection.sources.length + 1}`,
                location: input.filePath || input.url,
                addedAt: Date.now(),
                bytes: Buffer.byteLength(content, 'utf8'),
            };
            const chunks = chunkText(content, sourceId, dedupeStrings(input.tags ?? collection.tags));
            await this.embedChunks(source.label, chunks);
            collection.sources.push(source);
            collection.chunks.push(...chunks);
            sourcesAdded += 1;
            chunksAdded += chunks.length;
        }
        collection.updatedAt = Date.now();
        this.persist(collection);
        return { collection, sourcesAdded, chunksAdded };
    }
    attachCollection(collectionId, runtimeId, sessionId) {
        const collection = this.getRequiredCollection(collectionId);
        collection.attachedRuntimeIds = dedupeStrings([...collection.attachedRuntimeIds, runtimeId]);
        if (sessionId) {
            collection.attachedSessionIds = dedupeStrings([...collection.attachedSessionIds, sessionId]);
        }
        collection.updatedAt = Date.now();
        this.persist(collection);
        return collection;
    }
    detachCollection(collectionId, runtimeId, sessionId) {
        const collection = this.getRequiredCollection(collectionId);
        if (runtimeId) {
            collection.attachedRuntimeIds = collection.attachedRuntimeIds.filter((entry) => entry !== runtimeId);
        }
        if (sessionId) {
            collection.attachedSessionIds = collection.attachedSessionIds.filter((entry) => entry !== sessionId);
        }
        collection.updatedAt = Date.now();
        this.persist(collection);
        return collection;
    }
    deleteCollection(collectionId) {
        const target = this.collectionPath(collectionId);
        if (!target)
            return false;
        if (!fs.existsSync(target))
            return false;
        fs.unlinkSync(target);
        return true;
    }
    retrieve(query, options = {}) {
        const limit = Math.max(1, Math.min(20, options.limit ?? 6));
        const queryVector = this.embedder.embedSync(query);
        const collections = options.collectionIds?.length
            ? options.collectionIds.map((collectionId) => this.getCollection(collectionId)).filter((collection) => Boolean(collection))
            : this.listCollections()
                .map((summary) => this.getCollection(summary.collectionId))
                .filter((collection) => Boolean(collection))
                .filter((collection) => {
                if (options.runtimeId && collection.attachedRuntimeIds.includes(options.runtimeId))
                    return true;
                if (options.sessionId && collection.attachedSessionIds.includes(options.sessionId))
                    return true;
                return !options.runtimeId && !options.sessionId;
            });
        return collections
            .flatMap((collection) => collection.chunks.map((chunk) => {
            const source = collection.sources.find((entry) => entry.sourceId === chunk.sourceId);
            const normalizedEmbedding = this.ensureChunkEmbedding(chunk, source?.label ?? '');
            const scoreBreakdown = computeSemanticScore({
                query,
                queryVector,
                candidateText: `${source?.label ?? ''}\n${chunk.text}`,
                candidateVector: normalizedEmbedding,
                lexicalTexts: [chunk.text, source?.label ?? ''],
                embedder: this.embedder,
                weights: {
                    semantic: 0.84,
                    lexical: 0.16,
                    path: 0,
                },
            });
            return {
                collectionId: collection.collectionId,
                collectionName: collection.name,
                chunkId: chunk.chunkId,
                sourceId: chunk.sourceId,
                label: source?.label ?? chunk.chunkId,
                text: chunk.text,
                tokens: chunk.tokens,
                tags: chunk.tags,
                score: scoreBreakdown.final,
                scoreBreakdown,
            };
        }))
            .filter((hit) => hit.score > 0)
            .sort((left, right) => right.score - left.score || left.label.localeCompare(right.label))
            .slice(0, limit);
    }
    getRequiredCollection(collectionId) {
        const collection = this.getCollection(collectionId);
        if (!collection) {
            throw new Error(`RAG collection not found: ${collectionId}`);
        }
        return collection;
    }
    toSummary(collection) {
        return {
            collectionId: collection.collectionId,
            name: collection.name,
            description: collection.description,
            createdAt: collection.createdAt,
            updatedAt: collection.updatedAt,
            tags: collection.tags,
            scope: collection.scope,
            attachedRuntimeIds: collection.attachedRuntimeIds,
            attachedSessionIds: collection.attachedSessionIds,
            sourceCount: collection.sources.length,
            chunkCount: collection.chunks.length,
        };
    }
    persist(collection) {
        fs.writeFileSync(this.collectionPath(collection.collectionId, true), JSON.stringify(collection, null, 2), 'utf8');
    }
    collectionPath(collectionId, strict = false) {
        const sanitizedCollectionId = this.sanitizeCollectionId(collectionId);
        if (!sanitizedCollectionId) {
            if (strict) {
                throw new Error(`Invalid RAG collection id: ${collectionId}`);
            }
            return undefined;
        }
        return path.join(this.rootDir, `${sanitizedCollectionId}.json`);
    }
    sanitizeCollectionId(collectionId) {
        const normalized = String(collectionId || '').trim();
        if (!COLLECTION_ID_PATTERN.test(normalized))
            return undefined;
        return normalized;
    }
    async readInputContent(input) {
        if (input.text)
            return String(input.text);
        if (input.filePath) {
            return fs.readFileSync(path.resolve(input.filePath), 'utf8');
        }
        if (input.url) {
            return fetchText(input.url, this.requestTimeoutMs);
        }
        return '';
    }
    async embedChunks(sourceLabel, chunks) {
        await Promise.all(chunks.map(async (chunk) => {
            try {
                chunk.embedding = await this.embedder.embed(`${sourceLabel}\n${chunk.text}`);
            }
            catch {
                chunk.embedding = undefined;
            }
            chunk.embedding = this.ensureChunkEmbedding(chunk, sourceLabel);
        }));
    }
    backfillCollectionEmbeddings(collection) {
        let changed = false;
        const sourceLabels = new Map(collection.sources.map((source) => [source.sourceId, source.label]));
        for (const chunk of collection.chunks) {
            const normalized = this.ensureChunkEmbedding(chunk, sourceLabels.get(chunk.sourceId) ?? '');
            if (chunk.embedding !== normalized) {
                chunk.embedding = normalized;
                changed = true;
            }
            else if (!Array.isArray(chunk.embedding) || chunk.embedding.length !== this.embeddingDimensions) {
                changed = true;
            }
        }
        return changed;
    }
    ensureChunkEmbedding(chunk, sourceLabel) {
        if (Array.isArray(chunk.embedding)
            && chunk.embedding.length === this.embeddingDimensions
            && chunk.embedding.every((value) => Number.isFinite(value))) {
            return chunk.embedding;
        }
        return this.embedder.embedSync(`${sourceLabel}\n${chunk.text}`);
    }
}
function dedupeStrings(values) {
    return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function chunkText(content, sourceId, tags) {
    const normalized = content.replace(/\r\n/g, '\n').trim();
    if (!normalized)
        return [];
    const paragraphs = normalized.split(/\n{2,}/).map((segment) => segment.trim()).filter(Boolean);
    const chunks = [];
    let current = '';
    const flush = () => {
        const text = current.trim();
        if (!text)
            return;
        chunks.push({
            chunkId: `chunk_${randomUUID().slice(0, 8)}`,
            sourceId,
            text,
            tokens: estimateTokens(text),
            tags,
        });
        current = '';
    };
    for (const paragraph of paragraphs) {
        const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
        if (candidate.length <= MAX_CHUNK_CHARS) {
            current = candidate;
            continue;
        }
        flush();
        if (paragraph.length <= MAX_CHUNK_CHARS) {
            current = paragraph;
            continue;
        }
        for (let index = 0; index < paragraph.length; index += MAX_CHUNK_CHARS) {
            const slice = paragraph.slice(index, index + MAX_CHUNK_CHARS).trim();
            if (!slice)
                continue;
            chunks.push({
                chunkId: `chunk_${randomUUID().slice(0, 8)}`,
                sourceId,
                text: slice,
                tokens: estimateTokens(slice),
                tags,
            });
        }
    }
    flush();
    return chunks;
}
function fetchText(targetUrl, timeoutMs) {
    const url = new URL(targetUrl);
    const transport = url.protocol === 'https:' ? https : http;
    return new Promise((resolve, reject) => {
        let settled = false;
        const finish = (handler) => {
            if (settled)
                return;
            settled = true;
            handler();
        };
        const req = transport.get(url, (res) => {
            const chunks = [];
            res.on('aborted', () => finish(() => reject(new Error(`Remote RAG fetch aborted for ${targetUrl}`))));
            res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
            res.on('end', () => {
                finish(() => resolve(Buffer.concat(chunks).toString('utf8')));
            });
            res.on('error', (error) => finish(() => reject(error)));
        });
        req.setTimeout(timeoutMs, () => {
            const error = new Error(`Timed out fetching RAG URL after ${timeoutMs}ms: ${targetUrl}`);
            finish(() => {
                req.destroy(error);
                reject(error);
            });
        });
        req.on('error', (error) => finish(() => reject(error)));
    });
}
function estimateTokens(value) {
    return Math.max(1, Math.ceil(String(value || '').length / 4));
}
