/*
 * SPDX-License-Identifier: MIT
 */
package org.ta4j.core;

import static org.ta4j.core.num.NaN.NaN;

import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;

import org.ta4j.core.Trade.TradeType;
import org.ta4j.core.analysis.cost.CostModel;
import org.ta4j.core.analysis.cost.ZeroCostModel;
import org.ta4j.core.num.Num;

/**
 * A {@code Position} models either a closed entry/exit pair or an open position
 * snapshot with only an entry trade.
 *
 * <p>
 * The exit trade has the complement type of the entry trade, i.e.:
 * <ul>
 * <li>entry == BUY --> exit == SELL
 * <li>entry == SELL --> exit == BUY
 * </ul>
 *
 * <p>
 * Open-position inspection APIs on {@link TradingRecord} also use this type, so
 * callers can query per-lot and net exposure through one consistent contract.
 * </p>
 */
public class Position implements Serializable {

    @Serial
    private static final long serialVersionUID = -5484709075767220358L;

    /** The entry trade */
    private Trade entry;

    /** The exit trade */
    private Trade exit;

    /** The type of the entry trade */
    private final TradeType startingType;

    /** The cost model for transactions of the asset */
    private final transient CostModel transactionCostModel;

    /** The cost model for holding the asset */
    private final transient CostModel holdingCostModel;

    /** Constructor with {@link #startingType} = BUY. */
    public Position() {
        this(TradeType.BUY);
    }

    /**
     * Constructor.
     *
     * @param startingType the starting {@link TradeType trade type} of the position
     *                     (i.e. type of the entry trade)
     */
    public Position(TradeType startingType) {
        this(startingType, new ZeroCostModel(), new ZeroCostModel());
    }

    /**
     * Constructor.
     *
     * @param startingType         the starting {@link TradeType trade type} of the
     *                             position (i.e. type of the entry trade)
     * @param transactionCostModel the cost model for transactions of the asset
     * @param holdingCostModel     the cost model for holding asset (e.g. borrowing)
     */
    public Position(TradeType startingType, CostModel transactionCostModel, CostModel holdingCostModel) {
        if (startingType == null) {
            throw new IllegalArgumentException("Starting type must not be null");
        }
        this.startingType = startingType;
        this.transactionCostModel = transactionCostModel;
        this.holdingCostModel = holdingCostModel;
    }

    /**
     * Constructor.
     *
     * @param entry the entry {@link Trade trade}
     * @param exit  the exit {@link Trade trade}
     */
    public Position(Trade entry, Trade exit) {
        this(entry, exit, entry.getCostModel(), new ZeroCostModel());
    }

    /**
     * Constructor.
     *
     * @param entry                the entry {@link Trade trade}
     * @param exit                 the exit {@link Trade trade}
     * @param transactionCostModel the cost model for transactions of the asset
     * @param holdingCostModel     the cost model for holding asset (e.g. borrowing)
     */
    public Position(Trade entry, Trade exit, CostModel transactionCostModel, CostModel holdingCostModel) {

        if (entry.getType().equals(exit.getType())) {
            throw new IllegalArgumentException("Both trades must have different types");
        }

        if (!(entry.getCostModel().equals(transactionCostModel))
                || !(exit.getCostModel().equals(transactionCostModel))) {
            throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
        }

        this.startingType = entry.getType();
        this.entry = entry;
        this.exit = exit;
        this.transactionCostModel = transactionCostModel;
        this.holdingCostModel = holdingCostModel;
    }

    /**
     * Constructor for an open position.
     *
     * @param entry                the entry {@link Trade trade}
     * @param transactionCostModel the cost model for transactions of the asset
     * @param holdingCostModel     the cost model for holding asset (e.g. borrowing)
     * @since 0.22.2
     */
    public Position(Trade entry, CostModel transactionCostModel, CostModel holdingCostModel) {
        Objects.requireNonNull(entry, "entry");
        if (!(entry.getCostModel().equals(transactionCostModel))) {
            throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
        }
        this.startingType = entry.getType();
        this.entry = entry;
        this.exit = null;
        this.transactionCostModel = transactionCostModel;
        this.holdingCostModel = holdingCostModel;
    }

