import { bytesToString, type Hex, hexToBytes, pad, size, stringToBytes, toHex } from "viem"
import { unitsToDecimal } from "./decimal"
import { InvalidValueError, UnknownAttributeTypeError } from "./errors"
import {
  type ArkivValue,
  INDEXED_FLAG,
  makeValue,
  type StrValue,
  TYPE_IDS,
  type TypeTag,
} from "./types"
import { addr, bool, dec, decUnits, i32, key, u64, u256 } from "./values"

function strFromChain(value: string): StrValue {
  return makeValue("str", value)
}

/**
 * The `typeId` marking a tombstone — an attribute being unset. It carries a zero-length value and
 * is legal only in a patch's mutation list, never in a create.
 */
const TOMBSTONE_TYPE_ID = 0

/**
 * The node's `bytes32` type, which this SDK does not expose. Its values are 32 bytes, as a `key`'s
 * are, so they are read as `key`s.
 */
const BYTES32_TYPE_ID = 6
const BYTES32_TAG = "bytes32"

/**
 * An attribute in its ABI form: `(Ident32 name, uint8 typeId, bytes value)`.
 *
 * The low seven bits of `typeId` select the type, and bit 7 ({@link INDEXED_FLAG}) says whether
 * the node indexes the attribute. The `value` encoding is selected by the type: an exact 32-byte
 * word for the word types, the raw bytes for `str` and `bytes`, and zero-length for a tombstone.
 */
export type AbiAttribute = {
  name: Hex
  typeId: number
  value: Hex
}

/**
 * Encodes a typed value into the `bytes` the ABI carries for it.
 *
 * This and {@link decodeValueBytes} are the only place the value/wire mapping is written down.
 *
 * - Word types (`bool`, `i32`, `u64`, `u256`, `dec`, `addr`, `key`) become exactly 32
 *   bytes, in the encoding Solidity would use — right-aligned, and sign-extended for `i32`.
 * - `str` becomes its raw UTF-8 bytes, and `bytes` its raw bytes: variable length, no padding.
 */
export function encodeValueBytes(value: ArkivValue): Hex {
  switch (value.type) {
    case "bool":
      return pad(value.value ? "0x01" : "0x00", { size: 32 })
    case "i32":
      // int32 is sign-extended across the whole word, as Solidity encodes it.
      return toHex(BigInt.asUintN(256, BigInt(value.value)), { size: 32 })
    case "u64":
    case "u256":
      return toHex(value.value, { size: 32 })
    case "dec":
      return toHex(BigInt.asUintN(256, decUnits(value)), { size: 32 })
    case "key":
      return value.value
    case "addr":
      return pad(value.value.toLowerCase() as Hex, { size: 32 })
    case "str":
      return toHex(stringToBytes(value.value))
    case "bytes":
      return value.value
  }
}

/**
 * Decodes the ABI `bytes` of an attribute of type `tag`.
 *
 * Rejects anything a well-formed encoder would not have produced — a word type whose value is not
 * 32 bytes, an `i32` that is not sign-extended, a `bool` that is neither 0 nor 1 — rather than
 * silently truncating it.
 */
export function decodeValueBytes(tag: TypeTag, value: Hex): ArkivValue {
  if (tag === "str") return strFromChain(bytesToString(hexToBytes(value)))
  if (tag === "bytes") return makeValue("bytes", value.toLowerCase() as Hex)

  const width = size(value)
  if (width !== 32) {
    throw new InvalidValueError(tag, value, `a ${tag} value must be 32 bytes, got ${width}`)
  }
  const raw = BigInt(value)

  switch (tag) {
    case "bool":
      if (raw !== 0n && raw !== 1n) {
        throw new InvalidValueError("bool", value, "a bool word must hold exactly 0 or 1")
      }
      return bool(raw === 1n)
    case "i32": {
      const narrowed = BigInt.asIntN(32, raw)
      if (BigInt.asUintN(256, narrowed) !== raw) {
        throw new InvalidValueError("i32", value, "an i32 word must be sign-extended")
      }
      return i32(narrowed)
    }
    case "u64":
      if (raw >> 64n !== 0n) {
        throw new InvalidValueError("u64", value, "a u64 word must be zero-padded to 8 bytes")
      }
      return u64(raw)
    case "u256":
      return u256(raw)
    case "dec":
      return dec(unitsToDecimal(BigInt.asIntN(256, raw)))
    case "key":
      return key(value)
    case "addr": {
      if (raw >> 160n !== 0n) {
        throw new InvalidValueError(
          "addr",
          value,
          "an address word must be zero-padded to 20 bytes",
        )
      }
      return addr(`0x${value.slice(-40)}`)
    }
  }
}

/**
 * Encodes a named, typed attribute into its ABI form.
 *
 * @param name - The attribute name. Assumed already validated.
 * @param indexed - Whether the node should index it. Never true for `bytes`, which the engine
 * rejects with the flag set.
 * @throws {InvalidValueError} If asked to index a `bytes` value.
 */
