/**
 * Utility for preparing step messages with injected user content.
 *
 * This module contains pure functions extracted from the prepareStep callback
 * in local_agent_handler.ts, enabling isolated unit testing.
 */

import { ImagePart, ModelMessage, TextPart, UserModelMessage } from "ai";
import type { UserMessageContentPart, Todo } from "./tools/types";
import { cleanMessage } from "@/ipc/utils/ai_messages_utils";
import { validateImageDimensions } from "./tools/image_utils";

/**
 * Check if a single todo is incomplete (pending or in_progress).
 */
const isIncompleteTodo = (todo: Todo): boolean =>
  todo.status === "pending" || todo.status === "in_progress";

/**
 * Check if there are incomplete todos (pending or in_progress).
 */
export function hasIncompleteTodos(todos: Todo[]): boolean {
  return todos.some(isIncompleteTodo);
}

/**
 * Format a list of todos as a bullet-point summary string.
 */
export function formatTodoSummary(todos: Todo[]): string {
  return todos.map((t) => `- [${t.status}] ${t.content}`).join("\n");
}

/**
 * Build a reminder message for incomplete todos.
 */
export function buildTodoReminderMessage(todos: Todo[]): string {
  const incompleteTodos = todos.filter(isIncompleteTodo);

  const todoList = formatTodoSummary(incompleteTodos);

  // Note: The "incomplete todo(s)" substring is used as a detection marker by test
  // infrastructure in testing/fake-llm-server/ (chatCompletionHandler.ts and
  // localAgentHandler.ts). Update those files if this text changes.
  return `You have ${incompleteTodos.length} incomplete todo(s). Please continue and complete them:\n\n${todoList}`;
}

/**
 * A message that has been processed and is ready to inject.
 */
export interface InjectedMessage {
  insertAtIndex: number;
  /** Sequence number to preserve FIFO order for same-index messages */
  sequence: number;
  message: UserModelMessage;
}

/**
 * Transform a UserMessageContentPart to the format expected by the AI SDK.
 * For images, validates dimensions and returns a text message if the image
 * exceeds the maximum allowed size (8000px in any dimension).
 */
export function transformContentPart(
  part: UserMessageContentPart,
): TextPart | ImagePart {
  if (part.type === "text") {
    return { type: "text", text: part.text };
  }
  // part.type === "image-url"
  // Validate image dimensions before sending to LLM
  const validation = validateImageDimensions(part.url);
  if (!validation.isValid && validation.errorMessage) {
    // Return a text explanation instead of the oversized image
    return {
      type: "text",
      text: `[Image omitted: ${validation.errorMessage}]`,
    };
  }
  return { type: "image", image: new URL(part.url) };
}

/**
 * Process pending user messages and add them to the injected messages list.
 * Each message is recorded with the current message count as its insertion index.
 *
 * @param pendingUserMessages - Queue of pending messages (will be mutated/emptied)
 * @param allInjectedMessages - List of already injected messages (will be mutated)
 * @param currentMessageCount - The current number of messages in the conversation
 */
export function processPendingMessages(
  pendingUserMessages: UserMessageContentPart[][],
  allInjectedMessages: InjectedMessage[],
  currentMessageCount: number,
): void {
  while (pendingUserMessages.length > 0) {
    const content = pendingUserMessages.shift()!;
    allInjectedMessages.push({
      insertAtIndex: currentMessageCount,
      sequence: allInjectedMessages.length, // Track insertion order
      message: {
        role: "user" as const,
        content: content.map(transformContentPart),
      },
    });
  }
}

/**
 * Build a new messages array with injected messages inserted at their recorded positions.
 * Messages are processed in reverse order of insertion index to avoid shifting issues.
 * For messages with the same index, we process in reverse sequence order to preserve FIFO.
 *
 * @param messages - The original messages array
 * @param injectedMessages - Messages to inject with their target indices
 * @returns New array with injected messages inserted at correct positions
 */
export function injectMessagesAtPositions<T>(
  messages: T[],
  injectedMessages: InjectedMessage[],
): (T | InjectedMessage["message"])[] {
  if (injectedMessages.length === 0) {
    return messages;
  }

  // Type as union from the start to allow inserting InjectedMessage["message"]
  const newMessages: (T | InjectedMessage["message"])[] = [...messages];

  // Sort by insertion index descending, then by sequence descending.
  // The sequence descending ensures that for same-index messages,
  // we splice the LAST-added first, so after all splices the FIRST-added
  // ends up in front (preserving FIFO order).
  const sortedInjections = [...injectedMessages].sort((a, b) => {
    if (a.insertAtIndex !== b.insertAtIndex) {
      return b.insertAtIndex - a.insertAtIndex;
    }
    return b.sequence - a.sequence;
  });

  for (const injection of sortedInjections) {
    newMessages.splice(injection.insertAtIndex, 0, injection.message);
  }

  return newMessages;
}

/**
 * The complete prepareStep logic as a pure function.
 *
 * @param options - The step options containing messages and other properties
 * @param pendingUserMessages - Queue of pending messages to process
 * @param allInjectedMessages - Accumulated list of injected messages
 * @returns Modified options with injected messages, or undefined if no changes needed
 */
