/*
 This file is part of GNU Taler
 (C) 2019 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * Types and helper functions for dealing with Taler amounts.
 */

/**
 * Imports.
 */
import {
  Codec,
  Context,
  DecodingError,
  buildCodecForObject,
  codecForNumber,
  codecForString,
  renderContext,
} from "./codec.js";
import { opFixedSuccess, opKnownFailure } from "./operation.js";
import { AmountString, CurrencySpecification } from "./types-taler-common.js";

/**
 * Number of fractional units that one value unit represents.
 */
export const amountFractionalBase = 1e8;

/**
 * How many digits behind the comma are required to represent the
 * fractional value in human readable decimal format?  Must match
 * lg(fractionalBase)
 */
export const amountFractionalLength = 8;

/**
 * Maximum allowed value field of an amount.
 */
export const amountMaxValue = 2 ** 52;

/**
 * Separator character between integer and fractional
 */
export const FRAC_SEPARATOR = ".";

/**
 * Separator character between integer and fractional
 */
export const CURRENCY_SEPARATOR = ":";

/**
 * Non-negative financial amount.  Fractional values are expressed as multiples
 * of 1e-8.
 */
export interface AmountJson {
  /**
   * Value, must be an integer.
   */
  readonly value: number;

  /**
   * Fraction, must be an integer.  Represent 1/1e8 of a unit.
   */
  readonly fraction: number;

  /**
   * Currency of the amount.
   */
  readonly currency: string;
}

/**
 * Immutable amount.
 */
export class Amount {
  static from(a: AmountLike): Amount {
    return new Amount(Amounts.parseOrThrow(a), 0);
  }

  static zeroOfCurrency(currency: string): Amount {
    return new Amount(Amounts.zeroOfCurrency(currency), 0);
  }

  add(...a: AmountLike[]): Amount {
    if (this.saturated) {
      return this;
    }
    const r = Amounts.add(this.val, ...a);
    return new Amount(r.amount, r.saturated ? 1 : 0);
  }

  isZero(): boolean {
    return this.val.fraction === 0 && this.val.value === 0;
  }

  sub(...a: AmountLike[]): Amount {
    if (this.saturated) {
      return this;
    }
    const r = Amounts.sub(this.val, ...a);
    return new Amount(r.amount, r.saturated ? 1 : 0);
  }

  mult(n: number): Amount {
    if (this.saturated) {
      return this;
    }
    const r = Amounts.mult(this, n);
    return new Amount(r.amount, r.saturated ? 1 : 0);
  }

  toJson(): AmountJson {
    return { ...this.val };
  }

  toString(): AmountString {
    return Amounts.stringify(this.val);
  }

  private constructor(
    private val: AmountJson,
    private saturated: number,
  ) {}
}

export const codecForAmountJson = (): Codec<AmountJson> =>
  buildCodecForObject<AmountJson>()
    .property("currency", codecForString())
    .property("value", codecForNumber())
    .property("fraction", codecForNumber())
    .build("AmountJson");

export function codecForAmountString(): Codec<AmountString> {
  return {
    decode(x: any, c?: Context): AmountString {
      if (typeof x !== "string") {
        throw new DecodingError(
          `expected string at ${renderContext(c)} but got ${typeof x}`,
        );
      }
      if (Amounts.parse(x) === undefined) {
        throw new DecodingError(
          `invalid amount at ${renderContext(c)} got "${x}"`,
        );
      }
      return x as AmountString;
    },
  };
}

/**
 * Result of a possibly overflowing operation.
 */
export interface Result {
  /**
   * Resulting, possibly saturated amount.
   */
  amount: AmountJson;
  /**
   * Was there an over-/underflow?
   */
  saturated: boolean;
}

/**
 * Type for things that are treated like amounts.
 */
export type AmountLike = string | AmountString | AmountJson | Amount;

export interface DivmodResult {
  quotient: number;
  remainder: AmountJson;
}

export enum AmountParseError {
  /**
   * Should have a string separated with a colon
   */
  MISSING_CURRENCY,
  /**
   * Currency should be less than 11 characters
   */
  CURRENCY_TOO_LONG,
  /**
   * Currency should be only letter from a to z
   */
  BAD_CURRENCY,
  /**
   * Number can only be digits from 0 to 9
   */
  BAD_NUMBER,
  /**
   * Integer part should be less than 2 ** 52
   */
  TOO_HIGH,
  /**
   * Fractional part should be shorter than 8 digits
   */
  TOO_PRECISE,
}

