import YUVBuffer, {YUVFrame} from "yuv-buffer";
import {EventEmitter} from "events";
import {FrameSink} from "yuv-canvas";
import {isElectron, isNode, Logger} from "@solid/libs";
import TypedEventEmitter from "typed-emitter";
import { Metadata, UUID } from "@solid/types";

declare global {
  type VN_PlayerOptions = {
    emit: (event: VNPlayerTopic, ...args: Parameters<VNPlayerEvent[VNPlayerTopic]>) => boolean,
    objid: string,
    vsync: boolean,
    muted: boolean,
    cache_size: number, // Mb
    jitter_buffer_len: number, // ms
    frame_buffers_num: number,
    log_level: string
  }

  interface VN_NativePlayer {
    new(options: VN_PlayerOptions): VN_NativePlayer;
  }

  type FrameInfo = {
    new: boolean,
    buf_pos: number,
    ts: number,
    meta: string
  }

  class VN_NativePlayer {
    constructor(options: VN_PlayerOptions);

    // internal logic methods
    set_img_buffer(buf: ArrayBuffer, ext_buf: ArrayBuffer): void;
    frame_unlock(buf_pos: number): void;
    get_info_and_lock_frame_if_ready(): FrameInfo;

    // control methods
    play(url: string, speed: number): void;
    pause(): void;
    resume(direction: number): void;
    teardown(): void;
    jump(timestamp: number): void;
    speed(speed: number): void;
    muteAudio(mute: boolean): void;
    setJitterBufferLen(length: number): void;
    crash(): void;
  }

  interface Window {
    setZeroTimeout: (fn: () => void) => void;
  }
}

let VN_NativePlayer: VN_NativePlayer | undefined = undefined;

const loadLib = () => {
  if (VN_NativePlayer) {
    return;
  }

  // lib can be loaded in node or electron only
  if (!isElectron() && !isNode()) {
    return;
  }

  try {
    const environment = isNode() ? "node" : "electron";

    const platformToImport: Partial<Record<NodeJS.Platform, any>> = {
      "win32": {
        "electron": () => require("@libs/vn-player-win32/binding/electron/vn_player.node"),
        "node": () => require("@libs/vn-player-win32/binding/node/vn_player.node")
      },
      "darwin": {
        "electron": () => require("@libs/vn-player-darwin/binding/electron/vn_player.node"),
        "node": () => require("@libs/vn-player-darwin/binding/node/vn_player.node")
      },
      "linux": {
        "electron": () => require("@libs/vn-player-linux/binding/electron/vn_player.node"),
        "node": () => require("@libs/vn-player-linux/binding/node/vn_player.node")
      }
    };
    if (!platformToImport[process.platform] && !platformToImport[process.platform][environment]) {
      console.assert(`'${process.platform}' '${environment}' does not supported`);
    }

    // console.log(`loading VN_NativePlayer '${process.platform}' '${environment}'`);
    const vn_player = platformToImport[process.platform][environment]();
    VN_NativePlayer = vn_player.VN_Player;
    // console.log("VN_NativePlayer", VN_NativePlayer);
  }
  catch (e) {
    console.error(e);
  }
};

// Only add setZeroTimeout to the window object, and hide everything
// else in a closure. (http://dbaron.org/log/20100309-faster-timeouts)
const fnSetZeroTimeout = () => {
  //if(typeof window === 'undefined' || typeof window.setZeroTimeout === 'function')
  //    return

  const timeouts: (() => void)[] = [];
  const messageName = "zero-timeout-message";

  // Like setTimeout, but only takes a function argument.  There's
  // no time argument (always zero) and no arguments (you have to
  // use a closure).
  function setZeroTimeout(fn: () => void) {
    timeouts.push(fn);
    window.postMessage(messageName, "*");
  }

  function handleMessage(event: any) {
    if (event.source === window && event.data === messageName) {
      event.stopPropagation();
      if (timeouts.length > 0) {
        const fn = timeouts.shift();
        fn && fn();
      }
    }
  }

  window.addEventListener("message", handleMessage, true);

  // Add the one thing we want added to the window object.
  window.setZeroTimeout = setZeroTimeout;
};

