/**
 * Importance ranking over the code graph.
 *
 * Runs a weighted PageRank on the `imports`/`calls`/`uses`/`inherits`/`implements`
 * edges so that symbols and files which many *important* others depend on
 * float to the top. This is the signal `buildRepoMap` uses to decide what to
 * keep within a token budget (cf. Aider's tree-sitter repo map).
 *
 * Pure and dependency-light (types, zod, and the shared deterministic
 * comparator) so it stays trivially testable and obeys the module DAG — it
 * performs no I/O and does not count tokens.
 */
import { z } from 'zod';
import { compareCodeUnits } from '../utils.js';
export const RankEdgeWeightsSchema = z.object({
    imports: z.number().optional(),
    calls: z.number().optional(),
    uses: z.number().optional(),
    inherits: z.number().optional(),
});
export const RankOptionsSchema = z.object({
    damping: z.number().min(0).max(1).optional(),
    maxIterations: z.number().int().positive().optional(),
    epsilon: z.number().positive().optional(),
    edgeWeights: RankEdgeWeightsSchema.optional(),
    biasExports: z.boolean().optional(),
    includeExternal: z.boolean().optional(),
});
export const RankedNodeSchema = z.object({
    nodeId: z.string(),
    score: z.number(),
    normalizedScore: z.number(),
    type: z.string(),
    name: z.string(),
    path: z.string(),
    relativePath: z.string().optional(),
    line: z.number().optional(),
    containerName: z.string().optional(),
    isExported: z.boolean(),
});
export const RankedFileSchema = z.object({
    path: z.string(),
    relativePath: z.string(),
    score: z.number(),
    normalizedScore: z.number(),
});
export const RankResultSchema = z.object({
    nodes: z.array(RankedNodeSchema),
    fileScores: z.array(RankedFileSchema),
    iterations: z.number(),
    converged: z.boolean(),
});
const DEFAULT_DAMPING = 0.85;
const DEFAULT_MAX_ITERATIONS = 30;
const DEFAULT_EPSILON = 1e-4;
const DEFAULT_WEIGHTS = { imports: 1, calls: 0.5, uses: 0.5, inherits: 2 };
const CALL_WEIGHT_CAP = 4;
const EXPORT_SEED_BOOST = 2;
const RANKING_EDGE_TYPES = new Set(['imports', 'calls', 'uses', 'inherits', 'implements']);
function symbolIdentity(parts) {
    return [parts.type, parts.path, parts.name, parts.line ?? '', parts.containerName ?? ''].join('|');
}
function collectExportedNodeKeys(index) {
    const keys = new Set();
    for (const file of index.files) {
        for (const symbol of file.symbols) {
            if (!symbol.exported)
                continue;
            keys.add(symbolIdentity({
                type: symbol.type,
                path: file.path,
                name: symbol.name,
                line: symbol.line,
                containerName: symbol.containerName,
            }));
        }
    }
    return keys;
}
/**
 * Weighted PageRank over the dependency/call graph.
 *
 * Edge direction matches storage (`from` depends on `to`), so rank flows from a
 * dependent toward the thing it depends on — heavily-relied-upon nodes
 * accumulate importance. `ambiguous`/`unresolved` edges (no concrete `to`) are
 * skipped because their importance cannot be attributed.
 */
