import {StarknetSigner} from "./StarknetSigner";
import {StarknetTransactions, StarknetTx} from "../chain/modules/StarknetTransactions";
import {StarknetChainInterface} from "../chain/StarknetChainInterface";
import {bigIntMax, getLogger, LoggerType} from "../../utils/Utils";
import {Account, BlockTag} from "starknet";
import {access, readFile, writeFile, mkdir, constants} from "fs/promises";
import {StarknetFees} from "../chain/modules/StarknetFees";
import {cloneDeep} from "@scure/btc-signer/transaction";
import { PromiseQueue } from "promise-queue-ts";

const WAIT_BEFORE_BUMP = 15*1000;
const MIN_FEE_INCREASE_ABSOLUTE = 1n*1_000_000n; //0.001GWei
const MIN_FEE_INCREASE_PPM = 110_000n; // +11%

const MIN_TIP_INCREASE_ABSOLUTE = 1n*1_000_000_000n; //1GWei
const MIN_TIP_INCREASE_PPM = 110_000n; // +11%

/**
 * Configuration for the persistent signer
 *
 * @category Wallets
 */
export type StarknetPersistentSignerConfig = {
    /**
     * How long to wait for the transaction to confirm before bumping the fee (default 15,000ms = 15s)
     */
    waitBeforeBump?: number;
    /**
     * Minimum fee increment in absolute terms in base units of STRK token (default 1,000,000 = 0.001GFri)
     */
    minFeeIncreaseAbsolute?: bigint;
    /**
     * Minimum fee increase in PPM (parts per million, i.e. 10,000 = 1%) (default 110,000 = 11%)
     */
    minFeeIncreasePpm?: bigint;
    /**
     * Minimum tip increment in absolute terms in base units of STRK token (default 1,000,000,000 = 1GFri)
     */
    minTipIncreaseAbsolute?: bigint;
    /**
     * Minimum tip increase in PPM (parts per million, i.e. 10,000 = 1%) (default 110,000 = 11%)
     */
    minTipIncreasePpm?: bigint;
};

/**
 * A complex starknet signer implementation with internal nonce management, with race condition preventions,
 *  automatic transaction rebroadcasting and failovers. Uses the NodeJS `fs` library to persist transaction
 *  data across application restarts, hence this doesn't work on frontends and is intended to be used as a
 *  robust backend wallet implementation.
 *
 * @category Wallets
 */
export class StarknetPersistentSigner extends StarknetSigner {

    private pendingTxs: Map<bigint, {
        txs: StarknetTx[],
        lastBumped: number,
        sending?: boolean //Not saved
    }> = new Map();

    private confirmedNonce: bigint = 0n;
    private pendingNonce: bigint = 0n;

    private feeBumper: any;
    private stopped: boolean = false;

    private readonly directory: string;

    private readonly config: StarknetPersistentSignerConfig

    private readonly chainInterface: StarknetChainInterface;

    private readonly logger: LoggerType;

    constructor(
        account: Account,
        chainInterface: StarknetChainInterface,
        directory: string,
        config?: StarknetPersistentSignerConfig,
    ) {
        super(account, true);
        this.signTransaction = undefined;
        this.chainInterface = chainInterface;
        this.directory = directory;
        this.config = config ?? {};
        this.config.minFeeIncreaseAbsolute ??= MIN_FEE_INCREASE_ABSOLUTE;
        this.config.minFeeIncreasePpm ??= MIN_FEE_INCREASE_PPM;
        this.config.minTipIncreaseAbsolute ??= MIN_TIP_INCREASE_ABSOLUTE;
        this.config.minTipIncreasePpm ??= MIN_TIP_INCREASE_PPM;
        this.config.waitBeforeBump ??= WAIT_BEFORE_BUMP;
        this.logger = getLogger("StarknetPersistentSigner("+this.account.address+"): ");
    }