if (typeof window !== 'undefined' && typeof window.setZeroTimeout !== 'function') {
  fnSetZeroTimeout();
}
const zero_timeout_enabled = typeof window !== 'undefined' && typeof window.setZeroTimeout === 'function';

type VNPlayerEvent = {
  new_frame: (info: FrameInfo) => void,
  buffer_changed: () => void,
  new_stream: () => void,
  state_changed: (state: VNPlayerStreamState, code: VNPlayerStopCode, message: string) => void,
  recording_status_changed: (state: VNPlayerRecordingStatus, timestamp: number, filename: string, message: string) => void,
  stream_removed: () => void,
  msg_log: (message: string) => void,
  frame: (time: number, metadata?: [Metadata]) => void,
  new_size: (size: { width: number, height: number }) => void,
  create_image_buffer: (w: number, h: number, ext_buf: ArrayBuffer) => void
}
type VNPlayerTopic = keyof VNPlayerEvent;

export enum VNPlayerStreamState {
  IDLE = 0,             /* initial state, or timeout occurred if PLAY_* status was previous state */
  PLAY_FROM_SERVER = 1, /* playing stream from server */
  PLAY_FROM_BUFFER = 2, /* playing stream from cache */
  STOPPED = 3,          /* player stopped */
  OPENING = 4,          /* opening stream */
  BUFFERING = 5,        /* buffering stream */
  OPENED = 6            /* stream opened */
}

export enum VNPlayerRecordingStatus {
  RECORDING_IDLE = 0,    /* initial state, or timeout occurred if PLAY_* status was previous state */
  RECORDING_STARTED = 1, /* recording started */
  RECORDING_ENDING = 2,  /* waiting for all frames to be recorded */
  RECORDING_ENDED = 3,   /* recording ended */
  RECORDING_ERROR = 4,   /* some error occurred */
  RECORDING_SKIPPED = 5  /* skip chunk recording and continue to store the rest if it is already stored or in storing process */
}

export enum VNPlayerStopCode {
  RTCPTIMEOUT = 10000,
  EENDOFSTREAM = 10001,
  EMEDIAFORMATCHANGED = 10002,
  EUNKNOWNSTREAMTYPE = 10003,
  EENDOFCHUNK = 10004,
  EENDOFARCHIVE = 10005,
  EINTERNALERROR = 10100
}

type VNPlayerOptions = {
  scale?: boolean,
  vsync?: boolean,
  muted?: boolean,
  cell_w?: number,
  cell_h?: number,
  cache_size?: number,
  jitter_buffer_len?: number,
  frame_buffers_num?: number,
  log_level?: "info" | "debug",
  logger?: Logger | Console
};

export class VNPlayer {
  _logger: Logger | Console;

  private _jitterBufferLength: number = 1000; // ms
  private _muted: boolean = false;

  private _play: (obj: UUID, url: string, speed: number) => Promise<void>;
  private _pause: () => void;
  private _resume: (direction?: number) => void;
  private _teardown: () => void;
  private _resize: (cw: number, ch: number) => void;
  private _jump: (timestamp: number) => void;
  private _speed: (speed: number) => void;
  private _muteAudio: (mute: boolean) => void;
  private _setJitterBufferLen: (length: number) => void;
  private _crash: () => void;
  private _on: TypedEventEmitter<VNPlayerEvent>["on"];// (topic: VNPlayerTopic, callback: (...args: any[]) => void) => void;
  private _getSize: () => { width: number | undefined; height: number | undefined; };

