import {CellValue} from 'app/common/DocActions';
import {GristType} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import * as gristTypes from 'app/common/gristTypes';
import {NumberFormatOptions} from 'app/common/NumberFormat';
import {FormatOptions, formatUnknown, IsRightTypeFunc} from 'app/common/ValueFormatter';
import {decodeObject} from 'app/plugin/objtypes';
import {Style} from 'exceljs';
import * as moment from 'moment-timezone';

interface WidgetOptions extends NumberFormatOptions {
  textColor?: 'string';
  fillColor?: 'string';
  alignment?: 'left' | 'center' | 'right';
  dateFormat?: string;
  timeFormat?: string;
}
class BaseFormatter {
  protected isRightType: IsRightTypeFunc;
  protected widgetOptions: WidgetOptions;

  constructor(public type: string, public opts: FormatOptions) {
    this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||
      gristTypes.isRightType('Any')!;
    this.widgetOptions = opts;
  }

  /**
   * Formats a value that matches the type of this formatter. This should be overridden by derived
   * classes to handle values in formatter-specific ways.
   */
  public format(value: any): any {
    return value;
  }

  public style(): Partial<Style> {
    const argb = (hex: string) => `FF${hex.substr(1)}`;
    const style: Partial<Style> = {};
    if (this.widgetOptions.fillColor) {
      style.fill = {
        type: 'pattern',
        pattern: 'solid',
        fgColor: { argb: argb(this.widgetOptions.fillColor) }
      };
    }
    if (this.widgetOptions.textColor) {
      style.font = {
        color: { argb: argb(this.widgetOptions.textColor) }
      };
    }
    if (this.widgetOptions.alignment) {
      style.alignment = {
        horizontal: this.widgetOptions.alignment
      };
    }
    if (this.widgetOptions.dateFormat) {
      style.numFmt = excelDateFormat(this.widgetOptions.dateFormat, 'yyyy-mm-dd');
    }
    if (this.widgetOptions.timeFormat) {
      style.numFmt = excelDateFormat(this.widgetOptions.dateFormat!, 'yyyy-mm-dd') + ' ' +
        excelDateFormat(this.widgetOptions.timeFormat, 'h:mm am/pm');
    }
    // For number formats - we will support default excel formatting only,
    // those formats strings are the defaults that LibreOffice Calc is using.
    if (this.widgetOptions.numMode) {
      if (this.widgetOptions.numMode === 'currency') {
        style.numFmt = '[$$-409]#,##0.00';
      } else if (this.widgetOptions.numMode === 'percent') {
        style.numFmt = '0.00%';
      } else if (this.widgetOptions.numMode === 'decimal') {
        style.numFmt = '0.00';
      } else if (this.widgetOptions.numMode === 'scientific') {
        style.numFmt = '0.00E+00';
      }
    }
    return style;
  }

  /**
   * Formats using this.format() if a value is of the right type for this formatter, or using
   * formatUnknown (like AnyFormatter) otherwise, resulting in a string representation.
   */
  public formatAny(value: any): any {
    return this.isRightType(value) ? this.format(value) : formatUnknown(value);
  }
}

class AnyFormatter extends BaseFormatter {
  public format(value: any): any {
    return formatUnknown(value);
  }
}

class ChoiceListFormatter extends BaseFormatter {
  public format(value: any): any {
    const obj = decodeObject(value);
    if (Array.isArray(obj)) {
      return obj.join("; ");
    }
    return formatUnknown(value);
  }
}

class UnsupportedFormatter extends BaseFormatter {
  public format(value: any): any {
    return '';
  }
}

class NumberFormatter extends BaseFormatter {
  public format(value: any): any {
    return Number.isFinite(value) ? value : '';
  }
}

class DateFormatter extends BaseFormatter {
  private _timezone: string;

  constructor(type: string, opts: WidgetOptions, timezone: string = 'UTC') {
    opts.dateFormat = opts.dateFormat || 'YYYY-MM-DD';
    super(type, opts);
    this._timezone = timezone || 'UTC';
    // For native conversion - booleans are not a right type.
    this.isRightType = (value: CellValue) => typeof value === 'number';
  }

  public format(value: any): any {
    if (value === null) { return ''; }
    // convert time to correct timezone
    const time = moment(value * 1000).tz(this._timezone);
    // in case moment is not able to interpret this as a valid date
    // fallback to formatUnknown, for example for 0, NaN, Infinity
    if (!time) {
      return formatUnknown(value);
    }
    // make it look like a local time
    time.utc(true).local();
    // moment objects are mutable so we can just return original object.
    return time.toDate();
  }
}

