import { OpenAI } from "openai"
import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder"
import {
  MAX_BATCH_TOKENS,
  MAX_ITEM_TOKENS,
  MAX_BATCH_RETRIES as MAX_RETRIES,
  INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS,
  REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES,
  REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS,
} from "../constants"
import { getDefaultModelId, getModelQueryPrefix } from "../model-registry"
import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers"
import { Mutex } from "async-mutex"
import { Log } from "../../util/log"

const log = Log.create({ service: "embedder-openai-compatible" })

interface EmbeddingItem {
  embedding: string | number[]
  [key: string]: any
}

interface OpenAIEmbeddingResponse {
  data: EmbeddingItem[]
  usage?: {
    prompt_tokens?: number
    total_tokens?: number
  }
}

type OpenAICompatibleOptions = {
  headers?: Record<string, string>
  dimensions?: number
}

/**
 * OpenAI Compatible implementation of the embedder interface with batching and rate limiting.
 * This embedder allows using any OpenAI-compatible API endpoint by specifying a custom baseURL.
 */

export class OpenAICompatibleEmbedder implements IEmbedder {
  private embeddingsClient: OpenAI
  private readonly defaultModelId: string
  private readonly baseUrl: string
  private readonly apiKey?: string
  private readonly isFullUrl: boolean
  private readonly maxItemTokens: number
  private readonly headers: Record<string, string>
  private readonly dimensions?: number

  // Global rate limiting state shared across all instances
  private static globalRateLimitState = {
    isRateLimited: false,
    rateLimitResetTime: 0,
    consecutiveRateLimitErrors: 0,
    lastRateLimitError: 0,
    // Mutex to ensure thread-safe access to rate limit state
    mutex: new Mutex(),
  }

  /**
   * Creates a new OpenAI Compatible embedder
   * @param baseUrl The base URL for the OpenAI-compatible API endpoint
   * @param apiKey Optional API key for authentication
   * @param modelId Optional model identifier (defaults to "text-embedding-3-small")
   * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS)
   */
  constructor(
    baseUrl: string,
    apiKey?: string,
    modelId?: string,
    maxItemTokens?: number,
    options: OpenAICompatibleOptions = {},
  ) {
    if (!baseUrl) {
      throw new Error("Base URL is required for OpenAI-compatible embedder")
    }

    this.baseUrl = baseUrl
    this.apiKey = apiKey?.trim() || undefined

    const defaults = new Headers(options.headers)
    this.embeddingsClient = new OpenAI({
      baseURL: baseUrl,
      apiKey: this.apiKey ?? "EMPTY",
      defaultHeaders: options.headers,
      fetch: this.apiKey
        ? undefined
        : async (input, init) => {
            const headers = new Headers(init?.headers)
            if (!defaults.has("authorization")) headers.delete("authorization")
            if (!defaults.has("api-key")) headers.delete("api-key")
            return globalThis.fetch(input, { ...init, headers: Object.fromEntries(headers) })
          },
    })

    this.defaultModelId = modelId || getDefaultModelId("openai-compatible")
    // Cache the URL type check for performance
    this.isFullUrl = this.isFullEndpointUrl(baseUrl)
    this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS
    this.headers = options.headers ?? {}
    this.dimensions = options.dimensions
  }

  /**
   * Creates embeddings for the given texts with batching and rate limiting
   * @param texts Array of text strings to embed
   * @param model Optional model identifier
   * @returns Promise resolving to embedding response
   */
  async createEmbeddings(texts: string[], model?: string): Promise<EmbeddingResponse> {
    const modelToUse = model || this.defaultModelId

    // Apply model-specific query prefix if required
    const queryPrefix = getModelQueryPrefix("openai-compatible", modelToUse)
    const processedTexts = queryPrefix
      ? texts.map((text, index) => {
          // Prevent double-prefixing
          if (text.startsWith(queryPrefix)) {
            return text
          }
          const prefixedText = `${queryPrefix}${text}`
          const estimatedTokens = Math.ceil(prefixedText.length / 4)
          if (estimatedTokens > MAX_ITEM_TOKENS) {
            log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`)
            // Return original text if adding prefix would exceed limit
            return text
          }
          return prefixedText
        })
      : texts

    const allEmbeddings: number[][] = []
    const usage = { promptTokens: 0, totalTokens: 0 }
    const remainingTexts = [...processedTexts]

    while (remainingTexts.length > 0) {
      const currentBatch: string[] = []
      let currentBatchTokens = 0
      const processedIndices: number[] = []

      for (let i = 0; i < remainingTexts.length; i++) {
        const text = remainingTexts[i]
        const itemTokens = Math.ceil(text.length / 4)

        if (itemTokens > this.maxItemTokens) {
          log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${this.maxItemTokens})`)
          processedIndices.push(i)
          continue
        }

        if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) {
          currentBatch.push(text)
          currentBatchTokens += itemTokens
          processedIndices.push(i)
        } else {
          break
        }
      }

