// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
//
// This implementation is inspired by POSIX and Golang but does not port
// implementation code.

enum State {
  PASSTHROUGH,
  PERCENT,
  POSITIONAL,
  PRECISION,
  WIDTH,
}

enum WorP {
  WIDTH,
  PRECISION,
}

class Flags {
  plus?: boolean;
  dash?: boolean;
  sharp?: boolean;
  space?: boolean;
  zero?: boolean;
  lessthan?: boolean;
  width = -1;
  precision = -1;
}

const min = Math.min;
const UNICODE_REPLACEMENT_CHARACTER = "\ufffd";
const DEFAULT_PRECISION = 6;
const FLOAT_REGEXP = /(-?)(\d)\.?(\d*)e([+-])(\d+)/;

enum F {
  sign = 1,
  mantissa,
  fractional,
  esign,
  exponent,
}

class Printf {
  format: string;
  args: unknown[];
  i: number;

  state: State = State.PASSTHROUGH;
  verb = "";
  buf = "";
  argNum = 0;
  flags: Flags = new Flags();

  haveSeen: boolean[];

  // barf, store precision and width errors for later processing ...
  tmpError?: string;

  constructor(format: string, ...args: unknown[]) {
    this.format = format;
    this.args = args;
    this.haveSeen = new Array(args.length);
    this.i = 0;
  }

  doPrintf(): string {
    for (; this.i < this.format.length; ++this.i) {
      const c = this.format[this.i];
      switch (this.state) {
        case State.PASSTHROUGH:
          if (c === "%") {
            this.state = State.PERCENT;
          } else {
            this.buf += c;
          }
          break;
        case State.PERCENT:
          if (c === "%") {
            this.buf += c;
            this.state = State.PASSTHROUGH;
          } else {
            this.handleFormat();
          }
          break;
        default:
          throw Error("Should be unreachable, certainly a bug in the lib.");
      }
    }
    // check for unhandled args
    let extras = false;
    let err = "%!(EXTRA";
    for (let i = 0; i !== this.haveSeen.length; ++i) {
      if (!this.haveSeen[i]) {
        extras = true;
        err += ` '${Deno.inspect(this.args[i])}'`;
      }
    }
    err += ")";
    if (extras) {
      this.buf += err;
    }
    return this.buf;
  }

  // %[<positional>]<flag>...<verb>
  handleFormat(): void {
    this.flags = new Flags();
    const flags = this.flags;
    for (; this.i < this.format.length; ++this.i) {
      const c = this.format[this.i];
      switch (this.state) {
        case State.PERCENT:
          switch (c) {
            case "[":
              this.handlePositional();
              this.state = State.POSITIONAL;
              break;
            case "+":
              flags.plus = true;
              break;
            case "<":
              flags.lessthan = true;
              break;
            case "-":
              flags.dash = true;
              flags.zero = false; // only left pad zeros, dash takes precedence
              break;
            case "#":
              flags.sharp = true;
              break;
            case " ":
              flags.space = true;
              break;
            case "0":
              // only left pad zeros, dash takes precedence
              flags.zero = !flags.dash;
              break;
            default:
              if (("1" <= c && c <= "9") || c === "." || c === "*") {
                if (c === ".") {
                  this.flags.precision = 0;
                  this.state = State.PRECISION;
                  this.i++;
                } else {
                  this.state = State.WIDTH;
                }
                this.handleWidthAndPrecision(flags);
              } else {
                this.handleVerb();
                return; // always end in verb
              }
          } // switch c
          break;
        case State.POSITIONAL: // either a verb or * only verb for now, TODO
          if (c === "*") {
            const worp = this.flags.precision === -1
              ? WorP.WIDTH
              : WorP.PRECISION;
            this.handleWidthOrPrecisionRef(worp);
            this.state = State.PERCENT;
            break;
          } else {
            this.handleVerb();
            return; // always end in verb
          }
        default:
          throw new Error(`Should not be here ${this.state}, library bug!`);
      } // switch state
    }
  }

  handleWidthOrPrecisionRef(wOrP: WorP): void {
    if (this.argNum >= this.args.length) {
      // handle Positional should have already taken care of it...
      return;
    }
    const arg = this.args[this.argNum];
    this.haveSeen[this.argNum] = true;
    if (typeof arg === "number") {
      switch (wOrP) {
        case WorP.WIDTH:
          this.flags.width = arg;
          break;
        default:
          this.flags.precision = arg;
      }
    } else {
      const tmp = wOrP === WorP.WIDTH ? "WIDTH" : "PREC";
      this.tmpError = `%!(BAD ${tmp} '${this.args[this.argNum]}')`;
    }
    this.argNum++;
  }

