import seedrandom from "seedrandom";

import { DataItem, Node } from "../..";
import { generateIndexPairs, sleep, standardizeJSON } from "../../utils";

/**
 * runCache is the other main execution thread for collecting data items
 * which will get packed into bundles and submitted to the network
 * in order to archive them. This method should run indefinitely.
 *
 * This method stays in sync with the bundle proposal rounds
 * where the other main method "runNode" takes part. It works
 * by running in parallel to the validation and submission of
 * bundle proposals. When data needs to be validated or proposed
 * the other method simply looks in the globally available cache
 * and checks if this method already added some items into it.
 *
 * It starts by getting the current pool index and checking at
 * from which index to which the node has to collect the data items
 * in order to participate in the current proposal round.
 *
 * After a bundle proposal got finalized the cache gets cleared of
 * all finalized data items since they are not needed anymore and
 * starts collecting the data items which are needed for the
 * following round.
 *
 * @method runCache
 * @param {Node} this
 * @return {Promise<void>}
 */
export async function runCache(this: Node): Promise<void> {
  // run rounds indefinitely, continueRound returns always
  // true and is only used by unit tests to control the termination of
  // rounds by mocking it
  while (this.continueRound()) {
    try {
      // if there is no storage id we can assume that the last
      // bundle has been dropped or invalidated. In that case we
      // reset the cache
      if (!this.pool.bundle_proposal!.storage_id) {
        this.logger.debug(`this.cacheProvider.drop()`);
        await this.cacheProvider.drop();

        this.m.cache_current_items.set(0);
      }

      // determine the creation time of the current bundle proposal
      // if the creation time ever increases this means a new bundle
      // proposal is available
      const updatedAt = parseInt(this.pool.bundle_proposal!.updated_at);

      // determine the current index of the pool. All data items
      // before the current index can be deleted since they are already
      // finalized. Data items should always be cached from this index
      // and not before
      const currentIndex = parseInt(this.pool.data!.current_index);

      // determine the target index. Here the target index is the
      // index the cache should collect data in this particular round.
      // We start from the current index and first index all the way
      // to the current bundle proposal. Since the next uploader
      // creates a bundle starting from the current bundle proposal
      // we further index to the maximum possible bundle size ahead
      const targetIndex =
        currentIndex +
        parseInt(this.pool.bundle_proposal!.bundle_size) +
        parseInt(this.pool.data!.max_bundle_size);

      // delete all data items which came before the current index
      // because they got finalized and are not needed anymore
      this.logger.debug(
        `Deleting data from index ${Math.max(
          0,
          currentIndex - parseInt(this.pool.data!.max_bundle_size)
        )} to index ${currentIndex}`
      );

      for (
        let i = Math.max(
          0,
          currentIndex - parseInt(this.pool.data!.max_bundle_size)
        );
        i < currentIndex;
        i++
      ) {
        try {
          this.logger.debug(`this.cacheProvider.del(${i.toString()})`);
          await this.cacheProvider.del(i.toString());

          this.m.cache_current_items.dec();
        } catch {
          continue;
        }
      }

      this.m.cache_index_tail.set(Math.max(0, currentIndex - 1));

      // determine the start key for the current caching round
      // this key gets increased overtime to temp save the
      // current key while collecting the data items
      let key = this.pool.data!.current_key;

      // collect all data items from current pool index to
      // the target index
      this.logger.debug(
        `Caching from index ${currentIndex} to index ${targetIndex}`
      );

      for (let i = currentIndex; i < targetIndex; i++) {
        // check if data item was already collected. If it was
        // already collected we don't need to retrieve it again
        this.logger.debug(`this.cacheProvider.exists(${i.toString()})`);
        const itemFound = await this.cacheProvider.exists(i.toString());

        // retrieve the next key from the deterministic runtime
        // specific implementation. If the start key is not defined
        // the pool is in genesis state and therefore the pool
        // specific start key should be used
        if (key) {
          this.logger.debug(`this.runtime.nextKey(${key})`);
        }

        const nextKey = key
          ? await this.runtime.nextKey(key)
          : this.pool.data!.start_key;

        if (!itemFound) {
          // collect and transform data from every source at once
          const results: DataItem[] = await Promise.all(
            this.poolConfig.sources.map((source: string) =>
              this.saveGetTransformDataItem(source, nextKey)
            )
          );

          // validate if data items from those multiple sources are
          // valid against each other
          let valid = true;

          // we generate all possible index pairs so we can cross-validate
          // each data item with every other data item to ensure that
          // everything is correct
          const indexPairs = generateIndexPairs(results.length);

          // validate every data item for each possible index pair
          for (const pair of indexPairs) {
            try {
              // validate pair of data items
              valid = await this.runtime.validateDataItem(
                this,
                results[pair[0]],
                results[pair[1]]
              );

              // if an invalid data item pair was found abort and don't save
              // to cache
              if (!valid) {
                this.logger.info(
                  `Found mismatching data item between sources ${
                    this.poolConfig.sources[pair[0]]
                  } and ${this.poolConfig.sources[pair[1]]}`
                );
                break;
              }
            } catch (err) {
              this.logger.error(
                `Unexpected error validating data items between sources ${
                  this.poolConfig.sources[pair[0]]
                } and ${this.poolConfig.sources[pair[1]]}`
              );
              this.logger.error(standardizeJSON(err));

              // if data item validation fails abort and don't save to cache
              valid = false;
              break;
            }
          }

          // if validation between sources fails we abort further data collection
          if (!valid) {
            break;
          }

          // a random item from the result gets chosen. seed is the current item key
          const seed = i.toString();
          // calculate randIndex in results range
          const randIndex = Math.floor(
            seedrandom(seed).quick() * results.length
          );

          this.logger.debug(
            `Choosing item from seed:${seed} index:${randIndex} source:${this.poolConfig.sources[randIndex]}`
          );

          // add this data item to the cache
          this.logger.debug(`this.cacheProvider.put(${i.toString()},$ITEM)`);
          await this.cacheProvider.put(i.toString(), results[randIndex]);

          this.m.cache_current_items.inc();
          this.m.cache_index_head.set(i);

          // add a timeout so that the runtime data source
          // is not overloaded with requests
          await sleep(50);
        }

        // assign the next key for the next round
        key = nextKey;
      }

      // wait until a new bundle proposal is available. We don't need
      // to sync the pool here because the pool state already gets
      // synced in the other main function "runNode" so we only listen
      await this.waitForCacheContinuation(updatedAt);
    } catch (err) {
      this.logger.error(
        `Unexpected error collecting data items to local cache. Continuing ...`
      );
      this.logger.error(standardizeJSON(err));

      try {
        // drop cache if an unexpected error occurs during caching
        this.logger.debug(`this.cacheProvider.drop()`);
        await this.cacheProvider.drop();

        this.m.cache_current_items.set(0);
      } catch (dropError) {
        this.logger.error(
          `Unexpected error dropping local cache. Continuing ...`
        );
        this.logger.error(standardizeJSON(dropError));
      }
    }
  }
}
