import { describe, expect, it, vi } from "bun:test"
import type { Hex } from "viem"
import type { ArkivClient } from "../clients/baseClient"
import {
  type AddrValue,
  type ArkivValue,
  type Attributes,
  addr,
  type BytesValue,
  bytes,
  dec,
  defineSchema,
  InvalidAttributeNameError,
  i32,
  SchemaMismatchError,
  type StrValue,
  str,
} from "../schema"
import type { RpcEntity } from "../types/rpcSchema"
import { MAX_LIMIT } from "./engine"
import { InvalidPredicateError, QueryError } from "./errors"
import { eq, exists, gte, or } from "./expression"
import { asc, desc } from "./order"
import { SelectQueryBuilder } from "./queryBuilder"
import type { DynamicSelection, SelectArg, Selection } from "./selection"

const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as const
const KEY = `0x${"ab".repeat(32)}` as const

type Page = { data: RpcEntity[]; blockNumber: string; count?: string }

/** A client whose `arkiv_query` serves the given pages in order. */
function makeClient(...pages: Page[]) {
  const served = pages.length > 0 ? pages : [{ data: [], blockNumber: "0x1" }]
  let index = 0
  const request = vi.fn(async () => served[Math.min(index++, served.length - 1)])
  return { client: { request } as unknown as ArkivClient, request }
}

/** The `[queryString, options]` of the nth `arkiv_query` call. */
function sent(request: ReturnType<typeof makeClient>["request"], call = 0) {
  const params = request.mock.calls[call][0] as unknown as {
    method: string
    params: [string, Record<string, unknown>]
  }
  return { method: params.method, query: params.params[0], options: params.params[1] }
}

describe("the query it sends", () => {
  it("ANDs everything across repeated where() calls", async () => {
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true })
      .where(gte("level", i32(10)))
      .where(eq("category", "docs"), or(eq("status", "open"), eq("status", "review")))
      .fetch()

    expect(sent(request).method).toBe("arkiv_query")
    expect(sent(request).query).toBe(
      "level >= i32(10) AND category = str('docs') AND " +
        "(status = str('open') OR status = str('review'))",
    )
  })

  it("takes an array of expressions", async () => {
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true }).where([eq("a", 1), eq("b", 2)]).fetch()
    expect(sent(request).query).toBe("a = i32(1) AND b = i32(2)")
  })

  it("turns ownedBy and createdBy into typed system predicates", async () => {
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true })
      .where(eq("category", "docs"))
      .ownedBy(ACCOUNT)
      .createdBy(ACCOUNT)
      .fetch()

    expect(sent(request).query).toBe(
      `category = str('docs') AND $owner = addr(${ACCOUNT}) AND $creator = addr(${ACCOUNT})`,
    )
  })

  it("replaces rather than ANDs a second ownedBy — two owners would match nothing", async () => {
    const other = "0x1111111111111111111111111111111111111111"
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true }).ownedBy(ACCOUNT).ownedBy(other).fetch()
    expect(sent(request).query).toBe(`$owner = addr(${other})`)
  })

  it("is readable without running anything", () => {
    const { client } = makeClient()
    const builder = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
    expect(builder.toString()).toBe("a = i32(1)")
    expect(`${builder}`).toBe("a = i32(1)")
  })

  it("refuses a query with no filter, which the language cannot spell", async () => {
    const { client, request } = makeClient()
    const builder = new SelectQueryBuilder(client, { $key: true })
    expect(() => builder.toString()).toThrow(InvalidPredicateError)
    await expect(builder.fetch()).rejects.toThrow(/at least one filter/)
    expect(request).not.toHaveBeenCalled()
  })
})

