/**
 * Host side of a compiled server bundle — used by the runner (packages/lecodes-runner) and by tests.
 * Imported normally (Node/Bun), NOT through chisel: the bundle carries its own SDK copy and talks
 * to this module only through globals (`__lecodesServer`, `__lecodesRequest`, `__lecodesPublish`).
 *
 *   evaluate the bundle (it sets globalThis.__lecodesServer)
 *   const server = loadServerModules()          // classify exports, name channels
 *   const invoke = createInvoker(server)        // validate → run in request context → envelope
 *   await invoke("notes.server.ts#listNotes", [20], { headers, ip })
 */

import { AsyncLocalStorage } from "node:async_hooks"
import type { ServerManifest } from "../compile/serverSplit"
import type { AuthHost } from "./auth/host"
import type { AuthState } from "./auth/types"
import { setChannelPublisher, type Channel } from "./channel"
import { setRequestProvider, type RequestContext } from "./context"
import type { Db } from "./db/types"
import { takeRegisteredDbs } from "./db/defineDb"
import { validateArgs, ValidationError, type ParamSchema } from "./validate"

export type ServerRegistration = { modules: Record<string, Record<string, unknown>>, manifest: ServerManifest }

export type LoadedServer = {
  endpoints: Map<string, (...args: any[]) => unknown>
  channels: Map<string, Channel<any>>
  /** Every `defineDb` handle — exported or not (the bundle registers them on globalThis; normally exactly one). */
  dbs: Db<any>[]
  manifest: ServerManifest
}

const isDb = (v: unknown): v is Db<any> => typeof v === "object" && v !== null && typeof (v as Db<any>).$schema === "string"
const isChannel = (v: unknown): v is Channel<any> => typeof v === "object" && v !== null && (v as Channel<any>).__channel === true

/** Read the registration a server bundle left on `globalThis.__lecodesServer` (or a given one). */
export const loadServerModules = (
  reg: ServerRegistration | undefined = (globalThis as any).__lecodesServer,
): LoadedServer => {
  if (!reg) throw new Error("loadServerModules: no server bundle registered (globalThis.__lecodesServer)")
  const out: LoadedServer = { endpoints: new Map(), channels: new Map(), dbs: [], manifest: reg.manifest }
  for (const [mod, exports] of Object.entries(reg.modules)) {
    for (const [name, value] of Object.entries(exports)) {
      const id = `${mod}#${name}`
      if (typeof value === "function") out.endpoints.set(id, value as (...args: any[]) => unknown)
      else if (isChannel(value)) { value.__id = id; out.channels.set(id, value) }
      else if (isDb(value)) out.dbs.push(value)
    }
  }
  // Non-exported handles (`const db = defineDb(...)`): the bundle's SDK copy registered them on globalThis.
  for (const db of takeRegisteredDbs()) if (!out.dbs.includes(db)) out.dbs.push(db)
  return out
}

/** The wire envelope. `session` = a token the client must store from now on (new guest session / rotation). */
export type InvokeResult =
  | { ok: true, result: unknown, session?: string }
  | { ok: false, status: number, message: string, session?: string }

export type InvokerOptions = {
  /** Validate args against the manifest's param schemas (default true; endpoints without a schema pass). */
  validate?: boolean
  /** Where unexpected (non-ApiError) failures are reported. Default: console.error. */
  onError?: (id: string, error: unknown) => void
  /** The auth host (./auth/host.ts): resolves the bearer token into `ctx.auth` before each call. Absent = no sessions. */
  auth?: AuthHost | null
}

// The bundle's ApiError is a different class object than ours (its own SDK copy) — duck-type it.
const apiStatus = (e: unknown): number | null =>
  e && typeof e === "object" && (e as { name?: string }).name === "ApiError" && typeof (e as { status?: unknown }).status === "number"
    ? (e as { status: number }).status : null

/**
 * Build the invoker: `invoke(id, args, ctx)` runs endpoint `id` inside an AsyncLocalStorage-scoped
 * request context (what the `request`/`auth` globals read) and returns the wire envelope.
 */
// One request store for endpoints AND channel guards (a second store would steal the provider).
const als = new AsyncLocalStorage<RequestContext>()
const installProvider = () => setRequestProvider(() => als.getStore())

// A token minted/rotated during the call rides on the envelope (the client transport stores it).
const withSession = (r: InvokeResult, rc: RequestContext): InvokeResult => {
  const issued = (rc.auth as AuthState | undefined)?.issuedToken
  return issued ? { ...r, session: issued } : r
}

export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) => {
  installProvider()
  const validate = opts.validate ?? true
  const onError = opts.onError ?? ((id, e) => console.error(`[server] ${id}:`, e))
  const params = server.manifest.params as Record<string, ParamSchema[]> | undefined
  const auth = opts.auth ?? null

  return async (id: string, args: unknown[], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
    const fn = server.endpoints.get(id)
    if (!fn) return { ok: false, status: 404, message: `Unknown endpoint ${id}` }
    if (!Array.isArray(args)) return { ok: false, status: 400, message: "args must be an array" }
    const rc: RequestContext = { ...ctx, id }
    try {
      if (validate && params?.[id]) validateArgs(params[id], args)
      if (auth) await auth.resolve(rc)
      const result = await als.run(rc, () => fn(...args))
      return withSession({ ok: true, result: result === undefined ? null : result }, rc)
    } catch (e) {
      const status = apiStatus(e)
      if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
      if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
      onError(id, e)
      return withSession({ ok: false, status: 500, message: "Internal error" }, rc)
    }
  }
}

/** Run a channel's `onJoin` guard for a subscription (same request scoping as endpoints). */
export const createJoinGuard = (server: LoadedServer, opts: Pick<InvokerOptions, "auth"> = {}) => {
  installProvider()
  const auth = opts.auth ?? null
  return async (channelId: string, topic: string, ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
    const ch = server.channels.get(channelId)
    if (!ch) return { ok: false, status: 404, message: `Unknown channel ${channelId}` }
    const rc: RequestContext = { ...ctx, id: channelId }
    try {
      if (auth) await auth.resolve(rc)
      await als.run(rc, () => ch.options.onJoin?.(topic))
      return withSession({ ok: true, result: null }, rc)
    } catch (e) {
      const status = apiStatus(e)
      return withSession({ ok: false, status: status ?? 500, message: status !== null ? (e as Error).message : "Internal error" }, rc)
    }
  }
}

export { setChannelPublisher, setRequestProvider }
