#!/usr/bin/env python3
"""
백테 틱 청산 공통 — 실매 폴링(STRATEGY_LOOP_SLEEP≈0.1초) 근사.

1분·N분봉 OHLC intrabar(open→high→low→close)는 익절/어깨를 손절보다 먼저
체결하는 낙관 편향을 만든다. 전 전략 백테·파람서치는 ws_ticks 시간순 재생을
기본으로 하고, 틱 없는 구간은 OHLC 폴백을 쓰지 않는다(기본 OFF).
"""
from __future__ import annotations

from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple

from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int


SellFn = Callable[..., Optional[tuple]]


def _param_bool(
    params: Optional[Dict[str, Any]],
    param_key: str,
    env_key: str,
    default: bool,
) -> bool:
    if params is not None and params.get(param_key) is not None:
        s = str(params.get(param_key)).strip().lower()
        if s in ("1", "true", "t", "y", "yes", "on"):
            return True
        if s in ("0", "false", "f", "n", "no", "off", ""):
            return False
    return get_env_bool(env_key, default)


def parse_backtest_time(t: str) -> datetime:
    from kis_trader.utils.trade_time import parse_trade_datetime
    return parse_trade_datetime(t)


def _tick_time_to_ms(tick_time: str) -> int:
    dt = parse_backtest_time(tick_time)
    return int(dt.timestamp() * 1000)


def backtest_tick_poll_ms(
    params: Optional[Dict[str, Any]] = None,
    *,
    strategy_env: str = "",
    default: int = 100,
) -> int:
    """틱 청산 폴링 간격(ms). 실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 하한 50."""
    if params is not None and params.get("backtest_tick_poll_ms") is not None:
        try:
            return max(50, int(float(params["backtest_tick_poll_ms"])))
        except (TypeError, ValueError):
            pass
    if strategy_env:
        v = get_env_int(strategy_env, 0)
        if v > 0:
            return max(50, int(v))
    return max(50, int(get_env_int("BACKTEST_TICK_POLL_MS", default)))


def backtest_sell_slip_pct(
    params: Optional[Dict[str, Any]] = None,
    *,
    strategy_env: str = "",
) -> float:
    if params is not None and params.get("backtest_sell_slip_pct") is not None:
        try:
            return abs(float(params["backtest_sell_slip_pct"]))
        except (TypeError, ValueError):
            pass
    if strategy_env:
        return abs(float(get_env_float(strategy_env, 0.0)))
    return abs(float(get_env_float("BACKTEST_SELL_SLIP_PCT", 0.0)))


def strategy_use_tick_exit(
    params: Optional[Dict[str, Any]],
    env_key: str,
    *,
    default: bool = True,
) -> bool:
    return _param_bool(params, "backtest_use_tick_exit", env_key, default)


def strategy_tick_fallback_ohlc(
    params: Optional[Dict[str, Any]],
    env_key: str,
    *,
    default: bool = False,
) -> bool:
    return _param_bool(params, "backtest_tick_fallback_ohlc", env_key, default)


def collect_minute_ticks(
    ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]],
    code: str,
    minute_key: str,
) -> List[Dict[str, Any]]:
    if not ticks_by_code:
        return []
    bucket = ticks_by_code.get(code) or {}
    try:
        from kis_trader.backtest.shared_ticks import SharedBucketMapping
        if isinstance(bucket, SharedBucketMapping):
            return bucket.column_view_minute(minute_key)
    except Exception:
        pass
    ticks = list(bucket.get(str(minute_key)[:12]) or [])
    ticks.sort(key=lambda x: str(x.get("tick_time") or ""))
    return ticks


def try_sell_on_ticks(
    position: Dict[str, Any],
    ticks: List[Dict[str, Any]],
    params: Dict[str, Any],
    sell_fn: SellFn,
    *,
    is_eod: bool = False,
    entry_time: str = "",
    poll_ms: int = 100,
    slip_pct: float = 0.0,
    low_mode: str = "current",
) -> Optional[Tuple[str, float, str, float]]:
    """
    틱 시간순 청산 검사.

    Returns:
        (reason, fill_price, sell_time, hold_min) 또는 None
    """
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
    except ImportError:
        TickColumnView = None  # type: ignore[misc, assignment]
    if TickColumnView is not None and isinstance(ticks, TickColumnView):
        return _try_sell_on_ticks_columnar(
            position, ticks, params, sell_fn,
            is_eod=is_eod, entry_time=entry_time,
            poll_ms=poll_ms, slip_pct=slip_pct, low_mode=low_mode,
        )

    if not ticks:
        return None

    entry_key = str(entry_time or "")[:12]
    try:
        entry_dt = parse_backtest_time(entry_time or ticks[0].get("tick_time", entry_key))
    except ValueError:
        entry_dt = parse_backtest_time(entry_key)

    session_low: Optional[float] = None
    if str(low_mode).strip().lower() == "session_low":
        ep = float(position.get("entry_price", 0) or 0)
        session_low = float(position.get("session_low", ep) or ep)

    last_check_ms = -10**15
    n = len(ticks)
    poll = max(50, int(poll_ms))
    slip = abs(float(slip_pct))

    for idx, tick in enumerate(ticks):
        tt = str(tick.get("tick_time") or "")
        if len(tt) < 12:
            continue
        if entry_key and tt[:12] < entry_key:
            continue

        try:
            tick_ms = _tick_time_to_ms(tt)
        except ValueError:
            continue

        px = float(tick.get("price") or 0)
        if px <= 0:
            continue

        mp = max(float(position.get("max_price", position["entry_price"])), px)
        position["max_price"] = mp

        if session_low is not None:
            session_low = min(session_low, px)
            position["session_low"] = session_low
            lo_sim = session_low
        else:
            lo_sim = px

        if tick_ms - last_check_ms < poll:
            continue
        last_check_ms = tick_ms

        candle = {
            "high": mp,
            "low": lo_sim,
            "close": px,
            "candle_time": tt[:12],
        }
        eod_here = bool(is_eod and idx == n - 1)
        res = sell_fn(position, candle, params, is_eod=eod_here)
        if not res:
            continue

        reason, _theoretical = res
        fill_px = px
        if slip > 0:
            fill_px = px * (1.0 - slip / 100.0)

        try:
            sell_dt = parse_backtest_time(tt)
        except ValueError:
            sell_dt = parse_backtest_time(tt[:12])
        hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1)
        sell_time = tt[:14] if len(tt) >= 14 else tt[:12]
        return reason, fill_px, sell_time, hold_min

    return None


