/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import { SizeOf, TypeIndex } from "./ctypes";
import { assert, StringToUint8Array, Uint8ArrayToString } from "./support";
import { detectGPUDevice, GPUDeviceDetectOutput } from "./webgpu";
import * as compact from "./compact";
import * as runtime from "./runtime";
import { Disposable } from "./types";

enum RPCServerState {
  InitHeader,
  InitHeaderKey,
  InitServer,
  WaitForCallback,
  ReceivePacketHeader,
  ReceivePacketBody,
}

/** RPC magic header */
const RPC_MAGIC = 0xff271;

/**
 * An utility class to read from binary bytes.
 */
class ByteStreamReader {
  offset = 0;
  bytes: Uint8Array;

  constructor(bytes: Uint8Array) {
    this.bytes = bytes;
  }

  readU32(): number {
    const i = this.offset;
    const b = this.bytes;
    const val = b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24);
    this.offset += 4;
    return val;
  }

  readU64(): number {
    const val = this.readU32();
    this.offset += 4;
    return val;
  }

  readByteArray(): Uint8Array {
    const len = this.readU64();
    assert(this.offset + len <= this.bytes.byteLength);
    const ret = new Uint8Array(len);
    ret.set(this.bytes.slice(this.offset, this.offset + len));
    this.offset += len;
    return ret;
  }
}

/**
 * A websocket based RPC
 */
export class RPCServer {
  url: string;
  key: string;
  socket: WebSocket;
  state: RPCServerState = RPCServerState.InitHeader;
  logger: (msg: string) => void;
  getImports: () => Record<string, unknown>;
  private tensorCacheUrl: string;
  private tensorCacheDevice: string;
  private initProgressCallback?: runtime.InitProgressCallback;
  private asyncOnServerLoad?: (inst: runtime.Instance) => Promise<void>;
  private pendingSend: Promise<void> = Promise.resolve();
  private name: string;
  private inst?: runtime.Instance = undefined;
  private globalObjects: Array<Disposable> = [];
  private serverRecvData?: (header: Uint8Array, body: Uint8Array) => void;
  private currPacketHeader?: Uint8Array;
  private currPacketLength = 0;
  private remoteKeyLength = 0;
  private pendingBytes = 0;
  private buffredBytes = 0;
  private messageQueue: Array<Uint8Array> = [];

  constructor(
    url: string,
    key: string,
    getImports: () => Record<string, unknown>,
    logger: (msg: string) => void = console.log,
    tensorCacheUrl = "",
    tensorCacheDevice = "cpu",
    initProgressCallback: runtime.InitProgressCallback | undefined = undefined,
    asyncOnServerLoad: ((inst: runtime.Instance) => Promise<void>) | undefined = undefined,
  ) {
    this.url = url;
    this.key = key;
    this.name = "WebSocketRPCServer[" + this.key + "]: ";
    this.getImports = getImports;
    this.logger = logger;
    this.tensorCacheUrl = tensorCacheUrl;
    this.tensorCacheDevice = tensorCacheDevice;
    this.initProgressCallback = initProgressCallback;
    this.asyncOnServerLoad = asyncOnServerLoad;
    this.checkLittleEndian();
    this.socket = compact.createWebSocket(url);
    this.socket.binaryType = "arraybuffer";

    this.socket.addEventListener("open", (event: Event) => {
      return this.onOpen(event);
    });
    this.socket.addEventListener("message", (event: MessageEvent) => {
      return this.onMessage(event);
    });
    this.socket.addEventListener("close", (event: CloseEvent) => {
      return this.onClose(event);
    });
  }

  private onClose(_event: CloseEvent): void {
    if (this.inst !== undefined) {
      this.globalObjects.forEach(obj => {
        obj.dispose();
      });
      this.log(this.inst.runtimeStatsText());
      this.inst.dispose();
    }
    if (this.state === RPCServerState.ReceivePacketHeader) {
      this.log("Closing the server in clean state");
      this.log("Automatic reconnecting..");
      new RPCServer(
        this.url, this.key, this.getImports, this.logger,
        this.tensorCacheUrl, this.tensorCacheDevice,
        this.initProgressCallback, this.asyncOnServerLoad);
    } else {
      this.log("Closing the server, final state=" + this.state);
    }
  }

  private onOpen(_event: Event): void {
    // Send the headers
    let bkey = StringToUint8Array("server:" + this.key);
    bkey = bkey.slice(0, bkey.length - 1);
    const intbuf = new Int32Array(1);
    intbuf[0] = RPC_MAGIC;
    this.socket.send(intbuf);
    intbuf[0] = bkey.length;
    this.socket.send(intbuf);
    this.socket.send(bkey);
    this.log("connected...");
    // request bytes: magic + keylen
    this.requestBytes(SizeOf.I32 + SizeOf.I32);
    this.state = RPCServerState.InitHeader;
  }

