// Copyright 2016 Twitter. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.twitter.heron.scheduler.dryrun;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import com.google.common.base.Optional;
import com.google.common.base.Strings;

import com.twitter.heron.common.basics.ByteAmount;
import com.twitter.heron.spi.packing.PackingPlan;
import com.twitter.heron.spi.packing.Resource;

/**
 * Formatter utilities
 */
public final class FormatterUtils {

  public FormatterUtils(boolean rich) {
    this.rich = rich;
  }

  /**
   * If render in rich format (with color and text style)
   */
  private final boolean rich;

  /**
   * Simple and self-contained support of using ANSI escape codes.
   *
   * @see <a href="https://en.wikipedia.org/wiki/ANSI_escape_code">ANSI escape code</a>
   *
   */
  private static final String ANSI_RESET = "\u001B[0m";
  private static final String ANSI_RED = "\u001B[31m";
  private static final String ANSI_GREEN = "\u001B[32m";
  private static final String ANSI_BOLD = "\u001B[1m";

  /*
   * Unicode of long strike overlay. A character followed by \u0036 will
   * be rendered on terminal as itself being struck through
   *
   * See: http://unicode.org/charts/PDF/U0300.pdf
   */
  private static final String STRIKETHROUGH = "\u0336";

  public enum TextColor {
    DEFAULT,
    RED,
    GREEN
  }

  public enum TextStyle {
    DEFAULT,
    BOLD,
    STRIKETHROUGH
  }

  public enum ContainerChange {
    UNAFFECTED,
    MODIFIED,
    NEW,
    REMOVED
  }


  /**
   * Poor man's tabulate implementation
   *
   * Each tabulates consists of a list of rows. Each row consists of a list of cells.
   *
   */

  /**
   * Cell is the smallest unit in a tabulate. More generally, it is a class
   * that represents a piece of text with style and color added.
   */
  public static class Cell {
    // Text in the cell
    private final String text;

    // Length of the text. It is used to calculate the proper widht of a column
    private final int length;
    private String formatter;
    private TextColor color;
    private TextStyle style;


    public Cell(String text) {
      this.text = text;
      this.length = text.length();
      this.formatter = "%s";
      this.color = TextColor.DEFAULT;
      this.style = TextStyle.DEFAULT;
    }

    public Cell(String text, TextColor color) {
      this(text);
      this.color = color;
    }

    public Cell(String text, TextStyle style) {
      this(text);
      this.style = style;
    }

    public Cell(String text, TextColor color, TextStyle style) {
      this(text);
      this.color = color;
      this.style = style;
    }

    public void setColor(TextColor color) {
      this.color = color;
    }

    public void setStyle(TextStyle style) {
      this.style = style;
    }

    public void setFormatter(String formatter) {
      this.formatter = formatter;
    }

    public final int getLength() {
      return this.length;
    }

    /**
     * Convert Cell to String
     * @param rich if render in rich format
     * @return
     */
    public String toString(boolean rich) {
      StringBuilder builder = new StringBuilder();
      String formattedText = String.format(formatter, text);
      if (rich) {
        switch (style) {
          case BOLD:
            builder.append(ANSI_BOLD);
            builder.append(formattedText);
            break;
        /* Adding strike-through effect to a string is different. One needs to append unicode of
           long strikethrough overlay to each single character in a string of characters. */
          case STRIKETHROUGH:
            for (int i = 0; i < formattedText.length(); i++) {
              builder.append(formattedText.charAt(i));
              builder.append(STRIKETHROUGH);
            }
            break;
          case DEFAULT:
            builder.append(formattedText);
            break;
          default:
            throw new RuntimeException("Unknown text style: " + style);
        }
        switch (color) {
          case RED:
            builder.insert(0, ANSI_RED);
            break;
          case GREEN:
            builder.insert(0, ANSI_GREEN);
            break;
          case DEFAULT:
            break;
          default:
            throw new RuntimeException("Unknown text color: " + color);
        }
        // Only append ANSI reset escape code if text style or text color is added
        if (style != TextStyle.DEFAULT || color != TextColor.DEFAULT) {
          builder.append(ANSI_RESET);
        }
      } else {
        builder.append(formattedText);
      }
      return builder.toString();
    }
  }

  /**
   * Row, which consists a list of cells.
   *
   * <pre>
   *   ----------------------
   *   | xxxx | yyyy | zzzz | &lt;- a list of cells
   *   ----------------------
   *                 ^
   *                 |------- separator: "|"
   * </pre>
   */
  public static class Row {
    private List<Cell> row;
    private static final String SEPARATOR = "|";

    public Row(List<String> row) {
      this.row = new ArrayList<>();
      for (String text: row) {
        this.row.add(new Cell(text));
      }
    }

