/*
 This file is part of GNU Taler
 (C) 2018-2019 GNUnet e.V.

 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/>
 */

import { j2s } from "./helpers.js";
import { Logger } from "./logging.js";

/**
 * Type-safe codecs for converting from/to JSON.
 */

/* eslint-disable @typescript-eslint/ban-types */

const logger = new Logger("codec.ts");

/**
 * Error thrown when decoding fails.
 */
export class DecodingError extends Error {
  constructor(message: string) {
    super(message);
    Object.setPrototypeOf(this, DecodingError.prototype);
    this.name = "DecodingError";
  }
}

/**
 * Context information to show nicer error messages when decoding fails.
 */
export interface Context {
  readonly path?: string[];
}

export function renderContext(c?: Context): string {
  const p = c?.path;
  if (p) {
    return p.join(".");
  } else {
    return "(unknown)";
  }
}

function joinContext(c: Context | undefined, part: string): Context {
  const path = c?.path ?? [];
  return {
    path: path.concat([part]),
  };
}

/**
 * A codec converts untyped JSON to a typed object.
 */
export interface Codec<V> {
  /**
   * Decode untyped JSON to an object of type [[V]].
   */
  readonly decode: (x: any, c?: Context) => V;
}

/**
 * A codec built from an ObjectCodecBuilder object.
 */
export interface ObjectCodec<V> extends Codec<V> {
  /**
   * Get properties from builder. This method is only supported for codecs
   * built from ObjectCodecBuilder.
   */
  readonly getProps: () => Prop[];
}

type SingletonRecord<K extends keyof any, V> = { [Y in K]: V };

interface Prop {
  name: string;
  codec: Codec<any>;
  deprecated?: boolean;
}

interface Alternative {
  tagValue: any;
  codec: Codec<any>;
}

class ObjectCodecBuilder<OutputType, PartialOutputType> {
  private propList: Prop[] = [];
  private deprecatedProps: Set<string> = new Set();
  private _allowExtra: boolean = false;

  /**
   * Define a property for the object.
   */
  property<K extends keyof OutputType & string>(
    x: K,
    codec: Codec<OutputType[K]>,
  ): ObjectCodecBuilder<
    OutputType,
    PartialOutputType & SingletonRecord<K, OutputType[K]>
  > {
    if (!codec) {
      throw Error("inner codec must be defined");
    }
    this.propList.push({ name: x, codec: codec });
    return this as any;
  }

  /**
   * Define a property for the object.
   */
  propertyStrict<K extends keyof OutputType & string>(
    x: K,
    codec: Codec<OutputType[K]>,
  ): ObjectCodecBuilder<
    OutputType,
    PartialOutputType & SingletonRecord<K, OutputType[K]>
  > {
    if (!codec) {
      throw Error("inner codec must be defined");
    }
    this.propList.push({ name: x, codec: codec });
    return this as any;
  }

  /**
   * Concatenate properties from @a codec.
   *
   * @param other codec to concat properties from
   *
   * FIXME: do proper union of all `other' props.
   */
  mixin<K extends keyof OutputType & string, V extends OutputType[K]>(
    other: ObjectCodec<V>,
  ): ObjectCodecBuilder<OutputType, PartialOutputType & V> {
    this.propList.push(...other.getProps());
    return this as any;
  }

  /**
   * Define a deprecated property for the object.
   *
   * Deprecated properties won't be validated, their presence will
   * be validated in TRACE mode.
   */
  deprecatedProperty(
    x: string,
  ): ObjectCodecBuilder<OutputType, PartialOutputType> {
    this.deprecatedProps.add(x);
    return this as any;
  }

  /**
   * Do not log warnings if the object has extra properties.
   */
  allowExtra(): ObjectCodecBuilder<OutputType, PartialOutputType> {
    this._allowExtra = true;
    return this;
  }

  /**
   * Return the built codec.
   *
   * @param objectDisplayName name of the object that this codec operates on,
   *   used in error messages.
   */
  build(objectDisplayName: string): ObjectCodec<PartialOutputType> {
    const propList = this.propList;
    const allowExtra = this._allowExtra;
    const deprecatedPros = this.deprecatedProps;
    return {
      decode(x: any, c?: Context): PartialOutputType {
        if (!c) {
          c = {
            path: [`(${objectDisplayName})`],
          };
        }
        if (typeof x !== "object") {
          throw new DecodingError(
            `expected object for ${objectDisplayName} at ${renderContext(
              c,
            )} but got ${typeof x}`,
          );
        }
        const obj: any = {};
        for (const prop of propList) {
          const propRawVal = x[prop.name];
          const propVal = prop.codec.decode(
            propRawVal,
            joinContext(c, prop.name),
          );
          obj[prop.name] = propVal;
        }
        for (const prop in x) {
          if (prop in obj) {
            continue;
          }
          if (allowExtra) {
            obj[prop] = x[prop];
          } else if (deprecatedPros.has(prop)) {
            logger.trace(
              `Deprecated property ${prop} for ${objectDisplayName} at ${renderContext(
                c,
              )}`,
            );
          } else {
            logger.warn(
              `Extra property ${prop} for ${objectDisplayName} at ${renderContext(
                c,
              )}`,
            );
          }
        }
        return obj as PartialOutputType;
      },

      getProps(): Prop[] {
        return propList;
      },
    };
  }
}

