import type {
  Experimental_RealtimeModelV4 as RealtimeModelV4,
  Experimental_RealtimeModelV4ClientEvent as RealtimeModelV4ClientEvent,
  Experimental_RealtimeModelV4FunctionCallOutput as RealtimeModelV4FunctionCallOutput,
  Experimental_RealtimeModelV4ServerEvent as RealtimeModelV4ServerEvent,
  Experimental_RealtimeModelV4SessionConfig as RealtimeModelV4SessionConfig,
  Experimental_RealtimeModelV4Usage as RealtimeModelV4Usage,
} from '@ai-sdk/provider';
import { isRecord, safeParseJSON } from '@ai-sdk/provider-utils';
import { getModelPath } from '../get-model-path';
import type { GoogleRealtimeModelOptions } from './google-realtime-model-options';

type GoogleRealtimeFunctionCall = {
  id: string;
  name: string;
  args?: Record<string, unknown>;
};

type GoogleRealtimeServerContent = {
  generationComplete?: boolean;
  interactionStatus?: string;
  interrupted?: boolean;
  modelTurn?: {
    parts?: Array<{
      inlineData?: { data?: string };
      text?: string;
    }>;
  };
  outputTranscription?: { text?: string; finished?: boolean };
  inputTranscription?: { text?: string; finished?: boolean };
  turnComplete?: boolean;
  waitingForInput?: boolean;
};

type GoogleRealtimeTokenDetail = {
  modality?: string;
  tokenCount?: number;
};

type GoogleRealtimeUsageMetadata = {
  promptTokenCount?: number;
  responseTokenCount?: number;
  totalTokenCount?: number;
  toolUsePromptTokenCount?: number;
  thoughtsTokenCount?: number;
  cachedContentTokenCount?: number;
  promptTokensDetails?: GoogleRealtimeTokenDetail[];
  responseTokensDetails?: GoogleRealtimeTokenDetail[];
};

type GoogleRealtimeWireEvent = {
  setupComplete?: unknown;
  toolCall?: {
    functionCalls?: GoogleRealtimeFunctionCall[];
  };
  toolCallCancellation?: unknown;
  serverContent?: GoogleRealtimeServerContent;
  usageMetadata?: GoogleRealtimeUsageMetadata;
  inputTranscription?: { text?: string; finished?: boolean };
  goAway?: { timeLeft?: string };
  sessionResumptionUpdate?: {
    newHandle?: string;
    resumable?: boolean;
    lastConsumedClientMessageIndex?: string;
  };
};

/**
 * Stateful event mapper for Google's Gemini Live API.
 *
 * Unlike OpenAI/xAI, Google's events don't have response/item IDs and
 * a single message can contain multiple pieces of data. This class
 * tracks turn state to generate consistent synthetic IDs.
 */
export class GoogleRealtimeEventMapper {
  private turnCounter = 0;
  private inputTranscriptionCounter = 0;
  private inputTranscriptionBuffer = '';
  private inputTranscriptionBoundary: 'none' | 'interrupted' | 'turn-complete' =
    'none';
  private preserveInputTranscriptionAcrossTurnComplete = false;
  private hasAudio = false;
  private hasText = false;
  private hasTranscript = false;
  private turnClosed = false;
  private inputAudioRate = 16000;

  private get responseId(): string {
    return `google-resp-${this.turnCounter}`;
  }

  private get itemId(): string {
    return `google-item-${this.turnCounter}`;
  }

  /**
   * Rolls over to the next turn lazily, once model content for the next turn
   * arrives. `turnComplete` merely marks the current turn closed.
   * This keeps an output transcript that arrives shortly after `turnComplete`
   * attached to the turn it belongs to, since Google delivers transcription
   * independently with no guaranteed ordering relative to `turnComplete`.
   */
  private beginTurnIfClosed(): void {
    if (!this.turnClosed) return;
    this.turnCounter++;
    this.hasAudio = false;
    this.hasText = false;
    this.hasTranscript = false;
    this.turnClosed = false;
    // Once the next response starts, its completion must delimit its own
    // input even if the interrupted response never sent a turnComplete.
    this.preserveInputTranscriptionAcrossTurnComplete = false;
  }

