import logging
from typing import Any, Literal

from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config
from freqtrade.ft_types import BacktestResultType
from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats
from freqtrade.util import decimals_per_coin, fmt_coin, print_rich_table


logger = logging.getLogger(__name__)


def _get_line_floatfmt(stake_currency: str) -> list[str]:
    """
    Generate floatformat (goes in line with _generate_result_line())
    """
    return ["s", "d", ".2f", f".{decimals_per_coin(stake_currency)}f", ".2f", "d", "s", "s"]


def _get_line_header(
    first_column: str | list[str], stake_currency: str, direction: str = "Trades"
) -> list[str]:
    """
    Generate header lines (goes in line with _generate_result_line())
    """
    return [
        *([first_column] if isinstance(first_column, str) else first_column),
        direction,
        "Avg Profit %",
        f"Tot Profit {stake_currency}",
        "Tot Profit %",
        "Avg Duration",
        "Win  Draw  Loss  Win%",
    ]


def generate_wins_draws_losses(wins, draws, losses):
    if wins > 0 and losses == 0:
        wl_ratio = "100"
    elif wins == 0:
        wl_ratio = "0"
    else:
        wl_ratio = f"{100.0 / (wins + draws + losses) * wins:.1f}" if losses > 0 else "100"
    return f"{wins:>4}  {draws:>4}  {losses:>4}  {wl_ratio:>4}"


def text_table_bt_results(
    pair_results: list[dict[str, Any]], stake_currency: str, title: str
) -> None:
    """
    Generates and returns a text table for the given backtest data and the results dataframe
    :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row
    :param stake_currency: stake-currency - used to correctly name headers
    :param title: Title of the table
    """

    headers = _get_line_header("Pair", stake_currency, "Trades")
    output = [
        [
            t["key"],
            t["trades"],
            t["profit_mean_pct"],
            f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
            t["profit_total_pct"],
            t["duration_avg"],
            generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
        ]
        for t in pair_results
    ]
    # Ignore type as floatfmt does allow tuples but mypy does not know that
    print_rich_table(output, headers, summary=title)


def text_table_tags(
    tag_type: Literal["enter_tag", "exit_tag", "mix_tag"],
    tag_results: list[dict[str, Any]],
    stake_currency: str,
) -> None:
    """
    Generates and returns a text table for the given backtest data and the results dataframe
    :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row
    :param stake_currency: stake-currency - used to correctly name headers
    """
    floatfmt = _get_line_floatfmt(stake_currency)
    fallback: str = ""
    is_list = False
    if tag_type == "enter_tag":
        title = "Enter Tag"
        headers = _get_line_header(title, stake_currency, "Entries")
    elif tag_type == "exit_tag":
        title = "Exit Reason"
        headers = _get_line_header(title, stake_currency, "Exits")
        fallback = "exit_reason"
    else:
        # Mix tag
        title = "Mixed Tag"
        headers = _get_line_header(["Enter Tag", "Exit Reason"], stake_currency, "Trades")
        floatfmt.insert(0, "s")
        is_list = True

    output = [
        [
            *(
                (
                    list(t["key"])
                    if isinstance(t["key"], list | tuple)
                    else [t["key"], ""]
                    if is_list
                    else [t["key"]]
                )
                if t.get("key") is not None and len(str(t["key"])) > 0
                else [t.get(fallback, "OTHER")]
            ),
            t["trades"],
            t["profit_mean_pct"],
            f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
            t["profit_total_pct"],
            t.get("duration_avg"),
            generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
        ]
        for t in tag_results
    ]
    # Ignore type as floatfmt does allow tuples but mypy does not know that
    print_rich_table(output, headers, summary=f"{title.upper()} STATS")


def text_table_periodic_breakdown(
    days_breakdown_stats: list[dict[str, Any]], stake_currency: str, period: str
) -> None:
    """
    Generate small table with Backtest results by days
    :param days_breakdown_stats: Days breakdown metrics
    :param stake_currency: Stakecurrency used
    """
    headers = [
        period.capitalize(),
        "Trades",
        f"Tot Profit {stake_currency}",
        "Profit Factor",
        "Win  Draw  Loss  Win%",
    ]
    output = [
        [
            d["date"],
            d.get("trades", "N/A"),
            fmt_coin(d["profit_abs"], stake_currency, False),
            round(d["profit_factor"], 2) if "profit_factor" in d else "N/A",
            generate_wins_draws_losses(d["wins"], d["draws"], d.get("losses", d.get("loses", 0))),
        ]
        for d in days_breakdown_stats
    ]
    print_rich_table(output, headers, summary=f"{period.upper()} BREAKDOWN")


