// SPDX-License-Identifier: Apache-2.0

import { decode as cborDecode, encode as cborEncode } from 'cborg';
import deoxysii from '@oasisprotocol/deoxysii';
import { sha512_256 } from '@noble/hashes/sha512';
import { hmac } from '@noble/hashes/hmac';
import { randomBytes } from '@noble/hashes/utils';

import {
  BoxKeyPair,
  boxKeyPairFromSecretKey,
  crypto_box_SECRETKEYBYTES,
  naclScalarMult,
} from './munacl.js';

import { BytesLike, isBytesLike, getBytes, hexlify } from './ethersutils.js';

/**
 * Some Ethereum libraries are picky about hex encoding vs Uint8Array
 *
 * The ethers BytesLike type can be either, if the request came as a hex encoded
 * string we should return hex encoded string, if request came as Uint8Array we
 * should return one.
 *
 * Notably hardhat-ignition doesn't work well with Uint8Array responses
 *
 * @param example Some example data, where we should return the same type
 * @param output Output data
 * @returns Output data, as either hex encoded 0x-prefixed string, or Uint8Array
 */
function asBytesLike(example: BytesLike, output: BytesLike): BytesLike {
  if (!isBytesLike(example) || !isBytesLike(output)) {
    throw new Error('Not byteslike data!');
  }
  if (typeof example === 'string') {
    if (typeof output === 'string') {
      return output;
    }
    return hexlify(output);
  }
  if (typeof output === 'string') {
    return hexlify(output);
  }
  return output;
}

export enum CipherKind {
  X25519DeoxysII = 1,
}

export type InnerEnvelope = {
  body: Uint8Array;
};

export type Envelope = {
  format: CipherKind;
  body: {
    pk: Uint8Array;
    nonce: Uint8Array;
    data: Uint8Array;
    epoch?: number;
  };
};

export type AeadEnvelope = { nonce: Uint8Array; data: Uint8Array };

export type CallResult = {
  ok?: string | Uint8Array | AeadEnvelope;
  fail?: CallFailure;
  unknown?: AeadEnvelope;
};

export type CallFailure = { module: string; code: number; message?: string };

function formatFailure(fail: CallFailure): string {
  if (fail.message) return fail.message;
  return `Call failed in module '${fail.module}' with code '${fail.code}'`;
}

export abstract class Cipher {
  public abstract kind: CipherKind;
  // The Sapphire's public key rotated each epoch
  public abstract publicKey: Uint8Array;
  // The client's keypair for encrypting/decrypting transactions and queries using the X25519-DeoxysII, rotated on-demand.
  public abstract ephemeralKey: Uint8Array;
  public abstract epoch?: number;

  public abstract encrypt(
    plaintext: Uint8Array,
    nonce?: Uint8Array,
  ): {
    ciphertext: Uint8Array;
    nonce: Uint8Array;
  };

  public abstract decrypt(
    nonce: Uint8Array,
    ciphertext: Uint8Array,
  ): Uint8Array;

  /** Encrypts the plaintext and encodes it for sending. */
  public encryptCall(
    calldata?: BytesLike | null,
    nonce?: Uint8Array,
  ): BytesLike {
    // Txs without data are just balance transfers, and all data in those is public.
    if (calldata === undefined || calldata === null || calldata.length === 0)
      return '';

    if (!isBytesLike(calldata)) {
      throw new Error('Attempted to sign tx having non-byteslike data.');
    }

    const innerEnvelope = cborEncode({ body: getBytes(calldata) });

    let ciphertext: Uint8Array;
    ({ ciphertext, nonce } = this.encrypt(innerEnvelope, nonce));

    const envelope: Envelope = {
      format: this.kind,
      body: {
        pk: this.publicKey,
        nonce,
        data: ciphertext,
        epoch: this.epoch,
      },
    };

    return asBytesLike(calldata, cborEncode(envelope));
  }

  public decryptCall(envelopeBytes: BytesLike): BytesLike {
    const envelope = cborDecode(getBytes(envelopeBytes));
    if (!isEnvelopeFormatOk(envelope)) {
      throw new EnvelopeError('Unexpected non-envelope!');
    }
    const result = this.decrypt(envelope.body.nonce, envelope.body.data);
    const inner = cborDecode(result) as InnerEnvelope;
    return asBytesLike(envelopeBytes, inner.body);
  }

  public encryptResult(
    ok?: Uint8Array,
    innerFail?: string,
    outerFail?: string,
  ): Uint8Array {
    if (ok || innerFail) {
      if ((ok && innerFail) || outerFail) {
        throw new EnvelopeError('Conflicting result envelope', {
          ok,
          innerFail,
          outerFail,
        });
      }

      // Inner envelope is encrypted
      const inner = cborEncode(innerFail ? { fail: innerFail } : { ok });
      const { nonce, ciphertext: data } = this.encrypt(inner);

      // Outer envelope is plaintext
      const envelope = cborEncode({
        ok: { nonce, data } as AeadEnvelope,
      });
      return envelope;
    }
    if (outerFail) {
      // Outer failures are returned in plaintext
      return cborEncode({ fail: outerFail });
    }
    throw new EnvelopeError('Cannot encrypt result with no data or failures!', {
      ok,
      innerFail,
      outerFail,
    });
  }

