import type { ChatMessage, ToolCall } from "@/types/chat";

export type StreamChatCallbacks = {
  /** Update the messages array (e.g. pass to setState). */
  updateMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
  /** Called when the stream sends an error or fetch fails. */
  onError: (message: string) => void;
  /** Called when the stream finishes (success or error). */
  onDone: () => void;
  /** Message used when fetch throws and no server error is available. */
  defaultErrorMessage?: string;
};

type StreamChunk =
  | { type: "error"; error: string }
  | { type: "tool_calls"; tool_calls: ToolCall[] }
  | { type: "content"; delta: string };

/**
 * POST to chat/completion with stream: true, parse NDJSON stream, and invoke
 * callbacks so the caller can update UI (e.g. React state).
 */
export async function streamChatCompletion(
  url: string,
  headers: Record<string, string>,
  apiMessages: { role: string; content: string }[],
  callbacks: StreamChatCallbacks,
  signal?: AbortSignal,
): Promise<void> {
  const {
    updateMessages,
    onError,
    onDone,
    defaultErrorMessage = "Something went wrong. Please try again.",
  } = callbacks;

  try {
    const res = await fetch(url, {
      method: "POST",
      headers,
      body: JSON.stringify({ messages: apiMessages, stream: true }),
      signal,
    });

    if (!res.ok) {
      const errBody = await res.json().catch(() => ({}));
      const message = (errBody as { error?: string }).error ?? res.statusText;
      onError(message);
      onDone();
      return;
    }

    const reader = res.body?.getReader();
    const decoder = new TextDecoder();
    if (!reader) {
      onError("No response body");
      onDone();
      return;
    }

    let buffer = "";
    let hadStreamError = false;

    const applyChunk = (data: StreamChunk) => {
      if (data.type === "error") {
        onError(data.error);
        updateMessages((prev) =>
          prev.filter((m) => !(m.role === "assistant" && m.content === "")),
        );
        return "break";
      }
      if (data.type === "tool_calls" && data.tool_calls?.length) {
        updateMessages((prev) => {
          const next = [...prev];
          const lastMsg = next[next.length - 1];
          if (lastMsg?.role === "assistant")
            next[next.length - 1] = {
              ...lastMsg,
              toolCalls: data.tool_calls,
            };
          return next;
        });
        return "continue";
      }
      if (data.type === "content" && data.delta !== undefined) {
        updateMessages((prev) => {
          const next = [...prev];
          const lastMsg = next[next.length - 1];
          if (lastMsg?.role === "assistant")
            next[next.length - 1] = {
              ...lastMsg,
              content: lastMsg.content + data.delta,
            };
          return next;
        });
        return "continue";
      }
      return "continue";
    };

    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop() ?? "";
      for (const line of lines) {
        const trimmed = line.trim();
        if (!trimmed) continue;
        try {
          const data = JSON.parse(trimmed) as StreamChunk & { type: string };
          const result = applyChunk(data as StreamChunk);
          if (result === "break") {
            hadStreamError = true;
            break;
          }
        } catch {
          // skip malformed JSON lines
        }
      }
      if (hadStreamError) break;
    }

    // Flush remaining buffer
    if (!hadStreamError && buffer.trim()) {
      try {
        const data = JSON.parse(buffer.trim()) as StreamChunk & {
          type: string;
          delta?: string;
        };
        if (data.type === "content" && data.delta !== undefined) {
          updateMessages((prev) => {
            const next = [...prev];
            const lastMsg = next[next.length - 1];
            if (lastMsg?.role === "assistant")
              next[next.length - 1] = {
                ...lastMsg,
                content: lastMsg.content + data.delta!,
              };
            return next;
          });
        }
      } catch {
        // ignore final malformed chunk
      }
    }

    if (!hadStreamError) {
      updateMessages((prev) => {
        const next = [...prev];
        const lastMsg = next[next.length - 1];
        if (lastMsg?.role === "assistant" && lastMsg.content === "")
          next[next.length - 1] = { ...lastMsg, content: " " };
        return next;
      });
    }
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      // User stopped generation — not an error
    } else {
      onError(defaultErrorMessage);
      updateMessages((prev) =>
        prev.filter((m) => !(m.role === "assistant" && m.content === "")),
      );
    }
  } finally {
    onDone();
  }
}

/**
 * Parse search_objects tool call response(s) into event ids for thumbnails.
 */
export function getEventIdsFromSearchObjectsToolCalls(
  toolCalls: ToolCall[] | undefined,
): { id: string }[] {
  if (!toolCalls?.length) return [];
  const results: { id: string }[] = [];
  for (const tc of toolCalls) {
    if (tc.name !== "search_objects" || !tc.response?.trim()) continue;
    try {
      const parsed = JSON.parse(tc.response) as unknown;
      if (!Array.isArray(parsed)) continue;
      for (const item of parsed) {
        if (
          item &&
          typeof item === "object" &&
          "id" in item &&
          typeof (item as { id: unknown }).id === "string"
        ) {
          results.push({ id: (item as { id: string }).id });
        }
      }
    } catch {
      // ignore parse errors
    }
  }
  return results;
}

const ATTACHED_EVENT_MARKER = /^\[attached_event:([A-Za-z0-9._-]+)\]\s*\n?/;

export function parseAttachedEvent(content: string): {
  eventId: string | null;
  body: string;
} {
  if (!content) return { eventId: null, body: content };
  const match = content.match(ATTACHED_EVENT_MARKER);
  if (!match) return { eventId: null, body: content };
  const body = content.slice(match[0].length).replace(/^\n+/, "");
  return { eventId: match[1], body };
}

export function prependAttachment(body: string, eventId: string): string {
  return `[attached_event:${eventId}]\n\n${body}`;
}

export type FindSimilarObjectsResult = {
  anchor: { id: string } | null;
  results: { id: string; score?: number }[];
};

/**
 * Parse find_similar_objects tool call response(s) into anchor + ranked results.
 * Returns null if no find_similar_objects call is present so the caller can
 * decide whether to render.
 */
export function getFindSimilarObjectsFromToolCalls(
  toolCalls: ToolCall[] | undefined,
): FindSimilarObjectsResult | null {
  if (!toolCalls?.length) return null;
  for (const tc of toolCalls) {
    if (tc.name !== "find_similar_objects" || !tc.response?.trim()) continue;
    try {
      const parsed = JSON.parse(tc.response) as {
        anchor?: { id?: unknown };
        results?: unknown;
      };
      const anchorId =
        parsed.anchor && typeof parsed.anchor.id === "string"
          ? parsed.anchor.id
          : null;
      const anchor = anchorId ? { id: anchorId } : null;
      const results: { id: string; score?: number }[] = [];
      if (Array.isArray(parsed.results)) {
        for (const item of parsed.results) {
          if (
            item &&
            typeof item === "object" &&
            "id" in item &&
            typeof (item as { id: unknown }).id === "string"
          ) {
            const entry: { id: string; score?: number } = {
              id: (item as { id: string }).id,
            };
            const rawScore = (item as { score?: unknown }).score;
            if (typeof rawScore === "number") entry.score = rawScore;
            results.push(entry);
          }
        }
      }
      return { anchor, results };
    } catch {
      // ignore parse errors
    }
  }
  return null;
}