  constructor(yuvCanvas: FrameSink | undefined, options: VNPlayerOptions) {
    loadLib();

    this._logger = options.logger ?? console;

    this._logger.log("VNPlayer constructor");

    const logger = this._logger;
    let _objid: string;
    let _native_vn_player: VN_NativePlayer | null = null;
    let _rendering: boolean = false;
    //let _cell_w = options.cell_w ?? undefined // may be resized
    //let _cell_h = options.cell_h ?? undefined // may be resized
    let _cell_size: { width?: number, height?: number } = {
      width: options.cell_w,
      height: options.cell_h
    };
    let _stream_size: { width: number, height: number } = {
      width: 0,
      height: 0
    };
    const _scale: boolean = options.scale ?? true;
    const _vsync: boolean = options.vsync ?? true;
    const _yuvCanvas = yuvCanvas;
    const _cache_size: number = options.cache_size ?? 2; // Mb
    const _buf_num: number = options.frame_buffers_num ?? 3; // number of frame buffers to use
    const _log_level: string = options.log_level ?? 'info'; // debug

    if (typeof options.jitter_buffer_len === "number") {
      this._jitterBufferLength = options.jitter_buffer_len;
    }
    if (typeof options.muted === "boolean") {
      this._muted = options.muted;
    }

    const _emitter = new EventEmitter() as TypedEventEmitter<VNPlayerEvent>;
    let _frame: YUVFrame[] = new Array(_buf_num);

    // used in 'new_frame' callback when vsync disabled
    // this.drawSingleFrameAsync = (num: number, callback: () => void) => {
    function _drawSingleFrameAsync(num: number, callback: () => void) {
      if (!zero_timeout_enabled /*|| !yuvCanvas*/) { // console app?
        callback();
        return;
      }

      //const now = performance.now()
      window.setZeroTimeout(() => {
        try {
          const frame = _frame[num];
          if (!frame) {
            logger.log("ERROR:", "frame is undefined");
          } else {
            //this._logger.log(`[${objid}]`, 'timeout before drawing frame:', Math.round((performance.now() - now)*1000), 'usecs')
            _yuvCanvas?.drawFrame(frame);
            //this._logger.log(`[${objid}]`, 'drawing the frame took', Math.round((performance.now() - now)*1000), 'usecs')
          }
        }
        catch (error: any) {
          logger.log('ERROR:', error.message);
        }
        finally {
          callback();
        }
      });
    }

    // Used when vsync enabled.
    // This is the most preferred rendering method, since, apart from synchronization
    // with the monitor refresh rate, it does not depend on the overflow of the frame
    // queue.
    async function _render(this: any) {
      this._logger.log(`[${_objid}] rendering started`);
      _rendering = true;
      do {
        if (_native_vn_player) {
          const obj = _native_vn_player.get_info_and_lock_frame_if_ready();
          if (obj.new) {
            const buf_pos = obj.buf_pos;
            const frame = _frame[buf_pos];
            if (!frame) {
              logger.log("ERROR:", "frame is undefined");
            } else {
              _yuvCanvas?.drawFrame(frame);
              _native_vn_player.frame_unlock(buf_pos);
            }

            onNewFrame(obj.ts, obj.meta);
            this.onNewFrame(obj.ts, obj.meta);
          }
        }

        await new Promise(window.requestAnimationFrame);
      } while (_rendering);

      this._logger.log(`[${_objid}] rendering stopped`);
    }

    function onNewFrame(ts: number, meta: string) {
      //this._logger.log("onNewFrame", ts, meta);
      let metadata;
      if (meta) {
        try {
          metadata = JSON.parse(meta);
        }
        catch (e) {
          logger.error(e);
        }
        //this._logger.log("onNewFrame", ts, metadata);
      }

      _emitter.emit("frame", ts / 1000, metadata);
    }

    _emitter.on("create_image_buffer", (width, height, ext_buf) => {
      this._logger.log(`[${_objid}] create_image_buffer ${width}x${height}`);

      if (!_native_vn_player) {
        this._logger.error("_native_vn_player is not defined");
        return;
      }

      const chromaWidth = Math.round(width / 2);
      const chromaHeight = Math.round(height / 2);
      const luma_len = width * height;
      const chroma_len = chromaWidth * chromaHeight;
      const total_len = luma_len + 2 * chroma_len; // luma_len*1.5

      const format = YUVBuffer.format({
        width: width,
        height: height,
        chromaWidth: chromaWidth,
        chromaHeight: chromaHeight,
        displayWidth: _scale && _cell_size.width !== undefined ? _cell_size.width : width,
        displayHeight: _scale && _cell_size.height !== undefined ? _cell_size.height : height
        //cropWidth: width
      });

      const buf = new ArrayBuffer(_buf_num * total_len);
      for (let i = 0; i < _buf_num; i++) {
        const offset = i * total_len;

        const frame = YUVBuffer.frame(format);
        frame.y.bytes = new Uint8Array(buf, offset, luma_len);
        frame.u.bytes = new Uint8Array(buf, offset + luma_len, chroma_len);
        frame.v.bytes = new Uint8Array(buf, offset + luma_len + chroma_len, chroma_len);

        _frame[i] = frame;
      }

      _native_vn_player.set_img_buffer(buf, ext_buf);

      _stream_size = {
        width: width,
        height: height
      };
      _emitter.emit("new_size", _stream_size);
    });

    _emitter.on('new_frame', (frameInfo) => {
      //this._logger.log("new_frame", frameInfo);
      if (!frameInfo.new) return;

      if (!_native_vn_player) {
        this._logger.error("new_frame, _native_vn_player is undefined");
        return;
      }

      _drawSingleFrameAsync.call(this, frameInfo.buf_pos, () => {
        _native_vn_player?.frame_unlock(frameInfo.buf_pos);
        //const now = performance.now()

        onNewFrame(frameInfo.ts, frameInfo.meta);
        this.onNewFrame(frameInfo.ts, frameInfo.meta);

        // this._logger.log('onNewFrame() took:', Math.round((performance.now() - now)*1000), 'usecs')
      });
    });

    /*_emitter.on("new_frame", (...args) => {
      const time = args[0].ts;
      this._logger.log("new_frame", new Date(time / 1000), "; Args:", args);
    });*/
    _emitter.on("buffer_changed", (...args) => {
      this._logger.log("buffer_changed", ...args);
    });
    _emitter.on("new_stream", (...args) => {
      this._logger.log("new_stream", ...args);
    });
    _emitter.on("state_changed", (state, code, message) => {
      const codeEnum = (code in VNPlayerStopCode) ? VNPlayerStopCode[code] : "unknown code";
      this._logger.log("state_changed", `state=${state} (${VNPlayerStreamState[state]})`, `code=${code}`, code ? `(${codeEnum})` : "", message);

      if (!_native_vn_player) {
        this._logger.error("state_changed, _native_vn_player is undefined");
        return;
      }

      this.onStateChanged(state, code, message);
      if (!_vsync || _yuvCanvas === undefined) {
        return;
      }

      //this._logger.log(`[${_objid}] state_changed to ${state}`)
      switch (state) {
        case 3:
          _rendering = false;
          break;
        case 6:
          _render.call(this);
          break;
      }
    });

    _emitter.on("recording_status_changed", (state, timestamp, filename, message) => {
      this._logger.log("recording_status_changed", `state=${state} (${VNPlayerRecordingStatus[state]})`, `timestamp=${timestamp}`, `filename=${filename}`, message);

      if (!_native_vn_player) {
        this._logger.error("recording_status_changed, _native_vn_player is undefined");
        return;
      }

      this.onRecordingStatusChanged(state, timestamp, filename, message);
    });

    _emitter.on("stream_removed", (...args) => {
      this._logger.log("stream_removed", ...args);
    });

    _emitter.on("msg_log", (...args) => {
      this._logger.log("msg_log", ...args);
    });

    const _loadVNPlayer = () => {
      logger.log("VNPlayer loadVNPlayer", _objid);

      if (!VN_NativePlayer) {
        throw new Error("VN_Player lib is not loaded");
      }

      if (!_objid) {
        throw new Error("_objid is not defined");
      }

      try {
        // TODO: remove objid from player constructor
        logger.log("create new VN_NativePlayer");
        _native_vn_player = new VN_NativePlayer({
          emit: _emitter.emit.bind(_emitter),
          objid: _objid,
          vsync: _vsync,
          muted: this._muted,
          cache_size: _cache_size,      // Mb
          jitter_buffer_len: this._jitterBufferLength,
          frame_buffers_num: _buf_num,
          log_level: _log_level
        });
      }
      catch (e) {
        logger.error("loadVNPlayer", e);
      }
    };

    async function _init(obj: UUID) {
      logger.log("VNPlayer init", obj);

      _objid = obj;
      _loadVNPlayer();
    }

    this._play = async (obj: UUID, url: string, speed: number) => {
      this._logger.log(`[${obj}] Let's play url: ${url}`);
      //this._logger.log("VNPlayer play", obj, url);

      if (!url) {
        this._logger.error("url is undefined");
        return;
      }

      if (!_native_vn_player) {
        await _init(obj);
      }

      if (!_native_vn_player) {
        this._logger.error("_play, _native_vn_player is undefined");
        return;
      }

      this._logger.log(`[${_objid}] Let's play url: ${url}`);
      _native_vn_player.play(url, speed);
    };

    this._pause = () => {
      this._logger.log(`[${_objid}] VNPlayer pause`);
      //this._logger.log("VNPlayer pause", _objid);

      _rendering = false;

      if (!_native_vn_player) {
        this._logger.error("_pause, _native_vn_player is undefined");
        return;
      }

      _native_vn_player.pause();
    };

    this._resume = (direction = 1) => {
      this._logger.log(`[${_objid}] VNPlayer resume, direction: ${direction}`);
      //this._logger.log("VNPlayer resume", _objid);

      if (_vsync && _yuvCanvas !== undefined) {
        _render.call(this);
      }

      if (!_native_vn_player) {
        this._logger.error("_resume, _native_vn_player is undefined");
        return;
      }

      _native_vn_player.resume(direction);
    };

    this._teardown = () => {
      _rendering = false;
      _frame = new Array(_buf_num);

      this._logger.log(`[${_objid}] VNPlayer teardown`);

      _yuvCanvas?.clear();
      if (!_native_vn_player) {
        return;
      }

      _native_vn_player.teardown();
      _native_vn_player = null;

      this.forceGC(); // after forceGC ,the c++ destructor function will call
    };

    this._resize = (cw: number, ch: number) => {
      this._logger.log(`[${_objid}] cell resized to ${cw}x${ch}`);
      //this._logger.log("VNPlayer resize", _objid, cw, ch)

      _yuvCanvas?.clear();

      if (!_scale) {
        return;
      }
      if (!_native_vn_player) {
        this._logger.error("_resize, _native_vn_player is undefined");
        return;
      }

      for (let i = 0; i < _buf_num; i++) {
        const frame = _frame[i];
        if (!frame) {
          continue;
        }
        frame.format.displayWidth = _cell_size.width = cw;
        frame.format.displayHeight = _cell_size.height = ch;
      }

      //_emitter.emit("new_size", _cell_size);
    };

    this._jump = (timestamp: number) => {
      this._logger.log(`[${_objid}] jump to timestamp: ${timestamp}`);
      //this._logger.log("VNPlayer jump", _objid);

      if (!_native_vn_player) {
        this._logger.error("_jump, _native_vn_player is undefined");
        return;
      }

      _native_vn_player.jump(timestamp);
    };

    this._speed = (speed: number) => {
      this._logger.log(`[${_objid}] set speed to: ${speed}`);

      if (!_native_vn_player) {
        this._logger.error("_speed, _native_vn_player is undefined");
        return;
      }

      _native_vn_player.speed(speed);
    };

    this._muteAudio = (mute: boolean) => {
      this._logger.log(`[${_objid}] muteAudio: ${mute}`);

      if (!_native_vn_player) {
        this._logger.error("_muteAudio, _native_vn_player is undefined");
        return;
      }

      _native_vn_player.muteAudio(mute);
    };

    this._setJitterBufferLen = (length: number) => {
      this._logger.log(`[${_objid}] setJitterBufferLen: ${length}`);

      if (!_native_vn_player) {
        this._logger.error("_setJitterBufferLen, _native_vn_player is undefined");
        return;
      }

      _native_vn_player.setJitterBufferLen(length);
    };

    this._crash = () => {
      _native_vn_player ? _native_vn_player.crash()
                        : this._logger.error("crash(): _native_vn_player is undefined");
    }

    this._on = (topic: VNPlayerTopic, callback: VNPlayerEvent[VNPlayerTopic]): TypedEventEmitter<VNPlayerEvent> => {
      const topicList = new Set<VNPlayerTopic>([
        "new_frame",
        "buffer_changed",
        "new_stream",
        "state_changed",
        "recording_status_changed",
        "stream_removed",
        "msg_log",

        "frame",
        "new_size"
      ]);

      if (!topicList.has(topic)) {
        throw {message: "incorrect topic name"};
      }

      return _emitter.on(topic, callback);
    };

    this._getSize = () => {
      return _stream_size;
    };
  };

