import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import path from 'node:path'

import { test } from 'vitest'

import { pathForRegistryBackendRequest } from './connection-config'
import type { PumpDeps } from './gateway-file-download'
import {
  downloadTempPath,
  filenameFromContentDisposition,
  gatewayFilePath,
  gatewayFileRequestPaths,
  isNotFoundError,
  parseDataUrlToBuffer,
  pumpStreamToFile,
  resolveGatewayFileBackend,
  writeBufferToFile
} from './gateway-file-download'

// A Readable-like response driven manually in tests.
class FakeResponse extends EventEmitter {
  paused = false
  resumed = false
  destroyed = false

  pause() {
    this.paused = true
  }

  resume() {
    this.resumed = true
  }

  destroy() {
    this.destroyed = true
  }
}

// A write stream that records writes and lets tests control backpressure.
class FakeWriteStream extends EventEmitter {
  chunks: Buffer[] = []
  ended = false
  destroyed = false
  private writeReturns: boolean[]

  constructor(writeReturns: boolean[] = [], { opens = true }: { opens?: boolean } = {}) {
    super()
    this.writeReturns = writeReturns

    // Like fs.WriteStream: 'open' fires once the exclusive create succeeded.
    // `opens: false` models a create that fails before any file exists.
    if (opens) {
      queueMicrotask(() => this.emit('open'))
    }
  }

  write(chunk: Buffer): boolean {
    this.chunks.push(chunk)

    return this.writeReturns.length ? this.writeReturns.shift()! : true
  }

  end(cb: () => void) {
    this.ended = true
    cb()
  }

  // Like fs.WriteStream: the descriptor is released asynchronously and 'close'
  // fires afterwards.
  destroy() {
    this.destroyed = true
    queueMicrotask(() => this.emit('close'))
  }
}

// Deps recorder shared by the pumpStreamToFile tests: captures every path the
// pump opens, renames, or unlinks so each test can assert the destination itself
// was never touched before the body finished.
function recordingDeps(ws: FakeWriteStream, { renameError }: { renameError?: Error } = {}) {
  const opened: string[] = []
  const renamed: Array<[string, string]> = []
  const unlinked: string[] = []

  const deps: PumpDeps = {
    createWriteStream: (p: string) => {
      opened.push(p)

      return ws as never
    },
    rename: async (from: string, to: string) => {
      if (renameError) {
        throw renameError
      }

      renamed.push([from, to])
    },
    unlink: async (p: string) => {
      unlinked.push(p)
    }
  }

  return { deps, opened, renamed, unlinked }
}

// Separator-agnostic: path.join emits backslashes on Windows, so the expectation
// is "short hidden .part name, same directory as the destination", not a
// literal POSIX string.
const TEMP_BASENAME = /^\.hermes-download-[0-9a-f]{8}\.part$/

// path.join normalizes separators (``/tmp`` -> ``\\tmp`` on Windows) while the
// literal destination strings in these tests do not, so compare normalized forms.
function assertTempPathBeside(tempPath: string, destPath: string) {
  assert.equal(
    path.normalize(path.dirname(tempPath)),
    path.normalize(path.dirname(destPath)),
    'temp file must sit beside the destination'
  )
  assert.match(path.basename(tempPath), TEMP_BASENAME)
}

test('downloadTempPath stays beside the destination with a short, random per-call name', () => {
  const a = downloadTempPath('/tmp/out.bin')
  const b = downloadTempPath('/tmp/out.bin')

  assertTempPathBeside(a, '/tmp/out.bin')
  assertTempPathBeside(b, '/tmp/out.bin')
  assert.notEqual(a, b, 'two concurrent saves into the same directory must not share a temp file')

  // The temp name must not grow with the user's filename: a destination near the
  // filesystem's name limit still gets a temp file that fits beside it.
  const longName = `/downloads/${'x'.repeat(250)}.bin`

  assert.equal(path.normalize(path.dirname(downloadTempPath(longName))), path.normalize(path.dirname(longName)))
  assert.ok(path.basename(downloadTempPath(longName)).length < 40)
})

