class DeviceEvent extends Event {
  constructor(name, device) {
    super(name);
    this.device = device;
  }
}

class DeviceCommandEvent extends DeviceEvent {
  constructor(name, device, cmd, data) {
    super(name, device);
    this.cmd = cmd;
    this.data = data;
  }
}

class DeviceUpdateEvent extends DeviceEvent {
  constructor(device, name, data) {
    super("update", device);
    this.name = name;
    this.data = data;
  }
}

class DeviceErrorEvent extends DeviceEvent {
  constructor(device, error) {
    super("error", device);
    this.error = error;
  }
}

class DeviceConnectionStatusEvent extends DeviceEvent {
  constructor(device, status) {
    super("connectionstatus", device);
    this.status = status;
  }
}

class Device extends EventEmitter {
  /** @type {Connection[]} */
  active_connections = [];
  /** @type {object} */
  info;

  #input_queue;
  #pingTimer;

  // device
  prev_set = {};

  skip_prd = 1000;  // skip updates
  tout_prd = 2500;  // connection timeout

  // external
  granted = false;
  cfg_flag = false;

  /**
   * @param {GyverHub} hub 
   * @param {string} id 
   */
  constructor(hub, id) {
    super();
    this._hub = hub;
    this.info = hub.config.getDevice(id);
    this.#input_queue = new InputQueue(1000, 1000);  // TODO config
    this.#pingTimer = new AsyncTimer(3000, async () => {
      try {
        await this.#postAndWait('ping', ['OK']);
        this.dispatchEvent(new DeviceConnectionStatusEvent(this, true));
      } catch (e) {
        console.log('[PING]', e);
        this.dispatchEvent(new DeviceConnectionStatusEvent(this, false));
      }
      this.#pingTimer.restart();
    })
  }

  /**
   * Check if module is enabled
   * @param {Module} mod 
   * @returns {boolean}
   */
  isModuleEnabled(mod) {
    return !(this.info.modules & mod);
  }

  //#region Communication

  /**
   * Send command to device
   * @param {string} cmd 
   * @param {string} name 
   * @param {string} value 
   */
  async #post(cmd, name = '', value = '') {
    if (cmd == 'set' && name && this.isModuleEnabled(Modules.SET)) {
      if (this.prev_set[name]) clearTimeout(this.prev_set[name]);
      this.prev_set[name] = setTimeout(() => delete this.prev_set[name], this.skip_prd);
    }

    console.log('[OUT]', this.info.id, cmd, name, value);
    await this.getConnection().post(this, cmd, name, value);
  }

  /**
   * Send command to device and wait for response
   * @param {string} cmd Command
   * @param {string[]} types List of possible responses
   * @param {string} name 
   * @param {string} value 
   * @returns {Promise<[string, object]>}
   */
  async #postAndWait(cmd, types, name = '', value = '') {
    this.dispatchEvent(new DeviceEvent("transferstart", this));
    let res;
    try {
      await this.#post(cmd, name, value);
      res = await this.#input_queue.get(types);
    } catch (e) {
      this.dispatchEvent(new DeviceEvent("transfererror", this));
      this.dispatchEvent(new DeviceEvent("transferend", this));
      throw e;
    }
    this.dispatchEvent(new DeviceEvent("transfersuccess", this));
    this.dispatchEvent(new DeviceEvent("transferend", this));
    return res;
  }

  async _parse(type, data) {
    this.#input_queue.put(type, data);
    this.dispatchEvent(new DeviceCommandEvent("command", this, type, data));
    this.dispatchEvent(new DeviceCommandEvent("command." + type, this, type, data));

    switch (type) {
    case 'ui':
      await this.#postAndWait('unix', ['OK'], Math.floor(new Date().getTime() / 1000));
      break;

    case 'refresh':
      await this.updateUi();
      break;

    case 'update':
      this._checkUpdates(data.updates);
      break;

    case 'error':
      this.dispatchEvent(new DeviceErrorEvent(this, new DeviceError(data.code)));
    }
  }

  //#endregion

  //#region Connection

  /**
   * Check if device is accessable by http.
   * @returns {boolean}
   */
  isHttpAccessable() {
    return this.active_connections.some(conn => conn instanceof HTTPConnection);
  }

  /**
   * Get primary connection
   * @returns {Connection | null}
   */
  getConnection() {
    return this.active_connections.length ? Array_maxBy(this.active_connections, conn => conn.priority) : null;
  }

  /**
   * Check if device is connected to hub.
   * @returns {boolean}
   */
  isConnected() {
    return this.active_connections.length !== 0;
  }

  /**
   * Add new active connection to device.
   * @param {Connection} conn 
   */
  addConnection(conn) {
    if (this.active_connections.includes(conn)) return;

    this.active_connections.push(conn);
    this.dispatchEvent(new DeviceEvent('connectionchanged', this));

    conn.addEventListener('statechange', e => {
      switch (e.state) {
        case ConnectionState.DISCONNECTED:
          Array_remove(this.active_connections, conn);
          this.dispatchEvent(new DeviceEvent('connectionchanged', this));
          break;
      }
    })
  }

  //#endregion

  //#region Interraction > Common

  async getInfo() {
    const [type, data] = await this.#postAndWait('info', ['info', 'OK']);
    if (type === 'info') return data.info;
    return undefined;
  }

  async reboot() {
    await this.#postAndWait('reboot', ['OK']);
  }

  async sendCli(command) {
    await this.#postAndWait('cli', ['OK'], 'cli', command);
  }

  //#endregion

  //#region Interraction > UI

  async updateUi(){
    return (await this.#postAndWait('ui', ['ui']))[1];
  }

  async set(name, value){
    const [cmd, data] = await this.#postAndWait('set', ['ui', 'ack'], name, value);
    if (cmd === 'ui') return data;
    if (data.name !== name) throw new HubError("set / ack check failed!");
    return undefined;
  }

  _checkUpdates(updates) {
    if (typeof updates !== 'object')
      return;

    for (const [keys, data] of Object.entries(updates)) {
      if (typeof data !== 'object')
        continue;
      if ('value' in data && this.prev_set[keys])
        delete data.value;
      if (!Object.keys(data).length)
        continue;

      const names = keys.includes(';') ? keys.split(';') : [keys];
      for (const name of names)
        this.dispatchEvent(new DeviceUpdateEvent(this, name, data));
    }
  }

  async focus() {
    if (this.info.ws_port && this._hub._ws && this.active_connections.some(conn => conn instanceof HTTPConnection)) {
      const ws = this._hub._ws;

      await ws.disconnect();
      ws.options.ip = this.info.ip;
      ws.options.port = this.info.ws_port;
      ws.options.enabled = true;
      await ws.connect();
      if (!ws.isConnected()) {
        await ws.disconnect();
      } else {
        this.addConnection(ws)
      }
    }

    this.#pingTimer.start();
    return await this.updateUi();
  }

  async unfocus() {
    this.#pingTimer.cancel();
    await this.#post('unfocus');
    if (this._hub._ws) await this._hub._ws.disconnect();
  }

  //#endregion

  //#region Interraction > FS

  async deleteFile(path) {
    return (await this.#postAndWait('delete', ['files'], path))[1];
  }

  async createFile(path) {
    return (await this.#postAndWait('mkfile', ['files'], path))[1];
  }

  async renameFile(path, new_name) {
    return (await this.#postAndWait('rename', ['files'], path, new_name))[1];
  }

  async formatFS() {
    return (await this.#postAndWait('format', ['files']))[1];
  }

  async updateFileList() {
    return (await this.#postAndWait('files', ['files']))[1];
  }

  async fsStop() {
    if (this.isModuleEnabled(Modules.FETCH) || this.isModuleEnabled(Modules.UPLOAD) || this.isModuleEnabled(Modules.OTA))
      await this.#post('fs_abort', 'all');
  }

  async upload(file, path, progress = undefined) {
    if (!this.isModuleEnabled(Modules.UPLOAD)) 
      throw new DeviceError(HubErrors.Disabled);

    const data = await readFileAsArrayBuffer(file);
    const buffer = new Uint8Array(data);

    const crc = crc32(buffer);

    if (this.isHttpAccessable() && this.info.http_t) {
      let formData = new FormData();
      formData.append('upload', file, "upload");
      await http_post(`http://${this.info.ip}:${this.info.http_port}/hub/upload?path=${path}&crc32=${crc}&client_id=${this._hub.clientId}&size=${buffer.length}`, formData)
    } else {
      if (!progress) progress = () => {};

      const upl_bytes = Array.from(buffer);
      const upl_size = upl_bytes.length;
      const max_enc_len = this.info.max_upl * 3 / 4 - 60;

      let [cmd, data] = await this.#postAndWait('upload', ['upload_next', 'upload_err'], path, upl_size);

      if (cmd === 'upload_next')
        [cmd, data] = await this.#postAndWait('upload_chunk', ['upload_next', 'upload_err'], 'crc', crc);

      while (cmd === 'upload_next') {
        const data2 = String.fromCharCode.apply(null, upl_bytes.splice(0, max_enc_len));
        progress(Math.round((upl_size - upl_bytes.length) / upl_size * 100));
        if (upl_bytes.length)
          [cmd, data] = await this.#postAndWait('upload_chunk', ['upload_next', 'upload_err'], 'next', window.btoa(data2));
        else
          [cmd, data] = await this.#postAndWait('upload_chunk', ['upload_done', 'upload_err'], 'last', window.btoa(data2));
      }

      if (cmd === 'upload_err')
        throw new DeviceError(data.code);
    }

    await this.updateFileList();
  }

  async fetch(path, type, progress = undefined) {
    if (!this.isModuleEnabled(Modules.FETCH))
      throw new DeviceError(HubErrors.Disabled);

    if (!progress) progress = () => {};

    if (this.isHttpAccessable() && this.info.http_t) {
      return await http_fetch_blob(`http://${this.info.ip}:${this.info.http_port}/hub/fetch?path=${path}&client_id=${this._hub.clientId}`, 
          type, progress, this._hub.config.get('connections', 'HTTP', 'request_timeout'));

    } else {
      let [cmd, data] = await this.#postAndWait('fetch', ['fetch_start', 'fetch_err'], path);
      let fet_len, fet_buf;
      if (cmd === 'fetch_start') {
        fet_len = data.len;
        fet_buf = '';
        [cmd, data] = await this.#postAndWait('fetch_next', ['fetch_chunk', 'fetch_err']);
        progress(0);
      }

      while (cmd === 'fetch_chunk') {
        fet_buf += atob(data.data);

        if (data.last) { 
          if (fet_buf.length != fet_len)
            throw new DeviceError(HubErrors.SizeMiss);
  
          const crc = crc32(fet_buf);
          if (crc != data.crc32)
            throw new DeviceError(HubErrors.CrcMiss);

          if (type === 'url')
            return `data:${getMime(path)};base64,${btoa(fet_buf)}`;
          else if (type === 'text')
            return new TextDecoder().decode(Uint8Array.from(fet_buf, (m) => m.codePointAt(0)));
        }

        // not last chunk
        progress(Math.round(fet_buf.length / fet_len * 100));
        [cmd, data] = await this.#postAndWait('fetch_next', ['fetch_chunk', 'fetch_err']);
      }

      if (cmd === 'fetch_err')
        throw new DeviceError(data.code);
    }
  }
  
  //#endregion

  //#region Interraction > OTA

  async otaUrl(type, url) {
    const [t, data] = await this.#postAndWait('ota_url', ['ota_url_ok', 'ota_url_err'], type, url);
    if (t === 'ota_url_err')
      throw new DeviceError(data.code);
  }

  async uploadOta(file, type, progress = undefined) {
    if (!this.isModuleEnabled(Modules.OTA)) 
      throw new DeviceError(HubErrors.Disabled);

    if (this.isHttpAccessable() && this.info.http_t) {
      let formData = new FormData();
      formData.append(type, file, "ota");
      await http_post(`http://${this.info.ip}:${this.info.http_port}/hub/ota?type=${type}&client_id=${this._hub.clientId}`, formData)
    } else {
      if (!progress) progress = () => {};

      const fdata = await readFileAsArrayBuffer(file);
      const buffer = new Uint8Array(fdata);
      const ota_bytes = Array.from(buffer);
      const ota_size = ota_bytes.length;
      const max_enc_len = this.info.max_upl * 3 / 4 - 60;

      let [cmd, data] = await this.#postAndWait('ota', ['ota_next', 'ota_done', 'ota_err'], type);

      while (cmd === 'ota_next') {
        const data2 = String.fromCharCode.apply(null, ota_bytes.splice(0, max_enc_len));
        progress(Math.round((ota_size - ota_bytes.length) / ota_size * 100));
        [cmd, data] = await this.#postAndWait('ota_chunk', ['ota_next', 'ota_done', 'ota_err'], (ota_bytes.length) ? 'next' : 'last', window.btoa(data2));
      }

      if (cmd === 'ota_err')
        throw new DeviceError(data.code);
    }
  }

  //#endregion
};