import { base32 } from './bases/base32.ts'
import { base36 } from './bases/base36.ts'
import { base58btc } from './bases/base58.ts'
import { coerce, toArrayBufferBackedArray } from './bytes.ts'
import * as Digest from './hashes/digest.ts'
import * as varint from './varint.ts'
import type * as API from './link/interface.ts'

// This way TS will also expose all the types from module
export * from './link/interface.ts'

export function format <T extends API.Link<unknown, number, number, API.Version>, Prefix extends string> (link: T, base?: API.MultibaseEncoder<Prefix>): API.ToString<T, Prefix> {
  const { bytes, version } = link
  switch (version) {
    case 0:
      return toStringV0(
        bytes,
        baseCache(link),
        base as API.MultibaseEncoder<'z'> ?? base58btc.encoder
      )
    default:
      return toStringV1(
        bytes,
        baseCache(link),
        (base ?? base32.encoder) as API.MultibaseEncoder<Prefix>
      )
  }
}

export function toJSON <Link extends API.UnknownLink> (link: Link): API.LinkJSON<Link> {
  return {
    '/': format(link)
  }
}

export function fromJSON <Link extends API.UnknownLink> (json: API.LinkJSON<Link>): CID<unknown, number, number, API.Version> {
  return CID.parse(json['/'])
}

const cache = new WeakMap<API.UnknownLink, Map<string, string>>()

function baseCache (cid: API.UnknownLink): Map<string, string> {
  const baseCache = cache.get(cid)
  if (baseCache == null) {
    const baseCache = new Map()
    cache.set(cid, baseCache)
    return baseCache
  }
  return baseCache
}

export class CID<Data = unknown, Format extends number = number, Alg extends number = number, Version extends API.Version = API.Version> implements API.Link<Data, Format, Alg, Version> {
  readonly code: Format
  readonly version: Version
  readonly multihash: API.MultihashDigest<Alg>
  readonly bytes: Uint8Array<ArrayBuffer>
  readonly '/': Uint8Array<ArrayBuffer>

  /**
   * @param version - Version of the CID
   * @param code - Code of the codec content is encoded in, see https://github.com/multiformats/multicodec/blob/master/table.csv
   * @param multihash - (Multi)hash of the of the content.
   */
  constructor (version: Version, code: Format, multihash: API.MultihashDigest<Alg>, bytes: Uint8Array) {
    this.code = code
    this.version = version
    this.multihash = multihash
    this.bytes = toArrayBufferBackedArray(bytes)

    // flag to serializers that this is a CID and
    // should be treated specially
    this['/'] = this.bytes
  }

  /**
   * Signalling `cid.asCID === cid` has been replaced with `cid['/'] === cid.bytes`
   * please either use `CID.asCID(cid)` or switch to new signalling mechanism
   *
   * @deprecated
   */
  get asCID (): this {
    return this
  }

  // ArrayBufferView
  get byteOffset (): number {
    return this.bytes.byteOffset
  }

  // ArrayBufferView
  get byteLength (): number {
    return this.bytes.byteLength
  }

  toV0 (): CID<Data, API.DAG_PB, API.SHA_256, 0> {
    switch (this.version) {
      case 0: {
        return this as CID<Data, API.DAG_PB, API.SHA_256, 0>
      }
      case 1: {
        const { code, multihash } = this

        if (code !== DAG_PB_CODE) {
          throw new Error('Cannot convert a non dag-pb CID to CIDv0')
        }

        // sha2-256
        if (multihash.code !== SHA_256_CODE) {
          throw new Error('Cannot convert non sha2-256 multihash CID to CIDv0')
        }

        return (
          CID.createV0(
            multihash as API.MultihashDigest<API.SHA_256>
          )
        )
      }
      default: {
        throw Error(
          `Can not convert CID version ${this.version} to version 0. This is a bug please report`
        )
      }
    }
  }

