import json
from decimal import Decimal
from pathlib import Path

import pytest
from pydantic import SecretStr

import binance_quant.auto_sell as auto_sell
from binance_quant.auto_sell import run_live_auto_sell_once
from binance_quant.config import Settings
from binance_quant.models import AccountSnapshot, Balance, SymbolMetadata, TradingMode
from binance_quant.storage import Storage


def read_audit_events(tmp_path: Path) -> list[dict]:
    events = []
    for block in (tmp_path / "logs" / "audit.log").read_text(encoding="utf-8").split("=" * 80):
        if "\nJSON:\n" not in block:
            continue
        raw_json = block.split("\nJSON:\n", 1)[1].splitlines()[0]
        events.append(json.loads(raw_json))
    return events


class FakeExchange:
    def __init__(self):
        self.sold = False
        self.calls = []

    def exchange_info(self, symbol):
        self.calls.append(("exchange_info", symbol))
        base = symbol.removesuffix("USDT")
        return SymbolMetadata(
            symbol,
            base,
            "USDT",
            Decimal("5"),
            Decimal("0.00001"),
            Decimal("0.00001"),
            Decimal("0.01"),
            Decimal("0.01"),
        )

    def account(self):
        self.calls.append(("account",))
        return AccountSnapshot(
            {
                "USDT": Balance("USDT", Decimal("10")),
                "BTC": Balance("BTC", Decimal("0.00123456")),
                "ETH": Balance("ETH", Decimal("0")),
            }
        )

    def symbol_order_book_ticker(self, symbol):
        self.calls.append(("symbol_order_book_ticker", symbol))
        return {"symbol": symbol, "bidPrice": "9999.95", "askPrice": "10000"}

    def klines(self, symbol, interval="1h", limit=6):
        self.calls.append(("klines", symbol, interval, limit))
        return [
            [1, "10500", "10510", "10490", "10500", "10", 2],
            [2, "10500", "10500", "10390", "10400", "10", 3],
            [3, "10400", "10400", "10290", "10300", "10", 4],
            [4, "10300", "10300", "10190", "10200", "10", 5],
            [5, "10200", "10200", "10090", "10100", "10", 6],
            [6, "10100", "10100", "9990", "10000", "15", 7],
        ]

    def open_orders(self, symbol):
        self.calls.append(("open_orders", symbol))
        return []

    def create_market_sell_quantity(self, symbol, quantity, client_order_id):
        self.calls.append(("create_market_sell_quantity", symbol, quantity, client_order_id))
        self.sold = True
        return {
            "symbol": symbol,
            "side": "SELL",
            "type": "MARKET",
            "status": "FILLED",
            "clientOrderId": client_order_id,
            "orderId": 22334,
            "orderListId": -1,
            "executedQty": str(quantity),
            "cummulativeQuoteQty": "12.29987700",
            "transactTime": 1_700_000_001_000,
            "fills": [
                {
                    "tradeId": 77889,
                    "price": "9999.90",
                    "qty": str(quantity),
                    "commission": "0.01229988",
                    "commissionAsset": "USDT",
                }
            ],
        }


class FalsyExchange(FakeExchange):
    def __bool__(self):
        return False


class NoExchangeCalls:
    sold = False

    def __getattr__(self, name):
        raise AssertionError(f"exchange method {name} should not be called")


def settings(tmp_path: Path, enabled=True, mode=TradingMode.LIVE, daily_limit=5, **overrides) -> Settings:
    values = {
        "_env_file": None,
        "mode": mode,
        "api_key": "key",
        "api_secret": SecretStr("secret"),
        "database_path": tmp_path / "audit.sqlite3",
        "live_auto_seller_enabled": enabled,
        "auto_sell_symbols": ("BTCUSDT",),
        "auto_sell_min_score": Decimal("4"),
        "auto_sell_daily_limit": daily_limit,
    }
    values.update(overrides)
    return Settings(**values)


