import { createHash } from 'node:crypto';
import { z } from 'zod';
import { compareCodeUnits, normalizeRelativePath } from '../utils.js';
import { CodeNodeTypeSchema, CodeSkippedFileSchema, CodeSourceSpanV1Schema, } from './types.js';
export const CODE_MODULE_CLUSTERS_VERSION = 1;
export const CODE_MODULE_CLUSTER_DEFAULT_LIMIT = 100;
export const CODE_MODULE_CLUSTER_HARD_LIMIT = 1_000;
export const CODE_MODULE_CLUSTER_DEFAULT_MAX_MEMBERS = 500;
export const CODE_MODULE_CLUSTER_HARD_MAX_MEMBERS = 10_000;
export const CODE_MODULE_CLUSTER_DEFAULT_MAX_ENTRY_POINTS = 50;
export const CODE_MODULE_CLUSTER_HARD_MAX_ENTRY_POINTS = 1_000;
export const CODE_MODULE_CLUSTER_DEFAULT_MAX_DEPENDENCIES = 100;
export const CODE_MODULE_CLUSTER_HARD_MAX_DEPENDENCIES = 1_000;
export const CODE_MODULE_CLUSTER_LABEL_PROPAGATION_PASSES = 10;
export const CODE_MODULE_CLUSTER_EVIDENCE_DETAIL_LIMIT = 50;
export const CodeModuleClusterOptionsSchema = z.object({
    limit: z.number().int().positive().max(CODE_MODULE_CLUSTER_HARD_LIMIT).optional(),
    maxMembers: z.number().int().positive().max(CODE_MODULE_CLUSTER_HARD_MAX_MEMBERS).optional(),
    maxEntryPoints: z.number().int().positive().max(CODE_MODULE_CLUSTER_HARD_MAX_ENTRY_POINTS).optional(),
    maxDependencies: z.number().int().positive().max(CODE_MODULE_CLUSTER_HARD_MAX_DEPENDENCIES).optional(),
});
export const CodeModuleClusterEntryPointSchema = z.object({
    nodeId: z.string(),
    name: z.string(),
    kind: CodeNodeTypeSchema,
    relativePath: z.string(),
    span: CodeSourceSpanV1Schema,
});
export const CodeModuleClusterDependencySchema = z.object({
    toClusterId: z.string(),
    importCount: z.number().int().positive(),
});
export const CodeModuleClusterSchema = z.object({
    id: z.string(),
    label: z.string(),
    keywords: z.array(z.string()).max(5),
    size: z.number().int().min(2),
    cohesion: z.number().min(0).max(1),
    members: z.array(z.string()),
    entryPoints: z.array(CodeModuleClusterEntryPointSchema),
    dependencies: z.array(CodeModuleClusterDependencySchema),
    truncated: z.boolean(),
    omitted: z.object({
        members: z.number().int().nonnegative(),
        entryPoints: z.number().int().nonnegative(),
        dependencies: z.number().int().nonnegative(),
    }),
});
export const CodeModuleClusterResultSchema = z.object({
    status: z.literal('resolved'),
    version: z.literal(CODE_MODULE_CLUSTERS_VERSION),
    clusters: z.array(CodeModuleClusterSchema),
    total: z.number().int().nonnegative(),
    omitted: z.number().int().nonnegative(),
    truncated: z.boolean(),
    unclusteredFiles: z.number().int().nonnegative(),
    honesty: z.object({
        freshness: z.literal('index-snapshot'),
        stalenessReason: z.string(),
        coverage: z.object({
            status: z.enum(['full', 'partial']),
            indexedFiles: z.number().int().nonnegative(),
            clusteredFiles: z.number().int().nonnegative(),
            unclusteredFiles: z.number().int().nonnegative(),
            skippedFiles: z.number().int().nonnegative(),
            budgetDroppedFiles: z.number().int().nonnegative(),
            parserGapFiles: z.number().int().nonnegative(),
            importGapFiles: z.number().int().nonnegative(),
            resolvedLocalImports: z.number().int().nonnegative(),
            uniqueLocalImportEdges: z.number().int().nonnegative(),
            unreachableImportEndpoints: z.number().int().nonnegative(),
            unresolvedImports: z.number().int().nonnegative(),
            externalImports: z.number().int().nonnegative(),
            unclusteredDependencyEdges: z.number().int().nonnegative(),
        }),
        evidenceGaps: z.object({
            skippedFiles: z.array(CodeSkippedFileSchema),
            skippedFilesOmitted: z.number().int().nonnegative(),
            parserGapPaths: z.array(z.string()),
            parserGapPathsOmitted: z.number().int().nonnegative(),
            importGapPaths: z.array(z.string()),
            importGapPathsOmitted: z.number().int().nonnegative(),
        }),
        omissions: z.object({
            clusters: z.number().int().nonnegative(),
            members: z.number().int().nonnegative(),
            entryPoints: z.number().int().nonnegative(),
            dependencies: z.number().int().nonnegative(),
        }),
        bounds: z.object({
            maxClusters: z.number().int().positive(),
            maxMembers: z.number().int().positive(),
            maxEntryPoints: z.number().int().positive(),
            maxDependencies: z.number().int().positive(),
            propagationPasses: z.literal(CODE_MODULE_CLUSTER_LABEL_PROPAGATION_PASSES),
        }),
    }),
});
const KEYWORD_STOPWORDS = new Set([
    'app',
    'code',
    'common',
    'component',
    'components',
    'core',
    'file',
    'files',
    'index',
    'lib',
    'main',
    'module',
    'shared',
    'source',
    'src',
    'spec',
    'specs',
    'test',
    'tests',
    'util',
    'utils',
]);
function lengthFramedHash(domain, values) {
    const hash = createHash('sha256');
    hash.update(`${domain}\0`, 'utf8');
    for (const value of values) {
        const bytes = Buffer.from(value, 'utf8');
        hash.update(`${bytes.byteLength}:`, 'ascii');
        hash.update(bytes);
    }
    return hash.digest('hex');
}
function clusterId(members) {
    return `cluster1:${lengthFramedHash('occ.module-clusters.v1', members)}`;
}
function visitOrderKey(iteration, relativePath) {
    return createHash('sha256').update(`occ.module-clusters.v1\0${iteration}\0${relativePath}`, 'utf8').digest('hex');
}
function pathPair(left, right) {
    return compareCodeUnits(left, right) <= 0 ? [left, right] : [right, left];
}
function pairKey(left, right) {
    return JSON.stringify(pathPair(left, right));
}
function makeIndexPathMap(index) {
    const result = new Map();
    for (const file of index.files) {
        const relativePath = normalizeRelativePath(file.relativePath);
        result.set(normalizeRelativePath(file.path), relativePath);
        result.set(relativePath, relativePath);
    }
    return result;
}
function relativeEdgePath(pathByIndexPath, value) {
    if (value === undefined)
        return undefined;
    return pathByIndexPath.get(normalizeRelativePath(value));
}
function resolvedLocalImports(index, pathByIndexPath) {
    // A schema-valid index may carry resolved-local edges whose endpoints do
    // not join index.files (e.g. a filtered or persisted replay); dropping one
    // must stay countable rather than silently thinning the graph.
    let unreachableImportEndpoints = 0;
    const imports = index.edges
        .filter(edge => edge.type === 'imports' && edge.status === 'resolved' && edge.importKind === 'local' && edge.to !== undefined)
        .map(edge => {
        const from = relativeEdgePath(pathByIndexPath, edge.fromPath);
        const to = relativeEdgePath(pathByIndexPath, edge.toPath);
        if (from === undefined || to === undefined) {
            unreachableImportEndpoints++;
            return undefined;
        }
        return from !== to ? { from, to, edge } : undefined;
    })
        .filter((edge) => edge !== undefined)
        .sort((left, right) => compareCodeUnits(left.from, right.from) ||
        compareCodeUnits(left.to, right.to) ||
        compareCodeUnits(left.edge.id, right.edge.id));
    return { imports, unreachableImportEndpoints };
}
/** Deterministic asynchronous label propagation over the undirected local-import graph. */
export function buildCodeModuleClusterAssignment(index) {
    const paths = [...new Set(index.files.map(file => normalizeRelativePath(file.relativePath)))].sort(compareCodeUnits);
    const pathByIndexPath = makeIndexPathMap(index);
    const { imports, unreachableImportEndpoints } = resolvedLocalImports(index, pathByIndexPath);
    const adjacency = new Map(paths.map(relativePath => [relativePath, new Set()]));
    const undirected = new Map();
    for (const edge of imports) {
        adjacency.get(edge.from)?.add(edge.to);
        adjacency.get(edge.to)?.add(edge.from);
        undirected.set(pairKey(edge.from, edge.to), pathPair(edge.from, edge.to));
    }
    const labels = new Map(paths.map(relativePath => [relativePath, relativePath]));
    for (let iteration = 0; iteration < CODE_MODULE_CLUSTER_LABEL_PROPAGATION_PASSES; iteration++) {
        const visitOrder = paths
            .map(relativePath => ({ relativePath, key: visitOrderKey(iteration, relativePath) }))
            .sort((left, right) => compareCodeUnits(left.key, right.key) || compareCodeUnits(left.relativePath, right.relativePath));
        let changed = false;
        for (const visit of visitOrder) {
            const neighbors = adjacency.get(visit.relativePath);
            if (!neighbors || neighbors.size === 0)
                continue;
            const frequencies = new Map();
            for (const neighbor of [...neighbors].sort(compareCodeUnits)) {
                const label = labels.get(neighbor);
                if (label !== undefined)
                    frequencies.set(label, (frequencies.get(label) ?? 0) + 1);
            }
            const next = [...frequencies.entries()].sort((left, right) => right[1] - left[1] || compareCodeUnits(left[0], right[0]))[0]?.[0];
            if (next !== undefined && next !== labels.get(visit.relativePath)) {
                labels.set(visit.relativePath, next);
                changed = true;
            }
        }
        if (!changed)
            break;
    }
    const groups = new Map();
    for (const relativePath of paths) {
        const label = labels.get(relativePath) ?? relativePath;
        const members = groups.get(label) ?? [];
        members.push(relativePath);
        groups.set(label, members);
    }
    const clusters = [...groups.values()]
        .map(members => members.sort(compareCodeUnits))
        .filter(members => members.length >= 2)
        .map(members => ({ id: clusterId(members), members }))
        .sort((left, right) => right.members.length - left.members.length || compareCodeUnits(left.id, right.id));
    const clusterIdByPath = new Map();
    for (const cluster of clusters) {
        for (const member of cluster.members)
            clusterIdByPath.set(member, cluster.id);
    }
    return {
        clusters,
        clusterIdByPath,
        resolvedLocalImports: imports,
        uniqueUndirectedEdges: [...undirected.values()].sort((left, right) => compareCodeUnits(left[0], right[0]) || compareCodeUnits(left[1], right[1])),
        pathByIndexPath,
        unreachableImportEndpoints,
    };
}
function tokenizePath(relativePath) {
    const withoutExtension = relativePath.replace(/\.[^./]+$/u, '');
    return withoutExtension
        .replace(/([a-z\d])([A-Z])/gu, '$1 $2')
        .split(/[^\p{L}\p{N}]+/u)
        .map(token => token.toLowerCase())
        .filter(token => token.length >= 2 && !KEYWORD_STOPWORDS.has(token) && !/^\d+$/u.test(token));
}
function clusterKeywords(members) {
    const counts = new Map();
    for (const member of members) {
        for (const token of tokenizePath(member))
            counts.set(token, (counts.get(token) ?? 0) + 1);
    }
    return [...counts.entries()]
        .sort((left, right) => right[1] - left[1] || compareCodeUnits(left[0], right[0]))
        .slice(0, 5)
        .map(([token]) => token);
}
function fallbackLabel(members) {
    const first = members[0] ?? 'module';
    const parts = first
        .replace(/\.[^./]+$/u, '')
        .split('/')
        .filter(Boolean);
    return parts.at(-1) ?? first;
}
function roundRatio(numerator, denominator) {
    return denominator === 0 ? 0 : Number((numerator / denominator).toFixed(6));
}
function conservativeSpan(symbol) {
    if (symbol.span)
        return symbol.span;
    const startLine = Math.max(1, symbol.line);
    return {
        version: 1,
        offsetEncoding: 'utf8-bytes',
        endExclusive: true,
        startLine,
        endLine: Math.max(startLine, symbol.endLine ?? startLine),
        exact: false,
    };
}
function exportedSymbols(index) {
    const result = [];
    for (const file of index.files) {
        for (const symbol of file.symbols) {
            if (symbol.exported === true && symbol.nodeId !== undefined)
                result.push({ file, symbol });
        }
    }
    return result.sort((left, right) => compareCodeUnits(left.file.relativePath, right.file.relativePath) ||
        left.symbol.line - right.symbol.line ||
        compareCodeUnits(left.symbol.nodeId ?? '', right.symbol.nodeId ?? ''));
}
function clusterEntryPoints(index, assignment) {
    const result = new Map();
    const externallyImportedFiles = new Set();
    const externallyReachedNodeIds = new Set();
    for (const edge of assignment.resolvedLocalImports) {
        const sourceCluster = assignment.clusterIdByPath.get(edge.from);
        const targetCluster = assignment.clusterIdByPath.get(edge.to);
        if (targetCluster !== undefined && sourceCluster !== targetCluster)
            externallyImportedFiles.add(edge.to);
    }
    for (const edge of index.edges) {
        if ((edge.type !== 'calls' && edge.type !== 'uses') || edge.status !== 'resolved' || edge.to === undefined)
            continue;
        const sourcePath = relativeEdgePath(assignment.pathByIndexPath, edge.fromPath);
        const targetPath = relativeEdgePath(assignment.pathByIndexPath, edge.toPath);
        if (!sourcePath || !targetPath)
            continue;
        const sourceCluster = assignment.clusterIdByPath.get(sourcePath);
        const targetCluster = assignment.clusterIdByPath.get(targetPath);
        if (targetCluster !== undefined && sourceCluster !== targetCluster)
            externallyReachedNodeIds.add(edge.to);
    }
    for (const { file, symbol } of exportedSymbols(index)) {
        const relativePath = normalizeRelativePath(file.relativePath);
        const id = assignment.clusterIdByPath.get(relativePath);
        if (!id || !symbol.nodeId)
            continue;
        if (!externallyImportedFiles.has(relativePath) && !externallyReachedNodeIds.has(symbol.nodeId))
            continue;
        const entries = result.get(id) ?? [];
        entries.push({
            nodeId: symbol.nodeId,
            name: symbol.name,
            kind: symbol.type,
            relativePath,
            span: conservativeSpan(symbol),
        });
        result.set(id, entries);
    }
    return result;
}
function clusterDependencies(assignment) {
    const counts = new Map();
    let unclusteredEdges = 0;
    for (const edge of assignment.resolvedLocalImports) {
        const from = assignment.clusterIdByPath.get(edge.from);
        const to = assignment.clusterIdByPath.get(edge.to);
        if (!from || !to) {
            unclusteredEdges++;
            continue;
        }
        if (from === to)
            continue;
        const targets = counts.get(from) ?? new Map();
        targets.set(to, (targets.get(to) ?? 0) + 1);
        counts.set(from, targets);
    }
    const dependencies = new Map();
    for (const [from, targets] of counts) {
        dependencies.set(from, [...targets.entries()]
            .map(([toClusterId, importCount]) => ({ toClusterId, importCount }))
            .sort((left, right) => right.importCount - left.importCount || compareCodeUnits(left.toClusterId, right.toClusterId)));
    }
    return { dependencies, unclusteredEdges };
}
function parserGapPaths(index) {
    const parser = [];
    const imports = [];
    for (const file of [...index.files].sort((left, right) => compareCodeUnits(left.relativePath, right.relativePath))) {
        const provenance = file.provenance;
        const relativePath = normalizeRelativePath(file.relativePath);
        if (!provenance ||
            provenance.syntax.status !== 'valid' ||
            provenance.capabilities.definitions !== 'full' ||
            provenance.capabilities.imports !== 'full' ||
            provenance.capabilities.exports !== 'full') {
            parser.push(relativePath);
        }
        if (!provenance || provenance.capabilities.imports !== 'full')
            imports.push(relativePath);
    }
    return { parser, imports };
}
function boundedPaths(paths) {
    const shown = paths.slice(0, CODE_MODULE_CLUSTER_EVIDENCE_DETAIL_LIMIT);
    return { shown, omitted: Math.max(0, paths.length - shown.length) };
}
/** Cluster checkout modules from resolved local imports with deterministic, bounded output. */
export function clusterCodeModules(index, rawOptions = {}) {
    const options = CodeModuleClusterOptionsSchema.parse(rawOptions);
    const maxClusters = options.limit ?? CODE_MODULE_CLUSTER_DEFAULT_LIMIT;
    const maxMembers = options.maxMembers ?? CODE_MODULE_CLUSTER_DEFAULT_MAX_MEMBERS;
    const maxEntryPoints = options.maxEntryPoints ?? CODE_MODULE_CLUSTER_DEFAULT_MAX_ENTRY_POINTS;
    const maxDependencies = options.maxDependencies ?? CODE_MODULE_CLUSTER_DEFAULT_MAX_DEPENDENCIES;
    const assignment = buildCodeModuleClusterAssignment(index);
    const entryPointsByCluster = clusterEntryPoints(index, assignment);
    const dependencyResult = clusterDependencies(assignment);
    // One pass over the unique undirected edges: an edge is internal to its
    // shared cluster, and incident to each distinct cluster it touches — the
    // same counts the previous per-cluster scan produced in O(clusters × edges).
    const internalEdgesByCluster = new Map();
    const incidentEdgesByCluster = new Map();
    for (const [left, right] of assignment.uniqueUndirectedEdges) {
        const leftCluster = assignment.clusterIdByPath.get(left);
        const rightCluster = assignment.clusterIdByPath.get(right);
        if (leftCluster !== undefined && leftCluster === rightCluster) {
            internalEdgesByCluster.set(leftCluster, (internalEdgesByCluster.get(leftCluster) ?? 0) + 1);
            incidentEdgesByCluster.set(leftCluster, (incidentEdgesByCluster.get(leftCluster) ?? 0) + 1);
            continue;
        }
        if (leftCluster !== undefined) {
            incidentEdgesByCluster.set(leftCluster, (incidentEdgesByCluster.get(leftCluster) ?? 0) + 1);
        }
        if (rightCluster !== undefined) {
            incidentEdgesByCluster.set(rightCluster, (incidentEdgesByCluster.get(rightCluster) ?? 0) + 1);
        }
    }
    const fullClusters = assignment.clusters.map(cluster => {
        const members = cluster.members;
        const internalEdges = internalEdgesByCluster.get(cluster.id) ?? 0;
        const incidentEdges = incidentEdgesByCluster.get(cluster.id) ?? 0;
        const keywords = clusterKeywords(members);
        const allEntryPoints = entryPointsByCluster.get(cluster.id) ?? [];
        const allDependencies = dependencyResult.dependencies.get(cluster.id) ?? [];
        const omitted = {
            members: Math.max(0, members.length - maxMembers),
            entryPoints: Math.max(0, allEntryPoints.length - maxEntryPoints),
            dependencies: Math.max(0, allDependencies.length - maxDependencies),
        };
        return {
            id: cluster.id,
            label: keywords.slice(0, 3).join(' / ') || fallbackLabel(members),
            keywords,
            size: members.length,
            cohesion: roundRatio(internalEdges, incidentEdges),
            members: members.slice(0, maxMembers),
            entryPoints: allEntryPoints.slice(0, maxEntryPoints),
            dependencies: allDependencies.slice(0, maxDependencies),
            truncated: omitted.members + omitted.entryPoints + omitted.dependencies > 0,
            omitted,
        };
    });
    const clusters = fullClusters.slice(0, maxClusters);
    const omitted = Math.max(0, fullClusters.length - clusters.length);
    const nestedOmissions = clusters.reduce((totals, cluster) => ({
        members: totals.members + cluster.omitted.members,
        entryPoints: totals.entryPoints + cluster.omitted.entryPoints,
        dependencies: totals.dependencies + cluster.omitted.dependencies,
    }), { members: 0, entryPoints: 0, dependencies: 0 });
    const clusteredFiles = assignment.clusters.reduce((total, cluster) => total + cluster.members.length, 0);
    const unclusteredFiles = Math.max(0, index.files.length - clusteredFiles);
    const gaps = parserGapPaths(index);
    const parserDetails = boundedPaths(gaps.parser);
    const importDetails = boundedPaths(gaps.imports);
    const sortedSkipped = [...index.skippedFiles].sort((left, right) => compareCodeUnits(left.relativePath, right.relativePath) || compareCodeUnits(left.reason, right.reason));
    const shownSkipped = sortedSkipped.slice(0, CODE_MODULE_CLUSTER_EVIDENCE_DETAIL_LIMIT);
    const unresolvedImports = index.edges.filter(edge => edge.type === 'imports' && (edge.status !== 'resolved' || edge.importKind === 'unresolved')).length;
    const externalImports = index.edges.filter(edge => edge.type === 'imports' && edge.status === 'resolved' && edge.importKind === 'external').length;
    const budgetDroppedFiles = index.truncated?.droppedFiles ?? 0;
    const coveragePartial = sortedSkipped.length > 0 ||
        budgetDroppedFiles > 0 ||
        gaps.parser.length > 0 ||
        gaps.imports.length > 0 ||
        unresolvedImports > 0 ||
        assignment.unreachableImportEndpoints > 0;
    const truncated = omitted > 0 || clusters.some(cluster => cluster.truncated);
    return CodeModuleClusterResultSchema.parse({
        status: 'resolved',
        version: CODE_MODULE_CLUSTERS_VERSION,
        clusters,
        total: fullClusters.length,
        omitted,
        truncated,
        unclusteredFiles,
        honesty: {
            freshness: 'index-snapshot',
            stalenessReason: 'working tree was not rechecked after index construction',
            coverage: {
                status: coveragePartial ? 'partial' : 'full',
                indexedFiles: index.files.length,
                clusteredFiles,
                unclusteredFiles,
                skippedFiles: sortedSkipped.length,
                budgetDroppedFiles,
                parserGapFiles: gaps.parser.length,
                importGapFiles: gaps.imports.length,
                resolvedLocalImports: assignment.resolvedLocalImports.length,
                uniqueLocalImportEdges: assignment.uniqueUndirectedEdges.length,
                unreachableImportEndpoints: assignment.unreachableImportEndpoints,
                unresolvedImports,
                externalImports,
                unclusteredDependencyEdges: dependencyResult.unclusteredEdges,
            },
            evidenceGaps: {
                skippedFiles: shownSkipped,
                skippedFilesOmitted: sortedSkipped.length - shownSkipped.length,
                parserGapPaths: parserDetails.shown,
                parserGapPathsOmitted: parserDetails.omitted,
                importGapPaths: importDetails.shown,
                importGapPathsOmitted: importDetails.omitted,
            },
            omissions: {
                clusters: omitted,
                ...nestedOmissions,
            },
            bounds: {
                maxClusters,
                maxMembers,
                maxEntryPoints,
                maxDependencies,
                propagationPasses: CODE_MODULE_CLUSTER_LABEL_PROPAGATION_PASSES,
            },
        },
    });
}
//# sourceMappingURL=clusters.js.map