    /**
     * @return the entry {@link Trade trade} of the position
     */
    public Trade getEntry() {
        return entry;
    }

    /**
     * @return the exit {@link Trade trade} of the position
     */
    public Trade getExit() {
        return exit;
    }

    /**
     * Returns the entry-side direction of this position.
     *
     * @return the entry side, or {@code null} when the position has no entry yet
     * @since 0.22.4
     */
    public ExecutionSide side() {
        if (entry == null) {
            return null;
        }
        return entry.isBuy() ? ExecutionSide.BUY : ExecutionSide.SELL;
    }

    /**
     * Returns the entry amount of this position.
     *
     * <p>
     * For aggregated open positions this is the net open amount.
     * </p>
     *
     * @return the entry amount, or {@code null} when the position has no entry yet
     * @since 0.22.4
     */
    public Num amount() {
        return entry == null ? null : entry.getAmount();
    }

    /**
     * Returns the average entry price of this position.
     *
     * <p>
     * For standard positions this is the entry trade price. For aggregated open
     * positions this is the weighted average entry price of the net exposure.
     * </p>
     *
     * @return the average entry price, or {@code null} when the position has no
     *         entry yet
     * @since 0.22.4
     */
    public Num averageEntryPrice() {
        return entry == null ? null : entry.getPricePerAsset();
    }

    /**
     * Returns the total entry cost of this position.
     *
     * @return the total entry cost, or {@code null} when the position has no entry
     *         yet
     * @since 0.22.4
     */
    public Num totalEntryCost() {
        return entry == null ? null : entry.getValue();
    }

