/**
 * Google Gemini API Client
 *
 * Handles communication with Google Gemini API for translation
 * Supports dynamic config refresh when database settings change
 */

import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai';
import { getGeminiConfig, onConfigChange } from '../config';
import { logger } from '../utils/logger';
import { Tone, GeminiTranslationResponse, BulkStringItem, BulkStringResult, BulkTranslateResponse } from '../types';
import {
  canonicalizeStructuredFields,
  splitStructuredTranslation,
  StructuredFields,
} from '../utils/structuredFields';
import { MAX_TRUNCATION_RETRIES } from '../config/batchLimits';

// Model selection is code-driven (no env var).
//
// ENABLE_MODEL_FALLBACK=1 is the ONLY resilience this client has: it gates the loop that retries a
// retryable failure (rate limit, 429/503, model unavailable) against GEMINI_FALLBACK_MODELS.
// Setting it to 0 does NOT make the client stricter or more production-like — it makes every
// transient upstream error a hard, unretried failure. It was previously named DEVELOPMENT_MODE,
// which read as dev-only scaffolding safe to switch off in production. It is not. Do not flip it.
//
// Model IDs below were verified live against the production API key on 2026-08-22 with a real
// generateContent call. ListModels is NOT authoritative for this key: it advertises
// gemini-2.5-flash with generateContent support, yet that model returns a hard 404
// ("no longer available to new users... use models/gemini-3.6-flash"). Every fallback attempt on
// 2026-08-22 (302 of them) failed for exactly this reason. Re-verify with a real call, never a
// model listing, before changing any ID here.
// TODO: Switch primary to gemini-3-pro once production API keys are in place.
const ENABLE_MODEL_FALLBACK: 0 | 1 = 1;
const GEMINI_PRIMARY_MODEL: string = 'gemini-3.1-flash-lite';
const GEMINI_FALLBACK_MODELS: string[] = [
  // Google's own 404 message names this as the replacement; verified 200.
  'gemini-3.6-flash',
  // Second chain entry so a single decommissioning cannot take the fallback path down again.
  'gemini-3.5-flash',
];
const STRUCTURED_PLACEHOLDER_PATTERN = /__(?:TAG|EXCPT)_\d+__/g;
const STRUCTURED_PLACEHOLDER_PREFIX_PATTERN = /__(?:TAG|EXCPT)_/;

interface BulkResponseItem {
  id?: unknown;
  text?: unknown;
  failed?: unknown;
}

interface BulkCallOutcome {
  response: BulkTranslateResponse;
  incompleteIds: Set<string>;
}

const hasMaxTokensFinishReason = (response: unknown): boolean => {
  if (!response || typeof response !== 'object') {
    return false;
  }

  const candidates = (response as { candidates?: unknown }).candidates;
  if (!Array.isArray(candidates)) {
    return false;
  }

  return candidates.some((candidate) => {
    if (!candidate || typeof candidate !== 'object') {
      return false;
    }

    return (candidate as { finishReason?: unknown }).finishReason === 'MAX_TOKENS';
  });
};

export interface GeminiTokenUsage {
  inputTokens: number;
  outputTokens: number;
  totalTokens: number;
}

export function getGeminiTokenUsage(usageMetadata: unknown): GeminiTokenUsage {
  const metadata = usageMetadata && typeof usageMetadata === 'object'
    ? usageMetadata as Record<string, unknown>
    : {};
  const inputTokens = typeof metadata.promptTokenCount === 'number' && metadata.promptTokenCount >= 0
    ? metadata.promptTokenCount
    : 0;
  const candidateTokens = typeof metadata.candidatesTokenCount === 'number' && metadata.candidatesTokenCount >= 0
    ? metadata.candidatesTokenCount
    : 0;
  const thinkingTokens = typeof metadata.thoughtsTokenCount === 'number' && metadata.thoughtsTokenCount >= 0
    ? metadata.thoughtsTokenCount
    : 0;
  const outputTokens = candidateTokens + thinkingTokens;

  return {
    inputTokens,
    outputTokens,
    totalTokens: inputTokens + outputTokens,
  };
}

class GeminiClient {
  private client: GoogleGenerativeAI | null = null;
  private model: GenerativeModel | null = null;
  private readonly timeout = 60000; // 60 seconds timeout
  private currentConfig: { apiKey: string } | null = null;