/**
 * Helper class for dealing with amounts.
 */
export class Amounts {
  private constructor() {
    throw Error("not instantiable");
  }

  static currencyOf(amount: AmountLike) {
    const amt = Amounts.parseOrThrow(amount);
    return amt.currency;
  }

  static zeroOfAmount(amount: AmountLike): AmountJson {
    const amt = Amounts.parseOrThrow(amount);
    return {
      currency: amt.currency,
      fraction: 0,
      value: 0,
    };
  }

  /**
   * Get an amount that represents zero units of a currency.
   */
  static zeroOfCurrency(currency: string): AmountJson {
    return {
      currency,
      fraction: 0,
      value: 0,
    };
  }

  static jsonifyAmount(amt: AmountLike): AmountJson {
    if (typeof amt === "string") {
      return Amounts.parseOrThrow(amt);
    }
    if (amt instanceof Amount) {
      return amt.toJson();
    }
    return amt;
  }

  static divmod(a1: AmountLike, a2: AmountLike): DivmodResult {
    const am1 = Amounts.jsonifyAmount(a1);
    const am2 = Amounts.jsonifyAmount(a2);
    if (am1.currency != am2.currency) {
      throw Error(`incompatible currency (${am1.currency} vs${am2.currency})`);
    }

    const x1 =
      BigInt(am1.value) * BigInt(amountFractionalBase) + BigInt(am1.fraction);
    const x2 =
      BigInt(am2.value) * BigInt(amountFractionalBase) + BigInt(am2.fraction);

    const quotient = x1 / x2;
    const remainderScaled = x1 % x2;

    return {
      quotient: Number(quotient),
      remainder: {
        currency: am1.currency,
        value: Number(remainderScaled / BigInt(amountFractionalBase)),
        fraction: Number(remainderScaled % BigInt(amountFractionalBase)),
      },
    };
  }

  static sum(amounts: AmountLike[]): Result {
    if (amounts.length <= 0) {
      throw Error("can't sum zero amounts");
    }
    const jsonAmounts = amounts.map((x) => Amounts.jsonifyAmount(x));
    return Amounts.add(jsonAmounts[0], ...jsonAmounts.slice(1));
  }

  static sumOrZero(currency: string, amounts: AmountLike[]): Result {
    if (amounts.length <= 0) {
      return {
        amount: Amounts.zeroOfCurrency(currency),
        saturated: false,
      };
    }
    const jsonAmounts = amounts.map((x) => Amounts.jsonifyAmount(x));
    return Amounts.add(jsonAmounts[0], ...jsonAmounts.slice(1));
  }

  /**
   * Add two amounts.  Return the result and whether
   * the addition overflowed.  The overflow is always handled
   * by saturating and never by wrapping.
   *
   * Throws when currencies don't match.
   */
  static add(first: AmountLike, ...rest: AmountLike[]): Result {
    const firstJ = Amounts.jsonifyAmount(first);
    const currency = firstJ.currency;
    let value =
      firstJ.value + Math.floor(firstJ.fraction / amountFractionalBase);
    if (value > amountMaxValue) {
      return {
        amount: {
          currency,
          value: amountMaxValue,
          fraction: amountFractionalBase - 1,
        },
        saturated: true,
      };
    }
    let fraction = firstJ.fraction % amountFractionalBase;
    for (const x of rest) {
      const xJ = Amounts.jsonifyAmount(x);
      if (xJ.currency.toUpperCase() !== currency.toUpperCase()) {
        throw Error(`Mismatched currency: ${xJ.currency} and ${currency}`);
      }

      value =
        value +
        xJ.value +
        Math.floor((fraction + xJ.fraction) / amountFractionalBase);
      fraction = Math.floor((fraction + xJ.fraction) % amountFractionalBase);
      if (value > amountMaxValue) {
        return {
          amount: {
            currency,
            value: amountMaxValue,
            fraction: amountFractionalBase - 1,
          },
          saturated: true,
        };
      }
    }
    return { amount: { currency, value, fraction }, saturated: false };
  }

