/*
 This file is part of GNU Taler
 (C) 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 
 */
import { InternalWalletState } from "./state";
import {
  Denomination,
  codecForExchangeKeysJson,
  codecForExchangeWireJson,
} from "../types/talerTypes";
import { OperationErrorDetails } from "../types/walletTypes";
import {
  ExchangeRecord,
  ExchangeUpdateStatus,
  Stores,
  DenominationRecord,
  DenominationStatus,
  WireFee,
  ExchangeUpdateReason,
  ExchangeUpdatedEventRecord,
} from "../types/dbTypes";
import { canonicalizeBaseUrl } from "../util/helpers";
import * as Amounts from "../util/amounts";
import { parsePaytoUri } from "../util/payto";
import {
  OperationFailedAndReportedError,
  guardOperationException,
  makeErrorDetails,
} from "./errors";
import {
  WALLET_CACHE_BREAKER_CLIENT_VERSION,
  WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "./versions";
import { getTimestampNow } from "../util/time";
import { compare } from "../util/libtoolVersion";
import { createRecoupGroup, processRecoupGroup } from "./recoup";
import { TalerErrorCode } from "../TalerErrorCode";
import {
  readSuccessResponseJsonOrThrow,
  readSuccessResponseTextOrThrow,
} from "../util/http";
import { Logger } from "../util/logging";
const logger = new Logger("exchanges.ts");
async function denominationRecordFromKeys(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
  denomIn: Denomination,
): Promise {
  const denomPubHash = await ws.cryptoApi.hashEncoded(denomIn.denom_pub);
  const d: DenominationRecord = {
    denomPub: denomIn.denom_pub,
    denomPubHash,
    exchangeBaseUrl,
    feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit),
    feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh),
    feeRefund: Amounts.parseOrThrow(denomIn.fee_refund),
    feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw),
    isOffered: true,
    isRevoked: false,
    masterSig: denomIn.master_sig,
    stampExpireDeposit: denomIn.stamp_expire_deposit,
    stampExpireLegal: denomIn.stamp_expire_legal,
    stampExpireWithdraw: denomIn.stamp_expire_withdraw,
    stampStart: denomIn.stamp_start,
    status: DenominationStatus.Unverified,
    value: Amounts.parseOrThrow(denomIn.value),
  };
  return d;
}
async function setExchangeError(
  ws: InternalWalletState,
  baseUrl: string,
  err: OperationErrorDetails,
): Promise {
  console.log(`last error for exchange ${baseUrl}:`, err);
  const mut = (exchange: ExchangeRecord): ExchangeRecord => {
    exchange.lastError = err;
    return exchange;
  };
  await ws.db.mutate(Stores.exchanges, baseUrl, mut);
}
/**
 * Fetch the exchange's /keys and update our database accordingly.
 *
 * Exceptions thrown in this method must be caught and reported
 * in the pending operations.
 */
