import { MainClient, WebsocketClient } from "binance";
import APIClient from "../../apiwrapper/APIClient.js";
import Logger from "../../logging/Logger.js";
import BinanceApiConfig from "./BinanceApiConfig.js";
import BinanceWSEvent from "./BinanceWSEvent.js";
import BalanceChangeEvent from "../../apiwrapper/event/BalanceChangeEvent.js";
import KLineEvent from "../../apiwrapper/event/KLineEvent.js";
import TradingPairs from "../../apiwrapper/assets/TraidingPairs.js";
import Ticker24hEvent from "../../apiwrapper/event/Ticker24hEvent.js";
import OrderBookUpdateEvent from "../../apiwrapper/event/OrderBookUpdateEvent.js";
import OrderBookEntry from "../../apiwrapper/orderbook/OrderBookEntry.js";
import TradingPair from "../../apiwrapper/assets/TradingPair.js";
import MarketType from "../../apiwrapper/market/MarketType.js";
import TradeEvent from "../../apiwrapper/event/TradeEvent.js";
import Transaction from "../../apiwrapper/transaction/Transaction.js";
import TransactionUpdateEvent from "../../apiwrapper/event/TransactionUpdateEvent.js";
import TransactionFill from "../../apiwrapper/transaction/TransactionFill.js";
import TransactionStatus from "../../apiwrapper/transaction/TransactionStatus.js";
import TransactionPhase from "../../apiwrapper/transaction/TransactionPhase.js";

export default class BinanceApiClient extends APIClient {
    /**
     * @type {WebsocketClient}
     */
    #wsClient = null;

    /**
     * @type {MainClient}
     */
    #restClient = null;

    /**
     * @type {Map<string, import("binance").SymbolExchangeInfo>}
     */
    #assetPermissions = new Map();

    /**
     * @param {BinanceApiConfig} config 
     * @param {Logger} logger
     */
    constructor(config, logger) {
        super(config, logger);
        this.#wsClient = new WebsocketClient(config.getWSClientConfig());
        this.#restClient = new MainClient(config.getRestClientConfig());
        this.#loadAssetInfo();
        this.#wsClient.on('formattedMessage', this.#wsMessageHandler.bind(this));
        this.#wsClient.on('open', this.#connectionOpened.bind(this));
        this.#wsClient.on('reconnecting', this.#reconnected.bind(this));
        this.#wsClient.on('error', this.#handleWSError.bind(this));
    }

