import {SwapType} from "../enums/SwapType";
import {EventEmitter} from "events";
import {ISwapWrapper, SwapTypeDefinition} from "./ISwapWrapper";
import {ChainType} from "@atomiqlabs/base";
import {randomBytes, toBigInt} from "../utils/Utils";
import {SwapDirection} from "../enums/SwapDirection";
import {Fee} from "../types/fees/Fee";
import {FeeBreakdown} from "../types/fees/FeeBreakdown";
import {PercentagePPM, ppmToPercentage} from "../types/fees/PercentagePPM";
import {TokenAmount} from "../types/TokenAmount";
import {isSCToken, Token} from "../types/Token";
import {SwapExecutionAction} from "../types/SwapExecutionAction";
import {LoggerType} from "../utils/Logger";
import {isPriceInfoType, PriceInfoType} from "../types/PriceInfoType";
import {SwapStateInfo} from "../types/SwapStateInfo";
import {SwapExecutionStep} from "../types/SwapExecutionStep";

/**
 * Initialization data for creating a swap
 *
 * @category Swaps/Base
 */
export type ISwapInit = {
    pricingInfo: PriceInfoType,
    url?: string,
    expiry: number,
    swapFee: bigint,
    swapFeeBtc: bigint,
    exactIn: boolean,
    contractVersion: string
};

/**
 * Type guard to check if an object is an ISwapInit
 *
 * @category Swaps/Base
 */
export function isISwapInit(obj: any): obj is ISwapInit {
    return typeof obj === 'object' &&
        obj != null &&
        isPriceInfoType(obj.pricingInfo) &&
        (obj.url==null || typeof obj.url === 'string') &&
        typeof obj.expiry === 'number' &&
        typeof(obj.swapFee) === "bigint" &&
        typeof(obj.swapFeeBtc) === "bigint" &&
        (typeof obj.exactIn === 'boolean');
}

/**
 * Base abstract class for all swap types
 *
 * @category Swaps/Base
 */
export abstract class ISwap<
    T extends ChainType = ChainType,
    D extends SwapTypeDefinition<T, ISwapWrapper<T, D>, ISwap<T, D, S>> = SwapTypeDefinition<T, ISwapWrapper<T, any>, ISwap<T, any, any>>,
    S extends number = number
