// Net — LeCodes multiplayer, the public object (docs/multiplayer-plan.md §4). The transport, roster
// and JSON messages are ./core.ts; kinds, inputs, snapshots and the `Replicated` aspect are
// ./replication.ts. Principles: the schema object is the key and the verbs live on the addressee
// (`Net.send(Msg, d)` / `player.send(Msg, d)` / `Net.broadcast(Msg, d)` / `Net.on(Msg, cb)`); the
// player is the unit of addressing; offline / host / client / server run the same game code.

import {
  CH_RELIABLE, CH_UNRELIABLE, LOCAL_SLOT, NetPlayer, bridge, dispatchMessage, emit, ensureMe, queueLocal,
  removePlayer, resetToOffline, sendRaw, startPhases, state,
  type Listener, type NetEvents, type NetLaunch, type NetMessage, type NetRole, type NetStatus,
} from "./core"
import {
  NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats,
  type NetInput, type NetKind, type NetTransform,
} from "./replication"

export { NetPlayer, NetEntity, Replicated }
export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform }

class NetImpl {
  get role(): NetRole { return state.role }
  get status(): NetStatus { return state.status }
  get isServer(): boolean { return state.isServer }
  get isClient(): boolean { return state.isClient }
  /** True on hosts that ship the transport (desktop, the 3d/full Android variants, iOS). Web: false. */
  get supported(): boolean { return bridge() !== null }
  /** What the exe was launched as (`--server --port N` / `--connect ADDR`), or null. */
  get launch(): NetLaunch | null { return (bridge()?.launch() as NetLaunch | null) ?? null }
  get me(): NetPlayer { return state.role === "server" ? (null as unknown as NetPlayer) : ensureMe() }
  get players(): readonly NetPlayer[] { if (state.role !== "server") ensureMe(); return state.players }
  player(id: number): NetPlayer | undefined { return state.players.find((p) => p.id === id) }
  /** Our round trip to the server (client), 0 otherwise. */
  get rtt(): number { return state.role === "client" ? (bridge()?.clientInfo(0)[1] ?? 0) : 0 }
  /** The simulation tick — one per fixed step (`updateFixed`). The server's is THE tick; a client
   *  runs a few ticks ahead so its inputs arrive in time. */
  get tick(): number { return currentTick() }
  /** Live counters: snapshots, input lead / `ahead`, corrections, missed inputs, bytes out. */
  get stats(): typeof stats { return stats }

  /** Start serving on `port`. From offline this makes us the host (a player who is also the
   *  server); on the dedicated exe (`Net.launch.role === "server"`) there is no local player. */
  listen(opts: { port?: number; maxClients?: number; name?: string; dedicated?: boolean } = {}): boolean {
    const b = bridge()
    if (!b) { emit("error", "multiplayer is not supported on this host"); return false }
    if (state.role !== "offline") this.disconnect()
    if (opts.name) state.myName = opts.name
    // `dedicated` (no local player) defaults to how the exe was launched; a windowed --server that
    // still wants its own player passes `dedicated: false`.
    const dedicated = opts.dedicated ?? this.launch?.role === "server"
    if (!b.listen(opts.port ?? 40000, opts.maxClients ?? 16)) {
      state.status = "error"
      emit("error", `could not listen on port ${opts.port ?? 40000}`)
      return false
    }
    state.role = dedicated ? "server" : "host"
    state.isServer = true
    state.isClient = !dedicated
    state.status = "online"
    if (!dedicated) ensureMe()
    installReplication()
    startPhases()
    emit("connect")
    return true
  }

  /** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us. */
  connect(target: { address: string }, opts: { name?: string } = {}): Promise<void> {
    const b = bridge()
    if (!b) return Promise.reject(new Error("multiplayer is not supported on this host"))
    if (state.role !== "offline") this.disconnect()
    if (opts.name) state.myName = opts.name
    return new Promise<void>((resolve, reject) => {
      if (!b.connect(target.address)) { reject(new Error(`bad address '${target.address}'`)); return }
      state.role = "client"
      state.isServer = false
      state.isClient = true
      state.status = "connecting"
      state.connectResolve = resolve
      state.connectReject = reject
      installReplication()
      startPhases()
    })
  }

  /** Client → offline. Host/server → everyone is dropped, back to offline. */
  disconnect(): void {
    const b = bridge()
    if (state.role === "offline") return
    const wasClient = state.role === "client"
    const players = state.players.slice()
    b?.disconnect()
    resetToOffline()
    if (wasClient) emit("disconnect", "disconnected")
    else for (const p of players) if (p._slot !== LOCAL_SLOT) removePlayer(p, "server closed")
  }