    private async load() {
        const fileExists = await access(this.directory+"/txs.json", constants.F_OK).then(() => true).catch(() => false);
        if(!fileExists) return;
        const res = await readFile(this.directory+"/txs.json");
        if(res!=null) {
            const pendingTxs: {
                [nonce: string]: {
                    txs: string[],
                    lastBumped: number
                }
            } = JSON.parse((res as Buffer).toString());

            for(let nonceStr in pendingTxs) {
                const nonceData = pendingTxs[nonceStr];

                const nonce = BigInt(nonceStr);
                if(this.confirmedNonce>=nonce) continue; //Already confirmed

                if(this.pendingNonce<nonce) {
                    this.pendingNonce = nonce;
                }
                const parsedPendingTxns = nonceData.txs.map(StarknetTransactions.deserializeTx);
                this.pendingTxs.set(nonce, {
                    txs: parsedPendingTxns,
                    lastBumped: nonceData.lastBumped
                })
                for(let tx of parsedPendingTxns) {
                    this.chainInterface.Transactions._knownTxSet.add(tx.txId!);
                }
            }
        }
    }

    private priorSavePromise?: Promise<void>;
    private saveCount: number = 0;

    private async save() {
        const pendingTxs: {
            [nonce: string]: {
                txs: string[],
                lastBumped: number
            }
        } = {};
        for(let [nonce, data] of this.pendingTxs) {
            pendingTxs[nonce.toString(10)] = {
                lastBumped: data.lastBumped,
                txs: data.txs.map(StarknetTransactions.serializeTx)
            };
        }
        const requiredSaveCount = ++this.saveCount;
        if(this.priorSavePromise!=null) {
            await this.priorSavePromise;
        }
        if(requiredSaveCount===this.saveCount) {
            this.priorSavePromise = writeFile(this.directory+"/txs.json", JSON.stringify(pendingTxs));
            await this.priorSavePromise;
        }
    }

