/*!
 * Copyright 2017 Google Inc. 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.
 */
import {GrpcService} from './common-grpc/service';
import {PreciseDate} from '@google-cloud/precise-date';
import {
  isArray,
  isBoolean,
  isDate,
  isDecimal,
  isInfinite,
  isInteger,
  isNull,
  isNumber,
  isObject,
  isString,
  isUndefined,
  toArray,
} from './helper';
import {Big} from 'big.js';
import {common as p} from 'protobufjs';
import {google as spannerClient} from '../protos/protos';
import {GoogleError} from 'google-gax';
import * as uuid from 'uuid';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Value = any;

export interface Field {
  name: string;
  value: Value;
}

export interface IProtoMessageParams {
  // Supports proto message serialized binary data as a `Buffer` or pass a
  // message object created using the functions generated by protobufjs-cli.
  value: object;
  // Fully qualified name of the proto representing the message definition
  fullName: string;
  /**
   * Provide a First Class function that includes nested functions named
   * "encode" for serialization and "decode" for deserialization of the proto
   * message. The function should be sourced from the JS file generated by
   * protobufjs-cli for the proto message.
   */
  messageFunction?: Function;
}

export interface IProtoEnumParams {
  // Supports proto enum integer constant or pass the enum string
  // the functions generated by protobufjs-cli.
  value: string | number;
  // Fully qualified name of the proto representing the enum definition
  fullName: string;
  /**
   * An object containing enum string to id mapping.
   * @example: { POP: 0, JAZZ: 1, FOLK: 2, ROCK: 3 }
   *
   * The object should be sourced from the JS file generated by
   * protobufjs-cli for the proto message. Additionally, please review the
   * sample at {@link www.samples.com} for guidance.
   *
   * ToDo: Update the link
   */
  enumObject?: object;
}

export interface Json {
  [field: string]: Value;
}

export interface JSONOptions {
  wrapNumbers?: boolean;
  wrapStructs?: boolean;
  includeNameless?: boolean;
}

// https://github.com/Microsoft/TypeScript/issues/27920
type DateFields = [number, number, number];

/**
 * Date-like object used to represent Cloud Spanner Dates. DATE types represent
 * a logical calendar date, independent of time zone. DATE values do not
 * represent a specific 24-hour period. Rather, a given DATE value represents a
 * different 24-hour period when interpreted in a different time zone. Because
 * of this, all values passed to {@link Spanner.date} will be interpreted as
 * local time.
 *
 * To represent an absolute point in time, use {@link Spanner.timestamp}.
 *
 * @see Spanner.date
 * @see https://cloud.google.com/spanner/docs/data-types#date-type
 *
 * @class
 * @extends Date
 *
 * @param {string|number} [date] String representing the date or number
 *     representing the year. If year is a number between 0 and 99, then year is
 *     assumed to be 1900 + year.
 * @param {number} [month] Number representing the month (0 = January).
 * @param {number} [date] Number representing the date.
 *
 * @example
 * ```
 * Spanner.date('3-3-1933');
 * ```
 */