  // Google streams input transcription as a sequence of non-accumulating
  // fragments for a single user utterance (e.g. "The quick brown fox",
  // " jumps over the", " lazy dog."). Concatenate consecutive fragments into
  // one running transcript under a stable synthetic id so the realtime reducer
  // (which overwrites the message for a given id) surfaces them as a single
  // coherent user message rather than one message per fragment. Google's
  // `finished` signal is the authoritative utterance boundary. For older
  // payloads without that signal, response boundaries remain a fallback.
  //
  // Input transcriptions can arrive independently of response turn events, so
  // their synthetic IDs use a sequence independent of `turnCounter`.
  private accumulateInputTranscription({
    text,
    finished,
  }: {
    text?: string;
    finished?: boolean;
  }): { itemId: string; transcript: string } | undefined {
    // Google can send `finished` separately from text. Process it even when
    // text is absent or empty, without emitting an empty user message.
    if (!text) {
      if (finished === true) {
        this.finishInputTranscription();
      }
      return undefined;
    }

    const hasFinishedSignal = finished != null;
    if (
      this.inputTranscriptionBuffer !== '' &&
      (this.inputTranscriptionBoundary === 'interrupted' ||
        (this.inputTranscriptionBoundary === 'turn-complete' &&
          !hasFinishedSignal))
    ) {
      this.finishInputTranscription();
    }
    this.inputTranscriptionBoundary = 'none';
    this.inputTranscriptionBuffer += text;
    const result = {
      itemId: `google-input-${this.inputTranscriptionCounter}`,
      transcript: this.inputTranscriptionBuffer,
    };
    if (finished === true) {
      this.finishInputTranscription();
    }
    return result;
  }

  private finishInputTranscription(): void {
    if (this.inputTranscriptionBuffer === '') return;
    this.inputTranscriptionCounter++;
    this.inputTranscriptionBuffer = '';
  }

  parseServerEvent(
    raw: unknown,
  ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] {
    const events = this.parseEvent(raw);
    const usage = mapGoogleUsage(
      (raw as GoogleRealtimeWireEvent).usageMetadata,
    );
    if (usage == null) return events;
    const list = Array.isArray(events) ? events : [events];
    // Only a shared wire frame establishes an association with a completion.
    if (list.some(event => event.type === 'response-done')) {
      const mapped = list.map(event =>
        event.type === 'response-done' ? { ...event, usage } : event,
      );
      return Array.isArray(events) ? mapped : mapped[0];
    }
    const update: RealtimeModelV4ServerEvent = {
      type: 'custom',
      rawType: 'usageMetadata',
      usage,
      raw,
    };
    if (
      list.length === 1 &&
      list[0].type === 'custom' &&
      list[0].rawType === 'usageMetadata'
    )
      return update;
    return [...list, update];
  }

  private parseEvent(
    raw: unknown,
  ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] {
    const data = raw as GoogleRealtimeWireEvent;

    if (data.setupComplete != null) {
      return { type: 'session-created', raw };
    }

    if (data.toolCall != null) {
      this.beginTurnIfClosed();
      const functionCalls = data.toolCall.functionCalls ?? [];
      return functionCalls.flatMap(functionCall => {
        const args = JSON.stringify(functionCall.args ?? {});
        return [
          {
            type: 'function-call-arguments-delta' as const,
            responseId: this.responseId,
            itemId: this.itemId,
            callId: functionCall.id,
            delta: args,
            raw,
          },
          {
            type: 'function-call-arguments-done' as const,
            responseId: this.responseId,
            itemId: this.itemId,
            callId: functionCall.id,
            name: functionCall.name,
            arguments: args,
            raw,
          },
        ];
      });
    }

    if (data.toolCallCancellation != null) {
      return {
        type: 'custom',
        rawType: 'toolCallCancellation',
        raw,
      };
    }

    if (data.goAway != null) {
      return {
        type: 'custom',
        rawType: 'goAway',
        raw,
      };
    }

    if (data.sessionResumptionUpdate != null) {
      return {
        type: 'custom',
        rawType: 'sessionResumptionUpdate',
        raw,
      };
    }

    if (data.serverContent != null) {
      return this.parseServerContent(data.serverContent, raw);
    }

    if (data.inputTranscription != null) {
      const transcription = this.accumulateInputTranscription(
        data.inputTranscription,
      );
      if (transcription != null) {
        return {
          type: 'input-transcription-completed',
          ...transcription,
          raw,
        };
      }
    }

    return { type: 'custom', rawType: String(Object.keys(data)[0]), raw };
  }