class DateTimeFormatter extends DateFormatter {
  constructor(type: string, opts: WidgetOptions) {
    const timezone = gutil.removePrefix(type, "DateTime:") || '';
    opts.timeFormat = opts.timeFormat === undefined ? 'h:mma' : opts.timeFormat;
    super(type, opts, timezone);
  }
}

const formatters: Partial<Record<GristType, typeof BaseFormatter>> = {
  // for numbers - return javascript number
  Numeric: NumberFormatter,
  Int: NumberFormatter,
  // for booleans - return javascript booleans
  Bool: BaseFormatter,
  // for dates - return javascript Date object
  Date: DateFormatter,
  DateTime: DateTimeFormatter,
  ChoiceList: ChoiceListFormatter,
  // for attachments - return blank cell
  Attachments: UnsupportedFormatter,
  // for anything else - return string (use default AnyFormatter)
};

/**
 * Takes column type and format options and returns a constructor with a format function that can
 * properly convert a value passed to it into the right javascript object for that column.
 * Exceljs library is using javascript primitives to specify correct excel type.
 */
export function createExcelFormatter(type: string, opts: FormatOptions): BaseFormatter {
  const ctor = formatters[gristTypes.extractTypeFromColType(type) as GristType] || AnyFormatter;
  return new ctor(type, opts);
}

// ----------------------------------------------------------------------
// Helper functions
// ----------------------------------------------------------------------

// Mapping from moment-js basic date format tokens to excel numFmt basic tokens.
// We will convert all our predefined format to excel ones, and try to do our
// best on converting custom formats. If we fail on custom formats we will fall
// back to default ones.
// More on formats can be found:
// https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.numberingformats?view=openxml-2.8.1
// http://officeopenxml.com/WPdateTimeFieldSwitches.php
const mapping = new Map<string, string>();
mapping.set('YYYY', 'yyyy');
mapping.set('YY', 'yy');
mapping.set('M', 'm');
mapping.set('MM', 'mm');
mapping.set('MMM', 'mmm');
mapping.set('MMMM', 'mmmm');
mapping.set('D', 'd');
mapping.set('DD', 'dd');
mapping.set('DDD', 'ddd');
mapping.set('DDDD', 'dddd');
mapping.set('Do', 'dd'); // no direct match
mapping.set('L', 'yyyy-mm-dd');
mapping.set('LL', 'mmmmm d yyyy');
mapping.set('LLL', 'mmmmm d yyyy h:mm am/pm');
mapping.set('LLLL', 'ddd, mmmmm d yyyy h:mm am/pm');
mapping.set('h', 'h');
mapping.set('HH', 'hh');
// Minutes formats are the same as month's ones, but when they are after hour format
// they are treated as minutes.
mapping.set('m', 'm');
mapping.set('mm', 'mm');
mapping.set('mma', 'mm am/pm');
mapping.set('ss', 'ss');
mapping.set('s', 's');
mapping.set('a', 'am/pm');
mapping.set('A', 'am/pm');
mapping.set('S', '0');
mapping.set('SS', '00');
mapping.set('SSS', '000');
mapping.set('SSSS', '0000');
mapping.set('SSSSS', '00000');
mapping.set('SSSSSS', '000000');
// We will omit timezone formats
mapping.set('z', '');
mapping.set('zz', '');
mapping.set('Z', '');
mapping.set('ZZ', '');

/**
 * Converts Moment js format string to excel numFormat
 * @param format Moment js format string
 * @param def Default excel format string
 */
function excelDateFormat(format: string, def: string) {
  // split format to chunks by common separator
  const chunks = format.split(/([\s:.,-/]+)/);

  // try to map chunks
  for (let i = 0; i < chunks.length; i += 2) {
    const chunk = chunks[i];
    if (mapping.has(chunk)) {
      chunks[i] = mapping.get(chunk)!;
    } else {
      // fail on first mismatch
      return def;
    }
  }
  // fix the separators - they need to be prefixed by backslash
  for (let i = 1; i < chunks.length; i += 2) {
    const sep = chunks[i];
    if (sep === '-') {
      chunks[i] = '\\-';
    }
    if (sep.trim() === '') {
      chunks[i] = '\\' + sep;
    }
  }

  return chunks.join('');
}