test('pumpStreamToFile streams chunks into a sibling temp file, then renames it onto the destination', async () => {
  const res = new FakeResponse()
  const ws = new FakeWriteStream()
  const { deps, opened, renamed, unlinked } = recordingDeps(ws)

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('abc'))
  res.emit('data', Buffer.from('def'))
  res.emit('end')

  await promise

  assert.equal(Buffer.concat(ws.chunks).toString('utf8'), 'abcdef')
  assert.equal(ws.ended, true)
  assert.equal(opened.length, 1)
  assertTempPathBeside(opened[0], '/tmp/out.bin')
  assert.deepEqual(renamed, [[opened[0], '/tmp/out.bin']])
  assert.deepEqual(unlinked, []) // success -> no cleanup
})

test('pumpStreamToFile waits for the descriptor to close before renaming when the stream supports close()', async () => {
  const res = new FakeResponse()
  const order: string[] = []

  class ClosingWriteStream extends FakeWriteStream {
    close(cb: (err?: Error | null) => void) {
      order.push('close')
      // Like fs.WriteStream: end the stream, release the fd, then call back.
      this.ended = true
      setTimeout(() => cb(), 0)
    }
  }

  const ws = new ClosingWriteStream()
  const { deps, renamed } = recordingDeps(ws)

  deps.rename = async (from, to) => {
    order.push('rename')
    renamed.push([from, to])
  }

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('abc'))
  res.emit('end')

  await promise

  assert.deepEqual(order, ['close', 'rename'])
  assert.equal(renamed.length, 1)
  assert.equal(renamed[0][1], '/tmp/out.bin')
})

test('pumpStreamToFile applies backpressure: pauses on a full buffer and resumes on drain', async () => {
  const res = new FakeResponse()
  const ws = new FakeWriteStream([false]) // first write signals "buffer full"
  const { deps } = recordingDeps(ws)

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('big-chunk'))
  assert.equal(res.paused, true, 'source should be paused when write() returns false')
  assert.equal(res.resumed, false)

  ws.emit('drain')
  assert.equal(res.resumed, true, 'source should resume after the write stream drains')

  res.emit('end')
  await promise
})

test('pumpStreamToFile removes only the temp file and rejects on a write error', async () => {
  const res = new FakeResponse()
  const ws = new FakeWriteStream()
  const { deps, opened, renamed, unlinked } = recordingDeps(ws)

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('abc'))
  ws.emit('error', new Error('ENOSPC: disk full'))

  await assert.rejects(promise, /disk full/)
  assert.deepEqual(unlinked, [opened[0]])
  assertTempPathBeside(unlinked[0], '/tmp/out.bin')
  assert.deepEqual(renamed, [], 'a failed body must never be moved onto the destination')
  assert.equal(res.destroyed, true, 'source should be torn down on write failure')
})

test('pumpStreamToFile waits for the write stream to close before unlinking the temp file', async () => {
  const res = new FakeResponse()
  const order: string[] = []

  class SlowCloseWriteStream extends FakeWriteStream {
    destroy() {
      this.destroyed = true
      order.push('destroy')
      // Release the fd later than a microtask: cleanup must still wait for it.
      setTimeout(() => {
        order.push('close')
        this.emit('close')
      }, 5)
    }
  }

  const ws = new SlowCloseWriteStream()
  const { deps, opened, unlinked } = recordingDeps(ws)

  deps.unlink = async (p: string) => {
    order.push('unlink')
    unlinked.push(p)
  }

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('abc'))
  res.emit('error', new Error('socket hang up'))

  await assert.rejects(promise, /socket hang up/)
  assert.deepEqual(order, ['destroy', 'close', 'unlink'])
  assert.deepEqual(unlinked, [opened[0]])
})

