import { Observable, Subscriber } from 'rxjs';
import { compareByteArray } from '../crypto/utility';
import { GlobalCrypto } from '../global-crypto';

export type PayloadContainerVerifyInfo = {
  start: number;
  end: number;
  salt: Uint8Array;
  fingerPrint: Uint8Array;
  size: number;
  underCheck: boolean;
  done: boolean;
};

export type MACInfo = {
  /**
   * salt
   */
  s: Uint8Array;
  /**
   * // chunk size in bytes
   */
  c: number;
  /**
   * // fingerprint
   */
  f: Uint8Array;
};

export type PayloadContainerSubscriptionResult = {
  chunks: any[];
  done: Function;
};

export type FilePayloadContainerDataList = {
  chunk: any;
  mac?: MACInfo;
};

export interface PayloadContainer {
  /**
   * Push the payload into the inner logic
   * Return with the chunks inorder, if can
   * Note: Because of async incoming message, there is a chance we
   * can not give back immediatly the msg
   * @param part
   * @param payload
   * @param mac
   */
  push(part: number, payload: any, mac?: MACInfo): void;
  getLastSuccessPart(): number;
  reset(): void;
  onPayloadPush(): Observable<PayloadContainerSubscriptionResult>;
}

export class ResponsePayloadContainer implements PayloadContainer {
  private payloads: { [key: number]: any } = {}; // all payload for request
  private currPart: number = 0; // always point to the next actual chunk after push
  private partCount: number;
  private observer: Observable<PayloadContainerSubscriptionResult>;
  private observerPusher: Subscriber<PayloadContainerSubscriptionResult>;

  constructor(partCount: number) {
    if (partCount > 0) {
      this.partCount = partCount;
    } else {
      throw new Error('Invalid part count: ' + partCount);
    }

    this.observer = new Observable((observer) => {
      this.observerPusher = observer;
    });
  }

  public push(part: number, payload: any, mac?: MACInfo) {
    this.payloads[part] = payload;

    this.flushNext();
  }

  private flushNext() {
    if (this.currPart == this.partCount) {
      this.observerPusher.complete();
    } else {
      if (this.payloads[this.currPart]) {
        let chunksInOrder = [];

        while (this.payloads[this.currPart]) {
          chunksInOrder.push(this.payloads[this.currPart]);
          delete this.payloads[this.currPart];
          this.currPart++;
        }

        this.observerPusher.next({
          chunks: chunksInOrder,
          done: () => {
            this.flushNext();
          },
        });
      }
    }
  }

  public onPayloadPush() {
    return this.observer;
  }

  public getLastSuccessPart(): number {
    return this.currPart;
  }

  public reset(): void {
    this.payloads = [];
  }
}

export class FilePayloadContainer implements PayloadContainer {
  private payloads: { [key: number]: FilePayloadContainerDataList } = {}; // all payload for request
  private partCount: number;
  private lastVerifiedIndex: number = 0;

  private observer: Observable<PayloadContainerSubscriptionResult>;
  private observerPusher: Subscriber<PayloadContainerSubscriptionResult>;

  private flushInProgress: boolean = false;
  private pushedParts: number = 1; // we do not have the init msg if this is a file payload

  /**
   * if the mac check end, and we remove the not needed parts from
     the cache, the payload container will be empty
     if the user pause at this moment, we should know what was the
     last success interval
   */
  private lastPartAfterClear: number = 0;

  /**
   *
   * @param count How many payload part should we store
   */
  constructor(partCount: number) {
    if (partCount > 0) {
      this.partCount = partCount;
    } else {
      throw new Error('Invalid part count: ' + partCount);
    }

    this.observer = new Observable((observer) => {
      this.observerPusher = observer;
    });
  }

  public onPayloadPush() {
    return this.observer;
  }

  public push(part: number, payload: any, mac?: MACInfo) {
    if (part != 0) {
      this.payloads[part] = {
        chunk: payload,
        mac,
      };
    }

    this.pushedParts++;

    this.flushNext();
  }

  /**
   *
   * @param skipCheck add permission to this call, to recall itself without any async flushNext paralell call
   */
  private flushNext(skipCheck: boolean = false) {
    if (skipCheck || !this.flushInProgress) {
      this.flushInProgress = true;
      let pushedPartsNow = this.pushedParts;

      this.getVerifiedParts()
        .then((chunks) => {
          if (chunks.length > 0 || this.partCount == 1) {
            // there is concated payload or it could be an empty file too
            this.observerPusher.next({
              chunks,
              done: () => {
                if (this.isEnded()) {
                  this.observerPusher.complete();
                  this.flushInProgress = false;
                } else {
                  if (pushedPartsNow < this.pushedParts) {
                    // is there new part, waiting for flush
                    this.flushNext(true);
                  } else {
                    this.flushInProgress = false;
                  }
                }
              },
            });
          } else {
            this.flushInProgress = false;
          }
        })
        .catch((err) => {
          this.observerPusher.error(err);
          this.flushInProgress = false;
        });
    }
  }