  constructor() {
    this.initializeClient();

    // Subscribe to config changes to refresh client when Gemini settings are updated
    onConfigChange('gemini', () => {
      logger.info('Gemini config changed, refreshing client...');
      this.refreshConfig();
    });

    onConfigChange('all', () => {
      logger.info('All config changed, refreshing Gemini client...');
      this.refreshConfig();
    });
  }

  /**
   * Initialize or reinitialize the Gemini client with current config
   */
  private initializeClient(): void {
    try {
      const geminiConfig = getGeminiConfig();
      this.currentConfig = geminiConfig;

      this.client = new GoogleGenerativeAI(geminiConfig.apiKey);
      this.model = this.client.getGenerativeModel({ model: GEMINI_PRIMARY_MODEL });

      logger.info('Gemini client initialized', {
        gemini_model: GEMINI_PRIMARY_MODEL,
        model_fallback_enabled: ENABLE_MODEL_FALLBACK === 1,
        hasKey: !!geminiConfig.apiKey,
      });
    } catch (error) {
      logger.error('Failed to initialize Gemini client', { error });
      throw error;
    }
  }

  /**
   * Refresh the client configuration from database
   * Called automatically when config changes are detected
   */
  refreshConfig(): void {
    try {
      const newConfig = getGeminiConfig();

      // Only reinitialize if config has actually changed
      if (
        !this.currentConfig ||
        this.currentConfig.apiKey !== newConfig.apiKey
      ) {
        logger.info('Gemini config changed, reinitializing client...');
        this.initializeClient();
      } else {
        logger.debug('Gemini config unchanged, skipping reinitialize');
      }
    } catch (error) {
      logger.error('Failed to refresh Gemini client config', { error });
    }
  }

  /**
   * Get current configuration (for debugging/monitoring)
   */
  getCurrentConfig(): { hasKey: boolean } | null {
    return this.currentConfig
      ? { hasKey: !!this.currentConfig.apiKey }
      : null;
  }

  /**
   * Extract and preserve HTML tags from content
   * Returns content with placeholders and mapping of tags
   */
  private extractHtmlTags(content: string): { text: string; tags: Map<string, string> } {
    const tags = new Map<string, string>();
    let counter = 0;

    // Match HTML comments (Gutenberg block delimiters carry JSON attributes the
    // model must never have to re-escape) and HTML tags (opening, closing, self-closing)
    const htmlTagRegex = /<!--[\s\S]*?-->|<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z][a-zA-Z0-9-]*(?:="[^"]*"|='[^']*'|=[^\s>]*)?)*\s*\/?>/g;

    const text = content.replace(htmlTagRegex, (match) => {
      let placeholder: string;
      do {
        placeholder = `__TAG_${counter++}__`;
      } while (content.includes(placeholder) || tags.has(placeholder));
      tags.set(placeholder, match);
      return placeholder;
    });

