/**
 * openapi-spec.ts
 *
 * Semantic renderer for the OpenAPI spec held inside an `openApiReference`
 * doc's `specification.code` field (a raw OpenAPI 3.x YAML string). Turns the
 * spec into a compact Markdown view the `DocCoverage` judge can read —
 * surfacing operations (HTTP method + path), summaries/descriptions,
 * parameters, and request/response shapes.
 *
 * Mirrors the D0069 split used for `typesReference`: this module is the pure
 * parse+render half (returns `null` when the body is not a renderable OpenAPI
 * spec); the metadata header and the raw-fenced fallback are orchestrated by
 * the renderer in `document-renderers.ts`, exactly as `renderTypesReference`
 * wraps `renderTypedocDeclarations`.
 *
 * Budget: the caller passes a byte budget (chars). Operations render in
 * document order until the budget is reached, then an explicit
 * "*N more operations omitted*" line — omissions are always counted, never a
 * silent truncation mid-operation.
 */
import { load } from "js-yaml";
/** HTTP methods recognised as operations within a path item (OpenAPI 3.x). */
const HTTP_METHODS = [
    "get",
    "put",
    "post",
    "delete",
    "patch",
    "head",
    "options",
    "trace",
];
/**
 * Render an OpenAPI YAML spec to Markdown. Returns `null` when `code` is not a
 * parseable OpenAPI document with at least one operation — the caller then
 * keeps the raw-fenced fallback (mirrors `renderTypedocDeclarations` → raw
 * typedoc inline, D0069 §7).
 */
export function renderOpenApiSpec(code, options) {
    const parsed = parseSpec(code);
    if (parsed === null)
        return null;
    try {
        return assembleOutput(parsed.spec, parsed.operations, options.maxBytes);
    }
    catch {
        return null;
    }
}
/**
 * List the operationIds the spec declares (with their method/path/summary),
 * for the renderer's `extractSymbols` recognition reference. Operations
 * without an `operationId` are skipped — there is no stable identifier to
 * surface. Returns `[]` when the body is not a parseable OpenAPI spec.
 */
export function listOperationRefs(code) {
    const parsed = parseSpec(code);
    if (parsed === null)
        return [];
    const out = [];
    for (const { method, path, op } of parsed.operations) {
        const operationId = str(op.operationId);
        if (!operationId)
            continue;
        const summary = str(op.summary);
        out.push({ operationId, method, path, ...(summary ? { summary } : {}) });
    }
    return out;
}
/**
 * Parse + validate the spec into the operation list, or `null` when the body
 * is not a renderable OpenAPI document. Single parse point shared by the
 * renderer and the symbol extractor. The defensive `try` maps any residual
 * internal failure onto the same `null` fallback (caller keeps the raw path).
 */