async function updateExchangeWithKeys(
  ws: InternalWalletState,
  baseUrl: string,
): Promise {
  const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl);
  if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) {
    return;
  }
  const keysUrl = new URL("keys", baseUrl);
  keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
  const resp = await ws.http.get(keysUrl.href);
  const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
    resp,
    codecForExchangeKeysJson(),
  );
  if (exchangeKeysJson.denoms.length === 0) {
    const opErr = makeErrorDetails(
      TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
      "exchange doesn't offer any denominations",
      {
        exchangeBaseUrl: baseUrl,
      },
    );
    await setExchangeError(ws, baseUrl, opErr);
    throw new OperationFailedAndReportedError(opErr);
  }
  const protocolVersion = exchangeKeysJson.version;
  const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
  if (versionRes?.compatible != true) {
    const opErr = makeErrorDetails(
      TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
      "exchange protocol version not compatible with wallet",
      {
        exchangeProtocolVersion: protocolVersion,
        walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
      },
    );
    await setExchangeError(ws, baseUrl, opErr);
    throw new OperationFailedAndReportedError(opErr);
  }
  const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
    .currency;
  const newDenominations = await Promise.all(
    exchangeKeysJson.denoms.map((d) =>
      denominationRecordFromKeys(ws, baseUrl, d),
    ),
  );
  const lastUpdateTimestamp = getTimestampNow();
  const recoupGroupId: string | undefined = undefined;
  await ws.db.runWithWriteTransaction(
    [Stores.exchanges, Stores.denominations, Stores.recoupGroups, Stores.coins],
    async (tx) => {
      const r = await tx.get(Stores.exchanges, baseUrl);
      if (!r) {
        console.warn(`exchange ${baseUrl} no longer present`);
        return;
      }
      if (r.details) {
        // FIXME: We need to do some consistency checks!
      }
      // FIXME: validate signing keys and merge with old set
      r.details = {
        auditors: exchangeKeysJson.auditors,
        currency: currency,
        lastUpdateTime: lastUpdateTimestamp,
        masterPublicKey: exchangeKeysJson.master_public_key,
        protocolVersion: protocolVersion,
        signingKeys: exchangeKeysJson.signkeys,
      };
      r.updateStatus = ExchangeUpdateStatus.FetchWire;
      r.lastError = undefined;
      await tx.put(Stores.exchanges, r);
      for (const newDenom of newDenominations) {
        const oldDenom = await tx.get(Stores.denominations, [
          baseUrl,
          newDenom.denomPub,
        ]);
        if (oldDenom) {
          // FIXME: Do consistency check
        } else {
          await tx.put(Stores.denominations, newDenom);
        }
      }
      // Handle recoup
      const recoupDenomList = exchangeKeysJson.recoup ?? [];
      const newlyRevokedCoinPubs: string[] = [];
      logger.trace("recoup list from exchange", recoupDenomList);
      for (const recoupInfo of recoupDenomList) {
        const oldDenom = await tx.getIndexed(
          Stores.denominations.denomPubHashIndex,
          recoupInfo.h_denom_pub,
        );
        if (!oldDenom) {
          // We never even knew about the revoked denomination, all good.
          continue;
        }
        if (oldDenom.isRevoked) {
          // We already marked the denomination as revoked,
          // this implies we revoked all coins
          console.log("denom already revoked");
          continue;
        }
        console.log("revoking denom", recoupInfo.h_denom_pub);
        oldDenom.isRevoked = true;
        await tx.put(Stores.denominations, oldDenom);
        const affectedCoins = await tx
          .iterIndexed(Stores.coins.denomPubHashIndex, recoupInfo.h_denom_pub)
          .toArray();
        for (const ac of affectedCoins) {
          newlyRevokedCoinPubs.push(ac.coinPub);
        }
      }
      if (newlyRevokedCoinPubs.length != 0) {
        console.log("recouping coins", newlyRevokedCoinPubs);
        await createRecoupGroup(ws, tx, newlyRevokedCoinPubs);
      }
    },
  );
  if (recoupGroupId) {
    // Asynchronously start recoup.  This doesn't need to finish
    // for the exchange update to be considered finished.
    processRecoupGroup(ws, recoupGroupId).catch((e) => {
      console.log("error while recouping coins:", e);
    });
  }
}
async function updateExchangeFinalize(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
): Promise {
  const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
  if (!exchange) {
    return;
  }
  if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
    return;
  }
  await ws.db.runWithWriteTransaction(
    [Stores.exchanges, Stores.exchangeUpdatedEvents],
    async (tx) => {
      const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
      if (!r) {
        return;
      }
      if (r.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
        return;
      }
      r.addComplete = true;
      r.updateStatus = ExchangeUpdateStatus.Finished;
      await tx.put(Stores.exchanges, r);
      const updateEvent: ExchangeUpdatedEventRecord = {
        exchangeBaseUrl: exchange.baseUrl,
        timestamp: getTimestampNow(),
      };
      await tx.put(Stores.exchangeUpdatedEvents, updateEvent);
    },
  );
}
async function updateExchangeWithTermsOfService(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
): Promise {
  const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
  if (!exchange) {
    return;
  }
  if (exchange.updateStatus != ExchangeUpdateStatus.FetchTerms) {
    return;
  }
  const reqUrl = new URL("terms", exchangeBaseUrl);
  reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
  const headers = {
    Accept: "text/plain",
  };
  const resp = await ws.http.get(reqUrl.href, { headers });
  const tosText = await readSuccessResponseTextOrThrow(resp);
  const tosEtag = resp.headers.get("etag") || undefined;
  await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
    const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
    if (!r) {
      return;
    }
    if (r.updateStatus != ExchangeUpdateStatus.FetchTerms) {
      return;
    }
    r.termsOfServiceText = tosText;
    r.termsOfServiceLastEtag = tosEtag;
    r.updateStatus = ExchangeUpdateStatus.FinalizeUpdate;
    await tx.put(Stores.exchanges, r);
  });
}
export async function acceptExchangeTermsOfService(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
  etag: string | undefined,
): Promise {
  await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
    const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
    if (!r) {
      return;
    }
    r.termsOfServiceAcceptedEtag = etag;
    r.termsOfServiceAcceptedTimestamp = getTimestampNow();
    await tx.put(Stores.exchanges, r);
  });
}
/**
 * Fetch wire information for an exchange and store it in the database.
 *
 * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
 */
