/**
 * A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
 * past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
 * the actual transport to prove:
 *   - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
 *   - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
 *   - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
 */
import { EventEmitter } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))

import { generateP256Identity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import {
  createMtlsFetch,
  RENEW_REQUEST_TIMEOUT_MS,
  MAX_RENEW_RESPONSE_BYTES,
} from '../src/certs/nativeRenew.js'

const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })

/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
class FakeClientRequest extends EventEmitter {
  readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
  readonly written: string[] = []
  ended = false
  destroyedWith: Error | undefined
  setTimeout(ms: number, cb: () => void): this {
    this.setTimeoutCalls.push({ ms, cb })
    return this
  }
  write(chunk: string): boolean {
    this.written.push(chunk)
    return true
  }
  end(): this {
    this.ended = true
    return this
  }
  destroy(err?: Error): this {
    this.destroyedWith = err
    if (err) this.emit('error', err)
    return this
  }
}

/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
class FakeIncomingMessage extends EventEmitter {
  statusCode = 200
  destroyed = false
  destroy(): this {
    this.destroyed = true
    return this
  }
}

type ReqCb = (res: FakeIncomingMessage) => void
let dirs: string[] = []

function enrolledKs(): ReturnType<typeof openKeystore> {
  const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
  dirs.push(dir)
  const ks = openKeystore(dir)
  ks.saveIdentity(generateP256Identity())
  ks.saveCert('LEAFCERT', 'CACHAIN')
  return ks
}

beforeEach(() => {
  requestMock.mockReset()
})
afterEach(() => {
  for (const d of dirs) rmSync(d, { recursive: true, force: true })
  dirs = []
})

describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
  it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
    const ks = enrolledKs()
    let seenUrl = ''
    let seenOpts: Record<string, unknown> = {}
    let seenReq: FakeClientRequest | undefined
    requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
      seenUrl = url
      seenOpts = opts
      const req = new FakeClientRequest()
      seenReq = req
      queueMicrotask(() => {
        const res = new FakeIncomingMessage()
        res.statusCode = 200
        cb(res)
        res.emit('data', Buffer.from('{"cert":"NEW",'))
        res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
        res.emit('end')
      })
      return req
    })

    const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
    const res = await f('https://cp.example.com/renew', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: '{"csr":"x"}',
    })

    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
    expect(seenUrl).toBe('https://cp.example.com/renew')
    expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
    expect(seenOpts.method).toBe('POST')
    expect(seenOpts.cert).toBe('LEAFCERT')
    // ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA).
    expect(seenOpts.ca).toBeUndefined()
    expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
    // HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
    expect(seenReq!.setTimeoutCalls).toHaveLength(1)
    expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
    expect(seenReq!.written).toEqual(['{"csr":"x"}'])
    expect(seenReq!.ended).toBe(true)
  })

  it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
    const ks = enrolledKs()
    let seenReq: FakeClientRequest | undefined
    requestMock.mockImplementation(() => {
      const req = new FakeClientRequest()
      seenReq = req
      return req // never invokes the response callback — a stalled control-plane
    })

    const f = createMtlsFetch(ks, { certParser: farFuture })
    const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })

    // Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
    expect(seenReq!.setTimeoutCalls).toHaveLength(1)
    expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
    seenReq!.setTimeoutCalls[0]!.cb()

    await expect(p).rejects.toThrow(/timed out/i)
    expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
  })

  it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
    const ks = enrolledKs()
    let seenRes: FakeIncomingMessage | undefined
    requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
      const req = new FakeClientRequest()
      queueMicrotask(() => {
        const res = new FakeIncomingMessage()
        res.statusCode = 200
        seenRes = res
        cb(res)
        res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
        // deliberately NO 'end' — a capped stream must reject on its own, not wait for end
      })
      return req
    })

    const f = createMtlsFetch(ks, { certParser: farFuture })
    await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
    expect(seenRes!.destroyed).toBe(true)
  })
})
