/*
This file is part of web3.js.

web3.js is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

web3.js 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 Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public License
along with web3.js.  If not, see <http://www.gnu.org/licenses/>.
*/
// Taken from https://github.com/web3/web3.js/blob/v4.3.0/packages/web3-eth/src/utils/reject_if_block_timeout.ts

import { Web3Context } from "web3-core";
import { TransactionBlockTimeoutError } from "web3-errors";
import { NUMBER_DATA_FORMAT, NewHeadsSubscription, getBlockNumber } from "web3-eth";
import { EthExecutionAPI, Bytes, Web3BaseProvider, BlockHeaderOutput } from "web3-types";
import { rejectIfConditionAtInterval } from "web3-utils";

export interface ResourceCleaner {
  clean: () => void;
}

function resolveByPolling(
  web3Context: Web3Context<EthExecutionAPI>,
  starterBlockNumber: number,
  transactionHash?: Bytes,
): [Promise<never>, ResourceCleaner] {
  const pollingInterval = web3Context.transactionPollingInterval;
  const [intervalId, promiseToError] =
    rejectIfConditionAtInterval(async () => {
      let lastBlockNumber;
      try {
        lastBlockNumber = await getBlockNumber(web3Context, NUMBER_DATA_FORMAT);
      } catch (error) {
        console.warn("An error happen while trying to get the block number", error);
        return undefined;
      }
      const numberOfBlocks = lastBlockNumber - starterBlockNumber;
      if (numberOfBlocks >= web3Context.transactionBlockTimeout) {
        return new TransactionBlockTimeoutError({
          starterBlockNumber,
          numberOfBlocks,
          transactionHash,
        });
      }
      return undefined;
    }, pollingInterval);

  const clean = () => {
    clearInterval(intervalId);
  };

  return [promiseToError, { clean }];
}

async function resolveBySubscription(
  web3Context: Web3Context<EthExecutionAPI>,
  starterBlockNumber: number,
  transactionHash?: Bytes,
): Promise<[Promise<never>, ResourceCleaner]> {
  // The following variable will stay true except if the data arrived,
  //	or if watching started after an error had occurred.
  let needToWatchLater = true;

  let subscription: NewHeadsSubscription;
  let resourceCleaner: ResourceCleaner;
  // internal helper function
  function revertToPolling(
    reject: (value: Error | PromiseLike<Error>) => void,
    previousError?: Error,
  ) {
    if (previousError) {
      console.warn("error happened at subscription. So revert to polling...", previousError);
    }
    resourceCleaner.clean();

    needToWatchLater = false;
    const [promiseToError, newResourceCleaner] = resolveByPolling(
      web3Context,
      starterBlockNumber,
      transactionHash,
    );
    resourceCleaner.clean = newResourceCleaner.clean;
    promiseToError.catch((error) => reject(error as Error));
  }
  try {
    subscription = (await web3Context.subscriptionManager?.subscribe(
      "newHeads",
    )) as unknown as NewHeadsSubscription;
    resourceCleaner = {
      clean: () => {
        // Remove the subscription, if it was not removed somewhere
        // 	else by calling, for example, subscriptionManager.clear()
        if (subscription.id) {
          web3Context.subscriptionManager
            ?.removeSubscription(subscription)
            .then(() => {
              // Subscription ended successfully
            })
            .catch(() => {
              // An error happened while ending subscription. But no need to take any action.
            });
        }
      },
    };
  } catch (error) {
    return resolveByPolling(web3Context, starterBlockNumber, transactionHash);
  }
  const promiseToError: Promise<never> = new Promise((_, reject) => {
    try {
      subscription.on("data", (lastBlockHeader: BlockHeaderOutput) => {
        needToWatchLater = false;
        if (!lastBlockHeader?.number) {
          return;
        }
        const numberOfBlocks = Number(
          BigInt(lastBlockHeader.number) - BigInt(starterBlockNumber),
        );

        if (numberOfBlocks >= web3Context.transactionBlockTimeout) {
          // Transaction Block Timeout is known to be reached by subscribing to new heads
          reject(
            new TransactionBlockTimeoutError({
              starterBlockNumber,
              numberOfBlocks,
              transactionHash,
            }),
          );
        }
      });
      subscription.on("error", (error) => {
        revertToPolling(reject, error);
      });
    } catch (error) {
      revertToPolling(reject, error as Error);
    }

    // Fallback to polling if tx receipt didn't arrived in "blockHeaderTimeout" [10 seconds]
    setTimeout(() => {
      if (needToWatchLater) {
        revertToPolling(reject);
      }
    }, web3Context.blockHeaderTimeout * 1000);
  });

  return [promiseToError, resourceCleaner];
}

/* TODO: After merge, there will be constant block mining time (exactly 12 second each block, except slot missed that currently happens in <1% of slots. ) so we can optimize following function
for POS NWs, we can skip checking getBlockNumber(); after interval and calculate only based on time  that certain num of blocked are mined after that for internal double check, can do one getBlockNumber() call and timeout.
*/
export async function rejectIfBlockTimeout(
  web3Context: Web3Context<EthExecutionAPI>,
  transactionHash?: Bytes,
): Promise<[Promise<never>, ResourceCleaner]> {
  const { provider } = web3Context.requestManager;
  let callingRes: [Promise<never>, ResourceCleaner];
  const starterBlockNumber = await getBlockNumber(web3Context, NUMBER_DATA_FORMAT);
  // TODO: once https://github.com/web3/web3.js/issues/5521 is implemented, remove checking for `enableExperimentalFeatures.useSubscriptionWhenCheckingBlockTimeout`
  if (
    (provider as Web3BaseProvider).supportsSubscriptions?.() &&
    web3Context.enableExperimentalFeatures.useSubscriptionWhenCheckingBlockTimeout
  ) {
    callingRes = await resolveBySubscription(web3Context, starterBlockNumber, transactionHash);
  } else {
    callingRes = resolveByPolling(web3Context, starterBlockNumber, transactionHash);
  }
  return callingRes;
}
