import expect from "expect";
import invariant from "invariant";
import now from "performance-now";
import sample from "lodash/sample";
import { throwError, of, Observable, OperatorFunction, firstValueFrom } from "rxjs";
import {
  first,
  filter,
  map,
  reduce,
  tap,
  mergeMap,
  timeout,
  distinctUntilChanged,
  retry,
} from "rxjs/operators";
import { log } from "@ledgerhq/logs";
import { getCurrencyBridge, getAccountBridge } from "../bridge";
import { promiseAllBatched, delay } from "../promise";
import { isAccountEmpty, formatAccount } from "../account";
import { getOperationConfirmationNumber } from "../operation";
import { getEnv } from "@ledgerhq/live-env";
import {
  listAppCandidates,
  createSpeculosDevice,
  releaseSpeculosDevice,
  findAppCandidate,
} from "../load/speculos";
import type { AppCandidate } from "@ledgerhq/ledger-wallet-framework/bot/types";
import { formatReportForConsole, formatTime, formatAppCandidate, formatError } from "./formatters";
import type {
  AppSpec,
  MutationSpec,
  SpecReport,
  MutationReport,
  DeviceAction,
  TransactionTestInput,
  TransactionArg,
  TransactionRes,
  TransactionDestinationTestInput,
  DeviceActionEvent,
} from "./types";
import { makeBridgeCacheSystem } from "../bridge/cache";
import type {
  Account,
  AccountLike,
  Operation,
  SignedOperation,
  SignOperationEvent,
  TransactionCommon,
} from "@ledgerhq/types-live";
import type { TransactionStatus } from "../generated/types";
import { botTest } from "@ledgerhq/ledger-wallet-framework/bot/bot-test-context";
import { getDefaultAccountNameForCurrencyIndex } from "@ledgerhq/live-wallet/accountName";

let appCandidates;
const localCache = {};
const cache = makeBridgeCacheSystem({
  saveData(c, d) {
    localCache[c.id] = d;
    return Promise.resolve();
  },

  getData(c) {
    return Promise.resolve(localCache[c.id]);
  },
});

const defaultScanAccountsRetries = 2;
const delayBetweenScanAccountRetries = 5000;