class UnionCodecBuilder<
  TargetType,
  TagPropertyLabel extends keyof TargetType,
  CommonBaseType,
  PartialTargetType,
> {
  private alternatives = new Map<any, Alternative>();

  constructor(
    private discriminator: TagPropertyLabel,
    private baseCodec?: Codec<CommonBaseType>,
  ) {}

  /**
   * Define a property for the object.
   */
  alternative<V>(
    tagValue: TargetType[TagPropertyLabel],
    codec: Codec<V>,
  ): UnionCodecBuilder<
    TargetType,
    TagPropertyLabel,
    CommonBaseType,
    PartialTargetType | V
  > {
    if (!codec) {
      throw Error("inner codec must be defined");
    }
    this.alternatives.set(tagValue, { codec, tagValue });
    return this as any;
  }

  /**
   * Return the built codec.
   *
   * @param objectDisplayName name of the object that this codec operates on,
   *   used in error messages.
   */
  build<R extends PartialTargetType & CommonBaseType = never>(
    objectDisplayName: string,
  ): Codec<R> {
    const alternatives = this.alternatives;
    const discriminator = this.discriminator;
    const baseCodec = this.baseCodec;
    return {
      decode(x: any, c?: Context): R {
        if (!c) {
          c = {
            path: [`(${objectDisplayName})`],
          };
        }
        const d = x[discriminator];
        if (d === undefined && !alternatives.has(d)) {
          throw new DecodingError(
            `expected tag for ${objectDisplayName} at ${renderContext(
              c,
            )}.${String(discriminator)}`,
          );
        }
        const alt = alternatives.get(d);
        if (!alt) {
          throw new DecodingError(
            `unknown tag for ${objectDisplayName} ${d} at ${renderContext(
              c,
            )}.${String(discriminator)}`,
          );
        }
        const altDecoded = alt.codec.decode(x);
        if (baseCodec) {
          const baseDecoded = baseCodec.decode(x, c);
          return { ...baseDecoded, ...altDecoded };
        } else {
          return altDecoded;
        }
      },
    };
  }
}

export class UnionCodecPreBuilder<T> {
  discriminateOn<D extends keyof T, B = {}>(
    discriminator: D,
    baseCodec?: Codec<B>,
  ): UnionCodecBuilder<T, D, B, never> {
    return new UnionCodecBuilder<T, D, B, never>(discriminator, baseCodec);
  }
}

/**
 * Return a builder for a codec that decodes an object with properties.
 */
export function buildCodecForObject<T>(): ObjectCodecBuilder<T, {}> {
  return new ObjectCodecBuilder<T, {}>();
}

export function buildCodecForUnion<T>(): UnionCodecPreBuilder<T> {
  return new UnionCodecPreBuilder<T>();
}

/**
 * Return a codec for a mapping from a string to values described by the inner codec.
 */
export function codecForMap<T>(
  innerCodec: Codec<T>,
): Codec<{ [x: string]: T }> {
  if (!innerCodec) {
    throw Error("inner codec must be defined");
  }
  return {
    decode(x: any, c?: Context): { [x: string]: T } {
      const map: { [x: string]: T } = {};
      if (typeof x !== "object") {
        throw new DecodingError(`expected object at ${renderContext(c)}`);
      }
      for (const i in x) {
        map[i] = innerCodec.decode(x[i], joinContext(c, `[${i}]`));
      }
      return map;
    },
  };
}

/**
 * Return a codec for a list, containing values described by the inner codec.
 */
export function codecForList<T>(innerCodec: Codec<T>): Codec<T[]> {
  if (!innerCodec) {
    throw Error("inner codec must be defined");
  }
  return {
    decode(x: any, c?: Context): T[] {
      const arr: T[] = [];
      if (!Array.isArray(x)) {
        throw new DecodingError(`expected array at ${renderContext(c)}`);
      }
      for (const i in x) {
        arr.push(innerCodec.decode(x[i], joinContext(c, `[${i}]`)));
      }
      return arr;
    },
  };
}

/**
 * Return a codec for a value that must be a number.
 */
export function codecForNumber(): Codec<number> {
  return {
    decode(x: any, c?: Context): number {
      if (typeof x === "number") {
        return x;
      }
      throw new DecodingError(
        `expected number at ${renderContext(c)} but got ${typeof x}`,
      );
    },
  };
}

/**
 * Return a codec for a value that must be a number.
 */
export function codecForBoolean(): Codec<boolean> {
  return {
    decode(x: any, c?: Context): boolean {
      if (typeof x === "boolean") {
        return x;
      }
      throw new DecodingError(
        `expected boolean at ${renderContext(c)} but got ${typeof x}`,
      );
    },
  };
}

/**
 * Return a codec for a value that must be a string.
 */
