/*
 This file is part of GNU Taler
 (C) 2019-2020 Taler Systems SA

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

/**
 * Classes and helpers for error handling specific to wallet operations.
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Imports.
 */
import {
  AbsoluteTime,
  CancellationToken,
  PaymentInsufficientBalanceDetails,
  TalerErrorCode,
  TalerErrorDetail,
  TransactionState,
  TransactionType,
} from "@gnu-taler/taler-util";

type empty = Record<string, never>;

export interface DetailsMap {
  [TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING]: {
    exchangeResponse: any;
  };
  [TalerErrorCode.WALLET_PENDING_OPERATION_FAILED]: {
    innerError: TalerErrorDetail;
    transactionId?: string;
  };
  [TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT]: {
    exchangeBaseUrl: string;
  };
  [TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE]: {
    exchangeProtocolVersion: string;
    walletProtocolVersion: string;
  };
  [TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK]: empty;
  [TalerErrorCode.WALLET_REWARD_COIN_SIGNATURE_INVALID]: empty;
  [TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED]: {
    orderId: string;
    claimUrl: string;
  };
  [TalerErrorCode.WALLET_ORDER_ALREADY_PAID]: {
    orderId: string;
    fulfillmentUrl: string | undefined;
  };
  [TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED]: empty;
  [TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID]: {
    merchantPub: string;
    orderId: string;
  };
  [TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH]: {
    baseUrlForDownload: string;
    baseUrlFromContractTerms: string;
  };
  [TalerErrorCode.WALLET_INVALID_TALER_PAY_URI]: {
    talerPayUri: string;
  };
  [TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR]: {
    requestUrl: string;
    requestMethod: string;
    httpStatusCode: number;
    errorResponse?: any;
  };
  [TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION]: {
    stack?: string;
  };
  [TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE]: {
    bankProtocolVersion: string;
    walletProtocolVersion: string;
  };
  [TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN]: {
    operation: string;
  };
  [TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED]: {
    requestUrl: string;
    requestMethod: string;
    throttleStats: Record<string, unknown>;
  };
  [TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT]: {
    requestUrl: string;
    requestMethod: string;
    timeoutMs: number;
  };
  [TalerErrorCode.GENERIC_TIMEOUT]: {
    requestUrl: string;
    requestMethod: string;
    timeoutMs: number;
  };
  [TalerErrorCode.WALLET_NETWORK_ERROR]: {
    requestUrl: string;
    requestMethod: string;
  };
  [TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE]: {
    requestUrl: string;
    requestMethod: string;
    httpStatusCode: number;
    /**
     * Original response which is malformed
     */
    response?: string;
    validationError?: string;
    /**
     * Content type of the response, usually only specified if not the
     * expected content type.
     */
    contentType?: string;
  };
  [TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR]: {
    operation: string;
    error: string;
    detail: TalerErrorDetail | undefined;
  };
  [TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID]: empty;
  [TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE]: {
    numErrors: number;
    errorsPerCoin: Record<number, TalerErrorDetail>;
  };
  [TalerErrorCode.WALLET_CORE_NOT_AVAILABLE]: {
    lastError?: TalerErrorDetail;
  };
  [TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR]: {
    httpStatusCode: number;
  };
  [TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR]: {
    requestError: TalerErrorDetail;
  };
  [TalerErrorCode.WALLET_CRYPTO_WORKER_ERROR]: {
    innerError: TalerErrorDetail;
  };
  [TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST]: {
    detail: string;
  };
  [TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED]: {
    kycUrl: string;
  };
  [TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE]: {
    insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
  };
  [TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE]: {
    insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
  };
  [TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE]: {
    numErrors: number;
    /**
     * Errors, can be truncated.
     */
    errors: TalerErrorDetail[];
  };
  [TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH]: {
    urlWallet: string;
    urlExchange: string;
  };
  [TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE]: {
    exchangeBaseUrl: string;
    innerError: TalerErrorDetail | undefined;
  };
  [TalerErrorCode.WALLET_DB_UNAVAILABLE]: {
    innerError: TalerErrorDetail | undefined;
  };
  [TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED]: {
    exchangeBaseUrl: string;
    tosStatus: string;
    currentEtag: string | undefined;
  };
  [TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT]: {
    detail?: string;
  };
  [TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED]: {
    message?: string;
    txState: TransactionState;
    debugStateNum?: number;
  };
}

type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : empty;

export type TypedTalerErrorDetail<Y> = TalerErrorDetail &
  (Y extends keyof DetailsMap ? DetailsMap[Y] : {});

