/**
 * OpenAI Chat Completions protocol guard: orphan `tool` messages.
 *
 * The OpenAI Chat Completions spec requires every message with
 * `role: "tool"` to reference a `tool_call_id` that appears in a preceding
 * `assistant` message's `tool_calls` array. When this invariant is broken
 * (typically by a buggy client that records a tool result without the
 * matching assistant tool_call), the upstream rejects the request with a
 * 400 like:
 *
 *   {"type":"error","error":{"type":"bad_request_error",
 *     "message":"invalid params, tool result's tool id(call_xxx) not found"}}
 *
 * This module finds such orphan tool messages and returns a rewritten body
 * with them removed so the proxy can forward a valid request. The original
 * body is preserved by the caller for the captured log, so the inspector UI
 * still shows the broken request the client actually sent.
 */

const ROLE_ASSISTANT = "assistant";
const ROLE_TOOL = "tool";

export type OrphanToolStripResult = {
  /** The new request body. Equal to input (by reference) when no change was made. */
  body: string;
  /** Number of orphan tool messages removed. */
  removed: number;
  /**
   * The `tool_call_id` values of the removed messages, in document order.
   * `null` is used when a tool message had no `tool_call_id` at all.
   */
  orphanIds: Array<string | null>;
};

import { safeGetOwnProperty, isPlainRecord } from "../lib/objectUtils";

function isObjectWithMessages(value: unknown): value is { messages: unknown[] } {
  if (!isPlainRecord(value)) return false;
  if (!Object.prototype.hasOwnProperty.call(value, "messages")) return false;
  const messages = safeGetOwnProperty(value, "messages");
  return Array.isArray(messages);
}

/**
 * Collects the set of tool_call ids from an assistant message's `tool_calls`
 * array. Tolerant of malformed entries: anything without a string `id` is
 * skipped silently (those ids cannot be referenced by a tool message anyway).
 */
function collectAssistantToolCallIds(message: unknown, into: Set<string>): void {
  if (!isPlainRecord(message)) return;
  const toolCalls = safeGetOwnProperty(message, "tool_calls");
  if (!Array.isArray(toolCalls)) return;
  for (const tc of toolCalls) {
    if (!isPlainRecord(tc)) continue;
    const id = safeGetOwnProperty(tc, "id");
    if (typeof id === "string" && id.length > 0) {
      into.add(id);
    }
  }
}

/**
 * Returns the indices of `tool`-role messages whose `tool_call_id` does not
 * appear in any *preceding* assistant message's `tool_calls`. A tool message
 * with no `tool_call_id` at all is also considered orphan.
 *
 * Pure, exported for unit tests. Used by `stripOpenAIOrphanToolMessages`.
 */
export function findOrphanToolMessageIndices(messages: readonly unknown[]): {
  indices: number[];
  orphanIds: Array<string | null>;
} {
  const knownToolCallIds = new Set<string>();
  const indices: number[] = [];
  const orphanIds: Array<string | null> = [];

  for (let i = 0; i < messages.length; i++) {
    const msg = messages[i];
    if (!isPlainRecord(msg)) continue;

    const role = safeGetOwnProperty(msg, "role");

    if (role === ROLE_ASSISTANT) {
      collectAssistantToolCallIds(msg, knownToolCallIds);
      continue;
    }

    if (role === ROLE_TOOL) {
      const id = safeGetOwnProperty(msg, "tool_call_id");
      if (typeof id !== "string" || id.length === 0) {
        indices.push(i);
        orphanIds.push(null);
        continue;
      }
      if (!knownToolCallIds.has(id)) {
        indices.push(i);
        orphanIds.push(id);
      }
    }
  }

  return { indices, orphanIds };
}

/**
 * Inspects an OpenAI-format request body and, if its `messages` array
 * contains one or more orphan `tool` messages (see `findOrphanToolMessageIndices`),
 * returns a new body with those messages removed. The original string is
 * returned unchanged (by reference equality) when no edit is needed so the
 * caller can skip a re-serialization.
 *
 * Tolerant of bodies that are not valid JSON, not OpenAI-format, or that
 * have no `messages` array — all such inputs are returned unchanged.
 */
export function stripOpenAIOrphanToolMessages(rawBody: string): OrphanToolStripResult {
  let parsed: unknown;
  try {
    parsed = JSON.parse(rawBody);
  } catch {
    return { body: rawBody, removed: 0, orphanIds: [] };
  }

  if (!isObjectWithMessages(parsed)) {
    return { body: rawBody, removed: 0, orphanIds: [] };
  }

  const messages = parsed.messages;
  const { indices, orphanIds } = findOrphanToolMessageIndices(messages);

  if (indices.length === 0) {
    return { body: rawBody, removed: 0, orphanIds: [] };
  }

  const dropSet = new Set(indices);
  const kept: unknown[] = [];
  for (let i = 0; i < messages.length; i++) {
    if (!dropSet.has(i)) kept.push(messages[i]);
  }
  parsed.messages = kept;

  return { body: JSON.stringify(parsed), removed: indices.length, orphanIds };
}
