import {describe, expect, test} from 'bun:test'

import {
  createGuardedFetch,
  fetchPinned,
  guardedFetch,
  isPrivateAddress,
  readCapped,
} from './fetch-guard'

describe('isPrivateAddress', () => {
  const privateAddresses = [
    '0.0.0.0',
    '10.0.0.1',
    '100.64.0.1',
    '100.127.255.254',
    '127.0.0.1',
    '169.254.169.254',
    '172.16.0.1',
    '172.31.255.255',
    '192.0.0.1',
    '192.168.1.1',
    '198.18.0.1',
    '198.19.255.254',
    '224.0.0.1',
    '239.255.255.255',
    '240.0.0.1',
    '255.255.255.255',
    '::',
    '::1',
    '::ffff:10.0.0.1',
    '::ffff:127.0.0.1',
    'fc00::1',
    'fd12:3456::1',
    'fe80::1',
  ]
  const publicAddresses = [
    '1.1.1.1',
    '8.8.8.8',
    '93.184.216.34',
    '100.63.0.1',
    '100.128.0.1',
    '172.15.0.1',
    '172.32.0.1',
    '192.0.1.1',
    '192.167.0.1',
    '198.17.0.1',
    '198.20.0.1',
    '223.255.255.254',
    '2606:4700:4700::1111',
    '::ffff:8.8.8.8',
  ]

  test.each(privateAddresses)('%s is private', (ip) => {
    expect(isPrivateAddress(ip)).toBe(true)
  })

  test.each(publicAddresses)('%s is public', (ip) => {
    expect(isPrivateAddress(ip)).toBe(false)
  })
})

describe('guardedFetch validation', () => {
  test('rejects a literal private target with 400', () => {
    expect(
      guardedFetch('http://10.0.0.1/', '*/*'),
    ).rejects.toMatchObject({
      status: 400,
    })
  })

  test('rejects the metadata endpoint with 400', () => {
    expect(
      guardedFetch('http://169.254.169.254/latest/meta-data', '*/*'),
    ).rejects.toMatchObject({status: 400})
  })

  test('rejects localhost with 400', () => {
    expect(
      guardedFetch('http://localhost:8080/', '*/*'),
    ).rejects.toMatchObject({status: 400})
  })

  test('rejects a non-http protocol with 400', () => {
    expect(
      guardedFetch('file:///etc/passwd', '*/*'),
    ).rejects.toMatchObject({
      status: 400,
    })
  })
})

const redirect = (location: string | null) =>
  new Response(null, {
    status: 302,
    headers: location ? {location} : {},
  })

const hopSequence = (responses: Response[]): typeof fetchPinned => {
  let hop = 0
  return (url) => {
    const res = responses[hop++]
    if (!res) throw new Error(`unexpected hop to ${url}`)
    return Promise.resolve(res)
  }
}

describe('guardedFetch redirects', () => {
  test('follows redirects and re-validates each hop', async () => {
    const fetch = createGuardedFetch(
      hopSequence([
        redirect('https://1.1.1.1/next'),
        new Response('done'),
      ]),
    )
    const res = await fetch('https://8.8.8.8/', '*/*')
    expect(res?.status).toBe(200)
    expect(await res?.text()).toBe('done')
  })

  test('rejects a redirect to a private host with 400', () => {
    const fetch = createGuardedFetch(
      hopSequence([redirect('http://169.254.169.254/creds')]),
    )
    expect(fetch('https://8.8.8.8/', '*/*')).rejects.toMatchObject({
      status: 400,
    })
  })

  test('returns null when a redirect lacks a location', async () => {
    const fetch = createGuardedFetch(hopSequence([redirect(null)]))
    expect(await fetch('https://8.8.8.8/', '*/*')).toBeNull()
  })

  test('returns null on a malformed location header', async () => {
    const fetch = createGuardedFetch(
      hopSequence([redirect('https://exa mple.com/')]),
    )
    expect(await fetch('https://8.8.8.8/', '*/*')).toBeNull()
  })

  test('returns null on an unparseable initial url', async () => {
    const fetch = createGuardedFetch(hopSequence([]))
    expect(await fetch('http://[garbage', '*/*')).toBeNull()
  })

  test('returns null when the redirect budget is exhausted', async () => {
    const fetch = createGuardedFetch(() =>
      Promise.resolve(redirect('https://1.1.1.1/again')),
    )
    expect(await fetch('https://8.8.8.8/', '*/*')).toBeNull()
  })

  test('maps a transport failure to 502', () => {
    const fetch = createGuardedFetch(() =>
      Promise.reject(new Error('connect refused')),
    )
    expect(fetch('https://8.8.8.8/', '*/*')).rejects.toMatchObject({
      status: 502,
    })
  })
})

describe('fetchPinned', () => {
  test('connects to the pinned address, not the url host', async () => {
    let sawHost = ''
    const server = Bun.serve({
      port: 0,
      hostname: '127.0.0.1',
      fetch: (req) => {
        sawHost = new URL(req.url).hostname
        return new Response('pinned', {headers: {'x-probe': 'hit'}})
      },
    })
    const url = new URL(
      `http://guarded.invalid:${server.port}/path?q=1`,
    )
    const res = await fetchPinned(
      url,
      {address: '127.0.0.1', family: 4},
      '*/*',
    )
    expect(res.status).toBe(200)
    expect(res.headers.get('x-probe')).toBe('hit')
    expect(await res.text()).toBe('pinned')
    expect(sawHost).toBe('guarded.invalid')
    void server.stop()
  })
})

describe('readCapped', () => {
  const chunkedResponse = (chunks: Uint8Array[]) =>
    new Response(
      new ReadableStream({
        start(controller) {
          for (const chunk of chunks) controller.enqueue(chunk)
          controller.close()
        },
      }),
    )

  test('reads a short body fully', async () => {
    const res = chunkedResponse([new TextEncoder().encode('hello')])
    expect(await readCapped(res, 1024)).toEqual(
      new TextEncoder().encode('hello'),
    )
  })

  test('stops reading once the cap is reached', async () => {
    const chunk = new Uint8Array(1024).fill(1)
    const res = chunkedResponse(
      Array.from({length: 100}, () => chunk),
    )
    const bytes = await readCapped(res, 4 * 1024)
    expect(bytes.byteLength).toBeLessThanOrEqual(5 * 1024)
    expect(bytes.byteLength).toBeGreaterThanOrEqual(4 * 1024)
  })

  test('returns empty bytes for a bodyless response', async () => {
    expect(await readCapped(new Response(null), 1024)).toEqual(
      new Uint8Array(),
    )
  })
})
