import { publicKeyFromProtobuf, publicKeyToProtobuf } from '@libp2p/crypto/keys'
import * as varint from 'uint8-varint'
import { Uint8ArrayList } from 'uint8arraylist'
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
import { fromString as uint8arraysFromString } from 'uint8arrays/from-string'
import { Envelope as Protobuf } from './envelope.js'
import { InvalidSignatureError } from './errors.js'
import type { Record, Envelope, PrivateKey, PublicKey } from '@libp2p/interface'

export interface RecordEnvelopeInit {
  publicKey: PublicKey
  payloadType: Uint8Array
  payload: Uint8Array
  signature: Uint8Array
}

export class RecordEnvelope implements Envelope {
  /**
   * Unmarshal a serialized Envelope protobuf message
   */
  static createFromProtobuf = async (data: Uint8Array | Uint8ArrayList): Promise<RecordEnvelope> => {
    const envelopeData = Protobuf.decode(data)
    const publicKey = publicKeyFromProtobuf(envelopeData.publicKey)

    return new RecordEnvelope({
      publicKey,
      payloadType: envelopeData.payloadType,
      payload: envelopeData.payload,
      signature: envelopeData.signature
    })
  }

  /**
   * Seal marshals the given Record, places the marshaled bytes inside an Envelope
   * and signs it with the given peerId's private key
   */
  static seal = async (record: Record, privateKey: PrivateKey): Promise<RecordEnvelope> => {
    if (privateKey == null) {
      throw new Error('Missing private key')
    }

    const domain = record.domain
    const payloadType = record.codec
    const payload = record.marshal()
    const signData = formatSignaturePayload(domain, payloadType, payload)
    const signature = await privateKey.sign(signData.subarray())

    return new RecordEnvelope({
      publicKey: privateKey.publicKey,
      payloadType,
      payload,
      signature
    })
  }

  /**
   * Open and certify a given marshaled envelope.
   * Data is unmarshaled and the signature validated for the given domain.
   */
  static openAndCertify = async (data: Uint8Array | Uint8ArrayList, domain: string): Promise<RecordEnvelope> => {
    const envelope = await RecordEnvelope.createFromProtobuf(data)
    const valid = await envelope.validate(domain)

    if (!valid) {
      throw new InvalidSignatureError('Envelope signature is not valid for the given domain')
    }

    return envelope
  }

  public publicKey: PublicKey
  public payloadType: Uint8Array
  public payload: Uint8Array
  public signature: Uint8Array
  public marshaled?: Uint8Array

  /**
   * The Envelope is responsible for keeping an arbitrary signed record
   * by a libp2p peer.
   */
  constructor (init: RecordEnvelopeInit) {
    const { publicKey, payloadType, payload, signature } = init

    this.publicKey = publicKey
    this.payloadType = payloadType
    this.payload = payload
    this.signature = signature
  }

  /**
   * Marshal the envelope content
   */
  marshal (): Uint8Array {
    if (this.marshaled == null) {
      this.marshaled = Protobuf.encode({
        publicKey: publicKeyToProtobuf(this.publicKey),
        payloadType: this.payloadType,
        payload: this.payload.subarray(),
        signature: this.signature
      })
    }

    return this.marshaled
  }

  /**
   * Verifies if the other Envelope is identical to this one
   */
  equals (other: Envelope): boolean {
    return uint8ArrayEquals(this.marshal(), other.marshal())
  }

  /**
   * Validate envelope data signature for the given domain
   */
  async validate (domain: string): Promise<boolean> {
    const signData = formatSignaturePayload(domain, this.payloadType, this.payload)

    return this.publicKey.verify(signData.subarray(), this.signature)
  }
}

/**
 * Helper function that prepares a Uint8Array to sign or verify a signature
 */
const formatSignaturePayload = (domain: string, payloadType: Uint8Array, payload: Uint8Array | Uint8ArrayList): Uint8ArrayList => {
  // When signing, a peer will prepare a Uint8Array by concatenating the following:
  // - The length of the domain separation string string in bytes
  // - The domain separation string, encoded as UTF-8
  // - The length of the payload_type field in bytes
  // - The value of the payload_type field
  // - The length of the payload field in bytes
  // - The value of the payload field

  const domainUint8Array = uint8arraysFromString(domain)
  const domainLength = varint.encode(domainUint8Array.byteLength)
  const payloadTypeLength = varint.encode(payloadType.length)
  const payloadLength = varint.encode(payload.length)

  return new Uint8ArrayList(
    domainLength,
    domainUint8Array,
    payloadTypeLength,
    payloadType,
    payloadLength,
    payload
  )
}
