/**
 * Crypto config contexts - crypto suite

The challenge is to have cryptographic primitives be upgradable seamlessly.

One facet of this we have to acknowledge is that a user may use numerous different devices and client versions to
access the system. This means having dynamic crypto suite negotiation between peers or even of oneself for messages
or stored keys cannot be done while making sure all the user's clients agree. Not all clients are online all the time
to prevent premature upgrade.

So to make sure a newer client won't lock out an older one for the same user we need a current preferred version that
will be replaced by an already available newer one after some time. Basically introduce newer/better/bigger
cryptographic primitives in advance so when a change has to be made all clients will reasonably be supporting that.
The window for such a transition shall be at least a few months to allow all clients to be upgraded.

At the same time of a preferred suite upgrade the older crypto primitives can be deprecated, meaning no encryption
shall be done with them. For decrypting old data they shall not be removed from the context though. The new suite shall
warn anyone if new data is to be processed with an obsolete primitive.

In terms of new suite deployment order follow the general major upgrade policy: TODO: write this down somewhere
    - all client and nano apps support the current preferred suite: v1.0
    - create client and nano apps for all platforms with the new suite while the preferred is still the old one: v1.1
    - wait a sufficient time so clients can be upgraded before enforcing the new suite
    - create client and nano apps for all platforms with the new suite as preferred and the old one deprecated: v2.0
    - any client or nano that failed to upgrade in the time window will no longer work
 */

import { base64 } from 'rfc4648';
import { MissingKey } from '../keyring/base';
import { concatByteArray } from '../utility';

/**
 * The dump() of AbstractCryptoConfig shall raise this if the provided arguments are not sufficient to perform the
    implementation. The context will try to fall back to older versions.
 */
export class ArgumentsError extends Error {}

export class UnknownCryptoConfigVersion extends Error {}

export class CryptoConfigVersioning {
  public static config_version_from_int(v: number): Uint8Array {
    if (v > 0 && v < 128) {
      return new Uint8Array([v]);
    }
    throw 'config_version_from_int Error';
  }

  public static config_version_from_data(d: Uint8Array): Uint8Array {
    if (d && d.byteLength > 0) {
      return d.slice(0, 1);
    }

    return null;
  }

  public static config_version_to_int(v: Uint8Array): number {
    if (v.byteLength == 1) {
      return v[0];
    }
    throw 'config_version_to_int Error';
  }
}

