import { type AnyColumn, isColumn } from "./column"
import { InvalidValueError, SchemaDefinitionError, TooManyAttributesError } from "./errors"
import { validateAttributeName } from "./names"
import { type ArkivValue, isArkivValue, type StrValue, type TypeTag, type ValueOf } from "./types"
import { type BareOf, str } from "./values"

/**
 * The most attributes one entity operation may carry, and so the most columns a schema may have —
 * one fewer for a named schema, whose tag is one more attribute on every create.
 */
export const MAX_ATTRIBUTES = 32

/**
 * The attribute a named schema tags its entities with: an indexed `str` holding the schema's name.
 * Creates write it, and `.from(schema)` filters on it, so a query through a schema only ever sees
 * entities written through it.
 */
export const SCHEMA_ATTRIBUTE = "arkiv.schema"

/**
 * Marks a schema. A registry symbol rather than `instanceof`, so a schema defined through one copy
 * of the SDK — its CJS build, say — is still recognised by another, such as the ESM build an app
 * loaded.
 */
const SCHEMA_BRAND = Symbol.for("@arkiv-network/sdk/schema")

/** A schema's columns, keyed by attribute name. */
export type Columns = { readonly [name: string]: AnyColumn }

/**
 * A schema: the named, typed attributes an entity of one kind carries, and which of them the node
 * indexes. Built with {@link defineSchema}.
 *
 * A schema is an SDK-side convention. The node sees only attributes, each with a type and an
 * indexed flag; the schema is what types the SDK's writes and reads, and checks them at runtime.
 *
 * A named schema also tags every entity it creates with its name, in the indexed
 * {@link SCHEMA_ATTRIBUTE} attribute, so that a query through it sees only its own entities.
 *
 * @typeParam C - The columns, keyed by attribute name.
 */
export class Schema<C extends Columns = Columns> {
  /** The name entities are tagged with, or `undefined` for an untagged schema. */
  readonly name: string | undefined
  /** The columns, keyed by attribute name, in the order they were declared. */
  readonly columns: C
  /** The tag every create writes and every query filters on, if the schema is named. */
  readonly tag: StrValue | undefined

  /** Use {@link defineSchema}. */
  constructor(name: string | undefined, columns: C) {
    this.tag = name === undefined ? undefined : validateName(name)
    validateColumns(columns, name !== undefined)
    this.name = name
    this.columns = Object.freeze({ ...columns })
    Object.defineProperty(this, SCHEMA_BRAND, { value: true })
    Object.freeze(this)
  }
}

/**
 * Declares a schema: the attributes an entity carries, their types, and which of them are indexed.
 *
 * Each column is a type constructor called with no arguments — `str()`, `dec()`, `bytes()` —
 * refined with `.indexed()`, `.optional()` or `.default(...)`. Only indexed columns can be
 * filtered on; the rest are stored and selectable, and do not pay for an index.
 *
 * Give the schema a name to tag its entities: every create then also writes an indexed
 * `arkiv.schema` attribute holding the name, and `.from(schema)` filters on it — the way a
 * collection keeps one model's documents apart from another's. Without a name, a query through the
 * schema sees every entity its `where` matches, whatever wrote it — though the columns it selects
 * are still checked against the schema, so an entity that does not fit is left out of the page, or
 * fails the query with `{ onMismatch: "throw" }`.
 *
 * @param name - The name entities are tagged with — a `str`, so at most 128 bytes. Optional.
 * @param columns - The columns, keyed by attribute name.
 * @throws {InvalidAttributeNameError} If a name violates the attribute-name grammar.
 * @throws {SchemaDefinitionError} If the name is not a valid `str`, an entry is not a column, or a
 * column is named after the schema tag.
 * @throws {TooManyAttributesError} If there are more columns than one create can carry — at most
 * {@link MAX_ATTRIBUTES}, or one fewer for a named schema, whose tag counts as an attribute.
 *
 * @example
 * import { addr, bytes, dec, defineSchema, str } from "@arkiv-network/sdk/schema"
 *
 * const Posts = defineSchema("posts", {
 *   author:    addr().indexed(),
 *   category:  str().indexed(),
 *   score:     dec().indexed(),
 *   title:     str(),                          // stored, not indexed
 *   body:      bytes(),                        // a blob; bytes can never be indexed
 *   thumbnail: bytes().optional(),             // bytes columns share one MAX_ENTITY_BYTES budget
 *   mimeType:  str().default("text/markdown"), // written on create when left out
 * })
 */
export function defineSchema<const C extends Columns>(name: string, columns: C): Schema<C>
export function defineSchema<const C extends Columns>(columns: C): Schema<C>
export function defineSchema<const C extends Columns>(
  ...args: [name: string, columns: C] | [columns: C]
): Schema<C> {
  return args.length === 2 ? new Schema(args[0], args[1]) : new Schema(undefined, args[0])
}

function validateName(name: string): StrValue {
  if (typeof name !== "string" || name.length === 0) {
    throw new SchemaDefinitionError(
      "a schema name must be a non-empty string — leave it out for an untagged schema",
    )
  }
  try {
    return str(name)
  } catch (error) {
    if (!(error instanceof InvalidValueError)) throw error
    throw new SchemaDefinitionError(`the name is not a valid str tag. ${error.message}`)
  }
}