describe("the options it sends", () => {
  it("sends the resolved selection, and nothing else by default", async () => {
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true, $attributes: true })
      .where(eq("a", 1))
      .fetch()

    const { options } = sent(request)
    expect(options).toEqual({ select: expect.objectContaining({ key: true, attributes: true }) })
    // No limit or offset unless asked for — the node picks its own page size.
    expect(Object.keys(options).sort()).toEqual(["select"])
  })

  it("sends limit and offset as the RPC spells them", async () => {
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true })
      .where(eq("a", 1))
      .limit(100)
      .offset(200)
      .fetch()

    const { options } = sent(request)
    expect(options).toMatchObject({
      limit: "0x64",
      offset: "0xc8",
    })
  })

  it("sends an offset of 0", async () => {
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).offset(0).fetch()
    expect(sent(request).options).toMatchObject({ offset: "0x0" })
  })

  it("rejects an offset the node would reject", async () => {
    const { client, request } = makeClient()
    const query = () => new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
    await expect(query().offset(-1).fetch()).rejects.toThrow(/non-negative integer/)
    await expect(query().offset(1.5).fetch()).rejects.toThrow(/non-negative integer/)
    await expect(query().offset(Number.NaN).fetch()).rejects.toThrow(/non-negative integer/)
    expect(request).not.toHaveBeenCalled()
  })

  it("sends each fetch the builder as it is then", async () => {
    const { client, request } = makeClient()
    const builder = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).limit(2)
    await builder.fetch()
    await builder.offset(2).fetch()
    expect(sent(request, 0).options).not.toHaveProperty("offset")
    expect(sent(request, 1).options).toMatchObject({ limit: "0x2", offset: "0x2" })
  })

  it("rejects a page size the node would reject", async () => {
    const { client, request } = makeClient()
    const query = () => new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
    await expect(
      query()
        .limit(MAX_LIMIT + 1)
        .fetch(),
    ).rejects.toThrow(/exceeds the node maximum/)
    await expect(query().limit(0).fetch()).rejects.toThrow(/positive integer/)
    await expect(query().limit(1.5).fetch()).rejects.toThrow(/positive integer/)
    expect(query().limit(MAX_LIMIT)).toBeDefined()
    expect(request).not.toHaveBeenCalled()
  })
})

describe("withCount", () => {
  it("carries the total, typed bigint", async () => {
    const { client, request } = makeClient({
      data: [{ key: KEY }],
      blockNumber: "0x1",
      count: "0x2",
    })
    const page = await new SelectQueryBuilder(client, { $key: true })
      .where(eq("a", 1))
      .withCount()
      .limit(1)
      .offset(1)
      .fetch()

    const total: bigint = page.count
    expect(total).toBe(2n)
    expect(sent(request).options.select).toMatchObject({ key: true, count: true })
  })

  it("fails rather than typing a missing count as bigint", async () => {
    const { client } = makeClient({ data: [], blockNumber: "0x1" })
    await expect(
      new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).withCount().fetch(),
    ).rejects.toThrow(/returned no count/)
  })

  it("leaves count undefined, in the type too, unless asked for", async () => {
    const { client, request } = makeClient({ data: [], blockNumber: "0x1" })
    const page = await new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).fetch()
    const nothing: undefined = page.count
    expect(nothing).toBeUndefined()
    expect(sent(request).options.select).not.toHaveProperty("count")
  })

  it("survives from(), and leaves the builder it was called on uncounted", async () => {
    const Posts = defineSchema("posts", { category: str().indexed() })
    const { client, request } = makeClient({ data: [], blockNumber: "0x1", count: "0x0" })
    const base = new SelectQueryBuilder(client, { $key: true }).where(eq("category", "news"))
    const counted = base.withCount().from(Posts)
    const page = await counted.fetch()
    const total: bigint = page.count
    expect(total).toBe(0n)
    await base.fetch()
    expect(sent(request, 1).options.select).not.toHaveProperty("count")
  })
})