  /** Decrypts the data contained within a hex-encoded serialized envelope. */
  public decryptResult(callResult: BytesLike): BytesLike {
    const envelope = cborDecode(getBytes(callResult));
    if (envelope.fail) {
      throw new EnvelopeError(formatFailure(envelope.fail), envelope.fail);
    }

    // Unencrypted results will have `ok` as bytes, not a struct
    if (
      envelope.ok &&
      (typeof envelope.ok === 'string' || envelope.ok instanceof Uint8Array)
    ) {
      throw new EnvelopeError('Received unencrypted envelope', envelope);
    }

    // Encrypted result will have `ok` as a CBOR encoded struct
    const { nonce, data } = (envelope.ok as AeadEnvelope) ?? envelope.unknown;
    const inner = cborDecode(this.decrypt(nonce, data));

    if (inner.ok) {
      return asBytesLike(callResult, getBytes(inner.ok));
    }

    if (inner.fail) {
      throw new EnvelopeError(formatFailure(inner.fail), inner.fail);
    }

    throw new EnvelopeError(
      `Unexpected inner call result: ${JSON.stringify(inner)}`,
      inner,
    );
  }
}

/**
 * A {@link Cipher} that derives a shared secret using X25519 and then uses DeoxysII for encrypting using that secret.
 *
 * This is the default cipher.
 */
export class X25519DeoxysII extends Cipher {
  public override readonly kind = CipherKind.X25519DeoxysII;
  public override readonly publicKey: Uint8Array;
  public override readonly ephemeralKey: Uint8Array;
  public override readonly epoch: number | undefined;

  private cipher: deoxysii.AEAD;
  public secretKey: Uint8Array; // Stored for curious users.

  /** Creates a new cipher using an ephemeral keypair stored in memory. */
  static ephemeral(peerPublicKey: BytesLike, epoch?: number): X25519DeoxysII {
    const keypair = boxKeyPairFromSecretKey(
      randomBytes(crypto_box_SECRETKEYBYTES),
    );
    return new X25519DeoxysII(keypair, getBytes(peerPublicKey), epoch);
  }

  static fromSecretKey(
    secretKey: BytesLike,
    peerPublicKey: BytesLike,
    epoch?: number,
  ): X25519DeoxysII {
    const keypair = boxKeyPairFromSecretKey(getBytes(secretKey));
    return new X25519DeoxysII(keypair, getBytes(peerPublicKey), epoch);
  }

  public constructor(
    keypair: BoxKeyPair,
    peerPublicKey: Uint8Array,
    epoch?: number,
  ) {
    super();

    this.publicKey = keypair.publicKey;
    this.ephemeralKey = keypair.secretKey;
    this.epoch = epoch;

    // Derive a shared secret using X25519 (followed by hashing to remove ECDH bias).
    const keyBytes = hmac
      .create(
        sha512_256,
        new TextEncoder().encode('MRAE_Box_Deoxys-II-256-128'),
      )
      .update(naclScalarMult(keypair.secretKey, peerPublicKey))
      .digest().buffer;

    this.secretKey = new Uint8Array(keyBytes);
    this.cipher = new deoxysii.AEAD(new Uint8Array(this.secretKey)); // deoxysii owns the input
  }

  public encrypt(
    plaintext: Uint8Array,
    nonce: Uint8Array = randomBytes(deoxysii.NonceSize),
  ): {
    ciphertext: Uint8Array;
    nonce: Uint8Array;
  } {
    const ciphertext = this.cipher.encrypt(nonce, plaintext);
    return { nonce, ciphertext };
  }

  public decrypt(nonce: Uint8Array, ciphertext: Uint8Array): Uint8Array {
    return this.cipher.decrypt(nonce, ciphertext);
  }
}

// -----------------------------------------------------------------------------
// Determine if the CBOR encoded calldata is a signed query or an evelope

export class EnvelopeError extends Error {
  public constructor(message: string, public readonly response?: unknown) {
    super(message);
  }
}

function isEnvelopeFormatOk(envelope: any): envelope is Envelope {
  const { format, body } = envelope;

  if (!body || !format) {
    throw new EnvelopeError('No body or format specified', envelope);
  }

  if (format !== CipherKind.X25519DeoxysII) {
    throw new EnvelopeError('Not encrypted format', envelope);
  }

  if (isBytesLike(body))
    throw new EnvelopeError('Requires struct body', envelope);

  if (!isBytesLike(body.data))
    throw new EnvelopeError('No body data', envelope);

  return true;
}

export function isCalldataEnveloped(calldata?: BytesLike): boolean {
  if (calldata === undefined) {
    return false;
  }
  try {
    const envelope = cborDecode(getBytes(calldata));

    if (!isEnvelopeFormatOk(envelope)) {
      throw new EnvelopeError(
        'Bogus Sapphire enveloped data found in transaction!',
      );
    }
    return true;
  } catch (e: any) {
    if (e instanceof EnvelopeError) throw e;
  }
  return false;
}
