// Net core — the transport half of LeCodes multiplayer (docs/multiplayer-plan.md §4–5): roles,
// connect / listen, players + roster, JSON messages, the poll pump. Replication (inputs, snapshots,
// `Replicated`) lives in ./replication.ts and plugs into this file through `hooks`; the public `Net`
// object is ./index.ts.
//
// Roles (`Net.role`):
//   offline  the default — both sides run in this process, `Net.send` delivers in-process
//   host     offline + `Net.listen()`: a player who is also the server (listen server)
//   server   the dedicated exe (`--server`): `isServer` true, `isClient` false, no local player
//   client   connected to a host or a server
// Game code is written once: `Net.isServer` guards the rules, `Net.isClient` the presentation,
// and offline satisfies both.
//
// Transport: the `_creatorNet` bridge (creator-pkg over creator-net / yojimbo) — a byte pipe with
// two channels (0 reliable-ordered, 1 unreliable) that knows nothing about players. Everything on
// the reliable channel here is JSON text: the roster protocol (`$`-prefixed, reserved) and the
// game's own `Net.message`s (`[name, data]`). Binary payloads (inputs, snapshots) go to
// ./replication.ts. Pumped from the aspect dispatcher's phase hooks (core/Aspect.ts) — poll FIRST in
// the early phase, flush LAST in the late one — claimed on the first listen/connect, never at
// module scope (chisel drops top-level side effects, and a host without the bridge has no loop).

import { _ensureNetDispatch, _phaseHooks } from "../core/Aspect"

export type Listener = (...args: any[]) => void

export type NetRole = "offline" | "host" | "client" | "server"
export type NetStatus = "idle" | "connecting" | "online" | "error"

/** A typed message key: `const Fire = Net.message<{ dir: number[] }>("fire")`. The type lives in
 *  TypeScript only; the payload is JSON. */
export type NetMessage<T = unknown> = { readonly name: string; readonly unreliable: boolean; readonly __t?: T }

export type NetEvents = {
  /** Client: welcomed by the server. Host/server: listening. */
  connect: () => void
  /** The connection ended (`reason` from the transport, e.g. "timed out", "kicked"). */
  disconnect: (reason: string) => void
  /** The server refused us (`reason`: "full" | "version" | "schema" | a kick reason). */
  reject: (reason: string) => void
  join: (player: NetPlayer) => void
  leave: (player: NetPlayer, reason: string) => void
  error: (message: string) => void
}

export type NetLaunch = { role: "server" | "client"; address: string; port: number; maxClients: number }

export const CH_RELIABLE = 0
export const CH_UNRELIABLE = 1
export const PROTO = 2
export const LOCAL_SLOT = -1

// Record kinds from creator-net (creator-net.h CNET_RECORD_*).
const REC_CONNECTED = 1
const REC_DISCONNECTED = 2
const REC_MESSAGE = 3
const REC_CONNECT_FAILED = 4

export const bridge = (): typeof _creatorNet | null => (typeof _creatorNet !== "undefined" ? _creatorNet : null)

export class NetPlayer {
  /** Session-unique id assigned by the server (1 = the host's own player when hosting). */
  readonly id: number
  name: string
  readonly connectedAt: number
  /** A local bag for game facts (score, team, ready). NOT replicated — anything a client must see
   *  goes through a message or replicated state. */
  data: any = {}
  /** @internal transport slot on the server; LOCAL_SLOT for the in-process player */
  _slot: number
  /** @internal server side: this player's input ring (replication.ts) */
  _in: any = null
  private _leave: Listener[] = []

  constructor(id: number, name: string, slot: number) {
    this.id = id
    this.name = name
    this._slot = slot
    this.connectedAt = Date.now()
  }

  get isMe(): boolean { return state.me === this }
  /** Round trip to this player in ms (server side) or to the server (our own player on a client). */
  get rtt(): number { return infoOf(this)[1] }
  get loss(): number { return infoOf(this)[2] }

  /** Server → this player. */
  send<T>(msg: NetMessage<T>, data: T): void {
    if (!state.isServer) throw new Error("player.send is server-side; a client uses Net.send")
    deliverTo(this, msg, data)
  }