describe("decoding", () => {
  it("builds entities from exactly the fields the row carries", async () => {
    const { client } = makeClient({
      blockNumber: "0x8e1ff",
      data: [
        {
          key: KEY,
          owner: ACCOUNT,
          expiresAt: "0x92e21",
          creationFlags: { readonly: false, permissionlessExtension: true, raw: 2 },
          attributeSchema: [
            { name: "version", type: "i32", indexed: true },
            { name: "body", type: "bytes", indexed: false },
          ],
          attributes: [
            { name: "projectId", type: "str", value: "alice-123" },
            { name: "version", type: "i32", value: 4 },
            { name: "balance", type: "u256", value: "0xf4240" },
            { name: "score", type: "dec", value: "3.5" },
            { name: "body", type: "bytes", value: "0x7b7d" },
          ],
        },
      ],
    })

    const page = await new SelectQueryBuilder(client, {
      $key: true,
      $expiresAt: true,
      $creationFlags: true,
      $creator: true,
      $createdAt: true,
      $attributes: true,
      $attributeSchema: true,
    })
      .where(eq("a", 1))
      .fetch()
    const entity = page.entities[0]

    expect(entity.$key).toBe(KEY)
    expect(entity.$expiresAt).toBe(0x92e21n)
    expect(entity.$creationFlags).toEqual({
      readonly: false,
      permissionlessExtension: true,
      raw: 2,
    })
    expect(entity.$attributes).toEqual({
      projectId: { type: "str", value: "alice-123" },
      version: { type: "i32", value: 4 },
      balance: { type: "u256", value: 1_000_000n },
      score: { type: "dec", value: "3.5" },
      body: { type: "bytes", value: "0x7b7d" },
    })
    expect(entity.$attributeSchema).toEqual({
      version: { type: "i32", indexed: true },
      body: { type: "bytes", indexed: false },
    })
    // Unselected stays undefined rather than becoming an empty value.
    expect(entity.$creator).toBeUndefined()
    expect(entity.$createdAt).toBeUndefined()
    expect(page.blockNumber).toBe(0x8e1ffn)
  })

  it("leaves indexed undefined when the node does not say, rather than guessing", async () => {
    const { client } = makeClient({
      blockNumber: "0x1",
      data: [{ key: KEY, attributeSchema: [{ name: "version", type: "i32" }] }],
    })
    const page = await new SelectQueryBuilder(client, { $attributeSchema: true })
      .where(eq("a", 1))
      .fetch()
    expect(page.entities[0].$attributeSchema).toEqual({
      version: { type: "i32", indexed: undefined },
    })
  })
})

