import * as AWS from "aws-sdk";
import * as Crypto from "crypto";

const regionMapping: { [name: string]: string } = {
  bom: "ap-south-1",
  cdg: "eu-west-3",
  cle: "us-east-2",
  dub: "eu-west-1",
  fra: "eu-central-1",
  gru: "sa-east-1",
  iad: "us-east-1",
  icn: "ap-northeast-2",
  lhr: "eu-west-2",
  nrt: "ap-northeast-1",
  pdx: "us-west-2",
  sin: "ap-southeast-1",
  sjc: "us-west-1",
  syd: "ap-southeast-2",
  yul: "ca-central-1"
};

export interface ArcanaClientOptions {
  bucket: string;
  serviceId: string;
  awsProfile?: string;
  awsRegion?: string;
}

export class InvalidOptions extends Error {}
export class InvalidMaterialError extends Error {}
export class InvalidIntermediateKeyError extends Error {}
export class NoMaterialError extends Error {}

export class ArcanaClient {
  private opts: ArcanaClientOptions = {
    bucket: process.env.ARCANA_BUCKET as string,
    serviceId: process.env.SERVICE_ID as string,
    awsProfile: process.env.AWS_PROFILE,
    awsRegion: process.env.AWS_REGION
  };

  private kmsClient: AWS.KMS;
  private s3Client: AWS.S3;

  constructor(options?: Partial<ArcanaClientOptions>) {
    const opts = this.options(options);
    const clientConfig: { [key: string]: string | AWS.SharedIniFileCredentials } = {
      region: this.getRegionID(opts.serviceId, opts.awsRegion)
    };

    if (opts.awsProfile) {
      clientConfig.credentials = new AWS.SharedIniFileCredentials({
        profile: opts.awsProfile
      });
    }

    this.kmsClient = new AWS.KMS(clientConfig);
    this.s3Client = new AWS.S3(clientConfig);
  }

  public async decrypt(materialName: string, defaultValue?: string): Promise<string> {
    let material;

    try {
      material = await this.s3Client
        .getObject({
          Bucket: this.opts.bucket,
          Key: `${this.opts.serviceId}/${materialName}`
        })
        .promise();
    } catch (err) {
      if (err.code !== "NoSuchKey") {
        throw err;
      }

      if (defaultValue) {
        console.error(err.message, err.stack);
        return defaultValue;
      }

      const error = new NoMaterialError();
      Object.assign(error, err);
      throw error;
    }

    if (!material.Metadata) {
      throw new InvalidMaterialError("Got no metadata");
    }

    if (!material.Body) {
      throw new InvalidMaterialError("Got no body");
    }

    const key = material.Metadata["arcana-key"];
    const iv = material.Metadata["arcana-iv"];
    const body = material.Body;

    const intermediateKey = await this.kmsClient
      .decrypt({
        CiphertextBlob: Buffer.from(key, "base64"),
        EncryptionContext: this.getEncryptionContext(materialName)
      })
      .promise();

    if (!intermediateKey.Plaintext) {
      throw new InvalidIntermediateKeyError("Intermediate Key has no value");
    }

    const aes = Crypto.createDecipheriv("aes-256-cbc", intermediateKey.Plaintext as Buffer, Buffer.from(iv, "base64"));

    return aes.update(Buffer.from(body.toString(), "base64")) + aes.final("utf8");
  }

  public async decryptAndTrim(materialName: string, defaultValue?: string): Promise<string> {
    const secret = await this.decrypt(materialName, defaultValue);
    return secret.trim();
  }

  private getEncryptionContext(materialName: string) {
    return {
      secret_name: materialName,
      service_id: this.opts.serviceId,
      s3_bucket: this.opts.bucket,
      s3_key: `${this.opts.serviceId}/${materialName}`
    };
  }

  private getRegionID(serviceID: string, awsRegion?: string): string {
    const target = awsRegion || serviceID.split("-").slice(-1)[0];
    return regionMapping[target] || target;
  }

  private options(options?: Partial<ArcanaClientOptions>): ArcanaClientOptions {
    Object.assign(this.opts, options);

    if (!this.opts.bucket) {
      throw new InvalidOptions("You must provide an s3 bucket name either to the constructor or via the ARCANA_BUCKET environment variable.");
    }

    if (!this.opts.serviceId) {
      throw new InvalidOptions("You must provide a service id either to the constructor or via the SERVICE_ID environment variable.");
    }

    return this.opts;
  }
}