  handleWidthAndPrecision(flags: Flags): void {
    const fmt = this.format;
    for (; this.i !== this.format.length; ++this.i) {
      const c = fmt[this.i];
      switch (this.state) {
        case State.WIDTH:
          switch (c) {
            case ".":
              // initialize precision, %9.f -> precision=0
              this.flags.precision = 0;
              this.state = State.PRECISION;
              break;
            case "*":
              this.handleWidthOrPrecisionRef(WorP.WIDTH);
              // force . or flag at this point
              break;
            default:
              const val = parseInt(c);
              // most likely parseInt does something stupid that makes
              // it unusable for this scenario ...
              // if we encounter a non (number|*|.) we're done with prec & wid
              if (isNaN(val)) {
                this.i--;
                this.state = State.PERCENT;
                return;
              }
              flags.width = flags.width == -1 ? 0 : flags.width;
              flags.width *= 10;
              flags.width += val;
          } // switch c
          break;
        case State.PRECISION: {
          if (c === "*") {
            this.handleWidthOrPrecisionRef(WorP.PRECISION);
            break;
          }
          const val = parseInt(c);
          if (isNaN(val)) {
            // one too far, rewind
            this.i--;
            this.state = State.PERCENT;
            return;
          }
          flags.precision *= 10;
          flags.precision += val;
          break;
        }
        default:
          throw new Error("can't be here. bug.");
      } // switch state
    }
  }

  handlePositional(): void {
    if (this.format[this.i] !== "[") {
      // sanity only
      throw new Error("Can't happen? Bug.");
    }
    let positional = 0;
    const format = this.format;
    this.i++;
    let err = false;
    for (; this.i !== this.format.length; ++this.i) {
      if (format[this.i] === "]") {
        break;
      }
      positional *= 10;
      const val = parseInt(format[this.i]);
      if (isNaN(val)) {
        //throw new Error(
        //  `invalid character in positional: ${format}[${format[this.i]}]`
        //);
        this.tmpError = "%!(BAD INDEX)";
        err = true;
      }
      positional += val;
    }
    if (positional - 1 >= this.args.length) {
      this.tmpError = "%!(BAD INDEX)";
      err = true;
    }
    this.argNum = err ? this.argNum : positional - 1;
    return;
  }

  handleLessThan(): string {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const arg = this.args[this.argNum] as any;
    if ((arg || {}).constructor.name !== "Array") {
      throw new Error(`arg ${arg} is not an array. Todo better error handling`);
    }
    let str = "[ ";
    for (let i = 0; i !== arg.length; ++i) {
      if (i !== 0) str += ", ";
      str += this._handleVerb(arg[i]);
    }
    return str + " ]";
  }