    return { text, tags };
  }

  /**
   * Restore HTML tags from placeholders
   */
  private restoreHtmlTags(text: string, tags: Map<string, string>): string {
    let result = text;

    tags.forEach((tag, placeholder) => {
      const occurrences = result.split(placeholder).length - 1;
      if (occurrences !== 1) {
        throw new Error(`HTML placeholder occurrence mismatch: ${placeholder}`);
      }
      result = result.replace(placeholder, tag);
    });

    return result;
  }

  private assertStructuredPlaceholdersPreserved(source: string, output: string, field: string): void {
    const expected = source.match(STRUCTURED_PLACEHOLDER_PATTERN) || [];
    const actual = output.match(STRUCTURED_PLACEHOLDER_PATTERN) || [];
    const expectedCounts = new Map(expected.map((placeholder) => [placeholder, expected.filter((item) => item === placeholder).length]));
    const actualCounts = new Map(actual.map((placeholder) => [placeholder, actual.filter((item) => item === placeholder).length]));

    const remaining = expected.reduce((text, placeholder) => text.split(placeholder).join(''), output);
    if (STRUCTURED_PLACEHOLDER_PREFIX_PATTERN.test(remaining) || expected.length !== actual.length ||
      [...expectedCounts.entries()].some(([placeholder, count]) => actualCounts.get(placeholder) !== count)) {
      throw new Error(`Structured translation placeholders were not preserved for field: ${field}`);
    }
  }


  private getToneInstruction(tone: Tone): string {
    const toneInstructions = {
      [Tone.FORMAL]: 'Use formal, professional language.',
      [Tone.CASUAL]: 'Use casual, conversational language.',
      [Tone.NEUTRAL]: 'Use neutral, standard language.',
    };

    return toneInstructions[tone] || toneInstructions[Tone.NEUTRAL];
  }

  /**
   * Get language name from ISO code for better prompting
   */
  private getLanguageName(code: string): string {
    const languageMap: Record<string, string> = {
      'en': 'English',
      'es': 'Spanish',
      'fr': 'French',
      'de': 'German',
      'it': 'Italian',
      'pt': 'Portuguese',
      'nl': 'Dutch',
      'pl': 'Polish',
      'ru': 'Russian',
      'ja': 'Japanese',
      'ko': 'Korean',
      'zh': 'Chinese',
      'ar': 'Arabic',
      'hi': 'Hindi',
      'tr': 'Turkish',
      'vi': 'Vietnamese',
      'th': 'Thai',
      'id': 'Indonesian',
      'ms': 'Malay',
      'sv': 'Swedish',
      'da': 'Danish',
      'no': 'Norwegian',
      'fi': 'Finnish',
      'cs': 'Czech',
      'el': 'Greek',
      'he': 'Hebrew',
      'ro': 'Romanian',
      'hu': 'Hungarian',
      'uk': 'Ukrainian',
      'bg': 'Bulgarian',
      'sr': 'Serbian',
      'hr': 'Croatian',
      'sk': 'Slovak',
      'sl': 'Slovenian',
      'et': 'Estonian',
      'lv': 'Latvian',
      'lt': 'Lithuanian',
    };

    return languageMap[code] || code.toUpperCase();
  }

  private withTimeout<T>(operation: Promise<T>, message: string, timeout = this.timeout): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error(message)), timeout);
      operation.then(resolve, reject).finally(() => clearTimeout(timer));
    });
  }

  /**
   * Translate content using Google Gemini API
   *
   * @param content Content to translate
   * @param sourceLang Source language (ISO 639-1 code)
   * @param targetLang Target language (ISO 639-1 code)
   * @param tone Translation tone/style
   * @returns Translation response with translated content, tokens used, and processing time
   * @throws Error if translation fails
   */
  private isRetryableError(e: unknown): boolean {
    if (!(e instanceof Error)) return false;
    const msg = (e.message || '').toLowerCase();
    return (
      msg.includes('rate limit') ||
      msg.includes('quota') ||
      msg.includes('resource_exhausted') ||
      msg.includes('too many requests') ||
      msg.includes('429') ||
      msg.includes('not found') ||
      msg.includes('model') ||
      msg.includes('503') ||
      msg.includes('unavailable') ||
      msg.includes('timed out')
    );
  }

  async translate(
    content: string,
    sourceLang: string,
    targetLang: string,
    tone: Tone = Tone.NEUTRAL
  ): Promise<GeminiTranslationResponse> {
    if (!this.client || !this.model) {
      throw new Error('Gemini client not initialized');
    }

    const startTime = Date.now();

    // Extract HTML tags to preserve them
    const { text: cleanText, tags } = this.extractHtmlTags(content);

    const sourceLanguage = this.getLanguageName(sourceLang);
    const targetLanguage = this.getLanguageName(targetLang);
    const toneInstruction = this.getToneInstruction(tone);

    // Build translation prompt
    const prompt = `Translate the following text from ${sourceLanguage} to ${targetLanguage}.

${toneInstruction}

IMPORTANT RULES:
1. Preserve all placeholders in the format __TAG_N__ exactly as they appear
2. Do not translate the placeholders themselves
3. Only translate the actual text content
4. Maintain the exact position and format of placeholders
5. Output ONLY the translated text, no explanations or additional text
6. Preserve all placeholders in the format __EXCPT_N__ exactly as they appear

Text to translate:
${cleanText}`;

    logger.info('Calling Gemini translation API', {
      sourceLang,
      targetLang,
      gemini_model: GEMINI_PRIMARY_MODEL,
      tone,
      contentLength: content.length,
      cleanTextLength: cleanText.length,
      tagCount: tags.size,
    });

    const runWithModel = async (
      activeModel: GenerativeModel,
      geminiModelName: string,
      fallbackUsed: boolean
    ): Promise<GeminiTranslationResponse> => {
      // Generate translation with timeout
      const result = await this.withTimeout(
        activeModel.generateContent(prompt),
        'Translation request timed out'
      );

      const response = result.response;
      const translatedText = response.text();

      if (!translatedText) {
        throw new Error('Empty translation response from Gemini API');
      }

      // Restore HTML tags
      const finalTranslation = this.restoreHtmlTags(translatedText, tags);

      // Extract token usage from response metadata (no extra API calls)
      const tokenUsage = getGeminiTokenUsage(response.usageMetadata);
      const inputTokens = tokenUsage.inputTokens;
      const outputTokens = tokenUsage.outputTokens;
      const totalTokens = tokenUsage.totalTokens;

      const processingTime = Date.now() - startTime;

      logger.info('Gemini translation successful', {
        gemini_model: geminiModelName,
        fallback_used: fallbackUsed,
        inputTokens,
        outputTokens,
        totalTokens,
        processingTimeMs: processingTime,
        tagsRestored: tags.size,
      });

      return {
        translation: finalTranslation,
        tokens_used: totalTokens,
        input_tokens: inputTokens,
        output_tokens: outputTokens,
        processing_time_ms: processingTime,
        model_used: geminiModelName,
      };
    };

    try {
      return await runWithModel(this.model, GEMINI_PRIMARY_MODEL, false);
    } catch (caughtError) {
      let error: unknown = caughtError;

      // Try each fallback model in order on retryable errors (rate limit, model unavailable, etc.)
      if (ENABLE_MODEL_FALLBACK === 1 && this.client && this.isRetryableError(error)) {
        for (const fallbackModelName of GEMINI_FALLBACK_MODELS) {
          logger.warn('Gemini primary model failed; attempting fallback', {
            failed_model: GEMINI_PRIMARY_MODEL,
            primary_error: error instanceof Error ? (error as Error).message.substring(0, 200) : 'unknown',
            fallback_model: fallbackModelName,
          });

          try {
            const fallbackModel = this.client.getGenerativeModel({ model: fallbackModelName });
            return await runWithModel(fallbackModel, fallbackModelName, true);
          } catch (fallbackError) {
            logger.error('Gemini fallback model failed', {
              model: fallbackModelName,
              error: fallbackError instanceof Error ? fallbackError.message : 'Unknown error',
            });
            error = fallbackError;
            // If this fallback also hit a retryable error, continue to next; otherwise break
            if (!this.isRetryableError(fallbackError)) break;
          }
        }
      }

      const processingTime = Date.now() - startTime;

      // Handle specific error cases
      if (error instanceof Error) {
        const errorMessage = error.message;

        logger.error('Gemini translation failed', {
          error: errorMessage,
          processingTime,
        });

        // Map common errors to user-friendly messages
        if (errorMessage.includes('timed out')) {
          throw new Error('Translation request timed out. Please try again shortly.');
        }

        if (errorMessage.includes('API key')) {
          throw new Error('Gemini API authentication failed.');
        }

        if (errorMessage.includes('quota') || errorMessage.includes('rate limit')) {
          throw new Error('Gemini API rate limit exceeded. Please try again later.');
        }

        if (errorMessage.includes('model') || errorMessage.includes('not found')) {
          throw new Error('Translation model temporarily unavailable. Please try again later.');
        }

        // Generic error
        throw new Error(`Translation failed: ${errorMessage}`);
      }

      // Unknown error type
      logger.error('Unexpected error in Gemini client', {
        error: error instanceof Error ? error.message : 'Unknown error',
        processingTime,
      });

      throw new Error('An unexpected error occurred during translation. Please try again.');
    }
  }

  /**
   * Translate multiple strings in a single Gemini API call (token-efficient bulk mode).
   *
   * Extracts HTML tags from each string before building the JSON prompt,
   * then restores them in the response. On JSON parse failure the entire batch
   * is marked as failed so the caller can fall back gracefully.
   *
   * @param strings  Array of { id, content } pairs — id is echoed back unchanged
   * @param sourceLang Source language ISO 639-1 code
   * @param targetLang Target language ISO 639-1 code
   * @param tone Translation tone
   * @returns Array of results in the same order as the input
   */
  async translateBulk(
    strings: BulkStringItem[],
    sourceLang: string,
    targetLang: string,
    tone: Tone = Tone.NEUTRAL
  ): Promise<BulkTranslateResponse> {
    if (!this.client || !this.model) {
      throw new Error('Gemini client not initialized');
    }

    if (strings.length === 0) {
      return { results: [], model_used: GEMINI_PRIMARY_MODEL, tokens_used: 0, input_tokens: 0, output_tokens: 0 };
    }

    const primaryModel = this.model;
    const client = this.client;
    const sourceLanguage = this.getLanguageName(sourceLang);
    const targetLanguage = this.getLanguageName(targetLang);
    const toneInstruction = this.getToneInstruction(tone);

    const runBulkWithModel = async (
      activeModel: GenerativeModel,
      modelName: string,
      fallbackUsed: boolean,
      batchStrings: BulkStringItem[]
    ): Promise<BulkCallOutcome> => {
      const processedStrings = batchStrings.map(s => {
        const { text, tags } = this.extractHtmlTags(s.content);
        return { id: s.id, text, tags };
      });
      const inputArray = processedStrings.map(s => ({ id: s.id, text: s.text }));
      const inputJson = JSON.stringify(inputArray);
      const prompt = `Translate the following JSON array from ${sourceLanguage} to ${targetLanguage}.
${toneInstruction}

Rules:
1. Return ONLY a valid JSON array — no explanation, no markdown, no code fences
2. Keep each object's "id" value unchanged
3. Translate only the "text" field value
4. Preserve HTML placeholders __TAG_N__ exactly as they appear
5. Preserve exception placeholders __EXCPT_N__ exactly as they appear
6. If a string fails to translate, return {"id":"...","text":"","failed":true}

${inputJson}`;

      logger.info('Calling Gemini bulk translation API', {
        sourceLang,
        targetLang,
        tone,
        stringCount: batchStrings.length,
      });

      const result = await this.withTimeout(
        activeModel.generateContent(prompt),
        'Bulk translation request timed out'
      );

      const response = result.response;
      const truncated = hasMaxTokensFinishReason(response);
      const tokenUsage = getGeminiTokenUsage(response.usageMetadata);
      const inputTokens = tokenUsage.inputTokens;
      const outputTokens = tokenUsage.outputTokens;
      const totalTokens = tokenUsage.totalTokens;
      const allBatchIds = new Set(batchStrings.map(s => s.id));

      const buildFailureOutcome = (error: string, incompleteIds: Set<string>): BulkCallOutcome => ({
        response: {
          results: batchStrings.map(s => ({
            id: s.id,
            translation: '',
            success: false,
            error,
          })),
          model_used: modelName,
          tokens_used: totalTokens,
          input_tokens: inputTokens,
          output_tokens: outputTokens,
        },
        incompleteIds,
      });

      const responseText = response.text();
      if (!responseText) {
        if (truncated) {
          return buildFailureOutcome('gemini output truncated', allBatchIds);
        }
        throw new Error('Empty response from Gemini API');
      }

      // Strip markdown code fences if the model wraps the JSON.
      let cleanResponse = responseText.trim();
      if (cleanResponse.startsWith('```')) {
        cleanResponse = cleanResponse.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '').trim();
      }

      let parsed: unknown;
      try {
        parsed = JSON.parse(cleanResponse);
      } catch (parseError) {
        logger.error('Failed to parse Gemini bulk response as JSON', {
          error: parseError instanceof Error ? parseError.message : 'Parse error',
          responsePreview: responseText.substring(0, 300),
          gemini_model: modelName,
        });
        // A malformed JSON body is itself evidence that the model output was
        // truncated, even when Gemini omits or changes the finish reason.
        return buildFailureOutcome('gemini output truncated', allBatchIds);
      }

      if (!Array.isArray(parsed)) {
        logger.error('Gemini bulk response was not a JSON array', {
          gemini_model: modelName,
        });
        // Treat a successful non-array body like malformed/truncated JSON so
        // every string gets a retry instead of becoming a terminal failure.
        return buildFailureOutcome('gemini output truncated', allBatchIds);
      }

      const resultMap = new Map<string, BulkResponseItem>();
      for (const rawItem of parsed) {
        if (!rawItem || typeof rawItem !== 'object') {
          continue;
        }
        const item = rawItem as BulkResponseItem;
        if (typeof item.id === 'string' && !resultMap.has(item.id)) {
          resultMap.set(item.id, item);
        }
      }

      // A MAX_TOKENS response is potentially incomplete even when some items
      // were parsed successfully. Completed items remain first-success wins,
      // while their IDs are still marked incomplete for the call-level signal.
      const incompleteIds = truncated ? new Set(allBatchIds) : new Set<string>();
      const results: BulkStringResult[] = processedStrings.map(s => {
        const item = resultMap.get(s.id);
        if (!item || typeof item.text !== 'string' || item.text.length === 0) {
          incompleteIds.add(s.id);
          return {
            id: s.id,
            translation: '',
            success: false,
            error: item?.failed === true ? 'Translation failed for this string' : 'String missing from response',
          };
        }
        if (item.failed === true) {
          return {
            id: s.id,
            translation: '',
            success: false,
            error: 'Translation failed for this string',
          };
        }
        const restored = this.restoreHtmlTags(item.text, s.tags);
        return {
          id: s.id,
          translation: restored,
          success: true,
        };
      });

      logger.info('Gemini bulk translation successful', {
        gemini_model: modelName,
        fallback_used: fallbackUsed,
        stringCount: batchStrings.length,
        totalTokens,
        truncated,
      });

      return {
        response: {
          results,
          model_used: modelName,
          tokens_used: totalTokens,
          input_tokens: inputTokens,
          output_tokens: outputTokens,
        },
        incompleteIds,
      };
    };

    const runBulkCall = async (batchStrings: BulkStringItem[]): Promise<BulkCallOutcome> => {
      let error: unknown;
      try {
        return await runBulkWithModel(primaryModel, GEMINI_PRIMARY_MODEL, false, batchStrings);
      } catch (caughtError) {
        error = caughtError;
      }

      // Try each fallback model in order on retryable errors (rate limit, model unavailable, etc.).
      if (ENABLE_MODEL_FALLBACK === 1 && this.isRetryableError(error)) {
        for (const fallbackModelName of GEMINI_FALLBACK_MODELS) {
          logger.warn('Gemini primary model failed on bulk; attempting fallback', {
            failed_model: GEMINI_PRIMARY_MODEL,
            primary_error: error instanceof Error ? error.message.substring(0, 200) : 'unknown',
            fallback_model: fallbackModelName,
            stringCount: batchStrings.length,
          });

          try {
            const fallbackModel = client.getGenerativeModel({ model: fallbackModelName });
            return await runBulkWithModel(fallbackModel, fallbackModelName, true, batchStrings);
          } catch (fallbackError) {
            logger.error('Gemini fallback model failed on bulk', {
              model: fallbackModelName,
              error: fallbackError instanceof Error ? fallbackError.message : 'Unknown error',
            });
            error = fallbackError;
            if (!this.isRetryableError(fallbackError)) break;
          }
        }
      }

      throw error instanceof Error ? error : new Error('Unknown error');
    };

    const completed = new Map<string, BulkStringResult>();
    const terminalFailures = new Map<string, BulkStringResult>();
    let modelUsed = GEMINI_PRIMARY_MODEL;
    let tokensUsed = 0;
    let inputTokens = 0;
    let outputTokens = 0;

    const processOutcome = (batchStrings: BulkStringItem[], outcome: BulkCallOutcome): void => {
      const batchIds = new Set(batchStrings.map(s => s.id));
      modelUsed = outcome.response.model_used;
      tokensUsed += outcome.response.tokens_used;
      inputTokens += outcome.response.input_tokens;
      outputTokens += outcome.response.output_tokens;

      for (const result of outcome.response.results) {
        if (!batchIds.has(result.id)) {
          continue;
        }
        if (result.success && result.translation.length > 0) {
          if (!completed.has(result.id)) {
            completed.set(result.id, result);
          }
          continue;
        }
        if (!outcome.incompleteIds.has(result.id) && !terminalFailures.has(result.id) && !completed.has(result.id)) {
          terminalFailures.set(result.id, result);
        }
      }
    };

    const markBatchFailed = (batchStrings: BulkStringItem[], caughtError: unknown): void => {
      const errorMessage = caughtError instanceof Error ? caughtError.message : 'Unknown error';
      logger.error('Gemini bulk translation failed', {
        error: errorMessage,
        stringCount: batchStrings.length,
      });
      for (const string of batchStrings) {
        if (!completed.has(string.id) && !terminalFailures.has(string.id)) {
          terminalFailures.set(string.id, {
            id: string.id,
            translation: '',
            success: false,
            error: errorMessage,
          });
        }
      }
    };

    let pending: BulkStringItem[] = [];
    try {
      const initialOutcome = await runBulkCall(strings);
      processOutcome(strings, initialOutcome);
      pending = strings.filter(string =>
        initialOutcome.incompleteIds.has(string.id) && !completed.has(string.id) && !terminalFailures.has(string.id)
      );
    } catch (caughtError) {
      markBatchFailed(strings, caughtError);
    }

    let splitNextRound = false;
    for (let round = 1; round <= MAX_TRUNCATION_RETRIES && pending.length > 0; round++) {
      const beforeIds = pending.map(string => string.id);
      logger.warn('Retrying incomplete Gemini bulk strings', {
        total: strings.length,
        incomplete: pending.length,
        round,
      });

      const batches = splitNextRound && pending.length > 1
        ? [pending.slice(0, Math.ceil(pending.length / 2)), pending.slice(Math.ceil(pending.length / 2))]
        : [pending];
      splitNextRound = false;
      const roundIncompleteIds = new Set<string>();

      for (const batch of batches) {
        try {
          const outcome = await runBulkCall(batch);
          processOutcome(batch, outcome);
          for (const id of outcome.incompleteIds) {
            if (!completed.has(id) && !terminalFailures.has(id)) {
              roundIncompleteIds.add(id);
            }
          }
        } catch (caughtError) {
          markBatchFailed(batch, caughtError);
        }
      }

      pending = pending.filter(string =>
        roundIncompleteIds.has(string.id) && !completed.has(string.id) && !terminalFailures.has(string.id)
      );
      const noProgress = pending.length === beforeIds.length && pending.every((string, index) => string.id === beforeIds[index]);
      if (noProgress && pending.length > 1) {
        splitNextRound = true;
      }
    }

    const results = strings.map(string => completed.get(string.id) ?? terminalFailures.get(string.id) ?? ({
      id: string.id,
      translation: '',
      success: false,
      error: 'gemini output truncated',
    }));

    return {
      results,
      model_used: modelUsed,
      tokens_used: tokensUsed,
      input_tokens: inputTokens,
      output_tokens: outputTokens,
    };
  }

  /**
   * Translate structured post fields (title, excerpt, content) in a single Gemini call.
   *
   * Fields are HTML-tag-extracted individually before building a JSON prompt,
   * then HTML tags are restored per-field from the JSON response.
   */
  async translateStructured(
    fields: StructuredFields,
    sourceLang: string,
    targetLang: string,
    tone: Tone = Tone.NEUTRAL
  ): Promise<{
    fields: StructuredFields;
    translatedFields: StructuredFields;
    translated_title?: string;
    translated_excerpt?: string;
    translated_content?: string;
    tokens_used: number;
    input_tokens: number;
    output_tokens: number;
    processing_time_ms: number;
    model_used: string;
  }> {
    if (!this.client || !this.model) {
      throw new Error('Gemini client not initialized');
    }

    const startTime = Date.now();
    const inputData = Object.fromEntries(
      Object.entries(canonicalizeStructuredFields(fields)).map(([key, value]) => [key, this.extractHtmlTags(value)])
    );
    const inputObj = Object.fromEntries(
      Object.entries(inputData).map(([key, value]) => [key, value.text])
    ) as StructuredFields;
    const sourceLanguage = this.getLanguageName(sourceLang);
    const targetLanguage = this.getLanguageName(targetLang);
    const toneInstruction = this.getToneInstruction(tone);
    const inputJson = JSON.stringify(inputObj);

    const prompt = `Translate the field values in the following JSON from ${sourceLanguage} to ${targetLanguage}.
${toneInstruction}

Rules:
1. Return ONLY a valid JSON object — no explanation, markdown, or code fences
2. Return exactly the same keys, with no missing or extra keys
3. Keep keys unchanged and translate only string values
4. Every output value must be a JSON string
5. Preserve HTML placeholders __TAG_N__ and exception placeholders __EXCPT_N__ exactly

${inputJson}`;

    const runWithModel = async (
      activeModel: GenerativeModel,
      modelName: string,
      fallbackUsed: boolean
    ) => {
      const result = await this.withTimeout(
        activeModel.generateContent({
          contents: [{ role: 'user', parts: [{ text: prompt }] }],
          // Constrained decoding: the API can only emit valid JSON, so a long
          // reply can no longer break its own string escaping mid-document.
          generationConfig: { responseMimeType: 'application/json' },
        }),
        'Structured translation request timed out'
      );
      const response = result.response;
      const responseText = response.text();
      if (!responseText) {
        throw new Error('Empty translation response from Gemini API');
      }

      let cleanResponse = responseText.trim();
      if (cleanResponse.startsWith('```')) {
        cleanResponse = cleanResponse.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '').trim();
      }

      let parsed: unknown;
      try {
        parsed = JSON.parse(cleanResponse);
      } catch (parseError) {
        logger.error('Failed to parse structured translation response as JSON', {
          error: parseError instanceof Error ? parseError.message : 'Parse error',
          modelName,
        });
        throw new Error('Failed to parse structured translation response');
      }

      if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
        throw new Error('Structured translation response must be a JSON object');
      }
      const output = parsed as Record<string, unknown>;
      const expectedKeys = Object.keys(inputObj).sort();
      const outputKeys = Object.keys(output).sort();
      if (expectedKeys.length !== outputKeys.length || expectedKeys.some((key, index) => key !== outputKeys[index])) {
        throw new Error('Structured translation response keys do not match input fields');
      }
      if (outputKeys.some((key) => typeof output[key] !== 'string')) {
        throw new Error('Structured translation response values must be strings');
      }

      const translated: StructuredFields = {};
      for (const key of expectedKeys) {
        const tags = inputData[key].tags;
        this.assertStructuredPlaceholdersPreserved(inputObj[key], output[key] as string, key);
        translated[key] = this.restoreHtmlTags(output[key] as string, tags);
      }

      // Extract token usage from response metadata (no extra API calls)
      const tokenUsage = getGeminiTokenUsage(response.usageMetadata);
      const inputTokens = tokenUsage.inputTokens;
      const outputTokens = tokenUsage.outputTokens;
      const totalTokens = tokenUsage.totalTokens;
      const processingTime = Date.now() - startTime;
      const aliases = splitStructuredTranslation(translated);

      logger.info('Gemini structured translation successful', {
        gemini_model: modelName,
        fallback_used: fallbackUsed,
        fieldCount: expectedKeys.length,
        totalTokens,
        processingTimeMs: processingTime,
      });

      return {
        fields: translated,
        translatedFields: translated,
        translated_title: aliases.translatedTitle,
        translated_excerpt: aliases.translatedExcerpt,
        translated_content: aliases.translatedContent,
        tokens_used: totalTokens,
        input_tokens: inputTokens,
        output_tokens: outputTokens,
        processing_time_ms: processingTime,
        model_used: modelName,
      };
    };

    try {
      return await runWithModel(this.model, GEMINI_PRIMARY_MODEL, false);
    } catch (caughtError) {
      let error: unknown = caughtError;

      if (ENABLE_MODEL_FALLBACK === 1 && this.client && this.isRetryableError(error)) {
        for (const fallbackModelName of GEMINI_FALLBACK_MODELS) {
          logger.warn('Gemini primary model failed on structured translation; attempting fallback', {
            // Without these two fields the primary's failure is invisible: on 2026-08-22 the
            // primary failed 302 times through this path and the cause was never recorded.
            failed_model: GEMINI_PRIMARY_MODEL,
            primary_error: error instanceof Error ? error.message.substring(0, 200) : 'unknown',
            fallback_model: fallbackModelName,
          });

          try {
            const fallbackModel = this.client.getGenerativeModel({ model: fallbackModelName });
            return await runWithModel(fallbackModel, fallbackModelName, true);
          } catch (fallbackError) {
            logger.error('Gemini fallback model failed on structured translation', {
              model: fallbackModelName,
            });
            error = fallbackError;
            if (!this.isRetryableError(fallbackError)) break;
          }
        }
      }

      throw error instanceof Error ? error : new Error('Structured translation failed');
    }
  }

  /**
   * Check health of Gemini service
   * Tests API key validity with a minimal request
   *
   * @returns true if service is healthy, false otherwise
   */
  async healthCheck(): Promise<boolean> {
    try {
      if (!this.client || !this.model) {
        logger.warn('Gemini client not initialized');
        return false;
      }

      // Test with a minimal translation
      const testPrompt = 'Translate "hello" to Spanish';
      const result = await this.withTimeout(
        this.model.generateContent(testPrompt),
        'Health check timeout',
        5000
      );

      const response = result.response;
      return !!response.text();
    } catch (error) {
      logger.warn('Gemini health check failed', {
        error: error instanceof Error ? error.message : 'Unknown error',
      });
      return false;
    }
  }
}

// Export singleton instance
export const geminiClient = new GeminiClient();
