import { assertValidGitPath } from '../../git/parse.js';
import { makeLineageId } from './lineage.js';
import { entitiesFromFormat, entitiesFromParsedFile, entityStateKey } from './snapshot.js';
import { ENTITY_LINEAGE_ID_VERSION } from './types.js';
/** How many near-misses a `not_found` carries back, so the answer stays useful without becoming a listing. */
const MAX_SUGGESTIONS = 10;
function push(map, key, value) {
    const bucket = map.get(key);
    if (bucket)
        bucket.push(value);
    else
        map.set(key, [value]);
}
function fileTestSignal(file, recognizer) {
    // Every name the file declares or imports, so a `TEST_CALL_NAMES` hit can be
    // qualified. A module that defines and calls its own `context(...)` is not a
    // test, and matching the bare name asserted that it was.
    const bindings = file.symbols.map(symbol => ({ name: symbol.name }));
    for (const entry of file.imports) {
        for (const binding of entry.bindings)
            bindings.push({ name: binding.localName, specifier: entry.specifier });
    }
    return recognizer.classifyFile({
        relativePath: file.relativePath,
        importSpecifiers: file.imports.map(entry => entry.specifier),
        // Unqualified calls only. A test framework's `describe`/`it`/`test`/
        // `context` are always called bare, whereas `pattern.test(value)` and
        // `session.context(query)` are ordinary methods that merely share those
        // names — and passing them through would mark most files in a codebase as
        // tests, which is exactly the kind of guess affected-test discovery must
        // not make.
        calleeNames: file.calls.filter(entry => !entry.qualifier).map(entry => entry.calleeName),
        bindings,
    });
}
/**
 * Project the current index into entity space and attach each entity's lineage.
 *
 * This is the join between two different notions of "the code": the graph
 * index, which knows about edges and node ids in the working tree, and the
 * lineage index, which knows what each entity has been through. `entityStateKey`
 * is the key on both sides.
 */
export function buildLiveEntities(input) {
    const live = {
        all: [],
        byNodeId: new Map(),
        byLineageId: new Map(),
        byName: new Map(),
        byQualifiedName: new Map(),
        byPath: new Map(),
        lineageIdCollisions: new Set(),
    };
    const graphNodesById = new Map(input.index.nodes.map(node => [node.id, node]));
    for (const file of input.index.files) {
        const { records, symbols } = entitiesFromParsedFile(file, file.relativePath);
        if (records.length === 0)
            continue;
        const fileSignal = fileTestSignal(file, input.recognizer);
        for (const [position, record] of records.entries()) {
            const symbol = symbols[position];
            const symbolSignal = input.recognizer.classifySymbol({
                name: record.name,
                kind: record.kind,
                containerName: record.containerName,
            });
            const testSignal = input.recognizer.classifyEntity(fileSignal, symbolSignal);
            const committed = input.entityIndex.state.byStateKey.get(entityStateKey(record));
            const ref = {
                lineageId: committed ?? provisionalLineageId(input.repoIdentity, record),
                name: record.name,
                qualifiedName: record.qualifiedName,
                kind: record.kind,
                relativePath: record.relativePath,
                startLine: record.startLine,
                endLine: record.endLine,
                isExported: record.isExported,
                isTest: testSignal.isTest,
            };
            if (symbol?.nodeId)
                ref.nodeId = symbol.nodeId;
            if (record.containerName)
                ref.containerName = record.containerName;
            const graphSignature = symbol?.nodeId ? graphNodesById.get(symbol.nodeId)?.signature : undefined;
            if (graphSignature ?? record.signature)
                ref.signature = graphSignature ?? record.signature;
            if (testSignal.isTest)
                ref.testReasons = testSignal.reasons;
            if (!committed)
                markSynthesized(ref, input, record.relativePath);
            const entity = { record, ref };
            live.all.push(entity);
            if (ref.nodeId)
                live.byNodeId.set(ref.nodeId, entity);
            if (!live.byLineageId.has(ref.lineageId))
                live.byLineageId.set(ref.lineageId, entity);
            else
                live.lineageIdCollisions?.add(ref.lineageId);
            push(live.byName, record.name, entity);
            push(live.byQualifiedName, record.qualifiedName, entity);
            push(live.byPath, record.relativePath, entity);
        }
    }
    for (const [relativePath, content] of input.formatFiles ?? []) {
        const { records } = entitiesFromFormat(content, relativePath);
        if (records.length === 0)
            continue;
        const fileSignal = input.recognizer.classifyFile({ relativePath });
        for (const record of records) {
            const symbolSignal = input.recognizer.classifySymbol({ name: record.name, kind: record.kind });
            const testSignal = input.recognizer.classifyEntity(fileSignal, symbolSignal);
            const committed = input.entityIndex.state.byStateKey.get(entityStateKey(record));
            const ref = {
                lineageId: committed ?? provisionalLineageId(input.repoIdentity, record),
                name: record.name,
                qualifiedName: record.qualifiedName,
                kind: record.kind,
                relativePath: record.relativePath,
                startLine: record.startLine,
                endLine: record.endLine,
                isExported: false,
                isTest: testSignal.isTest,
            };
            if (record.containerName)
                ref.containerName = record.containerName;
            if (testSignal.isTest)
                ref.testReasons = testSignal.reasons;
            if (!committed)
                markSynthesized(ref, input, record.relativePath);
            const entity = { record, ref };
            live.all.push(entity);
            if (!live.byLineageId.has(ref.lineageId))
                live.byLineageId.set(ref.lineageId, entity);
            else
                live.lineageIdCollisions?.add(ref.lineageId);
            push(live.byName, record.name, entity);
            push(live.byQualifiedName, record.qualifiedName, entity);
            push(live.byPath, record.relativePath, entity);
        }
    }
    return live;
}
/**
 * Say *why* an entity has no committed lineage.
 *
 * There are two reasons and they mean opposite things. Either git has never
 * seen this entity — the file is untracked, or the declaration is a working-tree
 * edit — which is a fact about the repository. Or the index hit a bound and
 * never reached it, which is a fact about OCC. A bounded index that reports the
 * second as the first turns its own limits into a false claim that a committed,
 * unmodified file is uncommitted, with no signal a caller could use to tell.
 */