describe("orderBy", () => {
  const Posts = defineSchema("posts", {
    category: str().indexed(),
    score: dec().indexed(),
    title: str(),
    body: bytes(),
  })
  const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
    new SelectQueryBuilder<S>(client, selection)

  it("sorts after the whole filter, a top-level OR included", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true })
      .where(or(eq("status", "open"), eq("status", "review")))
      .orderBy(desc("score", "u256"))
      .fetch()
    expect(sent(request).query).toBe(
      "status = str('open') OR status = str('review') ORDER BY score:u256 DESC",
    )
  })

  it("takes the type from the schema's column", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true }).from(Posts).orderBy(desc("score")).fetch()
    expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY score:dec DESC")
  })

  it("replaces the order when called again", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true })
      .from(Posts)
      .orderBy(desc("score"))
      .orderBy(asc("category"))
      .fetch()
    expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY category:str ASC")
  })

  it("takes exactly one key, at compile time and at the call", () => {
    const { client } = makeClient()
    const builder = select(client, { $key: true }).from(Posts).orderBy(desc("category"))
    // @ts-expect-error - orderBy takes one key
    expect(() => builder.orderBy()).toThrow(/takes one key/)
    // @ts-expect-error - the node sorts by one attribute only
    expect(() => builder.orderBy(asc("category"), desc("score"))).toThrow(/takes one key/)
    // @ts-expect-error - a key is an asc(...) or desc(...), not an array of them
    expect(() => builder.orderBy([asc("category")])).toThrow(/is an asc\(\.\.\.\) or a desc/)
    // @ts-expect-error - nor anything else
    expect(() => builder.orderBy("score")).toThrow(/is an asc\(\.\.\.\) or a desc/)
    // A failed call leaves the order it would have replaced.
    expect(builder.toString()).toEndWith("ORDER BY category:str DESC")
  })

  it("holds a key built by hand to what asc() and desc() check, at the call", () => {
    const { client } = makeClient()
    const builder = select(client, { $key: true }).where(eq("category", "docs"))
    const key = (fields: object) => ({ kind: "order", name: "score", type: "str", ...fields })
    const orderBy = (fields: object) => () => builder.orderBy(key(fields) as never)
    expect(orderBy({ direction: "desc -- " })).toThrow(/"asc" or "desc"/)
    expect(orderBy({ name: "a--b", direction: "asc" })).toThrow(InvalidAttributeNameError)
    expect(orderBy({ name: "a b", direction: "asc" })).toThrow(InvalidAttributeNameError)
    expect(orderBy({ type: "bytes", direction: "asc" })).toThrow(/cannot be sorted as "bytes"/)
    expect(orderBy({ type: "u64) OR (", direction: "asc" })).toThrow(/cannot be sorted as/)
    expect(orderBy({ name: "$createdAt", type: "u64", direction: "asc" })).toThrow(
      /cannot be sorted on/,
    )
    expect(orderBy({ name: "$whatever", type: "u64", direction: "asc" })).toThrow(/not a system/)
    expect(orderBy({ name: "$expiresAt", type: "u256", direction: "asc" })).toThrow(/always a u64/)
    // A hand-built $key with no type gets the fixed one, as asc() would give it.
    builder.orderBy(key({ name: "$key", type: undefined, direction: "asc" }) as never)
    expect(builder.toString()).toEndWith("ORDER BY $key:key ASC")
  })

  it("types each name of a union by itself, so a column and a system attribute mix", () => {
    const { client } = makeClient()
    const column = "$expiresAt" as "score" | "$expiresAt"
    const builder = select(client, { $key: true }).from(Posts).orderBy(desc(column))
    expect(builder.toString()).toEndWith("ORDER BY $expiresAt:u64 DESC")
  })

  it("needs a type for a name typed as plain string without a schema, at compile time", () => {
    const { client } = makeClient()
    const name: string = "score"
    const builder = select(client, { $key: true }).where(eq("category", "docs"))
    // @ts-expect-error - a plain string could name any attribute, so it needs its type
    expect(() => builder.orderBy(desc(name)).toString()).toThrow(/needs its type/)
    expect(builder.orderBy(desc(name, "dec")).toString()).toEndWith("ORDER BY score:dec DESC")
  })

  it("sends the order with an offset", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true }).from(Posts).orderBy(asc("$key")).offset(20).fetch()
    expect(sent(request).query).toEndWith("ORDER BY $key:key ASC")
    expect(sent(request).options).toMatchObject({ offset: "0x14" })
  })

  it("survives from() and withCount()", async () => {
    const { client, request } = makeClient({ data: [], blockNumber: "0x1", count: "0x0" })
    await select(client, { $key: true })
      .orderBy(desc("score", "dec"))
      .from(Posts)
      .withCount()
      .fetch()
    expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY score:dec DESC")
  })

  it("needs a type without a schema, at compile time and before sending", async () => {
    const { client, request } = makeClient()
    const builder = select(client, { $key: true }).where(eq("category", "docs"))
    // @ts-expect-error - score needs its type without a schema
    await expect(builder.orderBy(desc("score")).fetch()).rejects.toThrow(/needs its type/)
    expect(request).not.toHaveBeenCalled()
  })

  it("sorts on a column that is not indexed", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true }).from(Posts).orderBy(asc("title")).fetch()
    expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY title:str ASC")
  })

  it("only sorts on the schema's non-bytes columns, at compile time and before sending", async () => {
    const { client, request } = makeClient()
    const query = () => select(client, { $key: true }).from(Posts)
    // @ts-expect-error - title is a str, not a u256
    await expect(query().orderBy(asc("title", "u256")).fetch()).rejects.toThrow(/str column/)
    // @ts-expect-error - body is bytes, which cannot be sorted on
    await expect(query().orderBy(asc("body")).fetch()).rejects.toThrow(/bytes cannot be sorted on/)
    // @ts-expect-error - nope is not a column
    await expect(query().orderBy(asc("nope")).fetch()).rejects.toThrow(/not a column/)
    // @ts-expect-error - score is a dec, not a u256
    await expect(query().orderBy(asc("score", "u256")).fetch()).rejects.toThrow(/dec column/)
    expect(request).not.toHaveBeenCalled()
  })

  it("only sorts on the system attributes that can be sorted on, at compile time", () => {
    const { client } = makeClient()
    const builder = select(client, { $key: true }).from(Posts)
    // @ts-expect-error - $createdAt cannot be sorted on
    expect(() => builder.orderBy(desc("$createdAt"))).toThrow(
      /"\$createdAt" is returned in query results but cannot be sorted on/,
    )
    // @ts-expect-error - $expiresAt is a u64
    expect(() => builder.orderBy(desc("$expiresAt", "u256"))).toThrow(/always a u64/)
  })

  it("accepts the sortable system attributes without a type or a schema", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true }).ownedBy(ACCOUNT).orderBy(asc("$expiresAt")).fetch()
    expect(sent(request).query).toEndWith("ORDER BY $expiresAt:u64 ASC")
    await select(client, { $key: true }).ownedBy(ACCOUNT).orderBy(desc("$creator")).fetch()
    expect(sent(request, 1).query).toEndWith("ORDER BY $creator:addr DESC")
  })
})