  private parseServerContent(
    serverContent: GoogleRealtimeServerContent,
    raw: unknown,
  ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] {
    const events: RealtimeModelV4ServerEvent[] = [];

    if (serverContent.interrupted) {
      // A barge-in ends the current user utterance; the next input
      // transcription fragment belongs to a fresh utterance. The interrupted
      // response's trailing turnComplete must not split that new utterance.
      this.inputTranscriptionBoundary = 'interrupted';
      this.preserveInputTranscriptionAcrossTurnComplete = true;
      this.turnClosed = true;
      events.push({
        type: 'speech-started',
        raw,
      });
    }

    if (serverContent.modelTurn?.parts) {
      // New model response content marks the start of the next turn.
      this.beginTurnIfClosed();
      for (const part of serverContent.modelTurn.parts) {
        if (part.inlineData?.data) {
          this.hasAudio = true;
          events.push({
            type: 'audio-delta',
            responseId: this.responseId,
            itemId: this.itemId,
            delta: part.inlineData.data,
            raw,
          });
        }
        if (part.text) {
          this.hasText = true;
          events.push({
            type: 'text-delta',
            responseId: this.responseId,
            itemId: this.itemId,
            delta: part.text,
            raw,
          });
        }
      }
    }

    if (serverContent.outputTranscription?.text) {
      this.hasTranscript = true;
      events.push({
        type: 'audio-transcript-delta',
        responseId: this.responseId,
        itemId: this.itemId,
        delta: serverContent.outputTranscription.text,
        raw,
      });
    }

    if (serverContent.inputTranscription != null) {
      const transcription = this.accumulateInputTranscription(
        serverContent.inputTranscription,
      );
      if (transcription != null) {
        events.push({
          type: 'input-transcription-completed',
          ...transcription,
          raw,
        });
      }
    }

    // `generationComplete` means generation has stopped, but playback and the
    // turn can remain open. Keep it distinct from `response-done`, which is
    // emitted only when Google sends `turnComplete`.
    if (serverContent.generationComplete) {
      events.push({
        type: 'custom',
        rawType: 'generationComplete',
        raw,
      });
    }

    // `interactionStatus` (IN_PROGRESS | IDLE | WAITING_FOR_INPUT) is the definitive
    // session-activity signal for background-reasoning models: `turnComplete`
    // no longer implies the model is idle, since asynchronous tool calls and
    // audio may still follow. Surface it as a custom event so clients can
    // coordinate state on it.
    if (serverContent.interactionStatus != null) {
      events.push({
        type: 'custom',
        rawType: 'interactionStatus',
        raw,
      });
    }

    // `waitingForInput` is the always-on Proactive Audio turn-taking signal:
    // the model has yielded the floor and is not generating because it
    // expects the user to continue.
    if (serverContent.waitingForInput) {
      events.push({
        type: 'custom',
        rawType: 'waitingForInput',
        raw,
      });
    }

    if (serverContent.turnComplete) {
      if (this.hasAudio) {
        events.push({
          type: 'audio-done',
          responseId: this.responseId,
          itemId: this.itemId,
          raw,
        });
      }
      if (this.hasText) {
        events.push({
          type: 'text-done',
          responseId: this.responseId,
          itemId: this.itemId,
          raw,
        });
      }
      if (this.hasTranscript) {
        events.push({
          type: 'audio-transcript-done',
          responseId: this.responseId,
          itemId: this.itemId,
          raw,
        });
      }
      events.push({
        type: 'response-done',
        responseId: this.responseId,
        status: 'completed',
        raw,
      });
      // Mark the turn closed but defer advancing the counter until the next
      // response actually begins (see `beginTurnIfClosed`).
      this.turnClosed = true;
      if (this.preserveInputTranscriptionAcrossTurnComplete) {
        this.preserveInputTranscriptionAcrossTurnComplete = false;
      } else {
        // For payloads without `finished`, the completed response remains the
        // fallback signal that the next transcription is a new utterance.
        // A transcription carrying `finished` can still arrive late and close
        // the preceding utterance after this response boundary.
        if (this.inputTranscriptionBoundary !== 'interrupted') {
          this.inputTranscriptionBoundary = 'turn-complete';
        }
      }
    }

    if (events.length === 0) {
      return { type: 'custom', rawType: 'serverContent', raw };
    }

    return events.length === 1 ? events[0] : events;
  }