  toV1 (): CID<Data, Format, Alg, 1> {
    switch (this.version) {
      case 0: {
        const { code, digest } = this.multihash
        const multihash = Digest.create(code, digest)
        return (
          CID.createV1(this.code, multihash)
        )
      }
      case 1: {
        return this as CID<Data, Format, Alg, 1>
      }
      default: {
        throw Error(
          `Can not convert CID version ${this.version} to version 1. This is a bug please report`
        )
      }
    }
  }

  equals (other: unknown): other is CID<Data, Format, Alg, Version> {
    return CID.equals(this, other)
  }

  static equals <Data, Format extends number, Alg extends number, Version extends API.Version>(self: API.Link<Data, Format, Alg, Version>, other: unknown): other is CID {
    const unknown = other as { code?: unknown, version?: unknown, multihash?: unknown }
    return (
      unknown != null &&
      self.code === unknown.code &&
      self.version === unknown.version &&
      Digest.equals(self.multihash, unknown.multihash)
    )
  }

  toString (base?: API.MultibaseEncoder<string>): string {
    return format(this, base)
  }

  toJSON (): API.LinkJSON<this> {
    return { '/': format(this) }
  }

  link (): this {
    return this
  }

  readonly [Symbol.toStringTag] = 'CID';

  // Legacy

  [Symbol.for('nodejs.util.inspect.custom')] (): string {
    return `CID(${this.toString()})`
  }

  /**
   * Takes any input `value` and returns a `CID` instance if it was
   * a `CID` otherwise returns `null`. If `value` is instanceof `CID`
   * it will return value back. If `value` is not instance of this CID
   * class, but is compatible CID it will return new instance of this
   * `CID` class. Otherwise returns null.
   *
   * This allows two different incompatible versions of CID library to
   * co-exist and interop as long as binary interface is compatible.
   */
  static asCID <Data, Format extends number, Alg extends number, Version extends API.Version, U>(input: API.Link<Data, Format, Alg, Version> | U): CID<Data, Format, Alg, Version> | null {
    if (input == null) {
      return null
    }

    const value = input as any
    if (value instanceof CID) {
      // If value is instance of CID then we're all set.
      return value
    } else if ((value['/'] != null && value['/'] === value.bytes) || value.asCID === value) {
      // If value isn't instance of this CID class but `this.asCID === this` or
      // `value['/'] === value.bytes` is true it is CID instance coming from a
      // different implementation (diff version or duplicate). In that case we
      // rebase it to this `CID` implementation so caller is guaranteed to get
      // instance with expected API.
      const { version, code, multihash, bytes } = value
      return new CID(
        version,
        code,
        multihash as API.MultihashDigest<Alg>,
        bytes ?? encodeCID(version, code, multihash.bytes)
      )
    } else if (value[cidSymbol] === true) {
      // If value is a CID from older implementation that used to be tagged via
      // symbol we still rebase it to the this `CID` implementation by
      // delegating that to a constructor.
      const { version, multihash, code } = value
      const digest = Digest.decode(multihash) as API.MultihashDigest<Alg>
      return CID.create(version, code, digest)
    } else {
      // Otherwise value is not a CID (or an incompatible version of it) in
      // which case we return `null`.
      return null
    }
  }

  /**
   * @param version - Version of the CID
   * @param code - Code of the codec content is encoded in, see https://github.com/multiformats/multicodec/blob/master/table.csv
   * @param digest - (Multi)hash of the of the content.
   */
  static create <Data, Format extends number, Alg extends number, Version extends API.Version>(version: Version, code: Format, digest: API.MultihashDigest<Alg>): CID<Data, Format, Alg, Version> {
    if (typeof code !== 'number') {
      throw new Error('String codecs are no longer supported')
    }

    if (!(digest.bytes instanceof Uint8Array)) {
      throw new Error('Invalid digest')
    }

    switch (version) {
      case 0: {
        if (code !== DAG_PB_CODE) {
          throw new Error(
            `Version 0 CID must use dag-pb (code: ${DAG_PB_CODE}) block encoding`
          )
        } else {
          return new CID(version, code, digest, digest.bytes)
        }
      }
      case 1: {
        const bytes = encodeCID(version, code, digest.bytes)
        return new CID(version, code, digest, bytes)
      }
      default: {
        throw new Error('Invalid version')
      }
    }
  }