    /**
     * Set color for a list of cells in a row
     * @param color
     */
    public void setColor(TextColor color) {
      for (Cell cell: row) {
        cell.setColor(color);
      }
    }

    /**
     * Set style for a list of cells in a row
     * @param style
     */
    public void setStyle(TextStyle style) {
      for (Cell cell: row) {
        cell.setStyle(style);
      }
    }

    /**
     * Set formatter for each cell in the row
     * @param formatters
     */
    public void setFormatters(List<String> formatters) {
      for (int i = 0; i < formatters.size(); i++) {
        row.get(i).setFormatter(formatters.get(i));
      }
    }

    public int size() {
      return row.size();
    }

    /**
     * Get length of the cell at index {@code index} in the row
     * @param index
     * @return length of the cell
     */
    public Cell getCell(int index) {
      return row.get(index);
    }

    public String toString(boolean rich) {
      List<String> renderedCells = new ArrayList<>();
      for (Cell c: row) {
        renderedCells.add(c.toString(rich));
      }
      return String.format("%s %s %s",
          SEPARATOR, String.join(" " +  SEPARATOR + " ", renderedCells), SEPARATOR);
    }
  }


  /**
   * Table, which consists of a title and a list of rows below the title
   *
   * <pre>
   *     ============================
   *     | title1 | title2 | title3 | &lt;----- title
   *     ============================
   *     | xxxxxx | yyy    | zzzzzz | &lt;-|
   *     ----------------------------   |----- rows
   *     | gggg   | uuuuu  | ooo    | &lt;-|
   * </pre>
   */
  public static class Table {
    private Row title;
    private List<Row> rows;

    public Table(Row title, List<Row> rows) {
      this.title = title;
      this.rows = rows;
    }

    private StringBuilder addRow(StringBuilder builder, String row) {
      builder.append(row);
      builder.append('\n');
      return builder;
    }

    /**
     * Calculate proper width for each column.
     *
     * Notice that if a cell contains text "foo" in red and bold style, the internal
     * representation will be "\u001B[31m\u001B[1mfoo\u001B[0m". However during the
     * calculation, ANSI escape codes/Unicode overlay should not be counted because they only
     * serve as visual effect and do not take extra space on terminal.
     *
     * @return a list of integers specifying proper length of each column
     */
    private List<Integer> calculateColumnsMax() {
      List<Integer> width = new ArrayList<>();
      for (int i = 0; i < title.size(); i++) {
        width.add(title.getCell(i).getLength());
      }
      for (Row row: rows) {
        for (int i = 0; i < row.size(); i++) {
          width.set(i, Math.max(width.get(i), row.getCell(i).getLength()));
        }
      }
      return width;
    }
    /**
     * Generate formatter for each row based on rows. Width of a column is the
     * max width of all cells on that column.
     *
     * Explanation of the {@code metaCellFormatter}:
     *
     *  If the column max width is 8, then {@code String.format(metaCellFormatter, 8)}
     *  gives us {@code "%8s"}
     *
     *  @see <a href="https://docs.oracle.com/javase/8/docs/api/java/util/Formatter.html">
     *    Formatter</a>
     *
     * @return formatter for rows
     */
    private List<String> generateRowFormatter() {
      List<String> formatters = new ArrayList<>();
      List<Integer> columnsMax = calculateColumnsMax();
      String metaCellFormatter = "%%%ds";
      for (Integer width: columnsMax) {
        formatters.add(String.format(metaCellFormatter, width));
      }
      return formatters;
    }

    /**
     * Generate length of table frame
     *
     * Definition of table frame:
     *
     *     =============================== &lt;- table frame
     *     | xxxxx | yyyyy | zzzzz | sss |
     *     ===============================
     *     | ..... | ..... | ..... | ... |
     *
     * The constant 3 comes from two spaces surrounding the text
     * and one separator after the text.
     *
     *     space
     *      |
     *     ---------
     *     | xxxxx | &lt;- one separator
     *     ---------
     *            |
     *           space
     *
     * The final constant 1 is the leftmost separator of a row
     *
     * @return
     */
    private int calculateFrameLength() {
      int total = 0;
      for (Integer width: calculateColumnsMax()) {
        total += width + 3;
      }
      return total + 1;
    }

    /**
     * Format rows and title into a table
     * @param rich if render table in rich format
     * @return Formatted table
     */
    public String createTable(boolean rich) {
      // Generate formatter for each cell in a single row
      List<String> formatters = generateRowFormatter();
      // Set formatter for each row
      title.setFormatters(formatters);
      for (Row row: rows) {
        row.setFormatters(formatters);
      }
      // Calculate length for frames
      int frameLength = calculateFrameLength();
      // Start building table
      StringBuilder builder = new StringBuilder();
      // Add upper frame
      addRow(builder, Strings.repeat("=", frameLength));
      // Add title
      addRow(builder, title.toString(rich));
      // Add one single line to separate title and content
      addRow(builder, Strings.repeat("-", frameLength));
      // Add each row
      for (Row row: rows) {
        addRow(builder, row.toString(rich));
      }
      // Add lower frame
      addRow(builder, Strings.repeat("=", frameLength));
      return builder.toString();
    }
  }