  /** Handler for raw message. */
  private onMessage(event: MessageEvent): void {
    const buffer = event.data;
    this.buffredBytes += buffer.byteLength;
    this.messageQueue.push(new Uint8Array(buffer));
    this.processEvents();
  }
  /** Process ready events. */
  private processEvents(): void {
    while (this.buffredBytes >= this.pendingBytes && this.pendingBytes != 0) {
      this.onDataReady();
    }
  }
  /** State machine to handle each request */
  private onDataReady(): void {
    switch (this.state) {
      case RPCServerState.InitHeader: {
        this.handleInitHeader();
        break;
      }
      case RPCServerState.InitHeaderKey: {
        this.handleInitHeaderKey();
        break;
      }
      case RPCServerState.ReceivePacketHeader: {
        this.currPacketHeader = this.readFromBuffer(SizeOf.I64);
        const reader = new ByteStreamReader(this.currPacketHeader);
        this.currPacketLength = reader.readU64();
        assert(this.pendingBytes === 0);
        this.requestBytes(this.currPacketLength);
        this.state = RPCServerState.ReceivePacketBody;
        break;
      }
      case RPCServerState.ReceivePacketBody: {
        const body = this.readFromBuffer(this.currPacketLength);
        assert(this.pendingBytes === 0);
        assert(this.currPacketHeader !== undefined);
        this.onPacketReady(this.currPacketHeader, body);
        break;
      }
      case RPCServerState.WaitForCallback: {
        assert(this.pendingBytes === 0);
        break;
      }
      default: {
        throw new Error("Cannot handle state " + this.state);
      }
    }
  }

  private onPacketReady(header: Uint8Array, body: Uint8Array): void {
    if (this.inst === undefined) {
      // initialize server.
      const reader = new ByteStreamReader(body);
      const code = reader.readU32();
      const ver = Uint8ArrayToString(reader.readByteArray());
      const nargs = reader.readU32();

      // nargs=0 means no session_constructor_args (LocalSession request).
      // WASM RPC requires ["rpc.WasmSession", wasm_binary]. Wait for proper init.
      if (nargs === 0) {
        this.log("Received LocalSession init (nargs=0), waiting for WasmSession init...");
        this.requestBytes(SizeOf.I64);
        this.state = RPCServerState.ReceivePacketHeader;
        return;
      }

      const args = [];
      for (let i = 0; i < nargs; ++i) {
        const typeIndex = reader.readU32();
        if (typeIndex === TypeIndex.kTVMFFIRawStr) {
          const str = Uint8ArrayToString(reader.readByteArray());
          args.push(str);
        } else if (typeIndex === TypeIndex.kTVMFFIStr) {
          reader.readU32(); // skip duplicate type_index
          const str = Uint8ArrayToString(reader.readByteArray());
          args.push(str);
        } else if (typeIndex === TypeIndex.kTVMFFIByteArrayPtr) {
          args.push(reader.readByteArray());
        } else if (typeIndex === TypeIndex.kTVMFFIBytes) {
          reader.readU32(); // skip duplicate type_index
          args.push(reader.readByteArray());
        } else {
          throw new Error("cannot support type index " + typeIndex);
        }
      }
      this.onInitServer(args, header, body);
    } else {
      assert(this.serverRecvData !== undefined);
      this.serverRecvData(header, body);
      this.requestBytes(SizeOf.I64);
      this.state = RPCServerState.ReceivePacketHeader;
    }
  }

