// Figma connector — scoped fetch + ready-signal resolution (Epic #750, Issue #751).
//
// Server-side only, read-only PAT, REST API only (no SDK, no MCP, no OAuth, no webhooks).
// From a pasted board/section link it fetches ONLY the scoped node subtree via
// `GET /v1/files/:key/nodes?ids=&depth=[&version=]` — never the whole-file endpoint — and
// resolves an advisory readiness signal. Output is the raw scoped node tree (handed to the
// IR cleaner in #752) plus token-free provenance.
//
// Token handling: the PAT is resolved from an injected config override or the
// `FIGMA_ACCESS_TOKEN` env var. It is materialised into the `X-Figma-Token` header at the
// transport boundary only; it is never logged, never returned, and never placed in any
// error or provenance. The HTTP transport is the injectable FigmaHttpPort so a future
// proxy/custom-CA client (#802) slots in without touching this logic.
import { FigmaConnectorError } from "./figmaConnectorErrors.js";
import { DEFAULT_FIGMA_RETRY_POLICY, fetchWithBackoff, realFigmaRetrySleep, } from "./figmaRetry.js";
import { parseFigmaTarget } from "./figmaUrl.js";
import { resolveReadiness } from "./figmaReadiness.js";
import { classifyTokenFailure, resolveFigmaToken } from "./figmaTokenSource.js";
import { DEFAULT_SCOPED_PAGINATION_LIMITS, paginateScopedDocument, } from "./figmaScopedPagination.js";
const FIGMA_API_ORIGIN = "https://api.figma.com";
const DEFAULT_DEPTH = 4;
const DEFAULT_MAX_NODE_COUNT = 5000;
const EPOCH = "1970-01-01T00:00:00.000Z";
const resolveToken = (deps) => resolveFigmaToken({
    vaultToken: deps.vaultToken,
    configToken: deps.config?.accessToken,
    envToken: deps.env.FIGMA_ACCESS_TOKEN,
});
const buildScopedUrl = (fileKey, nodeId, depth, version) => {
    const url = new URL(`${FIGMA_API_ORIGIN}/v1/files/${encodeURIComponent(fileKey)}/nodes`);
    url.searchParams.set("ids", nodeId);
    url.searchParams.set("depth", String(depth));
    if (version !== undefined && version.length > 0)
        url.searchParams.set("version", version);
    return url.toString();
};
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
// Figma error bodies carry a short reason in `err` (occasionally `message`). We pass only that
// generic string to the structural classifier so a 403 resolves to the most specific coded error
// (expired / revoked / insufficient-scope) without ever surfacing the raw payload. The token is
// never part of a response body, so this cannot leak it.
const extractFigmaReason = (body) => {
    if (!isRecord(body))
        return undefined;
    const reason = body.err ?? body.message;
    return typeof reason === "string" ? reason : undefined;
};
const statusToError = (status, body) => classifyTokenFailure(status, extractFigmaReason(body));
const extractDocument = (body, nodeId) => {
    if (!isRecord(body) || !isRecord(body.nodes))
        throw new FigmaConnectorError("FIGMA_INTERNAL");
    const entry = body.nodes[nodeId];
    if (entry === undefined)
        throw new FigmaConnectorError("FIGMA_NOT_FOUND");
    if (!isRecord(entry) || !isRecord(entry.document)) {
        throw new FigmaConnectorError("FIGMA_INTERNAL");
    }
    return entry.document;
};
const countNodes = (node) => {
    let total = 1;
    for (const child of Array.isArray(node.children) ? node.children : [])
        total += countNodes(child);
    return total;
};
const guardScopeSize = (node, maxNodeCount) => {
    if (countNodes(node) > maxNodeCount)
        throw new FigmaConnectorError("FIGMA_OVERSIZED_SCOPE");
};
// A per-node deep fetch that hits one of these codes means the WHOLE build is compromised (auth lost,
// rate-limit exhausted, egress/TLS broken) — it must abort, not silently degrade a branch to shallow.
// Any other per-node failure (a transient 5xx or a vanished sub-node) is soft: that branch keeps its
// shallow content and the build proceeds with a truncation-aware coverage report.
const DEEP_FETCH_ABORT_CODES = new Set([
    "FIGMA_TOKEN_MISSING",
    "FIGMA_TOKEN_INVALID",
    "FIGMA_TOKEN_EXPIRED",
    "FIGMA_TOKEN_REVOKED",
    "FIGMA_INSUFFICIENT_SCOPE",
    "FIGMA_CONSENT_REQUIRED",
    "FIGMA_RATE_LIMITED",
    "FIGMA_PROXY_EGRESS_FAILED",
    "FIGMA_PROXY_UNREACHABLE",
    "FIGMA_TLS_CA_FAILURE",
]);
// One scoped `nodes?ids=&depth=` fetch (with 429 backoff), returning the raw document. No oversize
// guard here — the shallow path applies it; the deep path is bounded by the pagination budgets. The
// token flows into the header only.
const fetchDocumentAt = async (rt, token, fileKey, nodeId, fetchDepth, version) => {
    const requestUrl = buildScopedUrl(fileKey, nodeId, fetchDepth, version);
    const response = await fetchWithBackoff(() => rt.deps.http({ url: requestUrl, headers: { "X-Figma-Token": token } }), rt.retryPolicy, rt.sleep);
    if (response.status < 200 || response.status >= 300) {
        throw statusToError(response.status, response.json);
    }
    return extractDocument(response.json, nodeId);
};
const provenanceFor = (target, options) => ({
    fileKey: target.fileKey,
    nodeId: target.nodeId,
    version: options.version,
    fetchedAt: options.fetchedAt ?? EPOCH,
});
// RawFigmaNode → FigmaNode is a safe widening for the readiness reader (which tolerates absent fields).
const readinessFor = (rt, document, version) => resolveReadiness(document, { version, releaseMarker: rt.releaseMarker });
const resolveTarget = (url) => {
    const target = parseFigmaTarget(url);
    if (target === null)
        throw new FigmaConnectorError("FIGMA_MALFORMED_URL");
    return target;
};
const doFetchScopedNodes = async (rt, url, options) => {
    const token = resolveToken(rt.deps);
    const target = resolveTarget(url);
    const document = await fetchDocumentAt(rt, token, target.fileKey, target.nodeId, rt.depth, options.version);
    guardScopeSize(document, rt.maxNodeCount);
    return {
        nodes: document,
        provenance: provenanceFor(target, options),
        readiness: readinessFor(rt, document, options.version),
    };
};
// The per-screen deepening fetcher: a hard (auth/rate/egress) code aborts the build; any other
// per-node failure degrades that branch to its shallow content (recorded as truncation in coverage).
const makeDeepFetcher = (rt, token, target, version) => {
    return async (nodeId) => {
        try {
            return await fetchDocumentAt(rt, token, target.fileKey, nodeId, rt.paginationLimits.pageDepth, version);
        }
        catch (err) {
            if (err instanceof FigmaConnectorError && DEEP_FETCH_ABORT_CODES.has(err.code))
                throw err;
            return undefined;
        }
    };
};
const doFetchScopedNodesDeep = async (rt, url, options) => {
    const token = resolveToken(rt.deps);
    const target = resolveTarget(url);
    // One shallow discovery fetch enumerates the screens (and resolves advisory readiness). No oversize
    // guard: a wide canvas is exactly what the deep path paginates. This is the FIRST, auth-establishing
    // egress; a hard failure here aborts before any per-screen fetch.
    const discovery = await fetchDocumentAt(rt, token, target.fileKey, target.nodeId, rt.depth, options.version);
    const fetchNode = makeDeepFetcher(rt, token, target, options.version);
    const { document, coverage } = await paginateScopedDocument(discovery, fetchNode, rt.paginationLimits);
    return {
        nodes: document,
        provenance: provenanceFor(target, options),
        readiness: readinessFor(rt, document, options.version),
        coverage,
    };
};
export const createFigmaConnector = (deps) => {
    const rt = {
        deps,
        depth: deps.config?.depth ?? DEFAULT_DEPTH,
        releaseMarker: deps.config?.releaseMarker,
        maxNodeCount: deps.config?.maxNodeCount ?? DEFAULT_MAX_NODE_COUNT,
        retryPolicy: deps.retryPolicy ?? DEFAULT_FIGMA_RETRY_POLICY,
        sleep: deps.sleep ?? realFigmaRetrySleep,
        paginationLimits: { ...DEFAULT_SCOPED_PAGINATION_LIMITS, ...deps.config?.pagination },
    };
    return {
        fetchScopedNodes: (url, options = {}) => doFetchScopedNodes(rt, url, options),
        fetchScopedNodesDeep: (url, options = {}) => doFetchScopedNodesDeep(rt, url, options),
    };
};