def test_run_live_auto_sell_once_sells_downside_holding(tmp_path):
    fake = FakeExchange()

    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=fake)

    assert result.status == "sold"
    assert result.symbol == "BTCUSDT"
    assert fake.sold
    sell_call = next(call for call in fake.calls if call[0] == "create_market_sell_quantity")
    assert sell_call[1:3] == ("BTCUSDT", Decimal("0.00123"))
    audit = Storage(tmp_path / "audit.sqlite3")
    [run] = audit.list_auto_sell_runs()
    assert run["id"] == result.run_id
    assert run["status"] == "sold"
    assert run["quantity"] == "0.00123"
    assert [event["run_id"] for event in audit.list_auto_sell_events(result.run_id)] == [result.run_id]


def test_run_live_auto_sell_once_records_filled_trade_for_cost_basis(tmp_path):
    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=FakeExchange())

    audit = Storage(tmp_path / "audit.sqlite3")
    [fill] = audit.list_spot_trade_fills("BTCUSDT")

    assert result.status == "sold"
    assert fill["trade_id"] == 77889
    assert fill["side"] == "SELL"
    assert fill["price"] == "9999.90"
    assert fill["quantity"] == "0.00123"
    assert fill["quote_quantity"] == "12.29987700"
    assert fill["commission_asset"] == "USDT"


def test_run_live_auto_sell_once_writes_scan_risk_and_execution_audit_events(tmp_path):
    fake = FakeExchange()

    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=fake)

    assert result.status == "sold"
    events_by_name = {event["event"]: event for event in read_audit_events(tmp_path)}
    scan_event = events_by_name["auto_sell.scan"]
    assert scan_event["params"]["symbols"] == ["BTCUSDT"]
    assert scan_event["formula"]["spread_bps"] == "(ask - bid) / ((ask + bid) / 2) * 10000"
    assert scan_event["formula"]["score"] == "downside flags + bearish volume spike + positive downside percent"
    assert scan_event["result"]["candidates"][0]["symbol"] == "BTCUSDT"
    assert scan_event["result"]["candidates"][0]["score"] == "7.8462"

    risk_event = events_by_name["auto_sell.risk"]
    assert risk_event["params"]["symbol"] == "BTCUSDT"
    assert risk_event["result"]["approved"] is True
    assert risk_event["result"]["reason"] == "approved"
    assert risk_event["result"]["quantity"] == "0.00123"

    execution_event = events_by_name["auto_sell.execution"]
    assert execution_event["params"] == {"symbol": "BTCUSDT", "run_id": result.run_id}
    assert execution_event["result"]["status"] == "sold"
    assert execution_event["result"]["reason"] == "market sell filled"


@pytest.mark.parametrize(
    ("mode", "enabled", "reason"),
    [
        (TradingMode.LIVE, False, "live auto seller disabled"),
        (TradingMode.DRY_RUN, True, "live mode required"),
    ],
)
def test_run_live_auto_sell_once_safety_gates_short_circuit_before_exchange_calls(tmp_path, mode, enabled, reason):
    result = run_live_auto_sell_once(settings(tmp_path, enabled=enabled, mode=mode), exchange_client=NoExchangeCalls())

    assert result.status == "rejected"
    assert result.reason == reason


def test_run_live_auto_sell_once_disabled_short_circuits_before_constructing_exchange(tmp_path, monkeypatch):
    def fail_construct(*args, **kwargs):
        raise AssertionError("exchange client should not be constructed")

    monkeypatch.setattr(auto_sell, "BinanceSpotClient", fail_construct)

    result = run_live_auto_sell_once(settings(tmp_path, enabled=False))

    assert result.status == "rejected"
    assert result.reason == "live auto seller disabled"


def test_run_live_auto_sell_once_passes_proxy_to_exchange_client(tmp_path, monkeypatch):
    captured = {}

    class ConstructedExchange(FakeExchange):
        def __init__(self, **kwargs):
            super().__init__()
            captured.update(kwargs)

    monkeypatch.setattr(auto_sell, "BinanceSpotClient", ConstructedExchange)

    result = run_live_auto_sell_once(settings(tmp_path, proxy_url="http://192.168.1.3:28090"))

    assert result.status == "sold"
    assert captured["proxy_url"] == "http://192.168.1.3:28090"