describe("node rejections", () => {
  /** viem wraps the arkiv codes in its own EIP-1474 classes, so the code sits down the chain. */
  function rpcFailure(code: number, data?: unknown) {
    const inner = Object.assign(new Error("RPC Request failed."), { code, data })
    return Object.assign(new Error("Something went wrong."), {
      shortMessage: "Something went wrong.",
      cause: inner,
    })
  }

  it("names the failure and keeps the node's detail", async () => {
    const { client } = makeClient()
    const data = { position: 14, expected: "typed literal", got: "3.5" }
    ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(-32001, data))

    const promise = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).fetch()
    await expect(promise).rejects.toThrow(QueryError)

    const error = (await promise.catch((e) => e)) as QueryError
    expect(error.kind).toBe("parse")
    expect(error.code).toBe(-32001)
    expect(error.data).toEqual(data)
    expect(error.query).toBe("a = i32(1)")
    expect(error.message).toContain("Query: a = i32(1)")
  })

  it("words the message with the node's detail, not viem's label for the code", async () => {
    const { client } = makeClient()
    const inner = Object.assign(new Error("RPC Request failed."), {
      code: -32001,
      details: "unexpected character",
      data: { message: "unexpected character", position: 25 },
    })
    const outer = Object.assign(new Error("Requested resource not found."), {
      shortMessage: "Requested resource not found.",
      details: "unexpected character",
      code: -32001,
      cause: inner,
    })
    ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(outer)

    const error = (await new SelectQueryBuilder(client, { $key: true })
      .where(eq("a", 1))
      .fetch()
      .catch((e) => e)) as QueryError
    expect(error.message).toStartWith("Query rejected (parse, -32001): unexpected character.")
    expect(error.message).not.toContain("not found")
  })

  it("skips an empty detail for the next link's, or the node's data", async () => {
    const { client } = makeClient()
    const inner = Object.assign(new Error("RPC Request failed."), {
      code: -32001,
      data: { message: "unexpected character", position: 25 },
    })
    const outer = Object.assign(new Error(""), { details: "", code: -32001, cause: inner })
    ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(outer)

    const error = (await new SelectQueryBuilder(client, { $key: true })
      .where(eq("a", 1))
      .fetch()
      .catch((e) => e)) as QueryError
    expect(error.message).toStartWith("Query rejected (parse, -32001): unexpected character.")
  })

  it("falls back to the message in the node's data", async () => {
    const { client } = makeClient()
    const data = { message: "unknown tag", position: 3 }
    ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(-32002, data))

    const error = (await new SelectQueryBuilder(client, { $key: true })
      .where(eq("a", 1))
      .fetch()
      .catch((e) => e)) as QueryError
    expect(error.message).toStartWith("Query rejected (type, -32002): unknown tag.")
  })

  it("maps each code in the frozen range", async () => {
    const kinds = {
      [-32001]: "parse",
      [-32002]: "type",
      [-32003]: "literal",
      [-32004]: "limits",
    }
    for (const [code, kind] of Object.entries(kinds)) {
      const { client } = makeClient()
      ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(Number(code)))
      const error = await new SelectQueryBuilder(client, { $key: true })
        .where(eq("a", 1))
        .fetch()
        .catch((e) => e)
      expect((error as QueryError).kind).toBe(kind as QueryError["kind"])
    }
  })

  it("leaves anything else alone", async () => {
    const { client } = makeClient()
    const transportFailure = new Error("fetch failed")
    ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(transportFailure)

    const error = await new SelectQueryBuilder(client, { $key: true })
      .where(eq("a", 1))
      .fetch()
      .catch((e) => e)
    expect(error).toBe(transportFailure)
  })
})