// Ownership gate: an exclusive create can fail BEFORE this pump owns anything at
// the temp path (EEXIST on a collision). Cleanup must not unlink a file it did
// not create, or the destructive class moves from the destination to the temp
// name.
test('pumpStreamToFile never unlinks a temp path it did not create when the exclusive open fails', async () => {
  const res = new FakeResponse()
  const ws = new FakeWriteStream([], { opens: false })
  const { deps, opened, renamed, unlinked } = recordingDeps(ws)

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  const eexist: any = new Error("EEXIST: file already exists, open '/tmp/.hermes-download-deadbeef.part'")

  eexist.code = 'EEXIST'
  ws.emit('error', eexist)

  await assert.rejects(promise, /EEXIST/)
  assert.equal(opened.length, 1, 'one create attempt')
  assert.deepEqual(unlinked, [], 'the colliding file belongs to someone else and must survive')
  assert.deepEqual(renamed, [])
  assert.equal(res.destroyed, true)
})

test('pumpStreamToFile honours tempPathFor so a regression can pin the temp path', async () => {
  const res = new FakeResponse()
  const ws = new FakeWriteStream()
  const { deps, opened, renamed } = recordingDeps(ws)

  deps.tempPathFor = () => '/tmp/pinned.part'

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('abc'))
  res.emit('end')

  await promise

  assert.deepEqual(opened, ['/tmp/pinned.part'])
  assert.deepEqual(renamed, [['/tmp/pinned.part', '/tmp/out.bin']])
})

test('writeBufferToFile streams the buffer through the same temp-then-rename contract', async () => {
  const ws = new FakeWriteStream()
  const { deps, opened, renamed, unlinked } = recordingDeps(ws)

  await writeBufferToFile(Buffer.from('whole body'), '/tmp/out.bin', deps)

  assert.equal(Buffer.concat(ws.chunks).toString('utf8'), 'whole body')
  assert.equal(opened.length, 1)
  assertTempPathBeside(opened[0], '/tmp/out.bin')
  assert.deepEqual(renamed, [[opened[0], '/tmp/out.bin']])
  assert.deepEqual(unlinked, [])
})

test('writeBufferToFile leaves the destination untouched when the write fails after open', async () => {
  // fs.WriteStream surfaces a write failure before 'finish', never after, so
  // the fake errors from write() itself.
  class FailingWriteStream extends FakeWriteStream {
    write(chunk: Buffer): boolean {
      super.write(chunk)
      this.emit('error', new Error('ENOSPC: disk full'))

      return true
    }
  }

  const ws = new FailingWriteStream()
  const { deps, opened, renamed, unlinked } = recordingDeps(ws)

  await assert.rejects(writeBufferToFile(Buffer.from('whole body'), '/tmp/out.bin', deps), /disk full/)
  assert.ok(!opened.includes('/tmp/out.bin'))
  assert.deepEqual(unlinked, [opened[0]], 'only the owned temp file is removed')
  assert.deepEqual(renamed, [])
})

// Regression for #96597: opening the destination directly truncated it as soon
// as the stream opened, and the error path then unlinked it — so a gateway
// hiccup mid-download destroyed a pre-existing file the user had chosen to
// overwrite. The destination must be neither opened nor removed on failure.
test('pumpStreamToFile leaves a pre-existing destination untouched when the response fails mid-stream', async () => {
  const res = new FakeResponse()
  const ws = new FakeWriteStream()
  const { deps, opened, renamed, unlinked } = recordingDeps(ws)

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('abc'))
  res.emit('error', new Error('socket hang up'))

  await assert.rejects(promise, /socket hang up/)
  assert.ok(!opened.includes('/tmp/out.bin'), 'destination must not be opened (and truncated) before the body lands')
  assert.ok(!unlinked.includes('/tmp/out.bin'), 'destination must not be removed on failure')
  assert.deepEqual(unlinked, [opened[0]])
  assert.deepEqual(renamed, [])
})

test('pumpStreamToFile removes the temp file and rejects when the final rename fails', async () => {
  const res = new FakeResponse()
  const ws = new FakeWriteStream()
  const { deps, opened, unlinked } = recordingDeps(ws, { renameError: new Error('EPERM: destination locked') })

  const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)

  res.emit('data', Buffer.from('abc'))
  res.emit('end')

  await assert.rejects(promise, /destination locked/)
  assert.deepEqual(unlinked, [opened[0]], 'the temp file must not be left behind after a failed rename')
  assert.ok(!unlinked.includes('/tmp/out.bin'))
})