> {
    /**
     * Swap type
     */
    protected readonly abstract TYPE: SwapType;

    /**
     * Description for the states
     * @internal
     */
    protected readonly abstract swapStateDescription: Record<S, string>;
    /**
     * Name of the states
     * @internal
     */
    protected readonly abstract swapStateName: (state: number) => string;
    /**
     * Swap logger
     * @internal
     */
    protected readonly abstract logger: LoggerType;
    /**
     * Current newest defined version of the swap
     * @internal
     */
    protected readonly currentVersion: number = 1;
    /**
     * Wrapper instance holding this swap
     * @internal
     */
    protected readonly wrapper: D["Wrapper"];


    /**
     * The current version of the swap
     * @internal
     */
    protected version: number;
    /**
     * Whether a swap was initialized, a swap is considered initialize on first interaction with it, i.e.
     *  calling commit() on a Smart chain -> Bitcoin swaps, calling waitForPayment() or similar on the other
     *  direction. Not initiated swaps are not saved to the persistent storage by default (see
     *  {@link SwapperOptions.saveUninitializedSwaps})
     * @internal
     */
    protected initiated: boolean = false;
    /**
     * Expiration of the swap quote
     * @internal
     */
    protected expiry: number;
    /**
     * Pricing information of the swap
     * @internal
     */
    protected pricingInfo?: PriceInfoType;
    /**
     * Swap fee in the non-bitcoin token
     * @internal
     */
    protected swapFee: bigint;
    /**
     * Swap fee in bitcoin satoshis
     * @internal
     */
    protected swapFeeBtc: bigint;


    /**
     * Swap state
     * @internal
     */
    _state: S = 0 as S;
    /**
     * Random nonce to differentiate the swap from others with the same identifier hash (i.e. when quoting the same swap
     *  from multiple LPs)
     * @internal
     */
    _randomNonce: string;
    /**
     * Whether the swap is saved in the persistent storage or not.
     *
     * @remarks This field itself is not persisted but is instead derived during runtime
     *
     * @internal
     */
    _persisted: boolean = false;
    /**
     * @internal
     */
    _contractVersion?: string;
    /**
     * Storage specific metadata that can be used for e.g. optimistic concurrency
     *
     * @internal
     */
    _meta?: any;


    /**
     * Event emitter emitting `"swapState"` event when swap's state changes
     */
    readonly events: EventEmitter<{swapState: [D["Swap"]]}> = new EventEmitter();
    /**
     * URL of the intermediary (LP) used for this swap, already has the swap service specific path appended
     */
    readonly url?: string;
    /**
     * Smart chain identifier string corresponding to this swap
     */
    readonly chainIdentifier: T["ChainId"];
    /**
     * Whether a swap is an exact input swap
     */
    readonly exactIn: boolean;
    /**
     * A UNIX milliseconds timestamps of when this swap was created
     */
    createdAt: number;

    protected constructor(wrapper: D["Wrapper"], obj: any);
    protected constructor(wrapper: D["Wrapper"], swapInit: ISwapInit);
    protected constructor(
        wrapper: D["Wrapper"],
        swapInitOrObj: ISwapInit | any,
    ) {
        this.chainIdentifier = wrapper.chainIdentifier;
        this.wrapper = wrapper;
        if(isISwapInit(swapInitOrObj)) {
            this.pricingInfo = swapInitOrObj.pricingInfo;
            this.url = swapInitOrObj.url;
            this.expiry = swapInitOrObj.expiry;
            this.swapFee = swapInitOrObj.swapFee;
            this.swapFeeBtc = swapInitOrObj.swapFeeBtc;
            this.exactIn = swapInitOrObj.exactIn;
            this.version = this.currentVersion;
            this.createdAt = Date.now();
            this._randomNonce = randomBytes(16).toString("hex");
            this._contractVersion = swapInitOrObj.contractVersion;
        } else {
            this.expiry = swapInitOrObj.expiry;
            this.url = swapInitOrObj.url;

            this._state = swapInitOrObj.state;

            if(
                swapInitOrObj._isValid!=null && swapInitOrObj._differencePPM!=null && swapInitOrObj._satsBaseFee!=null &&
                swapInitOrObj._feePPM!=null && swapInitOrObj._swapPriceUSatPerToken!=null
            ) {
                this.pricingInfo = {
                    isValid: swapInitOrObj._isValid,
                    differencePPM: BigInt(swapInitOrObj._differencePPM),
                    satsBaseFee: BigInt(swapInitOrObj._satsBaseFee),
                    feePPM: BigInt(swapInitOrObj._feePPM),
                    realPriceUSatPerToken: toBigInt(swapInitOrObj._realPriceUSatPerToken),
                    realPriceUsdPerBitcoin: swapInitOrObj._realPriceUsdPerBitcoin,
                    swapPriceUSatPerToken: BigInt(swapInitOrObj._swapPriceUSatPerToken),
                };
            }

            this.swapFee = toBigInt(swapInitOrObj.swapFee);
            this.swapFeeBtc = toBigInt(swapInitOrObj.swapFeeBtc);

            this.version = swapInitOrObj.version;
            this.initiated = swapInitOrObj.initiated;
            this.exactIn = swapInitOrObj.exactIn;
            this.createdAt = swapInitOrObj.createdAt ?? swapInitOrObj.expiry;

            this._randomNonce = swapInitOrObj.randomNonce;
            this._contractVersion = swapInitOrObj.contractVersion;
            this._meta = swapInitOrObj._meta;
        }
        if(this.version!==this.currentVersion) {
            this.upgradeVersion();
        }
        if(this.initiated==null) this.initiated = true;
    }

    /**
     * Called when swap is deserialized to potentially update the version of the data for the swap
     *
     * @internal
     */
    protected abstract upgradeVersion(): void;

    /**
     * Waits till the swap reaches a specific state
     *
     * @param targetState The state to wait for
     * @param type Whether to wait for the state exactly or also to a state with a higher number
     * @param abortSignal Abort signal
     * @internal
     */
    protected waitTillState(targetState: S, type: "eq" | "gte" | "neq" = "eq", abortSignal?: AbortSignal): Promise<void> {
        //TODO: This doesn't hold strong reference to the swap, hence if no other strong reference to the
        // swap exists, it will just never resolve!
        return new Promise((resolve, reject) => {
            let listener: () => void;
            listener = () => {
                if(type==="eq" ? this._state===targetState : type==="gte" ? this._state>=targetState : this._state!=targetState) {
                    resolve();
                    this.events.removeListener("swapState", listener);
                }
            };
            this.events.on("swapState", listener);
            if(abortSignal!=null) abortSignal.addEventListener("abort", () => {
                this.events.removeListener("swapState", listener);
                reject(abortSignal.reason);
            });
        });
    }

    /**
     * Executes the swap with the provided wallet, the exact arguments for this functions differ for various swap
     *  types. Check the `execute()` function signature in the respective swap class to see the required arguments.
     *
     * @param args Execution arguments, usually contains a source wallet object, callbacks and options, for exact
     *  syntax check the respective swap class.
     *
     * @returns Whether a swap was successfully executed or not, if it wasn't the user can refund or claim manually
     */
    public abstract execute(...args: any[]): Promise<boolean>;

    //////////////////////////////
    //// Pricing

    /**
     * This attempts to populate missing fields in the pricing info based on the swap amounts
     *
     * @internal
     */
    protected tryRecomputeSwapPrice(): void {
        if(this.pricingInfo==null) return;
        if(this.pricingInfo.swapPriceUSatPerToken==null) {
            const priceUsdPerBtc = this.pricingInfo.realPriceUsdPerBitcoin;
            const input = this.getInput();
            const output = this.getOutput();
            if(input.isUnknown || output.isUnknown) return;
            if(isSCToken(input.token) && this.getDirection()===SwapDirection.TO_BTC) {
                this.pricingInfo = this.wrapper._prices.recomputePriceInfoSend(
                    this.chainIdentifier,
                    output.rawAmount!,
                    this.pricingInfo.satsBaseFee,
                    this.pricingInfo.feePPM,
                    input.rawAmount!,
                    input.token.address
                );
                this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
            } else if(isSCToken(output.token) && this.getDirection()===SwapDirection.FROM_BTC) {
                this.pricingInfo = this.wrapper._prices.recomputePriceInfoReceive(
                    this.chainIdentifier,
                    input.rawAmount!,
                    this.pricingInfo.satsBaseFee,
                    this.pricingInfo.feePPM,
                    output.rawAmount!,
                    output.token.address
                );
                this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
            }
        }
    }

    /**
     * Returns the specific state along with the human-readable description of that state
     *
     * @internal
     */
    protected _getStateInfo(state: S): SwapStateInfo<S> {
        return {
            state: state,
            name: this.swapStateName(state),
            description: this.swapStateDescription[state]
        };
    }

    /**
     * Re-fetches & revalidates the price data based on the current market prices
     */
    public async refreshPriceData(): Promise<void> {
        if(this.pricingInfo==null) return;
        const priceUsdPerBtc = this.pricingInfo.realPriceUsdPerBitcoin;
        const output = this.getOutput();
        if(output.isUnknown) return;

        if(isSCToken(this.getInputToken()) && this.getDirection()===SwapDirection.TO_BTC) {
            const input = this.getInputWithoutFee();
            if(input.isUnknown) return;

            this.pricingInfo = await this.wrapper._prices.isValidAmountSend(
                this.chainIdentifier,
                output.rawAmount!,
                this.pricingInfo.satsBaseFee,
                this.pricingInfo.feePPM,
                input.rawAmount! + this.swapFee,
                input.token.address,
                undefined,
                undefined,
                this.swapFeeBtc
            );
            this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
        } else if(isSCToken(output.token) && this.getDirection()===SwapDirection.FROM_BTC) {
            const input = this.getInput();
            if(input.isUnknown) return;

            this.pricingInfo = await this.wrapper._prices.isValidAmountReceive(
                this.chainIdentifier,
                input.rawAmount!,
                this.pricingInfo.satsBaseFee,
                this.pricingInfo.feePPM,
                output.rawAmount!,
                output.token.address,
                undefined,
                undefined,
                this.swapFeeBtc
            );
            this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
        }
    }

    /**
     * Checks if the pricing for the swap is valid, according to max allowed price difference set in the ISwapPrice
     */
    public hasValidPrice(): boolean {
        if(this.pricingInfo==null) throw new Error("Pricing info not found, cannot check price validity!");
        return this.pricingInfo.isValid;
    }

    /**
     * Returns pricing info about the swap
     */
    public getPriceInfo(): {
        marketPrice?: number,
        swapPrice: number,
        difference: PercentagePPM
    } {
        if(this.pricingInfo==null) throw new Error("Pricing info not provided and not known!");

        const swapPrice = this.getDirection()===SwapDirection.TO_BTC ?
            100_000_000_000_000/Number(this.pricingInfo.swapPriceUSatPerToken) :
            Number(this.pricingInfo.swapPriceUSatPerToken)/100_000_000_000_000;

        let marketPrice: number | undefined;
        if(this.pricingInfo.realPriceUSatPerToken!=null)
            marketPrice = this.getDirection()===SwapDirection.TO_BTC ?
                100_000_000_000_000/Number(this.pricingInfo.realPriceUSatPerToken) :
                Number(this.pricingInfo.realPriceUSatPerToken)/100_000_000_000_000;

        return {
            marketPrice,
            swapPrice,
            difference: ppmToPercentage(this.pricingInfo.differencePPM)
        }
    }


    //////////////////////////////
    //// Getters & utils

    /**
     * Asserts a given signer is the initiator of this swap
     *
     * @param signer Signer to check with this swap's initiator
     * @throws {Error} When signer's address doesn't match with the swap's initiator one
     * @internal
     */
    protected checkSigner(signer: T["Signer"] | string): void {
        if((typeof(signer)==="string" ? signer : signer.getAddress())!==this._getInitiator()) throw new Error("Invalid signer provided!");
    }

    /**
     * Await and prepares a list of passed transactions
     *
     * @param txsPromise
     * @internal
     */
    protected async prepareTransactions(txsPromise: Promise<T["TX"][]>): Promise<T["TX"][]> {
        const txs = await txsPromise;
        if(this.wrapper._chain.prepareTxs==null) return txs;
        return await this.wrapper._chain.prepareTxs(txs);
    }

    /**
     * Returns an escrow hash of the swap
     *
     * @internal
     */
    abstract _getEscrowHash(): string | null;

    /**
     * Checks if the swap's quote is expired for good (i.e. the swap strictly cannot be initiated anymore)
     * @internal
     */
    abstract _verifyQuoteDefinitelyExpired(): Promise<boolean>;

    /**
     * Checks if the swap's quote is still valid
     * @internal
     */
    abstract _verifyQuoteValid(): Promise<boolean>;

    /**
     * Returns the intiator address of the swap - address that created this swap
     * @internal
     */
    abstract _getInitiator(): string;

    /**
     * Sets this swap as initiated
     * @internal
     */
    _setInitiated(): void {
        this.initiated = true;
    }

    /**
     * Returns source address of the swap
     */
    public abstract getInputAddress(): string | null;

    /**
     * Returns destination address of the swap
     */
    public abstract getOutputAddress(): string | null;

    /**
     * Returns swap input transaction ID on the source chain
     */
    public abstract getInputTxId(): string | null;

    /**
     * Returns swap output transaction ID on the destination chain
     */
    public abstract getOutputTxId(): string | null;

    /**
     * Returns the ID of the swap, as used in the storage
     */
    public abstract getId(): string;

    /**
     * Checks whether there is some action required from the user for this swap - can mean either refundable or claimable
     */
    public abstract requiresAction(): boolean;

    /**
     * Returns whether the swap is finished and in its terminal state (this can mean successful, refunded or failed)
     */
    public abstract isFinished(): boolean;

    /**
     * Checks whether the swap's quote has definitely expired and cannot be committed anymore, we can remove such swap
     */
    public abstract isQuoteExpired(): boolean;

    /**
     * Checks whether the swap's quote is soft expired (this means there is not enough time buffer for it to commit,
     *  but it still can happen)
     */
    public abstract isQuoteSoftExpired(): boolean;

    /**
     * Returns whether the swap finished successful
     */
    public abstract isSuccessful(): boolean;

    /**
     * Returns whether the swap failed (e.g. was refunded)
     */
    public abstract isFailed(): boolean;

    /**
     * Returns whether the swap is currently being processed
     */
    public abstract isInProgress(): boolean;

    /**
     * Whether a swap was initialized, a swap is considered initialized on first interaction with it, i.e.
     *  calling commit() on a Smart chain -> Bitcoin swaps, calling waitForPayment() or similar on the other
     *  direction. Not initiated swaps are not saved to the persistent storage by default (see
     *  {@link SwapperOptions.saveUninitializedSwaps})
     */
    public isInitiated(): boolean {
        return this.initiated;
    }

    /**
     * Returns quote expiry in UNIX millis
     */
    public getQuoteExpiry(): number {
        return this.expiry;
    }

    /**
     * Returns the type of the swap
     */
    public getType(): SwapType {
        return this.TYPE;
    }

    /**
     * Returns the direction of the swap
     */
    public getDirection(): SwapDirection {
        return this.TYPE===SwapType.TO_BTC || this.TYPE===SwapType.TO_BTCLN ? SwapDirection.TO_BTC : SwapDirection.FROM_BTC;
    }

    /**
     * Returns the current state of the swap
     */
    public getState(): S {
        return this._state;
    }

    /**
     * Returns the current state of the swap along with the human-readable description of the state
     */
    public getStateInfo(): SwapStateInfo<S> {
        return this._getStateInfo(this._state);
    }

    /**
     * Returns a current state-dependent action for the user to execute, or `undefined` if there is no more action
     *  required for this swap - this means that the swap is probably finished (either expired, failed or settled).
     *
     * @param options Optional options argument for the additional action context (i.e. passing bitcoin wallet info to
     *  get funded PSBTs or passing the externally-generated swap secret), see the actual type in the respective swap
     *  classes
     */
    public abstract getExecutionAction(options?: any): Promise<SwapExecutionAction | undefined>;

    /**
     * Returns a list of execution steps the user has to go through for a given swap, to see the possible execution
     *  steps check out {@link SwapExecutionStep}.
     *
     * @param options Optional options argument for the additional steps context (i.e. automatic settlement timeout),
     *  see the actual type in the respective swap classes
     */
    public abstract getExecutionSteps(options?: any): Promise<SwapExecutionStep[]>;

    /**
     * Returns the current action and the full execution steps for a given swap. Prefer this to calling
     *  {@link getExecutionSteps} and {@link getExecutionAction} separately - if called sequentially they might
     *  return the respective steps/actions in different states if you hit the state transition boundary.
     *
     * @param options Optional options argument for the additional execution status context, see the actual type in
     *  the respective swap classes
     */
    public abstract getExecutionStatus(options?: { skipBuildingAction?: boolean } & any): Promise<{
        steps: SwapExecutionStep[],
        currentAction: SwapExecutionAction | undefined,
        stateInfo: SwapStateInfo<S>
    }>;

    /**
     * Submits signed transactions obtained from the execution action back to the swap.
     *
     * @remarks This endpoint will also wait till the submitted transactions are confirmed (on a smart-chain side)
     *  and till the swap state change is observed from the authoritative chain/intermediary state.
     *
     *  If invalid transactions are submitted, i.e. sending a simple noop or transfer transaction instead of the
     *  expected tx, this call may wait indefinitely unless aborted via the AbortSignal.
     *
     * @param txs Signed transactions
     * @param abortSignal Abort signal
     * @param requiredStates Optional list of states that the swap has to be in for the transactions to be
     *  submitted, else throws
     * @param idempotent Whether the tx submission should be handled idempotently, meaning if any of the supplied
     *  transactions are already processed as e.g. init, claim, refund or execution transactions the function just
     *  returns these transaction IDs without actually submitting them
     *
     * @internal
     */
    abstract _submitExecutionTransactions(txs: (T["SignedTXType"] | string | any)[], abortSignal?: AbortSignal, requiredStates?: S[], idempotent?: boolean): Promise<string[]>;

    //////////////////////////////
    //// Amounts & fees

    /**
     * Returns output amount of the swap, user receives this much
     */
    public abstract getOutput(): TokenAmount;

    /**
     * Returns the output token of the swap
     */
    public abstract getOutputToken(): Token<T["ChainId"]>;

    /**
     * Returns input amount of the swap, user needs to pay this much
     */
    public abstract getInput(): TokenAmount;

    /**
     * Returns the input token of the swap
     */
    public abstract getInputToken(): Token<T["ChainId"]>;

    /**
     * Returns input amount of the swap without the fees (swap fee, network fee)
     */
    public abstract getInputWithoutFee(): TokenAmount;

    /**
     * Returns total fee for the swap, the fee is represented in source currency & destination currency, but is
     *  paid only once
     */
    public abstract getFee(): Fee;

    /**
     * Returns the breakdown of all the fees paid
     */
    public abstract getFeeBreakdown(): FeeBreakdown<T["ChainId"]>;


    //////////////////////////////
    //// Storage

    /**
     * Saves the swap data to the underlying storage, or removes it if it is in a quote expired state
     *
     * @internal
     */
    _save(): Promise<void> {
        if(this.isQuoteExpired()) {
            return this.wrapper._removeSwapData(this);
        } else {
            return this.wrapper._saveSwapData(this);
        }
    }

    /**
     * Saves the swap data and also emits a swap state change
     *
     * @param state Optional state to set before the swap is saved an event emitted
     *
     * @internal
     */
    async _saveAndEmit(state?: S): Promise<void> {
        if(state!=null) this._state = state;
        await this._save();
        this._emitEvent();
    }

    /**
     * Serializes the swap to a JSON stringifiable representation (i.e. no bigints, buffers etc.)
     */
    public serialize(): any {
        if(this.pricingInfo==null) return {};
        return {
            id: this.getId(),
            type: this.getType(),
            escrowHash: this._getEscrowHash(),
            initiator: this._getInitiator(),

            _isValid: this.pricingInfo.isValid,
            _differencePPM: this.pricingInfo.differencePPM==null ? null :this.pricingInfo.differencePPM.toString(10),
            _satsBaseFee: this.pricingInfo.satsBaseFee==null ? null :this.pricingInfo.satsBaseFee.toString(10),
            _feePPM: this.pricingInfo.feePPM==null ? null :this.pricingInfo.feePPM.toString(10),
            _realPriceUSatPerToken: this.pricingInfo.realPriceUSatPerToken==null ? null :this.pricingInfo.realPriceUSatPerToken.toString(10),
            _realPriceUsdPerBitcoin: this.pricingInfo.realPriceUsdPerBitcoin,
            _swapPriceUSatPerToken: this.pricingInfo.swapPriceUSatPerToken==null ? null :this.pricingInfo.swapPriceUSatPerToken.toString(10),
            state: this._state,
            url: this.url,
            swapFee: this.swapFee==null ? null : this.swapFee.toString(10),
            swapFeeBtc: this.swapFeeBtc==null ? null : this.swapFeeBtc.toString(10),
            expiry: this.expiry,
            version: this.version,
            initiated: this.initiated,
            exactIn: this.exactIn,
            createdAt: this.createdAt,
            randomNonce: this._randomNonce,
            contractVersion: this._contractVersion,

            _meta: this._meta
        }
    }


    //////////////////////////////
    //// Events

    /**
     * Emits a `swapState` event with the current swap
     *
     * @internal
     */
    _emitEvent() {
        this.wrapper.events.emit("swapState", this);
        this.events.emit("swapState", this);
    }


    //////////////////////////////
    //// Swap ticks & sync

    /**
     * Synchronizes swap state from chain and/or LP node, usually ran on startup
     *
     * @param save whether to save the new swap state or not
     *
     * @returns {boolean} true if the swap changed, false if the swap hasn't changed
     *
     * @internal
     */
    abstract _sync(save?: boolean): Promise<boolean>;

    /**
     * Runs quick checks on the swap, such as checking the expiry, usually ran periodically every few seconds
     *
     * @param save whether to save the new swap state or not
     *
     * @returns {boolean} true if the swap changed, false if the swap hasn't changed
     *
     * @internal
     */
    abstract _tick(save?: boolean): Promise<boolean>;

}