    /**
     * Returns the entry fees currently carried by this position.
     *
     * <p>
     * For aggregated open positions this reflects the summed remaining entry fees.
     * </p>
     *
     * @return the entry fees, or {@code null} when the position has no entry yet
     * @since 0.22.4
     */
    public Num totalFees() {
        return entry == null ? null : entry.getCost();
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Position p) {
            return (entry == null ? p.getEntry() == null : entry.equals(p.getEntry()))
                    && (exit == null ? p.getExit() == null : exit.equals(p.getExit()));
        }
        return false;
    }

    @Override
    public int hashCode() {
        return Objects.hash(entry, exit);
    }

    /**
     * Operates the position at the index-th position.
     *
     * @param index the bar index
     * @return the trade
     * @see #operate(int, Num, Num)
     */
    public Trade operate(int index) {
        return operate(index, NaN, NaN);
    }

    /**
     * Operates the position at the index-th position.
     *
     * @param index  the bar index
     * @param price  the price
     * @param amount the amount
     * @return the trade
     * @throws IllegalStateException if {@link #isOpened()} and index {@literal <}
     *                               entry.index
     */
    public Trade operate(int index, Num price, Num amount) {
        CostModel effectiveTransactionCostModel = getTransactionCostModel();
        Trade trade = null;
        if (isNew()) {
            trade = operate(new BaseTrade(index, startingType, price, amount, effectiveTransactionCostModel));
        } else if (isOpened()) {
            if (index < entry.getIndex()) {
                throw new IllegalStateException("The index i is less than the entryTrade index");
            }
            trade = operate(
                    new BaseTrade(index, startingType.complementType(), price, amount, effectiveTransactionCostModel));
        }
        return trade;
    }

    /**
     * Operates the position with a pre-built trade.
     *
     * @param trade the trade to apply
     * @return the trade
     * @since 0.22.4
     */
    public Trade operate(Trade trade) {
        Objects.requireNonNull(trade, "trade");
        CostModel effectiveTransactionCostModel = getTransactionCostModel();
        if (!trade.getCostModel().equals(effectiveTransactionCostModel)) {
            throw new IllegalArgumentException("Trades and the position must incorporate the same trading cost model");
        }
        if (isNew()) {
            if (trade.getType() != startingType) {
                throw new IllegalArgumentException("The first trade type must match the starting type");
            }
            entry = trade;
            return trade;
        }
        if (isOpened()) {
            if (trade.getType() != startingType.complementType()) {
                throw new IllegalArgumentException("The exit trade type must complement the entry trade type");
            }
            if (trade.getIndex() < entry.getIndex()) {
                throw new IllegalStateException("The index i is less than the entryTrade index");
            }
            exit = trade;
            return trade;
        }
        return null;
    }

    /**
     * @return true if the position is closed, false otherwise
     */
    public boolean isClosed() {
        return (entry != null) && (exit != null);
    }

    /**
     * @return true if the position is opened, false otherwise
     */
    public boolean isOpened() {
        return (entry != null) && (exit == null);
    }

    /**
     * @return true if the position is new, false otherwise
     */
    public boolean isNew() {
        return (entry == null) && (exit == null);
    }

    /**
     * @return true if position is closed and {@link #getProfit()} > 0
     */
    public boolean hasProfit() {
        return getProfit().isPositive();
    }

    /**
     * @return true if position is closed and {@link #getProfit()} {@literal <} 0
     */
    public boolean hasLoss() {
        return getProfit().isNegative();
    }

    /**
     * Calculates the net profit of the position if it is closed. The net profit
     * includes any trading costs.
     *
     * @return the profit or loss of the position
     */
    public Num getProfit() {
        if (isOpened()) {
            return zero();
        } else {
            return getGrossProfit(exit.getPricePerAsset()).minus(getPositionCost());
        }
    }

    /**
     * Calculates the net profit of the position. If it is open, calculates the
     * profit until the final bar. The net profit includes any trading costs.
     *
     * @param finalIndex the index of the final bar to be considered (if position is
     *                   open)
     * @param finalPrice the price of the final bar to be considered (if position is
     *                   open)
     * @return the profit or loss of the position
     */
    public Num getProfit(int finalIndex, Num finalPrice) {
        Num grossProfit = getGrossProfit(finalPrice);
        Num tradingCost = getPositionCost(finalIndex);
        return grossProfit.minus(tradingCost);
    }

    /**
     * Calculates the gross profit of the position if it is closed. The gross profit
     * excludes any trading costs.
     *
     * @return the gross profit of the position
     */
    public Num getGrossProfit() {
        if (isOpened()) {
            return zero();
        } else {
            return getGrossProfit(exit.getPricePerAsset());
        }
    }

    /**
     * Calculates the gross profit of the position. The gross profit excludes any
     * trading costs.
     *
     * @param finalPrice the price of the final bar to be considered (if position is
     *                   open)
     * @return the profit or loss of the position
     */
    public Num getGrossProfit(Num finalPrice) {
        Num grossProfit;
        if (isOpened()) {
            grossProfit = entry.getAmount().multipliedBy(finalPrice).minus(entry.getValue());
        } else {
            grossProfit = exit.getValue().minus(entry.getValue());
        }

        // Profits of long position are losses of short
        if (entry.isSell()) {
            grossProfit = grossProfit.negate();
        }
        return grossProfit;
    }

    /**
     * Calculates the gross return of the position if it is closed. The gross return
     * excludes any trading costs (and includes the base).
     *
     * @return the gross return of the position in percent
     * @see #getGrossReturn(Num)
     */
    public Num getGrossReturn() {
        if (isOpened()) {
            return zero();
        } else {
            return getGrossReturn(exit.getPricePerAsset());
        }
    }

    /**
     * Calculates the gross return of the position, if it exited at the provided
     * price. The gross return excludes any trading costs (and includes the base).
     *
     * @param finalPrice the price of the final bar to be considered (if position is
     *                   open)
     * @return the gross return of the position in percent
     * @see #getGrossReturn(Num, Num)
     */
    public Num getGrossReturn(Num finalPrice) {
        return getGrossReturn(getEntry().getPricePerAsset(), finalPrice);
    }

    /**
     * Calculates the gross return of the position. If either the entry or exit
     * price is {@code NaN}, the close price from given {@code barSeries} is used.
     * The gross return excludes any trading costs (and includes the base).
     *
     * @param barSeries
     * @return the gross return in percent with entry and exit prices from the
     *         barSeries
     * @see #getGrossReturn(Num, Num)
     */
    public Num getGrossReturn(BarSeries barSeries) {
        Num entryPrice = getEntry().getPricePerAsset(barSeries);
        Num exitPrice = getExit().getPricePerAsset(barSeries);
        return getGrossReturn(entryPrice, exitPrice);
    }

    /**
     * Calculates the gross return between entry and exit price in percent. Includes
     * the base.
     *
     * <p>
     * For example:
     * <ul>
     * <li>For buy position with a profit of 4%, it returns 1.04 (includes the base)
     * <li>For sell position with a loss of 4%, it returns 0.96 (includes the base)
     * </ul>
     *
     * @param entryPrice the entry price
     * @param exitPrice  the exit price
     * @return the gross return in percent between entryPrice and exitPrice
     *         (includes the base)
     */
    public Num getGrossReturn(Num entryPrice, Num exitPrice) {
        if (getEntry().isBuy()) {
            return exitPrice.dividedBy(entryPrice);
        } else {
            Num one = entryPrice.getNumFactory().one();
            return ((exitPrice.dividedBy(entryPrice).minus(one)).negate()).plus(one);
        }
    }

    /**
     * Calculates the total cost of the position.
     *
     * @param finalIndex the index of the final bar to be considered (if position is
     *                   open)
     * @return the cost of the position
     */
    public Num getPositionCost(int finalIndex) {
        Num transactionCost = transactionCostModel.calculate(this, finalIndex);
        Num borrowingCost = getHoldingCost(finalIndex);
        return transactionCost.plus(borrowingCost);
    }

    /**
     * Calculates the total cost of the closed position.
     *
     * @return the cost of the position
     */
    public Num getPositionCost() {
        Num transactionCost = transactionCostModel.calculate(this);
        Num borrowingCost = getHoldingCost();
        return transactionCost.plus(borrowingCost);
    }

    /**
     * Calculates the holding cost of the closed position.
     *
     * @return the cost of the position
     */
    public Num getHoldingCost() {
        return holdingCostModel.calculate(this);
    }

    /**
     * Calculates the holding cost of the position.
     *
     * @param finalIndex the index of the final bar to be considered (if position is
     *                   open)
     * @return the cost of the position
     */
    public Num getHoldingCost(int finalIndex) {
        return holdingCostModel.calculate(this, finalIndex);
    }

    /**
     * @return the transaction cost model, or a zero-cost model after
     *         deserialization when the model is unset
     *
     * @since 0.22.2
     */
    public CostModel getTransactionCostModel() {
        return transactionCostModel == null ? new ZeroCostModel() : transactionCostModel;
    }

    /**
     * @return the holding cost model, or a zero-cost model after deserialization
     *         when the model is unset
     *
     * @since 0.22.2
     */
    public CostModel getHoldingCostModel() {
        return holdingCostModel == null ? new ZeroCostModel() : holdingCostModel;
    }

    /**
     * @return the {@link #startingType}
     */
    public TradeType getStartingType() {
        return startingType;
    }

    /**
     * @return the Num of 0
     */
    private Num zero() {
        return entry.getNetPrice().getNumFactory().zero();
    }

    @Override
    public String toString() {
        return "Entry: " + entry + " exit: " + exit;
    }
}