  serializeClientEvent(
    event: RealtimeModelV4ClientEvent,
    modelId: string,
  ): ReturnType<RealtimeModelV4['serializeClientEvent']> {
    switch (event.type) {
      case 'session-update':
        // Capture the configured capture rate so input audio blobs advertise
        // the real rate. Google accepts any rate as long as the blob's mimeType
        // matches; a mismatched label corrupts custom-rate audio.
        if (event.config.inputAudioFormat?.rate != null) {
          this.inputAudioRate = event.config.inputAudioFormat.rate;
        }
        return {
          setup: buildGoogleSessionConfig(event.config, modelId),
        };

      case 'input-audio-append':
        return {
          realtimeInput: {
            audio: {
              data: event.audio,
              mimeType: `audio/pcm;rate=${this.inputAudioRate}`,
            },
          },
        };

      case 'input-audio-commit':
        return {
          realtimeInput: {
            audioStreamEnd: true,
          },
        };

      case 'input-audio-clear':
      case 'response-create':
      case 'response-cancel':
      case 'conversation-item-truncate':
        return null;

      case 'conversation-item-create': {
        const item = event.item;
        switch (item.type) {
          case 'text-message':
            return {
              realtimeInput: {
                text: item.text,
              },
            };
          case 'function-call-output':
            return serializeFunctionCallOutput(item);
          case 'audio-message':
            return null;
        }
        break;
      }
    }

    return null;
  }
}

/**
 * Maps Gemini Live `usageMetadata` to normalized usage. Google reports usage
 * per modality in `promptTokensDetails` / `responseTokensDetails`.
 *
 * Field names are Live-API-specific: the Live `UsageMetadata` uses
 * `responseTokenCount` / `responseTokensDetails` for output, NOT the
 * `candidatesTokenCount` / `candidatesTokensDetails` of the regular
 * `generateContent` response (see google-language-model.ts). Do not "align"
 * these to `candidates*` — they are different wire shapes.
 *
 * Gross totals preserve provider counts even when modality details are absent
 * or contain modalities without a dedicated normalized bucket. Never guess a
 * text/audio split from those totals.
 */
function mapGoogleUsage(
  usageMetadata: GoogleRealtimeUsageMetadata | undefined,
): RealtimeModelV4Usage | undefined {
  if (usageMetadata == null) return undefined;

  const input = sumByModality(usageMetadata.promptTokensDetails);
  const output = sumByModality(usageMetadata.responseTokensDetails);

  return compactUsage({
    inputTokens:
      usageMetadata.promptTokenCount != null
        ? usageMetadata.promptTokenCount +
          (usageMetadata.toolUsePromptTokenCount ?? 0)
        : undefined,
    outputTokens:
      usageMetadata.responseTokenCount != null
        ? usageMetadata.responseTokenCount +
          (usageMetadata.thoughtsTokenCount ?? 0)
        : undefined,
    cachedInputTokens: usageMetadata.cachedContentTokenCount,
    inputTextTokens: input.text,
    inputAudioTokens: input.audio,
    outputTextTokens: output.text,
    outputAudioTokens: output.audio,
  });
}

/** Aggregate Gemini per-modality token details into text/audio buckets. */
function sumByModality(details: GoogleRealtimeTokenDetail[] | undefined): {
  text?: number;
  audio?: number;
} {
  if (details == null) return {};
  const totals: { text?: number; audio?: number } = {};
  for (const detail of details) {
    const count = detail.tokenCount;
    if (count == null) continue;
    const modality = detail.modality?.toUpperCase();
    if (modality === 'AUDIO') {
      totals.audio = (totals.audio ?? 0) + count;
    } else if (modality === 'TEXT') {
      totals.text = (totals.text ?? 0) + count;
    }
  }
  return totals;
}

/** Drop `undefined` fields; return `undefined` when nothing is observable. */
function compactUsage(
  usage: RealtimeModelV4Usage,
): RealtimeModelV4Usage | undefined {
  const result: RealtimeModelV4Usage = {};
  for (const [key, value] of Object.entries(usage)) {
    if (value != null) {
      result[key as keyof RealtimeModelV4Usage] = value;
    }
  }
  return Object.keys(result).length > 0 ? result : undefined;
}

/**
 * The value Gemini accepts for `functionResponse.response`.
 *
 * The field is typed `google.protobuf.Struct`, which is an object and nothing else, so
 * a string, number, array or `null` on the wire is a protocol violation and the socket
 * closes with 1007. `onToolCall` returns `unknown` and `addToolOutput` takes `unknown`,
 * so those shapes reach here as perfectly valid JSON.
 *
 * The field's own docstring says what to do with one: "Use `output` key to specify
 * function output ... If `output` and `error` keys are not specified, then whole
 * `response` is treated as function output." An object is passed through unchanged and
 * everything else is wrapped.
 */
function toFunctionResponseStruct(value: unknown): Record<string, unknown> {
  const isStruct =
    typeof value === 'object' && value !== null && !Array.isArray(value);
  return isStruct ? (value as Record<string, unknown>) : { output: value };
}

