import { assert, test, beforeEach, afterEach } from 'vitest'
import {
  ConnectionConfig,
  setupRequestController,
  wrapStreamWithConnectionCleanup,
} from '../src/connectionConfig'

// Store original env vars to restore after tests
let originalEnv: { [key: string]: string | undefined }

beforeEach(() => {
  originalEnv = {
    E2B_API_URL: process.env.E2B_API_URL,
    E2B_DOMAIN: process.env.E2B_DOMAIN,
    E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL,
    E2B_DEBUG: process.env.E2B_DEBUG,
    E2B_VALIDATE_API_KEY: process.env.E2B_VALIDATE_API_KEY,
  }
})

afterEach(() => {
  // Restore original env vars
  Object.keys(originalEnv).forEach((key) => {
    if (originalEnv[key] === undefined) {
      delete process.env[key]
    } else {
      process.env[key] = originalEnv[key]
    }
  })

  // Clear the process-wide integration attribution
  ConnectionConfig.setIntegration(undefined)
})

test('api_url defaults correctly', () => {
  // Ensure no env vars interfere
  delete process.env.E2B_API_URL
  delete process.env.E2B_DOMAIN
  delete process.env.E2B_DEBUG

  const config = new ConnectionConfig()
  assert.equal(config.apiUrl, 'https://api.e2b.app')
})

test('api_url in args', () => {
  const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
  assert.equal(config.apiUrl, 'http://localhost:8080')
})

test('api_url in env var', () => {
  process.env.E2B_API_URL = 'http://localhost:8080'

  const config = new ConnectionConfig()
  assert.equal(config.apiUrl, 'http://localhost:8080')
})

test('api_url has correct priority', () => {
  process.env.E2B_API_URL = 'http://localhost:1111'

  const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
  assert.equal(config.apiUrl, 'http://localhost:8080')
})

test('sandbox_url defaults to stable sandbox host in production', () => {
  delete process.env.E2B_SANDBOX_URL
  delete process.env.E2B_DOMAIN
  delete process.env.E2B_DEBUG

  const config = new ConnectionConfig()

  assert.equal(
    config.getSandboxUrl('sbx-test', {
      sandboxDomain: 'e2b.app',
      envdPort: 49983,
    }),
    'https://sandbox.e2b.app'
  )
})

test('sandbox_direct_url keeps per-sandbox host in production', () => {
  delete process.env.E2B_SANDBOX_URL
  delete process.env.E2B_DOMAIN
  delete process.env.E2B_DEBUG

  const config = new ConnectionConfig()

  assert.equal(
    config.getSandboxDirectUrl('sbx-test', {
      sandboxDomain: 'e2b.app',
      envdPort: 49983,
    }),
    'https://49983-sbx-test.e2b.app'
  )
})

test('sandbox_url keeps per-sandbox host outside production', () => {
  delete process.env.E2B_SANDBOX_URL
  delete process.env.E2B_DEBUG

  const config = new ConnectionConfig({ domain: 'e2b.dev' })

  assert.equal(
    config.getSandboxUrl('sbx-test', {
      sandboxDomain: 'sandbox.e2b.dev',
      envdPort: 49983,
    }),
    'https://49983-sbx-test.sandbox.e2b.dev'
  )
})

test('sandbox_url in args has priority', () => {
  process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'

  const config = new ConnectionConfig({ sandboxUrl: 'https://sandbox.custom' })

  assert.equal(
    config.getSandboxUrl('sbx-test', {
      sandboxDomain: 'e2b.app',
      envdPort: 49983,
    }),
    'https://sandbox.custom'
  )
})

test('sandbox_url in env var overrides default', () => {
  process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'

  const config = new ConnectionConfig()

  assert.equal(
    config.getSandboxUrl('sbx-test', {
      sandboxDomain: 'e2b.app',
      envdPort: 49983,
    }),
    'https://sandbox.from-env'
  )
})

test('sandbox_url stays localhost in debug mode', () => {
  delete process.env.E2B_SANDBOX_URL
  process.env.E2B_DEBUG = 'true'

  const config = new ConnectionConfig()

  assert.equal(
    config.getSandboxUrl('sbx-test', {
      sandboxDomain: 'e2b.app',
      envdPort: 49983,
    }),
    'http://localhost:49983'
  )
})