export function prepareStepMessages<
  TMessage extends ModelMessage,
  T extends { messages: TMessage[]; [key: string]: unknown },
>(
  options: T,
  pendingUserMessages: UserMessageContentPart[][],
  allInjectedMessages: InjectedMessage[],
): (Omit<T, "messages"> & { messages: TMessage[] }) | undefined {
  const { messages, ...rest } = options;

  // Move any new pending messages to the permanent injected list
  processPendingMessages(
    pendingUserMessages,
    allInjectedMessages,
    messages.length,
  );

  // Clean messages for OpenAI compatibility during multi-step agent flows:
  // 1. Strip itemId to prevent "Item with id not found" errors
  // 2. Filter orphaned reasoning to prevent "reasoning without following item" errors
  const filteredMessages = messages.map(cleanMessage);

  // Check if we need to return modified options
  const hasInjections = allInjectedMessages.length > 0;
  const hasFilteredContent = filteredMessages.some(
    (msg, i) => msg !== messages[i],
  );

  if (!hasInjections && !hasFilteredContent) {
    return undefined;
  }

  // Build the new messages array with injections
  // Cast is safe because InjectedMessage["message"] is a valid ModelMessage
  const newMessages = hasInjections
    ? (injectMessagesAtPositions(
        filteredMessages,
        allInjectedMessages,
      ) as TMessage[])
    : filteredMessages;

  return { messages: newMessages, ...rest };
}

/**
 * Ensure user messages don't appear between a tool_use and its tool_result.
 *
 * After mid-turn compaction, injected user messages (e.g., web_crawl screenshots)
 * can end up at stale array positions that break the AI SDK's tool result
 * validation. This function detects any such misplaced user messages and moves
 * them forward past the pending tool results.
 *
 * Returns a new array if changes were made, or null if no fix was needed.
 */
export function ensureToolResultOrdering<T extends ModelMessage>(
  messages: T[],
): T[] | null {
  const result = [...messages] as T[];
  let changed = false;
  const pendingToolCallIds = new Set<string>();

  for (let i = 0; i < result.length; i++) {
    const msg = result[i];
    const content = Array.isArray(msg.content) ? msg.content : [];

    if (msg.role === "assistant") {
      for (const part of content) {
        if (isToolCallPart(part)) {
          pendingToolCallIds.add(part.toolCallId);
        }
      }
    } else if (msg.role === "tool") {
      for (const part of content) {
        if (isToolResultPart(part)) {
          pendingToolCallIds.delete(part.toolCallId);
        }
      }
    } else if (msg.role === "user" && pendingToolCallIds.size > 0) {
      // This user message is between a tool_use and its tool_result.
      // Collect all consecutive misplaced user messages so we can move
      // them as a batch, preserving their FIFO order.
      const misplacedStart = i;
      let misplacedEnd = i;
      while (
        misplacedEnd + 1 < result.length &&
        result[misplacedEnd + 1].role === "user"
      ) {
        misplacedEnd++;
      }
      const misplacedCount = misplacedEnd - misplacedStart + 1;

      // Find the next position where all pending tool results are resolved.
      // Use a snapshot so the lookahead doesn't corrupt the outer tracking set.
      const lookaheadPending = new Set(pendingToolCallIds);
      let insertAfter = misplacedEnd;
      for (let j = misplacedEnd + 1; j < result.length; j++) {
        const next = result[j];
        if (next.role === "tool" && Array.isArray(next.content)) {
          for (const part of next.content) {
            if (isToolResultPart(part)) {
              lookaheadPending.delete(part.toolCallId);
            }
          }
          insertAfter = j;
          if (lookaheadPending.size === 0) break;
        } else if (next.role === "assistant") {
          // New assistant turn — stop scanning to avoid crossing turn boundaries
          break;
        }
      }

      if (insertAfter > misplacedEnd) {
        // Remove the batch and re-insert after the tool result, preserving order.
        const moved = result.splice(misplacedStart, misplacedCount);
        // After splice, insertAfter shifted by -misplacedCount
        const adjustedTarget = insertAfter - misplacedCount + 1;
        result.splice(adjustedTarget, 0, ...moved);
        changed = true;
        // Restart the scan from the beginning with a fresh pending set.
        // The array has been mutated, so skipping ahead would miss tool-result
        // messages that need to update pendingToolCallIds.
        pendingToolCallIds.clear();
        i = -1; // will become 0 after the for-loop increment
      } else {
        // Couldn't find a safe position; skip past the batch
        i = misplacedEnd;
      }
    }
  }

  return changed ? result : null;
}

function isToolCallPart(
  part: unknown,
): part is { type: "tool-call"; toolCallId: string } {
  return (
    typeof part === "object" &&
    part !== null &&
    "type" in part &&
    (part as Record<string, unknown>).type === "tool-call" &&
    "toolCallId" in part
  );
}

function isToolResultPart(
  part: unknown,
): part is { type: "tool-result"; toolCallId: string } {
  return (
    typeof part === "object" &&
    part !== null &&
    "type" in part &&
    (part as Record<string, unknown>).type === "tool-result" &&
    "toolCallId" in part
  );
}