  private static final List<String> TITLE_NAMES = Arrays.asList(
        "component", "task ID", "CPU", "RAM (MB)", "disk (MB)");


  /******************************** Auxiliary functions ********************************/

  /**
   * Format new amount associated with change in percentage
   * For example, with {@code oldAmount = 2} and {@code newAmount = 1}
   * the result Cell is " -50.00%" (in red color)
   * @param oldAmount old resource usage
   * @param newAmount new resource usage
   * @return formatted chagne in percentage if oldAmount and newAmount differ
   */
  public static Optional<Cell> percentageChange(double oldAmount, double newAmount) {
    double delta = newAmount - oldAmount;
    double percentage = delta / oldAmount * 100.0;
    if (percentage == 0.0) {
      return Optional.absent();
    } else {
      String sign = "";
      if (percentage > 0.0) {
        sign = "+";
      }
      Cell cell = new Cell(String.format("%s%.2f%%", sign, percentage));
      // set color to red if percentage drops, to green if percentage increases
      if ("".equals(sign)) {
        cell.setColor(TextColor.RED);
      } else {
        cell.setColor(TextColor.GREEN);
      }
      return Optional.of(cell);
    }
  }

  public String renderContainerName(Integer containerId) {
    return new Cell(String.format("Container %d", containerId),
              FormatterUtils.TextStyle.BOLD).toString(rich);
  }

  public String renderContainerChange(ContainerChange change) {
    Cell c = new Cell(change.toString());
    switch (change) {
      case NEW:
        c.setColor(TextColor.GREEN);
        break;
      case REMOVED:
        c.setColor(TextColor.RED);
        break;
      default:
        break;
    }
    return c.toString(rich);
  }

  public Row rowOfInstancePlan(PackingPlan.InstancePlan plan,
                                      TextColor color, TextStyle style) {
    String taskId = String.valueOf(plan.getTaskId());
    String cpu = String.valueOf(plan.getResource().getCpu());
    String ram = String.valueOf(plan.getResource().getRam().asMegabytes());
    String disk = String.valueOf(plan.getResource().getDisk().asMegabytes());
    List<String> cells = Arrays.asList(
          plan.getComponentName(), taskId, cpu, ram, disk);
    Row row = new Row(cells);
    row.setStyle(style);
    row.setColor(color);
    return row;
  }

  public String renderOneContainer(List<Row> rows) {
    Row title = new Row(TITLE_NAMES);
    title.setStyle(TextStyle.BOLD);
    return new Table(title, rows).createTable(rich);
  }

  public String renderResourceUsage(Resource resource) {
    double cpu = resource.getCpu();
    ByteAmount ram = resource.getRam();
    ByteAmount disk = resource.getDisk();
    return String.format("CPU: %s, RAM: %s MB, Disk: %s MB",
      cpu, ram.asMegabytes(), disk.asMegabytes());
  }

  public String renderResourceUsageChange(Resource oldResource, Resource newResource) {
    double oldCpu = oldResource.getCpu();
    double newCpu = newResource.getCpu();
    Optional<Cell> cpuUsageChange = FormatterUtils.percentageChange(oldCpu, newCpu);
    long oldRam = oldResource.getRam().asMegabytes();
    long newRam = newResource.getRam().asMegabytes();
    Optional<Cell> ramUsageChange = FormatterUtils.percentageChange(oldRam, newRam);
    long oldDisk = oldResource.getDisk().asMegabytes();
    long newDisk = newResource.getDisk().asMegabytes();
    Optional<Cell> diskUsageChange = FormatterUtils.percentageChange(oldDisk, newDisk);
    String cpuUsage = String.format("CPU: %s", newCpu);
    if (cpuUsageChange.isPresent()) {
      cpuUsage += String.format(" (%s)", cpuUsageChange.get().toString(rich));
    }
    String ramUsage = String.format("RAM: %s MB", newRam);
    if (ramUsageChange.isPresent()) {
      ramUsage += String.format(" (%s)", ramUsageChange.get().toString(rich));
    }
    String diskUsage = String.format("Disk: %s MB", newDisk);
    if (diskUsageChange.isPresent()) {
      diskUsage += String.format(" (%s)", diskUsageChange.get().toString(rich));
    }
    return String.join(", ", cpuUsage, ramUsage, diskUsage);
  }
}