  // Following methods may be extended with useful logic in the derived classes

  /**
   *
   * @param obj
   * @param url
   * @param speed >=1 - increase speed forward, <=1 - increase speed backward, (-1, 1) - slowdown speed
   */
  async play(obj: UUID, url: string, speed: number): Promise<void> {
    await this._play(obj, url, speed);
  }

  pause() {
    this._pause();
  }

  /**
   * @param {-1|1} direction
   */
  resume(direction: number) {
    this._resume(direction);
  }

  teardown() {
    this._teardown();
  }

  // Since VN_Player base class can be used also in a node console application,
  // the resize method must be called explicitly on cell resizing.
  // Please use following ds-electron's commit as an example of handling cells resize:
  // https://bb.videonext.com/projects/DIS/repos/ds-electron/commits/60cacfdad8e821571a26307d15b2f02da9af1cf0

  /**
   * @param {number} cw, cell width
   * @param {number} ch, cell height
   */
  resize(cw: number, ch: number) {
    this._resize(cw, ch);
  }

  /**
   * @param {number} timestamp, sec
   */
  jump(timestamp: number) {
    this._jump(timestamp);
  }

  speed(speed: number) {
    this._speed(speed);
  }

  muteAudio(mute: boolean) {
    this._muted = mute;
    this._muteAudio(mute);
  }

  setJitterBufferLen(length: number) {
    this._jitterBufferLength = length;
    this._setJitterBufferLen(length);
  }

  crash() {
    this._crash();
  }

  onNewFrame(ts: number, meta: string) {
    // this._logger.log('Pure VN_Player.onNewFrame() method called. It would be better to extend it in a derived class.')
  }

  onStateChanged(state: VNPlayerStreamState, code: number, msg: string) {
    // this._logger.log('Pure VN_Player.onStateChanged() method called. It would be better to extend it in a derived class.')
  }

  onRecordingStatusChanged(state: VNPlayerRecordingStatus, timestamp: number, filename: string, msg: string) {
    // this._logger.log('Pure VN_Player.onRecordingStatusChanged() method called. It would be better to extend it in a derived class.')
  }

  on(topic: VNPlayerTopic, callback: (...args: any[]) => void) {
    this._on(topic, callback);
  }

  getSize() {
    return this._getSize();
  }

  private forceGC() {
    if (global.gc) {
      global.gc();
    } else {
      this._logger.warn('No GC hook! Add `--expose-gc` to execution options.');
    }
  }
}
