/* eslint-env mocha */

import { expect } from 'aegir/chai'
import sinon from 'sinon'
import { multiaddr } from '@multiformats/multiaddr'
import { IdentifyService, IdentifyServiceInit } from '../../src/identify/index.js'
import Peers from '../fixtures/peers.js'
import { PersistentPeerStore } from '@libp2p/peer-store'
import { DefaultAddressManager } from '../../src/address-manager/index.js'
import { MemoryDatastore } from 'datastore-core/memory'
import drain from 'it-drain'
import { pipe } from 'it-pipe'
import { mockConnectionGater, mockRegistrar, mockUpgrader, connectionPair } from '@libp2p/interface-mocks'
import { createFromJSON } from '@libp2p/peer-id-factory'
import { Components } from '@libp2p/components'
import { PeerRecordUpdater } from '../../src/peer-record-updater.js'
import {
  MULTICODEC_IDENTIFY,
  MULTICODEC_IDENTIFY_PUSH
} from '../../src/identify/consts.js'
import { DefaultConnectionManager } from '../../src/connection-manager/index.js'
import { DefaultTransportManager } from '../../src/transport-manager.js'
import { CustomEvent } from '@libp2p/interfaces/events'
import delay from 'delay'
import { pEvent } from 'p-event'
import { start, stop } from '@libp2p/interfaces/startable'

const listenMaddrs = [multiaddr('/ip4/127.0.0.1/tcp/15002/ws')]

const defaultInit: IdentifyServiceInit = {
  protocolPrefix: 'ipfs',
  host: {
    agentVersion: 'v1.0.0'
  },
  maxInboundStreams: 1,
  maxOutboundStreams: 1,
  maxPushIncomingStreams: 1,
  maxPushOutgoingStreams: 1,
  timeout: 1000
}

const protocols = [MULTICODEC_IDENTIFY, MULTICODEC_IDENTIFY_PUSH]

async function createComponents (index: number) {
  const peerId = await createFromJSON(Peers[index])

  const components = new Components({
    peerId,
    datastore: new MemoryDatastore(),
    registrar: mockRegistrar(),
    upgrader: mockUpgrader(),
    connectionGater: mockConnectionGater(),
    peerStore: new PersistentPeerStore(),
    connectionManager: new DefaultConnectionManager({
      minConnections: 50,
      maxConnections: 1000,
      autoDialInterval: 1000,
      inboundUpgradeTimeout: 1000
    })
  })
  components.setAddressManager(new DefaultAddressManager(components, {
    announce: listenMaddrs.map(ma => ma.toString())
  }))

  const transportManager = new DefaultTransportManager(components)
  components.setTransportManager(transportManager)

  await components.getPeerStore().protoBook.set(peerId, protocols)

  return components
}