function markSynthesized(ref, input, relativePath) {
    if (input.untracked?.has(relativePath)) {
        ref.provisional = true;
        return;
    }
    if (input.truncated) {
        ref.indeterminate = true;
        return;
    }
    ref.provisional = true;
}
/**
 * A stable id for something git has never seen.
 *
 * Derived from the entity's current position rather than from a birth commit,
 * because there is no birth commit yet. It is reproducible within a working
 * tree, and `provisional` marks it so nothing mistakes it for history.
 */
export function provisionalLineageId(repoIdentity, record) {
    return makeLineageId({
        repoIdentity,
        birthCommit: 'uncommitted',
        birthPath: record.relativePath,
        birthKind: record.kind,
        birthQualifiedName: record.qualifiedName,
        birthOrdinal: record.ordinal,
    });
}
export function normalizeRelativePath(value) {
    assertValidGitPath(value);
    return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
}
/**
 * Does this entity's file fall under the requested path?
 *
 * One rule covers both a file and a directory, with no stat and no trailing
 * slash convention: a path matches itself, or anything beneath it. An exact file
 * path therefore still matches only that file, because no path can begin with
 * `src/reader.ts/` — while `src/services` matches every entity in the subtree,
 * which is the natural first question to ask about an unfamiliar one.
 */
export function matchesPathScope(entityPath, wanted) {
    if (entityPath === wanted)
        return true;
    return wanted.length > 0 && entityPath.startsWith(`${wanted}/`);
}
/** Normalize a caller-supplied path scope; a trailing slash carries no extra meaning. */
export function normalizePathScope(value) {
    return normalizeRelativePath(value).replace(/\/+$/, '');
}
/**
 * Resolve a query to exactly one entity, or refuse.
 *
 * A qualified name is tried before a bare one, so `Cache.get` never competes
 * with a free function named `get`. When more than one entity survives, the
 * result is `ambiguous` with every candidate listed: silently taking the first
 * would make the answer depend on file iteration order, and the caller would
 * have no way to know it happened.
 */
export function resolveEntity(query, live) {
    const wantedPath = query.relativePath ? normalizePathScope(query.relativePath) : undefined;
    const narrow = (entities) => entities.filter(entity => {
        if (query.kind && entity.record.kind !== query.kind)
            return false;
        if (wantedPath && !matchesPathScope(entity.record.relativePath, wantedPath))
            return false;
        return true;
    });
    const qualified = narrow(live.byQualifiedName.get(query.name) ?? []);
    const found = qualified.length > 0 ? qualified : narrow(live.byName.get(query.name) ?? []);
    const matches = preferDeclarations(found, query);
    if (matches.length === 1)
        return { status: 'resolved', entity: matches[0] };
    if (matches.length > 1) {
        return {
            status: 'ambiguous',
            candidates: matches.map(entity => entity.ref),
            message: `"${query.name}" matches ${matches.length} entities. Narrow it with --file or --kind.`,
        };
    }
    return {
        status: 'not_found',
        candidates: suggestionsFor(query, live),
        message: describeMiss(query, live, wantedPath),
    };
}
/**
 * A test block named after what it tests must not make that thing
 * unaddressable.
 *
 * `describe('buildRepoMap', …)` is a real entity with its own name, so a bare
 * query for `buildRepoMap` matches both it and the function. Refusing there
 * would break the most ordinary query there is, and a declaration is what a
 * bare name almost always means. This is precedence, not a guess: it applies
 * only when exactly one declaration survives, and the block stays reachable
 * through `kind` or a path.
 */