  kick(reason = "kicked"): void {
    if (!state.isServer || this._slot === LOCAL_SLOT) return
    sendRaw(this._slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
    bridge()?.kick(this._slot)
  }

  on(event: "leave", cb: (player: NetPlayer, reason: string) => void): this {
    if (!this._leave.includes(cb)) this._leave.push(cb)
    return this
  }
  off(event: "leave", cb: (player: NetPlayer, reason: string) => void): this {
    const i = this._leave.indexOf(cb)
    if (i >= 0) this._leave.splice(i, 1)
    return this
  }
  /** @internal */
  _left(reason: string): void {
    for (const fn of this._leave.slice()) fn(this, reason)
  }
}

// ---- state ---------------------------------------------------------------------------------------

export const state = {
  role: "offline" as NetRole,
  status: "idle" as NetStatus,
  isServer: true,
  isClient: true,
  me: null as NetPlayer | null,
  players: [] as NetPlayer[],
  nextId: 1,
  /** a session is up: the phase hooks are installed and pump every frame */
  active: false,
  events: new Map<string, Listener[]>(),
  handlers: new Map<string, Listener[]>(),
  connectResolve: null as null | (() => void),
  connectReject: null as null | ((e: Error) => void),
  myName: "Player",
}

// Replication's seams into the roster / pump (set by replication.ts when a session starts — from a
// function, never at module scope). Each is optional so the core runs alone.
export const hooks = {
  frameEarly: null as null | ((dt: number) => void),
  frameFixed: null as null | ((dt: number) => void),
  frameLate: null as null | ((dt: number) => void),
  /** client: extra `$hello` fields (schema hash) */
  helloExtra: null as null | (() => Record<string, unknown>),
  /** server: a reject reason for this `$hello`, or null to accept */
  helloCheck: null as null | ((data: any) => string | null),
  /** server: extra `$welcome` fields (tick, kind table) */
  welcomeExtra: null as null | (() => Record<string, unknown>),
  /** server: right after `$welcome` + `$join` went out — send the live entities to the newcomer */
  afterWelcome: null as null | ((p: NetPlayer) => void),
  /** client: the `$welcome` arrived (before the connect event) */
  onWelcome: null as null | ((data: any) => void),
  /** client: a reserved `$` message the roster does not know (spawn / despawn) */
  clientReserved: null as null | ((name: string, data: any) => void),
  /** binary payloads (channel 1): inputs on the server, snapshots on the client */
  serverBinary: null as null | ((p: NetPlayer, channel: number, data: ArrayBuffer) => void),
  clientBinary: null as null | ((channel: number, data: ArrayBuffer) => void),
  /** server: a player is gone (after the leave event) */
  playerLeft: null as null | ((p: NetPlayer) => void),
  /** the session ends (before the state resets); `role` = the role we are leaving */
  reset: null as null | ((role: NetRole) => void),
}

export const emit = (event: keyof NetEvents, ...args: any[]): void => {
  const list = state.events.get(event)
  if (!list) return
  for (const fn of list.slice()) fn(...args)
}

const EMPTY_INFO = new Float32Array(5)
const infoOf = (p: NetPlayer): Float32Array => {
  const b = bridge()
  if (!b || p._slot === LOCAL_SLOT && state.role !== "client") return EMPTY_INFO
  return b.clientInfo(p._slot)
}

export const playerBySlot = (slot: number): NetPlayer | undefined => state.players.find((p) => p._slot === slot)

export const addPlayer = (id: number, name: string, slot: number): NetPlayer => {
  const p = new NetPlayer(id, name, slot)
  state.players.push(p)
  return p
}

export const removePlayer = (p: NetPlayer, reason: string): void => {
  const i = state.players.indexOf(p)
  if (i >= 0) state.players.splice(i, 1)
  p._left(reason)
  emit("leave", p, reason)
  if (state.isServer) hooks.playerLeft?.(p)
}

// The local player exists in every role but `server`.
export const ensureMe = (): NetPlayer => {
  if (state.me) return state.me
  state.me = addPlayer(state.nextId++, state.myName, LOCAL_SLOT)
  return state.me
}

// ---- transport --------------------------------------------------------------------------------------

export const sendRaw = (slot: number, channel: number, payload: string | ArrayBuffer): boolean => {
  const b = bridge()
  return b ? b.send(slot, channel, payload) : false
}

// Handler dispatch is in-process for the local player (offline / host's own), async so a message
// sent from inside a handler behaves like one that crossed the wire (ordered, never re-entrant).
export const queueLocal = (fn: () => void): void => { Promise.resolve().then(fn) }

export const dispatchMessage = (name: string, data: unknown, from: NetPlayer | null): void => {
  const list = state.handlers.get(name)
  if (!list) return
  for (const fn of list.slice()) fn(data, from)
}

export const deliverTo = (p: NetPlayer, msg: NetMessage<any>, data: unknown): void => {
  if (p._slot === LOCAL_SLOT) {
    queueLocal(() => dispatchMessage(msg.name, data, null))
    return
  }
  sendRaw(p._slot, msg.unreliable ? CH_UNRELIABLE : CH_RELIABLE, JSON.stringify([msg.name, data]))
}

export const broadcastRaw = (payload: string | ArrayBuffer, except: NetPlayer | null, channel = CH_RELIABLE): void => {
  for (const p of state.players) {
    if (p === except || p._slot === LOCAL_SLOT) continue
    sendRaw(p._slot, channel, payload)
  }
}

// The session's frame plumbing: poll first thing in the early phase, replication's own work around
// it, sends flushed at the end of the late phase. The dispatcher (and its fixed phase — Net.tick is
// a fixed-step count) is forced on, so an app with no aspects at all still pumps.
export const startPhases = (): void => {
  if (state.active) return
  state.active = true
  _ensureNetDispatch()
  _phaseHooks.early = (dt) => { pump(); if (state.active) hooks.frameEarly?.(dt) }
  _phaseHooks.fixed = (dt) => { if (state.active) hooks.frameFixed?.(dt) }
  _phaseHooks.late = (dt) => { if (state.active) hooks.frameLate?.(dt) }
}

export const stopPhases = (): void => {
  if (!state.active) return
  state.active = false
  _phaseHooks.early = _phaseHooks.fixed = _phaseHooks.late = null
}

export const resetToOffline = (): void => {
  hooks.reset?.(state.role)
  stopPhases()
  state.role = "offline"
  state.status = "idle"
  state.isServer = true
  state.isClient = true
  state.players = []
  state.me = null
  state.nextId = 1
  state.connectResolve = null
  state.connectReject = null
}

// ---- server side ------------------------------------------------------------------------------------

const onServerRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
  const b = bridge()!
  if (kind === REC_CONNECTED) return   // the roster starts with the client's $hello
  if (kind === REC_DISCONNECTED) {
    const p = playerBySlot(slot)
    if (!p) return
    const reason = b.reasonString(arg, true)
    removePlayer(p, reason)
    broadcastRaw(JSON.stringify(["$leave", { id: p.id, reason }]), null)
    return
  }
  if (kind !== REC_MESSAGE) return
  if (payload instanceof ArrayBuffer) {
    const from = playerBySlot(slot)
    if (from) hooks.serverBinary?.(from, arg, payload)
    return
  }
  if (typeof payload !== "string") return
  let parsed: any
  try { parsed = JSON.parse(payload) } catch { return }
  if (!Array.isArray(parsed) || typeof parsed[0] !== "string") return
  const [name, data] = parsed
  if (name === "$hello") {
    if (playerBySlot(slot)) return
    const reason = !data || data.proto !== PROTO ? "version" : hooks.helloCheck?.(data) ?? null
    if (reason) {
      sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
      b.kick(slot)
      return
    }
    const p = addPlayer(state.nextId++, String(data.name ?? `Player ${state.nextId - 1}`), slot)
    sendRaw(slot, CH_RELIABLE, JSON.stringify(["$welcome", {
      id: p.id,
      players: state.players.filter((q) => q !== p).map((q) => ({ id: q.id, name: q.name })),
      ...(hooks.welcomeExtra?.() ?? {}),
    }]))
    broadcastRaw(JSON.stringify(["$join", { id: p.id, name: p.name }]), p)
    hooks.afterWelcome?.(p)
    emit("join", p)
    return
  }
  if (name.startsWith("$")) return
  const from = playerBySlot(slot)
  if (from) dispatchMessage(name, data, from)
}