def text_table_strategy(strategy_results, stake_currency: str, title: str):
    """
    Generate summary table per strategy
    :param strategy_results: Dict of <Strategyname: DataFrame> containing results for all strategies
    :param stake_currency: stake-currency - used to correctly name headers
    """
    headers = _get_line_header("Strategy", stake_currency, "Trades")
    # _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless
    # therefore we slip this column in only for strategy summary here.
    headers.append("Drawdown")

    # Align drawdown string on the center two space separator.
    if "max_drawdown_account" in strategy_results[0]:
        drawdown = [f"{t['max_drawdown_account'] * 100:.2f}" for t in strategy_results]
    else:
        # Support for prior backtest results
        drawdown = [f"{t['max_drawdown_per']:.2f}" for t in strategy_results]

    dd_pad_abs = max([len(t["max_drawdown_abs"]) for t in strategy_results])
    dd_pad_per = max([len(dd) for dd in drawdown])
    drawdown = [
        f"{t['max_drawdown_abs']:>{dd_pad_abs}} {stake_currency}  {dd:>{dd_pad_per}}%"
        for t, dd in zip(strategy_results, drawdown, strict=False)
    ]

    output = [
        [
            t["key"],
            t["trades"],
            f"{t['profit_mean_pct']:.2f}",
            f"{t['profit_total_abs']:.{decimals_per_coin(stake_currency)}f}",
            t["profit_total_pct"],
            t["duration_avg"],
            generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]),
            drawdown,
        ]
        for t, drawdown in zip(strategy_results, drawdown, strict=False)
    ]
    print_rich_table(output, headers, summary=title)