export function codecForString(): Codec<string> {
  return {
    decode(x: any, c?: Context): string {
      if (typeof x === "string") {
        return x;
      }
      throw new DecodingError(
        `expected string at ${renderContext(c)} but got ${typeof x}`,
      );
    },
  };
}

/**
 * Return a codec for a value that must be a string.
 */
export function codecForStringURL(shouldEndWithSlash?: boolean): Codec<string> {
  return {
    decode(x: any, c?: Context): string {
      if (typeof x !== "string") {
        throw new DecodingError(
          `expected string at ${renderContext(c)} but got ${typeof x}`,
        );
      }
      if (shouldEndWithSlash && !x.endsWith("/")) {
        throw new DecodingError(
          `expected URL string that ends with slash at ${renderContext(
            c,
          )} but got ${x}`,
        );
      }
      try {
        const url = new URL(x);
        return x;
      } catch (e) {
        if (e instanceof Error) {
          throw new DecodingError(e.message);
        } else {
          throw new DecodingError(
            `expected an URL string at ${renderContext(c)} but got "${x}"`,
          );
        }
      }
    },
  };
}

/**
 * Return a codec for a value that must be a string.
 */
export function codecForURL(shouldEndWithSlash?: boolean): Codec<URL> {
  return {
    decode(x: any, c?: Context): URL {
      if (typeof x !== "string") {
        throw new DecodingError(
          `expected string at ${renderContext(c)} but got ${typeof x}`,
        );
      }
      if (shouldEndWithSlash && !x.endsWith("/")) {
        throw new DecodingError(
          `expected URL string that ends with slash at ${renderContext(
            c,
          )} but got ${x}`,
        );
      }
      try {
        const url = new URL(x);
        return url;
      } catch (e) {
        if (e instanceof Error) {
          throw new DecodingError(e.message);
        } else {
          throw new DecodingError(
            `expected an URL string at ${renderContext(c)} but got "${x}"`,
          );
        }
      }
    },
  };
}

/**
 * Codec that allows any value.
 */
export function codecForAny(): Codec<any> {
  return {
    decode(x: any, c?: Context): any {
      return x;
    },
  };
}

/**
 * Return a codec for a value that must be a string.
 */
export function codecForConstString<V extends string>(s: V): Codec<V> {
  return {
    decode(x: any, c?: Context): V {
      if (x === s) {
        return x;
      }
      if (typeof x !== "string") {
        throw new DecodingError(
          `expected string constant "${s}" at ${renderContext(
            c,
          )} but got ${typeof x}`,
        );
      }
      throw new DecodingError(
        `expected string constant "${s}" at ${renderContext(
          c,
        )} but got string value "${x}"`,
      );
    },
  };
}

/**
 * Return a codec for a boolean true constant.
 */
export function codecForConstTrue(): Codec<true> {
  return {
    decode(x: any, c?: Context): true {
      if (x === true) {
        return x;
      }
      throw new DecodingError(
        `expected boolean true at ${renderContext(c)} but got ${typeof x}`,
      );
    },
  };
}

/**
 * Return a codec for a boolean true constant.
 */
export function codecForConstFalse(): Codec<false> {
  return {
    decode(x: any, c?: Context): false {
      if (x === false) {
        return x;
      }
      throw new DecodingError(
        `expected boolean false at ${renderContext(c)} but got ${typeof x}`,
      );
    },
  };
}

/**
 * Return a codec for a value that must be a constant number.
 */
export function codecForConstNumber<V extends number>(n: V): Codec<V> {
  return {
    decode(x: any, c?: Context): V {
      if (x === n) {
        return x;
      }
      throw new DecodingError(
        `expected number constant "${n}" at ${renderContext(
          c,
        )}  but got ${typeof x}`,
      );
    },
  };
}

export function codecOptional<V>(innerCodec: Codec<V>): Codec<V | undefined> {
  return {
    decode(x: any, c?: Context): V | undefined {
      if (x === undefined || x === null) {
        return undefined;
      }
      return innerCodec.decode(x, c);
    },
  };
}

export function codecOptionalDefault<V>(
  innerCodec: Codec<V>,
  def: V,
): Codec<V> {
  return {
    decode(x: any, c?: Context): V {
      if (x === undefined || x === null) {
        return def;
      }
      return innerCodec.decode(x, c);
    },
  };
}

export function codecForLazy<V>(innerCodec: () => Codec<V>): Codec<V> {
  let instance: Codec<V> | undefined = undefined;
  return {
    decode(x: any, c?: Context): V {
      if (instance === undefined) {
        instance = innerCodec();
      }
      return instance.decode(x, c);
    },
  };
}

export type CodecType<T> = T extends Codec<infer X> ? X : any;

export function codecForEither<T extends Array<Codec<unknown>>>(
  ...alts: [...T]
): Codec<CodecType<T[number]>> {
  return {
    decode(x: any, c?: Context): any {
      for (const alt of alts) {
        try {
          return alt.decode(x, c);
        } catch (e) {
          continue;
        }
      }
      if (logger.shouldLogTrace()) {
        logger.trace(`offending value: ${j2s(x)}`);
      }
      throw new DecodingError(
        `No alternative matched at ${renderContext(c)}`,
      );
    },
  };
}
