'use strict'
/* eslint-env mocha */

const { expect } = require('aegir/utils/chai')
const sinon = require('sinon')

const Envelope = require('../../src/record/envelope')
const PeerRecord = require('../../src/record/peer-record')
const PeerStore = require('../../src/peer-store/persistent')

const { Multiaddr } = require('multiaddr')
const { MemoryDatastore } = require('interface-datastore')
const uint8ArrayFromString = require('uint8arrays/from-string')

const peerUtils = require('../utils/creators/peer')

describe('Persisted PeerStore', () => {
  let datastore, peerStore
  let peerId

  before(async () => {
    [peerId] = await peerUtils.createPeerId({ fixture: false })
  })

  describe('start and stop flows', () => {
    beforeEach(() => {
      datastore = new MemoryDatastore()
      peerStore = new PeerStore({ datastore, peerId })
    })

    afterEach(() => peerStore.stop())

    it('should try to load content from an empty datastore on start', async () => {
      const spyQuery = sinon.spy(datastore, 'query')
      const spyProcessEntry = sinon.spy(peerStore, '_processDatastoreEntry')

      await peerStore.start()
      expect(spyQuery).to.have.property('callCount', 1)
      expect(spyProcessEntry).to.have.property('callCount', 0)

      // No data to populate
      expect(peerStore.peers.size).to.eq(0)
    })

    it('should try to commit data on stop but should not add to batch if not exists', async () => {
      const spyDs = sinon.spy(peerStore, '_commitData')
      const spyBatch = sinon.spy(datastore, 'batch')

      await peerStore.start()
      expect(spyDs).to.have.property('callCount', 0)

      await peerStore.stop()
      expect(spyBatch).to.have.property('callCount', 0)
      expect(spyDs).to.have.property('callCount', 1)
    })
  })

  describe('simple setup with content stored per change (threshold 1)', () => {
    beforeEach(() => {
      datastore = new MemoryDatastore()
      peerStore = new PeerStore({ datastore, peerId, threshold: 1 })
    })

    afterEach(() => peerStore.stop())

    it('should store peerStore content on datastore after peer marked as dirty (threshold 1)', async () => {
      const [peer] = await peerUtils.createPeerId({ number: 2 })
      const multiaddrs = [new Multiaddr('/ip4/156.10.1.22/tcp/1000')]
      const protocols = ['/ping/1.0.0']
      const spyDirty = sinon.spy(peerStore, '_addDirtyPeer')
      const spyDs = sinon.spy(datastore, 'batch')
      const commitSpy = sinon.spy(peerStore, '_commitData')

      await peerStore.start()

      // AddressBook
      peerStore.addressBook.set(peer, multiaddrs)

      expect(spyDirty).to.have.property('callCount', 1) // Address
      expect(spyDs).to.have.property('callCount', 1)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      // ProtoBook
      peerStore.protoBook.set(peer, protocols)

      expect(spyDirty).to.have.property('callCount', 2) // Protocol
      expect(spyDs).to.have.property('callCount', 2)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      // Should have three peer records stored in the datastore
      const queryParams = {
        prefix: '/peers/'
      }

      let count = 0
      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        count++
      }
      expect(count).to.equal(2)

      // Validate data
      const storedPeer = peerStore.get(peer)
      expect(storedPeer.id.toB58String()).to.eql(peer.toB58String())
      expect(storedPeer.protocols).to.have.members(protocols)
      expect(storedPeer.addresses.map((a) => a.multiaddr.toString())).to.have.members([multiaddrs[0].toString()])
      expect(storedPeer.addresses.map((a) => a.isCertified)).to.have.members([false])
    })

    it('should load content to the peerStore when restart but not put in datastore again', async () => {
      const spyDs = sinon.spy(datastore, 'batch')
      const peers = await peerUtils.createPeerId({ number: 2 })
      const commitSpy = sinon.spy(peerStore, '_commitData')
      const multiaddrs = [
        new Multiaddr('/ip4/156.10.1.22/tcp/1000'),
        new Multiaddr('/ip4/156.10.1.23/tcp/1000')
      ]
      const protocols = ['/ping/1.0.0']

      await peerStore.start()

      // AddressBook
      peerStore.addressBook.set(peers[0], [multiaddrs[0]])
      peerStore.addressBook.set(peers[1], [multiaddrs[1]])

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      // KeyBook
      peerStore.keyBook.set(peers[0], peers[0].pubKey)
      peerStore.keyBook.set(peers[1], peers[1].pubKey)

      // no batch commit as public key inline

      // ProtoBook
      peerStore.protoBook.set(peers[0], protocols)
      peerStore.protoBook.set(peers[1], protocols)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      // MetadataBook
      peerStore.metadataBook.set(peers[0], 'location', uint8ArrayFromString('earth'))

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      expect(spyDs).to.have.property('callCount', 5) // 2 Address + 2 Proto + 1 Metadata
      expect(peerStore.peers.size).to.equal(2)

      await peerStore.stop()
      peerStore.keyBook.data.clear()
      peerStore.addressBook.data.clear()
      peerStore.protoBook.data.clear()

      // Load on restart
      const spy = sinon.spy(peerStore, '_processDatastoreEntry')

      await peerStore.start()

      expect(spy).to.have.property('callCount', 5)
      expect(spyDs).to.have.property('callCount', 5)

      expect(peerStore.peers.size).to.equal(2)
      expect(peerStore.addressBook.data.size).to.equal(2)
      expect(peerStore.keyBook.data.size).to.equal(0)
      expect(peerStore.protoBook.data.size).to.equal(2)
      expect(peerStore.metadataBook.data.size).to.equal(1)
    })

    it('should delete content from the datastore on delete', async () => {
      const [peer] = await peerUtils.createPeerId()
      const multiaddrs = [new Multiaddr('/ip4/156.10.1.22/tcp/1000')]
      const protocols = ['/ping/1.0.0']
      const commitSpy = sinon.spy(peerStore, '_commitData')

      await peerStore.start()

      // AddressBook
      peerStore.addressBook.set(peer, multiaddrs)
      // ProtoBook
      peerStore.protoBook.set(peer, protocols)
      // MetadataBook
      peerStore.metadataBook.set(peer, 'location', uint8ArrayFromString('earth'))

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      const spyDs = sinon.spy(datastore, 'batch')
      const spyAddressBook = sinon.spy(peerStore.addressBook, 'delete')
      const spyKeyBook = sinon.spy(peerStore.keyBook, 'delete')
      const spyProtoBook = sinon.spy(peerStore.protoBook, 'delete')
      const spyMetadataBook = sinon.spy(peerStore.metadataBook, 'delete')

      // Delete from PeerStore
      peerStore.delete(peer)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      await peerStore.stop()

      expect(spyAddressBook).to.have.property('callCount', 1)
      expect(spyKeyBook).to.have.property('callCount', 1)
      expect(spyProtoBook).to.have.property('callCount', 1)
      expect(spyMetadataBook).to.have.property('callCount', 1)
      expect(spyDs).to.have.property('callCount', 3)

      // Should have zero peer records stored in the datastore
      const queryParams = {
        prefix: '/peers/'
      }

      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        throw new Error('Datastore should be empty')
      }
    })

    it('should store certified peer records after peer marked as dirty (threshold 1)', async () => {
      const [peerId] = await peerUtils.createPeerId()
      const multiaddrs = [new Multiaddr('/ip4/156.10.1.22/tcp/1000')]
      const spyDirty = sinon.spy(peerStore, '_addDirtyPeer')
      const spyDs = sinon.spy(datastore, 'batch')
      const commitSpy = sinon.spy(peerStore, '_commitData')

      await peerStore.start()

      const peerRecord = new PeerRecord({
        peerId,
        multiaddrs
      })
      const envelope = await Envelope.seal(peerRecord, peerId)

      // consume peer record
      const consumed = peerStore.addressBook.consumePeerRecord(envelope)
      expect(consumed).to.eql(true)
      expect(spyDirty).to.have.property('callCount', 1) // Address
      expect(spyDs).to.have.property('callCount', 1)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      // Should have three peer records stored in the datastore
      const queryParams = {
        prefix: '/peers/'
      }

      let count = 0
      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        count++
      }
      expect(count).to.equal(1)

      // Validate data
      const storedPeer = peerStore.get(peerId)
      expect(storedPeer.id.toB58String()).to.eql(peerId.toB58String())
      expect(storedPeer.addresses.map((a) => a.multiaddr.toString())).to.have.members([multiaddrs[0].toString()])
      expect(storedPeer.addresses.map((a) => a.isCertified)).to.have.members([true])
    })

    it('should load certified peer records to the peerStore when restart but not put in datastore again', async () => {
      const spyDs = sinon.spy(datastore, 'batch')
      const peers = await peerUtils.createPeerId({ number: 2 })
      const commitSpy = sinon.spy(peerStore, '_commitData')
      const multiaddrs = [
        new Multiaddr('/ip4/156.10.1.22/tcp/1000'),
        new Multiaddr('/ip4/156.10.1.23/tcp/1000')
      ]
      const peerRecord0 = new PeerRecord({
        peerId: peers[0],
        multiaddrs: [multiaddrs[0]]
      })
      const envelope0 = await Envelope.seal(peerRecord0, peers[0])
      const peerRecord1 = new PeerRecord({
        peerId: peers[1],
        multiaddrs: [multiaddrs[1]]
      })
      const envelope1 = await Envelope.seal(peerRecord1, peers[1])

      await peerStore.start()

      // AddressBook
      let consumed = peerStore.addressBook.consumePeerRecord(envelope0)
      expect(consumed).to.eql(true)
      consumed = peerStore.addressBook.consumePeerRecord(envelope1)
      expect(consumed).to.eql(true)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      expect(spyDs).to.have.property('callCount', 2) // 2 Address + 2 Key + 2 Proto + 1 Metadata
      expect(peerStore.peers.size).to.equal(2)

      await peerStore.stop()
      peerStore.addressBook.data.clear()

      // Load on restart
      const spy = sinon.spy(peerStore, '_processDatastoreEntry')

      await peerStore.start()

      expect(spy).to.have.property('callCount', 2)
      expect(spyDs).to.have.property('callCount', 2)

      expect(peerStore.peers.size).to.equal(2)
      expect(peerStore.addressBook.data.size).to.equal(2)

      expect(peerStore.addressBook.getRawEnvelope(peers[0])).to.exist()
      expect(peerStore.addressBook.getRawEnvelope(peers[1])).to.exist()

      // Validate stored envelopes
      const storedEnvelope0 = await peerStore.addressBook.getPeerRecord(peers[0])
      expect(envelope0.equals(storedEnvelope0)).to.eql(true)

      const storedEnvelope1 = await peerStore.addressBook.getPeerRecord(peers[1])
      expect(envelope1.equals(storedEnvelope1)).to.eql(true)

      // Validate multiaddrs
      const storedPeer0 = peerStore.get(peers[0])
      expect(storedPeer0.id.toB58String()).to.eql(peers[0].toB58String())
      expect(storedPeer0.addresses.map((a) => a.multiaddr.toString())).to.have.members([multiaddrs[0].toString()])
      expect(storedPeer0.addresses.map((a) => a.isCertified)).to.have.members([true])

      const storedPeer1 = peerStore.get(peers[1])
      expect(storedPeer1.id.toB58String()).to.eql(peers[1].toB58String())
      expect(storedPeer1.addresses.map((a) => a.multiaddr.toString())).to.have.members([multiaddrs[1].toString()])
      expect(storedPeer1.addresses.map((a) => a.isCertified)).to.have.members([true])
    })

    it('should delete certified peer records from the datastore on delete', async () => {
      const [peer] = await peerUtils.createPeerId()
      const multiaddrs = [new Multiaddr('/ip4/156.10.1.22/tcp/1000')]
      const commitSpy = sinon.spy(peerStore, '_commitData')

      await peerStore.start()

      // AddressBook
      const peerRecord = new PeerRecord({
        peerId: peer,
        multiaddrs
      })
      const envelope = await Envelope.seal(peerRecord, peer)

      // consume peer record
      const consumed = peerStore.addressBook.consumePeerRecord(envelope)
      expect(consumed).to.eql(true)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)
      expect(peerStore.addressBook.getRawEnvelope(peer)).to.exist()

      const spyDs = sinon.spy(datastore, 'batch')
      const spyAddressBook = sinon.spy(peerStore.addressBook, 'delete')

      // Delete from PeerStore
      peerStore.delete(peer)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      await peerStore.stop()

      expect(spyAddressBook).to.have.property('callCount', 1)
      expect(spyDs).to.have.property('callCount', 1)

      // Should have zero peer records stored in the datastore
      const queryParams = {
        prefix: '/peers/'
      }

      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        throw new Error('Datastore should be empty')
      }

      expect(peerStore.addressBook.getRawEnvelope(peer)).to.not.exist()
    })
  })

  describe('setup with content not stored per change (threshold 2)', () => {
    beforeEach(() => {
      datastore = new MemoryDatastore()
      peerStore = new PeerStore({ datastore, peerId, threshold: 2 })
    })

    afterEach(() => peerStore.stop())

    it('should not commit until threshold is reached', async () => {
      const spyDirty = sinon.spy(peerStore, '_addDirtyPeer')
      const spyDirtyMetadata = sinon.spy(peerStore, '_addDirtyPeerMetadata')
      const spyDs = sinon.spy(datastore, 'batch')
      const commitSpy = sinon.spy(peerStore, '_commitData')

      const peers = await peerUtils.createPeerId({ number: 2 })

      const multiaddrs = [new Multiaddr('/ip4/156.10.1.22/tcp/1000')]
      const protocols = ['/ping/1.0.0']

      await peerStore.start()

      expect(spyDirty).to.have.property('callCount', 0)
      expect(spyDs).to.have.property('callCount', 0)

      // Add Peer0 data in multiple books
      peerStore.addressBook.set(peers[0], multiaddrs)
      peerStore.protoBook.set(peers[0], protocols)
      peerStore.metadataBook.set(peers[0], 'location', uint8ArrayFromString('earth'))

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      // Remove data from the same Peer
      peerStore.addressBook.delete(peers[0])

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      expect(spyDirty).to.have.property('callCount', 3) // 2 AddrBook ops, 1 ProtoBook op
      expect(spyDirtyMetadata).to.have.property('callCount', 1) // 1 MetadataBook op
      expect(peerStore._dirtyPeers.size).to.equal(1)
      expect(spyDs).to.have.property('callCount', 0)

      const queryParams = {
        prefix: '/peers/'
      }
      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        throw new Error('Datastore should be empty')
      }

      // Add data for second book
      peerStore.addressBook.set(peers[1], multiaddrs)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      expect(spyDirty).to.have.property('callCount', 4)
      expect(spyDirtyMetadata).to.have.property('callCount', 1)
      expect(spyDs).to.have.property('callCount', 1)

      // Should have three peer records stored in the datastore
      let count = 0
      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        count++
      }
      expect(count).to.equal(3)
      expect(peerStore.peers.size).to.equal(2)
    })

    it('should commit on stop if threshold was not reached', async () => {
      const spyDirty = sinon.spy(peerStore, '_addDirtyPeer')
      const spyDs = sinon.spy(datastore, 'batch')

      const protocols = ['/ping/1.0.0']
      const [peer] = await peerUtils.createPeerId()

      await peerStore.start()

      // Add Peer data in a book
      peerStore.protoBook.set(peer, protocols)

      expect(spyDs).to.have.property('callCount', 0)
      expect(spyDirty).to.have.property('callCount', 1) // ProtoBook
      expect(peerStore._dirtyPeers.size).to.equal(1)

      const queryParams = {
        prefix: '/peers/'
      }
      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        throw new Error('Datastore should be empty')
      }

      await peerStore.stop()

      expect(spyDirty).to.have.property('callCount', 1)
      expect(spyDs).to.have.property('callCount', 1)
      expect(peerStore._dirtyPeers.size).to.equal(0) // Reset

      // Should have one peer record stored in the datastore
      let count = 0
      for await (const _ of datastore.query(queryParams)) { // eslint-disable-line
        count++
      }
      expect(count).to.equal(1)
      expect(peerStore.peers.size).to.equal(1)
    })
  })
})

