import { publicKeyFromProtobuf } from '@libp2p/crypto/keys'
import { InvalidMultihashError } from '@libp2p/interface'
import { logger } from '@libp2p/logger'
import * as cborg from 'cborg'
import { base36 } from 'multiformats/bases/base36'
import { CID } from 'multiformats/cid'
import * as Digest from 'multiformats/hashes/digest'
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
import { InvalidRecordDataError, InvalidValueError, SignatureVerificationError, UnsupportedValidityError } from './errors.ts'
import { IpnsEntry } from './pb/ipns.ts'
import type { IPNSRecord, IPNSRecordV2, IPNSRecordData } from './index.ts'
import type { PublicKey } from '@libp2p/interface'
import type { MultihashDigest } from 'multiformats/cid'

const log = logger('ipns:utils')
const IPNS_PREFIX = uint8ArrayFromString('/ipns/')
const LIBP2P_CID_CODEC = 0x72
const IDENTITY_CODEC = 0x0
const SHA2_256_CODEC = 0x12

/**
 * Extracts a public key from the passed PeerId, falling back to the pubKey
 * embedded in the ipns record
 */
export function extractPublicKeyFromIPNSRecord (record: IPNSRecord | IPNSRecordV2): PublicKey | undefined {
  let pubKey: PublicKey | undefined

  if (record.pubKey != null) {
    try {
      pubKey = publicKeyFromProtobuf(record.pubKey)
    } catch (err) {
      log.error(err)
      throw err
    }
  }

  if (pubKey != null) {
    return pubKey
  }
}

/**
 * Utility for creating the record data for being signed
 */
export function ipnsRecordDataForV1Sig (value: Uint8Array, validityType: IpnsEntry.ValidityType, validity: Uint8Array): Uint8Array {
  const validityTypeBuffer = uint8ArrayFromString(validityType)

  return uint8ArrayConcat([value, validity, validityTypeBuffer])
}

/**
 * Utility for creating the record data for being signed
 */
export function ipnsRecordDataForV2Sig (data: Uint8Array): Uint8Array {
  const entryData = uint8ArrayFromString('ipns-signature:')

  return uint8ArrayConcat([entryData, data])
}

export function marshalIPNSRecord (obj: IPNSRecord | IPNSRecordV2): Uint8Array {
  if ('signatureV1' in obj) {
    return IpnsEntry.encode({
      value: uint8ArrayFromString(obj.value),
      signatureV1: obj.signatureV1,
      validityType: obj.validityType,
      validity: uint8ArrayFromString(obj.validity),
      sequence: obj.sequence,
      ttl: obj.ttl,
      pubKey: obj.pubKey,
      signatureV2: obj.signatureV2,
      data: obj.data
    })
  } else {
    return IpnsEntry.encode({
      pubKey: obj.pubKey,
      signatureV2: obj.signatureV2,
      data: obj.data
    })
  }
}

export function unmarshalIPNSRecord (buf: Uint8Array): IPNSRecord {
  const message = IpnsEntry.decode(buf)

  // protobufjs returns bigints as numbers
  if (message.sequence != null) {
    message.sequence = BigInt(message.sequence)
  }

  // protobufjs returns bigints as numbers
  if (message.ttl != null) {
    message.ttl = BigInt(message.ttl)
  }

  // Check if we have the data field. If we don't, we fail. We've been producing
  // V1+V2 records for quite a while and we don't support V1-only records during
  // validation any more
  if (message.signatureV2 == null || message.data == null) {
    throw new SignatureVerificationError('Missing data or signatureV2')
  }

  const data = parseCborData(message.data)
  const value = normalizeByteValue(data.Value)
  const validity = uint8ArrayToString(data.Validity)

  if (message.value != null && message.signatureV1 != null) {
    // V1+V2
    validateCborDataMatchesPbData(message)

    return {
      value,
      validityType: IpnsEntry.ValidityType.EOL,
      validity,
      sequence: data.Sequence,
      ttl: data.TTL,
      pubKey: message.pubKey,
      signatureV1: message.signatureV1,
      signatureV2: message.signatureV2,
      data: message.data
    }
  } else if (message.signatureV2 != null) {
    // V2-only
    return {
      value,
      validityType: IpnsEntry.ValidityType.EOL,
      validity,
      sequence: data.Sequence,
      ttl: data.TTL,
      pubKey: message.pubKey,
      signatureV2: message.signatureV2,
      data: message.data
    }
  } else {
    throw new Error('invalid record: does not include signatureV1 or signatureV2')
  }
}

export function multihashToIPNSRoutingKey (digest: MultihashDigest<0x00 | 0x12>): Uint8Array {
  return uint8ArrayConcat([
    IPNS_PREFIX,
    digest.bytes
  ])
}