  /**
   * Simplified version of `create` for CIDv0.
   */
  static createV0 <T = unknown>(digest: API.MultihashDigest<typeof SHA_256_CODE>): CID<T, typeof DAG_PB_CODE, typeof SHA_256_CODE, 0> {
    return CID.create(0, DAG_PB_CODE, digest)
  }

  /**
   * Simplified version of `create` for CIDv1.
   *
   * @param code - Content encoding format code.
   * @param digest - Multihash of the content.
   */
  static createV1 <Data, Code extends number, Alg extends number>(code: Code, digest: API.MultihashDigest<Alg>): CID<Data, Code, Alg, 1> {
    return CID.create(1, code, digest)
  }

  /**
   * Decoded a CID from its binary representation. The byte array must contain
   * only the CID with no additional bytes.
   *
   * An error will be thrown if the bytes provided do not contain a valid
   * binary representation of a CID.
   */
  static decode <Data, Code extends number, Alg extends number, Version extends API.Version>(bytes: API.ByteView<API.Link<Data, Code, Alg, Version>>): CID<Data, Code, Alg, Version> {
    const [cid, remainder] = CID.decodeFirst(bytes)
    if (remainder.length !== 0) {
      throw new Error('Incorrect length')
    }
    return cid
  }

  /**
   * Decoded a CID from its binary representation at the beginning of a byte
   * array.
   *
   * Returns an array with the first element containing the CID and the second
   * element containing the remainder of the original byte array. The remainder
   * will be a zero-length byte array if the provided bytes only contained a
   * binary CID representation.
   */
  static decodeFirst <T, C extends number, A extends number, V extends API.Version>(bytes: API.ByteView<API.Link<T, C, A, V>>): [CID<T, C, A, V>, Uint8Array] {
    const specs = CID.inspectBytes(bytes)
    const prefixSize = specs.size - specs.multihashSize
    const multihashBytes = coerce(
      bytes.subarray(prefixSize, prefixSize + specs.multihashSize)
    )
    if (multihashBytes.byteLength !== specs.multihashSize) {
      throw new Error('Incorrect length')
    }
    const digestBytes = multihashBytes.subarray(
      specs.multihashSize - specs.digestSize
    )
    const digest = new Digest.Digest(
      specs.multihashCode,
      specs.digestSize,
      digestBytes,
      multihashBytes
    )
    const cid =
      specs.version === 0
        ? CID.createV0(digest as API.MultihashDigest<API.SHA_256>)
        : CID.createV1(specs.codec, digest)
    return [cid as CID<T, C, A, V>, bytes.subarray(specs.size)]
  }

  /**
   * Inspect the initial bytes of a CID to determine its properties.
   *
   * Involves decoding up to 4 varints. Typically this will require only 4 to 6
   * bytes but for larger multicodec code values and larger multihash digest
   * lengths these varints can be quite large. It is recommended that at least
   * 10 bytes be made available in the `initialBytes` argument for a complete
   * inspection.
   */
  static inspectBytes <T, C extends number, A extends number, V extends API.Version>(initialBytes: API.ByteView<API.Link<T, C, A, V>>): { version: V, codec: C, multihashCode: A, digestSize: number, multihashSize: number, size: number } {
    let offset = 0
    const next = (): number => {
      const [i, length] = varint.decode(initialBytes.subarray(offset))
      offset += length
      return i
    }

    let version = next() as V
    let codec = DAG_PB_CODE as C
    if (version as number === 18) {
      // CIDv0
      version = 0 as V
      offset = 0
    } else {
      codec = next() as C
    }

    if (version !== 0 && version !== 1) {
      throw new RangeError(`Invalid CID version ${version}`)
    }

    const prefixSize = offset
    const multihashCode = next() as A // multihash code
    const digestSize = next() // multihash length
    const size = offset + digestSize
    const multihashSize = size - prefixSize

    return { version, codec, multihashCode, digestSize, multihashSize, size }
  }