test('validateApiKey defaults to true', () => {
  delete process.env.E2B_VALIDATE_API_KEY

  const config = new ConnectionConfig()
  assert.equal(config.validateApiKey, true)
})

test('validateApiKey disabled via env var', () => {
  process.env.E2B_VALIDATE_API_KEY = 'false'

  const config = new ConnectionConfig()
  assert.equal(config.validateApiKey, false)
})

test('validateApiKey in args has priority over env var', () => {
  process.env.E2B_VALIDATE_API_KEY = 'true'

  const config = new ConnectionConfig({ validateApiKey: false })
  assert.equal(config.validateApiKey, false)
})

test('debug false in args overrides E2B_DEBUG env var', () => {
  process.env.E2B_DEBUG = 'true'

  const config = new ConnectionConfig({ debug: false })
  assert.equal(config.debug, false)
})

test('debug defaults to E2B_DEBUG env var', () => {
  process.env.E2B_DEBUG = 'true'

  const config = new ConnectionConfig()
  assert.equal(config.debug, true)
})

test('setIntegration appends the integration to the user agent', () => {
  ConnectionConfig.setIntegration('testing/version')
  const config = new ConnectionConfig()

  assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
  assert.equal(
    config.headers?.['User-Agent']?.endsWith(' testing/version'),
    true
  )
})

test('integration survives config rebuilds', () => {
  ConnectionConfig.setIntegration('testing/version')
  const config = new ConnectionConfig()
  const rebuiltConfig = new ConnectionConfig({ ...config })

  assert.equal(
    rebuiltConfig.headers?.['User-Agent']?.endsWith(' testing/version'),
    true
  )
})

test('setIntegration does not retro-tag configs built earlier', () => {
  const before = new ConnectionConfig()
  ConnectionConfig.setIntegration('testing/version')
  const after = new ConnectionConfig()

  assert.equal(before.headers?.['User-Agent']?.includes('testing'), false)
  assert.equal(
    after.headers?.['User-Agent']?.endsWith(' testing/version'),
    true
  )
})

test('clearing the integration restores the plain user agent', () => {
  ConnectionConfig.setIntegration('testing/version')
  ConnectionConfig.setIntegration(undefined)
  const config = new ConnectionConfig()

  assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
  assert.equal(config.headers?.['User-Agent']?.includes('testing'), false)
})

test('custom user agent is preserved without integration', () => {
  const config = new ConnectionConfig({
    apiHeaders: { 'User-Agent': 'my-app/1.0' },
  })

  assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
})

test('custom user agent wins over integration', () => {
  ConnectionConfig.setIntegration('testing/version')

  const config = new ConnectionConfig({
    headers: { 'User-Agent': 'my-app/1.0' },
  })

  assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
})

test('custom user agent survives config rebuilds', () => {
  ConnectionConfig.setIntegration('testing/version')
  const config = new ConnectionConfig({
    apiHeaders: { 'User-Agent': 'my-app/1.0' },
  })
  const rebuiltConfig = new ConnectionConfig({ ...config })

  assert.equal(rebuiltConfig.headers?.['User-Agent'], 'my-app/1.0')
})

test('clearing the integration propagates to config rebuilds', () => {
  ConnectionConfig.setIntegration('testing/version')
  const config = new ConnectionConfig()
  ConnectionConfig.setIntegration(undefined)
  const rebuiltConfig = new ConnectionConfig({ ...config })

  assert.equal(
    rebuiltConfig.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'),
    true
  )
  assert.equal(
    rebuiltConfig.headers?.['User-Agent']?.includes('testing'),
    false
  )
})

test('getSignal returns user signal when no timeout is set', () => {
  const config = new ConnectionConfig({ requestTimeoutMs: 0 })
  const controller = new AbortController()
  const signal = config.getSignal(0, controller.signal)
  assert.strictEqual(signal, controller.signal)
})

test('getSignal aborts when user signal is aborted', () => {
  const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
  const controller = new AbortController()
  const signal = config.getSignal(undefined, controller.signal)
  assert.ok(signal)
  assert.equal(signal!.aborted, false)
  controller.abort()
  assert.equal(signal!.aborted, true)
})

test('getSignal returns timeout signal when no user signal is provided', () => {
  const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
  const signal = config.getSignal()
  assert.ok(signal)
  assert.equal(signal!.aborted, false)
})