def test_run_live_auto_sell_once_uses_falsy_injected_exchange(tmp_path, monkeypatch):
    def fail_construct(*args, **kwargs):
        raise AssertionError("exchange client should not be constructed")

    monkeypatch.setattr(auto_sell, "BinanceSpotClient", fail_construct)
    fake = FalsyExchange()

    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=fake)

    assert result.status == "sold"
    assert fake.sold


def test_run_live_auto_sell_once_records_no_candidate_without_sell(tmp_path):
    class SidewaysExchange(FakeExchange):
        def klines(self, symbol, interval="1h", limit=6):
            self.calls.append(("klines", symbol, interval, limit))
            return [
                [1, "10000", "10010", "9990", "10000", "10", 2],
                [2, "10000", "10020", "9990", "10010", "10", 3],
                [3, "10010", "10020", "9990", "10000", "10", 4],
                [4, "10000", "10020", "9990", "10010", "10", 5],
                [5, "10010", "10020", "9990", "10000", "10", 6],
                [6, "10000", "10020", "9990", "10010", "8", 7],
            ]

    fake = SidewaysExchange()

    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=fake)

    assert result.status == "no_candidate"
    assert result.symbol == ""
    assert result.reason == "no candidate passed scanner"
    assert not fake.sold
    assert not any(call[0] == "create_market_sell_quantity" for call in fake.calls)


def test_run_live_auto_sell_once_skips_zero_holdings_without_sell(tmp_path):
    class EmptyHoldingExchange(FakeExchange):
        def account(self):
            self.calls.append(("account",))
            return AccountSnapshot({"BTC": Balance("BTC", Decimal("0")), "USDT": Balance("USDT", Decimal("10"))})

    fake = EmptyHoldingExchange()

    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=fake)

    assert result.status == "rejected"
    assert result.reason == "no free balance"
    assert not fake.sold
    assert not any(call[0] == "create_market_sell_quantity" for call in fake.calls)


def test_run_live_auto_sell_once_rejects_existing_open_sell_order(tmp_path):
    class OpenSellOrderExchange(FakeExchange):
        def open_orders(self, symbol):
            self.calls.append(("open_orders", symbol))
            return [{"symbol": symbol, "side": "SELL", "type": "LIMIT"}]

    fake = OpenSellOrderExchange()

    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=fake)

    assert result.status == "rejected"
    assert result.reason == "open sell order exists"
    assert not fake.sold


def test_run_live_auto_sell_once_skips_symbol_with_open_sell_order_and_sells_next_candidate(tmp_path):
    class MultiHoldingExchange(FakeExchange):
        def account(self):
            self.calls.append(("account",))
            return AccountSnapshot(
                {
                    "BTC": Balance("BTC", Decimal("0.00123456")),
                    "ETH": Balance("ETH", Decimal("0.25")),
                    "USDT": Balance("USDT", Decimal("10")),
                }
            )

        def open_orders(self, symbol):
            self.calls.append(("open_orders", symbol))
            if symbol == "BTCUSDT":
                return [{"symbol": symbol, "side": "SELL", "type": "LIMIT"}]
            return []

    fake = MultiHoldingExchange()

    result = run_live_auto_sell_once(
        settings(tmp_path, auto_sell_symbols=("BTCUSDT", "ETHUSDT")),
        exchange_client=fake,
    )

    assert result.status == "sold"
    assert result.symbol == "ETHUSDT"
    sell_call = next(call for call in fake.calls if call[0] == "create_market_sell_quantity")
    assert sell_call[1] == "ETHUSDT"


def test_run_live_auto_sell_once_skips_below_min_notional_and_sells_next_candidate(tmp_path):
    class MultiHoldingExchange(FakeExchange):
        def account(self):
            self.calls.append(("account",))
            return AccountSnapshot(
                {
                    "BTC": Balance("BTC", Decimal("0.00001")),
                    "ETH": Balance("ETH", Decimal("0.25")),
                    "USDT": Balance("USDT", Decimal("10")),
                }
            )

    fake = MultiHoldingExchange()

    result = run_live_auto_sell_once(
        settings(tmp_path, auto_sell_symbols=("BTCUSDT", "ETHUSDT")),
        exchange_client=fake,
    )

    assert result.status == "sold"
    assert result.symbol == "ETHUSDT"
    sell_call = next(call for call in fake.calls if call[0] == "create_market_sell_quantity")
    assert sell_call[1] == "ETHUSDT"