export async function runWithAppSpec<T extends TransactionCommon>(
  spec: AppSpec<T>,
  reportLog: (arg0: string) => void,
): Promise<SpecReport<T>> {
  log("engine", `spec ${spec.name}`);
  const seed = getEnv("SEED");
  invariant(seed, "SEED is not set");
  const coinapps = getEnv("COINAPPS");
  invariant(coinapps, "COINAPPS is not set");

  if (!appCandidates) {
    appCandidates = await listAppCandidates(coinapps);
  }

  const mutationReports: MutationReport<T>[] = [];
  const { appQuery, currency, dependency, onSpeculosDeviceCreated } = spec;
  const appCandidate = findAppCandidate(appCandidates, appQuery);
  if (!appCandidate) {
    console.warn("no app found for " + spec.name);
    console.warn(appQuery);
    console.warn(JSON.stringify(appCandidates, undefined, 2));
  }
  invariant(
    appCandidate,
    "%s: no app found. Are you sure your COINAPPS is up to date?",
    spec.name,
    coinapps,
  );
  log("engine", `spec ${spec.name} will use ${formatAppCandidate(appCandidate as AppCandidate)}`);
  const deviceParams = {
    ...(appCandidate as AppCandidate),
    appName: spec.currency.managerAppName,
    seed,
    dependency,
    coinapps,
    onSpeculosDeviceCreated,
  };
  let device;

  const hintWarnings: string[] = [];
  const appReport: SpecReport<T> = {
    spec,
    hintWarnings,
    skipMutationsTimeoutReached: false,
  };

  // staticly check that all mutations declared a test too (if no generic spec test)
  if (!spec.test) {
    const list = spec.mutations.filter(m => !m.test);
    if (list.length > 0) {
      hintWarnings.push("mutations should define a test(): " + list.map(m => m.name).join(", "));
    }
  }

  // staticly assess if testDestination is necessary
  const mutationThatProducedDestinationsWithoutTests: MutationSpec<any>[] = [];
  const mutationWithDestinationTestsWithoutDestination: MutationSpec<any>[] = [];

  try {
    device = await createSpeculosDevice(deviceParams);
    appReport.appPath = device.appPath;
    const bridge = getCurrencyBridge(currency);
    const syncConfig = {
      paginationConfig: {},
    };
    let t = now();
    const preloadedData = await cache.prepareCurrency(currency);
    const preloadDuration = now() - t;
    appReport.preloadDuration = preloadDuration;
    // Scan all existing accounts
    const beforeScanTime = now();
    t = now();
    let accounts = await firstValueFrom(
      bridge
        .scanAccounts({
          currency,
          deviceId: device.id,
          syncConfig,
        })
        .pipe(
          retry({
            count: spec.scanAccountsRetries || defaultScanAccountsRetries,
            delay: delayBetweenScanAccountRetries,
          }),
          filter(e => e.type === "discovered"),
          map(e => deepFreezeAccount(e.account)),
          reduce<Account, Account[]>((all, a) => all.concat(a), []),
          timeout({
            each: getEnv("BOT_TIMEOUT_SCAN_ACCOUNTS"),
            with: () =>
              throwError(() => new Error("scan accounts timeout for currency " + currency.name)),
          }),
        ),
    );
    appReport.scanDuration = now() - beforeScanTime;

    // check if there are more accounts than mutation declared as a hint for the dev
    if (accounts.length <= spec.mutations.length) {
      hintWarnings.push(
        `There are not enough accounts (${accounts.length}) to cover all mutations (${
          spec.mutations.length
        }).\nPlease increase the account target to at least ${spec.mutations.length + 1} accounts`,
      );
    }

    appReport.accountsBefore = accounts;
    if (!spec.allowEmptyAccounts) {
      invariant(accounts.length > 0, "unexpected empty accounts for " + currency.name);
    }
    const preloadStats = preloadDuration > 10 ? ` (preload: ${formatTime(preloadDuration)})` : "";
    reportLog(
      `Spec ${spec.name} found ${accounts.length} ${
        currency.name
      } accounts${preloadStats}. Will use ${formatAppCandidate(
        appCandidate as AppCandidate,
      )}\n${accounts.map(a => formatAccount(a, "head")).join("\n")}\n`,
    );

    if (accounts.every(isAccountEmpty)) {
      reportLog(
        `This SEED does not have ${currency.name}. Please send funds to ${accounts
          .map(a => a.freshAddress)
          .join(" or ")}\n`,
      );
      appReport.accountsAfter = accounts;
      return appReport;
    }

    const mutationsStartTime = now();
    const skipMutationsTimeout = spec.skipMutationsTimeout || getEnv("BOT_SPEC_DEFAULT_TIMEOUT");
    let mutationsCount = {};
    // we sequentially iterate on the initial account set to perform mutations
    const length = accounts.length;
    const totalTries = spec.multipleRuns || 1;
    // dynamic buffer with ids of accounts that need a resync (between runs)
    let accountIdsNeedResync: string[] = [];

    for (let j = 0; j < totalTries; j++) {
      for (let i = 0; i < length; i++) {
        t = now();
        if (t - mutationsStartTime > skipMutationsTimeout) {
          appReport.skipMutationsTimeoutReached = true;
          break;
        }
        log("engine", `spec ${spec.name} sync all accounts (try ${j} run ${i})`);

        // resync all accounts that needs to be resynced
        const resynced = await promiseAllBatched(
          getEnv("SYNC_MAX_CONCURRENT"),
          accounts.filter(a => accountIdsNeedResync.includes(a.id)),
          syncAccount,
        );

        accounts = accounts.map((a: Account) => {
          const i = accountIdsNeedResync.indexOf(a.id);
          return i !== -1 ? resynced[i] : a;
        });

        accountIdsNeedResync = [];

        appReport.accountsAfter = accounts;
        const resyncAccountsDuration = now() - t;
        const account = accounts[i];
        const report = await runOnAccount({
          appCandidate,
          spec,
          device,
          account,
          accounts,
          mutationsCount,
          resyncAccountsDuration,
          accountIdsNeedResync,
          preloadedData,
        });
        if (report.finalAccount) {
          // optim: no need to resync if all went well with finalAccount
          const finalAccount: Account = report.finalAccount;
          accountIdsNeedResync = accountIdsNeedResync.filter(id => id !== finalAccount.id);
          accounts = accounts.map((a: Account) => (a.id === finalAccount.id ? finalAccount : a));
        }
        if (report.finalDestination) {
          // optim: no need to resync if all went well with finalDestination
          const finalDestination: Account = report.finalDestination;
          accountIdsNeedResync = accountIdsNeedResync.filter(id => id !== finalDestination.id);
          accounts = accounts.map((a: Account) =>
            a.id === finalDestination.id ? finalDestination : a,
          );
        } else if (report.mutation) {
          const { mutation } = report;
          if (report.destination) {
            if (
              !mutation.testDestination &&
              !mutationThatProducedDestinationsWithoutTests.includes(mutation)
            ) {
              mutationThatProducedDestinationsWithoutTests.push(mutation);
            }
          } else {
            if (
              mutation.testDestination &&
              !mutationWithDestinationTestsWithoutDestination.includes(mutation)
            ) {
              mutationWithDestinationTestsWithoutDestination.push(mutation);
            }
          }
        }
        // eslint-disable-next-line no-console
        console.log(formatReportForConsole(report as any));
        mutationReports.push(report);
        appReport.mutations = mutationReports;

        if (
          report.error ||
          (report.latestSignOperationEvent &&
            report.latestSignOperationEvent.type === "device-signature-requested")
        ) {
          log(
            "engine",
            `spec ${spec.name} is recreating the device because deviceAction didn't finished`,
          );
          await releaseSpeculosDevice(device.id);
          device = await createSpeculosDevice(deviceParams);
          if (spec.onSpeculosDeviceCreated) {
            await spec.onSpeculosDeviceCreated(device);
          }
        }
      }
      mutationsCount = {};
    }

    if (
      mutationReports.every(r => !r.mutation) &&
      accounts.some(a => a.spendableBalance.gt(spec.minViableAmount || 0))
    ) {
      hintWarnings.push(
        "No mutation were found possible. Yet there are funds in the accounts, please investigate.",
      );
    }

    if (mutationThatProducedDestinationsWithoutTests.length) {
      hintWarnings.push(
        "mutations should define a testDestination(): " +
          mutationThatProducedDestinationsWithoutTests.map(m => m.name).join(", "),
      );
    }
    if (mutationWithDestinationTestsWithoutDestination.length) {
      hintWarnings.push(
        "mutations should NOT define a testDestination() because there are no 'destination' sibling account found: " +
          mutationWithDestinationTestsWithoutDestination.map(m => m.name).join(", "),
      );
    }

    mutationReports.forEach(m => {
      m.hintWarnings.forEach(h => {
        const txt = `mutation ${m.mutation?.name || "?"}: ${h}`;
        if (!hintWarnings.includes(txt)) {
          hintWarnings.push(txt);
        }
      });
    });

    appReport.mutations = mutationReports;
    appReport.accountsAfter = accounts;
  } catch (e: any) {
    if (process.env.CI) console.error(e);
    appReport.fatalError = e;
    log("engine", `spec ${spec.name} failed with ${String(e)}`);
  } finally {
    log("engine", `spec ${spec.name} finished`);
    if (device) await releaseSpeculosDevice(device.id);
  }

  return appReport;
}
export async function runOnAccount<T extends TransactionCommon>({
  appCandidate,
  spec,
  device,
  account,
  accounts,
  accountIdsNeedResync,
  mutationsCount,
  resyncAccountsDuration,
  preloadedData,
}: {
  appCandidate: any;
  spec: AppSpec<T>;
  device: any;
  account: any;
  accounts: any;
  accountIdsNeedResync: string[];
  mutationsCount: Record<string, number>;
  resyncAccountsDuration: number;
  preloadedData: any;
}): Promise<MutationReport<T>> {
  const { mutations } = spec;
  let latestSignOperationEvent;
  const hintWarnings: string[] = [];
  const report: MutationReport<T> = {
    spec,
    appCandidate,
    resyncAccountsDuration,
    hintWarnings,
  };

  try {
    const accountBridge = getAccountBridge(account);
    const accountBeforeTransaction = account;
    report.account = account;
    log("engine", `spec ${spec.name}/${getDefaultAccountNameForCurrencyIndex(account)}`);
    const maxSpendable = await accountBridge.estimateMaxSpendable({
      account,
    });
    report.maxSpendable = maxSpendable;
    log(
      "engine",
      `spec ${spec.name}/${getDefaultAccountNameForCurrencyIndex(
        account,
      )} maxSpendable=${maxSpendable.toString()}`,
    );
    const candidates: Array<{
      mutation: MutationSpec<T>;
      tx: T;
      updates: Array<Partial<T> | null | undefined>;
      destination: Account | undefined;
    }> = [];
    const unavailableMutationReasons: Array<{
      mutation: MutationSpec<T>;
      error: Error;
    }> = [];

    for (const mutation of mutations) {
      try {
        const count = mutationsCount[mutation.name] || 0;
        invariant(
          count < (mutation.maxRun || Infinity),
          "maximum mutation run reached (%s)",
          count,
        );
        const arg: TransactionArg<T> = {
          appCandidate,
          account,
          bridge: accountBridge,
          siblings: accounts.filter(a => a !== account),
          maxSpendable,
          preloadedData,
        };
        if (spec.transactionCheck) spec.transactionCheck(arg);
        const r: TransactionRes<T> = mutation.transaction(arg);
        candidates.push({
          mutation,
          tx: r.transaction,
          // $FlowFixMe what the hell
          updates: r.updates,
          destination: r.destination,
        });
      } catch (error: any) {
        if (process.env.CI) console.error(error);
        unavailableMutationReasons.push({
          mutation,
          error,
        });
      }
    }

    const candidate = sample(candidates);

    if (!candidate) {
      // no mutation were suitable
      report.unavailableMutationReasons = unavailableMutationReasons;
      return report;
    }

    // a mutation was chosen
    const { tx, mutation, updates } = candidate;
    report.mutation = mutation;
    report.mutationTime = now();
    // prepare the transaction and ensure it's valid
    let status;
    let errors = [];
    let transaction: T = await accountBridge.prepareTransaction(account, tx);
    deepFreezeTransaction(transaction);

    for (const patch of updates) {
      if (patch) {
        await accountBridge.getTransactionStatus(account, transaction); // result is unused but that would happen in normal flow

        report.transaction = transaction;
        transaction = await accountBridge.updateTransaction(transaction, patch);
        report.transaction = transaction;
        transaction = await accountBridge.prepareTransaction(account, transaction);
        deepFreezeTransaction(transaction);
      }
    }

    report.transaction = transaction;
    const destination =
      candidate.destination || accounts.find(a => a.freshAddress === transaction.recipient);
    report.destination = destination;
    status = await accountBridge.getTransactionStatus(account, transaction);
    errors = Object.values(status.errors);

    deepFreezeStatus(status);
    report.status = status;
    report.statusTime = now();
    const warnings = Object.values(status.warnings);

    if (mutation.recoverBadTransactionStatus) {
      if (errors.length || warnings.length) {
        // there is something to recover from
        const recovered = mutation.recoverBadTransactionStatus({
          transaction,
          status,
          account,
          bridge: accountBridge,
        });

        if (recovered && recovered !== transaction) {
          deepFreezeTransaction(recovered);
          report.recoveredFromTransactionStatus = {
            transaction,
            status,
          };
          report.transaction = transaction = recovered;
          status = await accountBridge.getTransactionStatus(account, transaction);
          errors = Object.values(status.errors);
          deepFreezeStatus(status);
          report.status = status;
          report.statusTime = now();
        }
      }
    }

    // without recovering mechanism, we simply assume an error is a failure
    if (errors.length) {
      console.warn(status);
      botTest("mutation must not have tx status errors", () => {
        // all mutation must express transaction that are POSSIBLE
        // recoveredFromTransactionStatus can also be used to solve this for tricky cases
        throw errors[0];
      });
    }

    const { expectStatusWarnings } = mutation;
    if (warnings.length || expectStatusWarnings) {
      const expected =
        expectStatusWarnings &&
        expectStatusWarnings({
          transaction,
          status,
          account,
          bridge: accountBridge,
        });
      if (expected) {
        botTest("verify status.warnings expectations", () =>
          expect(status.warnings).toEqual(expected),
        );
      } else {
        for (const k in status.warnings) {
          const e = status.warnings[k];
          hintWarnings.push(
            `unexpected status.warnings.${k} = ${String(
              e,
            )} – Please implement expectStatusWarnings on the mutation if expected`,
          );
        }
      }
    }

    mutationsCount[mutation.name] = (mutationsCount[mutation.name] || 0) + 1;
    // sign the transaction with speculos
    log("engine", `spec ${spec.name}/${getDefaultAccountNameForCurrencyIndex(account)} signing`);
    const signedOperation = await firstValueFrom(
      accountBridge
        .signOperation({
          account,
          transaction,
          deviceId: device.id,
        })
        .pipe(
          tap(e => {
            latestSignOperationEvent = e;
            log(
              "engine",
              `spec ${spec.name}/${getDefaultAccountNameForCurrencyIndex(account)}: ${e.type}`,
            );
          }),
          autoSignTransaction({
            transport: device.transport,
            deviceAction: mutation.deviceAction || spec.genericDeviceAction,
            appCandidate,
            account,
            transaction,
            status,
          }),
          first((e: any) => e.type === "signed"),
          map(e => (invariant(e.type === "signed", "signed operation"), e.signedOperation)),
        ),
    );
    deepFreezeSignedOperation(signedOperation);

    report.signedOperation = signedOperation;
    report.signedTime = now();

    // at this stage, we are about to broadcast we assume we will need to resync receiver account
    if (report.destination) {
      accountIdsNeedResync.push(report.destination.id);
    }
    // even if the test will actively sync the account, we need to pessimisticly assume it won't, we may not reach the final step of it.
    // after the runOnAccount() call, we actively remove from accountIdsNeedResync the account.id if it is actually sucessful
    accountIdsNeedResync.push(account.id);

    // broadcast the transaction
    const optimisticOperation = getEnv("DISABLE_TRANSACTION_BROADCAST")
      ? signedOperation.operation
      : await accountBridge
          .broadcast({
            account,
            signedOperation,
          })
          .catch(e => {
            // wrap the error into some bot test context
            botTest("during broadcast", () => {
              throw e;
            });
          });

    deepFreezeOperation(optimisticOperation);
    report.optimisticOperation = optimisticOperation;
    report.broadcastedTime = now();
    log(
      "engine",
      `spec ${spec.name}/${getDefaultAccountNameForCurrencyIndex(account)}/${
        optimisticOperation.hash
      } broadcasted`,
    );

    // wait the condition are good (operation confirmed)
    // test() is run over and over until either timeout is reach OR success
    const testBefore = now();
    const timeOut = mutation.testTimeout || spec.testTimeout || 5 * 60 * 1000;
    const step = account => {
      if (spec.skipOperationHistory) {
        return optimisticOperation;
      }
      const timedOut = now() - testBefore > timeOut;
      const operation = account.operations.find(o => o.id === optimisticOperation.id);

      if (timedOut && !operation) {
        botTest("waiting operation id to appear after broadcast", () => {
          throw new Error("could not find optimisticOperation " + optimisticOperation.id);
        });
      }

      if (operation) {
        try {
          const arg = {
            accountBeforeTransaction,
            transaction,
            status,
            optimisticOperation,
            operation,
            account,
          };
          transactionTest(arg);
          if (spec.test) spec.test(arg);
          if (mutation.test) mutation.test(arg);
          report.testDuration = now() - testBefore;
        } catch (e) {
          // this is too critical to "ignore"
          if (e instanceof TypeError || e instanceof SyntaxError) {
            report.testDuration = now() - testBefore;
            throw e;
          }
          // We never reach the final test success
          if (timedOut) {
            report.testDuration = now() - testBefore;
            throw e;
          }

          log("bot", "failed confirm test. trying again. " + String(e));
          // We will try again
          return;
        }
      }

      return operation;
    };

    const result = await awaitAccountOperation<Operation>({ account, step });
    const { account: finalAccount, value: operation } = result;
    report.finalAccount = finalAccount;
    report.operation = operation;
    report.confirmedTime = now();
    log(
      "engine",
      `spec ${spec.name}/${getDefaultAccountNameForCurrencyIndex(account)}/${
        optimisticOperation.hash
      } confirmed`,
    );

    const destinationBeforeTransaction = destination;
    if (destination && mutation.testDestination) {
      const { testDestination } = mutation;
      // test() is run over and over until either timeout is reach OR success
      const ntestBefore = now();
      const newTimeOut = Math.max(10000, timeOut - (ntestBefore - testBefore));
      log("bot", "remaining time to test destination: " + (newTimeOut / 1000).toFixed(0) + "s");
      const sendingOperation = operation;
      const step = account => {
        if (spec.skipOperationHistory) {
          return sendingOperation;
        }
        const timedOut = now() - ntestBefore > newTimeOut;
        let operation;
        try {
          operation = account.operations.find(op => op.hash === sendingOperation.hash);
          botTest("destination account should receive an operation (by tx hash)", () =>
            invariant(operation, "no operation found with hash %s", sendingOperation.hash),
          );
          if (!operation) throw new Error();
          const arg: TransactionDestinationTestInput<T> = {
            transaction,
            status,
            sendingAccount: finalAccount,
            sendingOperation,
            operation,
            destinationBeforeTransaction,
            destination: account,
          };
          botTest("destination", () => testDestination(arg));
          report.testDestinationDuration = now() - ntestBefore;
        } catch (e) {
          // this is too critical to "ignore"
          if (e instanceof TypeError || e instanceof SyntaxError) {
            report.testDuration = now() - testBefore;
            throw e;
          }
          // We never reach the final test success
          if (timedOut) {
            report.testDestinationDuration = now() - ntestBefore;
            throw e;
          }
          log("bot", "failed destination confirm test. trying again. " + String(e));
          // We will try again
          return;
        }
        return operation;
      };
      const result = await awaitAccountOperation<Operation>({
        account: destination,
        step,
      });
      report.finalDestination = result.account;
      report.finalDestinationOperation = result.value;
      report.destinationConfirmedTime = now();
    }
  } catch (error: any) {
    if (process.env.CI) console.error(error);
    log("mutation-error", spec.name + ": " + formatError(error, true));
    report.error = error;
    report.errorTime = now();
  }

  report.latestSignOperationEvent = latestSignOperationEvent;
  return report;
}

