import random
import ib_insync
import numpy as np
from autotrader.brokers.trading import Order, IsolatedPosition, Position
from autotrader.brokers.ib.utils import Utils


class Broker:
    """AutoTrader-InteractiveBrokers API interface.

    Attributes
    ----------
    utils : Utils
        The broker utilities.
    ib : ib_insync connection
        Used to query IB.
    account : str
        The active IB account.

    Notes
    -----
        - when closing a position using close_position(), if there are attached SL
          and/or TP orders, they must be closed manually using cancel_pending_order().
          Usually only one of the pair needs to be cancelled, and the other will too.
        - required signal_dict keys for different security types (eg. futures
          require symbol, exchange and contract_month)
        - The products localSymbol will always take precedence over the symbol.
          As such, it should be used as much as possible to avoid accidental
          actions.
    """

    def __init__(self, config: dict, utils: Utils = None) -> None:
        """Initialise AutoTrader-Interactive Brokers API interface.

        Parameters
        ----------
        config : dict
            The IB configuration dictionary. This can contain the host, port,
            clientID and read_only boolean flag.
        utils : Utils, optional
            Broker utilities class instance. The default is None.
        """
        self.utils = utils if utils is not None else Utils()

        self.host = config["host"] if "host" in config else "127.0.0.1"
        self.port = config["port"] if "port" in config else 7497
        self.client_id = (
            config["clientID"] if "clientID" in config else random.randint(0, 9999)
        )
        self.read_only = config["read_only"] if "read_only" in config else False
        self.account = config["account"] if "account" in config else ""

        self.ib = ib_insync.IB()
        self._check_connection()
        self.account = self._get_account() if self.account == "" else self.account

    def __repr__(self):
        return "AutoTrader-InteractiveBrokers interface"

    def __str__(self):
        return "AutoTrader-InteractiveBrokers interface"

    def get_NAV(self) -> float:
        """Returns the net asset/liquidation value of the account."""
        self._check_connection()
        summary = self.get_summary()
        return float(summary["NetLiquidation"]["value"])

    def get_balance(self) -> float:
        """Returns account balance."""
        self._check_connection()
        summary = self.get_summary()
        return float(summary["TotalCashValue"]["value"])

    def place_order(self, order: Order, **kwargs) -> None:
        """Disassembles order_details dictionary to place order.

        Parameters
        ----------
        order: Order
            The AutoTrader Order.

        Returns
        -------
        None
            Orders will be submitted to IB.
        """
        self._check_connection()

        # Call order to set order time
        order()

        if order.order_type == "market":
            self._place_market_order(order)
        elif order.order_type == "stop-limit":
            self._place_stop_limit_order(order)
        elif order.order_type == "limit":
            self._place_limit_order(order)
        elif order.order_type == "close":
            self._close_position(order)
        else:
            print("Order type not recognised.")

        self._refresh()

    def get_orders(self, instrument: str = None, **kwargs) -> dict:
        """Returns all pending orders (have not been filled) in the account.

        Parameters
        ----------
        instrument : str, optional
            The trading instrument's symbol. The default is None.

        Returns
        -------
        dict
            Pending orders for the requested instrument. If no instrument is provided,
            all pending orders will be returned.
        """
        self._check_connection()

        # Get all open trades
        open_trades = self.ib.openTrades()

        pending_orders = {}
        for trade in open_trades:
            trade_dict = trade.dict()
            contract = trade_dict["contract"]
            order_dict = trade_dict["order"].dict()
            order_status_dict = trade_dict["orderStatus"].dict()
            order_status = order_status_dict["status"]

            if order_status in ib_insync.OrderStatus.ActiveStates:
                # Order is still active (not yet filled)
                new_order = {}
                new_order["order_ID"] = order_dict["orderId"]
                new_order["order_type"] = order_dict["orderType"]
                new_order["order_stop_price"] = order_dict["auxPrice"]
                new_order["order_limit_price"] = order_dict["lmtPrice"]
                new_order["direction"] = 1 if order_dict["action"] == "BUY" else -1
                new_order["order_time"] = None
                new_order["instrument"] = contract.symbol
                new_order["size"] = order_dict["totalQuantity"]
                new_order["order_price"] = None
                new_order["take_profit"] = None
                new_order["take_distance"] = None
                new_order["stop_type"] = None
                new_order["stop_distance"] = None
                new_order["stop_loss"] = None
                new_order["related_orders"] = None
                new_order["granularity"] = None
                new_order["strategy"] = None

                if instrument is not None and contract.symbol == instrument:
                    pending_orders[new_order["order_ID"]] = Order._from_dict(new_order)
                elif instrument is None:
                    pending_orders[new_order["order_ID"]] = Order._from_dict(new_order)

        return pending_orders

    def cancel_order(self, order_id: int, **kwargs) -> list:
        """Cancels pending order by order ID.

        Parameters
        ----------
        order_id : int
            The ID of the order to be concelled.

        Returns
        -------
        list
            A list of the cancelled trades.

        """
        self._check_connection()

        open_trades = self.ib.openTrades()
        cancelled_trades = []
        for trade in open_trades:
            order = trade.order
            if order.orderId == order_id:
                cancel_trade = self.ib.cancelOrder(order)
                cancelled_trades.append(cancel_trade)

        return cancelled_trades

    def get_trades(self, instrument: str = None, **kwargs) -> dict:
        """Returns the open trades held by the account.

        Parameters
        ----------
        instrument : str, optional
            The trading instrument's symbol. The default is None.

        Returns
        -------
        dict
            The open trades.
        """
        self._check_connection()

        # Get all open trades
        all_open_trades = self.ib.openTrades()

        open_trades = {}
        for trade in all_open_trades:
            trade_dict = trade.dict()
            contract = trade_dict["contract"]
            order_dict = trade_dict["order"].dict()
            order_status_dict = trade_dict["orderStatus"].dict()
            order_status = order_status_dict["status"]

            if order_status == "Filled":
                # Trade order has been filled
                new_trade = {}
                new_trade["order_ID"] = order_dict["orderId"]
                new_trade["order_stop_price"] = order_dict["auxPrice"]
                new_trade["order_limit_price"] = order_dict["lmtPrice"]
                new_trade["direction"] = 1 if order_dict["action"] == "BUY" else -1
                new_trade["order_time"] = None
                new_trade["instrument"] = contract.symbol
                new_trade["size"] = order_dict["totalQuantity"]
                new_trade["order_price"] = None
                new_trade["entry_price"] = order_status_dict["lastFillPrice"]
                new_trade["order_type"] = None
                new_trade["take_profit"] = None
                new_trade["take_distance"] = None
                new_trade["stop_type"] = None
                new_trade["stop_distance"] = None
                new_trade["stop_loss"] = None
                new_trade["related_orders"] = None
                new_trade["granularity"] = None
                new_trade["strategy"] = None

                if instrument is not None and contract.symbol == instrument:
                    open_trades[new_trade["order_ID"]] = IsolatedPosition(new_trade)
                elif instrument is None:
                    open_trades[new_trade["order_ID"]] = IsolatedPosition(new_trade)

        return open_trades

    def get_trade_details(self, trade_ID: str, **kwargs) -> dict:
        """Returns the details of the trade specified by trade_ID.

        Parameters
        ----------
        trade_ID : str
            The ID of the trade.

        Returns
        -------
        dict
            The details of the trade.
        """
        raise NotImplementedError(
            "This method is not available, and will "
            + "be deprecated with a future release. Please use the "
            + "get_trades method instead."
        )

    def get_positions(self, instrument: str = None, **kwargs) -> dict:
        """Gets the current positions open on the account.

        Parameters
        ----------
        instrument : str, optional
            The trading instrument's symbol. This can be either the naive
            symbol, or the localSymbol. The default is None.

        Returns
        -------
        open_positions : dict
            A dictionary containing details of the open positions.

        Notes
        -----
        This function returns the position in an underlying product. If
        there are multiple contracts on the underlying, they will be
        appended to the returned Position objects' "contracts" and
        "portfolio_items" attributes.
        """

        def adjust_position(existing_pos, portfolio_item, pos_dict):
            # Position item already exists, append portfolio item
            existing_pos.long_units += pos_dict["long_units"]
            existing_pos.long_PL += pos_dict["long_PL"]
            existing_pos.short_units += pos_dict["short_units"]
            existing_pos.short_PL += pos_dict["short_PL"]
            existing_pos.net_position += pos_dict["net_position"]
            existing_pos.PL += pos_dict["PL"]
            existing_pos.contracts.append(portfolio_item.contract)
            existing_pos.portfolio_items.append(portfolio_item)

        self._check_connection()

        all_portfolio_items = self.ib.portfolio()
        open_positions = {}
        for item in all_portfolio_items:
            units = item.position
            pnl = item.unrealizedPNL
            pos_symbol = item.contract.symbol
            pos_dict = {
                "long_units": units if np.sign(units) > 0 else 0,
                "long_PL": pnl if np.sign(units) > 0 else 0,
                "short_units": abs(units) if np.sign(units) < 0 else 0,
                "short_PL": pnl if np.sign(units) < 0 else 0,
                "net_position": units,
                "PL": pnl,
                "contracts": [item.contract],
                "portfolio_items": [item],
                "instrument": pos_symbol,
            }

            symbol_match = instrument == pos_symbol  # Product symbol match
            localSymbol_match = (
                instrument == item.contract.localSymbol
            )  # localSymbol match
            unique_match = symbol_match or localSymbol_match

            if instrument is not None and unique_match:
                # The current item matches the request, append it
                key_symbol = (
                    item.contract.localSymbol
                    if localSymbol_match
                    else item.contract.symbol
                )

                pos_dict["instrument"] = key_symbol

                if pos_symbol in open_positions:
                    # Portfolio item already exists, append portfolio item
                    existing_pos = open_positions[pos_symbol]
                    adjust_position(existing_pos, item, pos_dict)
                else:
                    # New position
                    open_positions[key_symbol] = Position(**pos_dict)

            elif instrument is None:
                # Append all positions
                if pos_symbol in open_positions:
                    # Position item already exists, append portfolio item
                    existing_pos = open_positions[pos_symbol]
                    adjust_position(existing_pos, item, pos_dict)
                else:
                    # New position
                    open_positions[pos_symbol] = Position(**pos_dict)

        return open_positions

    def get_summary(self) -> dict:
        """Returns account summary."""
        self._check_connection()
        raw_summary = self.ib.accountSummary(self.account)
        summary = self.utils.accsum_to_dict(self.account, raw_summary)

        return summary

    def _get_historical_data(
        self, instrument: str, interval: str, from_time: str, to_time: str
    ):
        """Returns historical price data."""
        # self._check_connection()
        # self.ib.reqHistoricalData()
        raise NotImplementedError("This method is not available.")

    def _connect(self):
        """Connects from IB application."""
        self.ib.connect(
            host=self.host,
            port=self.port,
            clientId=self.client_id,
            readonly=self.read_only,
            account=self.account,
        )

    def _disconnect(self):
        """Disconnects from IB application."""
        self.ib.disconnect()

    def _check_connection(self):
        """Checks if there is an active connection to IB. If not, will
        attempt to reconnect.
        """
        self._refresh()
        connected = self.ib.isConnected()

        while not connected:
            try:
                # Try to connect
                self.ib = ib_insync.IB()
                self._connect()
            except:
                print("Connection to IB failed... trying to reconnect.")
                # Connection failed, increment client ID
                self.client_id = random.randint(0, 9999)

                # Sleep for a little while
                self.ib.sleep(10)

            # Update connection status
            connected = self.ib.isConnected()

    def _refresh(self):
        """Refreshes IB session events."""
        self.ib.sleep(0)

    def _get_account(
        self,
    ):
        """Returns the first managed account."""
        self._check_connection()
        accounts = self.ib.managedAccounts()
        return accounts[0]

    def _close_position(self, order: Order, **kwargs):
        """Closes open position of symbol by placing opposing market order.

        Warning
        -------
        If the order instrument is for an underlying product, all contracts
        held attributed to the underlying will be closed.
        """
        self._check_connection()

        symbol = (
            order.localSymbol if order.localSymbol is not None else order.instrument
        )
        positions = self.get_positions(instrument=symbol)
        position = positions[symbol]

        for item in position.portfolio_items:
            # Place opposing market order
            item_units = item.position
            action = "BUY" if item_units < 0 else "SELL"
            units = abs(item_units)
            IB_order = ib_insync.MarketOrder(action, units)
            contract = item.contract
            self.ib.qualifyContracts(contract)
            self.ib.placeOrder(contract, IB_order)

    def _place_market_order(self, order: Order):
        """Places a market order."""
        self._check_connection()

        # Build contract
        contract = self.utils.build_contract(order)

        # Create market order
        action = "BUY" if order.direction > 0 else "SELL"
        units = abs(order.size)
        market_order = ib_insync.MarketOrder(
            action, units, orderId=self.ib.client.getReqId(), transmit=False
        )

        # Attach SL and TP orders
        orders = self._attach_auxiliary_orders(order, market_order)

        # Submit orders
        self._process_orders(contract, orders)

    def _place_stop_limit_order(self, order):
        """Places stop-limit order."""
        self._check_connection()

        # Build contract
        contract = self.utils.build_contract(order)

        # Create stop limit order
        action = "BUY" if order.direction > 0 else "SELL"
        units = abs(order.size)
        lmtPrice = order.order_limit_price
        stopPrice = order.order_stop_price
        IBorder = ib_insync.StopLimitOrder(
            action,
            units,
            lmtPrice,
            stopPrice,
            orderId=self.ib.client.getReqId(),
            transmit=False,
        )

        # Attach SL and TP orders
        orders = self._attach_auxiliary_orders(order, IBorder)

        # Submit orders
        self._process_orders(contract, orders)

    def _place_limit_order(self, order):
        """Places limit order."""
        self._check_connection()

        # Build contract
        contract = self.utils.build_contract(order)

        action = "BUY" if order.direction > 0 else "SELL"
        units = abs(order.size)
        lmtPrice = order.order_limit_price
        IBorder = ib_insync.LimitOrder(
            action, units, lmtPrice, orderId=self.ib.client.getReqId(), transmit=False
        )

        # Attach SL and TP orders
        orders = self._attach_auxiliary_orders(order, IBorder)

        # Submit orders
        self._process_orders(contract, orders)

    def _attach_auxiliary_orders(
        self, order: Order, parent_order: ib_insync.order
    ) -> list:
        orders = [parent_order]

        # TP order
        if order.take_profit is not None:
            takeProfit_order = self._create_take_profit_order(
                order, parent_order.orderId
            )
            orders.append(takeProfit_order)

        # SL order
        if order.stop_loss is not None:
            stopLoss_order = self._create_stop_loss_order(order, parent_order.orderId)
            orders.append(stopLoss_order)

        return orders

    def _process_orders(self, contract: ib_insync.Contract, orders: list) -> None:
        """Processes a list of orders for a given contract."""
        self._check_connection()

        # Submit orders
        for i, order in enumerate(orders):
            if i == len(orders) - 1:
                # Final order; set transmit to True
                order.transmit = True
            else:
                order.transmit = False
            self.ib.placeOrder(contract, order)

    def _convert_to_oca(
        self, orders: list, oca_group: str = None, oca_type: int = 1
    ) -> list:
        """Converts a list of Orders to One Cancels All group of orders.

        Parameters
        ----------
        orders : list
            A list of orders.

        Returns
        -------
        oca_orders : list
            The orders modified to be in a OCA group.
        """
        self._check_connection()

        if oca_group is None:
            oca_group = f"OCA_{self.ib.client.getReqId()}"

        oca_orders = self.ib.oneCancelsAll(orders, oca_group, oca_type)
        return oca_orders

    def _create_take_profit_order(self, order: Order, parentId: int):
        """Constructs a take profit order."""
        quantity = order.size
        takeProfitPrice = order.take_profit
        action = "BUY" if order.direction < 0 else "SELL"
        takeProfit_order = ib_insync.LimitOrder(
            action,
            quantity,
            takeProfitPrice,
            orderId=self.ib.client.getReqId(),
            transmit=False,
            parentId=parentId,
        )
        return takeProfit_order

    def _create_stop_loss_order(self, order: Order, parentId: int):
        """Constructs a stop loss order."""
        # TODO - add support for trailing SL
        quantity = order.size
        stopLossPrice = order.stop_loss
        action = "BUY" if order.direction < 0 else "SELL"
        stopLoss_order = ib_insync.StopOrder(
            action,
            quantity,
            stopLossPrice,
            orderId=self.ib.client.getReqId(),
            transmit=True,
            parentId=parentId,
        )
        return stopLoss_order