  public isEnded() {
    return (
      this.partCount == 1 ||
      (this.lastVerifiedIndex == this.partCount - 1 && Object.keys(this.payloads).length == 0)
    );
  }

  public getLastSuccessPart(): number {
    // get the parts numbers
    let keys = Object.keys(this.payloads);
    if (keys.length == 0) {
      return this.lastPartAfterClear;
    }

    let keysInorder = keys.map((value) => parseInt(value)).sort((a, b) => a - b);

    // select the last, which does not include a part gap
    let currIndex = 1; // ignore the init message
    // while we reached the last element or the next is still the next part number (6,7 or 8,9 etc)
    while (keysInorder[currIndex + 1] && keysInorder[currIndex + 1] - keysInorder[currIndex] == 1) {
      currIndex++;
    }

    if (!keysInorder[currIndex]) {
      // 0, undefined or NaN
      return 0;
    }
    return keysInorder[currIndex];
  }

  public reset(): void {
    this.payloads = {};
  }

  private getVerifiedParts(): Promise<Uint8Array[]> {
    return new Promise(async (resolve, reject) => {
      try {
        let chunks = [];
        let c;
        while ((c = await this.getNextVerifiedPart())) {
          chunks.push(c);
        }

        resolve(chunks);
      } catch (e) {
        reject(e);
      }
    });
  }

  private getNextVerifiedPart(): Promise<Uint8Array> | null {
    // collect and flush all done part
    let start = this.lastVerifiedIndex + 1;
    let end = start;

    while (this.payloads[end] && !this.payloads[end].mac) {
      end++;
    }

    if (this.payloads[end] && this.payloads[end].mac) {
      return this.verifyPart(start, end, this.payloads[end].mac).then((chunk) => {
        this.lastVerifiedIndex = end;
        return chunk;
      });
    } else {
      return null;
    }
  }

  private verifyPart(start, end, info: MACInfo): Promise<Uint8Array> {
    let size = 0;

    // check the sum size before a heavy byte array concat operation
    // the init message does not contains file info, but the others should
    for (let i = start; i <= end; i++) {
      if (this.payloads[i].chunk.fc) {
        size += this.payloads[i].chunk.fc.length;
      } else {
        console.error('payload err', this.payloads);
        return Promise.reject('Payload does not contains file data, can not verify mac');
      }
    }

    if (size != info.c) {
      console.error('payload size error', size, info);
      return Promise.reject('Part size does not match with mac');
    } else {
      // concat the chunkcs
      const joinedFileChunkData = this.joinFileChunks(this.payloads, start, end);

      // we do not need the chunks anymore in this container
      // we will return them or they are corrupted, so need to redownload
      for (let i = start; i <= end; i++) {
        delete this.payloads[i];
      }

      // just to be sure
      if (joinedFileChunkData.length == info.c) {
        // MAC check for parts
        return this.verifyChunk(joinedFileChunkData, info).then(() => {
          this.lastPartAfterClear = Math.max(this.lastPartAfterClear, end);
          return joinedFileChunkData;
        });
      } else {
        console.error('file size error with mac', joinedFileChunkData, info);
        return Promise.reject('Part size does not match(2) with mac');
      }
    }
  }

  /**
   * At random payload position we can get MAC. We have to collect all the MAC before the next
   * and encode it with a hash256. We have to compare it with the server's mac.
   * @param dataArray
   * @param start
   * @param end
   * @returns
   */
  private joinFileChunks(
    dataArray: { [key: number]: FilePayloadContainerDataList },
    start: number,
    end: number
  ) {
    let size = 0;
    for (let i = start; i <= end; i++) {
      size += dataArray[i].chunk.fc.length;
    }

    const arr = new Uint8Array(size);

    let index = 0;

    for (let i = start; i <= end; i++) {
      arr.set(dataArray[i].chunk.fc, index);
      index += dataArray[i].chunk.fc.length;
    }

    return arr;
  }

  private verifyChunk(joinedFileChunkData, info: MACInfo): Promise<void> {
    return this.generateMAC(info.s, joinedFileChunkData).then((mac) => {
      if (compareByteArray(mac, info.f)) {
        return;
      } else {
        return Promise.reject('size with finger print mismatch');
      }
    });
  }

  private generateMAC(salt, dataChunk): any {
    const arr = new Uint8Array(salt.length + dataChunk.length);
    arr.set(salt, 0);
    arr.set(dataChunk, salt.length);

    return GlobalCrypto.SHA512(arr).then((result) => {
      return new Uint8Array(result.slice(0, 32));
    });
  }
}
