// Functions to support outputting stuff to the user
import { inspect } from "util";

import { isQuiet, formatIsJson, formatIsCsv, formatIsParsingCompatible } from "./io-options";

import * as os from "os";
import * as wrap from "wordwrap";
import * as tty from "tty";

const Table = require("cli-table3");
const Spinner = require("cli-spinner").Spinner;

import { terminal } from "./terminal";

import * as _ from "lodash";

//
// Display a progress spinner while waiting for the provided promise
// to complete.
//
export function progress<T>(title: string, action: Promise<T>): Promise<T> {
  const stdoutIsTerminal = tty.isatty(1);
  if (!formatIsParsingCompatible() && !isQuiet() && stdoutIsTerminal) {
    const spinner = new Spinner(title);
    spinner.start();
    return action.then((result) => {
      spinner.stop(true);
      return result;
    })
    .catch((ex) => {
      spinner.stop(true);
      throw ex;
    });
  }  else {
    return action;
  }
}

//
// Output an array of items, passing each item through a formatting
// function.
//
export function list<T>(formatter: {(item: T): string}, items: T[]): void {
  console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
  if (!items || Object.keys(items).length === 0) { return; }

  if (!formatIsJson()) {
    items.map(formatter).forEach((text) => console.log(text));
  } else {
    console.log(JSON.stringify(items));
  }
}

//
// Output a line of help text
//
export function help(t: string): void;
export function help(): void;
export function help(...args: any[]) : void {
  console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
  let t: string;
  if (args.length === 0) {
    t = "";
  } else {
    t = args[0];
  }
  console.log(t);
}

//
// Output a line of plain text. Only outputs if the format is regular text.
// If passing a converter, then the raw data is output in json format instead.
//
export function text<T>(converter: {(data: T): string}, data: T): void;
export function text(t: string): void;
export function text(...args: any[]): void {
  console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
  let converter: {(data: any): string};
  let data: any;
  if (args.length === 1) {
    converter = null;
    data = args[0];
  } else {
    [converter, data] = args;
  }

  if (formatIsJson()) {
    if (converter) {
      console.log(JSON.stringify(data));
    }
  } else {
    converter = converter || ((s) => s);
    console.log(converter(data));
  }
}

//
// Output tabular data.
// By default, does a simple default table using cli-table3.
// If you want to, you can pass in explicit table initialization
// options. See https://github.com/jamestalmage/cli-table3 for docs
// on the module.
//
export function table(options: any, data: any[]): void;
export function table(data: any[]): void;
export function table(...args: any[]): void {
  console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
  let options: any;
  let data: any[];
  [options, data] = args;

  if (!data) {
    data = options;
    options = undefined;
  }

  if (!formatIsJson()) {
    const cliTable = new Table(options);
    data.forEach((item) => cliTable.push(item));
    console.log(cliTable.toString());
  } else {
    console.log(JSON.stringify(data));
  }
}

// Formatting helper for cli-table3 - default command output table style
export function getCommandOutputTableOptions(header: string[]): object {
  return {
    head: header,
    style: {
      head: []
    }
  };
}

//
// Formatting helper for cli-table3 - two columns with no table outlines. Used by
// help commands for formatting lists of options, commands, etc.
//
export function getOptionsForTwoColumnTableWithNoBorders(firstColumnWidth: number) {
  const consoleWidth = terminal.columns();
  // There will be a single whitespace to the right from the each column, count it as unavailable
  const availableWidth = consoleWidth - 2;
  const secondColumnWidth = availableWidth - firstColumnWidth;

  return {
    chars: {
      top: "", "top-mid": "", "top-left": "", "top-right": "",
      bottom: "", "bottom-mid": "", "bottom-left": "", "bottom-right": "",
      left: "", "left-mid": "", mid: "", "mid-mid": "",
      right: "", "right-mid": "", middle: ""
    },
    style: { "padding-left": 0, "padding-right": 0 },
    colWidths: [firstColumnWidth, secondColumnWidth],
    wordWrap: true
  };
}