export abstract class AbstractConfigLoadArgs {
  public cipher: Uint8Array;

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

export abstract class AbstractConfigDumpArgs {}

/**
 * Versioning of production configs shall start from 11, because having development configs with preceding versions
    is beneficial.
 */
export abstract class AbstractCryptoConfig<
  _TLoad extends AbstractConfigLoadArgs,
  _TDump extends AbstractConfigDumpArgs
> extends CryptoConfigVersioning {
  public version: Uint8Array;
  public never_dump: boolean;

  constructor(version: number, never_dump: boolean = false) {
    super();
    this.version = AbstractCryptoConfig.config_version_from_int(version);
    this.never_dump = never_dump;
  }

  /**
     * Decrypt content and/or verify signature.
        The first argument will be the packed version of this config that the payload is prefixed with. Depending
        on the implementation the payload shall be wrapped in a memory-view to use zero-copy operations.
     */
  public abstract load(args: _TLoad): Promise<Uint8Array>;

  /**
     *
     * Encrypt content and/or create signature.
        The first argument will be the packed version of this config. The returned value must be prefixed with it!
        Arguments of older version may get expanded so it is still callable with new/current arguments.

     */
  public abstract dump(args: _TDump): Promise<Uint8Array>;
}

export class CryptoContext<
  _TLoad extends AbstractConfigLoadArgs,
  _TDump extends AbstractConfigDumpArgs
> extends CryptoConfigVersioning {
  private _configs: AbstractCryptoConfig<_TLoad, _TDump>[] = [];
  private _legacy_versions: Uint8Array[] = [];

  constructor(config?: AbstractCryptoConfig<_TLoad, _TDump>[], legacy_versions?: Uint8Array[]) {
    /**
     * The legacy_version can be specified to supplement the load in case of any failure with a version. This is
        useful for contexts that used to not be versioned and need easy migration.
        NOTE: The legacy-version feature must only be used when the version config cryptographically authenticates.

     */
    super();
    if (config !== undefined) {
      let sortedConfig = config.sort((a, b) => {
        return (
          CryptoContext.config_version_to_int(b.version) -
          CryptoContext.config_version_to_int(a.version)
        );
      });

      let versions = new Set();
      sortedConfig.forEach((c) => {
        let tmp = CryptoContext.config_version_to_int(c.version);
        if (versions.has(tmp)) {
          throw 'Config version conflict for context';
        }
        versions.add(tmp);
        this._configs.push(c);
      });
    } else {
      throw 'CryptoContext invalid config';
    }
    if (legacy_versions !== undefined) {
      const lvs = new Set();
      for (let i = 0; i < legacy_versions.length; i++) {
        lvs.add(base64.stringify(legacy_versions[i]));
      }
      for (let i = 0; i < this._configs.length; i++) {
        for (let j = 0; j < legacy_versions.length; j++) {
          if (
            CryptoContext.config_version_to_int(this._configs[i].version) ===
            CryptoContext.config_version_to_int(legacy_versions[j])
          ) {
            this._legacy_versions.push(this._configs[i].version);
            lvs.delete(base64.stringify(legacy_versions[j]));
            break;
          }
        }
      }
      if (lvs.size !== 0) {
        throw 'Legacy config version is not in CryptoContext';
      }
    }
  }

  public legacy_versions(): Uint8Array[] {
    return this._legacy_versions;
  }

  public top_config_version(): Uint8Array {
    return this._configs[0].version;
  }

  public top_config(): [Uint8Array, AbstractCryptoConfig<_TLoad, _TDump>] {
    return [this._configs[0].version, this._configs[0]];
  }

  /**
    This is used to get specific configs during testing.
   */
  public get_config(version: number): AbstractCryptoConfig<_TLoad, _TDump> {
    for (let i = 0; i < this._configs.length; i++) {
      if (CryptoContext.config_version_to_int(this._configs[i].version) === version) {
        return this._configs[i];
      }
    }
  }

  /**
     * Decrypt or verify data.
        If the context has a legacy configuration, that will be tried in case of any error.
     */
  public async load(args: _TLoad): Promise<Uint8Array> {
    try {
      const version = CryptoContext.config_version_from_data(args.cipher);
      if (version === null) {
        throw new UnknownCryptoConfigVersion(version + '');
      }
      for (let i = 0; i < this._configs.length; i++) {
        if (base64.stringify(version) == base64.stringify(this._configs[i].version)) {
          return await this._configs[i].load(args);
        }
      }
      throw new UnknownCryptoConfigVersion(version + '');
    } catch (err) {
      if (this._legacy_versions.length > 0) {
        const base_cipher = args.cipher;
        for (let j = 0; j < this._legacy_versions.length; j++) {
          args.cipher = concatByteArray(this._legacy_versions[j], base_cipher);
          for (let i = 0; i < this._configs.length; i++) {
            if (
              base64.stringify(this._legacy_versions[j]) ==
              base64.stringify(this._configs[i].version)
            ) {
              try {
                return await this._configs[i].load(args);
              } catch (e) {
                // ignore
              }
            }
          }
        }
      }
      throw err;
    }
  }

  public async dump(args: _TDump): Promise<Uint8Array> {
    for (let i = 0; i < this._configs.length; i++) {
      if (this._configs[i].never_dump) {
        continue;
      }
      try {
        const packed = await this._configs[i].dump(args);
        if (packed[0] != this._configs[i].version[0]) {
          throw 'AssertionError - version mismatch';
        }
        return packed;
      } catch (err) {
        if (err instanceof ArgumentsError || err instanceof MissingKey) {
        } else {
          throw err;
        }
      }
    }
    throw new ArgumentsError();
  }

  public describe() {
    let configs = [];
    for (let i = 0; i < this._configs.length; i++) {
      configs.push({
        version: CryptoConfigVersioning.config_version_to_int(this._configs[i].version),
        never_dump: this._configs[i].never_dump,
      });
    }
    return {
      configs: configs,
      legacy_versions: Array.from(this._legacy_versions),
    }
  }
}