test('parseDataUrlToBuffer decodes base64 payloads', () => {
  const buffer = parseDataUrlToBuffer('data:text/markdown;base64,IyByZXBvcnQ=')

  assert.equal(buffer.toString('utf8'), '# report')
})

test('parseDataUrlToBuffer decodes percent-encoded (non-base64) payloads', () => {
  const buffer = parseDataUrlToBuffer('data:text/plain,hello%20world')

  assert.equal(buffer.toString('utf8'), 'hello world')
})

test('parseDataUrlToBuffer throws on a malformed data URL', () => {
  assert.throws(() => parseDataUrlToBuffer('not-a-data-url'), /Malformed data URL/)
})

test('filenameFromContentDisposition prefers filename* and reduces to a basename', () => {
  assert.equal(
    filenameFromContentDisposition("attachment; filename*=UTF-8''report%20with%20spaces.pdf"),
    'report with spaces.pdf'
  )
  assert.equal(filenameFromContentDisposition('attachment; filename="report.md"'), 'report.md')
  // A traversal attempt in the header cannot escape the chosen directory.
  assert.equal(filenameFromContentDisposition('attachment; filename="../../etc/passwd"'), 'passwd')
  assert.equal(filenameFromContentDisposition(''), '')
  assert.equal(filenameFromContentDisposition(undefined), '')
})

test('gatewayFilePath normalizes bare paths and file:// URLs', () => {
  assert.equal(gatewayFilePath('/Users/me/report.md'), '/Users/me/report.md')
  assert.equal(gatewayFilePath('file:///Users/me/a%20b.md'), '/Users/me/a b.md')
  assert.equal(gatewayFilePath(''), '')
  assert.equal(gatewayFilePath(null), '')
})

test('gatewayFileRequestPaths keeps streaming and fallback requests on the same registered backend', () => {
  const paths = gatewayFileRequestPaths('/srv/output/image one.png', requestPath =>
    pathForRegistryBackendRequest(requestPath, 'research', { sharedRemote: true })
  )

  assert.deepEqual(paths, {
    dataUrl: '/api/fs/read-data-url?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research',
    download: '/api/fs/download?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research'
  })
})

test('isNotFoundError matches only HTTP 404', () => {
  const notFound: any = new Error('404: missing')

  notFound.statusCode = 404
  assert.equal(isNotFoundError(notFound), true)

  const forbidden: any = new Error('403: nope')

  forbidden.statusCode = 403
  assert.equal(isNotFoundError(forbidden), false)
  assert.equal(isNotFoundError(new Error('plain')), false)
  assert.equal(isNotFoundError(null), false)
})

test('resolveGatewayFileBackend pins registered files to their owning connection', async () => {
  const calls: string[] = []

  const route = await resolveGatewayFileBackend(
    { connectionId: '  work-ssh  ', profile: ' default ' },
    {
      ensureLegacy: async profile => {
        calls.push(`legacy:${profile}`)

        return { baseUrl: 'http://local.invalid' }
      },
      ensureRegistry: async (connectionId, profile) => {
        calls.push(`registry:${connectionId}:${profile}`)

        return { baseUrl: 'http://ssh.invalid' }
      }
    }
  )

  assert.deepEqual(calls, ['registry:work-ssh:default'])
  assert.deepEqual(route, {
    connection: { baseUrl: 'http://ssh.invalid' },
    connectionId: 'work-ssh',
    profile: 'default'
  })
})

test('resolveGatewayFileBackend preserves the legacy route when no connection owns the file', async () => {
  const calls: string[] = []

  const route = await resolveGatewayFileBackend(
    { profile: 'coder' },
    {
      ensureLegacy: async profile => {
        calls.push(`legacy:${profile}`)

        return { baseUrl: 'http://local.invalid' }
      },
      ensureRegistry: async connectionId => {
        calls.push(`registry:${connectionId}`)

        return { baseUrl: 'http://remote.invalid' }
      }
    }
  )

  assert.deepEqual(calls, ['legacy:coder'])
  assert.equal(route.connectionId, null)
  assert.equal(route.profile, 'coder')
  assert.deepEqual(route.connection, { baseUrl: 'http://local.invalid' })
})
