import { isoDay } from "./owners.js";
import { MAX_EVIDENCE, fileReader, impactedEvidence, reachTerms, seedEvidence, } from "./evidence.js";
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
/** Owners as the page carries them: no scores, since a weight nobody can check is
 * not evidence — the commit count and the date are what a reader can verify. */
function nodeOwners(owners) {
    if (owners === undefined || owners.length === 0)
        return undefined;
    return owners.map((o) => ({ name: o.name, handle: o.handle, commits: o.commits, last: isoDay(o.last) }));
}
/** Kinds that carry behaviour — what "did a test reach it" is asked of. */
const BEHAVIOURAL = new Set(["function", "method", "class", "constructor"]);
/** Node-type names, which double as the viewer's legend labels. */
const CHANGED = "changed";
const AFFECTED = "affected";
/** One sentence about the tests, rather than the two that used to say it twice. */
function testNote(a) {
    const signals = {
        changed: `A test that reaches it changed too — ${a.reached} of ${plural(a.behavioural, "function")} covered.`,
        stale: `Tests reach it, and this diff left all ${plural(a.testFiles.length, "of them", "of them")} alone.`,
        none: "No test reaches this code.",
        na: "No function or class changed here — types, config or comments only.",
    };
    return signals[a.tests];
}
/** `a`, `a and b`, `a, b and c`, `a, b and 3 more` — never a bare `+N`, which
 * reads as a version number when it sits next to a symbol name. */
function list(names) {
    const shown = names.slice(0, 3);
    const rest = names.length - shown.length;
    const tail = rest > 0 ? `${rest} more` : shown.pop();
    return shown.length === 0 ? String(tail) : `${shown.join(", ")} and ${tail}`;
}
/**
 * What this PR did to an area, in the reviewer's words.
 *
 * The old copy counted ("2 files, 3 symbols") and then said the same thing about
 * tests twice ("no test reaches it. 0 of 2 changed functions have a test that
 * reaches them"), where `0 of 2` reads like a coverage score. Naming the symbols
 * is shorter AND the thing a reviewer wanted to know.
 */
function changedSummary(a, seeds) {
    const named = seeds.filter((s) => !s.wholeFile);
    const behaviour = named.filter((s) => BEHAVIOURAL.has(s.kind)).map((s) => s.name);
    const rest = named.filter((s) => !BEHAVIOURAL.has(s.kind)).map((s) => s.name);
    const where = ` in ${plural(a.files.length, "file")}.`;
    let what;
    if (behaviour.length > 0) {
        what = `Edits ${list(behaviour)}`;
        if (rest.length > 0)
            what += `, plus ${list(rest)}`;
    }
    else if (rest.length > 0) {
        what = `Edits ${list(rest)}`;
    }
    else {
        // Whole-file seeds only: an added file, or edits outside every symbol.
        what = `Changes ${plural(a.seeds, "region")} outside any symbol`;
    }
    return `${what}${where} ${testNote(a)}`;
}
function changedNode(a, seeds, byPath) {
    const files = new Set(a.files);
    const mine = seeds.filter((s) => files.has(s.path));
    const evidence = mine
        .slice(0, MAX_EVIDENCE)
        .map((s) => seedEvidence(s, byPath.get(s.path)))
        .filter((e) => e !== null);
    return {
        id: `changed:${a.key}`,
        name: a.label,
        type: CHANGED,
        summary: changedSummary(a, mine),
        sources: a.files,
        evidence: evidence.length > 0 ? evidence : undefined,
        owners: nodeOwners(a.owners),
    };
}
function affectedNode(m, reach, read) {
    const nearest = m.symbols[0];
    const how = nearest ? ` — nearest is ${nearest.name}, ${plural(nearest.depth, "hop")} away.` : ".";
    const evidence = m.symbols
        .slice(0, MAX_EVIDENCE)
        .map((s) => impactedEvidence(s, reach, read))
        .filter((e) => e !== null);
    return {
        id: `affected:${m.key}`,
        name: m.label,
        type: AFFECTED,
        // Leading with "not edited" is the fact a reviewer needs first: this area is
        // collateral, not part of their diff.
        summary: `Not edited by this PR. ${plural(m.symbols.length, "symbol")} here ${m.symbols.length === 1 ? "reaches" : "reach"} code it changed${how}`,
        sources: m.symbols.map((s) => `${s.path} · ${s.span}`),
        evidence: evidence.length > 0 ? evidence : undefined,
        owners: nodeOwners(m.owners),
    };
}
/**
 * What an empty canvas means.
 *
 * A PR that only touches lockfiles, workflows or docs has no radius, and the page
 * published for it used to be a blank canvas behind a link promising a graph — a
 * reader cannot tell that from a broken export. Naming the reason (usually: no
 * parser claims these files) turns it into an answer.
 */
function emptyNote(r) {
    if (r.changed.length === 0)
        return "This pull request changes no files.";
    if (r.unindexed.length === r.changed.length) {
        const shown = r.unindexed.slice(0, 3).join(", ");
        const rest = r.unindexed.length > 3 ? `, +${r.unindexed.length - 3} more` : "";
        return `Nothing to draw: no parser claims ${plural(r.unindexed.length, "changed file")} (${shown}${rest}), so this change has no symbols to trace.`;
    }
    return "Nothing to draw: the changed symbols have no resolved dependents at this depth.";
}
/**
 * Build the viewer graph for a report.
 *
 * `root` is the repository the report was taken in; without it the affected side
 * loses its snippets — there is nothing to read — but keeps everything else, which
 * is what a unit test wants.
 *
 * Nothing is capped at the graph level. The caps in the markdown renderer exist
 * because a Mermaid diagram inside a comment cannot lay out more than a handful of
 * circles; this page is force-directed and pannable, which is why it exists.
 */
export function blastVizGraph(r, opts = {}) {
    const byPath = new Map(r.changed.map((f) => [f.path, f]));
    // Longest name first, so `export` cannot match ahead of `exportViz` on a line
    // that contains both.
    const reach = reachTerms(r.seeds, r.changed);
    const read = fileReader(opts.root);
    const nodes = [
        ...r.areas.map((a) => changedNode(a, r.seeds, byPath)),
        ...r.modules.map((m) => affectedNode(m, reach, read)),
    ];
    /** Changed path → the id of the area node standing for it. */
    const areaOf = new Map();
    for (const a of r.areas)
        for (const f of a.files)
            areaOf.set(f, `changed:${a.key}`);
    const edges = [];
    const seen = new Set();
    for (const m of r.modules) {
        for (const from of m.from) {
            const source = areaOf.get(from);
            if (!source)
                continue; // a changed test file: the signal, not an area
            const affected = `affected:${m.key}`;
            const key = `${affected}→${source}`;
            if (seen.has(key))
                continue;
            seen.add(key);
            // Edge direction is the DEPENDENCY, affected → changed, not the impact. The
            // viewer words its panels "depends on" / "depended on by" from the edge, so
            // pointing it the other way made an affected area read as the thing being
            // depended upon — backwards, on the one screen a reviewer is reading closely.
            edges.push({
                source: affected,
                target: source,
                relation: "depends_on",
                description: "this area calls or imports code the PR changed",
            });
        }
    }
    return {
        meta: {
            nodeCount: nodes.length,
            edgeCount: edges.length,
            emptyNote: nodes.length === 0 ? emptyNote(r) : undefined,
            // Changed files no parser claims: named here so a thin graph is explained
            // rather than read as "this PR is safe".
            skippedFiles: r.unindexed.length,
            droppedEdges: 0,
        },
        nodes,
        edges,
    };
}
//# sourceMappingURL=viz.js.map