# source: https://raw.githubusercontent.com/liliang-cn/trade/3284879148c6634e897f00de4185f6c3898edc09/tests/test_llm_backtest.py
"""Unit tests for the freqtrade backtest runner.

We mock the docker subprocess so these tests run offline. Goal:
- valid input → docker called with expected args, JSON parsed, metrics extracted
- missing data → download-data called first, then backtesting
- existing data → download-data skipped
- backtest fails → BacktestError raised, no metrics
- malformed JSON → metrics zeroed but no crash
"""

from __future__ import annotations

import json
import tempfile
import textwrap
from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from websea_copy_app.config import Settings

from llm_strategy.backtest import (
    BacktestError,
    default_pairs,
    default_timerange,
    parse_backtest_result,
    render_backtest_config,
    run_backtest,
)


# ─── Helpers ─────────────────────────────────────────────────────────────────


def _setup_freqtrade_root() -> Path:
    """Build a tmp freqtrade root with the bits run_backtest needs:
    - user_data/strategies/<ClassName>.py
    - user_data/config.copytrade.json template"""
    tmp = Path(tempfile.mkdtemp())
    user_data = tmp / "user_data"
    (user_data / "strategies").mkdir(parents=True)
    (user_data / "data" / "okx" / "futures").mkdir(parents=True)
    template = {
        "$schema": "https://schema.freqtrade.io/schema.json",
        "dry_run": True,
        "trading_mode": "futures",
        "margin_mode": "isolated",
        "stake_currency": "USDT",
        "stake_amount": 100,
        "max_open_trades": 10,
        "timeframe": "15m",
        "pairlists": [{"method": "StaticPairList"}],
        "exchange": {
            "name": "binance",
            "key": "",
            "secret": "",
            "password": "",
            "pair_whitelist": [".*/USDT:USDT"],
            "pair_blacklist": [],
        },
    }
    (user_data / "config.copytrade.json").write_text(json.dumps(template))

    strategy_code = textwrap.dedent("""\
        # llm-generated: strategy_id=short_grid model=test
        from pandas import DataFrame
        from freqtrade.strategy import IStrategy
        class Github_liliang_cn_trade__test_llm_backtest__20260506_125337(IStrategy):
            INTERFACE_VERSION = 3
            timeframe = "15m"
            stoploss = -0.02
            def populate_indicators(self, df, m): return df
            def populate_entry_trend(self, df, m): return df
            def populate_exit_trend(self, df, m): return df
    """)
    (user_data / "strategies" / "Github_liliang_cn_trade__test_llm_backtest__20260506_125337.py").write_text(strategy_code)
    return tmp


@pytest.fixture
def settings() -> Settings:
    root = _setup_freqtrade_root()
    return Settings(
        manager_freqtrade_root=str(root),
        host_freqtrade_root=str(root),
        llm_api_key="x",
    )


def _good_freqtrade_export(class_name: str = "Github_liliang_cn_trade__test_llm_backtest__20260506_125337") -> dict:
    """Mimic the JSON shape freqtrade `--export trades` writes."""
    return {
        "strategy": {
            class_name: {
                "profit_total": 0.18,
                "profit_total_abs": 1800.0,
                "max_drawdown_account": 0.05,
                "max_drawdown_abs": 200.0,
                "total_trades": 12,
                "wins": 8,
                "losses": 4,
                "draws": 0,
                "sharpe": 1.42,
                "sortino": 2.10,
                "calmar": 1.05,
                "cagr": 0.5,
                "market_change": 0.02,
                "starting_balance": 10000.0,
                "final_balance": 11800.0,
                "backtest_start": "2026-03-01 00:00:00+00:00",
                "backtest_end": "2026-04-30 23:45:00+00:00",
                "results_per_pair": [
                    {"key": "BTC/USDT:USDT", "trades": 12, "profit_total": 0.18, "profit_total_abs": 1800.0, "wins": 8, "losses": 4},
                ],
            }
        }
    }


# ─── Pure-fn tests (no subprocess) ───────────────────────────────────────────