export function rankNodes(index, options = {}) {
    const damping = options.damping ?? DEFAULT_DAMPING;
    const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
    const epsilon = options.epsilon ?? DEFAULT_EPSILON;
    const weights = { ...DEFAULT_WEIGHTS, ...options.edgeWeights };
    const biasExports = options.biasExports ?? true;
    const includeExternal = options.includeExternal ?? false;
    const nodes = index.nodes.filter(node => includeExternal || !node.external);
    const count = nodes.length;
    if (count === 0) {
        return { nodes: [], fileScores: [], iterations: 0, converged: true };
    }
    const indexOf = new Map();
    nodes.forEach((node, i) => indexOf.set(node.id, i));
    const exportedKeys = collectExportedNodeKeys(index);
    const isExported = nodes.map(node => exportedKeys.has(symbolIdentity({
        type: node.type,
        path: node.path,
        name: node.name,
        line: node.line,
        containerName: node.containerName,
    })));
    // Accumulate edge weight per (from → to) pair. Calls are summed at 0.5 each
    // but capped, so a function called 20× does not drown structural edges.
    const pairWeight = new Map();
    const pairCallWeight = new Map();
    for (const edge of index.edges) {
        if (!RANKING_EDGE_TYPES.has(edge.type))
            continue;
        if (edge.to == null)
            continue;
        const from = indexOf.get(edge.from);
        const to = indexOf.get(edge.to);
        if (from == null || to == null || from === to)
            continue;
        const key = `${from}>${to}`;
        if (edge.type === 'calls' || edge.type === 'uses') {
            const current = pairCallWeight.get(key) ?? 0;
            const next = Math.min(CALL_WEIGHT_CAP, current + (edge.type === 'calls' ? weights.calls : weights.uses));
            pairCallWeight.set(key, next);
            pairWeight.set(key, (pairWeight.get(key) ?? 0) + (next - current));
        }
        else {
            const weight = edge.type === 'imports' ? weights.imports : weights.inherits;
            pairWeight.set(key, (pairWeight.get(key) ?? 0) + weight);
        }
    }
    const outgoing = Array.from({ length: count }, () => []);
    const outWeight = new Array(count).fill(0);
    for (const [key, weight] of pairWeight) {
        const [fromStr, toStr] = key.split('>');
        const from = Number(fromStr);
        const to = Number(toStr);
        outgoing[from].push({ to, weight });
        outWeight[from] += weight;
    }
    // Personalization/teleport vector — bias toward exported (public API) nodes.
    const seedRaw = nodes.map((_, i) => (biasExports && isExported[i] ? EXPORT_SEED_BOOST : 1));
    const seedTotal = seedRaw.reduce((sum, value) => sum + value, 0);
    const seed = seedRaw.map(value => value / seedTotal);
    let rank = seed.slice();
    let iterations = 0;
    let converged = false;
    for (let iter = 0; iter < maxIterations; iter++) {
        iterations = iter + 1;
        const next = seed.map(value => (1 - damping) * value);
        let danglingMass = 0;
        for (let i = 0; i < count; i++) {
            if (outWeight[i] === 0)
                danglingMass += rank[i];
        }
        for (let i = 0; i < count; i++) {
            next[i] += damping * danglingMass * seed[i];
        }
        for (let i = 0; i < count; i++) {
            if (outWeight[i] === 0)
                continue;
            const share = damping * rank[i];
            for (const edge of outgoing[i]) {
                next[edge.to] += share * (edge.weight / outWeight[i]);
            }
        }
        let delta = 0;
        for (let i = 0; i < count; i++)
            delta += Math.abs(next[i] - rank[i]);
        rank = next;
        if (delta < epsilon) {
            converged = true;
            break;
        }
    }
    const total = rank.reduce((sum, value) => sum + value, 0) || 1;
    const normalized = rank.map(value => value / total);
    const maxScore = normalized.reduce((max, value) => Math.max(max, value), 0) || 1;
    const rankedNodes = nodes
        .map((node, i) => ({
        nodeId: node.id,
        score: normalized[i],
        normalizedScore: (normalized[i] / maxScore) * 100,
        type: node.type,
        name: node.name,
        path: node.path,
        relativePath: node.relativePath,
        line: node.line,
        containerName: node.containerName,
        isExported: isExported[i],
    }))
        // Content-addressed tiebreakers: ties must not depend on insertion order,
        // or adding one unrelated file could reorder every equal-scored node.
        // Path-first mirrors the effective tie order a path-sorted parse already
        // produced, so existing wire bytes hold. Never tiebreak by nodeId — its
        // `node2:<type>:` prefix would group ties by type and change bytes.
        .sort((a, b) => b.score - a.score ||
        compareCodeUnits(a.path, b.path) ||
        (a.line ?? 0) - (b.line ?? 0) ||
        compareCodeUnits(a.containerName ?? '', b.containerName ?? '') ||
        compareCodeUnits(a.name, b.name) ||
        compareCodeUnits(a.type, b.type));
    const fileAccumulator = new Map();
    nodes.forEach((node, i) => {
        const existing = fileAccumulator.get(node.path);
        if (existing) {
            existing.score += normalized[i];
        }
        else {
            fileAccumulator.set(node.path, { relativePath: node.relativePath ?? node.path, score: normalized[i] });
        }
    });
    const maxFileScore = [...fileAccumulator.values()].reduce((max, entry) => Math.max(max, entry.score), 0) || 1;
    const fileScores = [...fileAccumulator.entries()]
        .map(([path, entry]) => ({
        path,
        relativePath: entry.relativePath,
        score: entry.score,
        normalizedScore: (entry.score / maxFileScore) * 100,
    }))
        .sort((a, b) => b.score - a.score || compareCodeUnits(a.path, b.path));
    return { nodes: rankedNodes, fileScores, iterations, converged };
}
const RANK_CACHE_MAX_VARIANTS = 8;
// Per-index ranking memo. The WeakMap keys on the CodebaseIndex object itself,
// so building a new index naturally invalidates every cached ranking for the
// old one. Each index keeps a small insertion-order LRU of option variants.
const rankCache = new WeakMap();
/**
 * Cache key for a RankOptions variant on a given index. The key is prefixed
 * with a cheap content fingerprint (`files`/`nodes`/`edges` lengths) so the
 * common in-place mutations — pushing files, nodes, or edges onto a cached
 * index — invalidate stale rankings at zero cost. Defaults are applied in a
 * fixed key order (mirroring `rankNodes`), so `{}` and explicitly-passed
 * defaults share one cache entry.
 */