async function updateExchangeWithWireInfo(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
): Promise {
  const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
  if (!exchange) {
    return;
  }
  if (exchange.updateStatus != ExchangeUpdateStatus.FetchWire) {
    return;
  }
  const details = exchange.details;
  if (!details) {
    throw Error("invalid exchange state");
  }
  const reqUrl = new URL("wire", exchangeBaseUrl);
  reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
  const resp = await ws.http.get(reqUrl.href);
  const wireInfo = await readSuccessResponseJsonOrThrow(
    resp,
    codecForExchangeWireJson(),
  );
  for (const a of wireInfo.accounts) {
    logger.trace("validating exchange acct");
    const isValid = await ws.cryptoApi.isValidWireAccount(
      a.payto_uri,
      a.master_sig,
      details.masterPublicKey,
    );
    if (!isValid) {
      throw Error("exchange acct signature invalid");
    }
  }
  const feesForType: { [wireMethod: string]: WireFee[] } = {};
  for (const wireMethod of Object.keys(wireInfo.fees)) {
    const feeList: WireFee[] = [];
    for (const x of wireInfo.fees[wireMethod]) {
      const startStamp = x.start_date;
      const endStamp = x.end_date;
      const fee: WireFee = {
        closingFee: Amounts.parseOrThrow(x.closing_fee),
        endStamp,
        sig: x.sig,
        startStamp,
        wireFee: Amounts.parseOrThrow(x.wire_fee),
      };
      const isValid = await ws.cryptoApi.isValidWireFee(
        wireMethod,
        fee,
        details.masterPublicKey,
      );
      if (!isValid) {
        throw Error("exchange wire fee signature invalid");
      }
      feeList.push(fee);
    }
    feesForType[wireMethod] = feeList;
  }
  await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
    const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
    if (!r) {
      return;
    }
    if (r.updateStatus != ExchangeUpdateStatus.FetchWire) {
      return;
    }
    r.wireInfo = {
      accounts: wireInfo.accounts,
      feesForType: feesForType,
    };
    r.updateStatus = ExchangeUpdateStatus.FetchTerms;
    r.lastError = undefined;
    await tx.put(Stores.exchanges, r);
  });
}
export async function updateExchangeFromUrl(
  ws: InternalWalletState,
  baseUrl: string,
  forceNow = false,
): Promise {
  const onOpErr = (e: OperationErrorDetails): Promise =>
    setExchangeError(ws, baseUrl, e);
  return await guardOperationException(
    () => updateExchangeFromUrlImpl(ws, baseUrl, forceNow),
    onOpErr,
  );
}
/**
 * Update or add exchange DB entry by fetching the /keys and /wire information.
 * Optionally link the reserve entry to the new or existing
 * exchange entry in then DB.
 */