    private async checkPastTransactions() {
        let _gasPrice: {l1GasCost: bigint, l2GasCost: bigint, l1DataGasCost: bigint} | null = null;
        let _safeBlockNonce: bigint | null = null;

        for(let [nonce, data] of this.pendingTxs) {
            if(!data.sending && data.lastBumped<Date.now()-this.config.waitBeforeBump!) {
                if(_safeBlockNonce==null) {
                    _safeBlockNonce = await this.chainInterface.Transactions.getNonce(this.account.address, BlockTag.LATEST);
                    this.confirmedNonce = _safeBlockNonce - 1n;
                }
                if(this.confirmedNonce >= nonce) {
                    this.pendingTxs.delete(nonce);
                    data.txs.forEach(tx => this.chainInterface.Transactions._knownTxSet.delete(tx.txId!));
                    this.logger.info("checkPastTransactions(): Tx confirmed, required fee bumps: ", data.txs.length);
                    this.save();
                    continue;
                }

                const lastTx = data.txs[data.txs.length-1];
                if(_gasPrice==null) {
                    const feeRate = await this.chainInterface.Fees.getFeeRate();
                    _gasPrice = StarknetFees.extractFromFeeRateString(feeRate);
                }

                let l1GasCost = BigInt(lastTx.details.resourceBounds.l1_gas.max_price_per_unit);
                let l2GasCost = BigInt(lastTx.details.resourceBounds.l2_gas.max_price_per_unit);
                let l1DataGasCost = BigInt(lastTx.details.resourceBounds.l1_data_gas.max_price_per_unit);
                let tip = BigInt(lastTx.details.tip);

                let feeBumped: boolean = false;
                if(_gasPrice.l1GasCost > l1GasCost) {
                    //Bump by minimum allowed or to the actual _gasPrice.l1GasCost
                    l1GasCost = bigIntMax(_gasPrice.l1GasCost, this.config.minFeeIncreaseAbsolute! + (l1GasCost * (1_000_000n + this.config.minFeeIncreasePpm!) / 1_000_000n));
                    feeBumped = true;
                }
                if(_gasPrice.l1DataGasCost > l1DataGasCost) {
                    //Bump by minimum allowed or to the actual _gasPrice.l1GasCost
                    l1DataGasCost = bigIntMax(_gasPrice.l1DataGasCost, this.config.minFeeIncreaseAbsolute! + (l1DataGasCost * (1_000_000n + this.config.minFeeIncreasePpm!) / 1_000_000n));
                    feeBumped = true;
                }
                if(_gasPrice.l2GasCost > l2GasCost || feeBumped) { //In case the fees for l1 and l1Data were bumped, we also need to bump the l2GasFee regardless
                    l2GasCost = bigIntMax(_gasPrice.l2GasCost, this.config.minFeeIncreaseAbsolute! + (l2GasCost * (1_000_000n + this.config.minFeeIncreasePpm!) / 1_000_000n));
                    feeBumped = true;
                }
                if(feeBumped) tip = this.config.minTipIncreaseAbsolute! + (tip * (1_000_000n + this.config.minTipIncreasePpm!) / 1_000_000n);

                if(!feeBumped) {
                    //Not fee bumped
                    this.logger.debug("checkPastTransactions(): Tx yet unconfirmed but not increasing fee for ", lastTx.txId);
                    //Rebroadcast the tx
                    await this.chainInterface.Transactions.sendTransaction(lastTx).catch(e => {
                        if(e.baseError?.code === 52) { //Invalid transaction nonce
                            this.logger.debug("checkPastTransactions(): Tx re-broadcast success, tx already confirmed: ", lastTx.txId);
                            return;
                        }
                        if(e.baseError?.code === 59) { //Transaction already in the mempool
                            this.logger.debug("checkPastTransactions(): Tx re-broadcast success, tx already known to the RPC: ", lastTx.txId);
                            return;
                        }
                        this.logger.error("checkPastTransactions(): Tx re-broadcast error", e)
                    });
                    data.lastBumped = Date.now();
                    continue;
                }

                const newTx = cloneDeep(lastTx);
                delete newTx.signed;
                delete newTx.txId;

                newTx.details.tip = tip;
                newTx.details.resourceBounds.l1_gas.max_price_per_unit = l1GasCost;
                newTx.details.resourceBounds.l2_gas.max_price_per_unit = l2GasCost;
                newTx.details.resourceBounds.l1_data_gas.max_price_per_unit = l1DataGasCost;

                await this._signTransaction(newTx);
                this.logger.info(`checkPastTransactions(): Bump fee for tx ${lastTx.txId} -> ${newTx.txId}`);

                //Double check pending txns still has nonce after async signTransaction was called
                if(!this.pendingTxs.has(nonce)) continue;

                for(let callback of this.chainInterface.Transactions._cbksBeforeTxReplace) {
                    try {
                        await callback(StarknetTransactions.serializeTx(lastTx), lastTx.txId!, StarknetTransactions.serializeTx(newTx), newTx.txId!)
                    } catch (e) {
                        this.logger.error("checkPastTransactions(): beforeTxReplace callback error: ", e);
                    }
                }

                data.txs.push(newTx);
                data.lastBumped = Date.now();
                this.save();

                this.chainInterface.Transactions._knownTxSet.add(newTx.txId!);

                //TODO: Better error handling when sending tx
                await this.chainInterface.Transactions.sendTransaction(newTx).catch(e => {
                    if(e.baseError?.code === 52) { //Invalid transaction nonce
                        return
                    }
                    this.logger.error("checkPastTransactions(): Fee-bumped tx broadcast error", e)
                });
            }
        }
    }

    private startFeeBumper() {
        let func: () => Promise<void>;
        func = async () => {
            try {
                await this.checkPastTransactions();
            } catch (e) {
                this.logger.error("startFeeBumper(): Error when check past transactions: ", e);
            }

            if(this.stopped) return;

            this.feeBumper = setTimeout(func, 1000);
        };
        func();
    }