//
// Output a "report", which is a formatted output of a single object
// with ability to control naming of fields in the output, lets you
// output subobjects formatted nicely, and aligns everything for you.
//
// Usage looks like:
//  out.report([
//    // Report format here, one array entry per field to output
//    [ "Field name to display", "path.to.property.to.display.in.data", optionalFormatter ],
//    [ "Second field name", "second.path.to.display", /* No formatter on this one */ ]
//  ],
//  "Optional string to print if no data is available",
//  theDataToFormat);
//
// The paths to properties are simple dotted property names like you'd use in javascript.
// For example, in the profile list command, there's this line to display some of the
// current profile properties:
//
//   out.report([
//       ["Username", "userName" ],
//       [ "Display Name", "displayName" ],
//       [ "Email", "email"]
//     ], "No logged in user. Use 'appcenter login' command to log in.",
//     user);
//
// "userName", "displayName", and "email" are names of properties on the user object being
// passed in. If there were subobjects, for example if the input object looked like this:
//
//   let user = {
//     name: {
//       userName: "chris",
//       displayName: "christav"
//     },
//     email: "not.giving@real.email.here"
//    };
//
// This format could be displayed in a report like so:
//
//   out.report([
//       [ "Username", "name.userName" ],
//       [ "Display Name", "name.displayName" ],
//       [ "Email", "email"]
//     ], "No logged in user. Use 'appcenter login' command to log in.",
//     user);
//
// Each report format entry can have a formatter supplied with it. This is a function that
// takes the field's value and returns the appropriate string for display. By default
// report just calls 'toString' on the value, but you can use a formatter to customize
// to whatever you like.
//
// There are a few supplied formatters you can use out of the box attached to the report
// function. They are:
//
//   out.report.asDate: takes an input string, parses it as a Date object, then outputs the result.
//   out.report.inspect: takes any input object and returns the result of calling util.inspect on it.
//   out.report.allProperties: Takes an object with properties itself, and runs report
//                             recursively on that object. This results in a nicely indented subreport
//                             in the final output.
//
// In addition, if the formatter is itself an array, it becomes the report format for the subobjects.
// So you can nest arbitrary reports. For exmaple, asssuming the same user field, then using this:
//
//   out.report(
//     [
//       [ "Email", "email" ],
//       // Nested subobject
//       [ "Names", "name",
//         [
//           // report format for each of the subobject's fields
//           [ "User Name", "userName" ],
//           [ "Display Name", "displayName" ]
//         ]
//       ]
//     ],
//     {
//       // reformat our user to show subobjects
//       name: {
//         displayName: user.displayName,
//         userName: user.userName
//       },
//       email: user.email
//     });
//
// The resulting output looks like this:
//
//   Email: not.giving@real.email.here
//   Names:
//          User Name:    christav-yngr
//          Display Name: christav
//

//
// Support functions for "report" output
//
function spaces(num: number): string {
  if (num > 0) {
    return new Array(num + 1).join(" ");
  }
  return "";
}

function toWidth(s: string, width: number): string {
  const pad = width - s.length;
  return s + spaces(pad);
}

function defaultFormat(data: any): string {
  if (typeof data === "undefined" || data === null) {
    return "";
  }
  if (data instanceof Array) {
    if (data.length === 0) {
      return "[]";
    }
    return data.join(", ");
  }

  return data.toString();
}

function getProperty(value: any, propertyName: string): any {
  if (typeof value === "undefined" || value === null) {
    return "";
  }

  if (!propertyName) {
    return value;
  }

  const first = propertyName.split(".")[0];
  const rest = propertyName.slice(first.length + 1);
  return getProperty(value[first], rest);
}

function doReport(indentation: number, reportFormat: any[], data: any, outfn: {(message: string): void}): void {
  if (reportFormat.length === 0) {
    return;
  }

  let maxWidth = 80;
  if ((process.stdout as any).isTTY) {
    maxWidth = (process.stdout as any).columns;
  }

  const headerWidth = Math.max.apply(null,
    reportFormat.map(function (item) { return item[0].length; })
    ) + 2;

  reportFormat.forEach(function (item) {
    const title = item[0] + ":";
    const field = item[1];
    const formatter = item[2] || defaultFormat;

    const value = getProperty(data, field);
    if (formatter instanceof Array) {
      outfn(spaces(indentation) + toWidth(title, headerWidth));
      doReport(indentation + headerWidth, formatter, value, outfn);
    } else {
      const leftIndentation = "verbose: ".length + indentation + headerWidth;
      let formatted = wrap.hard(leftIndentation, maxWidth)(formatter(value));
      formatted = spaces(indentation) + toWidth(title, headerWidth) +
        formatted.slice(leftIndentation);
      outfn(formatted);
    }
  });
}

interface ReportFunc {
  (reportFormat: any, nullMessage: string, data: any): void;
  (reportFormat: any, data: any): void;
  allProperties: {(data: any): string };
  asDate: {(data: any): string };
  inspect: {(data: any): string };
}

