/**
 * graph-render — produce an inline PNG of an account-scoped Neo4j
 * subgraph rooted at a given nodeId, walking up to `hopDepth` hops.
 *
 * Surface contract:
 *   - The root node must exist and belong to the caller's account.
 *     A missing or foreign-account root returns a structured refusal,
 *     never throws.
 *   - The expansion is bounded by both `hopDepth` (max 3 to keep the
 *     image legible) and a hard 200-node cap. Subgraphs that exceed
 *     the cap render the first 200 nodes and carry a `truncated`
 *     envelope so the agent can tell the operator.
 *   - `:Trashed` nodes are excluded everywhere — matches memory-search.
 *   - One observability line per invocation, prefixed `[graph-render]`.
 *
 * The Cypher hopDepth is INLINED into the query string after a strict
 * validate (`Number.isInteger && 0 <= n <= 3`) because Neo4j does not
 * parameterise variable-length path bounds. Anything outside that range
 * fails fast at the param-validation layer, well before string assembly.
 */
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { getSession } from "../lib/neo4j.js";
import { layoutGraph } from "../render/layout.js";
import { drawGraph } from "../render/draw.js";
function getAccountsDir() {
    const root = process.env.PLATFORM_ROOT ?? process.env.MAXY_PLATFORM_ROOT;
    if (!root) {
        throw new Error("[graph-render] PLATFORM_ROOT not set (also checked MAXY_PLATFORM_ROOT) — cannot resolve account output dir.");
    }
    return resolve(root, "..", "data/accounts");
}
function slugifyElementId(elementId) {
    // Neo4j elementIds use `:` as a separator; filesystem-safe replacement
    // keeps the digest readable while avoiding path-segment confusion.
    return elementId.replace(/[:/\\]/g, "_");
}
const NODE_CAP = 200;
const MAX_HOP_DEPTH = 3;
const IMAGE_WIDTH = 1200;
const IMAGE_HEIGHT = 900;
function logLine(line) {
    process.stderr.write(`${line}\n`);
}
export async function graphRender(params) {
    const { accountId, nodeId } = params;
    const hopDepth = params.hopDepth;
    if (!Number.isInteger(hopDepth) || hopDepth < 0 || hopDepth > MAX_HOP_DEPTH) {
        return {
            ok: false,
            reason: "invalid-input",
            message: `hopDepth must be an integer between 0 and ${MAX_HOP_DEPTH} (got ${String(hopDepth)})`,
        };
    }
    const t0 = Date.now();
    const session = getSession();
    try {
        // Step 1 — confirm root exists and belongs to this account. The
        // explicit two-step check (rather than relying on the expand to
        // return zero rows) is what lets us return reason="scope" vs
        // reason="empty" with a meaningful message.
        const rootCheck = await session.run(`MATCH (root)
       WHERE elementId(root) = $nodeId
       RETURN root.accountId AS rootAccountId, labels(root) AS rootLabels`, { nodeId });
        if (rootCheck.records.length === 0) {
            logLine(`[graph-render] reject account=${accountId} reason=scope nodeId=${nodeId} cause=not-found`);
            return {
                ok: false,
                reason: "scope",
                message: `No node with id ${nodeId} is visible to this account.`,
            };
        }
        const rootAccountId = rootCheck.records[0].get("rootAccountId");
        if (rootAccountId !== accountId) {
            logLine(`[graph-render] reject account=${accountId} reason=scope nodeId=${nodeId} cause=foreign-account`);
            return {
                ok: false,
                reason: "scope",
                message: `Node ${nodeId} is not visible to this account.`,
            };
        }
        logLine(`[graph-render] start account=${accountId} nodeId=${nodeId} hopDepth=${hopDepth}`);
        // Step 2 — expand. Variable-length path bound is inlined after the
        // hopDepth validation above. Account filter + trash filter applied to
        // every node on every path. RELATIONSHIPS only between matched nodes.
        const expandQuery = `
      MATCH (root) WHERE elementId(root) = $nodeId
      MATCH path = (root)-[*0..${hopDepth}]-(n)
      WHERE n.accountId = $accountId AND NOT n:Trashed
      WITH collect(DISTINCT n) AS nodes
      WITH nodes[0..$cap] AS capped, size(nodes) AS totalCount
      UNWIND capped AS node
      WITH collect({ id: elementId(node), labels: labels(node), properties: properties(node) }) AS nodeRows,
           [n IN capped | elementId(n)] AS capIds, totalCount
      OPTIONAL MATCH (a)-[r]->(b)
      WHERE elementId(a) IN capIds AND elementId(b) IN capIds
      RETURN nodeRows,
             collect(DISTINCT { from: elementId(startNode(r)), to: elementId(endNode(r)), type: type(r) }) AS edgeRows,
             totalCount
    `;
        const result = await session.run(expandQuery, {
            nodeId,
            accountId,
            cap: NODE_CAP,
        });
        if (result.records.length === 0) {
            logLine(`[graph-render] reject account=${accountId} reason=empty nodeId=${nodeId}`);
            return {
                ok: false,
                reason: "empty",
                message: "Root node found but its neighbourhood is empty after trash filter.",
            };
        }
        const record = result.records[0];
        const nodeRows = record.get("nodeRows");
        const edgeRowsRaw = record.get("edgeRows");
        const totalCountRaw = record.get("totalCount");
        const totalCount = typeof totalCountRaw === "number"
            ? totalCountRaw
            : (totalCountRaw?.toNumber?.() ?? nodeRows.length);
        // OPTIONAL MATCH on no rels produces one {from:null,to:null,type:null}
        // row from collect; strip those.
        const edges = edgeRowsRaw
            .filter((e) => e.from && e.to && e.type)
            .map((e) => ({ from: e.from, to: e.to, type: e.type }));
        if (nodeRows.length === 0) {
            logLine(`[graph-render] reject account=${accountId} reason=empty nodeId=${nodeId}`);
            return {
                ok: false,
                reason: "empty",
                message: "Subgraph contained no nodes after trash filter.",
            };
        }
        const layoutNodes = nodeRows.map((n) => ({
            id: n.id,
            labels: n.labels,
        }));
        const nodeProperties = {};
        for (const n of nodeRows)
            nodeProperties[n.id] = n.properties;
        const layout = layoutGraph(layoutNodes, edges, {
            width: IMAGE_WIDTH,
            height: IMAGE_HEIGHT,
        });
        let pngBuffer;
        try {
            pngBuffer = drawGraph({
                layout,
                nodeProperties,
                width: IMAGE_WIDTH,
                height: IMAGE_HEIGHT,
            });
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            logLine(`[graph-render] reject account=${accountId} reason=draw-error nodeId=${nodeId} cause=${message}`);
            return {
                ok: false,
                reason: "draw-error",
                message: `Canvas draw failed: ${message}`,
            };
        }
        // Write the PNG to a deterministic per-account output/ path. It then
        // surfaces in the operator's Artefacts panel and on their file share;
        // the calling skill tells the operator its location — MCP `image`
        // content blocks do not surface in the maxy-code chat transport, and
        // there is no send-to-chat tool (see Task 584). The slug+hopDepth pair
        // makes repeated renders of the same view overwrite the same file
        // rather than accumulating.
        const accountsDir = getAccountsDir();
        const outputDir = resolve(accountsDir, accountId, "output", "graph-render");
        mkdirSync(outputDir, { recursive: true });
        const fileName = `${slugifyElementId(nodeId)}-h${hopDepth}.png`;
        const outputPath = resolve(outputDir, fileName);
        writeFileSync(outputPath, pngBuffer);
        const truncated = totalCount > nodeRows.length
            ? { rendered: nodeRows.length, total: totalCount }
            : undefined;
        const ms = Date.now() - t0;
        logLine(`[graph-render] ok account=${accountId} ms=${ms} bytes=${pngBuffer.length} ` +
            `nodeCount=${nodeRows.length} edgeCount=${edges.length} ` +
            `truncated=${truncated ? "true" : "false"} iterations=${layout.iterations} ` +
            `path=${outputPath}`);
        return {
            ok: true,
            path: outputPath,
            width: IMAGE_WIDTH,
            height: IMAGE_HEIGHT,
            nodeCount: nodeRows.length,
            edgeCount: edges.length,
            ...(truncated ? { truncated } : {}),
        };
    }
    finally {
        await session.close();
    }
}
//# sourceMappingURL=graph-render.js.map