function parseSpec(code) {
    let parsed;
    try {
        parsed = load(code);
    }
    catch {
        return null;
    }
    try {
        if (!isRecord(parsed))
            return null;
        const spec = parsed;
        const operations = collectOperations(spec.paths, spec);
        // No operations → nothing meaningful to render semantically.
        if (operations.length === 0)
            return null;
        return { spec, operations };
    }
    catch {
        return null;
    }
}
// ---------------------------------------------------------------------------
// Assembly
// ---------------------------------------------------------------------------
function assembleOutput(spec, operations, maxBytes) {
    const lines = [];
    // API identity header.
    const apiTitle = str(spec.info?.title);
    const apiVersion = str(spec.info?.version);
    if (apiTitle || apiVersion) {
        const versionSuffix = apiVersion ? ` (v${apiVersion})` : "";
        lines.push(`**API:** ${apiTitle ?? "(untitled)"}${versionSuffix}`);
    }
    const servers = (spec.servers ?? [])
        .map((s) => str(s?.url))
        .filter((u) => Boolean(u));
    if (servers.length > 0) {
        lines.push(`**Servers:** ${servers.map((u) => `\`${u}\``).join(", ")}`);
    }
    if (lines.length > 0)
        lines.push("");
    lines.push("### Operations", "");
    let rendered = 0;
    let budgetReached = false;
    for (const operation of operations) {
        const block = renderOperation(operation, spec);
        const projected = lines.join("\n").length + block.length + 1;
        // Always render at least one operation so a single large operation still
        // surfaces (the cap is best-effort, matching the typesReference renderer).
        if (rendered > 0 && projected > maxBytes) {
            budgetReached = true;
            break;
        }
        lines.push(block, "");
        rendered += 1;
    }
    if (budgetReached) {
        lines.push(`*${operations.length - rendered} more operation(s) omitted to fit the budget.*`, "");
    }
    // Component schema names — a recognition surface for request/response shapes,
    // appended only when the budget has room. Names only (no expansion).
    const schemaNames = Object.keys(spec.components?.schemas ?? {});
    if (schemaNames.length > 0 && !budgetReached) {
        const schemaLine = `**Schemas:** ${schemaNames
            .map((n) => `\`${n}\``)
            .join(", ")}`;
        if (lines.join("\n").length + schemaLine.length + 1 <= maxBytes) {
            lines.push(schemaLine, "");
        }
    }
    return {
        markdown: lines.join("\n").trimEnd(),
        operationCount: operations.length,
    };
}
function renderOperation({ method, path, op, parameters }, spec) {
    const lines = [];
    lines.push(`#### \`${method.toUpperCase()} ${path}\``);
    const summary = str(op.summary);
    if (summary)
        lines.push(summary);
    const meta = [];
    const operationId = str(op.operationId);
    if (operationId)
        meta.push(`operationId: \`${operationId}\``);
    const tags = asStringArray(op.tags);
    if (tags.length > 0)
        meta.push(`tags: ${tags.join(", ")}`);
    if (meta.length > 0)
        lines.push(meta.join(" · "));
    const description = clipDescription(str(op.description));
    if (description)
        lines.push("", description);
    const params = renderParameters(parameters, spec);
    if (params.length > 0) {
        lines.push("", "Parameters:", ...params);
    }
    const requestBody = renderRequestBody(op.requestBody, spec);
    if (requestBody)
        lines.push("", requestBody);
    const responses = renderResponses(op.responses, spec);
    if (responses.length > 0) {
        lines.push("", "Responses:", ...responses);
    }
    return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Operations + parameters + bodies + responses
// ---------------------------------------------------------------------------
function collectOperations(paths, spec) {
    if (!isRecord(paths))
        return [];
    const out = [];
    for (const [path, rawItem] of Object.entries(paths)) {
        // A path item may itself be a `{ $ref }` to a reusable path item.
        const item = resolveMaybeRef(rawItem, spec);
        if (!isRecord(item))
            continue;
        // Parameters declared once at the path level apply to every operation.
        const pathParams = Array.isArray(item.parameters) ? item.parameters : [];
        for (const method of HTTP_METHODS) {
            const op = item[method];
            if (!isRecord(op))
                continue;
            const opParams = Array.isArray(op.parameters) ? op.parameters : [];
            out.push({
                method,
                path,
                op,
                parameters: mergeParameters(pathParams, opParams, spec),
            });
        }
    }
    return out;
}
/**
 * Merge path-level and operation-level parameters. The operation overrides the
 * path on a `(name, in)` collision; otherwise path params come first, then the
 * operation's own. Each entry is `$ref`-resolved so the `(name, in)` key can be
 * read even when the parameter is a component reference.
 */
function mergeParameters(pathParams, opParams, spec) {
    const byKey = new Map();
    const order = [];
    let anon = 0;
    for (const raw of [...pathParams, ...opParams]) {
        const resolved = resolveMaybeRef(raw, spec);
        const name = isRecord(resolved) ? str(resolved.name) : undefined;
        const location = isRecord(resolved) ? str(resolved.in) : undefined;
        // Unkeyable entries (no name/in) can't collide — keep each distinct.
        const key = name && location ? `${name}|${location}` : `__anon${anon++}`;
        if (!byKey.has(key))
            order.push(key);
        byKey.set(key, resolved);
    }
    return order.map((key) => byKey.get(key));
}
function renderParameters(parameters, spec) {
    if (!Array.isArray(parameters))
        return [];
    const out = [];
    for (const raw of parameters) {
        const param = resolveMaybeRef(raw, spec);
        if (!isRecord(param))
            continue;
        const name = str(param.name);
        if (!name)
            continue;
        const location = str(param.in);
        const required = param.required === true;
        const facets = [location, required ? "required" : null].filter((f) => Boolean(f));
        const suffix = facets.length > 0 ? ` (${facets.join(", ")})` : "";
        out.push(`- \`${name}\`${suffix}`);
    }
    return out;
}
function renderRequestBody(requestBody, spec) {
    const body = resolveMaybeRef(requestBody, spec);
    if (!isRecord(body))
        return null;
    const required = body.required === true;
    const label = schemaLabel(jsonSchema(body.content));
    const requiredSuffix = required ? " (required)" : "";
    return `Request body${requiredSuffix}: ${label ?? "see schema"}`;
}
function renderResponses(responses, spec) {
    if (!isRecord(responses))
        return [];
    const out = [];
    for (const [status, raw] of Object.entries(responses)) {
        const response = resolveMaybeRef(raw, spec);
        if (!isRecord(response)) {
            out.push(`- \`${status}\``);
            continue;
        }
        const description = str(response.description);
        const label = schemaLabel(jsonSchema(response.content));
        const descPart = description ? ` — ${description}` : "";
        const schemaPart = label ? ` → ${label}` : "";
        out.push(`- \`${status}\`${descPart}${schemaPart}`);
    }
    return out;
}
/**
 * Pull the `application/json` schema object from a `content` map, when present.
 * Falls back to the first available media type so non-JSON bodies still
 * surface a schema label.
 */
function jsonSchema(content) {
    if (!isRecord(content))
        return undefined;
    const json = content["application/json"];
    if (isRecord(json))
        return json.schema;
    const first = Object.values(content)[0];
    return isRecord(first) ? first.schema : undefined;
}
/**
 * A human label for a JSON schema: the referenced component name when it is a
 * local `$ref`, else its declared `type`. Inline object/array schemas with no
 * `$ref` collapse to `object`/`array` — enough for the grader to know a body
 * exists without expanding the whole schema.
 */
function schemaLabel(schema) {
    if (!isRecord(schema))
        return null;
    const ref = str(schema.$ref);
    if (ref)
        return `\`${refName(ref)}\``;
    const type = str(schema.type);
    if (type)
        return `\`${type}\``;
    if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
        return "`union`";
    }
    return null;
}
// ---------------------------------------------------------------------------
// $ref resolution (local components only, single hop)
// ---------------------------------------------------------------------------
/**
 * Resolve a `{ $ref: "#/components/..." }` object one hop against the spec.
 * Non-ref values pass through unchanged. Only local component refs are
 * followed; external/remote refs return the original (label code surfaces the
 * ref name instead). Single-hop is deliberate — it covers the parameter and
 * body shapes these docs use without risking $ref cycles.
 */