async function syncAccount(initialAccount: Account): Promise<Account> {
  const acc = await firstValueFrom(
    getAccountBridge(initialAccount)
      .sync(initialAccount, {
        paginationConfig: {},
      })
      .pipe(
        reduce((a, f: (arg0: Account) => Account) => f(a), initialAccount),
        timeout({
          each: 10 * 60 * 1000,
          with: () =>
            throwError(
              () =>
                new Error(
                  "account sync timeout for " +
                    getDefaultAccountNameForCurrencyIndex(initialAccount),
                ),
            ),
        }),
      ),
  );
  return deepFreezeAccount(acc);
}

export function autoSignTransaction<T extends TransactionCommon>({
  transport,
  deviceAction,
  appCandidate,
  account,
  transaction,
  status,
  disableStrictStepValueValidation,
}: {
  transport: any;
  deviceAction: DeviceAction<T, any>;
  appCandidate: AppCandidate;
  account: Account;
  transaction: T;
  status: TransactionStatus;
  disableStrictStepValueValidation?: boolean;
}): OperatorFunction<SignOperationEvent, SignOperationEvent> {
  let sub;
  let observer;
  let state;
  const recentEvents: SignOperationEvent[] = [];
  return mergeMap<SignOperationEvent, Observable<SignOperationEvent>>(e => {
    if (e.type === "device-signature-requested") {
      return new Observable(o => {
        if (observer) {
          o.error(new Error("device-signature-requested should not be called twice!"));
          return;
        }

        observer = o;
        o.next(e);
        const timeout = setTimeout(() => {
          o.error(
            new Error(
              "device action timeout. Recent events was:\n" +
                recentEvents.map(e => JSON.stringify(e)).join("\n"),
            ),
          );
        }, 60 * 1000);
        sub = transport.automationEvents
          .pipe(
            // deduplicate two successive identical text in events (that can sometimes occur with speculos)
            distinctUntilChanged((a: DeviceActionEvent, b: DeviceActionEvent) => a.text === b.text),
          )
          .subscribe({
            next: event => {
              recentEvents.push(event);

              if (recentEvents.length > 5) {
                recentEvents.shift();
              }

              try {
                state = deviceAction({
                  appCandidate,
                  account,
                  transaction,
                  event,
                  transport,
                  state,
                  status,
                  disableStrictStepValueValidation,
                });
              } catch (e) {
                o.error(e);
              }
            },
            complete: () => {
              o.complete();
            },
            error: e => {
              o.error(e);
            },
          });
        return () => {
          clearTimeout(timeout);
          sub.unsubscribe();
        };
      });
    } else if (observer) {
      observer.complete();
      observer = null;
    }

    if (sub) {
      sub.unsubscribe();
    }

    return of<SignOperationEvent>(e);
  });
}

