import { Logger } from '@nestjs/common';
import {
  type AsrProvider,
  type AsrResult,
  type AudioInput,
  type ExtractionCatalog,
  type ExtractionProvider,
  type ExtractionResult,
  VoiceProviderError,
} from './voice.providers';
import { buildExtractionPrompt } from './extraction.prompt';
import {
  VOICE_INTENT_JSON_SCHEMA,
  toVoiceIntent,
  type WireVoiceIntent,
} from './extraction.wire';

type OpenRouterConfig = {
  apiKey: string;
  baseUrl: string;
  model: string;
};

type TranscriptionResponse = {
  text?: unknown;
  usage?: { seconds?: unknown; cost?: unknown };
};

type ChatResponse = {
  choices?: { message?: { content?: unknown } }[];
  usage?: { cost?: unknown };
};

function numberOrNull(value: unknown): number | null {
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

const errorLogger = new Logger('OpenRouterVoice');

async function readError(
  response: Response,
  stage: 'asr' | 'extraction',
): Promise<never> {
  // A vendor 4xx can echo the request back, transcript included. Keep the body out of the
  // thrown message — which callers log and could forward — and out of the default log
  // level; it stays available at debug when someone is actively diagnosing.
  try {
    errorLogger.debug(
      `${stage} ${response.status} body: ${(await response.text()).slice(0, 500)}`,
    );
  } catch {
    errorLogger.debug(`${stage} ${response.status} body unreadable`);
  }
  throw new VoiceProviderError(
    stage,
    `OpenRouter ${stage} failed with status ${response.status}`,
    response.status,
  );
}

/** Speech → text via OpenRouter's transcription endpoint (whisper-1 and friends). */
export class OpenRouterAsrProvider implements AsrProvider {
  private readonly logger = new Logger(OpenRouterAsrProvider.name);

  constructor(private readonly config: OpenRouterConfig) {}

  async transcribe(
    audio: AudioInput,
    localeHint: string,
    signal?: AbortSignal,
  ): Promise<AsrResult> {
    const response = await fetch(
      `${this.config.baseUrl}/audio/transcriptions`,
      {
        method: 'POST',
        signal,
        headers: {
          Authorization: `Bearer ${this.config.apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          model: this.config.model,
          input_audio: { data: audio.data, format: audio.format },
          language: localeHint,
        }),
      },
    );

    if (!response.ok) await readError(response, 'asr');

    const body = (await response.json()) as TranscriptionResponse;
    const text = typeof body.text === 'string' ? body.text.trim() : '';
    this.logger.debug(
      `transcribed ${numberOrNull(body.usage?.seconds) ?? '?'}s`,
    );

    return {
      text,
      usage: {
        seconds: numberOrNull(body.usage?.seconds),
        costUsd: numberOrNull(body.usage?.cost),
      },
    };
  }
}

/** Transcript → VoiceIntent via OpenRouter chat completions with a JSON schema. */
export class OpenRouterExtractionProvider implements ExtractionProvider {
  constructor(private readonly config: OpenRouterConfig) {}

  async extract(
    transcript: string,
    catalog: ExtractionCatalog,
    localeHint: string,
    signal?: AbortSignal,
  ): Promise<ExtractionResult> {
    const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
      method: 'POST',
      signal,
      headers: {
        Authorization: `Bearer ${this.config.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: this.config.model,
        temperature: 0,
        // Only route to endpoints that actually honour the JSON schema — otherwise OpenRouter may
        // pick a provider that treats it as a hint and returns prose, failing intermittently.
        provider: { require_parameters: true },
        messages: buildExtractionPrompt(transcript, catalog, localeHint),
        response_format: {
          type: 'json_schema',
          json_schema: {
            name: 'voice_intent',
            strict: true,
            schema: VOICE_INTENT_JSON_SCHEMA,
          },
        },
      }),
    });

    if (!response.ok) await readError(response, 'extraction');

    const body = (await response.json()) as ChatResponse;
    const content = body.choices?.[0]?.message?.content;
    if (typeof content !== 'string' || !content.trim()) {
      throw new VoiceProviderError(
        'extraction',
        'OpenRouter returned no content',
      );
    }

    let wire: WireVoiceIntent;
    try {
      wire = JSON.parse(content) as WireVoiceIntent;
    } catch {
      // Schema-constrained output should be valid JSON; if it is not, the resolvers can do
      // nothing with it, so fail here rather than pass rubbish downstream.
      throw new VoiceProviderError(
        'extraction',
        'OpenRouter returned unparseable JSON',
      );
    }

    return {
      intent: toVoiceIntent(wire),
      costUsd: numberOrNull(body.usage?.cost),
    };
  }
}