def test_default_timerange_is_60_days():
    tr = default_timerange()
    start, end = tr.split("-")
    assert len(start) == 8 and len(end) == 8


def test_default_pairs_has_btc():
    assert "BTC/USDT:USDT" in default_pairs()


def test_render_backtest_config_overrides_exchange_and_pairs():
    template = {
        "exchange": {"name": "binance", "pair_whitelist": [".*/USDT:USDT"]},
        "pairlists": [{"method": "StaticPairList"}],
    }
    cfg = render_backtest_config(
        template=template,
        exchange="okx",
        pairs=["BTC/USDT:USDT", "ETH/USDT:USDT"],
        stake_amount=200,
        max_open_trades=3,
        timeframe="1h",
    )
    assert cfg["exchange"]["name"] == "okx"
    assert cfg["exchange"]["pair_whitelist"] == ["BTC/USDT:USDT", "ETH/USDT:USDT"]
    assert cfg["exchange"]["key"] == ""  # always cleared
    assert cfg["stake_amount"] == 200
    assert cfg["max_open_trades"] == 3
    assert cfg["timeframe"] == "1h"
    assert cfg["dry_run"] is True


def test_render_backtest_config_does_not_mutate_template():
    template = {"exchange": {"name": "binance", "pair_whitelist": [".*"]}}
    render_backtest_config(
        template=template, exchange="okx", pairs=["BTC/USDT:USDT"],
        stake_amount=100, max_open_trades=5, timeframe="15m",
    )
    assert template["exchange"]["name"] == "binance"


def test_parse_backtest_result_extracts_metrics(tmp_path):
    export = tmp_path / "result.json"
    export.write_text(json.dumps(_good_freqtrade_export()))
    metrics, raw = parse_backtest_result(export_path=export, class_name="Github_liliang_cn_trade__test_llm_backtest__20260506_125337", stdout="...")
    assert metrics.profit_total_pct == pytest.approx(18.0)
    assert metrics.max_drawdown_pct == pytest.approx(5.0)
    assert metrics.trades_count == 12
    assert metrics.win_rate_pct == pytest.approx(100 * 8 / 12)
    assert metrics.sharpe == pytest.approx(1.42)
    assert len(metrics.per_pair) == 1
    assert metrics.per_pair[0]["pair"] == "BTC/USDT:USDT"
    assert raw is not None


def test_parse_backtest_result_handles_missing_export(tmp_path):
    metrics, raw = parse_backtest_result(
        export_path=tmp_path / "nonexistent.json",
        class_name="Github_liliang_cn_trade__test_llm_backtest__20260506_125337",
        stdout="something happened",
    )
    assert metrics.trades_count == 0
    assert raw is None


def test_parse_backtest_result_handles_class_block_missing(tmp_path):
    export = tmp_path / "result.json"
    export.write_text(json.dumps({"strategy": {"OtherClass": {}}}))
    metrics, raw = parse_backtest_result(
        export_path=export, class_name="Github_liliang_cn_trade__test_llm_backtest__20260506_125337", stdout="..."
    )
    assert metrics.trades_count == 0
    assert raw is not None  # raw is read; just our class isn't in it


# ─── End-to-end with subprocess mocked ───────────────────────────────────────


def _mock_docker(returncode: int = 0, stdout: str = "ok"):
    """Returns an async function that mimics _docker_run output."""
    async def fake(*args, **kwargs):
        return returncode, stdout
    return fake