function awaitAccountOperation<T>({
  account,
  step,
}: {
  account: Account;
  step: (arg0: Account) => T | null | undefined;
}): Promise<{
  account: Account;
  value: T;
}> {
  log("engine", "awaitAccountOperation on " + getDefaultAccountNameForCurrencyIndex(account));
  let syncCounter = 0;
  let acc = account;
  let lastSync = now();
  const loopDebounce = 1000;
  const targetInterval = getEnv("SYNC_PENDING_INTERVAL");

  async function loop() {
    const value = step(acc);

    if (value) {
      return {
        account: acc,
        value,
      };
    }
    const spent = now() - lastSync;
    await delay(Math.max(loopDebounce, targetInterval - spent));

    lastSync = now();
    log(
      "engine",
      "sync #" + syncCounter++ + " on " + getDefaultAccountNameForCurrencyIndex(account),
    );
    acc = await syncAccount(acc);
    const r = await loop();
    return r;
  }

  return loop();
}

// generic transaction test: make sure you are sure all coins suit the tests here
function transactionTest<T>({
  operation,
  optimisticOperation,
  account,
  accountBeforeTransaction,
}: TransactionTestInput<T>) {
  const dt = Date.now() - operation.date.getTime();
  const lowerThreshold = -60 * 1000; // -1mn accepted
  const upperThreshold = 30 * 60 * 1000; // 30mn up
  botTest("operation.date must not be in future", () => expect(dt).toBeGreaterThan(lowerThreshold));
  botTest("operation.date less than 30mn ago", () => expect(dt).toBeLessThan(upperThreshold));
  botTest("operation must not failed", () => {
    expect(!operation.hasFailed).toBe(true);
  });

  const { blockAvgTime } = account.currency;

  if (blockAvgTime && account.blockHeight) {
    const expected = getOperationConfirmationNumber(operation, account);
    const expectedMax = Math.ceil(upperThreshold / blockAvgTime);
    botTest("low amount of confirmations", () =>
      invariant(
        expected <= expectedMax,
        "There are way too much operation confirmation for a small amount of time. %s < %s",
        expected,
        expectedMax,
      ),
    );
  }

  botTest("optimisticOperation.value must not be NaN", () =>
    expect(!optimisticOperation.value.isNaN()).toBe(true),
  );
  botTest("optimisticOperation.fee must not be NaN", () =>
    expect(!optimisticOperation.fee.isNaN()).toBe(true),
  );
  botTest("operation.value must not be NaN", () => expect(!operation.value.isNaN()).toBe(true));
  botTest("operation.fee must not be NaN", () => expect(!operation.fee.isNaN()).toBe(true));

  botTest("successful tx should increase by at least 1 the number of account.operations", () =>
    expect(account.operations.length).toBeGreaterThanOrEqual(
      accountBeforeTransaction.operations.length + 1,
    ),
  );
}