      // Remove processed items from remainingTexts (in reverse order to maintain correct indices)
      for (let i = processedIndices.length - 1; i >= 0; i--) {
        remainingTexts.splice(processedIndices[i]!, 1)
      }

      if (currentBatch.length > 0) {
        const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse)
        allEmbeddings.push(...batchResult.embeddings)
        usage.promptTokens += batchResult.usage.promptTokens
        usage.totalTokens += batchResult.usage.totalTokens
      }
    }

    return { embeddings: allEmbeddings, usage }
  }

  /**
   * Determines if the provided URL is a full endpoint URL or a base URL that needs the endpoint appended by the SDK.
   * Uses smart pattern matching for known providers while accepting we can't cover all possible patterns.
   * @param url The URL to check
   * @returns true if it's a full endpoint URL, false if it's a base URL
   */
  private isFullEndpointUrl(url: string): boolean {
    // Known patterns for major providers
    const patterns = [
      // Azure OpenAI: /deployments/{deployment-name}/embeddings
      /\/deployments\/[^\/]+\/embeddings(\?|$)/,
      // Azure Databricks: /serving-endpoints/{endpoint-name}/invocations
      /\/serving-endpoints\/[^\/]+\/invocations(\?|$)/,
      // Direct endpoints: ends with /embeddings (before query params)
      /\/embeddings(\?|$)/,
      // Some providers use /embed instead of /embeddings
      /\/embed(\?|$)/,
    ]

    return patterns.some((pattern) => pattern.test(url))
  }

  /**
   * Makes a direct HTTP request to the embeddings endpoint
   * Used when the user provides a full endpoint URL (e.g., Azure OpenAI with query parameters)
   * @param url The full endpoint URL
   * @param batchTexts Array of texts to embed
   * @param model Model identifier to use
   * @returns Promise resolving to OpenAI-compatible response
   */
  private async makeDirectEmbeddingRequest(
    url: string,
    batchTexts: string[],
    model: string,
    signal?: AbortSignal,
  ): Promise<OpenAIEmbeddingResponse> {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        ...this.headers,
        ...(this.apiKey
          ? {
              "api-key": this.apiKey,
              Authorization: `Bearer ${this.apiKey}`,
            }
          : {}),
      },
      body: JSON.stringify({
        input: batchTexts,
        model: model,
        encoding_format: "base64",
        ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}),
      }),
      signal,
    })

    if (!response || !response.ok) {
      const status = response?.status || 0
      let errorText = "No response"
      try {
        if (response && typeof response.text === "function") {
          errorText = await response.text()
        } else if (response) {
          errorText = `Error ${status}`
        }
      } catch {
        // Ignore text parsing errors
        errorText = `Error ${status}`
      }
      const error = new Error(`HTTP ${status}: ${errorText}`) as HttpError
      error.status = status || response?.status || 0
      throw error
    }

    try {
      return (await response.json()) as OpenAIEmbeddingResponse
    } catch (e) {
      const error = new Error(`Failed to parse response JSON`) as HttpError
      error.status = response.status
      throw error
    }
  }

  /**
   * Helper method to handle batch embedding with retries and exponential backoff
   * @param batchTexts Array of texts to embed in this batch
   * @param model Model identifier to use
   * @returns Promise resolving to embeddings and usage statistics
   */
  private async _embedBatchWithRetries(
    batchTexts: string[],
    model: string,
  ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> {
    // Use cached value for performance
    const isFullUrl = this.isFullUrl

    for (let attempts = 0; attempts < MAX_RETRIES; attempts++) {
      // Check global rate limit before attempting request
      await this.waitForGlobalRateLimit()

      try {
        let response: OpenAIEmbeddingResponse

        if (isFullUrl) {
          // Use direct HTTP request for full endpoint URLs
          response = await this.makeDirectEmbeddingRequest(this.baseUrl, batchTexts, model)
        } else {
          // Use OpenAI SDK for base URLs
          response = (await this.embeddingsClient.embeddings.create({
            input: batchTexts,
            model: model,
            // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256
            // when processing numeric arrays, which breaks compatibility with models using larger dimensions.
            // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves.
            encoding_format: "base64",
            ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}),
          })) as OpenAIEmbeddingResponse
        }

        // Convert base64 embeddings to float32 arrays
        const processedEmbeddings = response.data.map((item: EmbeddingItem) => {
          if (typeof item.embedding === "string") {
            const buffer = Buffer.from(item.embedding, "base64")

            // Create Float32Array view over the buffer
            const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4)

            return {
              ...item,
              embedding: Array.from(float32Array),
            }
          }
          return item
        })

        // Replace the original data with processed embeddings
        response.data = processedEmbeddings

        const embeddings = response.data.map((item) => item.embedding as number[])

        return {
          embeddings: embeddings,
          usage: {
            promptTokens: response.usage?.prompt_tokens || 0,
            totalTokens: response.usage?.total_tokens || 0,
          },
        }
      } catch (error) {
        log.error("OpenAI Compatible embedder batch error", {
          err: error instanceof Error ? error.message : String(error),
          location: "OpenAICompatibleEmbedder:_embedBatchWithRetries",
          attempt: attempts + 1,
        })

        const hasMoreAttempts = attempts < MAX_RETRIES - 1

        // Check if it's a rate limit error
        const httpError = error as HttpError
        if (httpError?.status === 429) {
          // Update global rate limit state
          await this.updateGlobalRateLimitState(httpError)

          if (hasMoreAttempts) {
            // Calculate delay based on global rate limit state
            const baseDelay = INITIAL_DELAY_MS * Math.pow(2, attempts)
            const globalDelay = await this.getGlobalRateLimitDelay()
            const delayMs = Math.max(baseDelay, globalDelay)

            log.warn(`Rate limit hit, retrying in ${delayMs}ms (attempt ${attempts + 1}/${MAX_RETRIES})`)
            await new Promise((resolve) => setTimeout(resolve, delayMs))
            continue
          }
        }

        // Format and throw the error
        throw formatEmbeddingError(error, MAX_RETRIES)
      }
    }

    throw new Error(`Embedding failed after ${MAX_RETRIES} attempts`)
  }

  /**
   * Validates the OpenAI-compatible embedder configuration by testing endpoint connectivity and API key
   * @returns Promise resolving to validation result with success status and optional error message
   */
  async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
    return withValidationErrorHandling(async () => {
      try {
        // Test with a minimal embedding request
        const testTexts = ["test"]
        const modelToUse = this.defaultModelId

        let response: OpenAIEmbeddingResponse

        if (this.isFullUrl) {
          // Test direct HTTP request for full endpoint URLs
          const ctl = new AbortController()
          const timer = setTimeout(() => ctl.abort(), REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS)
          try {
            response = await this.makeDirectEmbeddingRequest(this.baseUrl, testTexts, modelToUse, ctl.signal)
          } finally {
            clearTimeout(timer)
          }
        } else {
          // Test using OpenAI SDK for base URLs
          response = (await this.embeddingsClient.embeddings.create(
            {
              input: testTexts,
              model: modelToUse,
              encoding_format: "base64",
              ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}),
            },
            {
              timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS,
              maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES,
            },
          )) as OpenAIEmbeddingResponse
        }

        const error = (response as { error?: string | { message?: string } }).error
        const message = typeof error === "string" ? error : error?.message
        if (message) return { valid: false, error: message }

        // Check if we got a valid response
        if (!response?.data || response.data.length === 0) {
          return {
            valid: false,
            error: "Invalid response from embedding endpoint",
          }
        }

        return { valid: true }
      } catch (error) {
        log.error("OpenAI Compatible embedder validation error", {
          err: error instanceof Error ? error.message : String(error),
          location: "OpenAICompatibleEmbedder:validateConfiguration",
        })
        throw error
      }
    }, "openai-compatible")
  }

  /**
   * Returns information about this embedder
   */
  get embedderInfo(): EmbedderInfo {
    return {
      name: "openai-compatible",
    }
  }

  /**
   * Waits if there's an active global rate limit
   */
  private async waitForGlobalRateLimit(): Promise<void> {
    const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire()
    try {
      const state = OpenAICompatibleEmbedder.globalRateLimitState

      if (state.isRateLimited && state.rateLimitResetTime > Date.now()) {
        const waitTime = state.rateLimitResetTime - Date.now()
        // Silent wait - no logging to prevent flooding
        release() // Release mutex before waiting
        await new Promise((resolve) => setTimeout(resolve, waitTime))
        return
      }

      // Reset rate limit if time has passed
      if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) {
        state.isRateLimited = false
        state.consecutiveRateLimitErrors = 0
      }
    } finally {
      // Only release if we haven't already
      try {
        release()
      } catch {
        // Already released
      }
    }
  }

  /**
   * Updates global rate limit state when a 429 error occurs
   */
  private async updateGlobalRateLimitState(error: HttpError): Promise<void> {
    const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire()
    try {
      const state = OpenAICompatibleEmbedder.globalRateLimitState
      const now = Date.now()

      // Increment consecutive rate limit errors
      if (now - state.lastRateLimitError < 60000) {
        // Within 1 minute
        state.consecutiveRateLimitErrors++
      } else {
        state.consecutiveRateLimitErrors = 1
      }

      state.lastRateLimitError = now

      // Calculate exponential backoff based on consecutive errors
      const baseDelay = 5000 // 5 seconds base
      const maxDelay = 300000 // 5 minutes max
      const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay)

      // Set global rate limit
      state.isRateLimited = true
      state.rateLimitResetTime = now + exponentialDelay

      // Silent rate limit activation - no logging to prevent flooding
    } finally {
      release()
    }
  }

  /**
   * Gets the current global rate limit delay
   */
  private async getGlobalRateLimitDelay(): Promise<number> {
    const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire()
    try {
      const state = OpenAICompatibleEmbedder.globalRateLimitState

      if (state.isRateLimited && state.rateLimitResetTime > Date.now()) {
        return state.rateLimitResetTime - Date.now()
      }

      return 0
    } finally {
      release()
    }
  }
}