def _try_sell_on_ticks_columnar(
    position: Dict[str, Any],
    view: Any,
    params: Dict[str, Any],
    sell_fn: SellFn,
    *,
    is_eod: bool = False,
    entry_time: str = "",
    poll_ms: int = 100,
    slip_pct: float = 0.0,
    low_mode: str = "current",
) -> Optional[Tuple[str, float, str, float]]:
    if len(view) == 0:
        return None
    from kis_trader.engine.whipsaw_filter import _tick_epoch_sec

    owner = view.owner
    _epoch = owner._epoch
    _price = owner._price
    _tick_time = owner._tick_time

    poll = max(50, int(poll_ms))
    slip = abs(float(slip_pct))
    entry_key = str(entry_time or "")[:12]
    entry_key_epoch = _tick_epoch_sec(entry_key) if entry_key else 0

    if entry_time:
        _entry_src = entry_time
    else:
        _fi = view.first_idx()
        _entry_src = _tick_time[_fi].decode("utf-8") if _fi >= 0 else entry_key
    try:
        entry_dt = parse_backtest_time(_entry_src)
    except ValueError:
        entry_dt = parse_backtest_time(entry_key)

    session_low: Optional[float] = None
    if str(low_mode).strip().lower() == "session_low":
        ep = float(position.get("entry_price", 0) or 0)
        session_low = float(position.get("session_low", ep) or ep)

    last_check_ms = -10**15
    n = len(view)
    idx = -1
    for i in view.iter_idx():
        idx += 1
        ts = int(_epoch[i])
        if ts <= 0:
            continue
        if entry_key and ts < entry_key_epoch:
            continue
        px = float(_price[i])
        if px <= 0:
            continue

        mp = max(float(position.get("max_price", position["entry_price"])), px)
        position["max_price"] = mp

        if session_low is not None:
            session_low = min(session_low, px)
            position["session_low"] = session_low
            lo_sim = session_low
        else:
            lo_sim = px

        tick_ms = ts * 1000
        if tick_ms - last_check_ms < poll:
            continue
        last_check_ms = tick_ms

        tt = _tick_time[i].decode("utf-8")
        candle = {
            "high": mp,
            "low": lo_sim,
            "close": px,
            "candle_time": tt[:12],
        }
        eod_here = bool(is_eod and idx == n - 1)
        res = sell_fn(position, candle, params, is_eod=eod_here)
        if not res:
            continue

        reason, _theoretical = res
        fill_px = px
        if slip > 0:
            fill_px = px * (1.0 - slip / 100.0)

        try:
            sell_dt = parse_backtest_time(tt)
        except ValueError:
            sell_dt = parse_backtest_time(tt[:12])
        hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1)
        sell_time = tt[:14] if len(tt) >= 14 else tt[:12]
        return reason, fill_px, sell_time, hold_min

    return None


def resolve_backtest_sell(
    position: Dict[str, Any],
    bar: Dict[str, Any],
    params: Dict[str, Any],
    *,
    is_eod: bool = False,
    sell_fn: Optional[SellFn] = None,
    low_mode: str = "current",
    ticks: Optional[List[Dict[str, Any]]] = None,
    use_tick_exit: bool = True,
    tick_fallback_ohlc: bool = False,
    poll_ms: int = 100,
    slip_pct: float = 0.0,
) -> Optional[Tuple[str, float, str, float, str]]:
    """
    한 봉 청산 — 틱 우선, 없으면 OHLC intrabar 폴백(기본 OFF).

    Returns:
        (reason, fill_price, sell_time, hold_min, exit_source)
        exit_source: ws_ticks | ohlc_bar
    """
    from kis_trader.engine.scalping_engine import check_sell_signal_backtest_bar

    if sell_fn is None:
        from kis_trader.engine.scalping_engine import check_sell_signal_live as sell_fn

    ct = str(bar.get("candle_time") or "")
    entry_time = str(position.get("entry_time") or "")

    if use_tick_exit and ticks:
        tick_res = try_sell_on_ticks(
            position, ticks, params, sell_fn,
            is_eod=is_eod, entry_time=entry_time,
            poll_ms=poll_ms, slip_pct=slip_pct, low_mode=low_mode,
        )
        if tick_res:
            reason, fill_px, sell_time, hold_min = tick_res
            return reason, fill_px, sell_time, hold_min, "ws_ticks"

    if not tick_fallback_ohlc:
        return None

    res = check_sell_signal_backtest_bar(
        position, bar, params, is_eod=is_eod, sell_fn=sell_fn, low_mode=low_mode,
    )
    if not res:
        return None
    reason, exit_price = res
    try:
        entry_dt = parse_backtest_time(entry_time)
        sell_dt = parse_backtest_time(ct)
        hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1)
    except ValueError:
        hold_min = 0.0
    return reason, float(exit_price), ct, hold_min, "ohlc_bar"
