/**
 * `model({...})` + `defineDb({ User, Post })` — the schema registry and the typed db handle.
 *
 * Runtime responsibilities: validate relation names, resolve `t.many()` back-fields, emit the `.marci`
 * schema text (`db.$schema`), and turn collection calls into lazy `Op`s over a `MarciTransport`
 * (`marcidb-embedded` handle, HTTP client, or a test fake). The transport is either passed to
 * `defineDb(models, { transport })` or provided by the host through `setDbTransport()` — user code
 * never sees a connection string.
 */

import type { Field, FieldDef, ScalarKind } from "./fields"
import { AUTH_SESSION_MODEL, AUTH_USER_MODEL, authSessionModel, isAuthModel, sessionUserField } from "../auth/models"
import { createQueryLayer, type FieldDesc, type MarciOp, type MarciTransport, type ModelsMeta } from "./marci/query"
import type { AuthBinding, Db, Fields, Model, Schema, ValidateRefs } from "./types"

export const model = <F extends Fields>(fields: F): Model<F> => ({ fields, __model: true })

// ───────────────────────────── transport ─────────────────────────────

// A GLOBAL seam (not a module variable): the compiled server bundle carries its own copy of this
// module, so the host's `setDbTransport` and the bundle's `defineDb` only meet through globalThis.
const seam = globalThis as unknown as { __lecodesDbTransport?: MarciTransport, __lecodesDbs?: Db<any>[] }
/** Host hook: the runner/dev-server installs the project's transport before loading user code. */
export const setDbTransport = (transport: MarciTransport | null) => {
  if (transport) seam.__lecodesDbTransport = transport
  else delete seam.__lecodesDbTransport
}
/**
 * Every `defineDb` call registers its handle here so the host finds the schema whether or not the
 * project exports `db` (`const db = defineDb(...)` used only inside endpoints is the common case).
 * The host reads and clears the list after evaluating a bundle (`loadServerModules`).
 */
export const takeRegisteredDbs = (): Db<any>[] => {
  const dbs = seam.__lecodesDbs ?? []
  seam.__lecodesDbs = []
  return dbs
}

const opOf = (o: unknown): MarciOp => {
  const op = (o as { __op?: MarciOp })?.__op
  if (!op) throw new Error("db.$transaction: every item must be a db.<model>.<method>(...) call")
  return op
}

/** A reference to a previous operation's result inside `$transaction` (resolved server-side): `ref("0.id")`. */
export const ref = (path: string): any => ({ $ref: path })

// ───────────────────────────── metadata ─────────────────────────────

type ModelMeta = {
  name: string
  fields: Record<string, FieldDef>
  idKeys: string[]
  /** id is a single uuid key we generate on insert. */
  uuidId: boolean
}

const isRel = (d: FieldDef) => d.kind === "one" || d.kind === "many" || d.kind === "list"

const buildMeta = (models: Record<string, Model>): Record<string, ModelMeta> => {
  const metas: Record<string, ModelMeta> = {}
  for (const [name, m] of Object.entries(models)) {
    if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`defineDb: model name "${name}" must be PascalCase`)
    const fields: Record<string, FieldDef> = {}
    for (const [key, f] of Object.entries(m.fields as Fields)) fields[key] = { ...(f as Field<any, any>).def }
    if (fields.id && !fields.id.isId) fields.id.isId = true
    const idKeys = Object.keys(fields).filter(k => fields[k].isId)
    const uuidId = idKeys.length === 1 && idKeys[0] === "id" && fields.id.scalar === "uuid"
    metas[name] = { name, fields, idKeys, uuidId }
  }
  // relations: targets must exist; `many` needs a back field
  for (const meta of Object.values(metas)) {
    for (const [key, d] of Object.entries(meta.fields)) {
      if (!isRel(d)) continue
      const target = metas[d.model!]
      if (!target) throw new Error(`defineDb: ${meta.name}.${key} references unknown model "${d.model}"`)
      if (d.kind !== "many") continue
      if (d.back) {
        const back = target.fields[d.back]
        if (!back || back.kind !== "one" || back.model !== meta.name)
          throw new Error(`defineDb: ${meta.name}.${key}: "${d.model}.${d.back}" is not a t.one("${meta.name}") field`)
        continue
      }
      const candidates = Object.entries(target.fields).filter(([, f]) => f.kind === "one" && f.model === meta.name).map(([k]) => k)
      if (candidates.length !== 1)
        throw new Error(
          `defineDb: ${meta.name}.${key}: cannot infer the back field on ${d.model} ` +
          (candidates.length ? `(candidates: ${candidates.join(", ")}) — pass it: t.many("${d.model}", "<field>")`
                             : `— add a t.one("${meta.name}") field to ${d.model}`))
      d.back = candidates[0]
    }
  }
  return metas
}

