/** Provider-agnostic content block — normalized across adapters. */
export type AIContentBlock =
  | { type: 'text'; text: string }
  | {
      type: 'image'
      image: {
        mediaType: string
        data: string
      }
    }

export type AIMessage = {
  role: 'system' | 'user' | 'assistant'
  content: string | AIContentBlock[]
}

export type AIRequest = {
  model: string
  messages: AIMessage[]
  maxTokens?: number
  temperature?: number
  stop?: string | string[]
  stream?: boolean
  /**
   * Optional idempotency key. Adapters SHOULD forward it to the provider's
   * idempotency header (e.g. `Idempotency-Key`) when the provider supports it,
   * to deduplicate retried calls after a timeout-driven fallback. Adapters that
   * do not support the header may ignore it.
   */
  idempotencyKey?: string
}

/** Raw token usage only — cost/accounting is host-domain. */
export type AIUsage = {
  promptTokens: number
  completionTokens: number
}

export type AIResponse = {
  content: string
  model: string
  provider: string
  usage: AIUsage
  finishReason?: string
}

export type ModelOption = {
  id: string
  label: string
}

export type AIAdapter = {
  provider: string
  call(req: AIRequest): Promise<AIResponse>
  stream?(req: AIRequest): ReadableStream<string>
  listModels?(): Promise<ModelOption[]>
  /**
   * Capability flag — true when this adapter maps image content blocks
   * (`AIContentBlock` image variant) to the provider's vision wire shape.
   * Optional (same family as `stream?`/`listModels?`); additive to the seam,
   * not a `usage`/cost change. Lets a consumer route image requests safely.
   */
  supportsImage?: boolean
}

export abstract class AIError extends Error {
  abstract readonly retryable: boolean
}

export class RateLimitError extends AIError {
  override readonly name = 'RateLimitError'
  readonly retryable = true

  constructor(
    message: string,
    readonly provider?: string,
    readonly cause?: unknown,
  ) {
    super(message)
  }
}

export class TransientError extends AIError {
  override readonly name = 'TransientError'
  readonly retryable = true

  constructor(
    message: string,
    readonly provider?: string,
    readonly cause?: unknown,
  ) {
    super(message)
  }
}

export class QuotaExhaustedError extends AIError {
  override readonly name = 'QuotaExhaustedError'
  readonly retryable = true

  constructor(
    message: string,
    readonly provider?: string,
    readonly cause?: unknown,
  ) {
    super(message)
  }
}

export class AuthError extends AIError {
  override readonly name = 'AuthError'
  readonly retryable = false

  constructor(
    message: string,
    readonly provider?: string,
    readonly cause?: unknown,
  ) {
    super(message)
  }
}

export class FatalError extends AIError {
  override readonly name = 'FatalError'
  readonly retryable = false

  constructor(
    message: string,
    readonly provider?: string,
    readonly cause?: unknown,
  ) {
    super(message)
  }
}

export class AllModelsFailedError extends FatalError {
  constructor(
    message: string,
    readonly lastError?: AIError,
  ) {
    super(message)
  }
}

export type AIProvider = 'anthropic' | 'openai-compat' | 'google' | 'workers-ai'

export type AIAdapterFactory = (creds: unknown) => AIAdapter

export type AIAdapterFactories = Partial<Record<AIProvider, AIAdapterFactory>>

let adapterFactories: AIAdapterFactories = {}

/** Wire adapter subpath makers without importing them from core (tree-shake safe). */
export function setAdapterFactories(factories: AIAdapterFactories): void {
  adapterFactories = { ...adapterFactories, ...factories }
}

export function getAdapter(provider: AIProvider, creds: unknown): AIAdapter {
  const factory = adapterFactories[provider]
  if (!factory) {
    throw new FatalError(
      `No adapter factory registered for "${provider}". Import @platform-modules/ai/${provider} and register its maker via setAdapterFactories.`,
      provider,
    )
  }
  return factory(creds)
}

export type FallbackChainEntry = {
  adapter: AIAdapter
  model?: string
}

export type ExecuteWithFallbackOpts = {
  timeoutMs?: number
  /**
   * When true, non-retryable AIErrors (AuthError, FatalError) from one provider
   * also fall through to the next provider in the chain instead of aborting.
   * Useful in multi-provider fallback scenarios where a misconfigured credential
   * for one provider should not block other healthy providers.
   * Default: false (non-retryable errors abort immediately — the safe default for single-provider use).
   */
  continueOnNonRetryable?: boolean
}

export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  let timeoutId: ReturnType<typeof setTimeout> | undefined
  const timeoutPromise = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(new TransientError(`Request timed out after ${ms}ms`))
    }, ms)
  })

  return Promise.race([promise, timeoutPromise]).finally(() => {
    if (timeoutId !== undefined) clearTimeout(timeoutId)
  })
}

function isRetryableError(err: unknown): err is AIError {
  return err instanceof AIError && err.retryable
}

const ALLOWED_USAGE_KEYS = new Set<string>(['promptTokens', 'completionTokens'])

/**
 * Runtime boundary guard — usage must carry token counts and NOTHING else.
 * Allowlist (not a denylist): the spec mandates `{ promptTokens, completionTokens }`
 * exactly, so any extra key — cost/price/credits, a novel currency name, or a nested
 * accounting object — is a host-domain leak and is rejected. Cost is host-domain.
 */
export function assertUsageBoundary(usage: AIUsage): void {
  const record = usage as AIUsage & Record<string, unknown>
  for (const key of Object.keys(record)) {
    if (!ALLOWED_USAGE_KEYS.has(key)) {
      throw new FatalError(
        `AIResponse.usage must carry only promptTokens/completionTokens — found host-domain field "${key}"`,
      )
    }
  }
  if (typeof usage.promptTokens !== 'number' || typeof usage.completionTokens !== 'number') {
    throw new FatalError('AIResponse.usage requires promptTokens and completionTokens')
  }
  if (
    !Number.isFinite(usage.promptTokens) ||
    usage.promptTokens < 0 ||
    !Number.isFinite(usage.completionTokens) ||
    usage.completionTokens < 0
  ) {
    throw new FatalError(
      'AIResponse.usage token counts must be finite non-negative numbers',
    )
  }
}

export async function executeWithFallback(
  req: AIRequest,
  chain: FallbackChainEntry[],
  opts?: ExecuteWithFallbackOpts,
): Promise<AIResponse> {
  if (chain.length === 0) {
    throw new AllModelsFailedError('All models failed')
  }

  const timeoutMs = opts?.timeoutMs ?? 30_000
  let lastError: AIError | undefined

  for (const entry of chain) {
    const callReq =
      entry.model !== undefined && entry.model !== req.model
        ? { ...req, model: entry.model }
        : req

    try {
      const response = await withTimeout(entry.adapter.call(callReq), timeoutMs)
      assertUsageBoundary(response.usage)
      return response
    } catch (err) {
      if (isRetryableError(err) || (opts?.continueOnNonRetryable === true && err instanceof AIError)) {
        lastError = err
        continue
      }
      throw err
    }
  }

  throw new AllModelsFailedError(
    `All models failed${lastError ? `: ${lastError.message}` : ''}`,
    lastError,
  )
}