export function makeErrorDetail<C extends TalerErrorCode>(
  code: C,
  detail: ErrBody<C>,
  hint?: string,
): TalerErrorDetail {
  if (!hint && !(detail as any).hint) {
    hint = getDefaultTalerErrorHint(code);
  }
  const when = AbsoluteTime.now();
  return { code, when, hint, ...detail };
}

export function makePendingOperationFailedError(
  innerError: TalerErrorDetail,
  tag: TransactionType,
  uid: string,
): TalerError {
  return TalerError.fromDetail(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED, {
    innerError,
    transactionId: `${tag}:${uid}`,
  });
}

export function summarizeTalerErrorDetail(ed: TalerErrorDetail): string {
  const errName = TalerErrorCode[ed.code] ?? "<unknown>";
  return `Error (${ed.code}/${errName})`;
}

export function getDefaultTalerErrorHint(code: number): string {
  const errName = TalerErrorCode[code];
  if (errName) {
    return `Error (${errName})`;
  } else {
    return `Error (<unknown>)`;
  }
}

export class TalerProtocolViolationError extends Error {
  constructor(hint?: string) {
    let msg: string;
    if (hint) {
      msg = `Taler protocol violation error (${hint})`;
    } else {
      msg = `Taler protocol violation error`;
    }
    super(msg);
    Object.setPrototypeOf(this, TalerProtocolViolationError.prototype);
  }
}

// compute a subset of TalerError, just for http request
type HttpErrors =
  | TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT
  | TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED
  | TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE
  | TalerErrorCode.WALLET_NETWORK_ERROR
  | TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR;

type TalerHttpErrorsDetails = {
  [code in HttpErrors]: TalerError<DetailsMap[code]>;
};

export type TalerHttpError =
  TalerHttpErrorsDetails[keyof TalerHttpErrorsDetails];

/**
 * Construct typed error details.
 * Fills in the hint with a default based on the error code name.
 */
export function makeTalerErrorDetail<C extends TalerErrorCode>(
  code: C,
  errBody: ErrBody<C>,
  hint?: string,
): TalerErrorDetail {
  if (!hint) {
    hint = getDefaultTalerErrorHint(code);
  }
  return { code, hint, ...errBody };
}

export class TalerError<T = any> extends Error {
  errorDetail: TalerErrorDetail & T;
  cause: Error | undefined;
  private constructor(d: TalerErrorDetail & T, cause?: Error) {
    super(d.hint ?? `Error (code ${d.code})`);
    this.errorDetail = d;
    this.cause = cause;
    Object.setPrototypeOf(this, TalerError.prototype);
  }

  static fromDetail<C extends TalerErrorCode>(
    code: C,
    detail: ErrBody<C>,
    hint?: string,
    cause?: Error,
  ): TalerError {
    if (!hint) {
      hint = getDefaultTalerErrorHint(code);
    }
    const when = AbsoluteTime.now();
    return new TalerError<unknown>({ code, when, hint, ...detail }, cause);
  }

  static fromUncheckedDetail(d: TalerErrorDetail, c?: Error): TalerError {
    return new TalerError<unknown>({ ...d }, c);
  }

  static fromException(e: any): TalerError {
    const errDetail = getErrorDetailFromException(e);
    return new TalerError(errDetail, e);
  }

  hasErrorCode<C extends keyof DetailsMap>(
    code: C,
  ): this is TalerError<DetailsMap[C]> {
    return this.errorDetail.code === code;
  }

  toString(): string {
    return `TalerError: ${JSON.stringify(this.errorDetail)}`;
  }
}

export function safeStringifyException(e: any): string {
  return JSON.stringify(getErrorDetailFromException(e), undefined, 2);
}

/**
 * Convert an exception (or anything that was thrown) into
 * a TalerErrorDetail object.
 */
export function getErrorDetailFromException(e: any): TalerErrorDetail {
  if (e instanceof TalerError) {
    return e.errorDetail;
  }
  if (e instanceof CancellationToken.CancellationError) {
    const err = makeErrorDetail(
      TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
      {},
    );
    return err;
  }
  if (e instanceof Error) {
    const err = makeErrorDetail(
      TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
      {
        stack: e.stack,
      },
      `unexpected exception (message: ${e.message})`,
    );
    return err;
  }
  // Something was thrown that is not even an exception!
  // Try to stringify it.
  let excString: string;
  try {
    excString = e.toString();
  } catch (e) {
    // Something went horribly wrong.
    excString = "can't stringify exception";
  }
  const err = makeErrorDetail(
    TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
    {},
    `unexpected exception (not an exception, ${excString})`,
  );
  return err;
}

/**
 * This function should not be called at runtime.
 * Useful on switch/case to detect that all path has been contemplated.
 * 
 * @param x 
 */
export function assertUnreachable(x: never): never {
  throw new Error("Didn't expect to get here");
}
