/* eslint-env mocha */

import { expect } from 'aegir/chai'
import sinon from 'sinon'
import { multiaddr } from '@multiformats/multiaddr'
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
import Peers from '../fixtures/peers.js'
import { createLibp2pNode } from '../../src/libp2p.js'
import { createBaseOptions } from '../utils/base-options.browser.js'
import { MULTIADDRS_WEBSOCKETS } from '../fixtures/browser.js'
import { createFromJSON } from '@libp2p/peer-id-factory'
import pWaitFor from 'p-wait-for'
import { peerIdFromString } from '@libp2p/peer-id'
import type { PeerId } from '@libp2p/interface-peer-id'
import type { Libp2pNode } from '../../src/libp2p.js'
import { pEvent } from 'p-event'
import { AGENT_VERSION } from '../../src/identify/consts.js'

describe('libp2p.dialer.identifyService', () => {
  let peerId: PeerId
  let libp2p: Libp2pNode
  let remoteLibp2p: Libp2pNode
  const remoteAddr = MULTIADDRS_WEBSOCKETS[0]

  before(async () => {
    peerId = await createFromJSON(Peers[0])
  })

  afterEach(async () => {
    sinon.restore()

    if (libp2p != null) {
      await libp2p.stop()
    }
  })

  after(async () => {
    if (remoteLibp2p != null) {
      await remoteLibp2p.stop()
    }
  })

  it('should run identify automatically after connecting', async () => {
    libp2p = await createLibp2pNode(createBaseOptions({
      peerId
    }))

    await libp2p.start()

    if (libp2p.identifyService == null) {
      throw new Error('Identity service was not configured')
    }

    const identityServiceIdentifySpy = sinon.spy(libp2p.identifyService, 'identify')
    const peerStoreSpyConsumeRecord = sinon.spy(libp2p.peerStore.addressBook, 'consumePeerRecord')
    const peerStoreSpyAdd = sinon.spy(libp2p.peerStore.addressBook, 'add')

    const connection = await libp2p.dial(remoteAddr)
    expect(connection).to.exist()

    // Wait for peer store to be updated
    // Dialer._createDialTarget (add), Identify (consume)
    await pWaitFor(() => peerStoreSpyConsumeRecord.callCount === 1 && peerStoreSpyAdd.callCount === 1)
    expect(identityServiceIdentifySpy.callCount).to.equal(1)

    // The connection should have no open streams
    await pWaitFor(() => connection.streams.length === 0)
    await connection.close()
  })

  it('should store remote agent and protocol versions in metadataBook after connecting', async () => {
    libp2p = await createLibp2pNode(createBaseOptions({
      peerId
    }))

    await libp2p.start()

    if (libp2p.identifyService == null) {
      throw new Error('Identity service was not configured')
    }

    const identityServiceIdentifySpy = sinon.spy(libp2p.identifyService, 'identify')
    const peerStoreSpyConsumeRecord = sinon.spy(libp2p.peerStore.addressBook, 'consumePeerRecord')
    const peerStoreSpyAdd = sinon.spy(libp2p.peerStore.addressBook, 'add')

    const connection = await libp2p.dial(remoteAddr)
    expect(connection).to.exist()

    // Wait for peer store to be updated
    // Dialer._createDialTarget (add), Identify (consume)
    await pWaitFor(() => peerStoreSpyConsumeRecord.callCount === 1 && peerStoreSpyAdd.callCount === 1)
    expect(identityServiceIdentifySpy.callCount).to.equal(1)

    // The connection should have no open streams
    await pWaitFor(() => connection.streams.length === 0)
    await connection.close()

    const remotePeer = peerIdFromString(remoteAddr.getPeerId() ?? '')

    const storedAgentVersion = await libp2p.peerStore.metadataBook.getValue(remotePeer, 'AgentVersion')
    const storedProtocolVersion = await libp2p.peerStore.metadataBook.getValue(remotePeer, 'ProtocolVersion')

    expect(storedAgentVersion).to.exist()
    expect(storedProtocolVersion).to.exist()
  })

  it('should push protocol updates to an already connected peer', async () => {
    libp2p = await createLibp2pNode(createBaseOptions({
      peerId
    }))

    await libp2p.start()

    if (libp2p.identifyService == null) {
      throw new Error('Identity service was not configured')
    }

    const identityServiceIdentifySpy = sinon.spy(libp2p.identifyService, 'identify')
    const identityServicePushSpy = sinon.spy(libp2p.identifyService, 'push')
    const connectionPromise = pEvent(libp2p.connectionManager, 'peer:connect')
    const connection = await libp2p.dial(remoteAddr)

    expect(connection).to.exist()
    // Wait for connection event to be emitted
    await connectionPromise

    // Wait for identify to finish
    await identityServiceIdentifySpy.firstCall.returnValue
    sinon.stub(libp2p, 'isStarted').returns(true)

    // Cause supported protocols to change
    await libp2p.handle('/echo/2.0.0', () => {})

    // Wait for push to complete
    await pWaitFor(() => identityServicePushSpy.callCount === 1)
    await identityServicePushSpy.firstCall.returnValue

    // Cause supported protocols to change back
    await libp2p.unhandle('/echo/2.0.0')

    // Wait for push to complete a second time
    await pWaitFor(() => identityServicePushSpy.callCount === 2)
    await identityServicePushSpy.secondCall.returnValue

    // Verify the remote peer is notified of both changes
    expect(identityServicePushSpy.callCount).to.equal(2)

    for (const call of identityServicePushSpy.getCalls()) {
      const [connections] = call.args
      expect(connections.length).to.equal(1)
      expect(connections[0].remotePeer.toString()).to.equal(remoteAddr.getPeerId())
      await call.returnValue
    }

    // Verify the streams close
    await pWaitFor(() => connection.streams.length === 0)
  })

  it('should append UserAgent information to default AGENT_VERSION', async () => {
    // Stub environment version for testing dynamic AGENT_VERSION
    sinon.stub(process, 'version').value('vTEST')

    if (typeof globalThis.navigator !== 'undefined') {
      sinon.stub(navigator, 'userAgent').value('vTEST')
    }

    libp2p = await createLibp2pNode(createBaseOptions({
      peerId
    }))

    await libp2p.start()

    if (libp2p.identifyService == null) {
      throw new Error('Identity service was not configured')
    }

    const storedAgentVersion = await libp2p.peerStore.metadataBook.getValue(peerId, 'AgentVersion')

    expect(AGENT_VERSION + ' UserAgent=vTEST').to.equal(uint8ArrayToString(storedAgentVersion ?? new Uint8Array()))
  })

  it('should store host data and protocol version into metadataBook', async () => {
    const agentVersion = 'js-project/1.0.0'

    libp2p = await createLibp2pNode(createBaseOptions({
      peerId,
      identify: {
        host: {
          agentVersion
        }
      }
    }))

    await libp2p.start()

    if (libp2p.identifyService == null) {
      throw new Error('Identity service was not configured')
    }

    const storedAgentVersion = await libp2p.peerStore.metadataBook.getValue(peerId, 'AgentVersion')
    const storedProtocolVersion = await libp2p.peerStore.metadataBook.getValue(peerId, 'ProtocolVersion')

    expect(agentVersion).to.equal(uint8ArrayToString(storedAgentVersion ?? new Uint8Array()))
    expect(storedProtocolVersion).to.exist()
  })

  it('should push multiaddr updates to an already connected peer', async () => {
    libp2p = await createLibp2pNode(createBaseOptions({
      peerId
    }))

    await libp2p.start()

    if (libp2p.identifyService == null) {
      throw new Error('Identity service was not configured')
    }

    const identityServiceIdentifySpy = sinon.spy(libp2p.identifyService, 'identify')
    const identityServicePushSpy = sinon.spy(libp2p.identifyService, 'push')
    const connectionPromise = pEvent(libp2p.connectionManager, 'peer:connect')
    const connection = await libp2p.dial(remoteAddr)

    expect(connection).to.exist()
    // Wait for connection event to be emitted
    await connectionPromise

    // Wait for identify to finish
    await identityServiceIdentifySpy.firstCall.returnValue
    sinon.stub(libp2p, 'isStarted').returns(true)

    await libp2p.peerStore.addressBook.add(libp2p.peerId, [multiaddr('/ip4/180.0.0.1/tcp/15001/ws')])

    // the protocol change event listener in the identity service is async
    await pWaitFor(() => identityServicePushSpy.callCount === 1)

    // Verify the remote peer is notified of change
    expect(identityServicePushSpy.callCount).to.equal(1)
    for (const call of identityServicePushSpy.getCalls()) {
      const [connections] = call.args
      expect(connections.length).to.equal(1)
      expect(connections[0].remotePeer.toString()).to.equal(remoteAddr.getPeerId())
      await call.returnValue
    }

    // Verify the streams close
    await pWaitFor(() => connection.streams.length === 0)
  })
})