function makeReport(reportFormat: any, nullMessage: string, data: any): void;
function makeReport(reportFormat: any, data: any): void;
function makeReport(...args: any[]): void {
  console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
  let reportFormat: any;
  let nullMessage: string;
  let data: any;

  if (args.length === 3) {
    [reportFormat, nullMessage, data] = args;
  } else {
    [reportFormat, data] = args;
    nullMessage = "No data available";
  }

  if (!formatIsJson()) {
    if (data === null || data === undefined) {
      console.log(nullMessage);
    } else {
      doReport(0, reportFormat, data, console.log);
    }
  } else {
     console.log(JSON.stringify(data));
  }
}

export const report = makeReport as ReportFunc;

report.allProperties = function (data: any): any {
  if (typeof data === "undefined" || data === null || data === "") {
    return "[]";
  }
  const subreport = Object.keys(data).map(function (key) {
    return [key, key];
  });
  const result: string[] = [];
  doReport(0, subreport, data, function (o) { result.push(o); });
  result.push("");
  return result.join(os.EOL);
};

report.asDate = function (data: any): string {
  const date = new Date(data);
  if (formatIsJson()) {
    return date.toJSON();
  } else {
    return date.toString();
  }
};

report.inspect = function (data: any): string {
  return inspect(data, {depth: null});
};

export function reportNewLineSeparatedArray(reportFormat: any, data: any[]) {
  console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
  if (!formatIsJson()) {
    data.forEach((item, index) => {
      if (index) {
        console.log("");
      }

      report(reportFormat, item);
    });
  } else {
    console.log(JSON.stringify(data));
  }
}

export function reportTitledGroupsOfTables(dataGroups: Array<{title: string, reportFormat: any, tables: any[]}>) {
  console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
  if (!formatIsJson()) {
    dataGroups.forEach((dataGroup, index) => {
      if (index) {
        console.log("");
      }
      console.log(dataGroup.title);
      console.log("");
      reportNewLineSeparatedArray(dataGroup.reportFormat, dataGroup.tables);
    });
  } else {
    console.log(JSON.stringify(dataGroups));
  }
}

function getMarginStringFromLevel(level: number) {
  return _.repeat(" ", level * 4);
}

//
// Formatting helper for cli-table3 - table with borders which can be moved to the right
// It is used to show sub-tables
//
function getTableWithLeftMarginOptions(leftMargin: string) {
  return {
    chars: {
       top: "─"
      , "top-mid": "┬"
      , "top-left":  leftMargin + "┌"
      , "top-right": "┐"
      , bottom: "─"
      , "bottom-mid": "┴"
      , "bottom-left": leftMargin + "└"
      , "bottom-right": "┘"
      , left: leftMargin + "│"
      , "left-mid": leftMargin + "├"
      , mid: "─"
      , "mid-mid": "┼"
      , right: "│"
      , "right-mid": "┤"
      , middle: "│"
    },
    style: { "padding-left": 0, "padding-right": 0 },
    wordWrap: true
  };
}

function convertNamedTablesToCsvString(stringTables: NamedTables): string {
  const columnsCount = calculateNumberOfColumns(stringTables);
  const delimitersCount = columnsCount - 1;
  const delimitersString = _.repeat(",", delimitersCount);

  function outputTable(table: INamedTable): string {
    let tableOutput = "";
    // table name
    tableOutput += table.name + delimitersString + os.EOL;

    // table contents
    const contents = _.cloneDeep(table.content);
    contents.forEach((row, index) => {
      if (index) {
        tableOutput += os.EOL;
      }
      if (isINamedTable(row)) {
        tableOutput += outputTable(row);
      } else {
        row.length = columnsCount;
        tableOutput += row.join(",");
      }
    });

    return tableOutput;
  }

  return stringTables.map((table) => outputTable(table)).join(os.EOL + delimitersString + os.EOL);
}