    private async syncNonceFromChain() {
        const txCount = await this.chainInterface.Transactions.getNonce(this.account.address, BlockTag.LATEST);
        this.confirmedNonce = txCount-1n;
        if(this.pendingNonce < this.confirmedNonce) {
            this.logger.info(`syncNonceFromChain(): Re-synced latest nonce from chain, adjusting local pending nonce ${this.pendingNonce} -> ${this.confirmedNonce}`);
            this.pendingNonce = this.confirmedNonce;
            for(let [nonce, data] of this.pendingTxs) {
                if(nonce <= this.pendingNonce) {
                    this.pendingTxs.delete(nonce);
                    data.txs.forEach(tx => this.chainInterface.Transactions._knownTxSet.delete(tx.txId!));
                    this.logger.info(`syncNonceFromChain(): Tx confirmed, nonce: ${nonce}, required fee bumps: `, data.txs.length);
                }
            }
            this.save();
        }
    }

    /**
     * @inheritDoc
     */
    async init(): Promise<void> {
        try {
            await mkdir(this.directory)
        } catch (e) {}

        const nonce = await this.chainInterface.Transactions.getNonce(this.account.address, BlockTag.LATEST);
        this.confirmedNonce = nonce - 1n;
        this.pendingNonce = nonce - 1n;

        await this.load();

        this.startFeeBumper();
    }

    /**
     * @inheritDoc
     */
    stop(): Promise<void> {
        this.stopped = true;
        if(this.feeBumper!=null) {
            clearTimeout(this.feeBumper);
            this.feeBumper = null;
        }
        return Promise.resolve();
    }

    private readonly sendTransactionQueue: PromiseQueue = new PromiseQueue();

    /**
     * Signs and sends the starknet transaction, the `onBeforePublish` callback is called after the transaction
     *  is signed and before it is broadcast. Ensures that transactions are always sent in order by using a
     *  "single-threaded" promise queue, and no nonce collision happen.
     *
     * @param transaction A transaction to sign and send
     * @param onBeforePublish A callback that is called before the transaction gets broadcasted
     */
    sendTransaction(transaction: StarknetTx, onBeforePublish?: (txId: string, rawTx: string) => Promise<void>): Promise<string> {
        return this.sendTransactionQueue.enqueue(async () => {
            if(transaction.details.nonce!=null) {
                if(transaction.details.nonce !== this.pendingNonce + 1n)
                    throw new Error("Invalid transaction nonce!");
            } else {
                transaction.details.nonce = this.pendingNonce + 1n;
            }

            const signedTx = await this._signTransaction(transaction);

            if(onBeforePublish!=null) {
                try {
                    await onBeforePublish(signedTx.txId!, StarknetTransactions.serializeTx(signedTx));
                } catch (e) {
                    this.logger.error("sendTransaction(): Error when calling onBeforePublish function: ", e);
                }
            }

            const pendingTxObject = {txs: [signedTx], lastBumped: Date.now(), sending: true};
            this.pendingNonce++;
            this.logger.debug("sendTransaction(): Incrementing pending nonce to: ", this.pendingNonce);
            this.pendingTxs.set(transaction.details.nonce, pendingTxObject);
            this.save();

            this.chainInterface.Transactions._knownTxSet.add(signedTx.txId!);

            try {
                const result = await this.chainInterface.Transactions.sendTransaction(signedTx);
                pendingTxObject.sending = false;
                return result;
            } catch (e: any) {
                this.chainInterface.Transactions._knownTxSet.delete(signedTx.txId!);
                this.pendingTxs.delete(transaction.details.nonce);
                this.pendingNonce--;
                this.logger.debug("sendTransaction(): Error when broadcasting transaction, reverting pending nonce to: ", this.pendingNonce);
                if(e.baseError?.code === 52) { //Invalid transaction nonce
                    //Re-check nonce from on-chain
                    this.logger.info("sendTransaction(): Got INVALID_TRANSACTION_NONCE (52) back from backend, re-checking latest nonce from chain!");
                    await this.syncNonceFromChain();
                }
                throw e;
            }
        });
    }

}