// ---- client side ------------------------------------------------------------------------------------

const onClientRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
  const b = bridge()!
  if (kind === REC_CONNECTED) {
    sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, ...(hooks.helloExtra?.() ?? {}) }]))
    return
  }
  if (kind === REC_CONNECT_FAILED || kind === REC_DISCONNECTED) {
    const reason = b.reasonString(arg, false)
    const wasOnline = state.status === "online"
    const reject = state.connectReject
    resetToOffline()
    if (kind === REC_CONNECT_FAILED) {
      state.status = "error"
      reject?.(new Error(reason))
      emit("error", reason)
    } else {
      reject?.(new Error(reason))
      if (wasOnline) emit("disconnect", reason)
    }
    return
  }
  if (kind !== REC_MESSAGE) return
  if (payload instanceof ArrayBuffer) { hooks.clientBinary?.(arg, payload); return }
  if (typeof payload !== "string") return
  let parsed: any
  try { parsed = JSON.parse(payload) } catch { return }
  if (!Array.isArray(parsed) || typeof parsed[0] !== "string") return
  const [name, data] = parsed
  switch (name) {
    case "$welcome": {
      state.me = addPlayer(data.id, state.myName, LOCAL_SLOT)
      for (const q of data.players ?? []) addPlayer(q.id, q.name, 0)
      state.status = "online"
      hooks.onWelcome?.(data)
      const resolve = state.connectResolve
      state.connectResolve = null
      state.connectReject = null
      resolve?.()
      emit("connect")
      return
    }
    case "$reject": {
      const reason = String(data?.reason ?? "rejected")
      const reject = state.connectReject
      state.connectReject = null
      state.connectResolve = null
      reject?.(new Error(reason))
      emit("reject", reason)
      return
    }
    case "$join": {
      const p = addPlayer(data.id, data.name, 0)
      emit("join", p)
      return
    }
    case "$leave": {
      const p = state.players.find((q) => q.id === data.id)
      if (p) removePlayer(p, String(data.reason ?? ""))
      return
    }
  }
  if (name.startsWith("$")) { hooks.clientReserved?.(name, data); return }
  dispatchMessage(name, data, null)
}

export const pump = (): void => {
  const b = bridge()
  if (!b) return
  const batch = b.poll()
  if (!batch) return
  const onRecord = state.isServer ? onServerRecord : onClientRecord
  for (let i = 0; i + 3 < batch.length; i += 4) {
    onRecord(batch[i], batch[i + 1], batch[i + 2], batch[i + 3])
    if (!state.active) return   // a record tore the session down
  }
}