/*
function deepFreeze(object, path: string[] = []) {
  // Retrieve the property names defined on object
  const propNames = Reflect.ownKeys(object);

  // Freeze properties before freezing self
  for (const name of propNames) {
    const value = object[name];

    if (value && typeof value === "object") {
      deepFreeze(value, [...path, name.toString()]);
    }
  }

  try {
    return Object.freeze(object);
  } catch (e) {
    console.warn("Can't freeze at " + path.join("."));
  }
}
*/

// deepFreeze logic specialized to freeze an account (it's too problematic to deep freeze all objects of Account too deeply)
function deepFreezeAccount<T extends AccountLike>(account: T): T {
  Object.freeze(account);
  if (account.type === "Account") {
    account.subAccounts?.forEach(deepFreezeAccount);
    account.nfts?.forEach(Object.freeze);
  }
  account.operations.forEach(deepFreezeOperation);
  account.pendingOperations.forEach(deepFreezeOperation);
  return account;
}

function deepFreezeOperation(operation: Operation): Operation {
  Object.freeze(operation);
  return operation;
}

function deepFreezeSignedOperation(signedOperation: SignedOperation): SignedOperation {
  Object.freeze(signedOperation);
  Object.freeze(signedOperation.operation);
  return signedOperation;
}

function deepFreezeStatus(transactionStatus: TransactionStatus): TransactionStatus {
  Object.freeze(transactionStatus);
  return transactionStatus;
}

function deepFreezeTransaction<T extends TransactionCommon>(transaction: T): T {
  Object.freeze(transaction);
  return transaction;
}