describe("the projected type", () => {
  // Compile-time only: these assertions fail the build, not the run.
  it("narrows the entity to exactly the selected fields", async () => {
    const { client } = makeClient({
      blockNumber: "0x1",
      data: [{ key: KEY, owner: ACCOUNT }],
    })

    const page = await new SelectQueryBuilder<{ $key: true; $owner: true }>(client, {
      $key: true,
      $owner: true,
    })
      .where(eq("a", 1))
      .fetch()

    const entity = page.entities[0]
    expectType<Hex>(entity.$key)
    expectType<Hex>(entity.$owner)
    // @ts-expect-error - creator was not selected
    entity.$creator
  })

  it("reads every attribute as a map with $attributes", async () => {
    const { client } = makeClient({
      blockNumber: "0x1",
      data: [{ key: KEY, attributes: [{ name: "version", type: "i32", value: 4 }] }],
    })
    const page = await new SelectQueryBuilder<{ $key: true; $attributes: true }>(client, {
      $key: true,
      $attributes: true,
    })
      .where(eq("a", 1))
      .fetch()

    expectType<Attributes>(page.entities[0].$attributes)
    expect(page.entities[0].$attributes).toEqual({ version: { type: "i32", value: 4 } })
  })
})

describe("through a schema", () => {
  /** Builds the way `client.select` does, so the selection is inferred as a literal. */
  const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
    new SelectQueryBuilder<S>(client, selection)

  const Posts = defineSchema({
    author: addr().indexed(),
    category: str().indexed(),
    score: dec().indexed(),
    title: str(),
    body: bytes(),
    thumbnail: bytes().optional(),
  })

  const row: RpcEntity = {
    key: KEY,
    owner: ACCOUNT,
    attributes: [
      { name: "author", type: "addr", value: ACCOUNT },
      { name: "title", type: "str", value: "Hello" },
      { name: "body", type: "bytes", value: "0x2348" },
    ],
  }

  it("asks for the selected columns by name, alongside the entity fields", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true, title: true, body: true })
      .from(Posts)
      .where(eq("category", "docs"))
      .fetch()
    expect(sent(request).options.select).toMatchObject({
      key: true,
      owner: false,
      attributes: { title: true, body: true },
    })
  })

  it("returns flat rows with each column typed by the schema", async () => {
    const { client } = makeClient({ blockNumber: "0x1", data: [row] })
    const page = await select(client, {
      $key: true,
      author: true,
      title: true,
      body: true,
      thumbnail: true,
    })
      .from(Posts)
      .where(eq("category", "docs"))
      .fetch()

    const [first] = page.entities
    expect(first).toEqual({
      $key: KEY,
      author: addr(ACCOUNT),
      title: str("Hello"),
      body: bytes("0x2348"),
      thumbnail: undefined,
    })
    expectType<Hex>(first.$key)
    expectType<AddrValue>(first.author)
    expectType<StrValue>(first.title)
    expectType<BytesValue>(first.body)
    expectType<BytesValue | undefined>(first.thumbnail)
    // @ts-expect-error - score was not selected
    first.score
  })

  it("decodes every entity of a page as a row", async () => {
    const { client } = makeClient({ blockNumber: "0x1", data: [row, row] })
    const { entities } = await select(client, { title: true })
      .from(Posts)
      .where(eq("category", "docs"))
      .fetch()
    expect(entities.map((entry) => entry.title.value)).toEqual(["Hello", "Hello"])
  })

  it("fails the fetch on an entity that does not fit the schema, with onMismatch: throw", async () => {
    const { client } = makeClient({
      blockNumber: "0x1",
      data: [{ key: KEY, attributes: [{ name: "author", type: "addr", value: ACCOUNT }] }],
    })
    await expect(
      select(client, { $key: true, title: true })
        .from(Posts, { onMismatch: "throw" })
        .where(eq("category", "docs"))
        .fetch(),
    ).rejects.toThrow(SchemaMismatchError)
  })

  it("checks the filters against the schema before sending anything", async () => {
    const { client, request } = makeClient()
    const builder = select(client, { $key: true }).from(Posts)
    // @ts-expect-error - title is not indexed
    await expect(builder.where(eq("title", "Hello")).fetch()).rejects.toThrow(/not indexed/)
    const bytesFilter = select(client, { $key: true }).from(Posts)
    // @ts-expect-error - body is bytes, which is never indexed
    await expect(bytesFilter.where(exists("body")).fetch()).rejects.toThrow(/never indexed/)
    expect(request).not.toHaveBeenCalled()
  })

  it("rejects a selection naming something the schema does not have, at from()", () => {
    const { client } = makeClient()
    expect(() =>
      select(client, { $key: true, nope: true })
        // @ts-expect-error - nope is not a column
        .from(Posts),
    ).toThrow(/"nope", which is not a column/)
  })

  it("checks a selection built at runtime when from() is called", () => {
    const { client } = makeClient()
    const selection: SelectArg = JSON.parse('{"$key": true, "nope": true}')
    expect(() => new SelectQueryBuilder(client, selection).from(Posts)).toThrow(
      /"nope", which is not a column/,
    )
  })

  it("sends the selection as it was when select() was called", async () => {
    const { client, request } = makeClient()
    const selection: Record<string, boolean> = { $key: true, title: true }
    const builder = new SelectQueryBuilder(client, selection as Selection).from(Posts)
    selection.author = true
    await builder.where(eq("category", "docs")).fetch()
    expect(sent(request).options.select).toMatchObject({ attributes: { title: true } })
  })

  it("carries the filters, owner, limit and offset across from()", async () => {
    const { client, request } = makeClient()
    await new SelectQueryBuilder(client, { $key: true })
      .where(eq("category", "docs"))
      .ownedBy(ACCOUNT)
      .limit(5)
      .offset(10)
      .from(Posts)
      .fetch()
    const { query, options } = sent(request)
    expect(query).toBe(`category = str('docs') AND $owner = addr(${ACCOUNT})`)
    expect(options).toMatchObject({ limit: "0x5", offset: "0xa" })
  })

  it("reads the same selection without a schema, untyped and unchecked", async () => {
    const misfit = { key: KEY, attributes: [{ name: "title", type: "i32", value: 7 }] }
    const { client, request } = makeClient({ blockNumber: "0x1", data: [row, misfit] })
    const page = await select(client, { $key: true, title: true, body: true })
      .where(eq("category", "docs"))
      .fetch()

    expect(sent(request).options.select).toMatchObject({
      key: true,
      attributes: { title: true, body: true },
    })
    // The same flat rows as through a schema; without one, nothing is checked, so the misfit
    // comes back as it is and a missing attribute as undefined.
    expect(page.entities).toEqual([
      { $key: KEY, title: str("Hello"), body: bytes("0x2348") },
      { $key: KEY, title: i32(7), body: undefined },
    ])
    const title: ArkivValue | undefined = page.entities[0]?.title
    expect(title).toEqual(str("Hello"))
  })

  it("skips a mismatch by default, and throws on it with onMismatch: throw", async () => {
    // Anyone can write an entity a query matches but the schema does not fit, so one such entity
    // must not fail every read of the others unless the caller asks for that.
    const misfit = { key: KEY, attributes: [{ name: "author", type: "addr", value: ACCOUNT }] }
    const pages = () => makeClient({ blockNumber: "0x1", data: [row, misfit, row] }).client

    const page = await select(pages(), { $key: true, title: true })
      .from(Posts)
      .where(eq("category", "docs"))
      .fetch()
    expect(page.entities.map((entry) => entry.title.value)).toEqual(["Hello", "Hello"])
    expect(page.skipped).toBe(1)

    await expect(
      select(pages(), { $key: true, title: true })
        .from(Posts, { onMismatch: "throw" })
        .where(eq("category", "docs"))
        .fetch(),
    ).rejects.toThrow(SchemaMismatchError)
  })

  it("still throws errors that are not mismatches when skipping", async () => {
    const { client } = makeClient({
      blockNumber: "0x1",
      data: [{ key: KEY, attributes: [{ name: "title", type: "nope", value: 1 }] }],
    })
    await expect(
      select(client, { title: true })
        .from(Posts, { onMismatch: "skip" })
        .where(eq("category", "docs"))
        .fetch(),
    ).rejects.toThrow(/Unknown attribute type/)
  })

  it("counts skips, so a page of misfits is not mistaken for the end", async () => {
    const misfit = { key: KEY, attributes: [] }
    const { client } = makeClient({ blockNumber: "0x1", data: [misfit, row, misfit] })
    const page = await select(client, { title: true })
      .from(Posts, { onMismatch: "skip" })
      .where(eq("category", "docs"))
      .limit(3)
      .fetch()
    expect(page.entities.map((entry) => entry.title.value)).toEqual(["Hello"])
    // The node counted all three toward the limit, so the next page starts at offset 3.
    expect(page.entities.length + page.skipped).toBe(3)
  })

  it("reports nothing skipped when not skipping, or without a schema", async () => {
    const throughSchema = await select(makeClient({ blockNumber: "0x1", data: [row] }).client, {
      title: true,
    })
      .from(Posts)
      .where(eq("category", "docs"))
      .fetch()
    expect(throughSchema.skipped).toBe(0)

    const plain = await new SelectQueryBuilder(makeClient().client, { $key: true })
      .where(eq("category", "docs"))
      .fetch()
    expect(plain.skipped).toBe(0)
  })

  it("leaves the builder it was called on untouched, so two schemas never share one", async () => {
    const Users = defineSchema("users", { category: str().indexed(), name: str() })
    const { client, request } = makeClient()
    const base = new SelectQueryBuilder(client, "*").limit(5)
    const posts = base.from(Posts).where(eq("category", "docs"))
    const users = base.from(Users)

    expect(posts).not.toBe(base)
    expect(users).not.toBe(posts)
    await users.where(eq("category", "people")).fetch()
    await posts.fetch()
    // Each carried what `base` held when it was made, and nothing the other added since.
    expect(sent(request, 0).query).toBe("arkiv.schema = str('users') AND category = str('people')")
    expect(sent(request, 0).options.limit).toBe("0x5")
    expect(sent(request, 1).query).toBe("category = str('docs')")
    expect(() => base.toString()).toThrow(/at least one filter/)
  })

  it("types a dynamic selection's rows with every field optional", async () => {
    const { client } = makeClient({ blockNumber: "0x1", data: [row] })
    const selection: SelectArg = { $key: true }
    const page = await new SelectQueryBuilder<DynamicSelection>(client, selection)
      .from(Posts)
      .where(eq("category", "docs"))
      .fetch()
    const [first] = page.entities
    expectType<StrValue | undefined>(first.title)
    expect(first).toEqual({ $key: KEY })
  })

  it("rejects an onMismatch it does not know", () => {
    const { client } = makeClient()
    expect(() =>
      select(client, { title: true }).from(Posts, { onMismatch: "ignore" as never }),
    ).toThrow(/"throw" or "skip"/)
  })

  it("rejects something that is not a schema", () => {
    const { client } = makeClient()
    expect(() =>
      new SelectQueryBuilder(client, "*").from({ columns: {} } as unknown as typeof Posts),
    ).toThrow(/defineSchema/)
  })
})

