import * as cdk from "@aws-cdk/core";
import * as cw from "@aws-cdk/aws-cloudwatch";
import { Duration } from "@aws-cdk/core";
import * as cw_actions from "@aws-cdk/aws-cloudwatch-actions";
import { AlarmSNSTopics } from "../metrics/alarm-sns-topics";
import { EnvName } from "../../env-names";

/**
 * Interface for creating CloudWatch alarms that alarm if nodes in the redis cluster
 * become unhealthy.
 */
export interface AlarmsProps {
  readonly cloudwatchAlarmSNSTopics: AlarmSNSTopics;
  readonly clusterName: string;
  readonly envName: EnvName;
  readonly instanceType: InstanceTypeInfo;
  readonly numShards: number;
  readonly numNodesPerShard: number;
  readonly twitchSeverity: number;
}

/**
 * Information on an instance type.
 */
export interface InstanceTypeInfo {
  name: string;
  numCPUs: number;
  memoryBytes: number;
}

/**
 * Params required to create an alarm.
 */
interface AlarmArgs {
  /**
   * descPrefix should describe what's happening to the node.
   * Info identifying the node will be appended to the descPrefix to create the alarm description.
   */
  readonly descPrefix: string;

  readonly enabled: boolean;

  /**
   * nameSuffix should be a dash separated phrase describing what's going on.
   * e.g. too-many-connections
   * It'll be appended to the node's information to create the alarm name.
   */
  readonly nameSuffix: string;

  readonly metricName: string;
  readonly statistic: string;
  readonly comparsionOperator: cw.ComparisonOperator;
  readonly threshold: number;
}

/**
 * Info on a single cache node within the cluster.
 */
interface Node {
  readonly clusterName: string;
  readonly numShards: number;
  readonly numNodesPerShard: number;
  readonly shardIndex: number;
  readonly nodeIndex: number;
}

/**
 * Alarms creates CloudWatch alarms that fire if nodes in the Redis cluster become
 * unhealthy. The alarms are based on
 * https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheMetrics.WhichShouldIMonitor.html
 */
export class Alarms extends cdk.Construct {
  constructor(scope: cdk.Construct, id: string, props: AlarmsProps) {
    super(scope, id);

    const alarmArgs: AlarmArgs[] = [
      this.getConnectionsAlarmArgs(),
      this.getCPUAlarmArgs(props.instanceType),
      this.getEvictionAlarmArgs()
    ];

    for (let shardIndex = 1; shardIndex <= props.numShards; shardIndex++) {
      for (
        let nodeIndex = 1;
        nodeIndex <= props.numNodesPerShard;
        nodeIndex++
      ) {
        const node: Node = {
          clusterName: props.clusterName,
          numShards: props.numShards,
          numNodesPerShard: props.numNodesPerShard,
          shardIndex,
          nodeIndex
        };
        for (let alarmArg of alarmArgs) {
          this.addElasticacheAlarm(props, alarmArg, node);
        }
      }
    }
  }

  /**
   *  Creates args for an alarm that triggers when Redis is uing too much CPU.
   */

  private getCPUAlarmArgs(instanceType: InstanceTypeInfo): AlarmArgs {
    // We'll use the CPUUtilization metric, but it covers all cores, while Redis only uses one.
    // To use CPUUtilization to detect if Redis is using too much CPU, we'll express the threshold
    // as a fraction of the instance's total capacity.
    const singleCoreThreshold = 80; // percent
    const threshold = singleCoreThreshold / instanceType.numCPUs;

    return {
      descPrefix: "The CPU for the redis node is too high.",
      enabled: true,
      nameSuffix: "cpu-too-high",

      metricName: "CPUUtilization",
      statistic: "Average",
      comparsionOperator:
        cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      threshold
    };
  }

  /**
   * Creates args for an alarm that triggers when Redis has too many connections.
   */
  private getConnectionsAlarmArgs(): AlarmArgs {
    return {
      descPrefix: "The Redis node has too many connections.",
      enabled: true,
      nameSuffix: "too-many-connections",

      metricName: "CurrConnections",
      statistic: "Average",
      comparsionOperator:
        cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      threshold: 4000
    };
  }

  /**
   * Creates args for an alarm that triggers when Redis is evicting items due to memory pressure.
   */
  private getEvictionAlarmArgs(): AlarmArgs {
    return {
      descPrefix:
        "The Redis node is evicting items because of memory pressure.",
      enabled: true,
      nameSuffix: "is-evicting-items",

      metricName: "Evictions",
      statistic: "Sum",
      comparsionOperator: cw.ComparisonOperator.GREATER_THAN_THRESHOLD,
      threshold: 0
    };
  }

  private addElasticacheAlarm(
    props: AlarmsProps,
    alarmArgs: AlarmArgs,
    node: Node
  ) {
    const cacheClusterID = getClusterID(node);
    const metric = new cw.Metric({
      metricName: alarmArgs.metricName,
      namespace: "AWS/ElastiCache",
      // Elasticache metric samples are published once per minute, so we'll be aggregating only one
      // sample per period.
      period: Duration.minutes(1),
      statistic: alarmArgs.statistic,
      dimensions: {
        CacheClusterId: cacheClusterID
      }
    });

    // Use an alarm name that's descriptive enough to be meaningful if read from a slack alert.
    // Example: staging-redis-raids-0001-002-too-many-connections
    const alarmName = `${props.envName}-redis-${cacheClusterID}-${alarmArgs.nameSuffix}`;

    const constructID = getConstructID(alarmArgs.metricName, node);
    const alarm = new cw.Alarm(this, constructID, {
      alarmName,
      actionsEnabled: alarmArgs.enabled,
      alarmDescription: `${alarmArgs.descPrefix} (env: ${props.envName}, node: ${cacheClusterID})`,
      comparisonOperator: alarmArgs.comparsionOperator,
      evaluationPeriods: 3,
      metric,
      threshold: alarmArgs.threshold,
      treatMissingData: cw.TreatMissingData.MISSING
    });

    const topic = props.cloudwatchAlarmSNSTopics.getTopicForSeverity(
      props.twitchSeverity
    );
    if (topic) {
      alarm.addAlarmAction(new cw_actions.SnsAction(topic));
      alarm.addOkAction(new cw_actions.SnsAction(topic));
    }
  }
}

/**
 * getClusterID returns a string that can be used as the CacheClusterId dimension
 * when selecting metrics.
 */
function getClusterID(node: Node): string {
  const shardIndexStr = node.shardIndex.toString().padStart(4, "0");
  const nodeIndexStr = node.nodeIndex.toString().padStart(3, "0");

  return `${node.clusterName}-${shardIndexStr}-${nodeIndexStr}`;
}

/**
 * getConstructID returns a construct ID related to the given cache node.
 */
function getConstructID(prefix: string, node: Node): string {
  return `${prefix}-Shard-${node.shardIndex}-Node-${node.nodeIndex}`;
}