  // ---- messages: JSON, typed in TS only -------------------------------------------------------------

  /** Declare a message type once; the name is the wire id. `unreliable` = channel 1 (fire-and-forget). */
  message<T = void>(name: string, opts: { unreliable?: boolean } = {}): NetMessage<T> {
    if (name.startsWith("$")) throw new Error("message names starting with '$' are reserved")
    return { name, unreliable: !!opts.unreliable }
  }

  /** Client → server (offline / host: delivered in-process, `from` = our own player). */
  send<T>(msg: NetMessage<T>, data: T): void {
    if (state.role === "client") {
      sendRaw(0, msg.unreliable ? CH_UNRELIABLE : CH_RELIABLE, JSON.stringify([msg.name, data]))
      return
    }
    if (state.role === "server") throw new Error("Net.send is client-side; the server uses player.send / Net.broadcast")
    const me = ensureMe()
    queueLocal(() => dispatchMessage(msg.name, data, me))
  }

  /** Server → every player (incl. the host's own), minus `except`. */
  broadcast<T>(msg: NetMessage<T>, data: T, opts: { except?: NetPlayer | null } = {}): void {
    if (!state.isServer) throw new Error("Net.broadcast is server-side")
    const payload = JSON.stringify([msg.name, data])
    const channel = msg.unreliable ? CH_UNRELIABLE : CH_RELIABLE
    for (const p of state.players) {
      if (p === opts.except) continue
      if (p._slot === LOCAL_SLOT) queueLocal(() => dispatchMessage(msg.name, data, null))
      else sendRaw(p._slot, channel, payload)
    }
  }

  /** Subscribe to a built-in event or to a message (`from` = the sending player on the server,
   *  null on a client — the server sent it). Returns `Net` for chaining. */
  on<K extends keyof NetEvents>(event: K, cb: NetEvents[K]): this
  on<T>(msg: NetMessage<T>, cb: (data: T, from: NetPlayer | null) => void): this
  on(key: any, cb: Listener): this {
    const map = typeof key === "string" ? state.events : state.handlers
    const name = typeof key === "string" ? key : key.name
    const list = map.get(name) ?? []
    if (!list.includes(cb)) list.push(cb)
    map.set(name, list)
    return this
  }

  off<K extends keyof NetEvents>(event: K, cb: NetEvents[K]): this
  off<T>(msg: NetMessage<T>, cb: (data: T, from: NetPlayer | null) => void): this
  off(key: any, cb: Listener): this {
    const map = typeof key === "string" ? state.events : state.handlers
    const name = typeof key === "string" ? key : key.name
    const list = map.get(name)
    if (!list) return this
    const i = list.indexOf(cb)
    if (i >= 0) list.splice(i, 1)
    return this
  }

  // ---- replication: defaults object = layout --------------------------------------------------------

  /** The per-tick input layout (one per game): `const PlayerInput = Net.input({ moveX: 0, moveY: 0, jump: false })`.
   *  Fields: number → f32, boolean → 1 bit, number[] → f32 × length. */
  input<T extends object>(defaults: T): NetInput<T> { return declareInput(defaults) }

  /** Set this tick's input — from `updateFixed`, once per tick, on the machine that owns the player
   *  (client / host / offline). The owner's sim reads it back through `rep.input(PlayerInput)`; a
   *  client also ships it to the server for the same tick. */
  setInput<T extends object>(inp: NetInput<T>, values: Partial<T>): void { setInput(inp, values) }

  /** A replicated entity type: the defaults are the snapshot layout (same field rules as `input`),
   *  `transform` tells the SDK which fields mirror the node's pose, `client` births the proxy on a
   *  client when the server spawns one (`new Node().aspect(Replicated, { kind, owner })`). */
  kind<S extends object>(name: string, defaults: S, opts: { transform?: NetTransform; client?: (rep: NetEntity<S>, scene: any) => any } = {}): NetKind<S> {
    return declareKind(name, defaults, opts as any)
  }

  /** Every replicated entity this machine knows. */
  get entities(): readonly NetEntity[] { return allEntities() }
  entity(netId: number): NetEntity | undefined { return entityById(netId) }

  /** Dev only: yojimbo's network simulator on our side of the wire (latency/jitter in ms, loss in %). */
  simulate(opts: { latency?: number; jitter?: number; loss?: number }): void {
    bridge()?.simulate(opts.latency ?? 0, opts.jitter ?? 0, opts.loss ?? 0)
  }
}

export const Net = new NetImpl()