function validateColumns(columns: Columns, tagged: boolean): void {
  if (typeof columns !== "object" || columns === null || Array.isArray(columns)) {
    throw new SchemaDefinitionError("defineSchema takes an object of columns keyed by name")
  }
  const entries = Object.entries(columns)
  // The tag is one more attribute on every create, so it counts against the same cap.
  const attributes = entries.length + (tagged ? 1 : 0)
  if (attributes > MAX_ATTRIBUTES) {
    throw new TooManyAttributesError(attributes, MAX_ATTRIBUTES, tagged)
  }
  for (const [name, column] of entries) {
    validateAttributeName(name)
    if (name === SCHEMA_ATTRIBUTE) {
      throw new SchemaDefinitionError(
        `"${name}" is the attribute a named schema tags its entities with, so a column cannot ` +
          "use it. Pick another name",
        name,
      )
    }
    if (!isColumn(column)) throw notAColumn(name, column)
  }
}

/** Whether `input` is a schema built with {@link defineSchema}. */
export function isSchema(input: unknown): input is Schema {
  return typeof input === "object" && input !== null && SCHEMA_BRAND in input
}

/**
 * The column `name` of `schema`, or `undefined` if it has none. Only the schema's own columns
 * count, so an inherited key such as `toString` or `__proto__` is not mistaken for one.
 */
export function lookupColumn(schema: Schema, name: string): AnyColumn | undefined {
  return Object.hasOwn(schema.columns, name) ? schema.columns[name] : undefined
}

/** The schema's column names, for an error message that has to say what would have worked. */
export function describeColumns(schema: Schema): string {
  return Object.keys(schema.columns).join(", ") || "(none)"
}

function notAColumn(name: string, input: unknown): SchemaDefinitionError {
  if (isArkivValue(input)) {
    return new SchemaDefinitionError(
      `got a ${input.type} value where a column belongs. Did you mean ` +
        `${input.type}().default(${defaultArgument(input)})? Called with a value, ${input.type}(...) ` +
        `makes a value; called with none, ${input.type}() declares a column`,
      name,
    )
  }
  return new SchemaDefinitionError(
    "not a column. Declare it with a type constructor called with no arguments, e.g. str() or " +
      "dec().indexed()",
    name,
  )
}

/** A value spelled as the argument its constructor would take, for an error message. */
function defaultArgument(value: ArkivValue): string {
  switch (value.type) {
    case "bool":
    case "i32":
      return String(value.value)
    case "u64":
    case "u256":
      return `${value.value}n`
    default:
      return JSON.stringify(value.value)
  }
}

/** The columns of a schema. */
export type ColumnsOf<S extends Schema> = S["columns"]

/**
 * What a write accepts for a column of type `T`: a value of that type, or the bare form that
 * defaults to it — a `string` for `str`, a `Uint8Array` for `bytes`, a `number` for `i32`, a
 * `bigint` for `u256`, a `boolean` for `bool`. Every other type is written tagged.
 */
export type ColumnInput<T extends TypeTag> = ValueOf<T> | BareOf<T>

/** The columns a create must provide: neither optional nor defaulted. */
type RequiredColumnName<C extends Columns> = {
  [K in keyof C]: C[K] extends { readonly isOptional: false; readonly defaultValue: undefined }
    ? K
    : never
}[keyof C]

/** The columns an entity may leave unset — which is to say, the ones a patch may unset. */
export type OptionalColumnName<S extends Schema> = {
  [K in keyof ColumnsOf<S>]: true extends ColumnsOf<S>[K]["isOptional"] ? K : never
}[keyof ColumnsOf<S>] &
  string

/** The indexed columns, which are the only ones `where` can filter on. */
export type IndexedColumnName<S extends Schema> = {
  [K in keyof ColumnsOf<S>]: true extends ColumnsOf<S>[K]["isIndexed"] ? K : never
}[keyof ColumnsOf<S>] &
  string

/**
 * The values a create takes for schema `S`: every required column, plus any optional or
 * defaulted one. A defaulted column left out is written with its default.
 *
 * @example
 * const values: CreateEntityValues<typeof Posts> = {
 *   author: addr("0x…"),
 *   category: "docs",          // bare string -> str
 *   score: dec("4.1"),
 *   title: "Cells, not entities",
 *   body: new Uint8Array(),    // bare Uint8Array -> bytes
 * }
 */
export type CreateEntityValues<S extends Schema> = Prettify<
  {
    readonly [K in RequiredColumnName<ColumnsOf<S>>]: ColumnInput<ColumnsOf<S>[K]["type"]>
  } & {
    readonly [K in Exclude<keyof ColumnsOf<S>, RequiredColumnName<ColumnsOf<S>>>]?:
      | ColumnInput<ColumnsOf<S>[K]["type"]>
      | undefined
  }
>

/**
 * What a patch writes for schema `S`: any subset of its columns. Defaults do not apply — a patch
 * changes only what it names — and a column given as `undefined` is left alone, as a create
 * leaves it out.
 */
export type PatchEntityValues<S extends Schema> = {
  readonly [K in keyof ColumnsOf<S>]?: ColumnInput<ColumnsOf<S>[K]["type"]> | undefined
}

type Prettify<T> = { [K in keyof T]: T[K] } & {}