export function encodeAbiAttribute(
  name: string,
  value: ArkivValue,
  indexed: boolean,
): AbiAttribute {
  if (indexed && value.type === "bytes") {
    throw new InvalidValueError("bytes", `<${name}>`, "bytes are stored, never indexed")
  }
  return {
    // Ident32 is left-aligned and null-padded, which is what `toHex` does for a string.
    name: toHex(name, { size: 32 }),
    typeId: TYPE_IDS[value.type] | (indexed ? INDEXED_FLAG : 0),
    value: encodeValueBytes(value),
  }
}

/**
 * Splits a wire `typeId` into its type and indexed flag.
 *
 * The node's `bytes32` (typeId 6) decodes as `key`.
 *
 * @throws {UnknownAttributeTypeError} If the low seven bits name no known type.
 */
export function decodeTypeId(typeId: number): { tag: TypeTag; indexed: boolean } {
  const base = typeId & ~INDEXED_FLAG
  const tag =
    base === BYTES32_TYPE_ID
      ? "key"
      : (Object.keys(TYPE_IDS) as TypeTag[]).find((candidate) => TYPE_IDS[candidate] === base)
  if (tag === undefined) throw new UnknownAttributeTypeError(typeId)
  return { tag, indexed: (typeId & INDEXED_FLAG) !== 0 }
}

/** Encodes a tombstone — the mutation that unsets `name`. Legal only in a patch. */
export function encodeTombstone(name: string): AbiAttribute {
  return { name: toHex(name, { size: 32 }), typeId: TOMBSTONE_TYPE_ID, value: "0x" }
}

/**
 * Decodes an attribute value as it comes back over JSON-RPC.
 *
 * The JSON encoding follows the value's **declared type**, never its magnitude: `bool` is a JSON
 * boolean, `i32` a JSON number, `u64`/`u256` a `0x` QUANTITY, `dec` a decimal string, `str` a
 * string, and
 * the byte-shaped types `0x` DATA. Decimal strings are also accepted for the integer types, so this
 * keeps working against a node that renders them that way.
 *
 * @param type - The type tag the response declared, e.g. `"u256"`.
 * @param value - The JSON value.
 * @throws {UnknownAttributeTypeError} If the tag names no known type.
 * @throws {InvalidValueError} If the value does not parse as that type.
 */
export function decodeRpcValue(type: string, value: unknown): ArkivValue {
  const tag = asTypeTag(type)
  switch (tag) {
    case "bool":
      return bool(asBoolean(value))
    case "i32":
      return i32(Number(asIntegerish("i32", value)))
    case "u64":
      return u64(asIntegerish("u64", value))
    case "u256":
      return u256(asIntegerish("u256", value))
    case "dec":
      return dec(asString("dec", value))
    case "key":
      return key(asString("key", value) as Hex)
    case "addr":
      return addr(asString("addr", value))
    case "str":
      return strFromChain(asString("str", value))
    case "bytes":
      return makeValue("bytes", asString("bytes", value).toLowerCase() as Hex)
  }
}

/**
 * Narrows a type tag as the node spelled it. The node's `bytes32` narrows to `key`.
 *
 * @throws {UnknownAttributeTypeError} If it names no type this SDK knows.
 */
export function asTypeTag(type: string): TypeTag {
  // `Object.hasOwn`, not `in`: `in` walks the prototype chain, so "constructor" and "toString"
  // would pass as type tags and then fall off the end of the exhaustive switch below as `undefined`.
  if (typeof type !== "string" || !Object.hasOwn(TYPE_IDS, type)) {
    if (type === BYTES32_TAG) return "key"
    throw new UnknownAttributeTypeError(type)
  }
  return type as TypeTag
}

function asBoolean(value: unknown): boolean {
  if (typeof value === "boolean") return value
  // Tolerated because a node may render it as text; anything else is a protocol error.
  if (value === "true" || value === "false") return value === "true"
  throw new InvalidValueError("bool", value, "not a boolean")
}

function asString(tag: TypeTag, value: unknown): string {
  if (typeof value !== "string") {
    throw new InvalidValueError(tag, value, `expected a string, got ${typeof value}`)
  }
  return value
}

/** A `0x` QUANTITY, a JSON number, or a decimal string — all of which name one integer. */
function asIntegerish(tag: TypeTag, value: unknown): bigint {
  if (typeof value === "number") {
    if (!Number.isInteger(value)) {
      throw new InvalidValueError(tag, value, "not an integer")
    }
    return BigInt(value)
  }
  if (typeof value === "bigint") return value
  if (typeof value === "string") {
    if (/^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value)
    if (/^-?\d+$/.test(value)) return BigInt(value)
  }
  throw new InvalidValueError(tag, value, "not an integer")
}
