import { z } from 'zod';
import { compareCodeUnits } from '../../utils.js';
import { rankNodesCached } from '../rank.js';
import { classifyEntityEvent } from './change-class.js';
import { countEntityAmbiguityEvidence, summarizeEntityEvidenceGaps } from './evidence.js';
import { matchesPathScope, normalizePathScope } from './resolve.js';
import { TRUNCATION_CODES } from './store.js';
const DAY_MS = 86_400_000;
export const EntityHotspotOptionsSchema = z.object({
    limit: z.number().int().positive().max(500).optional(),
    relativePath: z.string().min(1).optional(),
    /** ISO 8601 date or timestamp. */
    since: z.string().min(1).optional(),
    /** Blend graph importance into the otherwise history-and-size-only score. */
    pageRank: z.boolean().optional(),
});
function cutoffFor(since) {
    if (since === undefined)
        return undefined;
    const instant = Date.parse(since);
    return Number.isFinite(instant) ? instant : undefined;
}
/**
 * Rank current entities from persisted lineage events.
 *
 * The aggregation never reparses history and never upgrades an unknown change
 * to a logic change. A bounded-window seed is also excluded from the logic
 * count: it proves only that an entity existed at the boundary, not that it was
 * created there.
 */
export function rankEntityHotspots(options, context) {
    const parsed = EntityHotspotOptionsSchema.safeParse(options);
    if (!parsed.success) {
        return {
            status: 'error',
            code: 'invalid-options',
            message: parsed.error.issues[0]?.message ?? 'Invalid hotspot options.',
        };
    }
    const cutoff = cutoffFor(parsed.data.since);
    if (parsed.data.since !== undefined && cutoff === undefined) {
        return {
            status: 'error',
            code: 'invalid-since',
            message: `Invalid --since value "${parsed.data.since}"; use an ISO 8601 date or timestamp.`,
        };
    }
    const now = options.now ?? new Date();
    if (!Number.isFinite(now.getTime())) {
        return { status: 'error', code: 'invalid-options', message: 'now must be a valid Date.' };
    }
    const pathScope = parsed.data.relativePath ? normalizePathScope(parsed.data.relativePath) : undefined;
    const pageRankByNode = parsed.data.pageRank
        ? new Map(rankNodesCached(context.index).nodes.map(node => [node.nodeId, node.normalizedScore]))
        : undefined;
    let eventsConsidered = 0;
    let unknownChangeEvents = 0;
    let boundaryBirths = 0;
    let entitiesConsidered = 0;
    const rows = [];
    for (const liveEntity of context.live.all) {
        const ref = liveEntity.ref;
        if (ref.provisional || ref.indeterminate)
            continue;
        // Format entities deliberately have no code-graph node. Including a
        // package-lock object or a changelog heading would bury the hot function
        // this code command is meant to surface.
        if (!ref.nodeId)
            continue;
        if (pathScope && !matchesPathScope(ref.relativePath, pathScope))
            continue;
        entitiesConsidered++;
        const head = context.entityIndex.state.byLineageId.get(ref.lineageId);
        const birthAtWindowBoundary = head?.birthAtWindowBoundary === true;
        if (birthAtWindowBoundary)
            boundaryBirths++;
        let logicChangeCount = 0;
        let cosmeticChangeCount = 0;
        let neutralChangeCount = 0;
        let unknownChangeCount = 0;
        let lastLogicChange;
        let lastLogicInstant = Number.NEGATIVE_INFINITY;
        for (const event of context.entityIndex.eventsByLineage.get(ref.lineageId) ?? []) {
            const instant = Date.parse(event.committedAt);
            if (cutoff !== undefined && (!Number.isFinite(instant) || instant < cutoff))
                continue;
            eventsConsidered++;
            switch (classifyEntityEvent(event)) {
                case 'logic':
                    logicChangeCount++;
                    if (Number.isFinite(instant) && instant > lastLogicInstant) {
                        lastLogicInstant = instant;
                        lastLogicChange = event.committedAt;
                    }
                    break;
                case 'cosmetic':
                    cosmeticChangeCount++;
                    break;
                case 'neutral':
                    neutralChangeCount++;
                    break;
                case 'unknown':
                    unknownChangeCount++;
                    unknownChangeEvents++;
                    break;
            }
        }
        if (logicChangeCount === 0 || lastLogicChange === undefined)
            continue;
        const tokenCount = liveEntity.record.hashes.tokenCount;
        const weight = tokenCount !== undefined
            ? { value: tokenCount, basis: 'token-count' }
            : {
                value: Math.max(1, liveEntity.record.endLine - liveEntity.record.startLine + 1),
                basis: 'line-span',
            };
        const pageRank = ref.nodeId ? pageRankByNode?.get(ref.nodeId) : undefined;
        const pageRankFactor = pageRank === undefined ? 1 : 1 + pageRank / 100;
        const row = {
            entity: ref,
            logicChangeCount,
            cosmeticChangeCount,
            neutralChangeCount,
            unknownChangeCount,
            lastLogicChange,
            ageDays: Math.max(0, Math.floor((now.getTime() - lastLogicInstant) / DAY_MS)),
            weight,
            score: logicChangeCount * weight.value * pageRankFactor,
            birthAtWindowBoundary,
        };
        if (pageRank !== undefined)
            row.pageRank = pageRank;
        rows.push(row);
    }
    rows.sort((a, b) => b.score - a.score ||
        b.logicChangeCount - a.logicChangeCount ||
        compareCodeUnits(a.entity.relativePath, b.entity.relativePath) ||
        a.entity.startLine - b.entity.startLine);
    const limit = parsed.data.limit ?? 20;
    const selected = rows.slice(0, limit);
    const omitted = Math.max(0, rows.length - selected.length);
    const diagnostics = context.entityIndex.status.diagnostics;
    const commitsConsidered = context.entityIndex.commits.filter(commit => {
        if (cutoff === undefined)
            return true;
        const instant = Date.parse(commit.committedAt);
        return Number.isFinite(instant) && instant >= cutoff;
    }).length;
    return {
        status: 'resolved',
        rows: selected,
        scoring: parsed.data.pageRank
            ? 'logic-change-count × size-weight × pagerank-factor'
            : 'logic-change-count × size-weight',
        honesty: {
            freshness: {
                fresh: context.freshness.fresh,
                reasons: context.freshness.reasons,
                workingTreeMeasured: context.freshness.workingTree !== undefined,
            },
            truncation: {
                truncated: context.entityIndex.status.truncated || omitted > 0,
                reasons: diagnostics.filter(diagnostic => TRUNCATION_CODES.has(diagnostic.code)),
                omitted,
            },
            coverage: {
                commitsIndexed: context.entityIndex.status.commitsIndexed,
                commitsConsidered,
                eventsConsidered,
                entitiesConsidered,
                entitiesRanked: rows.length,
                boundaryBirths,
                unknownChangeEvents,
                ...(parsed.data.since ? { since: parsed.data.since } : {}),
            },
            ambiguityCount: countEntityAmbiguityEvidence(context.entityIndex.counters.ambiguitiesRefused, diagnostics),
            evidenceGaps: summarizeEntityEvidenceGaps(diagnostics),
        },
    };
}
//# sourceMappingURL=hotspots.js.map