  /** Event handler during server initialization. */
  private onInitServer(
    args: Array<unknown>,
    header: Uint8Array,
    body: Uint8Array
  ): void {
    // start the server
    assert(args[0] === "rpc.WasmSession");
    assert(this.pendingBytes === 0);

    const asyncInitServer = async (): Promise<void> => {
      assert(args[1] instanceof Uint8Array);
      const inst = await runtime.instantiate(
        args[1].buffer as ArrayBuffer,
        this.getImports(),
        this.logger
      );

      try {
        const output: GPUDeviceDetectOutput | undefined = await detectGPUDevice();
        if (output !== undefined) {
          const label = "WebGPU: "+ output.adapterInfo.description;
          this.log("Initialize GPU device: " + label);
          inst.initWebGPU(output.device);
        } else {
          this.log("Cannot find WebGPU device in the env");
        }
      } catch (err) {
        this.log("Cannnot initialize WebGPU, " + err.toString());
      }

      this.inst = inst;
      // begin scope to allow handling of objects
      this.inst.beginScope();
      if (this.initProgressCallback !== undefined) {
        this.inst.registerInitProgressCallback(this.initProgressCallback);
      }

      if (this.tensorCacheUrl.length != 0) {
        if (this.tensorCacheDevice === "cpu") {
          await this.inst.fetchTensorCache(this.tensorCacheUrl, this.inst.cpu());
        } else {
          assert(this.tensorCacheDevice === "webgpu");
          await this.inst.fetchTensorCache(this.tensorCacheUrl, this.inst.webgpu());
        }
      }

      assert(this.inst !== undefined);
      if (this.asyncOnServerLoad !== undefined) {
        await this.asyncOnServerLoad(this.inst);
      }
      const fcreate = this.inst.getGlobalFunc("rpc.CreateEventDrivenServer");
      const messageHandler = fcreate(
        (cbytes: Uint8Array): runtime.Scalar => {
          assert(this.inst !== undefined);
          if (this.socket.readyState === 1) {
            // WebSocket will automatically close the socket
            // if we burst send data that exceeds its internal buffer
            // wait a bit before we send next one.
            const sendDataWithCongestionControl = async (): Promise<void> => {
              const packetSize = 4 << 10;
              const maxBufferAmount = 4 * packetSize;
              const waitTimeMs = 20;
              for (
                let offset = 0;
                offset < cbytes.length;
                offset += packetSize
              ) {
                const end = Math.min(offset + packetSize, cbytes.length);
                while (this.socket.bufferedAmount >= maxBufferAmount) {
                  await new Promise((r) => setTimeout(r, waitTimeMs));
                }
                this.socket.send(cbytes.slice(offset, end));
              }
            };
            // Chain up the pending send so that the async send is always in-order.
            this.pendingSend = this.pendingSend.then(
              sendDataWithCongestionControl
            );
            // Directly return since the data are "sent" from the caller's pov.
            return this.inst.scalar(cbytes.length, "int32");
          } else {
            return this.inst.scalar(0, "int32");
          }
        },
        this.name,
        this.key
      );
      // message handler should persist across RPC runs
      this.globalObjects.push(
        this.inst.detachFromCurrentScope(messageHandler)
      );
      const writeFlag = this.inst.scalar(3, "int32");

      this.serverRecvData = (header: Uint8Array, body: Uint8Array): void => {
        if (messageHandler(header, writeFlag) === 0) {
          this.socket.close();
        }
        if (messageHandler(body, writeFlag) === 0) {
          this.socket.close();
        }
      };

      // Forward the same init sequence to the wasm RPC.
      // The RPC will look for "rpc.wasmSession"
      // and we will redirect it to the correct local session.
      // register the callback to redirect the session to local.
      const flocal = this.inst.getGlobalFunc("wasm.LocalSession");
      const localSession = flocal();
      assert(localSession instanceof runtime.Module);

      this.inst.registerFunc(
        "rpc.WasmSession",
        (_args: unknown): runtime.Module => {
          return localSession;
        }
      );
      messageHandler(header, writeFlag);
      messageHandler(body, writeFlag);

      this.log("Finish initializing the Wasm Server..");
      this.requestBytes(SizeOf.I64);
      this.state = RPCServerState.ReceivePacketHeader;
      // call process events in case there are bufferred data.
      this.processEvents();
      // recycle all values.
      this.inst.endScope();
    };

    this.state = RPCServerState.WaitForCallback;
    asyncInitServer();
  }

  private log(msg: string): void {
    this.logger(this.name + msg);
  }

  private handleInitHeader(): void {
    const reader = new ByteStreamReader(this.readFromBuffer(SizeOf.I32 * 2));
    const magic = reader.readU32();
    if (magic === RPC_MAGIC + 1) {
      throw new Error("key: " + this.key + " has already been used in proxy");
    } else if (magic === RPC_MAGIC + 2) {
      throw new Error("RPCProxy do not have matching client key " + this.key);
    }
    assert(magic === RPC_MAGIC, this.url + " is not an RPC Proxy");
    this.remoteKeyLength = reader.readU32();
    assert(this.pendingBytes === 0);
    this.requestBytes(this.remoteKeyLength);
    this.state = RPCServerState.InitHeaderKey;
  }

  private handleInitHeaderKey(): void {
    const remoteKey = Uint8ArrayToString(
      this.readFromBuffer(this.remoteKeyLength)
    );
    assert(this.pendingBytes === 0);
    this.requestBytes(SizeOf.I64);
    this.state = RPCServerState.ReceivePacketHeader;
  }

  private checkLittleEndian(): void {
    const a = new ArrayBuffer(4);
    const b = new Uint8Array(a);
    const c = new Uint32Array(a);
    b[0] = 0x11;
    b[1] = 0x22;
    b[2] = 0x33;
    b[3] = 0x44;
    assert(c[0] === 0x44332211, "RPCServer little endian to work");
  }

  private requestBytes(nbytes: number): void {
    this.pendingBytes += nbytes;
  }

  private readFromBuffer(nbytes: number): Uint8Array {
    const ret = new Uint8Array(nbytes);
    let ptr = 0;
    while (ptr < nbytes) {
      assert(this.messageQueue.length != 0);
      const nleft = nbytes - ptr;
      if (this.messageQueue[0].byteLength <= nleft) {
        const buffer = this.messageQueue.shift() as Uint8Array;
        ret.set(buffer, ptr);
        ptr += buffer.byteLength;
      } else {
        const buffer = this.messageQueue[0];
        ret.set(buffer.slice(0, nleft), ptr);
        this.messageQueue[0] = buffer.slice(nleft, buffer.byteLength);
        ptr += nleft;
      }
    }
    this.buffredBytes -= nbytes;
    this.pendingBytes -= nbytes;
    return ret;
  }
}
