import { describe, expect, it } from 'vitest'
import {
  AllModelsFailedError,
  AuthError,
  FatalError,
  RateLimitError,
  TransientError,
  assertUsageBoundary,
  executeWithFallback,
  getAdapter,
  setAdapterFactories,
  withTimeout,
  type AIAdapter,
  type AIRequest,
  type AIResponse,
  type AIUsage,
} from './index.js'

const baseReq: AIRequest = {
  model: 'test-model',
  messages: [{ role: 'user', content: 'hi' }],
}

function okAdapter(content = 'ok'): AIAdapter {
  return {
    provider: 'ok',
    async call(): Promise<AIResponse> {
      return {
        content,
        model: 'test-model',
        provider: 'ok',
        usage: { promptTokens: 1, completionTokens: 2 },
      }
    },
  }
}

function rateLimitAdapter(): AIAdapter {
  return {
    provider: 'rate-limited',
    async call(): Promise<AIResponse> {
      throw new RateLimitError('429', 'rate-limited')
    },
  }
}

function authErrorAdapter(): AIAdapter {
  return {
    provider: 'auth-bad',
    async call(): Promise<AIResponse> {
      throw new AuthError('401', 'auth-bad')
    },
  }
}

function slowAdapter(ms: number): AIAdapter {
  return {
    provider: 'slow',
    async call(): Promise<AIResponse> {
      await new Promise((resolve) => setTimeout(resolve, ms))
      return {
        content: 'late',
        model: 'test-model',
        provider: 'slow',
        usage: { promptTokens: 1, completionTokens: 1 },
      }
    },
  }
}

describe('executeWithFallback', () => {
  it('falls through on retryable RateLimitError to the next adapter', async () => {
    const result = await executeWithFallback(baseReq, [
      { adapter: rateLimitAdapter() },
      { adapter: okAdapter('backup') },
    ])
    expect(result.content).toBe('backup')
  })

  it('surfaces AuthError immediately without fall-through', async () => {
    await expect(
      executeWithFallback(baseReq, [
        { adapter: authErrorAdapter() },
        { adapter: okAdapter() },
      ]),
    ).rejects.toBeInstanceOf(AuthError)
  })

  it('throws AllModelsFailedError when chain is exhausted', async () => {
    await expect(
      executeWithFallback(baseReq, [{ adapter: rateLimitAdapter() }]),
    ).rejects.toBeInstanceOf(AllModelsFailedError)
  })

  it('continueOnNonRetryable: falls through on AuthError (non-retryable) to the next adapter when opt-in', async () => {
    const result = await executeWithFallback(
      baseReq,
      [{ adapter: authErrorAdapter() }, { adapter: okAdapter('fallback-ok') }],
      { continueOnNonRetryable: true },
    )
    expect(result.content).toBe('fallback-ok')
  })

  it('continueOnNonRetryable: still throws AllModelsFailedError when all providers fail', async () => {
    await expect(
      executeWithFallback(
        baseReq,
        [{ adapter: authErrorAdapter() }, { adapter: authErrorAdapter() }],
        { continueOnNonRetryable: true },
      ),
    ).rejects.toBeInstanceOf(AllModelsFailedError)
  })

  it('falls through on retryable timeout from withTimeout', async () => {
    const result = await executeWithFallback(
      baseReq,
      [{ adapter: slowAdapter(50) }, { adapter: okAdapter('fast') }],
      { timeoutMs: 5 },
    )
    expect(result.content).toBe('fast')
  })
})

describe('withTimeout', () => {
  it('rejects with retryable TransientError on timeout', async () => {
    await expect(withTimeout(new Promise(() => {}), 5)).rejects.toBeInstanceOf(TransientError)
  })
})