    async #connectionOpened(msg) {
        this.getLogger().info("Connection to binance web socket api!", msg);
    }

    async #reconnected(msg) {
        this.getLogger().info("Reconnected to binance web socket api!", msg);
    }

    async #handleWSError(e) {
        if (msg instanceof Error) {
            this.getLogger().error("Error on WS!", msg);
        } else {
            this.getLogger().error("Error on WS!", msg.error.message, msg.error.error);
        }
    }

    #wsMessageHandler(data) {
        if (!data.eventType) {
            this.getLogger().warn("Websocket message is not an event:", data);
            return;
        }

        switch (data.eventType) {
            case BinanceWSEvent.ACCOUNT_BALANCE_CHANGE:
                this.#fireAccountBalanceChangeEvent(data);
                break;
            case BinanceWSEvent.KLINE_EVENT:
                this.#fireKLineEvent(data);
                break;
            case BinanceWSEvent.TICKER_24H_EVENT:
                this.#fire24hTickerEvent(data);
                break;
            case BinanceWSEvent.BOOK_DEPTH_UPDATE_EVENT:
                this.#fireOrderBookUpdateEvent(data);
                break;
            case BinanceWSEvent.TRADE_EVENT:
                this.#fireTradeEvent(data);
                break;
            case BinanceWSEvent.EXECUTION_REPORT:
                this.#requestOrderUpdate(data.orderId, data.originalClientOrderId, data.symbol);
                break;
            default:
                this.getLogger().warn("Unknown event type!", data);
        }
    }

    async #fireTradeEvent(data){
        const tradingPair = TradingPairs.fromString(data.symbol);

        if (tradingPair == null) {
            this.getLogger().warn("Trade for unknown symbol: " + data.symbol);
            return;
        }

        const event = new TradeEvent(tradingPair);
        event.price = parseFloat(data.price);
        event.quantity = parseFloat(data.quantity);

        this.dispatchAssetEvent(event);
    }

    async #fireOrderBookUpdateEvent(data) {
        const tradingPair = TradingPairs.fromString(data.symbol);

        if (tradingPair == null) {
            this.getLogger().warn("Order book update for unknown symbol: " + data.symbol);
            return;
        }

        const event = new OrderBookUpdateEvent(tradingPair);
        event.updateId = data.lastUpdateId;
        if(data.bids && data.asks){
            data.bids.forEach(b => event.bids.push(new OrderBookEntry(parseFloat(b[0]), parseFloat(b[1]))));
            data.asks.forEach(a => event.asks.push(new OrderBookEntry(parseFloat(a[0]), parseFloat(a[1]))));
            event.isReset = true;
        } else {
            data.bidDepthDelta.forEach(b => event.bids.push(new OrderBookEntry(parseFloat(b.price), parseFloat(b.quantity))));
            data.askDepthDelta .forEach(a => event.asks.push(new OrderBookEntry(parseFloat(a.price), parseFloat(a.quantity))));
        }


        this.dispatchAssetEvent(event);
    }

    async #fire24hTickerEvent(data) {
        const tradingPair = TradingPairs.fromString(data.symbol);

        if (tradingPair == null) {
            this.getLogger().warn("24h ticker for unknown symbol: " + data.symbol);
            return;
        }

        const event = new Ticker24hEvent(tradingPair);
        event.priceChange = parseFloat(data.priceChange);
        event.priceChangePercent = parseFloat(data.priceChangePercent);
        event.weightedAveragePrice = parseFloat(data.weightedAveragePrice);
        event.bestBidPrice = parseFloat(data.bestBid);
        event.bestBidQuantity = parseFloat(data.bestBidQuantity);
        event.bestAskPrice = parseFloat(data.bestAskPrice);
        event.bestAskQuantity = parseFloat(data.bestAskQuantity);
        event.high = parseFloat(data.high);
        event.low = parseFloat(data.low);
        event.trades = parseFloat(data.trades);

        this.dispatchAssetEvent(event);
    }

    async #fireKLineEvent(data) {
        const tradingPair = TradingPairs.fromString(data.symbol);

        if (tradingPair == null) {
            this.getLogger().warn("Kline for unknown symbol: " + data.symbol);
            return;
        }

        const event = new KLineEvent(tradingPair, parseFloat(data.kline.high), parseFloat(data.kline.low), parseFloat(data.kline.open), parseFloat(data.kline.close));
        event.isClosed = data.kline.final;
        event.startTime = parseFloat(data.kline.startTime);
        event.endTime = parseFloat(data.kline.endTime);

        this.dispatchAssetEvent(event);
    }

    /**
     * @param {any} data 
     */
    async #fireAccountBalanceChangeEvent(data) {
        const event = new BalanceChangeEvent();
        data.balances.forEach(balanceUpdate => {
            event.setChangedBalance(balanceUpdate.asset, parseFloat(balanceUpdate.availableBalance));
        });

        this.dispatchAccountEvent(event);
    }

    /**
     * @param {TradingPair} tradingPair 
     */
    subscribeOrderBookUpdates(tradingPair) {
        this.requestOrderBook(tradingPair);
        this.#wsClient.subscribeDiffBookDepth(tradingPair.getKey(), 100, MarketType.SPOT)
    }

    /**
     * @param {TradingPair} tradingPair 
     */
    subscribeKLines(tradingPair) {
        this.#wsClient.subscribeKlines(tradingPair.getKey(), "1m", MarketType.SPOT);
    }

    /**
     * @param {TradingPair} tradingPair 
     */
    subscribe24hTicker(tradingPair) {
        this.#wsClient.subscribeSymbol24hrTicker(tradingPair.getKey(), MarketType.SPOT);
    }

    /**
     * @param {TradingPair} tradingPair
     */
    subscribeTrades(tradingPair) {
        this.#wsClient.subscribeTrades(tradingPair.getKey(), MarketType.SPOT);
    }

    subscribeBalanceChanges() {
        this.#wsClient.subscribeSpotUserDataStream();
    }

    /**
     * @param {TradingPair} tradingPair 
     */
    requestOrderBook(tradingPair) {
        this.#restClient.getOrderBook({ "symbol": tradingPair.getKey(), "limit": 10 })
            .then(resp => {
                resp.symbol = tradingPair.getKey();
                this.#fireOrderBookUpdateEvent(resp);
            })
            .catch(e => {
                this.getLogger().error("Unable to get order book information from binance rest api!", e);
            });
    }

    requestBalances() {
        if (this.getApiConfig().isTestNet()) {
            return;
        }

        this.#restClient.getBalances().then(resp => {
            const event = new BalanceChangeEvent();
            resp.forEach(coinInfo => {
                if (coinInfo.free > 0) {
                    event.setChangedBalance(coinInfo.coin, parseFloat(coinInfo.free));
                }
            });

            this.dispatchAccountEvent(event);
        }).catch(e => {
            this.getLogger().error("Unable to get balances from binance rest api!", e);
        });
    }

    requestAccountInformation() {
        this.#restClient.getAccountInformation().then(resp => {
            // TODO
        }).catch(e => {
            this.getLogger().error("Unable to get Account information from binance rest api!", e);
        });
    }

    /**
     * @param {Transaction} transaction 
     */
    requestBuy(transaction){
        this.#restClient.submitNewOrder({
            "symbol": transaction.symbol.getKey(),
            "side": "BUY", 
            "type": "LIMIT", 
            "price": this.#correctPriceForAsset(transaction.buySettings.orderPrice, transaction.symbol.getKey()), 
            "quantity": this.#correctQuantityForAsset(transaction.buySettings.orderQuantity, transaction.symbol.getKey()), 
            "newClientOrderId": transaction.id, 
            "timeInForce": "GTC",
            "timestamp": Date.now()
        }).then(resp => {
            transaction.buySettings.apiID = resp.orderId;
            transaction.phase = TransactionPhase.BUY_IN_PROGRESS;
            transaction.lockedForUpdate = false;
        }).catch(e => {
            this.getLogger().error("Unable to create buy order for " + transaction.id, {code: e.code, msg: e.message, url: e.requestUrl});
            const event = new TransactionUpdateEvent(transaction.id, transaction.buySettings.apiID, TransactionStatus.CANCELED);
            this.dispatchAccountEvent(event);
        });
    }

    /**
     * @param {Transaction} transaction 
     */
    requestSell(transaction){
        this.#restClient.submitNewOrder({
            "symbol": transaction.symbol.getKey(),
            "side": "SELL", 
            "type": "LIMIT", 
            "price": this.#correctPriceForAsset(transaction.sellSettings.orderPrice, transaction.symbol.getKey()), 
            "quantity" : this.#correctQuantityForAsset(transaction.sellSettings.orderQuantity, transaction.symbol.getKey()), 
            "newClientOrderId": transaction.id, 
            "timeInForce": "GTC",
            "timestamp": Date.now()
        }).then(resp => {
            transaction.sellSettings.apiID = resp.orderId;
            transaction.phase = TransactionPhase.SELL_IN_PROGRESS;
            transaction.lockedForUpdate = false;
        }).catch(e => {
            this.getLogger().error("Unable to create sell order for " + transaction.id, {code: e.code, msg: e.message, url: e.requestUrl});
            const event = new TransactionUpdateEvent(transaction.id, transaction.sellSettings.apiID, TransactionStatus.CANCELED);
            this.dispatchAccountEvent(event);
        });
    }

    /**
     * @param {Transaction} transaction 
     */
    requestBuyCancel(transaction){
        this.#requestCancel(transaction.buySettings.apiID, transaction.id, transaction.symbol.getKey());
    }

    /**
     * @param {Transaction} transaction 
     */
    requestSellCancel(transaction){
        this.#requestCancel(transaction.sellSettings.apiID, transaction.id, transaction.symbol.getKey());
    }

    /**
     * @param {string} orderId 
     * @param {string} clientId 
     * @param {string} symbol 
     */
    #requestCancel(orderId, clientId, symbol){
        /**
         * @type {import("binance").CancelOrderParams}
         */
        const orderParams = {
            "symbol": symbol,
        };

        if(clientId != null && clientId != ""){
            orderParams.origClientOrderId = clientId;
        }

        if(orderId != null && orderId != ""){
            orderParams.orderId = orderId;
        }

        this.#restClient.cancelOrder(orderParams).then(resp =>{
            this.#requestOrderUpdate(orderId, clientId, symbol);
        }).catch(e => {
            this.getLogger().error("Unable to cancle order for " + clientId, {code: e.code, msg: e.message, url: e.requestUrl});
        });
    }

    /**
     * @param {Transaction} transaction 
     * @param {"buy" | "sell"} type
     */
    requestTransactionUpdate(transaction, type = "buy"){
        const id = type == "buy" ? transaction.buySettings.apiID : transaction.sellSettings.apiID;
        this.#requestOrderUpdate(id, transaction.id, transaction.symbol.getKey());
    }

    /**
     * @param {string} orderId 
     * @param {string} clientId 
     * @param {string} symbol 
     */
    #requestOrderUpdate(orderId, clientId, symbol){
        /**
         * @type {import("binance").GetOrderParams}
         */
        const orderParams = {
            "symbol": symbol,
        };

        if(clientId != null && clientId != ""){
            orderParams.origClientOrderId = clientId;
        }

        if(orderId != null && orderId != ""){
            orderParams.orderId = orderId;
        }

        this.#restClient.getOrder(orderParams).then(resp => {
            const event = new TransactionUpdateEvent(resp.clientOrderId, resp.orderId, resp.status, resp.updateTime);
            if(resp.status == "CANCELED" && parseFloat(resp.executedQty) <= 0){
                this.dispatchAccountEvent(event);
            } else {
                this.#requestTransactionFills(orderId, clientId, symbol, event);
            }
        }).catch(e => {
            this.getLogger().error("Unable to get order status for " + clientId + "(" + orderId + ")", {code: e.code, msg: e.message, url: e.requestUrl});
        });
    }

    /**
     * @param {string} orderId 
     * @param {string} clientId 
     * @param {string} symbol 
     * @param {TransactionUpdateEvent} event
     */
    #requestTransactionFills(orderId, clientId, symbol, event){
        this.#restClient.getAccountTradeList({
            "symbol": symbol,
            "orderId": orderId,
        }).then(resp => {
            resp.forEach(trade => event.fills.push(new TransactionFill(parseFloat(trade.price), parseFloat(trade.qty))));
            this.dispatchAccountEvent(event);
        }).catch(e => {
            this.getLogger().error("Unable to get order fills for " + clientId + "(" + orderId + ")", {code: e.code, msg: e.message, url: e.requestUrl});
        });
    }

    #loadAssetInfo(){
        this.#restClient.getExchangeInfo().then(exchangeInfo => {
            exchangeInfo.symbols.forEach(symbolInfo => {
                this.#assetPermissions.set(symbolInfo.symbol, symbolInfo);
            });
        }).catch(e => {
            this.getLogger().error("Unable to get exchange info!");
        });
    }

    /**
     * @param {number} price 
     * @param {string} asset 
     * @returns {number}
     */
    #correctPriceForAsset(price, asset){
        if(!this.#assetPermissions.has(asset)){
            return price;
        }

        const symbolInfo = this.#assetPermissions.get(asset);
        const priceFilter = symbolInfo.filters.find(filter => filter.filterType == 'PRICE_FILTER');
        let newPrice = parseFloat(price);

        if(priceFilter != null){
            newPrice = this.#toPrecision(newPrice, priceFilter.tickSize);
        }

        return newPrice;
    }

    /**
     * @param {number} quantity 
     * @param {string} asset 
     * @returns {number}
     */
    #correctQuantityForAsset(quantity, asset){
        if(!this.#assetPermissions.has(asset)){
            return quantity;
        }

        const symbolInfo = this.#assetPermissions.get(asset);
        const lotFilter = symbolInfo.filters.find(filter => filter.filterType == 'LOT_SIZE');
        let newQuantity = parseFloat(quantity);

        if(lotFilter != null){
            newQuantity = this.#toPrecision(newQuantity, lotFilter.stepSize);
        }

        return newQuantity;
    }

    #toPrecision(value, precision){
        let precisionDigits = Math.log(Math.round(1/parseFloat(precision))) * Math.LOG10E + 1 | 0;
        precisionDigits =  precisionDigits > 0 && precision >= 1 ? precisionDigits - 1 : precisionDigits;

        return parseFloat(value).toFixed(precisionDigits);
    }
}