describe("through a named schema", () => {
  const Posts = defineSchema("posts", { category: str().indexed(), title: str() })
  const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
    new SelectQueryBuilder<S>(client, selection)

  it("filters on the schema's tag, ahead of everything else", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true }).from(Posts).where(eq("category", "docs")).fetch()
    expect(sent(request).query).toBe("arkiv.schema = str('posts') AND category = str('docs')")
  })

  it("needs no other filter, so it can list every entity of the schema", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true }).from(Posts).fetch()
    expect(sent(request).query).toBe("arkiv.schema = str('posts')")
  })

  it("checks the caller's filters, not the tag it adds itself", async () => {
    const { client } = makeClient()
    const builder = select(client, { $key: true }).from(Posts)
    // @ts-expect-error - arkiv.schema is not a column
    builder.where(eq("arkiv.schema", "comments"))
    await expect(builder.fetch()).rejects.toThrow(/"arkiv.schema" is not a column/)
  })

  it("leaves an unnamed schema's query to its filters alone", async () => {
    const { client, request } = makeClient()
    await select(client, { $key: true })
      .from(defineSchema({ category: str().indexed() }))
      .where(eq("category", "docs"))
      .fetch()
    expect(sent(request).query).toBe("category = str('docs')")
  })
})

/** Asserts the static type of an expression without touching it at runtime. */
function expectType<T>(_value: T): void {}