describe('identify (push)', () => {
  let localComponents: Components
  let remoteComponents: Components

  let localPeerRecordUpdater: PeerRecordUpdater

  beforeEach(async () => {
    localComponents = await createComponents(0)
    remoteComponents = await createComponents(1)

    localPeerRecordUpdater = new PeerRecordUpdater(localComponents)

    await Promise.all([
      start(localComponents),
      start(remoteComponents)
    ])
  })

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

    await Promise.all([
      stop(localComponents),
      stop(remoteComponents)
    ])
  })

  it('should be able to push identify updates to another peer', async () => {
    const localIdentify = new IdentifyService(localComponents, defaultInit)
    const remoteIdentify = new IdentifyService(remoteComponents, defaultInit)

    await start(localIdentify)
    await start(remoteIdentify)

    const [localToRemote, remoteToLocal] = connectionPair(localComponents, remoteComponents)

    // ensure connections are registered by connection manager
    localComponents.getUpgrader().dispatchEvent(new CustomEvent('connection', {
      detail: localToRemote
    }))
    remoteComponents.getUpgrader().dispatchEvent(new CustomEvent('connection', {
      detail: remoteToLocal
    }))

    // identify both ways
    await localIdentify.identify(localToRemote)
    await remoteIdentify.identify(remoteToLocal)

    const updatedProtocol = '/special-new-protocol/1.0.0'
    const updatedAddress = multiaddr('/ip4/127.0.0.1/tcp/48322')

    // should have protocols but not our new one
    const identifiedProtocols = await remoteComponents.getPeerStore().protoBook.get(localComponents.getPeerId())
    expect(identifiedProtocols).to.not.be.empty()
    expect(identifiedProtocols).to.not.include(updatedProtocol)

    // should have addresses but not our new one
    const identifiedAddresses = await remoteComponents.getPeerStore().addressBook.get(localComponents.getPeerId())
    expect(identifiedAddresses).to.not.be.empty()
    expect(identifiedAddresses.map(a => a.multiaddr.toString())).to.not.include(updatedAddress.toString())

    // update local data - change event will trigger push
    await localComponents.getPeerStore().protoBook.add(localComponents.getPeerId(), [updatedProtocol])
    await localComponents.getPeerStore().addressBook.add(localComponents.getPeerId(), [updatedAddress])

    // needed to update the peer record and send our supported addresses
    const addressManager = localComponents.getAddressManager()
    addressManager.getAddresses = () => {
      return [updatedAddress]
    }

    // ensure sequence number of peer record we are about to create is different
    await delay(1000)

    // make sure we have a peer record to send
    await localPeerRecordUpdater.update()

    // wait for the remote peer store to notice the changes
    const eventPromise = pEvent(remoteComponents.getPeerStore(), 'change:multiaddrs')

    // push updated peer record to connections
    await localIdentify.pushToPeerStore()

    await eventPromise

    // should have new protocol
    const updatedProtocols = await remoteComponents.getPeerStore().protoBook.get(localComponents.getPeerId())
    expect(updatedProtocols).to.not.be.empty()
    expect(updatedProtocols).to.include(updatedProtocol)

    // should have new address
    const updatedAddresses = await remoteComponents.getPeerStore().addressBook.get(localComponents.getPeerId())
    expect(updatedAddresses.map(a => {
      return {
        multiaddr: a.multiaddr.toString(),
        isCertified: a.isCertified
      }
    })).to.deep.equal([{
      multiaddr: updatedAddress.toString(),
      isCertified: true
    }])

    await stop(localIdentify)
    await stop(remoteIdentify)
  })

  it('should time out during push identify', async () => {
    let streamEnded = false
    const localIdentify = new IdentifyService(localComponents, {
      ...defaultInit,
      timeout: 10
    })
    const remoteIdentify = new IdentifyService(remoteComponents, defaultInit)

    await start(localIdentify)
    await start(remoteIdentify)

    // simulate connection between nodes
    const [localToRemote] = connectionPair(localComponents, remoteComponents)

    // replace existing handler with a really slow one
    await remoteComponents.getRegistrar().unhandle(MULTICODEC_IDENTIFY_PUSH)
    await remoteComponents.getRegistrar().handle(MULTICODEC_IDENTIFY_PUSH, ({ stream }) => {
      void pipe(
        stream,
        async function * (source) {
          // ignore the sent data
          await drain(source)

          // longer than the timeout
          await delay(1000)

          // the delay should have caused the local push to time out so this should
          // occur after the local push method invocation has completed
          streamEnded = true

          yield new Uint8Array()
        },
        stream
      )
    })

    const newStreamSpy = sinon.spy(localToRemote, 'newStream')

    // push updated peer record to remote
    await localIdentify.push([localToRemote])

    // should have closed stream
    expect(newStreamSpy).to.have.property('callCount', 1)
    const stream = await newStreamSpy.getCall(0).returnValue
    expect(stream).to.have.nested.property('stat.timeline.close')

    // method should have returned before the remote handler completes as we timed
    // out so we ignore the return value
    expect(streamEnded).to.be.false()
  })

  // LEGACY
  it('should be able to push identify updates to another peer with no certified peer records support', async () => {
    const localIdentify = new IdentifyService(localComponents, defaultInit)
    const remoteIdentify = new IdentifyService(remoteComponents, defaultInit)

    await start(localIdentify)
    await start(remoteIdentify)

    const [localToRemote, remoteToLocal] = connectionPair(localComponents, remoteComponents)

    // ensure connections are registered by connection manager
    localComponents.getUpgrader().dispatchEvent(new CustomEvent('connection', {
      detail: localToRemote
    }))
    remoteComponents.getUpgrader().dispatchEvent(new CustomEvent('connection', {
      detail: remoteToLocal
    }))

    // identify both ways
    await localIdentify.identify(localToRemote)
    await remoteIdentify.identify(remoteToLocal)

    const updatedProtocol = '/special-new-protocol/1.0.0'
    const updatedAddress = multiaddr('/ip4/127.0.0.1/tcp/48322')

    // should have protocols but not our new one
    const identifiedProtocols = await remoteComponents.getPeerStore().protoBook.get(localComponents.getPeerId())
    expect(identifiedProtocols).to.not.be.empty()
    expect(identifiedProtocols).to.not.include(updatedProtocol)

    // should have addresses but not our new one
    const identifiedAddresses = await remoteComponents.getPeerStore().addressBook.get(localComponents.getPeerId())
    expect(identifiedAddresses).to.not.be.empty()
    expect(identifiedAddresses.map(a => a.multiaddr.toString())).to.not.include(updatedAddress.toString())

    // update local data - change event will trigger push
    await localComponents.getPeerStore().protoBook.add(localComponents.getPeerId(), [updatedProtocol])
    await localComponents.getPeerStore().addressBook.add(localComponents.getPeerId(), [updatedAddress])

    // needed to send our supported addresses
    const addressManager = localComponents.getAddressManager()
    addressManager.getAddresses = () => {
      return [updatedAddress]
    }

    // wait until remote peer store notices protocol list update
    const waitForUpdate = pEvent(remoteComponents.getPeerStore(), 'change:protocols')

    await localIdentify.pushToPeerStore()

    await waitForUpdate

    // should have new protocol
    const updatedProtocols = await remoteComponents.getPeerStore().protoBook.get(localComponents.getPeerId())
    expect(updatedProtocols).to.not.be.empty()
    expect(updatedProtocols).to.include(updatedProtocol)

    // should have new address
    const updatedAddresses = await remoteComponents.getPeerStore().addressBook.get(localComponents.getPeerId())
    expect(updatedAddresses.map(a => {
      return {
        multiaddr: a.multiaddr.toString(),
        isCertified: a.isCertified
      }
    })).to.deep.equal([{
      multiaddr: updatedAddress.toString(),
      isCertified: false
    }])

    await stop(localIdentify)
    await stop(remoteIdentify)
  })
})