export class SpannerDate extends Date {
  constructor(dateString?: string);
  constructor(year: number, month: number, date: number);
  constructor(...dateFields: Array<string | number | undefined>) {
    const yearOrDateString = dateFields[0];

    // yearOrDateString could be 0 (number).
    if (yearOrDateString === null || yearOrDateString === undefined) {
      dateFields[0] = new Date().toDateString();
    }

    // JavaScript Date objects will interpret ISO date strings as Zulu time,
    // but by formatting it, we can infer local time.
    if (/^\d{4}-\d{1,2}-\d{1,2}/.test(yearOrDateString as string)) {
      const [year, month, date] = (yearOrDateString as string).split(/-|T/);
      dateFields = [`${month}-${date}-${year}`];
    }

    super(...(dateFields.slice(0, 3) as DateFields));
  }
  /**
   * Returns the date in ISO date format.
   * `YYYY-MM-DD`
   *
   * @returns {string}
   */
  toJSON(): string {
    const year = this.getFullYear().toString();
    const month = (this.getMonth() + 1).toString();
    const date = this.getDate().toString();

    return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${date.padStart(
      2,
      '0',
    )}`;
  }
}

/**
 * Using an abstract class to simplify checking for wrapped numbers.
 *
 * @private
 */
abstract class WrappedNumber {
  value!: string | number;
  abstract valueOf(): number;
}

/**
 * @typedef Float32
 * @see Spanner.float32
 */
export class Float32 extends WrappedNumber {
  value: number;
  constructor(value: number) {
    super();
    this.value = value;
  }
  valueOf(): number {
    return Number(this.value);
  }
}

/**
 * @typedef Float
 * @see Spanner.float
 */
export class Float extends WrappedNumber {
  value: number;
  constructor(value: number) {
    super();
    this.value = value;
  }
  valueOf(): number {
    return Number(this.value);
  }
}

/**
 * @typedef Int
 * @see Spanner.int
 */
export class Int extends WrappedNumber {
  value: string;
  constructor(value: string) {
    super();
    this.value = value.toString();
  }
  valueOf(): number {
    const num = Number(this.value);
    if (num > Number.MAX_SAFE_INTEGER) {
      throw new GoogleError(`Integer ${this.value} is out of bounds.`);
    }
    return num;
  }
}

/**
 * @typedef Struct
 * @see Spanner.struct
 */
export class Struct extends Array<Field> {
  /**
   * Converts struct into a pojo (plain old JavaScript object).
   *
   * @param {JSONOptions} [options] JSON options.
   * @returns {object}
   */
  toJSON(options?: JSONOptions): Json {
    return codec.convertFieldsToJson(this, options);
  }
  /**
   * Converts an array of fields to a struct.
   *
   * @private
   *
   * @param {object[]} fields List of struct fields.
   * @return {Struct}
   */
  static fromArray(fields: Field[]): Struct {
    return new Struct(...fields);
  }
  /**
   * Converts a JSON object to a struct.
   *
   * @private
   *
   * @param {object} json Struct JSON.
   * @return {Struct}
   */
  static fromJSON(json: Json): Struct {
    const fields = Object.keys(json || {}).map(name => {
      const value = json[name];
      return {name, value};
    });
    return Struct.fromArray(fields);
  }
}

/**
 * @typedef Numeric
 * @see Spanner.numeric
 */
export class Numeric {
  value: string;
  constructor(value: string) {
    this.value = value;
  }
  valueOf(): Big {
    return new Big(this.value);
  }
  toJSON(): string {
    return this.valueOf().toJSON();
  }
}

/**
 * @typedef PGNumeric
 * @see Spanner.pgNumeric
 */
export class PGNumeric {
  value: string;
  constructor(pgValue: string | number) {
    this.value = pgValue.toString();
  }
  valueOf(): Big {
    if (this.value.toLowerCase() === 'nan') {
      throw new Error(`${this.value} cannot be converted to a numeric value`);
    }
    return new Big(this.value);
  }
  toJSON(): string {
    return this.valueOf().toJSON();
  }
}

/**
 * @typedef ProtoMessage
 * @see Spanner.protoMessage
 */
export class ProtoMessage {
  value: Buffer;
  fullName: string;
  messageFunction?: Function;

  constructor(protoMessageParams: IProtoMessageParams) {
    this.fullName = protoMessageParams.fullName;
    this.messageFunction = protoMessageParams.messageFunction;

    if (protoMessageParams.value instanceof Buffer) {
      this.value = protoMessageParams.value;
    } else if (protoMessageParams.messageFunction) {
      this.value = protoMessageParams.messageFunction['encode'](
        protoMessageParams.value,
      ).finish();
    } else {
      throw new GoogleError(`protoMessageParams cannot be used to construct 
      the ProtoMessage. Pass the serialized buffer of the
       proto message as the value or provide the message object along with the 
       corresponding messageFunction generated by protobufjs-cli.`);
    }
  }

  toJSON(): string {
    if (this.messageFunction) {
      return this.messageFunction['toObject'](
        this.messageFunction['decode'](this.value),
      );
    }
    return this.value.toString();
  }
}

/**
 * @typedef ProtoEnum
 * @see Spanner.protoEnum
 */
export class ProtoEnum {
  value: string;
  fullName: string;
  enumObject?: object;

  constructor(protoEnumParams: IProtoEnumParams) {
    this.fullName = protoEnumParams.fullName;
    this.enumObject = protoEnumParams.enumObject;

    /**
     * @code{IProtoEnumParams} can accept either a number or a string as a value so
     * converting to string and checking whether it's numeric using regex.
     */
    if (/^\d+$/.test(protoEnumParams.value.toString())) {
      this.value = protoEnumParams.value.toString();
    } else if (
      protoEnumParams.enumObject &&
      protoEnumParams.enumObject[protoEnumParams.value]
    ) {
      this.value = protoEnumParams.enumObject[protoEnumParams.value];
    } else {
      throw new GoogleError(`protoEnumParams cannot be used for constructing the
       ProtoEnum. Pass the number as the value or provide the enum string 
       constant as the value along with the corresponding enumObject generated 
       by protobufjs-cli.`);
    }
  }

  toJSON(): string {
    if (this.enumObject) {
      return Object.getPrototypeOf(this.enumObject)[this.value];
    }
    return this.value.toString();
  }
}

/**
 * @typedef PGJsonb
 * @see Spanner.pgJsonb
 */
export class PGJsonb {
  value: object;
  constructor(pgValue: object | string) {
    if (typeof pgValue === 'string') {
      pgValue = JSON.parse(pgValue) as object;
    }
    this.value = pgValue;
  }

  toString(): string {
    return JSON.stringify(this.value);
  }
}

/**
 * @typedef PGOid
 * @see Spanner.pgOid
 */
export class PGOid extends WrappedNumber {
  value: string;
  constructor(value: string) {
    super();
    this.value = value.toString();
  }
  valueOf(): number {
    const num = Number(this.value);
    if (num > Number.MAX_SAFE_INTEGER) {
      throw new GoogleError(`PG.OID ${this.value} is out of bounds.`);
    }
    return num;
  }
}

/**
 * @typedef Interval
 * @see Spanner.interval
 */
export class Interval {
  private months: number;
  private days: number;
  private nanoseconds: bigint;

  // Regex to parse ISO8601 duration format: P[n]Y[n]M[n]DT[n]H[n]M[n][.fffffffff]S
  // Only seconds can be fractional, and can have at most 9 digits after decimal point.
  // Both '.' and ',' are considered valid decimal point.
  private static readonly ISO8601_PATTERN: RegExp =
    /^P(?!$)(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(?=-?[.,]?\d)(-?\d+H)?(-?\d+M)?(-?(((\d+)([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$/;

  static readonly MONTHS_PER_YEAR: number = 12;
  static readonly DAYS_PER_MONTH: number = 30;
  static readonly HOURS_PER_DAY: number = 24;
  static readonly MINUTES_PER_HOUR: number = 60;
  static readonly SECONDS_PER_MINUTE: number = 60;
  static readonly SECONDS_PER_HOUR: number =
    Interval.MINUTES_PER_HOUR * Interval.SECONDS_PER_MINUTE;
  static readonly MILLISECONDS_PER_SECOND: number = 1000;
  static readonly MICROSECONDS_PER_MILLISECOND: number = 1000;
  static readonly NANOSECONDS_PER_MICROSECOND: number = 1000;
  static readonly NANOSECONDS_PER_MILLISECOND: number =
    Interval.MICROSECONDS_PER_MILLISECOND *
    Interval.NANOSECONDS_PER_MICROSECOND;
  static readonly NANOSECONDS_PER_SECOND: number =
    Interval.MILLISECONDS_PER_SECOND *
    Interval.MICROSECONDS_PER_MILLISECOND *
    Interval.NANOSECONDS_PER_MICROSECOND;
  static readonly NANOSECONDS_PER_DAY: bigint =
    BigInt(Interval.HOURS_PER_DAY) *
    BigInt(Interval.SECONDS_PER_HOUR) *
    BigInt(Interval.NANOSECONDS_PER_SECOND);
  static readonly NANOSECONDS_PER_MONTH: bigint =
    BigInt(Interval.DAYS_PER_MONTH) * Interval.NANOSECONDS_PER_DAY;
  static readonly ZERO: Interval = new Interval(0, 0, BigInt(0));

  /**
   * @param months months part of the `Interval`
   * @param days days part of the `Interval`
   * @param nanoseconds nanoseconds part of the `Interval`
   */
  constructor(months: number, days: number, nanoseconds: bigint) {
    if (!isInteger(months)) {
      throw new GoogleError(
        `Invalid months: ${months}, months should be an integral value`,
      );
    }

    if (!isInteger(days)) {
      throw new GoogleError(
        `Invalid days: ${days}, days should be an integral value`,
      );
    }

    if (isNull(nanoseconds) || isUndefined(nanoseconds)) {
      throw new GoogleError(
        `Invalid nanoseconds: ${nanoseconds}, nanoseconds should be a valid bigint value`,
      );
    }

    this.months = months;
    this.days = days;
    this.nanoseconds = nanoseconds;
  }

  /**
   * @returns months part of the `Interval`.
   */
  getMonths(): number {
    return this.months;
  }

  /**
   * @returns days part of the `Interval`.
   */
  getDays(): number {
    return this.days;
  }

  /**
   * @returns nanoseconds part of the `Interval`.
   */
  getNanoseconds(): bigint {
    return this.nanoseconds;
  }

  /**
   * Constructs an `Interval` with specified months.
   */
  static fromMonths(months: number): Interval {
    return new Interval(months, 0, BigInt(0));
  }

  /**
   * Constructs an `Interval` with specified days.
   */
  static fromDays(days: number): Interval {
    return new Interval(0, days, BigInt(0));
  }

  /**
   * Constructs an `Interval` with specified seconds.
   */
  static fromSeconds(seconds: number): Interval {
    if (!isInteger(seconds)) {
      throw new GoogleError(
        `Invalid seconds: ${seconds}, seconds should be an integral value`,
      );
    }
    return new Interval(
      0,
      0,
      BigInt(Interval.NANOSECONDS_PER_SECOND) * BigInt(seconds),
    );
  }

  /**
   * Constructs an `Interval` with specified milliseconds.
   */
  static fromMilliseconds(milliseconds: number): Interval {
    if (!isInteger(milliseconds)) {
      throw new GoogleError(
        `Invalid milliseconds: ${milliseconds}, milliseconds should be an integral value`,
      );
    }
    return new Interval(
      0,
      0,
      BigInt(Interval.NANOSECONDS_PER_MILLISECOND) * BigInt(milliseconds),
    );
  }

  /**
   * Constructs an `Interval` with specified microseconds.
   */
  static fromMicroseconds(microseconds: number): Interval {
    if (!isInteger(microseconds)) {
      throw new GoogleError(
        `Invalid microseconds: ${microseconds}, microseconds should be an integral value`,
      );
    }
    return new Interval(
      0,
      0,
      BigInt(Interval.NANOSECONDS_PER_MICROSECOND) * BigInt(microseconds),
    );
  }

  /**
   * Constructs an `Interval` with specified nanoseconds.
   */
  static fromNanoseconds(nanoseconds: bigint): Interval {
    return new Interval(0, 0, nanoseconds);
  }

  /**
   * Constructs an Interval from ISO8601 duration format: `P[n]Y[n]M[n]DT[n]H[n]M[n][.fffffffff]S`.
   * Only seconds can be fractional, and can have at most 9 digits after decimal point.
   * Both '.' and ',' are considered valid decimal point.
   */
  static fromISO8601(isoString: string): Interval {
    const matcher = Interval.ISO8601_PATTERN.exec(isoString);
    if (!matcher) {
      throw new GoogleError(`Invalid ISO8601 duration string: ${isoString}`);
    }

    const getNullOrDefault = (groupIdx: number): string =>
      matcher[groupIdx] === undefined ? '0' : matcher[groupIdx];
    const years: number = parseInt(getNullOrDefault(1).replace('Y', ''));
    const months: number = parseInt(getNullOrDefault(2).replace('M', ''));
    const days: number = parseInt(getNullOrDefault(3).replace('D', ''));
    const hours: number = parseInt(getNullOrDefault(5).replace('H', ''));
    const minutes: number = parseInt(getNullOrDefault(6).replace('M', ''));
    const seconds: Big = Big(
      getNullOrDefault(7).replace('S', '').replace(',', '.'),
    );

    const totalMonths: number = Big(years)
      .mul(Big(Interval.MONTHS_PER_YEAR))
      .add(Big(months))
      .toNumber();
    if (!Number.isSafeInteger(totalMonths)) {
      throw new GoogleError(
        'Total months is outside of the range of safe integer',
      );
    }

    const totalNanoseconds = BigInt(
      seconds
        .add(
          Big((BigInt(hours) * BigInt(Interval.SECONDS_PER_HOUR)).toString()),
        )
        .add(
          Big(
            (BigInt(minutes) * BigInt(Interval.SECONDS_PER_MINUTE)).toString(),
          ),
        )
        .mul(Big(this.NANOSECONDS_PER_SECOND))
        .toString(),
    );

    return new Interval(totalMonths, days, totalNanoseconds);
  }

  /**
   * @returns string representation of Interval in ISO8601 duration format: `P[n]Y[n]M[n]DT[n]H[n]M[n][.fffffffff]S`
   */
  toISO8601(): string {
    if (this.equals(Interval.ZERO)) {
      return 'P0Y';
    }

    // months part is normalized to years and months.
    let result = 'P';
    if (this.months !== 0) {
      const years_part: number = Math.trunc(
        this.months / Interval.MONTHS_PER_YEAR,
      );
      const months_part: number =
        this.months - years_part * Interval.MONTHS_PER_YEAR;
      if (years_part !== 0) {
        result += `${years_part}Y`;
      }
      if (months_part !== 0) {
        result += `${months_part}M`;
      }
    }

    if (this.days !== 0) {
      result += `${this.days}D`;
    }

    // Nanoseconds part is normalized to hours, minutes and nanoseconds.
    if (this.nanoseconds !== BigInt(0)) {
      result += 'T';
      let nanoseconds: bigint = this.nanoseconds;
      const hours_part: bigint =
        nanoseconds /
        BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_HOUR);
      nanoseconds =
        nanoseconds -
        hours_part *
          BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_HOUR);

      const minutes_part: bigint =
        nanoseconds /
        BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_MINUTE);
      nanoseconds =
        nanoseconds -
        minutes_part *
          BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_MINUTE);
      const zero_bigint = BigInt(0);
      if (hours_part !== zero_bigint) {
        result += `${hours_part}H`;
      }

      if (minutes_part !== zero_bigint) {
        result += `${minutes_part}M`;
      }

      let sign = '';
      if (nanoseconds < zero_bigint) {
        sign = '-';
        nanoseconds = -nanoseconds;
      }

      // Nanoseconds are converted to seconds and fractional part.
      const seconds_part: bigint =
        nanoseconds / BigInt(Interval.NANOSECONDS_PER_SECOND);
      nanoseconds =
        nanoseconds - seconds_part * BigInt(Interval.NANOSECONDS_PER_SECOND);
      if (seconds_part !== zero_bigint || nanoseconds !== zero_bigint) {
        result += `${sign}${seconds_part}`;
        if (nanoseconds !== zero_bigint) {
          // Fractional part is kept in a group of 3
          // For e.g.: PT0.5S will be normalized to PT0.500S
          result += `.${nanoseconds
            .toString()
            .padStart(9, '0')
            .replace(/(0{3})+$/, '')}`;
        }
        result += 'S';
      }
    }

    return result;
  }

  equals(other: Interval): boolean {
    if (!other) {
      return false;
    }

    return (
      this.months === other.months &&
      this.days === other.days &&
      this.nanoseconds === other.nanoseconds
    );
  }

  valueOf(): Interval {
    return this;
  }

  /**
   * @returns JSON representation for Interval.
   * Interval is represented in ISO8601 duration format string in JSON.
   */
  toJSON(): string {
    return this.toISO8601().toString();
  }
}

/**
 * @typedef JSONOptions
 * @property {boolean} [wrapNumbers=false] Indicates if the numbers should be
 *     wrapped in Int/Float wrappers.
 * @property {boolean} [wrapStructs=false] Indicates if the structs should be
 *     wrapped in Struct wrapper.
 * @property {boolean} [includeNameless=false] Indicates if nameless columns
 *     should be included in the result. If true, nameless columns will be
 *     assigned the name '_{column_index}'.
 */
/**
 * Wherever a row or struct object is returned, it is assigned a "toJSON"
 * function. This function will generate the JSON for that row.
 *
 * @private
 *
 * @param {array} row The row to generate JSON for.
 * @param {JSONOptions} [options] JSON options.
 * @returns {object}
 */
function convertFieldsToJson(fields: Field[], options?: JSONOptions): Json {
  const json: Json = {};

  const defaultOptions = {
    wrapNumbers: false,
    wrapStructs: false,
    includeNameless: false,
  };

  options = Object.assign(defaultOptions, options);

  let index = 0;
  for (const {name, value} of fields) {
    if (!name && !options.includeNameless) {
      continue;
    }
    const fieldName = name ? name : `_${index}`;

    try {
      json[fieldName] = convertValueToJson(value, options);
    } catch (e) {
      (e as Error).message = [
        `Serializing column "${fieldName}" encountered an error: ${
          (e as Error).message
        }`,
        'Call row.toJSON({ wrapNumbers: true }) to receive a custom type.',
      ].join(' ');
      throw e;
    }
    index++;
  }

  return json;
}

/**
 * Attempts to convert a wrapped or nested value into a native JavaScript type.
 *
 * @private
 *
 * @param {*} value The value to convert.
 * @param {JSONOptions} options JSON options.
 * @return {*}
 */
function convertValueToJson(value: Value, options: JSONOptions): Value {
  if (!options.wrapNumbers && value instanceof WrappedNumber) {
    return value.valueOf();
  }

  if (value instanceof Struct) {
    if (!options.wrapStructs) {
      return value.toJSON(options);
    }

    return value.map(({name, value}) => {
      value = convertValueToJson(value, options);
      return {name, value};
    });
  }

  if (Array.isArray(value)) {
    return value.map(child => convertValueToJson(child, options));
  }

  if (value instanceof ProtoMessage || value instanceof ProtoEnum) {
    return value.toJSON();
  }

  return value;
}

/**
 * Re-decode after the generic gRPC decoding step.
 *
 * @private
 *
 * @param {*} value Value to decode
 * @param {object[]} type Value type object.
 * @param columnMetadata Optional parameter to deserialize data
 * @returns {*}
 */
function decode(
  value: Value,
  type: spannerClient.spanner.v1.Type,
  columnMetadata?: object,
): Value {
  if (isNull(value)) {
    return null;
  }

  let decoded = value;
  let fields;

  switch (type.code) {
    case spannerClient.spanner.v1.TypeCode.BYTES:
    case 'BYTES':
      decoded = Buffer.from(decoded, 'base64');
      break;
    case spannerClient.spanner.v1.TypeCode.PROTO:
    case 'PROTO':
      decoded = Buffer.from(decoded, 'base64');
      decoded = new ProtoMessage({
        value: decoded,
        fullName: type.protoTypeFqn,
        messageFunction: columnMetadata as Function,
      });
      break;
    case spannerClient.spanner.v1.TypeCode.ENUM:
    case 'ENUM':
      decoded = new ProtoEnum({
        value: decoded,
        fullName: type.protoTypeFqn,
        enumObject: columnMetadata as object,
      });
      break;
    case spannerClient.spanner.v1.TypeCode.FLOAT32:
    case 'FLOAT32':
      decoded = new Float32(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.FLOAT64:
    case 'FLOAT64':
      decoded = new Float(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.INT64:
    case 'INT64':
      if (
        type.typeAnnotation ===
          spannerClient.spanner.v1.TypeAnnotationCode.PG_OID ||
        type.typeAnnotation === 'PG_OID'
      ) {
        decoded = new PGOid(decoded);
        break;
      }
      decoded = new Int(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.NUMERIC:
    case 'NUMERIC':
      if (
        type.typeAnnotation ===
          spannerClient.spanner.v1.TypeAnnotationCode.PG_NUMERIC ||
        type.typeAnnotation === 'PG_NUMERIC'
      ) {
        decoded = new PGNumeric(decoded);
        break;
      }
      decoded = new Numeric(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.TIMESTAMP:
    case 'TIMESTAMP':
      decoded = new PreciseDate(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.DATE:
    case 'DATE':
      decoded = new SpannerDate(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.JSON:
    case 'JSON':
      if (
        type.typeAnnotation ===
          spannerClient.spanner.v1.TypeAnnotationCode.PG_JSONB ||
        type.typeAnnotation === 'PG_JSONB'
      ) {
        decoded = new PGJsonb(decoded);
        break;
      }
      decoded = JSON.parse(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.INTERVAL:
    case 'INTERVAL':
      decoded = Interval.fromISO8601(decoded);
      break;
    case spannerClient.spanner.v1.TypeCode.ARRAY:
    case 'ARRAY':
      decoded = decoded.map(value => {
        return decode(
          value,
          type.arrayElementType! as spannerClient.spanner.v1.Type,
          columnMetadata,
        );
      });
      break;
    case spannerClient.spanner.v1.TypeCode.STRUCT:
    case 'STRUCT':
      fields = type.structType!.fields!.map(({name, type}, index) => {
        const value = decode(
          (!Array.isArray(decoded) && decoded[name!]) || decoded[index],
          type as spannerClient.spanner.v1.Type,
          columnMetadata,
        );
        return {name, value};
      });
      decoded = Struct.fromArray(fields as Field[]);
      break;
    default:
      break;
  }

  return decoded;
}

/**
 * Encode a value in the format the API expects.
 *
 * @private
 *
 * @param {*} value The value to be encoded.
 * @returns {object} google.protobuf.Value
 */
function encode(value: Value): p.IValue {
  return GrpcService.encodeValue_(encodeValue(value));
}

/**
 * Formats values into expected format of google.protobuf.Value. The actual
 * conversion to a google.protobuf.Value object happens via
 * `Service.encodeValue_`
 *
 * @private
 *
 * @param {*} value The value to be encoded.
 * @returns {*}
 */
function encodeValue(value: Value): Value {
  if (isNumber(value) && !isDecimal(value)) {
    return value.toString();
  }

  if (isDate(value)) {
    return value.toJSON();
  }

  if (value instanceof WrappedNumber) {
    return value.value;
  }

  if (value instanceof Numeric) {
    return value.value;
  }

  if (value instanceof PGNumeric) {
    return value.value;
  }

  if (Buffer.isBuffer(value)) {
    return value.toString('base64');
  }

  if (value instanceof ProtoMessage) {
    return value.value.toString('base64');
  }

  if (value instanceof ProtoEnum) {
    return value.value;
  }

  if (value instanceof Struct) {
    return Array.from(value).map(field => encodeValue(field.value));
  }

  if (isArray(value)) {
    return value.map(encodeValue);
  }

  if (value instanceof PGJsonb) {
    return value.toString();
  }

  if (value instanceof Interval) {
    return value.toISO8601();
  }

  if (isObject(value)) {
    return JSON.stringify(value);
  }

  return value;
}

/**
 * Just a map with friendlier names for the types.
 *
 * @private
 * @enum {string}
 */
const TypeCode: {
  [name: string]: keyof typeof spannerClient.spanner.v1.TypeCode;
} = {
  unspecified: 'TYPE_CODE_UNSPECIFIED',
  bool: 'BOOL',
  int64: 'INT64',
  pgOid: 'INT64',
  uuid: 'UUID',
  float32: 'FLOAT32',
  float64: 'FLOAT64',
  numeric: 'NUMERIC',
  pgNumeric: 'NUMERIC',
  timestamp: 'TIMESTAMP',
  date: 'DATE',
  string: 'STRING',
  bytes: 'BYTES',
  json: 'JSON',
  jsonb: 'JSON',
  interval: 'INTERVAL',
  proto: 'PROTO',
  enum: 'ENUM',
  array: 'ARRAY',
  struct: 'STRUCT',
};

/**
 * Conveniece Type object that simplifies specifying the data type, the array
 * child type and/or struct fields.
 *
 * @private
 */
export interface Type {
  type: string;
  fields?: FieldType[];
  child?: Type;
  fullName?: string;
}

interface FieldType extends Type {
  name: string;
}

/**
 * @typedef {ParamType} StructField
 * @property {string} name The name of the field.
 */
/**
 * @typedef {object} ParamType
 * @property {string} type The param type. Must be one of the following:
 *     - uuid
 *     - float32
 *     - float64
 *     - int64
 *     - numeric
 *     - bool
 *     - string
 *     - bytes
 *     - json
 *     - interval
 *     - proto
 *     - enum
 *     - timestamp
 *     - date
 *     - struct
 *     - array
 * @property {StructField[]} [fields] **For struct types only**. Type
 *     definitions for the individual fields.
 * @property {string|ParamType} [child] **For array types only**. The array
 *     element type.
 */
/**
 * Get the corresponding Spanner data type for the provided value.
 *
 * @private
 *
 * @param {*} value - The value.
 * @returns {object}
 *
 * @example
 * ```
 * codec.getType(NaN);
 * // {type: 'float64'}
 * ```
 */
function getType(value: Value): Type {
  const isSpecialNumber =
    isInfinite(value) || (isNumber(value) && isNaN(value));

  if (value instanceof Float32) {
    return {type: 'float32'};
  }

  if (isDecimal(value) || isSpecialNumber || value instanceof Float) {
    return {type: 'float64'};
  }

  if (isNumber(value) || value instanceof Int) {
    return {type: 'int64'};
  }

  if (value instanceof Numeric) {
    return {type: 'numeric'};
  }

  if (value instanceof PGNumeric) {
    return {type: 'pgNumeric'};
  }

  if (value instanceof PGJsonb) {
    return {type: 'pgJsonb'};
  }

  if (value instanceof PGOid) {
    return {type: 'pgOid'};
  }

  if (value instanceof Interval) {
    return {type: 'interval'};
  }

  if (value instanceof ProtoMessage) {
    return {type: 'proto', fullName: value.fullName};
  }

  if (value instanceof ProtoEnum) {
    return {type: 'enum', fullName: value.fullName};
  }

  if (isBoolean(value)) {
    return {type: 'bool'};
  }

  if (uuid.validate(value)) {
    return {type: 'unspecified'};
  }

  if (isString(value)) {
    return {type: 'string'};
  }

  if (Buffer.isBuffer(value)) {
    return {type: 'bytes'};
  }

  if (value instanceof SpannerDate) {
    return {type: 'date'};
  }

  if (isDate(value)) {
    return {type: 'timestamp'};
  }

  if (value instanceof Struct) {
    return {
      type: 'struct',
      fields: Array.from(value).map(({name, value}) => {
        return Object.assign({name}, getType(value));
      }),
    };
  }

  if (isArray(value)) {
    let child;

    for (let i = 0; i < value.length; i++) {
      child = value[i];

      if (!isNull(child)) {
        break;
      }
    }

    return {
      type: 'array',
      child: getType(child),
    };
  }

  if (isObject(value)) {
    return {type: 'json'};
  }

  return {type: 'unspecified'};
}

/**
 * Converts a value to google.protobuf.ListValue
 *
 * @private
 *
 * @param {*} value The value to convert.
 * @returns {object}
 */
function convertToListValue<T>(value: T): p.IListValue {
  const values = (toArray(value) as T[]).map(codec.encode);
  return {values};
}

/**
 * Converts milliseconds to google.protobuf.Timestamp
 *
 * @private
 *
 * @param {number} ms The milliseconds to convert.
 * @returns {object}
 */
function convertMsToProtoTimestamp(
  ms: number,
): spannerClient.protobuf.ITimestamp {
  const rawSeconds = ms / 1000;
  const seconds = Math.floor(rawSeconds);
  const nanos = Math.round((rawSeconds - seconds) * 1e9);
  return {seconds, nanos};
}

/**
 * Converts google.protobuf.Timestamp to Date object.
 *
 * @private
 *
 * @param {object} timestamp The protobuf timestamp.
 * @returns {Date}
 */
function convertProtoTimestampToDate({
  nanos = 0,
  seconds = 0,
}: p.ITimestamp): Date {
  const ms = Math.floor(nanos) / 1e6;
  const s = Math.floor(seconds as number);
  return new Date(s * 1000 + ms);
}

/**
 * Encodes paramTypes into correct structure.
 *
 * @private
 *
 * @param {object|string} [config='unspecified'] Type config.
 * @return {object}
 */
function createTypeObject(
  friendlyType?: string | Type,
): spannerClient.spanner.v1.Type {
  if (!friendlyType) {
    friendlyType = 'unspecified';
  }

  if (typeof friendlyType === 'string') {
    friendlyType = {type: friendlyType} as Type;
  }

  const config: Type = friendlyType as Type;
  const code: keyof typeof spannerClient.spanner.v1.TypeCode =
    TypeCode[config.type] || TypeCode.unspecified;
  const type: spannerClient.spanner.v1.Type = {
    code,
  } as spannerClient.spanner.v1.Type;

  if (code === 'PROTO' || code === 'ENUM') {
    type.protoTypeFqn = config.fullName || '';
  }

  if (code === 'ARRAY') {
    type.arrayElementType = codec.createTypeObject(config.child);
  }

  if (code === 'STRUCT') {
    type.structType = {
      fields: toArray(config.fields!).map(field => {
        return {name: field.name, type: codec.createTypeObject(field)};
      }),
    };
  }

  if (friendlyType.type === 'pgNumeric') {
    type.typeAnnotation =
      spannerClient.spanner.v1.TypeAnnotationCode.PG_NUMERIC;
  } else if (friendlyType.type === 'jsonb') {
    type.typeAnnotation = spannerClient.spanner.v1.TypeAnnotationCode.PG_JSONB;
  } else if (friendlyType.type === 'pgOid') {
    type.typeAnnotation = spannerClient.spanner.v1.TypeAnnotationCode.PG_OID;
  }
  return type;
}

export const codec = {
  convertToListValue,
  convertMsToProtoTimestamp,
  convertProtoTimestampToDate,
  createTypeObject,
  SpannerDate,
  Float32,
  Float,
  Int,
  Numeric,
  PGNumeric,
  PGJsonb,
  ProtoMessage,
  ProtoEnum,
  PGOid,
  Interval,
  convertFieldsToJson,
  decode,
  encode,
  getType,
  Struct,
};
