import { ApiPromise } from "@polkadot/api";
import { Option, Bool } from "@polkadot/types";
import { Moment } from "@polkadot/types/interfaces";
import Big from "big.js";
import { Bitcoin, BitcoinUnit, Currency, ExchangeRate, MonetaryAmount } from "@interlay/monetary-js";
import { SecurityErrorCode, InterbtcPrimitivesOracleKey } from "@polkadot/types/lookup";

import {
    convertMoment,
    createExchangeRateOracleKey,
    createInclusionOracleKey,
    decodeFixedPointType,
    encodeUnsignedFixedPoint,
    sleep,
    SLEEP_TIME_MS,
    storageKeyToNthInner,
    unwrapRawExchangeRate,
} from "../utils";
import { UnsignedFixedPoint } from "../interfaces/default";
import { TransactionAPI } from "./transaction";
import { CurrencyUnit, WrappedCurrency } from "../types/currency";
import { FeeEstimationType } from "../types/oracleTypes";

export const DEFAULT_FEED_NAME = "DOT/BTC";
export const DEFAULT_INCLUSION_TIME: FeeEstimationType = "Fast";

/**
 * @category BTC Bridge
 */
export interface OracleAPI {
    /**
     * @param currency The collateral currency as a `Monetary.js` object
     * @param wrappedCurrency The wrapped currency to use in the returned exchange rate type, defaults to `Bitcoin`
     * @returns The exchange rate between Bitcoin and the provided collateral currency
     */
    getExchangeRate<C extends CurrencyUnit>(
        collateralCurrency: Currency<C>,
        wrappedCurrency?: Currency<BitcoinUnit>
    ): Promise<ExchangeRate<Currency<BitcoinUnit>, BitcoinUnit, Currency<C>, C>>;
    /**
     * Obtains the current fees for BTC transactions, in satoshi/byte.
     * @returns Big value for the current inclusion fees.
     */
    getBitcoinFees(): Promise<Big>;
    /**
     * @returns Last exchange rate time
     */
    getValidUntil<U extends CurrencyUnit>(counterCurrency: Currency<U>): Promise<Date>;
    /**
     * @returns A map from the oracle's account id to its name
     */
    getSourcesById(): Promise<Map<string, string>>;
    /**
     * @returns Boolean value indicating whether the oracle is online
     */
    isOnline(): Promise<boolean>;
    /**
     * Send a transaction to set the exchange rate between Bitcoin and a collateral currency
     * @param exchangeRate The rate to set
     */
    setExchangeRate<C extends CurrencyUnit>(
        exchangeRate: ExchangeRate<Bitcoin, BitcoinUnit, Currency<C>, C>
    ): Promise<void>;
    /**
     * Send a transaction to set the current fee estimate for BTC transactions
     * @param fees Estimated Satoshis per bytes to get a transaction included
     */
    setBitcoinFees(fees: Big): Promise<void>;
    /**
     * @param amount The amount of wrapped tokens to convert
     * @param currency A `Monetary.js` object
     * @returns Converted value
     */
    convertWrappedToCurrency<C extends CurrencyUnit>(
        amount: MonetaryAmount<Currency<BitcoinUnit>, BitcoinUnit>,
        currency: Currency<C>
    ): Promise<MonetaryAmount<Currency<C>, C>>;
    /**
     * @param amount The amount of collateral tokens to convert
     * @returns Converted value
     */
    convertCollateralToWrapped<C extends CurrencyUnit>(
        amount: MonetaryAmount<Currency<C>, C>
    ): Promise<MonetaryAmount<Currency<BitcoinUnit>, BitcoinUnit>>;
    /**
     * @returns The period of time (in milliseconds) after an oracle's last submission
     * during which it is considered online
     */
    getOnlineTimeout(): Promise<number>;
    /**
     * @param key A key defining an exchange rate or a BTC network fee estimate
     * @returns Whether the oracle entr for the given key has been updated
     */
    getRawValuesUpdated(key: InterbtcPrimitivesOracleKey): Promise<boolean>;
    /**
     * @param type The fee estimate type whose update is awaited
     * @remark Awaits an oracle update to the BTC inclusion fee
     */
    waitForFeeEstimateUpdate(type?: FeeEstimationType): Promise<void>;
    /**
     * @param exchangeRate The exchange rate whose counter currency to await an update for
     * (with respect to BTC)
     * @remark Awaits an oracle update to the exchange rate
     */
    waitForExchangeRateUpdate<C extends CurrencyUnit, U extends BitcoinUnit>(
        exchangeRate: ExchangeRate<Currency<U>, U, Currency<C>, C>
    ): Promise<void>;
}

export class DefaultOracleAPI implements OracleAPI {
    constructor(
        private api: ApiPromise,
        private wrappedCurrency: WrappedCurrency,
        private transactionAPI: TransactionAPI
    ) {}