function rankCacheKey(index, options) {
    const weights = { ...DEFAULT_WEIGHTS, ...options.edgeWeights };
    return (`${index.files.length}:${index.nodes.length}:${index.edges.length}:` +
        JSON.stringify({
            damping: options.damping ?? DEFAULT_DAMPING,
            maxIterations: options.maxIterations ?? DEFAULT_MAX_ITERATIONS,
            epsilon: options.epsilon ?? DEFAULT_EPSILON,
            imports: weights.imports,
            calls: weights.calls,
            uses: weights.uses,
            inherits: weights.inherits,
            biasExports: options.biasExports ?? true,
            includeExternal: options.includeExternal ?? false,
        }));
}
function cacheRankResult(index, options, result) {
    let variants = rankCache.get(index);
    if (!variants) {
        variants = new Map();
        rankCache.set(index, variants);
    }
    const key = rankCacheKey(index, options);
    variants.delete(key);
    variants.set(key, result);
    if (variants.size > RANK_CACHE_MAX_VARIANTS) {
        const oldest = variants.keys().next();
        if (!oldest.done)
            variants.delete(oldest.value);
    }
}
/**
 * Seed one memoized ranking for an index that a caller has already verified.
 *
 * This is an explicit entry point for cache owners; callers remain responsible
 * for proving that `result` belongs to this exact index and option variant.
 * OCC does not persist RankResult in the current cache contract.
 */
export function seedRankCache(index, options, result) {
    cacheRankResult(index, options, result);
}
/**
 * Memoized `rankNodes`. Repeated rankings of the same index object (e.g.
 * per-focused-map calls) skip PageRank entirely; up to
 * `RANK_CACHE_MAX_VARIANTS` option variants are retained per index, evicting
 * the oldest. The returned `RankResult` is shared between callers — treat it
 * as immutable. `rankNodes` itself stays pure and uncached.
 *
 * The index should be treated as immutable after construction. The cache key
 * carries only a `files`/`nodes`/`edges` length fingerprint, so in-place
 * mutations that change those lengths recompute, but mutations that keep them
 * constant (e.g. rewriting a node's name in place) are NOT detected and will
 * return a stale ranking.
 */
export function rankNodesCached(index, options = {}) {
    const variants = rankCache.get(index);
    const key = rankCacheKey(index, options);
    const cached = variants?.get(key);
    if (cached)
        return cached;
    const result = rankNodes(index, options);
    cacheRankResult(index, options, result);
    return result;
}
export function topRankedFiles(result, limit) {
    return result.fileScores.slice(0, limit);
}
export function topRankedSymbols(result, limit) {
    return result.nodes.filter(node => node.type !== 'file' && node.type !== 'module').slice(0, limit);
}
//# sourceMappingURL=rank.js.map