function preferDeclarations(matches, query) {
    if (matches.length < 2 || query.kind)
        return [...matches];
    const declarations = matches.filter(entity => entity.record.kind !== 'test' && entity.record.kind !== 'test_suite');
    return declarations.length === 1 ? declarations : [...matches];
}
function describeMiss(query, live, wantedPath) {
    // Distinguish "no such entity" from "it exists, but not where you said" —
    // the second is a different mistake and deserves a different message.
    const anywhere = [...(live.byQualifiedName.get(query.name) ?? []), ...(live.byName.get(query.name) ?? [])];
    if (anywhere.length === 0)
        return `No entity named "${query.name}".`;
    if (wantedPath && !anywhere.some(entity => matchesPathScope(entity.record.relativePath, wantedPath))) {
        const paths = [...new Set(anywhere.map(entity => entity.record.relativePath))].slice(0, 3);
        return `No entity named "${query.name}" in ${wantedPath}; it exists in ${paths.join(', ')}.`;
    }
    if (query.kind) {
        const kinds = [...new Set(anywhere.map(entity => entity.record.kind))];
        return `No ${query.kind} named "${query.name}"; found ${kinds.join(', ')} instead.`;
    }
    return `No entity named "${query.name}".`;
}
/**
 * Resolve a durable lineage id against one entity view.
 *
 * The view is whichever `LiveEntityIndex` answers the caller's question — the
 * working tree for live hashes, a committed projection for lineage-lane facts.
 * A lineage id maps to at most one entity by construction (`byLineageId` keeps
 * the first declaration on the rare collision, and `collision` reports when
 * that happened), so unlike name resolution there is no ambiguous arm.
 */
export function resolveEntityHandle(lineageId, view, entityIndex) {
    const expectedPrefix = `ent${ENTITY_LINEAGE_ID_VERSION}:`;
    if (!lineageId.startsWith(expectedPrefix)) {
        return {
            status: 'not_found',
            reason: 'version-mismatch',
            message: `Lineage id "${lineageId}" was minted under a different lineage schema version; ` +
                `this OCC produces "${expectedPrefix}…" ids. Re-capture the anchor.`,
        };
    }
    const entity = view.byLineageId.get(lineageId);
    if (entity) {
        const collision = view.lineageIdCollisions?.has(lineageId) === true;
        return collision ? { status: 'resolved', entity, collision } : { status: 'resolved', entity };
    }
    const events = entityIndex.eventsByLineage.get(lineageId);
    const lastEvent = events?.at(-1);
    if (lastEvent?.changeType === 'deleted') {
        return {
            status: 'not_found',
            reason: 'deleted',
            message: `Lineage ${lineageId} ended: deleted in commit ${lastEvent.commit.slice(0, 12)}.`,
            deletionEvent: lastEvent,
        };
    }
    const head = entityIndex.state.byLineageId.get(lineageId);
    if (head || (events && events.length > 0)) {
        return {
            status: 'not_found',
            reason: 'absent',
            message: `Lineage ${lineageId} is indexed with no recorded deletion, but the requested entity view ` +
                'no longer contains it (a working-tree deletion, or an index visibility gap).',
            ...(head ? { head } : {}),
        };
    }
    return {
        status: 'not_found',
        reason: 'outside-window',
        message: `Lineage ${lineageId} is not in the indexed window: it may belong to another repository, ` +
            'to history beyond the walk, or to a rewound window. Raise maxCommits or re-capture the anchor.',
    };
}
function suggestionsFor(query, live) {
    const wanted = query.name.toLowerCase();
    const suggestions = [];
    for (const entity of live.all) {
        const name = entity.record.name.toLowerCase();
        if (name === wanted || name.includes(wanted) || wanted.includes(name)) {
            suggestions.push(entity.ref);
            if (suggestions.length >= MAX_SUGGESTIONS)
                break;
        }
    }
    return suggestions;
}
//# sourceMappingURL=resolve.js.map