function resolveMaybeRef(value, spec) {
    if (!isRecord(value))
        return value;
    const ref = str(value.$ref);
    if (!ref)
        return value;
    const target = resolveLocalRef(spec, ref);
    return target ?? value;
}
function resolveLocalRef(spec, ref) {
    if (!ref.startsWith("#/"))
        return undefined;
    const segments = ref.slice(2).split("/");
    let cursor = spec;
    for (const segment of segments) {
        if (!isRecord(cursor))
            return undefined;
        cursor = cursor[decodeRefSegment(segment)];
    }
    return cursor;
}
/** JSON-pointer un-escaping (`~1` → `/`, `~0` → `~`). */
function decodeRefSegment(segment) {
    return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
function refName(ref) {
    const segments = ref.split("/");
    return decodeRefSegment(segments[segments.length - 1] ?? ref);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** First paragraph of a (possibly multi-line markdown) description, clipped. */
function clipDescription(text, max = 280) {
    if (!text)
        return null;
    const firstParagraph = text.split(/\n\s*\n/)[0]?.trim() ?? "";
    if (!firstParagraph)
        return null;
    const collapsed = firstParagraph.replace(/\s+/g, " ");
    if (collapsed.length <= max)
        return collapsed;
    return `${collapsed.slice(0, max).trimEnd()}…`;
}
function isRecord(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
function str(value) {
    return typeof value === "string" && value.length > 0 ? value : undefined;
}
function asStringArray(value) {
    if (!Array.isArray(value))
        return [];
    return value.filter((v) => typeof v === "string");
}