describe('libp2p.peerStore (Persisted)', () => {
  describe('disabled by default', () => {
    let libp2p

    before(async () => {
      [libp2p] = await peerUtils.createPeer({
        started: false
      })
    })

    afterEach(() => libp2p.stop())

    it('should not have have persistence capabilities', async () => {
      await libp2p.start()
      expect(libp2p.peerStore._dirtyPeers).to.not.exist()
      expect(libp2p.peerStore.threshold).to.not.exist()
    })
  })

  describe('enabled', () => {
    let libp2p
    let memoryDatastore

    beforeEach(async () => {
      memoryDatastore = new MemoryDatastore()
      ;[libp2p] = await peerUtils.createPeer({
        started: false,
        config: {
          datastore: memoryDatastore,
          peerStore: {
            persistence: true,
            threshold: 2 // trigger on  second peer changed
          }
        }
      })
    })

    afterEach(() => libp2p.stop())

    it('should start on libp2p start and load content', async () => {
      const spyPeerStore = sinon.spy(libp2p.peerStore, 'start')
      const spyDs = sinon.spy(memoryDatastore, 'query')

      await libp2p.start()
      expect(spyPeerStore).to.have.property('callCount', 1)
      expect(spyDs).to.have.property('callCount', 1)
    })

    it('should load content to the peerStore when a new node is started with the same datastore', async () => {
      const commitSpy = sinon.spy(libp2p.peerStore, '_commitData')
      const peers = await peerUtils.createPeerId({ number: 3 })
      const multiaddrs = [
        new Multiaddr('/ip4/156.10.1.22/tcp/1000'),
        new Multiaddr('/ip4/156.10.1.23/tcp/1000')
      ]
      const protocols = ['/ping/1.0.0']

      await libp2p.start()

      // AddressBook
      libp2p.peerStore.addressBook.set(peers[1], [multiaddrs[0]])
      libp2p.peerStore.addressBook.set(peers[2], [multiaddrs[1]])

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      // ProtoBook
      libp2p.peerStore.protoBook.set(peers[1], protocols)
      libp2p.peerStore.protoBook.set(peers[2], protocols)

      // let batch commit complete
      await Promise.all(commitSpy.returnValues)

      expect(libp2p.peerStore.peers.size).to.equal(2)

      await libp2p.stop()

      // Use a new node with the previously populated datastore
      const [newNode] = await peerUtils.createPeer({
        started: false,
        config: {
          datastore: memoryDatastore,
          peerStore: {
            persistence: true
          },
          config: {
            peerDiscovery: {
              autoDial: false
            }
          }
        }
      })

      expect(newNode.peerStore.peers.size).to.equal(0)

      const spy = sinon.spy(newNode.peerStore, '_processDatastoreEntry')

      await newNode.start()

      expect(spy).to.have.property('callCount', 4) // 4 datastore entries

      expect(newNode.peerStore.peers.size).to.equal(2)

      // Validate data
      const peer0 = newNode.peerStore.get(peers[1])
      expect(peer0.id.toB58String()).to.eql(peers[1].toB58String())
      expect(peer0.protocols).to.have.members(protocols)
      expect(peer0.addresses.map((a) => a.multiaddr.toString())).to.have.members([multiaddrs[0].toString()])

      const peer1 = newNode.peerStore.get(peers[2])
      expect(peer1.id.toB58String()).to.eql(peers[2].toB58String())
      expect(peer1.protocols).to.have.members(protocols)
      expect(peer1.addresses.map((a) => a.multiaddr.toString())).to.have.members([multiaddrs[1].toString()])

      await newNode.stop()
    })
  })
})