// ───────────────────────────── .marci emitter ─────────────────────────────

const SCALAR: Record<Exclude<ScalarKind, "">, string> = {
  string: "String", int: "Int", float: "Double", bool: "Bool", date: "DateTime", uuid: "Byte[16]",
}
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
const ON_DELETE = { cascade: "Cascade", setNull: "SetNull", restrict: "Restrict" } as const

const literal = (v: unknown, d: FieldDef): string => {
  if (d.scalar === "date" && v === "now") return "now()"
  if (Array.isArray(v)) return `[${v.map(x => literal(x, { ...d, array: false })).join(", ")}]`
  if (typeof v === "string") return d.kind === "enum" ? v : JSON.stringify(v)
  return String(v)
}

const emitField = (owner: string, key: string, d: FieldDef, extra: string[]): string => {
  let type: string
  const attrs: string[] = []
  switch (d.kind) {
    case "scalar":
      type = d.vector ? `Float[${d.vector.size}]` : SCALAR[d.scalar as Exclude<ScalarKind, "">]
      if (d.scalar === "uuid") attrs.push("@format(uuid)")
      break
    case "json": type = "Json"; break
    case "enum": {
      const name = `${owner}${cap(key)}`
      extra.push(`enum ${name} {\n${d.values!.map(v => `    ${v}`).join("\n")}\n}`)
      type = name; break
    }
    case "struct": {
      const name = `${owner}${cap(key)}`
      const inner: string[] = []
      for (const [k, f] of Object.entries(d.fields!)) inner.push(emitField(name, k, (f as Field<any, any>).def, extra))
      extra.push(`struct ${name} {\n${inner.join("\n")}\n}`)
      type = name; break
    }
    case "one": type = d.model!; break
    case "many": type = `${d.model}[]`; attrs.push(`@bind(${d.model}.${d.back})`); break
    case "list": type = `${d.model}[]`; attrs.push("@list"); break
  }
  if (d.array && d.kind !== "many" && d.kind !== "list") type += "[]"
  if (d.optional) type += "?"
  if (d.isId) attrs.unshift("@id")
  if (d.unique) attrs.push("@unique")
  if (d.index) attrs.push("@index")
  if (d.hasDefault) attrs.push(`@default(${literal(d.default, d)})`)
  if (d.onDelete) attrs.push(`@onDelete(${ON_DELETE[d.onDelete]})`)
  if (d.fulltext) attrs.push(`@fulltext(${d.fulltext})`)
  if (d.vector) attrs.push(`@vector(${d.vector.metric})`)
  return `    ${key.padEnd(12)} ${type}${attrs.length ? " " + attrs.join(" ") : ""}`
}

export const toMarci = (metas: Record<string, ModelMeta>): string => {
  const out: string[] = []
  const extra: string[] = []
  for (const meta of Object.values(metas)) {
    const lines = Object.entries(meta.fields).map(([k, d]) => emitField(meta.name, k, d, extra))
    out.push(`model ${meta.name} {\n${lines.join("\n")}\n}`)
  }
  return [...out, ...extra].join("\n\n") + "\n"
}

// ───────────────────────────── queries ─────────────────────────────

/**
 * The model metadata marcidb's query layer reads (`MODELS` in a generated client): field descriptors per
 * model — `key` / `body` scalars, `one` / `many` relations with their target. Structs are pseudo-models
 * (`User.info`) so an empty nested select fills with the struct's own scalars, as marcidb does.
 */