  /**
   * Subtract two amounts.  Return the result and whether
   * the subtraction overflowed.  The overflow is always handled
   * by saturating and never by wrapping.
   *
   * Throws when currencies don't match.
   */
  static sub(a: AmountLike, ...rest: AmountLike[]): Result {
    const aJ = Amounts.jsonifyAmount(a);
    const currency = aJ.currency;
    let value = aJ.value;
    let fraction = aJ.fraction;

    for (const b of rest) {
      const bJ = Amounts.jsonifyAmount(b);
      if (bJ.currency.toUpperCase() !== aJ.currency.toUpperCase()) {
        throw Error(`Mismatched currency: ${bJ.currency} and ${currency}`);
      }
      if (fraction < bJ.fraction) {
        if (value < 1) {
          return {
            amount: { currency, value: 0, fraction: 0 },
            saturated: true,
          };
        }
        value--;
        fraction += amountFractionalBase;
      }
      console.assert(fraction >= bJ.fraction);
      fraction -= bJ.fraction;
      if (value < bJ.value) {
        return { amount: { currency, value: 0, fraction: 0 }, saturated: true };
      }
      value -= bJ.value;
    }

    return { amount: { currency, value, fraction }, saturated: false };
  }

  /**
   * Compare two amounts.  Returns 0 when equal, -1 when a < b
   * and +1 when a > b.  Throws when currencies don't match.
   */
  static cmp(a: AmountLike, b: AmountLike): -1 | 0 | 1 {
    a = Amounts.jsonifyAmount(a);
    b = Amounts.jsonifyAmount(b);
    if (a.currency !== b.currency) {
      throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`);
    }
    const av = a.value + Math.floor(a.fraction / amountFractionalBase);
    const af = a.fraction % amountFractionalBase;
    const bv = b.value + Math.floor(b.fraction / amountFractionalBase);
    const bf = b.fraction % amountFractionalBase;
    switch (true) {
      case av < bv:
        return -1;
      case av > bv:
        return 1;
      case af < bf:
        return -1;
      case af > bf:
        return 1;
      case af === bf:
        return 0;
      default:
        throw Error("assertion failed");
    }
  }

  /**
   * Create a copy of an amount.
   */
  static copy(a: AmountJson): AmountJson {
    return {
      currency: a.currency,
      fraction: a.fraction,
      value: a.value,
    };
  }

  /**
   * Divide an amount.  Throws on division by zero.
   */
  static divide(a: AmountJson, n: number): AmountJson {
    if (n === 0) {
      throw Error(`Division by 0`);
    }
    if (n === 1) {
      return { value: a.value, fraction: a.fraction, currency: a.currency };
    }
    const r = a.value % n;
    return {
      currency: a.currency,
      fraction: Math.floor((r * amountFractionalBase + a.fraction) / n),
      value: Math.floor(a.value / n),
    };
  }

  /**
   * Check if an amount is non-zero.
   */
  static isNonZero(a: AmountLike): boolean {
    a = Amounts.jsonifyAmount(a);
    return a.value > 0 || a.fraction > 0;
  }

  static isZero(a: AmountLike): boolean {
    a = Amounts.jsonifyAmount(a);
    return a.value === 0 && a.fraction === 0;
  }

  /**
   * Check whether a string is a valid currency for a Taler amount.
   */
  static isCurrency(s: string): boolean {
    return /^[a-zA-Z]{1,11}$/.test(s);
  }

  /**
   * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct.
   *
   * Currency name size limit is 11 of ASCII letters
   * Fraction size limit is 8
   */
  static parseWithError(s: string) {
    const c_idx = s.indexOf(CURRENCY_SEPARATOR);

    if (c_idx === -1 || c_idx === 0) {
      return opKnownFailure(AmountParseError.MISSING_CURRENCY);
    }
    if (c_idx > 11) {
      return opKnownFailure(AmountParseError.MISSING_CURRENCY);
    }
    const currency = s.substring(0, c_idx).toUpperCase();
    if (!/^[a-zA-Z]+$/.test(currency)) {
      return opKnownFailure(AmountParseError.BAD_CURRENCY);
    }
    const number = s.substring(c_idx + 1);
    const d_idx = number.indexOf(FRAC_SEPARATOR);
    const integerStr = d_idx === -1 ? number : number.substring(0, d_idx);
    const fractStr =
      d_idx === -1 || d_idx === number.length
        ? "0"
        : number.substring(d_idx + 1);

    if (!/^[0-9]+$/.test(integerStr) || !/^[0-9]+$/.test(fractStr)) {
      return opKnownFailure(AmountParseError.BAD_NUMBER);
    }

    const value = Number.parseInt(integerStr, 10);
    const fraction = Math.round(
      amountFractionalBase * Number.parseFloat(FRAC_SEPARATOR + fractStr),
    );
    if (!Number.isInteger(value) || !Number.isInteger(fraction)) {
      return opKnownFailure(AmountParseError.BAD_NUMBER);
    }
    if (value > amountMaxValue) {
      return opKnownFailure(AmountParseError.TOO_HIGH);
    }
    if (fractStr.length > amountFractionalLength) {
      return opKnownFailure(AmountParseError.TOO_PRECISE);
    }
    return opFixedSuccess({
      currency,
      fraction,
      value,
    });
  }

  /**
   * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct.
   *
   * Currency name size limit is 11 of ASCII letters
   * Fraction size limit is 8
   */
  static parse(s: string): AmountJson | undefined {
    const res = s.match(/^([a-zA-Z]{1,11}):([0-9]+)([.][0-9]{1,8})?$/);
    if (!res) {
      return undefined;
    }
    const tail = res[3] || FRAC_SEPARATOR + "0";
    if (tail.length > amountFractionalLength + 1) {
      return undefined;
    }
    const value = Number.parseInt(res[2]);
    if (value > amountMaxValue) {
      return undefined;
    }
    return {
      currency: res[1].toUpperCase(),
      fraction: Math.round(amountFractionalBase * Number.parseFloat(tail)),
      value,
    };
  }

  /**
   * Parse amount in standard string form (like 'EUR:20.5'),
   * throw if the input is not a valid amount.
   */
  static parseOrThrow(s: AmountLike): AmountJson {
    if (s instanceof Amount) {
      return s.toJson();
    }
    if (typeof s === "object") {
      if (typeof s.currency !== "string") {
        throw Error("invalid amount object");
      }
      if (typeof s.value !== "number") {
        throw Error("invalid amount object");
      }
      if (typeof s.fraction !== "number") {
        throw Error("invalid amount object");
      }
      return { currency: s.currency, value: s.value, fraction: s.fraction };
    } else if (typeof s === "string") {
      const res = Amounts.parse(s);
      if (!res) {
        throw Error(`Can't parse amount: "${s}"`);
      }
      return res;
    } else {
      throw Error("invalid amount (illegal type)");
    }
  }

  static min(a: AmountLike, b: AmountLike): AmountJson {
    const cr = Amounts.cmp(a, b);
    if (cr >= 0) {
      return Amounts.jsonifyAmount(b);
    } else {
      return Amounts.jsonifyAmount(a);
    }
  }

  static max(a: AmountLike, b: AmountLike): AmountJson {
    const cr = Amounts.cmp(a, b);
    if (cr >= 0) {
      return Amounts.jsonifyAmount(a);
    } else {
      return Amounts.jsonifyAmount(b);
    }
  }

  static mult(a: AmountLike, n: number): Result {
    a = this.jsonifyAmount(a);
    if (!Number.isInteger(n)) {
      throw Error("amount can only be multiplied by an integer");
    }
    if (n < 0) {
      throw Error("amount can only be multiplied by a positive integer");
    }
    if (n == 0) {
      return {
        amount: Amounts.zeroOfCurrency(a.currency),
        saturated: false,
      };
    }
    let x = a;
    let acc = Amounts.zeroOfCurrency(a.currency);
    while (n > 1) {
      if (n % 2 == 0) {
        n = n / 2;
      } else {
        n = (n - 1) / 2;
        const r2 = Amounts.add(acc, x);
        if (r2.saturated) {
          return r2;
        }
        acc = r2.amount;
      }
      const r2 = Amounts.add(x, x);
      if (r2.saturated) {
        return r2;
      }
      x = r2.amount;
    }
    return Amounts.add(acc, x);
  }

  /**
   * Check if the argument is a valid amount in string form.
   */
  static check(a: any): boolean {
    if (typeof a !== "string") {
      return false;
    }
    try {
      const parsedAmount = Amounts.parse(a);
      return !!parsedAmount;
    } catch {
      return false;
    }
  }

  /**
   * Convert to standard human-readable string representation that's
   * also used in JSON formats.
   */
  static stringify(a: AmountLike): AmountString {
    a = Amounts.jsonifyAmount(a);
    const s = this.stringifyValue(a);

    return `${a.currency}:${s}` as AmountString;
  }

  /**
   * Show an amount in a form suitable for the user.
   * FIXME:  In the future, this should consider currency-specific
   * settings such as significant digits or currency symbols.
   */
  static toPretty(amount: AmountJson): string {
    const x = amount.value + amount.fraction / amountFractionalBase;
    return `${x} ${amount.currency}`;
  }

  static amountHasSameCurrency(a1: AmountLike, a2: AmountLike): boolean {
    const x1 = this.jsonifyAmount(a1);
    const x2 = this.jsonifyAmount(a2);
    return x1.currency.toUpperCase() === x2.currency.toUpperCase();
  }

  static isSameCurrency(curr1: string, curr2: string): boolean {
    return curr1.toLowerCase() === curr2.toLowerCase();
  }

  static stringifyValue(a: AmountLike, minFractional = 0): string {
    const aJ = Amounts.jsonifyAmount(a);
    const av = aJ.value + Math.floor(aJ.fraction / amountFractionalBase);
    const af = aJ.fraction % amountFractionalBase;
    let s = av.toString();

    if (af || minFractional) {
      s = s + FRAC_SEPARATOR;
      let n = af;
      for (let i = 0; i < amountFractionalLength; i++) {
        if (!n && i >= minFractional) {
          break;
        }
        s = s + Math.floor((n / amountFractionalBase) * 10).toString();
        n = (n * 10) % amountFractionalBase;
      }
    }

    return s;
  }

  /**
   * Number of fractional digits needed to fully represent the amount
   * @param a amount
   * @returns
   */
  static maxFractionalDigits(a: AmountJson): number {
    if (a.fraction === 0) return 0;
    if (a.fraction < 0) {
      console.error("amount fraction can not be negative", a);
      return 0;
    }
    let i = 0;
    let check = true;
    let rest = a.fraction;
    while (rest > 0 && check) {
      check = rest % 10 === 0;
      rest = rest / 10;
      i++;
    }
    return amountFractionalLength - i + 1;
  }

  static stringifyValueWithSpec(
    value: AmountJson,
    spec: CurrencySpecification,
  ): { currency: string; normal: string; small?: string } {
    const strValue = Amounts.stringifyValue(value);
    const pos = strValue.indexOf(FRAC_SEPARATOR);
    const originalPosition = pos < 0 ? strValue.length : pos;

    let currency = value.currency;
    const names = Object.keys(spec.alt_unit_names);
    let FRAC_POS_NEW_POSITION = originalPosition;
    //find symbol
    //FIXME: this should be based on a cache to speed up
    if (names.length > 0) {
      let unitIndex: string = "0"; //default entry by DD51
      names.forEach((index) => {
        const i = Number.parseInt(index, 10);
        if (Number.isNaN(i)) return; //skip
        if (originalPosition - i <= 0) return; //too big
        if (originalPosition - i < FRAC_POS_NEW_POSITION) {
          FRAC_POS_NEW_POSITION = originalPosition - i;
          unitIndex = index;
        }
      });
      currency = spec.alt_unit_names[unitIndex];
    }

    if (originalPosition === FRAC_POS_NEW_POSITION) {
      const { normal, small } = splitNormalAndSmall(
        strValue,
        originalPosition,
        spec,
      );
      return { currency, normal, small };
    }

    const intPart = strValue.substring(0, originalPosition);
    const fracPArt = strValue.substring(originalPosition + 1);
    //indexSize is always smaller than originalPosition
    const newValue =
      intPart.substring(0, FRAC_POS_NEW_POSITION) +
      FRAC_SEPARATOR +
      intPart.substring(FRAC_POS_NEW_POSITION) +
      fracPArt;
    const { normal, small } = splitNormalAndSmall(
      newValue,
      FRAC_POS_NEW_POSITION,
      spec,
    );
    return { currency, normal, small };
  }
}

function splitNormalAndSmall(
  decimal: string,
  fracSeparatorIndex: number,
  spec: CurrencySpecification,
): { normal: string; small?: string } {
  let normal: string;
  let small: string | undefined;
  if (
    decimal.length - fracSeparatorIndex - 1 >
    spec.num_fractional_normal_digits
  ) {
    const limit = fracSeparatorIndex + spec.num_fractional_normal_digits + 1;
    normal = decimal.substring(0, limit);
    small = decimal.substring(limit);
  } else {
    normal = decimal;
    small = undefined;
  }
  return { normal, small };
}