test('getSignal returns undefined when no timeout and no signal', () => {
  const config = new ConnectionConfig({ requestTimeoutMs: 0 })
  const signal = config.getSignal(0)
  assert.equal(signal, undefined)
})

test('requestTimeoutMs 0 from the config disables the timeout', () => {
  const config = new ConnectionConfig({ requestTimeoutMs: 0 })
  // The stored value is kept as 0 (not replaced by the default).
  assert.equal(config.requestTimeoutMs, 0)
  // getSignal() with no per-call arg falls back to the stored 0, which must
  // NOT produce a timeout signal.
  assert.equal(config.getSignal(), undefined)
  // With only a user signal, no timeout signal is layered on top.
  const controller = new AbortController()
  assert.strictEqual(
    config.getSignal(undefined, controller.signal),
    controller.signal
  )
})

test('setupRequestController with config timeout 0 never auto-aborts', async () => {
  const config = new ConnectionConfig({ requestTimeoutMs: 0 })
  const { controller } = setupRequestController(
    config.requestTimeoutMs,
    undefined
  )
  await new Promise((resolve) => setTimeout(resolve, 40))
  assert.equal(controller.signal.aborted, false)
})

test('setupRequestController aborts when user signal aborts', () => {
  const userController = new AbortController()
  const { controller } = setupRequestController(0, userController.signal)

  assert.equal(controller.signal.aborted, false)
  userController.abort()
  assert.equal(controller.signal.aborted, true)
})

test('setupRequestController is already aborted when user signal was pre-aborted', () => {
  const userController = new AbortController()
  userController.abort()

  const { controller } = setupRequestController(0, userController.signal)
  assert.equal(controller.signal.aborted, true)
})

test('setupRequestController cleanup removes the user-signal listener', () => {
  const userController = new AbortController()
  const { controller, cleanup } = setupRequestController(
    0,
    userController.signal
  )

  cleanup()
  // Internal controller is aborted by cleanup, so it stays aborted.
  assert.equal(controller.signal.aborted, true)

  // After cleanup the listener is detached — a subsequent abort on the
  // user signal must not propagate (verified indirectly: cleanup is
  // idempotent and no error is thrown).
  userController.abort()
  cleanup()
})

test('setupRequestController clearStartTimeout stops the handshake timer', async () => {
  const { controller, clearStartTimeout } = setupRequestController(
    20,
    undefined
  )
  clearStartTimeout()
  await new Promise((resolve) => setTimeout(resolve, 40))
  assert.equal(controller.signal.aborted, false)
})

test('setupRequestController handshake timer aborts when not cleared', async () => {
  const { controller } = setupRequestController(20, undefined)
  await new Promise((resolve) => setTimeout(resolve, 40))
  assert.equal(controller.signal.aborted, true)
})

test('setupRequestController handshake timeout aborts with TimeoutError reason', async () => {
  const { controller } = setupRequestController(20, undefined)
  await new Promise((resolve) => setTimeout(resolve, 40))
  assert.equal(controller.signal.aborted, true)
  assert.ok(controller.signal.reason instanceof DOMException)
  assert.equal((controller.signal.reason as DOMException).name, 'TimeoutError')
})

test('setupRequestController user signal still cancels after clearStartTimeout', () => {
  const userController = new AbortController()
  const { controller, clearStartTimeout } = setupRequestController(
    60_000,
    userController.signal
  )
  clearStartTimeout()
  assert.equal(controller.signal.aborted, false)
  userController.abort()
  assert.equal(controller.signal.aborted, true)
})

// Builds a source ReadableStream that records whether its underlying reader was
// cancelled, standing in for a fetch response body backed by a pooled
// connection. `cancel` being invoked is what releases that connection.
function trackedSource() {
  const state: { cancelled: boolean; cancelReason: unknown } = {
    cancelled: false,
    cancelReason: undefined,
  }
  const chunks = ['a', 'b'].map((s) => new TextEncoder().encode(s))
  let i = 0
  const body = new ReadableStream<Uint8Array>({
    pull(controller) {
      if (i < chunks.length) {
        controller.enqueue(chunks[i++])
      } else {
        controller.close()
      }
    },
    cancel(reason) {
      state.cancelled = true
      state.cancelReason = reason
    },
  })
  return { body, state }
}