async function updateExchangeFromUrlImpl(
  ws: InternalWalletState,
  baseUrl: string,
  forceNow = false,
): Promise {
  const now = getTimestampNow();
  baseUrl = canonicalizeBaseUrl(baseUrl);
  const r = await ws.db.get(Stores.exchanges, baseUrl);
  if (!r) {
    const newExchangeRecord: ExchangeRecord = {
      builtIn: false,
      addComplete: false,
      permanent: true,
      baseUrl: baseUrl,
      details: undefined,
      wireInfo: undefined,
      updateStatus: ExchangeUpdateStatus.FetchKeys,
      updateStarted: now,
      updateReason: ExchangeUpdateReason.Initial,
      timestampAdded: getTimestampNow(),
      termsOfServiceAcceptedEtag: undefined,
      termsOfServiceAcceptedTimestamp: undefined,
      termsOfServiceLastEtag: undefined,
      termsOfServiceText: undefined,
      updateDiff: undefined,
    };
    await ws.db.put(Stores.exchanges, newExchangeRecord);
  } else {
    await ws.db.runWithWriteTransaction([Stores.exchanges], async (t) => {
      const rec = await t.get(Stores.exchanges, baseUrl);
      if (!rec) {
        return;
      }
      if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && !forceNow) {
        return;
      }
      if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && forceNow) {
        rec.updateReason = ExchangeUpdateReason.Forced;
      }
      rec.updateStarted = now;
      rec.updateStatus = ExchangeUpdateStatus.FetchKeys;
      rec.lastError = undefined;
      t.put(Stores.exchanges, rec);
    });
  }
  await updateExchangeWithKeys(ws, baseUrl);
  await updateExchangeWithWireInfo(ws, baseUrl);
  await updateExchangeWithTermsOfService(ws, baseUrl);
  await updateExchangeFinalize(ws, baseUrl);
  const updatedExchange = await ws.db.get(Stores.exchanges, baseUrl);
  if (!updatedExchange) {
    // This should practically never happen
    throw Error("exchange not found");
  }
  return updatedExchange;
}
/**
 * Check if and how an exchange is trusted and/or audited.
 */
export async function getExchangeTrust(
  ws: InternalWalletState,
  exchangeInfo: ExchangeRecord,
): Promise<{ isTrusted: boolean; isAudited: boolean }> {
  let isTrusted = false;
  let isAudited = false;
  const exchangeDetails = exchangeInfo.details;
  if (!exchangeDetails) {
    throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
  }
  const currencyRecord = await ws.db.get(
    Stores.currencies,
    exchangeDetails.currency,
  );
  if (currencyRecord) {
    for (const trustedExchange of currencyRecord.exchanges) {
      if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) {
        isTrusted = true;
        break;
      }
    }
    for (const trustedAuditor of currencyRecord.auditors) {
      for (const exchangeAuditor of exchangeDetails.auditors) {
        if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) {
          isAudited = true;
          break;
        }
      }
    }
  }
  return { isTrusted, isAudited };
}
export async function getExchangePaytoUri(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
  supportedTargetTypes: string[],
): Promise {
  // We do the update here, since the exchange might not even exist
  // yet in our database.
  const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl);
  if (!exchangeRecord) {
    throw Error(`Exchange '${exchangeBaseUrl}' not found.`);
  }
  const exchangeWireInfo = exchangeRecord.wireInfo;
  if (!exchangeWireInfo) {
    throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`);
  }
  for (const account of exchangeWireInfo.accounts) {
    const res = parsePaytoUri(account.payto_uri);
    if (!res) {
      continue;
    }
    if (supportedTargetTypes.includes(res.targetType)) {
      return account.payto_uri;
    }
  }
  throw Error("no matching exchange account found");
}