async function serializeFunctionCallOutput(
  item: RealtimeModelV4FunctionCallOutput,
): Promise<unknown> {
  const parseResult = await safeParseJSON({ text: item.output });
  const response = parseResult.success
    ? toFunctionResponseStruct(parseResult.value)
    : // Preserve non-JSON output in the required object wrapper.
      { output: item.output };

  return {
    toolResponse: {
      functionResponses: [
        {
          id: item.callId,
          name: item.name,
          response,
        },
      ],
    },
  };
}

/**
 * Live models that reason in the background (e.g. `gemini-3.8-live-extended-thinking`).
 * Google requires exactly one of `thinkingLevel` / `thinkingBudget` in their setup and
 * rejects `thinkingConfig` on every other Live model.
 */
function isThinkingLiveModel(modelId: string): boolean {
  const modelName = modelId.split('/').at(-1)?.toLowerCase() ?? '';
  return /^gemini-\d+\.\d+-live\b.*thinking/.test(modelName);
}

/**
 * Builds a Google-specific session configuration from a normalized config.
 * Used to construct the `bidiGenerateContentSetup` payload for auth token creation.
 */
export function buildGoogleSessionConfig(
  config: RealtimeModelV4SessionConfig | undefined,
  modelId: string,
): Record<string, unknown> {
  const setup: Record<string, unknown> = {
    model: getModelPath(modelId),
  };

  const { google, ...restProviderOptions } = config?.providerOptions ?? {};
  const googleOptions = isRecord(google)
    ? (google as GoogleRealtimeModelOptions)
    : undefined;

  const generationConfig: Record<string, unknown> = {};

  if (config?.outputModalities != null) {
    generationConfig.responseModalities = config.outputModalities.map(m =>
      m.toUpperCase(),
    );
  } else {
    generationConfig.responseModalities = ['AUDIO'];
  }

  if (config?.voice != null) {
    generationConfig.speechConfig = {
      voiceConfig: {
        prebuiltVoiceConfig: {
          voiceName: config.voice,
        },
      },
    };
  }

  setup.generationConfig = generationConfig;

  if (config?.instructions != null) {
    setup.systemInstruction = {
      parts: [{ text: config.instructions }],
    };
  }

  if (config?.tools != null && config.tools.length > 0) {
    setup.tools = [
      {
        functionDeclarations: config.tools.map(tool => ({
          name: tool.name,
          description: tool.description,
          parametersJsonSchema: tool.parameters,
          ...(googleOptions?.defaultToolBehavior != null
            ? { behavior: googleOptions.defaultToolBehavior }
            : {}),
        })),
      },
    ];
  }

  if (config?.inputAudioTranscription != null) {
    setup.inputAudioTranscription = {};
  }

  if (config?.outputAudioTranscription != null) {
    setup.outputAudioTranscription = {};
  }

  // Background-reasoning models require a `thinkingLevel` or `thinkingBudget`.
  // Default to the lowest-latency level when the effective thinking config (the
  // typed option, else a raw `providerOptions.generationConfig.thinkingConfig`)
  // sets neither. Merged last so it survives a raw
  // `providerOptions.generationConfig`.
  const rawGenerationConfig = restProviderOptions.generationConfig;
  const explicitThinkingConfig =
    googleOptions?.thinkingConfig ??
    (isRecord(rawGenerationConfig) &&
    isRecord(rawGenerationConfig.thinkingConfig)
      ? rawGenerationConfig.thinkingConfig
      : undefined);
  const thinkingConfig =
    isThinkingLiveModel(modelId) &&
    explicitThinkingConfig?.thinkingLevel == null &&
    explicitThinkingConfig?.thinkingBudget == null
      ? { ...explicitThinkingConfig, thinkingLevel: 'low' as const }
      : googleOptions?.thinkingConfig;
  const applyThinkingConfig = () => {
    if (thinkingConfig == null) return;
    const target = isRecord(setup.generationConfig)
      ? setup.generationConfig
      : generationConfig;
    setup.generationConfig = { ...target, thinkingConfig };
  };

  if (config?.providerOptions == null) {
    applyThinkingConfig();
    return setup;
  }

  Object.assign(setup, restProviderOptions);

  if (googleOptions?.translationConfig != null) {
    const target = isRecord(setup.generationConfig)
      ? setup.generationConfig
      : generationConfig;
    setup.generationConfig = {
      ...target,
      translationConfig: googleOptions.translationConfig,
    };
  }

  applyThinkingConfig();
  return setup;
}