  /**
   * Takes cid in a string representation and creates an instance. If `base`
   * decoder is not provided will use a default from the configuration. It will
   * throw an error if encoding of the CID is not compatible with supplied (or
   * a default decoder).
   */
  static parse <Prefix extends string, Data, Code extends number, Alg extends number, Version extends API.Version>(source: API.ToString<API.Link<Data, Code, Alg, Version>, Prefix>, base?: API.MultibaseDecoder<Prefix>): CID<Data, Code, Alg, Version> {
    const [prefix, bytes] = parseCIDtoBytes(source, base)

    const cid = CID.decode(bytes)

    if (cid.version === 0 && source[0] !== 'Q') {
      throw Error('Version 0 CID string must not include multibase prefix')
    }

    // Cache string representation to avoid computing it on `this.toString()`
    baseCache(cid).set(prefix, source)

    return cid
  }
}

function parseCIDtoBytes <Prefix extends string, Data, Code extends number, Alg extends number, Version extends API.Version> (source: API.ToString<API.Link<Data, Code, Alg, Version>, Prefix>, base?: API.MultibaseDecoder<Prefix>): [Prefix, API.ByteView<API.Link<Data, Code, Alg, Version>>] {
  switch (source[0]) {
    // CIDv0 is parsed differently
    case 'Q': {
      const decoder = base ?? base58btc
      return [
        base58btc.prefix as Prefix,
        decoder.decode(`${base58btc.prefix}${source}`)
      ]
    }
    case base58btc.prefix: {
      const decoder = base ?? base58btc
      return [base58btc.prefix as Prefix, decoder.decode(source)]
    }
    case base32.prefix: {
      const decoder = base ?? base32
      return [base32.prefix as Prefix, decoder.decode(source)]
    }
    case base36.prefix: {
      const decoder = base ?? base36
      return [base36.prefix as Prefix, decoder.decode(source)]
    }
    default: {
      if (base == null) {
        throw Error(
          'To parse non base32, base36 or base58btc encoded CID multibase decoder must be provided'
        )
      }
      return [source[0] as Prefix, base.decode(source)]
    }
  }
}

function toStringV0 (bytes: Uint8Array, cache: Map<string, string>, base: API.MultibaseEncoder<'z'>): string {
  const { prefix } = base
  if (prefix !== base58btc.prefix) {
    throw Error(`Cannot string encode V0 in ${base.name} encoding`)
  }

  const cid = cache.get(prefix)
  if (cid == null) {
    const cid = base.encode(bytes).slice(1)
    cache.set(prefix, cid)
    return cid
  } else {
    return cid
  }
}

function toStringV1 <Prefix extends string> (bytes: Uint8Array, cache: Map<string, string>, base: API.MultibaseEncoder<Prefix>): string {
  const { prefix } = base
  const cid = cache.get(prefix)
  if (cid == null) {
    const cid = base.encode(bytes)
    cache.set(prefix, cid)
    return cid
  } else {
    return cid
  }
}

const DAG_PB_CODE = 0x70
const SHA_256_CODE = 0x12

function encodeCID (version: API.Version, code: number, multihash: Uint8Array): Uint8Array {
  const codeOffset = varint.encodingLength(version)
  const hashOffset = codeOffset + varint.encodingLength(code)
  const bytes = new Uint8Array(hashOffset + multihash.byteLength)
  varint.encodeTo(version, bytes, 0)
  varint.encodeTo(code, bytes, codeOffset)
  bytes.set(multihash, hashOffset)
  return bytes
}

const cidSymbol = Symbol.for('@ipld/js-cid/CID')