    async getExchangeRate<C extends CurrencyUnit>(
        collateralCurrency: Currency<C>
    ): Promise<ExchangeRate<Currency<BitcoinUnit>, BitcoinUnit, Currency<C>, C>> {
        const oracleKey = createExchangeRateOracleKey(this.api, collateralCurrency);

        const encodedRawRate = unwrapRawExchangeRate(await this.api.query.oracle.aggregate(oracleKey));
        if (encodedRawRate === undefined) {
            return Promise.reject(new Error(`No exchange rate for given currency: ${collateralCurrency.ticker}`));
        }
        const decodedRawRate = decodeFixedPointType(encodedRawRate);
        return new ExchangeRate<Currency<BitcoinUnit>, BitcoinUnit, Currency<C>, C>(
            this.wrappedCurrency,
            collateralCurrency,
            decodedRawRate,
            this.wrappedCurrency.rawBase,
            collateralCurrency.rawBase
        );
    }

    async convertWrappedToCurrency<C extends CurrencyUnit>(
        amount: MonetaryAmount<Currency<BitcoinUnit>, BitcoinUnit>,
        currency: Currency<C>
    ): Promise<MonetaryAmount<Currency<C>, C>> {
        const rate = await this.getExchangeRate(currency);
        return rate.toCounter(amount);
    }

    async convertCollateralToWrapped<C extends CurrencyUnit>(
        amount: MonetaryAmount<Currency<C>, C>
    ): Promise<MonetaryAmount<Currency<BitcoinUnit>, BitcoinUnit>> {
        const rate = await this.getExchangeRate(amount.currency);
        return rate.toBase(amount);
    }

    async getOnlineTimeout(): Promise<number> {
        const moment = await this.api.query.oracle.maxDelay();
        return moment.toNumber();
    }

    async setExchangeRate<C extends CurrencyUnit>(
        exchangeRate: ExchangeRate<Bitcoin, BitcoinUnit, Currency<C>, C>
    ): Promise<void> {
        const encodedExchangeRate = encodeUnsignedFixedPoint(
            this.api,
            exchangeRate.toBig({
                baseUnit: exchangeRate.base.rawBase,
                counterUnit: exchangeRate.counter.rawBase,
            })
        );
        const oracleKey = createExchangeRateOracleKey(this.api, exchangeRate.counter);
        const tx = this.api.tx.oracle.feedValues([[oracleKey, encodedExchangeRate]]);
        await this.transactionAPI.sendLogged(tx, this.api.events.oracle.FeedValues, true);
    }

    async getBitcoinFees(): Promise<Big> {
        const fast = createInclusionOracleKey(this.api, DEFAULT_INCLUSION_TIME);
        const fees = await this.api.query.oracle.aggregate(fast);

        const parseFees = (fee: Option<UnsignedFixedPoint>): Big => {
            const inner = unwrapRawExchangeRate(fee);
            if (inner !== undefined) {
                return decodeFixedPointType(inner);
            }
            throw new Error("Bitcoin fee estimate not set");
        };

        return parseFees(fees);
    }

    async setBitcoinFees(fees: Big): Promise<void> {
        if (!fees.round().eq(fees)) {
            throw new Error("tx fees must be an integer amount of satoshi");
        } else if (fees.lt(0)) {
            throw new Error("tx fees must be a positive amount of satoshi");
        }

        const oracleKey = createInclusionOracleKey(this.api, DEFAULT_INCLUSION_TIME);
        const encodedFee = encodeUnsignedFixedPoint(this.api, fees);
        const tx = this.api.tx.oracle.feedValues([[oracleKey, encodedFee]]);
        await this.transactionAPI.sendLogged(tx, this.api.events.oracle.FeedValues, true);
    }

    async getSourcesById(): Promise<Map<string, string>> {
        const oracles = await this.api.query.oracle.authorizedOracles.entries();
        const nameMap = new Map<string, string>();
        oracles.forEach((oracle) => nameMap.set(storageKeyToNthInner(oracle[0]).toString(), oracle[1].toUtf8()));
        return nameMap;
    }

    async getValidUntil<U extends CurrencyUnit>(counterCurrency: Currency<U>): Promise<Date> {
        const oracleKey = createExchangeRateOracleKey(this.api, counterCurrency);
        const validUntil = await this.api.query.oracle.validUntil(oracleKey);
        return validUntil.isSome ? convertMoment(validUntil.value as Moment) : Promise.reject("No such oracle key");
    }

    async isOnline(): Promise<boolean> {
        const errors = await this.api.query.security.errors();
        return !this.hasOracleError(errors.toArray());
    }

    async getRawValuesUpdated(key: InterbtcPrimitivesOracleKey): Promise<boolean> {
        const isSet = await this.api.query.oracle.rawValuesUpdated<Option<Bool>>(key);
        return isSet.unwrap().isTrue;
    }

    async waitForFeeEstimateUpdate(type: FeeEstimationType = DEFAULT_INCLUSION_TIME): Promise<void> {
        const key = createInclusionOracleKey(this.api, type);
        while (await this.getRawValuesUpdated(key)) {
            sleep(SLEEP_TIME_MS);
        }
    }

    async waitForExchangeRateUpdate<C extends CurrencyUnit, U extends BitcoinUnit>(
        exchangeRate: ExchangeRate<Currency<U>, U, Currency<C>, C>
    ): Promise<void> {
        const key = createExchangeRateOracleKey(this.api, exchangeRate.counter);
        while (await this.getRawValuesUpdated(key)) {
            sleep(SLEEP_TIME_MS);
        }
    }

    private hasOracleError(errors: SecurityErrorCode[]): boolean {
        for (const error of errors.values()) {
            if (error.isOracleOffline) {
                return true;
            }
        }
        return false;
    }
}