def test_run_live_auto_sell_once_rejects_below_cost_sell_before_minimum_hold(tmp_path):
    audit = Storage(tmp_path / "audit.sqlite3")
    audit.initialize()
    audit.record_spot_trade_fill(
        "BTCUSDT",
        _spot_buy(1, price="11000", qty="0.00123", quote_qty="13.53", time_ms=1_700_000_000_000),
    )
    fake = FakeExchange()

    result = run_live_auto_sell_once(
        settings(tmp_path, auto_sell_min_hold_seconds=3600),
        exchange_client=fake,
        now_ms=1_700_000_600_000,
    )

    assert result.status == "rejected"
    assert result.symbol == "BTCUSDT"
    assert result.reason == "sell below cost before minimum hold"
    assert not fake.sold
    assert not any(call[0] == "create_market_sell_quantity" for call in fake.calls)


def test_run_live_auto_sell_once_allows_below_cost_sell_after_minimum_hold(tmp_path):
    audit = Storage(tmp_path / "audit.sqlite3")
    audit.initialize()
    audit.record_spot_trade_fill(
        "BTCUSDT",
        _spot_buy(1, price="11000", qty="0.00123", quote_qty="13.53", time_ms=1_700_000_000_000),
    )
    fake = FakeExchange()

    result = run_live_auto_sell_once(
        settings(tmp_path, auto_sell_min_hold_seconds=3600),
        exchange_client=fake,
        now_ms=1_700_004_000_000,
    )

    assert result.status == "sold"
    assert result.symbol == "BTCUSDT"
    assert fake.sold


def test_run_live_auto_sell_once_records_pre_sell_failure_without_order(tmp_path):
    class BadTickerExchange(FakeExchange):
        def symbol_order_book_ticker(self, symbol):
            self.calls.append(("symbol_order_book_ticker", symbol))
            return {"symbol": symbol, "bidPrice": "9999.95"}

    fake = BadTickerExchange()

    result = run_live_auto_sell_once(settings(tmp_path), exchange_client=fake)

    assert result.status == "rejected"
    assert result.symbol == ""
    assert result.reason == "pre-sell data error"
    assert not fake.sold
    assert not any(call[0] == "create_market_sell_quantity" for call in fake.calls)
    [run] = Storage(tmp_path / "audit.sqlite3").list_auto_sell_runs()
    assert run["id"] == result.run_id
    assert run["status"] == "rejected"
    assert run["reason"] == "pre-sell data error"


def test_run_live_auto_sell_once_rechecks_daily_limit_with_reservation(tmp_path):
    audit = Storage(tmp_path / "audit.sqlite3")
    audit.initialize()
    audit.record_auto_sell_run("ETHUSDT", "reserved", Decimal("4"), "pending", Decimal("0.1"))
    fake = FakeExchange()

    result = run_live_auto_sell_once(
        settings(tmp_path, daily_limit=1),
        exchange_client=fake,
        now_ms=1_700_000_000_000,
    )

    assert result.status == "rejected"
    assert result.reason == "daily auto-sell limit reached"
    assert not fake.sold
    assert not any(call[0] == "create_market_sell_quantity" for call in fake.calls)


def _spot_buy(trade_id: int, *, price: str, qty: str, quote_qty: str, time_ms: int) -> dict:
    return {
        "id": trade_id,
        "orderId": trade_id + 100,
        "orderListId": -1,
        "price": price,
        "qty": qty,
        "quoteQty": quote_qty,
        "commission": "0",
        "commissionAsset": "BNB",
        "time": time_ms,
        "isBuyer": True,
        "isMaker": False,
        "isBestMatch": True,
    }
