/**
 * Context Assembler
 *
 * Mathematical context-token optimization engine.
 * Replaces heuristic file selection with a quality-scored greedy knapsack.
 *
 * Quality = w1 * relevance + w2 * recency + w3 * connectivity + w4 * novelty
 * Maximize Σ(quality_i) subject to Σ(tokens_i) ≤ budget
 *
 * Phase: 8B (depends on 8A Session DNA)
 */
import * as fs from 'fs';
import * as path from 'path';
import { createEmbedder } from './embedder.js';
import { computeSemanticScore } from './semantic-ranking.js';
// ─────────────────────────────────────────────────────────────────────────────
// Context Assembler
// ─────────────────────────────────────────────────────────────────────────────
const DEFAULT_WEIGHTS = {
    relevance: 0.45,
    recency: 0.20,
    connectivity: 0.10,
    novelty: 0.25,
};
export class ContextAssembler {
    weights;
    seenChunks = new Set(); // tracks chunks seen in session
    embedder;
    vectorCache = new Map();
    constructor(weights) {
        this.weights = { ...DEFAULT_WEIGHTS, ...weights };
        this.embedder = createEmbedder();
    }
    // ── Greedy Knapsack ────────────────────────────────────────────────────
    /**
     * Maximize Σ(quality_i) subject to Σ(tokens_i) ≤ budget
     * Solved via greedy knapsack: sort by quality/token ratio descending.
     */
    assemble(task, candidates, budget) {
        // Score all candidates
        const scored = candidates.map((chunk) => {
            const scoreBreakdown = this.scoreChunkBreakdown(chunk, task);
            return {
                ...chunk,
                quality: scoreBreakdown.final,
                scoreBreakdown,
            };
        });
        // Sort by quality-per-token ratio (descending) — greedy knapsack
        scored.sort((a, b) => {
            const ratioA = a.tokens > 0 ? a.quality / a.tokens : 0;
            const ratioB = b.tokens > 0 ? b.quality / b.tokens : 0;
            return ratioB - ratioA;
        });
        // Greedy selection
        const selected = [];
        let totalTokens = 0;
        let totalQuality = 0;
        let rejected = 0;
        for (const chunk of scored) {
            if (chunk.quality <= 0.05) {
                rejected++;
                continue; // Skip near-zero quality chunks
            }
            if (totalTokens + chunk.tokens <= budget) {
                selected.push(chunk);
                totalTokens += chunk.tokens;
                totalQuality += chunk.quality;
                this.seenChunks.add(this.chunkKey(chunk));
            }
            else {
                rejected++;
            }
        }
        return {
            selected,
            totalQuality,
            totalTokens,
            budget,
            efficiency: totalTokens > 0 ? totalQuality / totalTokens : 0,
            rejectedCount: rejected,
        };
    }
    // ── Quality Scoring ────────────────────────────────────────────────────
    /**
     * Compute quality score for a chunk:
     * quality = w1*relevance + w2*recency + w3*connectivity + w4*novelty
     */
    scoreChunk(chunk, task) {
        return this.scoreChunkBreakdown(chunk, task).final;
    }
    scoreChunkBreakdown(chunk, task) {
        const relevance = this.scoreRelevance(chunk, task);
        const recency = this.scoreRecency(chunk);
        const connectivity = chunk.quality;
        const novelty = this.scoreNovelty(chunk);
        let rawScore = (this.weights.relevance * relevance.final +
            this.weights.recency * recency +
            this.weights.connectivity * connectivity +
            this.weights.novelty * novelty);
        // Apply memory quality signals: penalize high-entropy chunks, boost high-trust
        if (chunk.entropy !== undefined) {
            rawScore *= (1 - chunk.entropy * 0.5); // high entropy → up to 50% penalty
        }
        if (chunk.trust !== undefined) {
            rawScore *= (0.7 + chunk.trust * 0.3); // low trust → up to 30% penalty
        }
        return {
            relevance,
            recency,
            connectivity,
            novelty,
            final: rawScore,
        };
    }
    /** Map quality score to token tier: L0, L1, or L2 */
    tierForQuality(quality) {
        if (quality < 0.25)
            return 'L0'; // skip / minimal
        if (quality < 0.60)
            return 'L1'; // outline
        return 'L2'; // full
    }
    /** Relevance: keyword overlap between chunk content and task */
    scoreRelevance(chunk, task) {
        const taskTokens = this.tokenize(task);
        const chunkTokens = this.tokenize(chunk.content.slice(0, 2000));
        if (taskTokens.length === 0 || chunkTokens.length === 0) {
            return {
                semantic: 0,
                lexical: 0.3,
                keywordCoverage: 0.3,
                pathBoost: 0,
                final: 0.3,
                fallbackUsed: true,
            };
        }
        const taskVector = this.vectorFor(`task:${task}`, task);
        const chunkVector = this.vectorFor(this.chunkKey(chunk), `${chunk.label}\n${chunk.source}\n${chunk.content.slice(0, 2000)}`);
        return computeSemanticScore({
            query: task,
            queryVector: taskVector,
            candidateText: `${chunk.label}\n${chunk.content.slice(0, 2000)}`,
            candidateVector: chunkVector,
            lexicalTexts: [chunk.label, chunk.content.slice(0, 2000)],
            pathText: chunk.source,
            embedder: this.embedder,
            weights: {
                semantic: 0.72,
                lexical: 0.2,
                path: 0.08,
            },
        });
    }
    /** Recency: how recently the file was modified (exponential decay) */
    scoreRecency(chunk) {
        // Hot loop: rely on chunk.mtimeMs stamped at chunk creation time. No per-chunk fs.
        // Chunks without a stamp (legacy producers) get a neutral 0.3 default.
        if (typeof chunk.mtimeMs !== 'number' || chunk.mtimeMs <= 0)
            return 0.3;
        const ageHours = (Date.now() - chunk.mtimeMs) / 3_600_000;
        return Math.exp(-0.693 * ageHours / 24);
    }
    /** Novelty: chunks not yet seen in this session score higher */
    scoreNovelty(chunk) {
        return this.seenChunks.has(this.chunkKey(chunk)) ? 0.1 : 1.0;
    }
    // ── Adaptive Budget ────────────────────────────────────────────────────
    /**
     * Compute adaptive budget: simple tasks -25%, complex tasks +50%
     */
    computeBudget(task, baseBudget) {
        const taskLower = task.toLowerCase();
        const complexSignals = [
            'refactor', 'rewrite', 'migrate', 'architect', 'redesign',
            'audit', 'complex', 'overhaul', 'multi-file', 'interrelated',
        ];
        const simpleSignals = [
            'lookup', 'find', 'check', 'status', 'quick', 'list', 'read',
        ];
        const complexCount = complexSignals.filter(s => taskLower.includes(s)).length;
        const simpleCount = simpleSignals.filter(s => taskLower.includes(s)).length;
        let multiplier = 1.0;
        let reason = 'standard complexity';
        if (complexCount >= 2) {
            multiplier = 1.50;
            reason = `high complexity (${complexCount} signals: ${complexSignals.filter(s => taskLower.includes(s)).join(', ')})`;
        }
        else if (complexCount === 1) {
            multiplier = 1.25;
            reason = `moderate complexity (${complexSignals.find(s => taskLower.includes(s))})`;
        }
        else if (simpleCount >= 2) {
            multiplier = 0.60;
            reason = `simple task (${simpleCount} signals)`;
        }
        else if (simpleCount === 1) {
            multiplier = 0.75;
            reason = `light task (${simpleSignals.find(s => taskLower.includes(s))})`;
        }
        return {
            base: baseBudget,
            taskMultiplier: multiplier,
            effectiveBudget: Math.round(baseBudget * multiplier),
            reason,
        };
    }
    // ── Semantic Chunking ──────────────────────────────────────────────────
    /**
     * Split a file into semantic chunks at function/class boundaries.
     * Falls back to line-based chunking if parsing fails.
     */
    async chunkFile(filepath) {
        const resolved = path.isAbsolute(filepath) ? filepath : path.join(process.cwd(), filepath);
        let content;
        let mtimeMs;
        try {
            // Async I/O per Hard Rule #1. Single stat per file is cached onto
            // every chunk so scoreRecency stays sync-free in the hot loop.
            const [readResult, statResult] = await Promise.allSettled([
                fs.promises.readFile(resolved, 'utf-8'),
                fs.promises.stat(resolved),
            ]);
            if (readResult.status !== 'fulfilled')
                return [];
            content = readResult.value;
            if (statResult.status === 'fulfilled')
                mtimeMs = statResult.value.mtimeMs;
        }
        catch {
            return [];
        }
        const lines = content.split('\n');
        let chunks;
        // For small files (< 100 lines), return as single chunk
        if (lines.length < 100) {
            chunks = [{
                    source: resolved,
                    content,
                    tokens: Math.ceil(content.length / 4),
                    quality: 0, // will be scored later
                    label: path.basename(filepath),
                    startLine: 1,
                    endLine: lines.length,
                }];
        }
        else {
            const ext = path.extname(filepath).toLowerCase();
            chunks = ['.ts', '.js', '.tsx', '.jsx'].includes(ext)
                ? this.chunkByBoundaries(resolved, lines)
                : this.chunkByWindow(resolved, lines, 80);
        }
        if (mtimeMs !== undefined) {
            for (const chunk of chunks)
                chunk.mtimeMs = mtimeMs;
        }
        return chunks;
    }
    /** Chunk TypeScript/JavaScript by function and class boundaries */
    chunkByBoundaries(filepath, lines) {
        const chunks = [];
        const boundaryPattern = /^(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const|enum|abstract)\s+(\w+)/;
        let currentStart = 0;
        let currentLabel = path.basename(filepath) + ':top';
        for (let i = 0; i < lines.length; i++) {
            const match = lines[i].match(boundaryPattern);
            if (match && i > currentStart + 3) {
                // Close previous chunk
                const chunkContent = lines.slice(currentStart, i).join('\n');
                if (chunkContent.trim().length > 0) {
                    chunks.push({
                        source: filepath,
                        content: chunkContent,
                        tokens: Math.ceil(chunkContent.length / 4),
                        quality: 0,
                        label: currentLabel,
                        startLine: currentStart + 1,
                        endLine: i,
                    });
                }
                currentStart = i;
                currentLabel = match[1];
            }
        }
        // Close last chunk
        const lastContent = lines.slice(currentStart).join('\n');
        if (lastContent.trim().length > 0) {
            chunks.push({
                source: filepath,
                content: lastContent,
                tokens: Math.ceil(lastContent.length / 4),
                quality: 0,
                label: currentLabel,
                startLine: currentStart + 1,
                endLine: lines.length,
            });
        }
        return chunks;
    }
    /** Chunk by fixed-size window (for non-JS/TS files) */
    chunkByWindow(filepath, lines, windowSize) {
        const chunks = [];
        for (let i = 0; i < lines.length; i += windowSize) {
            const end = Math.min(i + windowSize, lines.length);
            const chunkContent = lines.slice(i, end).join('\n');
            if (chunkContent.trim().length > 0) {
                chunks.push({
                    source: filepath,
                    content: chunkContent,
                    tokens: Math.ceil(chunkContent.length / 4),
                    quality: 0,
                    label: `${path.basename(filepath)}:${i + 1}-${end}`,
                    startLine: i + 1,
                    endLine: end,
                });
            }
        }
        return chunks;
    }
    // ── Helpers ────────────────────────────────────────────────────────────
    /** Simple tokenizer: lowercase, split on non-alphanum, filter stopwords */
    tokenize(text) {
        return text.toLowerCase()
            .split(/[^a-z0-9]+/)
            .filter(t => t.length > 2 && !STOPWORDS.has(t));
    }
    /** Unique key for a chunk (path + line range) */
    chunkKey(chunk) {
        return `${chunk.source}:${chunk.startLine}-${chunk.endLine}`;
    }
    vectorFor(key, value) {
        const cached = this.vectorCache.get(key);
        if (cached)
            return cached;
        const vector = this.embedder.embedSync(value);
        this.vectorCache.set(key, vector);
        return vector;
    }
    /** Reset session state (seen chunks) */
    reset() {
        this.seenChunks.clear();
    }
    // ── Formatting ─────────────────────────────────────────────────────────
    /** Format assembly result for MCP response */
    static format(result, config) {
        const lines = [
            `⚡ HyperTune Max — ${result.selected.length} chunks selected`,
            `Budget: ${config.effectiveBudget.toLocaleString()} tokens (${config.taskMultiplier}x ${config.reason})`,
            `Quality: ${result.totalQuality.toFixed(2)} │ Tokens: ${result.totalTokens.toLocaleString()} │ Efficiency: ${(result.efficiency * 1000).toFixed(2)} q/kT`,
            `Rejected: ${result.rejectedCount} chunks (below quality threshold or over budget)`,
            '',
        ];
        // Group chunks by file
        const fileGroups = new Map();
        for (const chunk of result.selected) {
            const existing = fileGroups.get(chunk.source) ?? [];
            existing.push(chunk);
            fileGroups.set(chunk.source, existing);
        }
        for (const [filePath, chunks] of fileGroups) {
            const totalFileTokens = chunks.reduce((s, c) => s + c.tokens, 0);
            const action = chunks.length === 1 && chunks[0].startLine === 1
                ? '✅ Read fully'
                : `✂️ Read ${chunks.length} section(s)`;
            lines.push(`${action} ${path.basename(filePath)} (${totalFileTokens.toLocaleString()} tokens)`);
            for (const c of chunks) {
                lines.push(`  └ L${c.startLine}-${c.endLine}: ${c.label} (q=${c.quality.toFixed(2)})`);
            }
        }
        return lines.join('\n');
    }
}
// ── Stopwords ────────────────────────────────────────────────────────────────
const STOPWORDS = new Set([
    'the', 'and', 'for', 'this', 'that', 'with', 'from', 'are', 'was', 'not',
    'but', 'have', 'has', 'had', 'will', 'can', 'all', 'been', 'its', 'may',
    'use', 'new', 'each', 'which', 'their', 'any', 'also', 'when', 'how',
    'let', 'var', 'const', 'return', 'import', 'export', 'function', 'class',
]);