describe('AIResponse.usage boundary', () => {
  it('accepts promptTokens and completionTokens only', () => {
    expect(() => assertUsageBoundary({ promptTokens: 3, completionTokens: 4 })).not.toThrow()
  })

  it('forbids a cost field at the TYPE level (AIUsage is exact)', () => {
    // @ts-expect-error — AIUsage must reject excess accounting fields at compile time.
    const polluted: AIUsage = { promptTokens: 1, completionTokens: 2, costUsd: 0.01 }
    expect(polluted.promptTokens).toBe(1)
  })

  it('rejects a novel (non-denylisted) cost key at runtime via allowlist', () => {
    const polluted = { promptTokens: 1, completionTokens: 2, usd: 0.5 } as unknown as {
      promptTokens: number
      completionTokens: number
    }
    expect(() => assertUsageBoundary(polluted)).toThrow(FatalError)
  })

  it('rejects cost-like fields at runtime', () => {
    const polluted = { promptTokens: 1, completionTokens: 2, cost: 0.01 } as {
      promptTokens: number
      completionTokens: number
      cost: number
    }
    expect(() => assertUsageBoundary(polluted)).toThrow(FatalError)
  })

  it('rejects extra keys even when their value is undefined', () => {
    const polluted = { promptTokens: 1, completionTokens: 1, extra: undefined } as {
      promptTokens: number
      completionTokens: number
      extra?: undefined
    }
    expect(() => assertUsageBoundary(polluted)).toThrow(FatalError)
  })

  it('rejects NaN token counts', () => {
    expect(() =>
      assertUsageBoundary({ promptTokens: NaN, completionTokens: 0 }),
    ).toThrow(FatalError)
  })

  it('rejects negative token counts', () => {
    expect(() =>
      assertUsageBoundary({ promptTokens: -1, completionTokens: 0 }),
    ).toThrow(FatalError)
  })

  it('rejects Infinity token counts', () => {
    expect(() =>
      assertUsageBoundary({ promptTokens: Infinity, completionTokens: 0 }),
    ).toThrow(FatalError)
  })

  it('executeWithFallback returns usage without cost fields', async () => {
    const result = await executeWithFallback(baseReq, [{ adapter: okAdapter() }])
    expect(result.usage.promptTokens).toBe(1)
    expect(result.usage.completionTokens).toBe(2)
    expect(Object.keys(result.usage).sort()).toEqual(['completionTokens', 'promptTokens'])
  })

  it('executeWithFallback enforces the no-cost boundary on the fallback-success path', async () => {
    const result = await executeWithFallback(baseReq, [
      { adapter: rateLimitAdapter() },
      { adapter: okAdapter('backup') },
    ])
    expect(result.content).toBe('backup')
    expect(result.usage.promptTokens).toBe(1)
    expect(result.usage.completionTokens).toBe(2)
    expect(Object.keys(result.usage).sort()).toEqual(['completionTokens', 'promptTokens'])
  })
})

describe('getAdapter', () => {
  it('returns a cred-bound adapter from the registry', () => {
    setAdapterFactories({
      'openai-compat': (creds) => ({
        provider: 'openai-compat',
        async call() {
          return {
            content: String((creds as { apiKey: string }).apiKey),
            model: 'm',
            provider: 'openai-compat',
            usage: { promptTokens: 0, completionTokens: 0 },
          }
        },
      }),
    })
    const adapter = getAdapter('openai-compat', { apiKey: 'secret' })
    expect(adapter.provider).toBe('openai-compat')
  })

  it('throws FatalError for unregistered providers', () => {
    setAdapterFactories({})
    expect(() => getAdapter('google', { apiKey: 'x' })).toThrow(FatalError)
  })
})

describe('error retryable flags', () => {
  it('maps hierarchy retryable correctly', () => {
    expect(new RateLimitError('x').retryable).toBe(true)
    expect(new TransientError('x').retryable).toBe(true)
    expect(new AuthError('x').retryable).toBe(false)
    expect(new FatalError('x').retryable).toBe(false)
  })
})