def text_table_add_metrics(strat_results: dict) -> None:
    stake = strat_results["stake_currency"]
    if len(strat_results["trades"]) > 0:
        best_trade = max(strat_results["trades"], key=lambda x: x["profit_ratio"])
        worst_trade = min(strat_results["trades"], key=lambda x: x["profit_ratio"])

        short_metrics = (
            [
                ("", ""),  # Empty line to improve readability
                (
                    "Long / Short trades",
                    f"{strat_results.get('trade_count_long', 'total_trades')} / "
                    f"{strat_results.get('trade_count_short', 0)}",
                ),
                (
                    "Long / Short profit %",
                    f"{strat_results['profit_total_long']:.2%} / "
                    f"{strat_results['profit_total_short']:.2%}",
                ),
                (
                    f"Long / Short profit {stake}",
                    f"{strat_results['profit_total_long_abs']:.{decimals_per_coin(stake)}f} / "
                    f"{strat_results['profit_total_short_abs']:.{decimals_per_coin(stake)}f}",
                ),
            ]
            if strat_results.get("trade_count_short", 0) > 0
            else []
        )

        drawdown_metrics = []
        if "max_relative_drawdown" in strat_results:
            # Compatibility to show old hyperopt results
            drawdown_metrics.append(
                ("Max % of account underwater", f"{strat_results['max_relative_drawdown']:.2%}")
            )
        drawdown_account = (
            strat_results["max_drawdown_account"]
            if "max_drawdown_account" in strat_results
            else strat_results["max_drawdown"]
        )
        drawdown_metrics.extend(
            [
                (
                    "Absolute drawdown",
                    f"{fmt_coin(strat_results['max_drawdown_abs'], stake)} "
                    f"({drawdown_account:.2%})",
                ),
                (
                    "Drawdown duration",
                    strat_results["drawdown_duration"]
                    if "drawdown_duration" in strat_results
                    else "N/A",
                ),
                (
                    "Profit at drawdown start",
                    fmt_coin(strat_results["max_drawdown_high"], stake),
                ),
                (
                    "Profit at drawdown end",
                    fmt_coin(strat_results["max_drawdown_low"], stake),
                ),
                ("Drawdown start", strat_results["drawdown_start"]),
                ("Drawdown end", strat_results["drawdown_end"]),
            ]
        )

        entry_adjustment_metrics = (
            [
                ("Canceled Trade Entries", strat_results.get("canceled_trade_entries", "N/A")),
                ("Canceled Entry Orders", strat_results.get("canceled_entry_orders", "N/A")),
                ("Replaced Entry Orders", strat_results.get("replaced_entry_orders", "N/A")),
            ]
            if strat_results.get("canceled_entry_orders", 0) > 0
            else []
        )

        trading_mode = (
            (
                [
                    (
                        "Trading Mode",
                        (
                            ""
                            if not strat_results.get("margin_mode")
                            or strat_results.get("trading_mode", "spot") == "spot"
                            else f"{strat_results['margin_mode'].capitalize()} "
                        )
                        + f"{strat_results['trading_mode'].capitalize()}",
                    )
                ]
            )
            if "trading_mode" in strat_results
            else []
        )

        # Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
        # command stores these results and newer version of freqtrade must be able to handle old
        # results with missing new fields.
        metrics = [
            ("Backtesting from", strat_results["backtest_start"]),
            ("Backtesting to", strat_results["backtest_end"]),
            *trading_mode,
            ("Max open trades", strat_results["max_open_trades"]),
            ("", ""),  # Empty line to improve readability
            (
                "Total/Daily Avg Trades",
                f"{strat_results['total_trades']} / {strat_results['trades_per_day']}",
            ),
            (
                "Starting balance",
                fmt_coin(strat_results["starting_balance"], stake),
            ),
            (
                "Final balance",
                fmt_coin(strat_results["final_balance"], stake),
            ),
            (
                "Absolute profit ",
                fmt_coin(strat_results["profit_total_abs"], stake),
            ),
            ("Total profit %", f"{strat_results['profit_total']:.2%}"),
            ("CAGR %", f"{strat_results['cagr']:.2%}" if "cagr" in strat_results else "N/A"),
            ("Sortino", f"{strat_results['sortino']:.2f}" if "sortino" in strat_results else "N/A"),
            ("Sharpe", f"{strat_results['sharpe']:.2f}" if "sharpe" in strat_results else "N/A"),
            ("Calmar", f"{strat_results['calmar']:.2f}" if "calmar" in strat_results else "N/A"),
            ("SQN", f"{strat_results['sqn']:.2f}" if "sqn" in strat_results else "N/A"),
            (
                "Profit factor",
                (
                    f"{strat_results['profit_factor']:.2f}"
                    if "profit_factor" in strat_results
                    else "N/A"
                ),
            ),
            (
                "Expectancy (Ratio)",
                (
                    f"{strat_results['expectancy']:.2f} ({strat_results['expectancy_ratio']:.2f})"
                    if "expectancy_ratio" in strat_results
                    else "N/A"
                ),
            ),
            (
                "Avg. daily profit",
                fmt_coin(
                    (strat_results["profit_total_abs"] / strat_results["backtest_days"]),
                    stake,
                ),
            ),
            (
                "Avg. stake amount",
                fmt_coin(strat_results["avg_stake_amount"], stake),
            ),
            (
                "Total trade volume",
                fmt_coin(strat_results["total_volume"], stake),
            ),
            *short_metrics,
            ("", ""),  # Empty line to improve readability
            (
                "Best Pair",
                f"{strat_results['best_pair']['key']} "
                f"{strat_results['best_pair']['profit_total']:.2%}",
            ),
            (
                "Worst Pair",
                f"{strat_results['worst_pair']['key']} "
                f"{strat_results['worst_pair']['profit_total']:.2%}",
            ),
            ("Best trade", f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"),
            ("Worst trade", f"{worst_trade['pair']} {worst_trade['profit_ratio']:.2%}"),
            (
                "Best day",
                fmt_coin(strat_results["backtest_best_day_abs"], stake),
            ),
            (
                "Worst day",
                fmt_coin(strat_results["backtest_worst_day_abs"], stake),
            ),
            (
                "Days win/draw/lose",
                f"{strat_results['winning_days']} / "
                f"{strat_results['draw_days']} / {strat_results['losing_days']}",
            ),
            (
                "Min/Max/Avg. Duration Winners",
                f"{strat_results.get('winner_holding_min', 'N/A')} / "
                f"{strat_results.get('winner_holding_max', 'N/A')} / "
                f"{strat_results.get('winner_holding_avg', 'N/A')}",
            ),
            (
                "Min/Max/Avg. Duration Losers",
                f"{strat_results.get('loser_holding_min', 'N/A')} / "
                f"{strat_results.get('loser_holding_max', 'N/A')} / "
                f"{strat_results.get('loser_holding_avg', 'N/A')}",
            ),
            (
                "Max Consecutive Wins / Loss",
                (
                    (
                        f"{strat_results['max_consecutive_wins']} / "
                        f"{strat_results['max_consecutive_losses']}"
                    )
                    if "max_consecutive_losses" in strat_results
                    else "N/A"
                ),
            ),
            ("Rejected Entry signals", strat_results.get("rejected_signals", "N/A")),
            (
                "Entry/Exit Timeouts",
                f"{strat_results.get('timedout_entry_orders', 'N/A')} / "
                f"{strat_results.get('timedout_exit_orders', 'N/A')}",
            ),
            *entry_adjustment_metrics,
            ("", ""),  # Empty line to improve readability
            ("Min balance", fmt_coin(strat_results["csum_min"], stake)),
            ("Max balance", fmt_coin(strat_results["csum_max"], stake)),
            *drawdown_metrics,
            ("Market change", f"{strat_results['market_change']:.2%}"),
        ]
        print_rich_table(metrics, ["Metric", "Value"], summary="SUMMARY METRICS", justify="left")

    else:
        start_balance = fmt_coin(strat_results["starting_balance"], stake)
        stake_amount = (
            fmt_coin(strat_results["stake_amount"], stake)
            if strat_results["stake_amount"] != UNLIMITED_STAKE_AMOUNT
            else "unlimited"
        )

        message = (
            "No trades made. "
            f"Your starting balance was {start_balance}, "
            f"and your stake was {stake_amount}."
        )
        print(message)


def _show_tag_subresults(results: dict[str, Any], stake_currency: str):
    """
    Print tag subresults (enter_tag, exit_reason_summary, mix_tag_stats)
    """
    if (enter_tags := results.get("results_per_enter_tag")) is not None:
        text_table_tags("enter_tag", enter_tags, stake_currency)

    if (exit_reasons := results.get("exit_reason_summary")) is not None:
        text_table_tags("exit_tag", exit_reasons, stake_currency)

    if (mix_tag := results.get("mix_tag_stats")) is not None:
        text_table_tags("mix_tag", mix_tag, stake_currency)


def show_backtest_result(
    strategy: str, results: dict[str, Any], stake_currency: str, backtest_breakdown: list[str]
):
    """
    Print results for one strategy
    """
    # Print results
    print(f"Result for strategy {strategy}")
    text_table_bt_results(
        results["results_per_pair"], stake_currency=stake_currency, title="BACKTESTING REPORT"
    )
    text_table_bt_results(
        results["left_open_trades"], stake_currency=stake_currency, title="LEFT OPEN TRADES REPORT"
    )

    _show_tag_subresults(results, stake_currency)

    for period in backtest_breakdown:
        if period in results.get("periodic_breakdown", {}):
            days_breakdown_stats = results["periodic_breakdown"][period]
        else:
            days_breakdown_stats = generate_periodic_breakdown_stats(
                trade_list=results["trades"], period=period
            )
        text_table_periodic_breakdown(
            days_breakdown_stats=days_breakdown_stats, stake_currency=stake_currency, period=period
        )

    text_table_add_metrics(results)

    print()


def show_backtest_results(config: Config, backtest_stats: BacktestResultType):
    stake_currency = config["stake_currency"]

    for strategy, results in backtest_stats["strategy"].items():
        show_backtest_result(
            strategy, results, stake_currency, config.get("backtest_breakdown", [])
        )

    if len(backtest_stats["strategy"]) > 0:
        # Print Strategy summary table

        print(
            f"Backtested {results['backtest_start']} -> {results['backtest_end']} |"
            f" Max open trades : {results['max_open_trades']}"
        )
        text_table_strategy(
            backtest_stats["strategy_comparison"], stake_currency, "STRATEGY SUMMARY"
        )


def show_sorted_pairlist(config: Config, backtest_stats: BacktestResultType):
    if config.get("backtest_show_pair_list", False):
        for strategy, results in backtest_stats["strategy"].items():
            print(f"Pairs for Strategy {strategy}: \n[")
            for result in results["results_per_pair"]:
                if result["key"] != "TOTAL":
                    print(f'"{result["key"]}",  // {result["profit_mean"]:.2%}')
            print("]")