function convertNamedTablesToListString(stringTables: NamedTables): string {
  function outputTable(table: INamedTable, level: number): string {
    const paddedTable = padTableCells(table);
    let tableOutput = "";
    const marginString = getMarginStringFromLevel(level);

    // table name
    tableOutput += marginString + paddedTable.name + os.EOL;

    // table contents
    const tableWithMergedStringArrays: any[] = [];
    // merging continuous string[] chains into Table objects
    for (const row of paddedTable.content) {
      if (isINamedTable(row)) {
        tableWithMergedStringArrays.push(row);
      } else {
        const lastElement = _.last(tableWithMergedStringArrays);
        let tableObject: any;
        if (_.isUndefined(lastElement) || isINamedTable(lastElement)) {
          tableObject = new Table(getTableWithLeftMarginOptions(marginString));
          tableWithMergedStringArrays.push(tableObject);
        } else {
          tableObject = lastElement;
        }

        tableObject.push(row);
      }
    }

    tableWithMergedStringArrays.forEach((rowObject, rowIndex) => {
      if (rowIndex) {
        tableOutput += os.EOL;
      }
      if (isINamedTable(rowObject)) {
        tableOutput += outputTable(rowObject, level + 2);
      } else {
        tableOutput += rowObject.toString();
      }
    });

    return tableOutput;
  }
  return stringTables.map((table) => outputTable(table, 0)).join(os.EOL + os.EOL);
}

function getMapKey(level: number, cellIndex: number): string {
  return [level, cellIndex].join();
}

// returns map of max table cell width across tables on the same level - it is used to nicely align table columns vertically
// key is [level, cellIndex].join()
function calculateTableCellsMaxWidthAcrossLevels(wholeTable: INamedTable): Map<string, number> {
  function calculate(table: INamedTable, level: number, levelAndCellIndexToMaxWidth: Map<string, number>): Map<string, number> {
    for (const entry of table.content) {
      if (entry instanceof Array) {
        // row
        entry.forEach((cell, cellIndex) => {
          const key = getMapKey(level, cellIndex);
          levelAndCellIndexToMaxWidth.set(key, _.max([levelAndCellIndexToMaxWidth.get(key), cell.length]));
        });
      } else {
        // inner table
        calculate(entry, level + 1, levelAndCellIndexToMaxWidth);
    }
  }

    return levelAndCellIndexToMaxWidth;
    }
  return calculate(wholeTable, 0, new Map<string, number>());
}

function padTableCells(wholeTable: INamedTable): INamedTable {
  // calculating max widths for the cells
  const levelAndCellIndexToMaxWidth = calculateTableCellsMaxWidthAcrossLevels(wholeTable);

  // recursively pad content
  function pad(table: INamedTable, level: number): INamedTable {
    const paddedContent = table.content.map((entry) => {
      if (entry instanceof Array) {
        // row
        return entry.map((cellContent, cellIndex) => _.padEnd(cellContent, levelAndCellIndexToMaxWidth.get(getMapKey(level, cellIndex))));
      } else {
        // inner table
        return pad(entry, level + 1);
  }
    });

  return {
    name: table.name,
      content: paddedContent
  };
  }

  return pad(wholeTable, 0);
}

function calculateNumberOfColumns(tables: Array<INamedTable | string[]>): number {
  if (tables.length) {
    return _.max(tables.map((table) => {
      if (table instanceof Array) {
        return table.length || 1;
      } else {
        return calculateNumberOfColumns(table.content);
      }
    }));
  } else {
    return 1;
  }
}

export interface INamedTable {
  name: string;
  content: Array<INamedTable | string[]>;
}

function isINamedTable(object: any): object is INamedTable {
  return object != null
    && typeof(object.name) === "string"
    && object.content instanceof Array
    && object.content.every((item: any) => isINamedTable(item) || (item instanceof Array && item.every((itemComponent) => typeof(itemComponent) === "string")));
}

// number - level of the table (controls left padding of the table and name)
export type NamedTables = INamedTable[];
type ObjectToNamedTablesConvertor<T> = (object: T,
                                numberFormatter: (num: number) => string,
                                dateFormatter: (date: Date) => string,
                                        percentageFormatter: (percentage: number) => string) => NamedTables;

export function reportObjectAsTitledTables<T>(toNamedTables: ObjectToNamedTablesConvertor<T>, object: T) {
  if (formatIsJson()) {
    console.log(JSON.stringify(object));
  } else {
    let output: string;
    if (formatIsCsv()) {
      const stringTables = toNamedTables(object, (num) => num.toString(), (date) => date.toISOString(), (percentage) => percentage.toString());
      output = convertNamedTablesToCsvString(stringTables);
    } else {
      const stringTables = toNamedTables(object, (num) => _.round(num, 2).toString(), (date) => date.toString(), (percentage) => _.round(percentage, 2).toString() + "%");
      output = convertNamedTablesToListString(stringTables);
    }

    console.log(output);
  }
}