const modelsMeta = (metas: Record<string, ModelMeta>): ModelsMeta => {
  const out: Record<string, FieldDesc[]> = {}
  const describe = (name: string, fields: Record<string, FieldDef>, implicitId: boolean) => {
    const descs: FieldDesc[] = implicitId ? [{ n: "id", k: "key" }] : []
    for (const [key, d] of Object.entries(fields)) {
      if (isRel(d)) descs.push({ n: key, k: d.kind === "one" ? "one" : "many", m: d.model! })
      else if (d.kind === "struct") {
        const sub = `${name}.${key}`
        const inner: Record<string, FieldDef> = {}
        for (const [ik, f] of Object.entries(d.fields!)) inner[ik] = (f as Field<any, any>).def
        describe(sub, inner, false)
        descs.push({ n: key, k: "one", m: sub })
      }
      else descs.push({ n: key, k: d.isId ? "key" : "body" })
    }
    out[name] = descs
  }
  for (const meta of Object.values(metas)) describe(meta.name, meta.fields, meta.idKeys.length === 0)
  return out
}

const uncap = (s: string) => s.charAt(0).toLowerCase() + s.slice(1)

export type DefineDbOptions = { transport?: MarciTransport }

/**
 * The schema registry: model name = key (`db.user` ← `User`). Relation strings are checked against the
 * keys both at the type level (`ValidateRefs`) and at runtime.
 */
/**
 * The auth models (docs/backend-plan.md §3.4): `auth.model.user()` must be registered as `User`,
 * `auth.model.session()` as `Session`; `Session` is added when the project doesn't declare it (a device
 * session exists before any login), and gets its `user` column exactly when a `User` model exists.
 */
const withAuthModels = (models: Record<string, Model>): { models: Record<string, Model>, auth: AuthBinding } => {
  for (const [name, m] of Object.entries(models)) {
    if (isAuthModel(m, "user") && name !== AUTH_USER_MODEL) throw new Error(`defineDb: auth.model.user() must be registered as "${AUTH_USER_MODEL}" (got "${name}")`)
    if (isAuthModel(m, "session") && name !== AUTH_SESSION_MODEL) throw new Error(`defineDb: auth.model.session() must be registered as "${AUTH_SESSION_MODEL}" (got "${name}")`)
  }
  // A plain `model()` named User is just a model (no auth binding); only auth.model.user() binds.
  const user = isAuthModel(models[AUTH_USER_MODEL], "user") ? models[AUTH_USER_MODEL] : null
  let session = models[AUTH_SESSION_MODEL]
  if (session && !isAuthModel(session, "session")) throw new Error(`defineDb: "${AUTH_SESSION_MODEL}" is reserved for auth.model.session()`)
  if (!session) session = authSessionModel()
  if (user && !("user" in session.fields)) session = { ...session, fields: { ...session.fields, user: sessionUserField() } }
  return { models: { ...models, [AUTH_SESSION_MODEL]: session }, auth: { user: user ? AUTH_USER_MODEL : null, session: AUTH_SESSION_MODEL } }
}

export const defineDb = <const S extends Schema>(declared: S & ValidateRefs<S>, options: DefineDbOptions = {}): Db<S> => {
  const { models, auth } = withAuthModels(declared as Record<string, Model>)
  const metas = buildMeta(models)
  const transport = () => {
    const t = options.transport ?? seam.__lecodesDbTransport
    if (!t) throw new Error("db: no transport — the host must call setDbTransport() before user code runs")
    return t
  }
  // marcidb's own query builder over our metadata; the transport is the only host-specific piece
  const { collection } = createQueryLayer({
    models: modelsMeta(metas),
    run: op => transport().exec(op),
    // a single uuid key is generated client-side so `insert` needs no id (the .marci says @format(uuid))
    prepareInsert: (name, data) => (metas[name].uuidId && data?.id == null ? { ...data, id: crypto.randomUUID() } : data),
  })
  const db: Record<string, unknown> = {
    $schema: toMarci(metas),
    $models: models,
    $auth: auth,
    $transaction: async (ops: unknown[]) => transport().batch(ops.map(opOf)),
  }
  for (const name of Object.keys(metas)) db[uncap(name)] = collection(name)
  ;(seam.__lecodesDbs ??= []).push(db as Db<any>)
  return db as Db<S>
}