export function multihashFromIPNSRoutingKey (key: Uint8Array): MultihashDigest<0x00 | 0x12> {
  const digest = Digest.decode(key.slice(IPNS_PREFIX.length))

  if (!isCodec(digest, IDENTITY_CODEC) && !isCodec(digest, SHA2_256_CODEC)) {
    throw new InvalidMultihashError('Multihash in IPNS key was not identity or sha2-256')
  }

  return digest
}

export function createCborData (value: Uint8Array, validityType: IpnsEntry.ValidityType, validity: Uint8Array, sequence: bigint, ttl: bigint): Uint8Array {
  let ValidityType

  if (validityType === IpnsEntry.ValidityType.EOL) {
    ValidityType = 0
  } else {
    throw new UnsupportedValidityError('The validity type is unsupported')
  }

  const data = {
    Value: value,
    Validity: validity,
    ValidityType,
    Sequence: sequence,
    TTL: ttl
  }

  return cborg.encode(data)
}

export function parseCborData (buf: Uint8Array): IPNSRecordData {
  const data = cborg.decode(buf)

  if (data.ValidityType === 0) {
    data.ValidityType = IpnsEntry.ValidityType.EOL
  } else {
    throw new UnsupportedValidityError('The validity type is unsupported')
  }

  if (Number.isInteger(data.Sequence)) {
    // sequence must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range
    data.Sequence = BigInt(data.Sequence)
  }

  if (Number.isInteger(data.TTL)) {
    // ttl must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range
    data.TTL = BigInt(data.TTL)
  }

  return data
}

export function normalizeByteValue (value: Uint8Array): string {
  const string = uint8ArrayToString(value).trim()

  // if we have a path, check it is a valid path
  if (string.startsWith('/')) {
    return string
  }

  // try parsing what we have as CID bytes or a CID string
  try {
    return `/ipfs/${CID.decode(value).toV1().toString()}`
  } catch {
    // fall through
  }

  try {
    return `/ipfs/${CID.parse(string).toV1().toString()}`
  } catch {
    // fall through
  }

  throw new InvalidValueError('Value must be a valid content path starting with /')
}

/**
 * Normalizes the given record value. It ensures it is a PeerID, a CID or a
 * string starting with '/'. PeerIDs become `/ipns/${cidV1Libp2pKey}`,
 * CIDs become `/ipfs/${cidAsV1}`.
 */
export function normalizeValue (value?: CID | PublicKey | MultihashDigest<0x00 | 0x12> | string): string {
  if (value != null) {
    const cid = asCID(value)

    // if we have a CID, turn it into an ipfs path
    if (cid != null) {
      // PeerID encoded as a CID
      if (cid.code === LIBP2P_CID_CODEC) {
        return `/ipns/${cid.toString(base36)}`
      }

      return `/ipfs/${cid.toV1().toString()}`
    }

    if (hasBytes(value)) {
      return `/ipns/${base36.encode(value.bytes)}`
    }

    // if we have a path, check it is a valid path
    const string = value.toString().trim()

    if (string.startsWith('/') && string.length > 1) {
      return string
    }
  }

  throw new InvalidValueError('Value must be a valid content path starting with /')
}

function validateCborDataMatchesPbData (entry: IpnsEntry): void {
  if (entry.data == null) {
    throw new InvalidRecordDataError('Record data is missing')
  }

  const data = parseCborData(entry.data)

  if (!uint8ArrayEquals(data.Value, entry.value ?? new Uint8Array(0))) {
    throw new SignatureVerificationError('Field "value" did not match between protobuf and CBOR')
  }

  if (!uint8ArrayEquals(data.Validity, entry.validity ?? new Uint8Array(0))) {
    throw new SignatureVerificationError('Field "validity" did not match between protobuf and CBOR')
  }

  if (data.ValidityType !== entry.validityType) {
    throw new SignatureVerificationError('Field "validityType" did not match between protobuf and CBOR')
  }

  if (data.Sequence !== entry.sequence) {
    throw new SignatureVerificationError('Field "sequence" did not match between protobuf and CBOR')
  }

  if (data.TTL !== entry.ttl) {
    throw new SignatureVerificationError('Field "ttl" did not match between protobuf and CBOR')
  }
}

function hasBytes (obj?: any): obj is { bytes: Uint8Array } {
  return obj.bytes instanceof Uint8Array
}

function hasToCID (obj?: any): obj is { toCID(): CID } {
  return typeof obj?.toCID === 'function'
}

function asCID (obj?: any): CID | null {
  if (hasToCID(obj)) {
    return obj.toCID()
  }

  // try parsing as a CID string
  try {
    return CID.parse(obj)
  } catch {
    // fall through
  }

  return CID.asCID(obj)
}

export function isCodec <T extends number> (digest: MultihashDigest, codec: T): digest is MultihashDigest<T> {
  return digest.code === codec
}
