"""Unit tests for Work Unit 5 — micro_trading.py v2 orchestration.

Covers (final-design.md §5, §6, §10.1):
  - Scan deadline mutex (overlap prevention + overrun)
  - Per-scan rollup aggregation + emission
  - Audit-log persistence and DB-failure containment
  - Executor-boundary dry-run guard
  - Rejection-line compression above max_rejection_lines_per_scan

The MicroTradingManager constructor wires live exchange/DB/executor; we
bypass it via ``__new__`` + hand-assignment so tests run without any
external dependency.  Structlog events are captured with
``structlog.testing.capture_logs`` (matches the project's own test style
in tests/test_signal_engine_v2.py).
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from unittest.mock import AsyncMock, MagicMock

from structlog.testing import capture_logs

from src.micro_scanner.config import MicroConfig
from src.micro_trading import MicroTradingManager


# ---------------------------------------------------------------------------
# Fixtures — a hollow MicroTradingManager instance
# ---------------------------------------------------------------------------


@dataclass
class _FakeScore:
    """Plain stand-in for CandidateScore (avoids MagicMock attribute quirks)."""

    score: float = 0.8
    scored_at: Any = None


@dataclass
class _FakeSigilConfig:
    """Minimal stand-in for SigilConfig the manager consults."""

    micro: MicroConfig = field(default_factory=MicroConfig)
    mode: str = "draft"


def _make_manager(
    micro_overrides: dict | None = None,
    executor: Any = None,
    db_conn: Any = None,
) -> MicroTradingManager:
    """Build a MicroTradingManager without invoking the real __init__."""
    mgr = MicroTradingManager.__new__(MicroTradingManager)

    micro = MicroConfig()
    if micro_overrides:
        for k, v in micro_overrides.items():
            setattr(micro, k, v)

    mgr._config = _FakeSigilConfig(micro=micro, mode="draft")
    mgr._micro_cfg = micro
    mgr._db_conn = db_conn or MagicMock()
    mgr._executor = executor or MagicMock()
    mgr._exchange = MagicMock()
    mgr._market_feed = None
    mgr._alert_fn = MagicMock()
    mgr._universe_scanner = MagicMock()
    mgr._scorer = MagicMock()
    mgr._watchlist = MagicMock()
    mgr._signal_engine = MagicMock()
    mgr._capital = MagicMock()
    mgr._exit_managers = {}
    mgr._scan_lock = asyncio.Lock()
    mgr._scan_counter = 0
    mgr._audit_write_failed = 0
    mgr._current_scan_state = {}
    mgr._symbol_protection_store = None
    return mgr


def _empty_state(**overrides: Any) -> dict:
    state = {
        "scan_id": "test-scan",
        "active_symbols": 0,
        "evaluated": 0,
        "passed_all_gates": 0,
        "rejected_score": 0,
        "rejected_stale": 0,
        "rejected_gates": {
            "trend": 0,
            "volume": 0,
            "spread": 0,
            "rsi": 0,
            "depth": 0,
            "resistance": 0,
        },
        "composite_would_pass": 0,
        "composite_scores": [],
        "signals_emitted": 0,
        "symbol_faults": 0,
        "rejection_lines_emitted": 0,
        "suppressed_rejections": 0,
    }
    state.update(overrides)
    return state


def _events_named(events: list[dict], name: str) -> list[dict]:
    return [e for e in events if e.get("event") == name]


# ---------------------------------------------------------------------------
# 1. Scan deadline mutex
# ---------------------------------------------------------------------------


async def test_scan_overlap_prevented_second_call_returns_immediately() -> None:
    """Second invocation while the lock is held → scan_overlap_prevented WARNING."""
    mgr = _make_manager()

    held = asyncio.Event()
    release = asyncio.Event()

    async def slow_scan() -> None:
        held.set()
        await release.wait()

    mgr._do_scan = slow_scan  # type: ignore[method-assign]

    with capture_logs() as events:
        task = asyncio.create_task(mgr.scan_and_trade())
        await held.wait()
        await mgr.scan_and_trade()  # should return immediately
        release.set()
        await task

    assert _events_named(events, "scan_overlap_prevented"), events


async def test_scan_overrun_logs_error_when_do_scan_exceeds_deadline() -> None:
    mgr = _make_manager(micro_overrides={"scan_deadline_seconds": 0.05})

    async def hang() -> None:
        await asyncio.sleep(1.0)

    mgr._do_scan = hang  # type: ignore[method-assign]

    with capture_logs() as events:
        await mgr.scan_and_trade()

    overrun = _events_named(events, "scan_overrun")
    assert overrun, events
    assert overrun[0].get("log_level") == "error"
    assert not mgr._scan_lock.locked()


# ---------------------------------------------------------------------------
# 2. Rollup aggregation
# ---------------------------------------------------------------------------


async def test_rollup_aggregates_gate_results_correctly() -> None:
    mgr = _make_manager()
    mgr._scan_counter = 1
    mgr._current_scan_state = _empty_state(
        active_symbols=30,
        evaluated=30,
        passed_all_gates=2,
        rejected_score=3,
        rejected_stale=1,
        rejected_gates={
            "trend": 12,
            "volume": 18,
            "spread": 3,
            "rsi": 1,
            "depth": 0,
            "resistance": 5,
        },
        composite_would_pass=7,
        composite_scores=[0.5, 0.6, 0.7],
        signals_emitted=2,
    )

    with capture_logs() as events:
        mgr._emit_rollup_if_due()

    rollups = _events_named(events, "micro_scan_rollup")
    assert len(rollups) == 1
    r = rollups[0]
    assert r["evaluated"] == 30
    assert r["passed_all_gates"] == 2
    assert r["signals_emitted"] == 2
    assert r["symbol_faults"] == 0
    assert r["rejected_gates"]["trend"] == 12
    assert r["rejected_gates"]["volume"] == 18
    assert r["composite_would_pass"] == 7
    assert abs(r["composite_mean"] - 0.6) < 1e-6


async def test_rollup_every_n_scans_skips_intermediate() -> None:
    mgr = _make_manager(micro_overrides={"gate_rollup_every_n_scans": 3})

    emit_counts = []
    for i in range(1, 7):
        mgr._scan_counter = i
        mgr._current_scan_state = _empty_state(scan_id=f"scan-{i}")
        with capture_logs() as events:
            mgr._emit_rollup_if_due()
        emit_counts.append(len(_events_named(events, "micro_scan_rollup")))

    assert emit_counts == [0, 0, 1, 0, 0, 1]


# ---------------------------------------------------------------------------
# 3. Audit-log persistence
# ---------------------------------------------------------------------------


async def test_audit_log_row_persisted_when_enabled() -> None:
    db = MagicMock()
    mgr = _make_manager(
        micro_overrides={"audit_log_rejections": True},
        db_conn=db,
    )
    mgr._scan_counter = 1
    mgr._current_scan_state = _empty_state(
        active_symbols=1, evaluated=1, passed_all_gates=1, signals_emitted=1
    )

    with capture_logs():
        mgr._emit_rollup_if_due()

    assert db.execute.called
    args, _ = db.execute.call_args
    sql, params = args
    assert "INSERT INTO trade_audit_log" in sql
    assert params[1] == "scan_rollup"
    assert params[2] == "__SCAN__"
    assert mgr._audit_write_failed == 0


async def test_audit_write_failure_increments_counter_and_does_not_raise() -> None:
    db = MagicMock()
    db.execute.side_effect = RuntimeError("DB offline")
    mgr = _make_manager(
        micro_overrides={"audit_log_rejections": True},
        db_conn=db,
    )
    mgr._scan_counter = 1
    mgr._current_scan_state = _empty_state()

    # Must not raise.
    with capture_logs():
        mgr._emit_rollup_if_due()

    assert mgr._audit_write_failed == 1


async def test_audit_log_not_written_when_disabled() -> None:
    db = MagicMock()
    mgr = _make_manager(
        micro_overrides={"audit_log_rejections": False},
        db_conn=db,
    )
    mgr._scan_counter = 1
    mgr._current_scan_state = _empty_state()
    with capture_logs():
        mgr._emit_rollup_if_due()
    db.execute.assert_not_called()


# ---------------------------------------------------------------------------
# 4. Executor-boundary dry-run guard
# ---------------------------------------------------------------------------


def _fake_ticker() -> dict:
    return {
        "bid": 1.0,
        "ask": 1.01,
        "last": 1.005,
        "baseVolume": 1_000_000,
        "quoteVolume": 1_000_000,
        "high": 1.2,
        "low": 0.9,
    }


def _fake_candles(n: int = 25) -> list[dict]:
    return [
        {
            "open": 1.0,
            "high": 1.1,
            "low": 0.9,
            "close": 1.05,
            "volume": 100,
            "timestamp": i,
        }
        for i in range(n)
    ]


async def test_dry_run_signals_only_skips_executor() -> None:
    executor = MagicMock()
    executor.place_entry = AsyncMock(return_value=MagicMock(id="pos-1"))
    mgr = _make_manager(
        micro_overrides={"dry_run_signals_only": True},
        executor=executor,
    )

    signal = MagicMock(signal_id="sig-123", confidence=0.8)
    mgr._signal_engine.evaluate_entry = MagicMock(return_value=signal)
    mgr._fetch_candles_1m = AsyncMock(return_value=_fake_candles())  # type: ignore[method-assign]
    mgr._scorer.score = MagicMock(return_value=_FakeScore(0.8))
    mgr._capital.calculate_position_size = MagicMock(return_value=Decimal("100"))
    mgr._current_scan_state = _empty_state(active_symbols=1)

    tickers = {"BLURUSDT": _fake_ticker()}
    with capture_logs() as events:
        await mgr._evaluate_entry_for_symbol("BLURUSDT", tickers, max_volume=1_000_000)

    executor.place_entry.assert_not_called()
    would_fire = _events_named(events, "entry_signal_would_fire")
    assert would_fire, events
    assert would_fire[0]["symbol"] == "BLURUSDT"
    assert mgr._current_scan_state["signals_emitted"] == 1


async def test_dry_run_false_executor_is_called() -> None:
    executor = MagicMock()
    executor.place_entry = AsyncMock(return_value=None)
    mgr = _make_manager(
        micro_overrides={"dry_run_signals_only": False},
        executor=executor,
    )

    signal = MagicMock(signal_id="sig-123", confidence=0.8)
    mgr._signal_engine.evaluate_entry = MagicMock(return_value=signal)
    mgr._fetch_candles_1m = AsyncMock(return_value=_fake_candles())  # type: ignore[method-assign]
    mgr._scorer.score = MagicMock(return_value=_FakeScore(0.8))
    mgr._capital.calculate_position_size = MagicMock(return_value=Decimal("100"))
    mgr._current_scan_state = _empty_state(active_symbols=1)

    tickers = {"BLURUSDT": _fake_ticker()}
    with capture_logs():
        await mgr._evaluate_entry_for_symbol("BLURUSDT", tickers, max_volume=1_000_000)
    executor.place_entry.assert_awaited_once()


# ---------------------------------------------------------------------------
# 5. Rejection-line compression
# ---------------------------------------------------------------------------


async def test_rejection_line_compression_bulk_summary() -> None:
    mgr = _make_manager(micro_overrides={"max_rejection_lines_per_scan": 20})
    mgr._scan_counter = 1

    # Flat candles → trend / volume gates fail.
    bad_candles = [
        {
            "open": 1.0,
            "high": 1.0,
            "low": 1.0,
            "close": 1.0,
            "volume": 1.0,
            "timestamp": i,
        }
        for i in range(25)
    ]
    depth = {
        "bid": 1.0,
        "ask": 1.01,
        "bid_depth": 100,
        "ask_depth": 100,
        "high_24h": 1.0,
    }
    ticker = {"high": 1.1, "low": 0.9}
    score = _FakeScore(0.9)

    mgr._current_scan_state = _empty_state(active_symbols=50)

    with capture_logs() as debug_events:
        for i in range(50):
            mgr._record_gate_telemetry(
                symbol=f"SYM{i}",
                candles_1m=bad_candles,
                depth=depth,
                score=score,
                ticker=ticker,
            )

    assert mgr._current_scan_state["rejection_lines_emitted"] == 20
    assert mgr._current_scan_state["suppressed_rejections"] == 30
    per_symbol = _events_named(debug_events, "entry_gates_failed")
    assert len(per_symbol) == 20

    with capture_logs() as rollup_events:
        mgr._emit_rollup_if_due()
    bulk = _events_named(rollup_events, "entry_gates_failed_bulk")
    assert len(bulk) == 1
    assert bulk[0]["suppressed_count"] == 30


# ---------------------------------------------------------------------------
# 6. Unit 3 addendum wiring
# ---------------------------------------------------------------------------


async def test_begin_scan_called_and_symbol_faults_read() -> None:
    """The scan must call engine.begin_scan() and pull engine.symbol_faults into rollup."""
    mgr = _make_manager()

    engine = MagicMock()
    engine.begin_scan = MagicMock()
    engine.symbol_faults = 4
    mgr._signal_engine = engine

    # Stub universe/ticker/watchlist plumbing so _run_scan_cycle reaches the symbol loop.
    mgr._universe_scanner.refresh_universe = AsyncMock(return_value=["BLURUSDT"])
    mgr._universe_scanner.scan_tickers = AsyncMock(
        return_value={"BLURUSDT": _fake_ticker()}
    )
    mgr._universe_scanner.upsert_candidates = MagicMock()
    mgr._scorer.score_from_ticker = MagicMock(return_value=MagicMock(score=0.8))
    mgr._watchlist.evaluate_all = AsyncMock(
        return_value={"promoted": [], "demoted": []}
    )
    mgr._get_active_symbols = MagicMock(return_value=["BLURUSDT"])  # type: ignore[method-assign]
    mgr._capital.refresh_portfolio = MagicMock()
    mgr._evaluate_entry_for_symbol = AsyncMock()  # type: ignore[method-assign]
    mgr._current_scan_state = _empty_state()

    await mgr._run_scan_cycle()

    engine.begin_scan.assert_called_once()
    assert mgr._current_scan_state["symbol_faults"] == 4


def test_build_depth_from_ticker_marks_from_ticker_flag() -> None:
    mgr = _make_manager()
    depth = mgr._build_depth_from_ticker(_fake_ticker())
    assert depth.get("from_ticker") is True
    # And the broken proxy math is preserved (design §13 defers fix to v3).
    assert depth["ask_depth"] == depth["bid_depth"] * 0.8


# ---------------------------------------------------------------------------
# 7. Blocker 2 — zombie filter wiring
# ---------------------------------------------------------------------------


async def test_is_alive_coin_rejects_zombie_in_entry_path() -> None:
    """A ticker with volume below threshold must be rejected before signal eval."""
    mgr = _make_manager()

    zombie_ticker = {
        "bid": 1.0,
        "ask": 1.01,
        "last": 1.005,
        "baseVolume": 100,
        "quoteVolume": 100,
        "high": 1.01,
        "low": 1.0,
    }

    mgr._fetch_candles_1m = AsyncMock(return_value=_fake_candles())  # type: ignore[method-assign]
    mgr._scorer.score = MagicMock(return_value=_FakeScore(0.8))
    mgr._signal_engine.evaluate_entry = MagicMock(return_value=None)
    mgr._current_scan_state = _empty_state(active_symbols=1)

    tickers = {"DEADUSDT": zombie_ticker}
    with capture_logs() as events:
        await mgr._evaluate_entry_for_symbol("DEADUSDT", tickers, max_volume=1_000_000)

    rejections = _events_named(events, "zombie_filter.rejected")
    assert rejections, events
    assert rejections[0]["symbol"] == "DEADUSDT"
    mgr._signal_engine.evaluate_entry.assert_not_called()


async def test_is_alive_coin_allows_alive_in_entry_path() -> None:
    """A ticker above thresholds must proceed to signal evaluation."""
    mgr = _make_manager()

    alive_ticker = _fake_ticker()
    alive_ticker["high"] = 1.2
    alive_ticker["low"] = 0.9
    alive_ticker["last"] = 1.0

    mgr._fetch_candles_1m = AsyncMock(return_value=_fake_candles())  # type: ignore[method-assign]
    mgr._scorer.score = MagicMock(return_value=_FakeScore(0.8))
    mgr._signal_engine.evaluate_entry = MagicMock(return_value=None)
    mgr._capital.calculate_position_size = MagicMock(return_value=Decimal("0"))
    mgr._current_scan_state = _empty_state(active_symbols=1)

    tickers = {"ALIVEUSDT": alive_ticker}
    with capture_logs() as events:
        await mgr._evaluate_entry_for_symbol("ALIVEUSDT", tickers, max_volume=1_000_000)

    rejections = _events_named(events, "zombie_filter.rejected")
    assert not rejections
    mgr._signal_engine.evaluate_entry.assert_called_once()


# ---------------------------------------------------------------------------
# 8. Blocker 3 — SymbolProtectionStore entry gate
# ---------------------------------------------------------------------------


async def test_symbol_protection_store_blocks_reentry() -> None:
    """If the protection store says can_trade=False, entry is skipped."""
    mgr = _make_manager()

    store = MagicMock()
    store.can_trade.return_value = False
    mgr._symbol_protection_store = store

    alive_ticker = _fake_ticker()
    alive_ticker["high"] = 1.2
    alive_ticker["low"] = 0.9
    alive_ticker["last"] = 1.0

    mgr._fetch_candles_1m = AsyncMock(return_value=_fake_candles())  # type: ignore[method-assign]
    mgr._scorer.score = MagicMock(return_value=_FakeScore(0.8))
    mgr._signal_engine.evaluate_entry = MagicMock(return_value=None)
    mgr._current_scan_state = _empty_state(active_symbols=1)

    tickers = {"COOLUSDT": alive_ticker}
    with capture_logs() as events:
        await mgr._evaluate_entry_for_symbol("COOLUSDT", tickers, max_volume=1_000_000)

    rejections = _events_named(events, "symbol_protection.rejected")
    assert rejections, events
    assert rejections[0]["symbol"] == "COOLUSDT"
    mgr._signal_engine.evaluate_entry.assert_not_called()