async def test_run_backtest_happy_path(settings):
    """download-data + backtesting both succeed; result JSON parsed."""
    export_target = Path(settings.manager_freqtrade_root) / "user_data" / "backtest_results"

    async def fake_docker(*args, **kwargs):
        # If the command is `backtesting`, write the export file to where freqtrade would
        if "backtesting" in args:
            # Find the --export-filename arg; it points to /freqtrade/user_data/...
            idx = args.index("--export-filename")
            container_path = args[idx + 1]
            host_path = Path(settings.manager_freqtrade_root) / "user_data" / container_path.split("/freqtrade/user_data/", 1)[1]
            host_path.parent.mkdir(parents=True, exist_ok=True)
            host_path.write_text(json.dumps(_good_freqtrade_export()))
            return 0, "BACKTESTING REPORT (fake)\n..."
        return 0, "downloaded\n..."

    with patch("llm_strategy.backtest._docker_run", side_effect=fake_docker):
        result = await run_backtest(
            settings=settings,
            file_name="Github_liliang_cn_trade__test_llm_backtest__20260506_125337.py",
            timerange="20260301-20260430",
            pairs=["BTC/USDT:USDT"],
        )

    assert result.class_name == "Github_liliang_cn_trade__test_llm_backtest__20260506_125337"
    assert result.timerange == "20260301-20260430"
    assert result.pairs == ["BTC/USDT:USDT"]
    assert result.metrics.trades_count == 12
    assert result.metrics.profit_total_pct == pytest.approx(18.0)
    assert "BACKTESTING REPORT" in result.raw_stdout

    # backtest_config_<tag>.json should be cleaned up
    leftover = list((Path(settings.manager_freqtrade_root) / "user_data").glob("backtest_config_*.json"))
    assert leftover == []


async def test_run_backtest_skips_download_when_data_present(settings, tmp_path):
    # Pre-create a feather file freqtrade would have downloaded
    futures_dir = Path(settings.manager_freqtrade_root) / "user_data" / "data" / "okx" / "futures"
    futures_dir.mkdir(parents=True, exist_ok=True)
    (futures_dir / "BTC_USDT_USDT-15m-futures.feather").write_bytes(b"\x00\x01\x02")

    docker_calls: list[tuple] = []

    async def fake_docker(*args, **kwargs):
        docker_calls.append(args)
        if "backtesting" in args:
            idx = args.index("--export-filename")
            container_path = args[idx + 1]
            host_path = Path(settings.manager_freqtrade_root) / "user_data" / container_path.split("/freqtrade/user_data/", 1)[1]
            host_path.parent.mkdir(parents=True, exist_ok=True)
            host_path.write_text(json.dumps(_good_freqtrade_export()))
            return 0, "ok"
        return 0, "ok"

    with patch("llm_strategy.backtest._docker_run", side_effect=fake_docker):
        await run_backtest(
            settings=settings,
            file_name="Github_liliang_cn_trade__test_llm_backtest__20260506_125337.py",
            timerange="20260301-20260430",
            pairs=["BTC/USDT:USDT"],
        )

    commands = ["download-data" if "download-data" in c else "backtesting" if "backtesting" in c else "?" for c in docker_calls]
    assert "download-data" not in commands
    assert "backtesting" in commands


async def test_run_backtest_propagates_backtest_failure(settings):
    async def fake_docker(*args, **kwargs):
        if "backtesting" in args:
            return 1, "ERROR: Strategy XYZ not found"
        return 0, "downloaded"

    with patch("llm_strategy.backtest._docker_run", side_effect=fake_docker):
        with pytest.raises(BacktestError, match="backtest failed"):
            await run_backtest(
                settings=settings,
                file_name="Github_liliang_cn_trade__test_llm_backtest__20260506_125337.py",
                timerange="20260301-20260430",
                pairs=["BTC/USDT:USDT"],
            )


async def test_run_backtest_propagates_download_failure(settings):
    async def fake_docker(*args, **kwargs):
        if "download-data" in args:
            return 1, "exchange okx unavailable"
        return 0, "ok"

    with patch("llm_strategy.backtest._docker_run", side_effect=fake_docker):
        with pytest.raises(BacktestError, match="download-data failed"):
            await run_backtest(
                settings=settings,
                file_name="Github_liliang_cn_trade__test_llm_backtest__20260506_125337.py",
                timerange="20260301-20260430",
                pairs=["BTC/USDT:USDT"],
            )


async def test_run_backtest_rejects_missing_strategy_file(settings):
    with pytest.raises(Exception):  # FileNotFoundError or StrategyValidationError
        await run_backtest(
            settings=settings,
            file_name="DoesNotExist.py",
            timerange="20260301-20260430",
            pairs=["BTC/USDT:USDT"],
        )