  handleVerb(): void {
    const verb = this.format[this.i];
    this.verb = verb;
    if (this.tmpError) {
      this.buf += this.tmpError;
      this.tmpError = undefined;
      if (this.argNum < this.haveSeen.length) {
        this.haveSeen[this.argNum] = true; // keep track of used args
      }
    } else if (this.args.length <= this.argNum) {
      this.buf += `%!(MISSING '${verb}')`;
    } else {
      const arg = this.args[this.argNum]; // check out of range
      this.haveSeen[this.argNum] = true; // keep track of used args
      if (this.flags.lessthan) {
        this.buf += this.handleLessThan();
      } else {
        this.buf += this._handleVerb(arg);
      }
    }
    this.argNum++; // if there is a further positional, it will reset.
    this.state = State.PASSTHROUGH;
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  _handleVerb(arg: any): string {
    switch (this.verb) {
      case "t":
        return this.pad(arg.toString());
        break;
      case "b":
        return this.fmtNumber(arg as number, 2);
        break;
      case "c":
        return this.fmtNumberCodePoint(arg as number);
        break;
      case "d":
        return this.fmtNumber(arg as number, 10);
        break;
      case "o":
        return this.fmtNumber(arg as number, 8);
        break;
      case "x":
        return this.fmtHex(arg);
        break;
      case "X":
        return this.fmtHex(arg, true);
        break;
      case "e":
        return this.fmtFloatE(arg as number);
        break;
      case "E":
        return this.fmtFloatE(arg as number, true);
        break;
      case "f":
      case "F":
        return this.fmtFloatF(arg as number);
        break;
      case "g":
        return this.fmtFloatG(arg as number);
        break;
      case "G":
        return this.fmtFloatG(arg as number, true);
        break;
      case "s":
        return this.fmtString(arg as string);
        break;
      case "T":
        return this.fmtString(typeof arg);
        break;
      case "v":
        return this.fmtV(arg);
        break;
      case "j":
        return this.fmtJ(arg);
        break;
      default:
        return `%!(BAD VERB '${this.verb}')`;
    }
  }

  pad(s: string): string {
    const padding = this.flags.zero ? "0" : " ";

    if (this.flags.dash) {
      return s.padEnd(this.flags.width, padding);
    }

    return s.padStart(this.flags.width, padding);
  }
  padNum(nStr: string, neg: boolean): string {
    let sign: string;
    if (neg) {
      sign = "-";
    } else if (this.flags.plus || this.flags.space) {
      sign = this.flags.plus ? "+" : " ";
    } else {
      sign = "";
    }
    const zero = this.flags.zero;
    if (!zero) {
      // sign comes in front of padding when padding w/ zero,
      // in from of value if padding with spaces.
      nStr = sign + nStr;
    }

    const pad = zero ? "0" : " ";
    const len = zero ? this.flags.width - sign.length : this.flags.width;

    if (this.flags.dash) {
      nStr = nStr.padEnd(len, pad);
    } else {
      nStr = nStr.padStart(len, pad);
    }

    if (zero) {
      // see above
      nStr = sign + nStr;
    }
    return nStr;
  }

  fmtNumber(n: number, radix: number, upcase = false): string {
    let num = Math.abs(n).toString(radix);
    const prec = this.flags.precision;
    if (prec !== -1) {
      this.flags.zero = false;
      num = n === 0 && prec === 0 ? "" : num;
      while (num.length < prec) {
        num = "0" + num;
      }
    }
    let prefix = "";
    if (this.flags.sharp) {
      switch (radix) {
        case 2:
          prefix += "0b";
          break;
        case 8:
          // don't annotate octal 0 with 0...
          prefix += num.startsWith("0") ? "" : "0";
          break;
        case 16:
          prefix += "0x";
          break;
        default:
          throw new Error("cannot handle base: " + radix);
      }
    }
    // don't add prefix in front of value truncated by precision=0, val=0
    num = num.length === 0 ? num : prefix + num;
    if (upcase) {
      num = num.toUpperCase();
    }
    return this.padNum(num, n < 0);
  }

  fmtNumberCodePoint(n: number): string {
    let s = "";
    try {
      s = String.fromCodePoint(n);
    } catch (RangeError) {
      s = UNICODE_REPLACEMENT_CHARACTER;
    }
    return this.pad(s);
  }

  fmtFloatSpecial(n: number): string {
    // formatting of NaN and Inf are pants-on-head
    // stupid and more or less arbitrary.

    if (isNaN(n)) {
      this.flags.zero = false;
      return this.padNum("NaN", false);
    }
    if (n === Number.POSITIVE_INFINITY) {
      this.flags.zero = false;
      this.flags.plus = true;
      return this.padNum("Inf", false);
    }
    if (n === Number.NEGATIVE_INFINITY) {
      this.flags.zero = false;
      return this.padNum("Inf", true);
    }
    return "";
  }

  roundFractionToPrecision(fractional: string, precision: number): string {
    if (fractional.length > precision) {
      fractional = "1" + fractional; // prepend a 1 in case of leading 0
      let tmp = parseInt(fractional.substr(0, precision + 2)) / 10;
      tmp = Math.round(tmp);
      fractional = Math.floor(tmp).toString();
      fractional = fractional.substr(1); // remove extra 1
    } else {
      while (fractional.length < precision) {
        fractional += "0";
      }
    }
    return fractional;
  }

  fmtFloatE(n: number, upcase = false): string {
    const special = this.fmtFloatSpecial(n);
    if (special !== "") {
      return special;
    }

    const m = n.toExponential().match(FLOAT_REGEXP);
    if (!m) {
      throw Error("can't happen, bug");
    }

    let fractional = m[F.fractional];
    const precision = this.flags.precision !== -1
      ? this.flags.precision
      : DEFAULT_PRECISION;
    fractional = this.roundFractionToPrecision(fractional, precision);

    let e = m[F.exponent];
    // scientific notation output with exponent padded to minlen 2
    e = e.length == 1 ? "0" + e : e;

    const val = `${m[F.mantissa]}.${fractional}${upcase ? "E" : "e"}${
      m[F.esign]
    }${e}`;
    return this.padNum(val, n < 0);
  }

  fmtFloatF(n: number): string {
    const special = this.fmtFloatSpecial(n);
    if (special !== "") {
      return special;
    }

    // stupid helper that turns a number into a (potentially)
    // VERY long string.
    function expandNumber(n: number): string {
      if (Number.isSafeInteger(n)) {
        return n.toString() + ".";
      }

      const t = n.toExponential().split("e");
      let m = t[0].replace(".", "");
      const e = parseInt(t[1]);
      if (e < 0) {
        let nStr = "0.";
        for (let i = 0; i !== Math.abs(e) - 1; ++i) {
          nStr += "0";
        }
        return (nStr += m);
      } else {
        const splIdx = e + 1;
        while (m.length < splIdx) {
          m += "0";
        }
        return m.substr(0, splIdx) + "." + m.substr(splIdx);
      }
    }
    // avoiding sign makes padding easier
    const val = expandNumber(Math.abs(n)) as string;
    const arr = val.split(".");
    const dig = arr[0];
    let fractional = arr[1];

    const precision = this.flags.precision !== -1
      ? this.flags.precision
      : DEFAULT_PRECISION;
    fractional = this.roundFractionToPrecision(fractional, precision);

    return this.padNum(`${dig}.${fractional}`, n < 0);
  }

  fmtFloatG(n: number, upcase = false): string {
    const special = this.fmtFloatSpecial(n);
    if (special !== "") {
      return special;
    }

    // The double argument representing a floating-point number shall be
    // converted in the style f or e (or in the style F or E in
    // the case of a G conversion specifier), depending on the
    // value converted and the precision. Let P equal the
    // precision if non-zero, 6 if the precision is omitted, or 1
    // if the precision is zero. Then, if a conversion with style E would
    // have an exponent of X:

    //     - If P > X>=-4, the conversion shall be with style f (or F )
    //     and precision P -( X+1).

    //     - Otherwise, the conversion shall be with style e (or E )
    //     and precision P -1.

    // Finally, unless the '#' flag is used, any trailing zeros shall be
    // removed from the fractional portion of the result and the
    // decimal-point character shall be removed if there is no
    // fractional portion remaining.

    // A double argument representing an infinity or NaN shall be
    // converted in the style of an f or F conversion specifier.
    // https://pubs.opengroup.org/onlinepubs/9699919799/functions/fprintf.html

    let P = this.flags.precision !== -1
      ? this.flags.precision
      : DEFAULT_PRECISION;
    P = P === 0 ? 1 : P;

    const m = n.toExponential().match(FLOAT_REGEXP);
    if (!m) {
      throw Error("can't happen");
    }

    const X = parseInt(m[F.exponent]) * (m[F.esign] === "-" ? -1 : 1);
    let nStr = "";
    if (P > X && X >= -4) {
      this.flags.precision = P - (X + 1);
      nStr = this.fmtFloatF(n);
      if (!this.flags.sharp) {
        nStr = nStr.replace(/\.?0*$/, "");
      }
    } else {
      this.flags.precision = P - 1;
      nStr = this.fmtFloatE(n);
      if (!this.flags.sharp) {
        nStr = nStr.replace(/\.?0*e/, upcase ? "E" : "e");
      }
    }
    return nStr;
  }

  fmtString(s: string): string {
    if (this.flags.precision !== -1) {
      s = s.substr(0, this.flags.precision);
    }
    return this.pad(s);
  }

  fmtHex(val: string | number, upper = false): string {
    // allow others types ?
    switch (typeof val) {
      case "number":
        return this.fmtNumber(val as number, 16, upper);
      case "string": {
        const sharp = this.flags.sharp && val.length !== 0;
        let hex = sharp ? "0x" : "";
        const prec = this.flags.precision;
        const end = prec !== -1 ? min(prec, val.length) : val.length;
        for (let i = 0; i !== end; ++i) {
          if (i !== 0 && this.flags.space) {
            hex += sharp ? " 0x" : " ";
          }
          // TODO: for now only taking into account the
          // lower half of the codePoint, ie. as if a string
          // is a list of 8bit values instead of UCS2 runes
          const c = (val.charCodeAt(i) & 0xff).toString(16);
          hex += c.length === 1 ? `0${c}` : c;
        }
        if (upper) {
          hex = hex.toUpperCase();
        }
        return this.pad(hex);
      }
      default:
        throw new Error(
          "currently only number and string are implemented for hex",
        );
    }
  }

  fmtV(val: object): string {
    if (this.flags.sharp) {
      const options = this.flags.precision !== -1
        ? { depth: this.flags.precision }
        : {};
      return this.pad(Deno.inspect(val, options));
    } else {
      const p = this.flags.precision;
      return p === -1 ? val.toString() : val.toString().substr(0, p);
    }
  }

  fmtJ(val: unknown): string {
    return JSON.stringify(val);
  }
}

export function sprintf(format: string, ...args: unknown[]): string {
  const printf = new Printf(format, ...args);
  return printf.doPrintf();
}

export function printf(format: string, ...args: unknown[]): void {
  const s = sprintf(format, ...args);
  Deno.stdout.writeSync(new TextEncoder().encode(s));
}