async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
  const reader = stream.getReader()
  let out = ''
  const decoder = new TextDecoder()
  for (;;) {
    const { done, value } = await reader.read()
    if (done) break
    out += decoder.decode(value, { stream: true })
  }
  return out
}

// Builds a source ReadableStream that never produces a chunk and errors its
// pending read when `signal` aborts, standing in for a stalled fetch response
// body whose connection is torn down by aborting the request controller.
function stallingSource(signal: AbortSignal) {
  const state: { cancelled: boolean } = { cancelled: false }
  const body = new ReadableStream<Uint8Array>({
    start(controller) {
      signal.addEventListener('abort', () => controller.error(signal.reason), {
        once: true,
      })
    },
    cancel() {
      state.cancelled = true
    },
  })
  return { body, state }
}

test('wrapStreamWithConnectionCleanup releases once on full read', async () => {
  const { body } = trackedSource()
  let cleanups = 0
  const stream = wrapStreamWithConnectionCleanup(body, {
    clearStartTimeout: () => {},
    cleanup: () => {
      cleanups++
    },
    controller: new AbortController(),
  })
  assert.equal(await readAll(stream), 'ab')
  assert.equal(cleanups, 1)
})

test('wrapStreamWithConnectionCleanup cancel cancels the underlying reader', async () => {
  const { body, state } = trackedSource()
  let cleanups = 0
  const stream = wrapStreamWithConnectionCleanup(body, {
    clearStartTimeout: () => {},
    cleanup: () => {
      cleanups++
    },
    controller: new AbortController(),
  })
  await stream.cancel('done')
  assert.equal(state.cancelled, true)
  assert.equal(state.cancelReason, 'done')
  assert.equal(cleanups, 1)
})

test('wrapStreamWithConnectionCleanup handles a null body', async () => {
  let cleanups = 0
  let cleared = 0
  const stream = wrapStreamWithConnectionCleanup(null, {
    clearStartTimeout: () => {
      cleared++
    },
    cleanup: () => {
      cleanups++
    },
    controller: new AbortController(),
  })
  assert.equal(cleared, 1)
  assert.equal(cleanups, 1)
  assert.equal(await readAll(stream), '')
})

test('wrapStreamWithConnectionCleanup aborts and releases an idle stream', async () => {
  const controller = new AbortController()
  const { body } = stallingSource(controller.signal)
  let cleanups = 0
  const stream = wrapStreamWithConnectionCleanup(body, {
    clearStartTimeout: () => {},
    cleanup: () => {
      cleanups++
    },
    controller,
    idleTimeoutMs: 20,
  })

  // No chunk ever arrives, so the idle timer fires, aborts the controller,
  // and the read rejects with the TimeoutError reason.
  let error: unknown
  try {
    await readAll(stream)
  } catch (err) {
    error = err
  }
  assert.equal((error as DOMException)?.name, 'TimeoutError')
  assert.equal(cleanups, 1)
  assert.equal(controller.signal.aborted, true)
})

test('wrapStreamWithConnectionCleanup with idle timeout 0 never auto-aborts', async () => {
  const { body } = trackedSource()
  let cleanups = 0
  const stream = wrapStreamWithConnectionCleanup(body, {
    clearStartTimeout: () => {},
    cleanup: () => {
      cleanups++
    },
    controller: new AbortController(),
    idleTimeoutMs: 0,
  })
  assert.equal(await readAll(stream), 'ab')
  assert.equal(cleanups, 1)
})

test('wrapStreamWithConnectionCleanup does not abort a slow consumer (wire-only)', async () => {
  // Source has both chunks ready immediately; the consumer pauses far longer
  // than the idle timeout between reads. Because the timer is armed only around
  // the network read and cleared as soon as a chunk arrives, the consumer's
  // pace must not trip it.
  const controller = new AbortController()
  const { body } = trackedSource()
  const stream = wrapStreamWithConnectionCleanup(body, {
    clearStartTimeout: () => {},
    cleanup: () => {},
    controller,
    idleTimeoutMs: 20,
  })
  const reader = stream.getReader()
  const decoder = new TextDecoder()

  const first = await reader.read()
  assert.equal(decoder.decode(first.value), 'a')
  await new Promise((resolve) => setTimeout(resolve, 50))
  const second = await reader.read()
  assert.equal(decoder.decode(second.value), 'b')
  assert.equal(controller.signal.aborted, false)
})
