import os
import sys
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import requests
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib
matplotlib.use("Agg")  # biar bisa jalan tanpa display/GUI (server/headless)
import mplfinance as mpf

# ============================================================
# NEUROBRO SCALPING & LONG SYSTEM — V1.0
# Implementasi lengkap resep 3-layer filter (Likuiditas -> Teknikal 3/5 -> Sentimen)
# + entry/SL/support-resistance sesuai dokumen resep.
# ============================================================

# ============ KONFIGURASI DASAR ============
TELEGRAM_BOT_TOKEN = "ISI_TOKEN_BOT_TELEGRAM_KAMU"
TELEGRAM_CHAT_ID = "ISI_CHAT_ID_KAMU"

TICKER_FILE = "tickers_idx.txt"

MIN_VOLUME = 100_000     # pre-filter cepat: skip saham yg volume bar terakhirnya receh banget
BATCH_SIZE = 50
BATCH_DELAY_SEC = 2

# --- TIMEFRAME ---
# "1h"  -> MODE SCALP. Dipakai sebagai proksi "30M chart" di resep (yfinance IDX
#          gak selalu nyediain 30m yg stabil, 1h paling deket & reliable).
# "1d"  -> MODE LONG/SWING harian.
# "1wk" -> MODE LONG/POSITION mingguan.
# Trend filter (SMA50) SELALU dihitung dari data HARIAN sesuai resep ("SMA 50 -
# Daily Chart"), terlepas dari TIMEFRAME primary di atas. Kalau primary != "1d",
# script otomatis fetch tambahan data harian khusus buat trend filter + cek
# likuiditas Layer 1 (volume/value traded harus dihitung dari basis harian).
TIMEFRAME = "1d"

TIMEFRAME_CONFIG = {
    "1h":  {"interval": "60m", "period": "1mo"},
    "1d":  {"interval": "1d",  "period": "2y"},
    "1wk": {"interval": "1wk", "period": "5y"},
}
DAILY_REF_PERIOD = "1y"   # period buat fetch tambahan data harian (trend filter + layer1)

MODE = "scalp" if TIMEFRAME == "1h" else "long"

# --- FILTER LIKUIDITAS & FREE FLOAT (Layer 1) ---
MIN_AVG_VALUE_TRADED = 1_000_000_000   # rata-rata nilai transaksi 20 hari (Rupiah)
MIN_FREE_FLOAT_PCT = 25                # minimal free float (%), dari cache free_float_idx.csv
FREE_FLOAT_FILE = "free_float_idx.csv"
REQUIRE_FREE_FLOAT_DATA = False        # True = saham tanpa data free float otomatis di-skip

SCALP_MIN_VOLUME_MULT = 5   # volume hari ini vs rata-rata 20 hari (scalp)
LONG_MIN_VOLUME_MULT = 3    # volume hari ini vs rata-rata 20 hari (long)
VOLUME_AVG_PERIOD = 20

SCALP_PRICE_CEILING = None  # contoh: 200 -> cuma scalp saham di bawah Rp200. None = disable (bebas)

CORP_ACTION_LOOKBACK_SCALP_DAYS = 30   # scalp: gak boleh ada stock split dlm 30 hari terakhir
CORP_ACTION_LOOKBACK_LONG_DAYS = 5     # long: boleh split asal udah lewat 5 hari

CHECK_LIVE_SPREAD = False   # True -> nambah 1 API call/ticker buat cek bid-ask spread real-time

# --- INDIKATOR TEKNIKAL (Layer 2) ---
EMA_FAST = 9
EMA_SLOW = 21
RSI_PERIOD = 14
SMA_TREND_PERIOD = 50
VOLUME_SPIKE_LOOKBACK_BARS = 3 # spike harus terjadi dlm 3 bar terakhir, kalau lebih -> kadaluarsa

# --- BOBOT SKOR (Layer 2) — total 100, minimal 70 buat lolos ---
MAX_SCORE_TOTAL = 100
MIN_SCORE_TOTAL = 70

# Trend Filter - SMA50, dihitung dari data harian (bobot 25)
TREND_MAX_POINTS = 25
TREND_STRONG_PCT = 3       # >3% di atas SMA50
TREND_MODERATE_PCT = 1     # 1-3% di atas SMA50
TREND_AT_SMA_BAND_PCT = 1  # |diff| <= ini dianggap "tepat di SMA50"
TREND_POINTS = {"strong": 25, "moderate": 20, "at_sma": 10, "below": 0}

# Entry Timing - EMA9/21 (bobot 20)
ENTRY_MAX_POINTS = 20
EMA_SLOPE_LOOKBACK_BARS = 3     # buat cek EMA "naik" vs "flat"
EMA_FLAT_THRESHOLD_PCT = 0.15   # slope di bawah ini dianggap flat/datar
ENTRY_POINTS = {"full_cross_rising": 20, "consolidation": 10, "flat_or_weak": 5, "bearish": 0}

# Volume Spike (bobot 20) — list (min_spike_pct, poin), urut menurun
VOLUME_MAX_POINTS = 20
VOLUME_TIERS = [(80, 20), (60, 17), (40, 14), (20, 8)]

# RSI (bobot 20) — list ((low, high), poin)
RSI_MAX_POINTS = 20
RSI_TIERS = [((46, 55), 20), ((56, 65), 15), ((30, 45), 10), ((66, 75), 8)]

# Price Action - 3 bar terakhir (bobot 15)
PRICE_ACTION_MAX_POINTS = 15
PRICE_ACTION_POINTS = {"full_higher_low_green": 15, "partial_higher_low": 12,
                        "higher_low_doji": 8, "no_pattern": 3, "bearish_or_lower_high": 0}

SR_LOOKBACK_BARS = 90
SR_PIVOT_LR = 3            # left/right bar buat deteksi pivot high/low
SR_CLUSTER_TOL_PCT = 1.5   # toleransi gabungin level yg berdekatan jadi satu zona
SR_MIN_TOUCH_USABLE = 2    # minimal disentuh 2x biar valid dipakai entry (sesuai resep entry hierarchy)
SR_STRONG_TOUCH = 3        # >=3x disentuh -> klasifikasi "kuat" (sesuai resep S&R)

FIB_LOOKBACK_BARS = 60
FIB_SCALP_LEVEL = 0.382
FIB_LONG_LEVELS = (0.5, 0.618)

SL_SCALP_LOOKBACK_BARS = 10
SL_SCALP_MAX_PCT = 2.0        # jarak SL scalp maksimal wajar 1.5-2% dari entry
SL_LONG_PCT_RANGE = (4.0, 6.0)  # jarak SL long wajar 4-6% dari entry
SL_LONG_ZONE_BUFFER_PCT = 1.0   # 1% di bawah zona demand

IHSG_TICKER = "^JKSE"

# --- JADWAL: kapan robot boleh jalan ---
RUN_DAYS = [0, 1, 2, 3, 4]     # 0=Senin ... 4=Jumat
RUN_HOUR_START = 9             # jam mulai boleh screening (WIB)
RUN_HOUR_END = 16              # jam terakhir boleh screening (WIB)
ENFORCE_SCHEDULE = False  # False = boleh jalan walau market lagi tutup (weekend/di luar jam bursa),
                          # tetep pake data terakhir yang ke-fetch dari yfinance (last close/last candle)

# --- SELF-LOOP MODE ---
SELF_LOOP = True

# --- CHART OTOMATIS ---
CHARTS_DIR = "charts"
SEND_CHART_TO_TELEGRAM = True
MAX_CHARTS_PER_RUN = 15
CHART_LOOKBACK_CANDLES = 90
# =======================================


def is_within_schedule() -> bool:
    if not ENFORCE_SCHEDULE:
        return True
    now = datetime.now(ZoneInfo("Asia/Jakarta"))
    if now.weekday() not in RUN_DAYS:
        print(f"[SKIP] Hari ini ({now.strftime('%A')}) di luar RUN_DAYS.")
        return False
    if not (RUN_HOUR_START <= now.hour < RUN_HOUR_END):
        print(f"[SKIP] Jam sekarang ({now.hour}:00 WIB) di luar jadwal.")
        return False
    return True


def load_tickers() -> list[str]:
    with open(TICKER_FILE) as f:
        codes = [line.strip() for line in f if line.strip()]
    return [f"{code}.JK" for code in codes]


def load_free_float() -> dict:
    if not os.path.exists(FREE_FLOAT_FILE):
        print(f"[WARN] {FREE_FLOAT_FILE} belum ada - jalankan update_free_float.py dulu "
              f"kalau mau filter free float aktif penuh.")
        return {}

    free_float_map = {}
    with open(FREE_FLOAT_FILE) as f:
        next(f)  # skip header
        for line in f:
            parts = line.strip().split(",")
            if len(parts) == 2 and parts[1]:
                try:
                    free_float_map[parts[0]] = float(parts[1])
                except ValueError:
                    continue
    return free_float_map


def chunk(lst: list, size: int):
    for i in range(0, len(lst), size):
        yield lst[i:i + size]


# ============ INDIKATOR DASAR ============

def calc_rsi(close: pd.Series, period: int = RSI_PERIOD) -> pd.Series:
    delta = close.diff()
    gain = delta.clip(lower=0)
    loss = -delta.clip(upper=0)
    avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
    rs = avg_gain / avg_loss
    return 100 - (100 / (1 + rs))


def calc_ema(series: pd.Series, period: int) -> pd.Series:
    return series.ewm(span=period, adjust=False).mean()


def estimate_tick_size(price: float) -> float:
    """Fraksi harga IDX (aturan BEI)."""
    if price < 200:
        return 1
    elif price < 500:
        return 2
    elif price < 2000:
        return 5
    elif price < 5000:
        return 10
    else:
        return 25


def compute_common_indicators(df: pd.DataFrame) -> pd.DataFrame:
    out = df.copy()
    out["ema_fast"] = calc_ema(out["Close"], EMA_FAST)
    out["ema_slow"] = calc_ema(out["Close"], EMA_SLOW)
    out["rsi"] = calc_rsi(out["Close"], RSI_PERIOD)
    out["vol_avg20"] = out["Volume"].rolling(VOLUME_AVG_PERIOD).mean()
    out["vol_ratio"] = out["Volume"] / out["vol_avg20"]
    out["is_green"] = out["Close"] > out["Open"]
    out["body"] = (out["Close"] - out["Open"]).abs()
    out["range"] = (out["High"] - out["Low"]).replace(0, np.nan)
    out["body_ratio"] = out["body"] / out["range"]
    return out


def compute_trend_sma(daily_df: pd.DataFrame, period: int = SMA_TREND_PERIOD) -> pd.DataFrame:
    out = daily_df.copy()
    out["sma_trend"] = out["Close"].rolling(period).mean()
    return out


def get_days_since_last_split(daily_df: pd.DataFrame):
    if daily_df is None or "Stock Splits" not in daily_df.columns:
        return None
    splits = daily_df[daily_df["Stock Splits"].fillna(0) != 0]
    if splits.empty:
        return None
    last_split_date = splits.index[-1]
    last_date = daily_df.index[-1]
    return (last_date - last_split_date).days


# ============ LAYER 1 — LIKUIDITAS & KELAYAKAN ============

def check_layer1(daily_df: pd.DataFrame, mode: str, ticker: str, free_float_map: dict,
                  price_ceiling: float | None = None) -> dict:
    checks = {}
    warnings = []
    fails = []

    # --- volume hari ini vs rata-rata 20 hari (basis harian) ---
    if daily_df is None or len(daily_df) < VOLUME_AVG_PERIOD + 1:
        checks["volume_multiple"] = {"status": "unknown"}
        warnings.append("volume_multiple: data harian belum cukup buat dihitung")
    else:
        avg_vol20 = daily_df["Volume"].iloc[-(VOLUME_AVG_PERIOD + 1):-1].mean()
        today_vol = daily_df["Volume"].iloc[-1]
        if not avg_vol20 or pd.isna(avg_vol20):
            checks["volume_multiple"] = {"status": "unknown"}
            warnings.append("volume_multiple: rata-rata volume 0/NaN")
        else:
            ratio = today_vol / avg_vol20
            threshold = SCALP_MIN_VOLUME_MULT if mode == "scalp" else LONG_MIN_VOLUME_MULT
            ok = ratio >= threshold
            checks["volume_multiple"] = {"status": "pass" if ok else "fail",
                                          "ratio": round(float(ratio), 2), "threshold": threshold}
            if not ok:
                fails.append(f"volume hari ini {ratio:.2f}x rata-rata 20 hari (min {threshold}x)")

    # --- rata-rata nilai transaksi 20 hari (Rupiah) ---
    if daily_df is not None and len(daily_df) >= VOLUME_AVG_PERIOD:
        avg_value = (daily_df["Close"] * daily_df["Volume"]).iloc[-VOLUME_AVG_PERIOD:].mean()
        ok = avg_value >= MIN_AVG_VALUE_TRADED
        checks["avg_value_traded"] = {"status": "pass" if ok else "fail", "value": round(float(avg_value))}
        if not ok:
            fails.append(f"rata-rata nilai transaksi 20 hari Rp{avg_value:,.0f} (min Rp{MIN_AVG_VALUE_TRADED:,.0f})")
    else:
        checks["avg_value_traded"] = {"status": "unknown"}
        warnings.append("avg_value_traded: data harian belum cukup")

    # --- free float ---
    code = ticker.replace(".JK", "")
    ff = free_float_map.get(code)
    if ff is None:
        checks["free_float"] = {"status": "unknown"}
        warnings.append("free_float: data gak ada di cache")
        if REQUIRE_FREE_FLOAT_DATA:
            fails.append("free_float: data wajib tapi gak tersedia (REQUIRE_FREE_FLOAT_DATA=True)")
    else:
        ok = ff >= MIN_FREE_FLOAT_PCT
        checks["free_float"] = {"status": "pass" if ok else "fail", "value": ff}
        if not ok:
            fails.append(f"free float {ff}% (min {MIN_FREE_FLOAT_PCT}%)")

    # --- harga (opsional, scalp murah) ---
    if mode == "scalp" and price_ceiling is not None and daily_df is not None and len(daily_df) > 0:
        close = float(daily_df["Close"].iloc[-1])
        ok = close < price_ceiling
        checks["price_ceiling"] = {"status": "pass" if ok else "fail", "close": round(close, 2)}
        if not ok:
            fails.append(f"harga Rp{close:,.0f} di atas batas scalp murah Rp{price_ceiling:,.0f}")

    # --- aksi korporasi: stock split ---
    split_days = get_days_since_last_split(daily_df) if daily_df is not None else None
    if split_days is not None:
        lookback = CORP_ACTION_LOOKBACK_SCALP_DAYS if mode == "scalp" else CORP_ACTION_LOOKBACK_LONG_DAYS
        ok = split_days > lookback
        checks["corp_action_split"] = {"status": "pass" if ok else "fail", "days_since_split": split_days}
        if not ok:
            fails.append(f"ada stock split {split_days} hari lalu, belum aman buat entry (min >{lookback} hari)")
    else:
        checks["corp_action_split"] = {"status": "pass", "days_since_split": None}

    # --- gak otomatis-able dari data OHLCV batch, ditandain manual check ---
    checks["trading_status_suspend"] = {"status": "not_checked"}
    checks["right_issue_waran"] = {"status": "not_checked"}
    checks["bid_ask_spread"] = {"status": "not_checked" if not CHECK_LIVE_SPREAD else "see_live_check"}
    warnings.append("trading_status_suspend & right_issue_waran butuh cek manual (data gak tersedia di yfinance)")
    if not CHECK_LIVE_SPREAD:
        warnings.append("bid_ask_spread gak dihitung dari data historis batch (aktifkan CHECK_LIVE_SPREAD kalau perlu)")

    passed = len(fails) == 0
    return {"passed": passed, "checks": checks, "fails": fails, "warnings": warnings}


# ============ SUPPORT & RESISTANCE ============

def find_pivot_lows(df: pd.DataFrame, left: int = SR_PIVOT_LR, right: int = SR_PIVOT_LR) -> list:
    low = df["Low"]
    n = len(low)
    idxs = []
    for i in range(left, n - right):
        window = low.iloc[i - left:i + right + 1]
        if low.iloc[i] == window.min():
            idxs.append(i)
    return idxs


def find_pivot_highs(df: pd.DataFrame, left: int = SR_PIVOT_LR, right: int = SR_PIVOT_LR) -> list:
    high = df["High"]
    n = len(high)
    idxs = []
    for i in range(left, n - right):
        window = high.iloc[i - left:i + right + 1]
        if high.iloc[i] == window.max():
            idxs.append(i)
    return idxs


def cluster_levels(levels_with_vol: list, tol_pct: float = SR_CLUSTER_TOL_PCT) -> list:
    if not levels_with_vol:
        return []
    items = sorted(levels_with_vol, key=lambda x: x[0])
    clusters = []
    current = [items[0]]
    for item in items[1:]:
        if abs(item[0] - current[-1][0]) / current[-1][0] * 100 <= tol_pct:
            current.append(item)
        else:
            clusters.append(current)
            current = [item]
    clusters.append(current)

    result = []
    for c in clusters:
        prices = [x[0] for x in c]
        vols = [x[1] for x in c]
        result.append({
            "level": float(np.mean(prices)),
            "low_bound": float(min(prices)),
            "high_bound": float(max(prices)),
            "touches": len(c),
            "avg_touch_volume": float(np.mean(vols)),
        })
    return result


def volume_profile_levels(df: pd.DataFrame, bins: int = 20, top_n: int = 3) -> list:
    prices = df["Close"]
    volumes = df["Volume"]
    price_min, price_max = prices.min(), prices.max()
    if price_max <= price_min:
        return []
    bin_edges = np.linspace(price_min, price_max, bins + 1)
    bin_vol = np.zeros(bins)
    bin_idx = np.clip(np.digitize(prices, bin_edges) - 1, 0, bins - 1)
    for idx, vol in zip(bin_idx, volumes):
        bin_vol[idx] += vol
    top_idx = np.argsort(bin_vol)[::-1][:top_n]
    levels = [{"level": float((bin_edges[i] + bin_edges[i + 1]) / 2), "volume": float(bin_vol[i])}
              for i in top_idx]
    return sorted(levels, key=lambda x: -x["volume"])


def classic_pivot_points(daily_df: pd.DataFrame):
    if daily_df is None or len(daily_df) < 2:
        return None
    prev = daily_df.iloc[-2]
    H, L, C = float(prev["High"]), float(prev["Low"]), float(prev["Close"])
    P = (H + L + C) / 3
    return {"pivot": round(P, 2), "r1": round(2 * P - L, 2), "r2": round(P + (H - L), 2),
            "s1": round(2 * P - H, 2), "s2": round(P - (H - L), 2)}


def nearest_round_numbers(price: float) -> dict:
    if price >= 5000:
        step = 500
    elif price >= 1000:
        step = 100
    elif price >= 200:
        step = 50
    else:
        step = 25
    lower = (price // step) * step
    return {"lower": lower, "upper": lower + step, "step": step}


def previous_day_high_low(daily_df: pd.DataFrame):
    if daily_df is None or len(daily_df) < 2:
        return None
    prev = daily_df.iloc[-2]
    return {"high": float(prev["High"]), "low": float(prev["Low"])}


def detect_support_resistance(df: pd.DataFrame, daily_df: pd.DataFrame,
                               lookback: int = SR_LOOKBACK_BARS) -> dict:
    recent = df.iloc[-lookback:] if len(df) > lookback else df
    low_idxs = find_pivot_lows(recent)
    high_idxs = find_pivot_highs(recent)

    support_raw = [(float(recent["Low"].iloc[i]), float(recent["Volume"].iloc[i])) for i in low_idxs]
    resistance_raw = [(float(recent["High"].iloc[i]), float(recent["Volume"].iloc[i])) for i in high_idxs]

    support_zones = cluster_levels(support_raw)
    resistance_zones = cluster_levels(resistance_raw)

    for z in support_zones + resistance_zones:
        z["strength"] = "kuat" if z["touches"] >= SR_STRONG_TOUCH else (
            "cukup" if z["touches"] >= SR_MIN_TOUCH_USABLE else "lemah")

    return {
        "support_zones": sorted(support_zones, key=lambda z: -z["level"]),
        "resistance_zones": sorted(resistance_zones, key=lambda z: z["level"]),
        "volume_profile": volume_profile_levels(recent),
        "pivot_points": classic_pivot_points(daily_df),
        "prev_day": previous_day_high_low(daily_df),
        "round_numbers": nearest_round_numbers(float(df["Close"].iloc[-1])),
    }


# ============ LAYER 2 — STRUKTUR TEKNIKAL (3 dari 5) ============

def score_trend_points(daily_df: pd.DataFrame) -> dict:
    if daily_df is None or "sma_trend" not in daily_df.columns or pd.isna(daily_df["sma_trend"].iloc[-1]):
        return {"points": 0, "max_points": TREND_MAX_POINTS, "reason": "data SMA50 harian belum cukup"}

    sma50 = float(daily_df["sma_trend"].iloc[-1])
    close = float(daily_df["Close"].iloc[-1])
    diff_pct = (close - sma50) / sma50 * 100
    base = {"sma50": round(sma50, 2), "close": round(close, 2), "diff_pct": round(diff_pct, 2),
            "max_points": TREND_MAX_POINTS}

    if diff_pct > TREND_STRONG_PCT:
        return {**base, "points": TREND_POINTS["strong"]}
    elif diff_pct > TREND_MODERATE_PCT:
        return {**base, "points": TREND_POINTS["moderate"]}
    elif diff_pct >= -TREND_AT_SMA_BAND_PCT:
        return {**base, "points": TREND_POINTS["at_sma"], "reason": "harga di sekitar SMA50"}
    else:
        return {**base, "points": TREND_POINTS["below"], "reason": "harga di bawah SMA50"}


def score_entry_timing_points(pdf: pd.DataFrame) -> dict:
    if len(pdf) < EMA_SLOPE_LOOKBACK_BARS + 1:
        return {"points": 0, "max_points": ENTRY_MAX_POINTS, "reason": "data EMA belum cukup"}

    ema9 = pdf["ema_fast"].iloc[-1]
    ema21 = pdf["ema_slow"].iloc[-1]
    close = pdf["Close"].iloc[-1]
    if pd.isna(ema9) or pd.isna(ema21):
        return {"points": 0, "max_points": ENTRY_MAX_POINTS, "reason": "data EMA belum cukup"}

    ema9_prev = pdf["ema_fast"].iloc[-1 - EMA_SLOPE_LOOKBACK_BARS]
    ema21_prev = pdf["ema_slow"].iloc[-1 - EMA_SLOPE_LOOKBACK_BARS]
    ema9_slope_pct = (ema9 - ema9_prev) / ema9_prev * 100 if ema9_prev else 0.0
    ema21_slope_pct = (ema21 - ema21_prev) / ema21_prev * 100 if ema21_prev else 0.0

    base = {"ema9": round(float(ema9), 2), "ema21": round(float(ema21), 2),
            "ema9_slope_pct": round(float(ema9_slope_pct), 2),
            "ema21_slope_pct": round(float(ema21_slope_pct), 2),
            "max_points": ENTRY_MAX_POINTS}

    if ema9 <= ema21:
        return {**base, "points": ENTRY_POINTS["bearish"], "reason": "EMA9 < EMA21"}

    both_rising = ema9_slope_pct > EMA_FLAT_THRESHOLD_PCT and ema21_slope_pct > EMA_FLAT_THRESHOLD_PCT
    if close > ema21 and both_rising:
        return {**base, "points": ENTRY_POINTS["full_cross_rising"]}
    if ema21 <= close <= ema9:
        return {**base, "points": ENTRY_POINTS["consolidation"], "reason": "konsolidasi antara EMA9-21"}
    return {**base, "points": ENTRY_POINTS["flat_or_weak"], "reason": "EMA9>21 tapi EMA9 flat / momentum kurang"}


def score_volume_points(pdf: pd.DataFrame) -> dict:
    recent = pdf.iloc[-VOLUME_SPIKE_LOOKBACK_BARS:]
    if recent["vol_ratio"].isna().all():
        return {"points": 0, "max_points": VOLUME_MAX_POINTS, "reason": "data volume belum cukup"}

    best_i = recent["vol_ratio"].idxmax()
    ratio = recent.loc[best_i, "vol_ratio"]
    if pd.isna(ratio):
        return {"points": 0, "max_points": VOLUME_MAX_POINTS, "reason": "data volume belum cukup"}

    is_green = bool(recent.loc[best_i, "is_green"])
    spike_pct = float((ratio - 1) * 100)
    base = {"spike_pct": round(spike_pct, 1), "max_points": VOLUME_MAX_POINTS}

    if not is_green:
        return {**base, "points": 0, "reason": "candle merah / spike gak valid (distribusi)"}
    for min_pct, pts in VOLUME_TIERS:
        if spike_pct >= min_pct:
            return {**base, "points": pts}
    return {**base, "points": 0, "reason": "spike di bawah 20%"}


def score_price_action_points(pdf: pd.DataFrame) -> dict:
    if len(pdf) < 4:
        return {"points": 0, "max_points": PRICE_ACTION_MAX_POINTS, "pattern": "data_kurang"}

    last3 = pdf.iloc[-3:]
    lows = last3["Low"].values
    highs = last3["High"].values
    greens = last3["is_green"].values

    asc_count = int(lows[0] < lows[1]) + int(lows[1] < lows[2])   # 0-2, makin tinggi makin banyak higher-low
    desc_count = int(highs[0] > highs[1]) + int(highs[1] > highs[2])

    cur = pdf.iloc[-1]
    prev = pdf.iloc[-2]
    bearish_engulf = bool(prev["is_green"]) and (not bool(cur["is_green"])) and \
        cur["Open"] >= prev["Close"] and cur["Close"] <= prev["Open"]
    body_ratio = cur["body_ratio"]
    is_doji = pd.notna(body_ratio) and body_ratio < 0.15
    last_bar_green = bool(greens[-1])

    base = {"max_points": PRICE_ACTION_MAX_POINTS}

    if asc_count == 2 and bool(greens.all()):
        return {**base, "points": PRICE_ACTION_POINTS["full_higher_low_green"],
                "pattern": "3bar_higher_low_all_green"}
    if desc_count == 2 or bearish_engulf:
        return {**base, "points": PRICE_ACTION_POINTS["bearish_or_lower_high"],
                "pattern": "lower_high_or_bearish_engulfing"}
    if asc_count >= 1 and last_bar_green:
        return {**base, "points": PRICE_ACTION_POINTS["partial_higher_low"],
                "pattern": "partial_higher_low_last_green"}
    if asc_count >= 1 and is_doji:
        return {**base, "points": PRICE_ACTION_POINTS["higher_low_doji"],
                "pattern": "higher_low_with_doji"}
    return {**base, "points": PRICE_ACTION_POINTS["no_pattern"], "pattern": "no_clear_pattern"}


def score_rsi_points(pdf: pd.DataFrame) -> dict:
    rsi = pdf["rsi"].iloc[-1]
    if pd.isna(rsi):
        return {"points": 0, "max_points": RSI_MAX_POINTS, "reason": "data RSI belum cukup"}
    rsi = float(rsi)

    for (lo, hi), pts in RSI_TIERS:
        if lo <= rsi <= hi:
            return {"points": pts, "max_points": RSI_MAX_POINTS, "rsi": round(rsi, 1)}
    return {"points": 0, "max_points": RSI_MAX_POINTS, "rsi": round(rsi, 1), "reason": ">75 atau <30"}


def score_action_label(total_score: float, mode: str) -> dict:
    if total_score >= 85:
        return {"tier": "85-100", "action": "Eksekusi penuh"}
    elif total_score >= 70:
        action = "Hati-hati — pastikan bid spread ≤1%" if mode == "scalp" else "Boleh entry, TP lebih dekat"
        return {"tier": "70-84", "action": action}
    elif total_score >= 55:
        action = ("Mungkin kalau 3 indikator bobot tinggi + kamu expert — tapi disaranin skip"
                  if mode == "scalp" else "Skip kecuali daily trend sangat bullish")
        return {"tier": "55-69", "action": action}
    else:
        return {"tier": "<55", "action": "Jangan entry"}


def layer2_weighted_score(pdf: pd.DataFrame, daily_df: pd.DataFrame, mode: str) -> dict:
    trend = score_trend_points(daily_df)
    entry_timing = score_entry_timing_points(pdf)
    volume = score_volume_points(pdf)
    rsi = score_rsi_points(pdf)
    price_action = score_price_action_points(pdf)

    indicators = {
        "trend_sma50": trend,
        "entry_timing_ema": entry_timing,
        "volume_spike": volume,
        "rsi_momentum": rsi,
        "price_action": price_action,
    }

    total_score = sum(v["points"] for v in indicators.values())
    passed = total_score >= MIN_SCORE_TOTAL

    return {
        "indicators": indicators,
        "total_score": total_score,
        "max_score": MAX_SCORE_TOTAL,
        "min_required": MIN_SCORE_TOTAL,
        "passed": passed,
        "action_label": score_action_label(total_score, mode),
    }


# ============ LAYER 3 — SENTIMEN (IHSG otomatis, sisanya manual) ============

def fetch_ihsg_change_pct():
    try:
        idx = yf.download(IHSG_TICKER, period="5d", interval="1d", progress=False)
        if idx is None or len(idx) < 2:
            return None
        prev_close = float(idx["Close"].iloc[-2])
        last_close = float(idx["Close"].iloc[-1])
        return (last_close - prev_close) / prev_close * 100
    except Exception as e:
        print(f"[WARN] gagal ambil data IHSG: {e}")
        return None


def layer3_context(ihsg_change_pct) -> dict:
    if ihsg_change_pct is None:
        return {"ihsg_checked": False, "note": "data IHSG gak tersedia",
                "warning": "Berita negatif, right issue/waran, saham viral tetap butuh cek manual."}
    is_red = ihsg_change_pct < 0
    return {
        "ihsg_checked": True,
        "ihsg_change_pct": round(ihsg_change_pct, 2),
        "ihsg_red": is_red,
        "note": "IHSG merah - ketatin SL / cari saham defensif" if is_red else "IHSG hijau/flat",
        "warning": "Berita negatif, right issue/waran, saham viral tetap butuh cek manual.",
    }


# ============ ENTRY & STOP LOSS ============

def suggest_entry(pdf: pd.DataFrame, ddf: pd.DataFrame, sr_info: dict, mode: str):
    close = float(pdf["Close"].iloc[-1])
    tick = estimate_tick_size(close)

    # Prioritas 1: support zone teruji (minimal 2x touch)
    for z in sr_info.get("support_zones", []):
        if z["touches"] >= SR_MIN_TOUCH_USABLE and z["low_bound"] * 0.98 <= close <= z["high_bound"] * 1.02:
            entry_price = z["high_bound"] + tick
            return {"priority": 1, "method": "support_zone",
                    "entry_price": round(entry_price, 2), "zone": z}

    # Prioritas 2: MA pullback (EMA21 scalp / SMA50 daily long)
    if mode == "scalp":
        ma_value = pdf["ema_slow"].iloc[-1]
    else:
        ma_value = ddf["sma_trend"].iloc[-1] if ddf is not None and "sma_trend" in ddf.columns else None

    rsi_now = pdf["rsi"].iloc[-1]
    rsi_prev = pdf["rsi"].iloc[-2] if len(pdf) > 1 else np.nan
    rsi_rising = pd.notna(rsi_now) and pd.notna(rsi_prev) and rsi_now > rsi_prev

    if ma_value is not None and pd.notna(ma_value):
        near_ma = abs(close - float(ma_value)) / float(ma_value) * 100 <= 1.0
        if near_ma and rsi_rising:
            return {"priority": 2, "method": "ma_pullback",
                    "entry_price": round(close, 2), "ma_value": round(float(ma_value), 2)}

    # Prioritas 3: Fibonacci retracement
    lookback = pdf.iloc[-FIB_LOOKBACK_BARS:] if len(pdf) > FIB_LOOKBACK_BARS else pdf
    swing_low = float(lookback["Low"].min())
    swing_high = float(lookback["High"].max())
    if swing_high > swing_low:
        diff = swing_high - swing_low
        if mode == "scalp":
            fib_level = swing_high - diff * FIB_SCALP_LEVEL
            if abs(close - fib_level) / fib_level * 100 <= 1.5:
                return {"priority": 3, "method": f"fibonacci_{FIB_SCALP_LEVEL}",
                        "entry_price": round(close, 2), "fib_level": round(fib_level, 2)}
        else:
            for r in FIB_LONG_LEVELS:
                fib_level = swing_high - diff * r
                if abs(close - fib_level) / fib_level * 100 <= 1.5:
                    return {"priority": 3, "method": f"fibonacci_{r}",
                            "entry_price": round(close, 2), "fib_level": round(fib_level, 2)}

    return None


def suggest_stop_loss(pdf: pd.DataFrame, sr_info: dict, mode: str, entry_price: float):
    close_price = entry_price if entry_price else float(pdf["Close"].iloc[-1])
    tick = estimate_tick_size(close_price)

    if mode == "scalp":
        lookback = pdf.iloc[-SL_SCALP_LOOKBACK_BARS:]
        swing_low_idx = lookback["Low"].idxmin()
        wick_low = float(lookback.loc[swing_low_idx, "Low"])
        sl = wick_low - tick
        dist_pct = (close_price - sl) / close_price * 100
        too_far = dist_pct > SL_SCALP_MAX_PCT
        return {"sl_price": round(sl, 2), "basis": "wick_low_swing",
                "distance_pct": round(dist_pct, 2), "max_allowed_pct": SL_SCALP_MAX_PCT,
                "too_far": too_far,
                "note": "jarak SL > 2%, tunggu pullback lebih deket dulu" if too_far else None}
    else:
        zones = sr_info.get("support_zones", [])
        if not zones:
            return None
        nearest = min(zones, key=lambda z: abs(close_price - z["level"]))
        sl = nearest["low_bound"] * (1 - SL_LONG_ZONE_BUFFER_PCT / 100)
        dist_pct = (close_price - sl) / close_price * 100
        lo, hi = SL_LONG_PCT_RANGE
        out_of_range = not (lo <= dist_pct <= hi)
        return {"sl_price": round(sl, 2), "basis": "demand_zone", "zone": nearest,
                "distance_pct": round(dist_pct, 2), "normal_range_pct": SL_LONG_PCT_RANGE,
                "out_of_normal_range": out_of_range}


# ============ ANALYZE — INTI SISTEM ============

def analyze(primary_df: pd.DataFrame, daily_df: pd.DataFrame, ticker: str, mode: str,
            free_float_map: dict, ihsg_change_pct=None, price_ceiling: float | None = None) -> dict | None:
    """
    primary_df : OHLCV di timeframe utama (30M-proxy buat scalp, 1D/1W buat long)
    daily_df   : OHLCV harian (dipakai buat trend filter SMA50 & Layer 1),
                 sama dengan primary_df kalau TIMEFRAME sudah "1d"
    """
    if primary_df is None or primary_df.empty or daily_df is None or daily_df.empty:
        return None

    primary_df = primary_df.dropna(subset=["Open", "High", "Low", "Close", "Volume"])
    daily_df = daily_df.dropna(subset=["Open", "High", "Low", "Close", "Volume"])
    if len(primary_df) < 60 or len(daily_df) < 60:
        return None

    if primary_df["Volume"].iloc[-1] < MIN_VOLUME:
        return None

    pdf = compute_common_indicators(primary_df)
    ddf = compute_trend_sma(daily_df)

    # --- LAYER 1: gerbang pertama, gak lolos -> berhenti ---
    layer1 = check_layer1(ddf, mode, ticker, free_float_map, price_ceiling)
    if not layer1["passed"]:
        return {"ticker": ticker, "mode": mode, "status": "rejected_layer1", "layer1": layer1}

    # --- LAYER 2: bobot skor 0-100, minimal 70 buat lolos ---
    sr_info = detect_support_resistance(pdf, ddf)
    layer2 = layer2_weighted_score(pdf, ddf, mode)
    if not layer2["passed"]:
        return {"ticker": ticker, "mode": mode, "status": "rejected_layer2",
                "close": round(float(pdf["Close"].iloc[-1]), 2),
                "layer1": layer1, "layer2": layer2}

    # --- LAYER 3: sentimen (IHSG otomatis, sisanya flag manual) ---
    layer3 = layer3_context(ihsg_change_pct)

    # --- entry & SL ---
    entry = suggest_entry(pdf, ddf, sr_info, mode)
    entry_price = entry["entry_price"] if entry else float(pdf["Close"].iloc[-1])
    stop_loss = suggest_stop_loss(pdf, sr_info, mode, entry_price)

    return {
        "ticker": ticker,
        "mode": mode,
        "status": "candidate",
        "close": round(float(pdf["Close"].iloc[-1]), 2),
        "rsi": layer2["indicators"]["rsi_momentum"].get("rsi"),
        "score": layer2["total_score"],
        "max_score": layer2["max_score"],
        "min_required": layer2["min_required"],
        "action_label": layer2["action_label"],
        "layer1": layer1,
        "layer2": layer2,
        "layer3": layer3,
        "support_resistance": sr_info,
        "entry": entry,
        "stop_loss": stop_loss,
        "last_updated": str(pdf.index[-1]),
    }


# ============ FETCH & SCREENING ============

def extract_ticker_df(raw: pd.DataFrame, ticker: str):
    if raw is None or raw.empty:
        return None
    try:
        if isinstance(raw.columns, pd.MultiIndex):
            if ticker not in raw.columns.get_level_values(0):
                return None
            df = raw[ticker].copy()
        else:
            df = raw.copy()
        return df.dropna(how="all")
    except Exception:
        return None


def make_chart(pdf_raw: pd.DataFrame, ticker: str, result: dict):
    os.makedirs(CHARTS_DIR, exist_ok=True)
    plot_df = pdf_raw.iloc[-CHART_LOOKBACK_CANDLES:].copy()
    plot_df = compute_common_indicators(plot_df)

    apds = [
        mpf.make_addplot(plot_df["ema_fast"], color="dodgerblue", width=1.0),
        mpf.make_addplot(plot_df["ema_slow"], color="orange", width=1.0),
    ]

    hlines_prices, hlines_colors = [], []
    entry = result.get("entry")
    sl = result.get("stop_loss")
    if entry and entry.get("entry_price"):
        hlines_prices.append(entry["entry_price"])
        hlines_colors.append("green")
    if sl and sl.get("sl_price"):
        hlines_prices.append(sl["sl_price"])
        hlines_colors.append("red")

    fname = f"{ticker.replace('.JK', '')}_{result['mode']}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
    fpath = os.path.join(CHARTS_DIR, fname)

    kwargs = dict(
        type="candle", style="yahoo", addplot=apds, volume=True,
        title=f"{ticker} | score {result['score']}/{result['max_score']} | {result['mode']}",
        savefig=dict(fname=fpath, dpi=110, bbox_inches="tight"),
    )
    if hlines_prices:
        kwargs["hlines"] = dict(hlines=hlines_prices, colors=hlines_colors,
                                 linestyle="--", linewidths=0.8)

    mpf.plot(plot_df, **kwargs)
    return fpath


def screen_batch(tickers: list, interval: str, period: str, mode: str,
                  free_float_map: dict, ihsg_change_pct, price_ceiling=None) -> list:
    results = []
    try:
        raw = yf.download(tickers, period=period, interval=interval, group_by="ticker",
                           threads=True, progress=False, auto_adjust=False, actions=False)
    except Exception as e:
        print(f"[ERROR] gagal fetch batch primary ({interval}): {e}")
        return results

    need_daily_fetch = interval != "1d"
    daily_raw = None
    if need_daily_fetch:
        try:
            daily_raw = yf.download(tickers, period=DAILY_REF_PERIOD, interval="1d", group_by="ticker",
                                     threads=True, progress=False, auto_adjust=False, actions=True)
        except Exception as e:
            print(f"[ERROR] gagal fetch batch harian (trend filter): {e}")

    for ticker in tickers:
        try:
            pdf_raw = extract_ticker_df(raw, ticker)
            if pdf_raw is None or pdf_raw.empty:
                continue

            ddf_raw = extract_ticker_df(daily_raw, ticker) if need_daily_fetch else pdf_raw
            if ddf_raw is None or ddf_raw.empty:
                continue

            result = analyze(pdf_raw, ddf_raw, ticker, mode, free_float_map, ihsg_change_pct, price_ceiling)
            if result is None or result.get("status") != "candidate":
                continue

            try:
                result["chart_path"] = make_chart(pdf_raw, ticker, result)
            except Exception as e:
                print(f"[WARN] gagal bikin chart {ticker}: {e}")
                result["chart_path"] = None

            results.append(result)
        except Exception as e:
            print(f"[ERROR] analisa {ticker} gagal: {e}")

    return results


def screen_all(tickers: list, timeframe: str, free_float_map: dict) -> list:
    cfg = TIMEFRAME_CONFIG[timeframe]
    mode = "scalp" if timeframe == "1h" else "long"
    ihsg_change_pct = fetch_ihsg_change_pct()

    all_results = []
    batches = list(chunk(tickers, BATCH_SIZE))
    for i, batch in enumerate(batches, 1):
        print(f"[INFO] Batch {i}/{len(batches)} ({len(batch)} ticker, mode={mode})...")
        all_results.extend(screen_batch(batch, cfg["interval"], cfg["period"], mode,
                                         free_float_map, ihsg_change_pct, SCALP_PRICE_CEILING))
        if i < len(batches):
            time.sleep(BATCH_DELAY_SEC)

    return sorted(all_results, key=lambda x: (-x["score"], x["rsi"] if x["rsi"] is not None else 999))


# ============ TELEGRAM ============

def send_telegram_photo(photo_path: str, caption: str):
    if not TELEGRAM_BOT_TOKEN or "ISI_TOKEN" in TELEGRAM_BOT_TOKEN:
        print("[WARN] Telegram token belum diisi, skip kirim chart.")
        return
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
    try:
        with open(photo_path, "rb") as f:
            resp = requests.post(url, data={"chat_id": TELEGRAM_CHAT_ID, "caption": caption,
                                             "parse_mode": "Markdown"},
                                  files={"photo": f}, timeout=30)
        if resp.status_code != 200:
            print(f"[WARN] Telegram sendPhoto gagal ({resp.status_code}): {resp.text}")
    except Exception as e:
        print(f"[WARN] Telegram sendPhoto error: {e}")


def send_telegram_message(text: str):
    if not TELEGRAM_BOT_TOKEN or "ISI_TOKEN" in TELEGRAM_BOT_TOKEN:
        return
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    try:
        requests.post(url, data={"chat_id": TELEGRAM_CHAT_ID, "text": text, "parse_mode": "Markdown"}, timeout=30)
    except Exception as e:
        print(f"[WARN] Telegram sendMessage error: {e}")


def format_caption(r: dict) -> str:
    lines = [f"*{r['ticker']}* ({r['mode']}) — Skor {r['score']}/{r['max_score']} "
             f"({r['action_label']['action']})",
             f"Close: Rp{r['close']:,.0f} | RSI: {r['rsi']}"]
    if r.get("entry"):
        lines.append(f"Entry: Rp{r['entry']['entry_price']:,.0f} ({r['entry']['method']})")
    else:
        lines.append("Entry: belum ada setup valid (support/MA pullback/fib)")
    if r.get("stop_loss"):
        sl = r["stop_loss"]
        flag = " ⚠️" if sl.get("too_far") or sl.get("out_of_normal_range") else ""
        lines.append(f"SL: Rp{sl['sl_price']:,.0f} ({sl['distance_pct']}%){flag}")
    if r.get("layer3", {}).get("ihsg_red"):
        lines.append("⚠️ IHSG merah — ketatin risk")
    lines.append("_Cek manual: berita, right issue/waran, status suspensi._")
    return "\n".join(lines)


# ============ ORKESTRASI ============

def run_screening():
    tickers = load_tickers()
    free_float_map = load_free_float()
    mode = "scalp" if TIMEFRAME == "1h" else "long"
    print(f"[INFO] Scanning {len(tickers)} saham, timeframe={TIMEFRAME} (mode={mode}), "
          f"free_float_data={len(free_float_map)} saham, min_avg_value={MIN_AVG_VALUE_TRADED:,.0f}...")

    results = screen_all(tickers, TIMEFRAME, free_float_map)
    print(f"[DONE] {len(results)} saham lolos Layer 1+2. Chart tersimpan di folder {CHARTS_DIR}/.")

    if not results:
        return

    sent = 0
    for r in results:
        caption = format_caption(r)
        print("\n" + caption)
        if SEND_CHART_TO_TELEGRAM and r.get("chart_path") and sent < MAX_CHARTS_PER_RUN:
            send_telegram_photo(r["chart_path"], caption)
            sent += 1
        elif sent >= MAX_CHARTS_PER_RUN:
            print(f"[INFO] Sudah kirim {MAX_CHARTS_PER_RUN} chart, sisanya cek folder {CHARTS_DIR}/ lokal.")


def main_once():
    if not is_within_schedule():
        return
    run_screening()


def seconds_until_next_hour_mark() -> float:
    now = datetime.now(ZoneInfo("Asia/Jakarta"))
    next_hour = (now.replace(minute=0, second=0, microsecond=0) + pd.Timedelta(hours=1))
    return (next_hour - now).total_seconds()


def main_loop():
    print(f"[INFO] Self-loop aktif (align ke jam bulat). "
          f"Jendela jalan: {RUN_HOUR_START}:00-{RUN_HOUR_END}:00 WIB, hari {RUN_DAYS}.")

    while True:
        now = datetime.now(ZoneInfo("Asia/Jakarta"))
        if is_within_schedule():
            try:
                run_screening()
            except Exception as e:
                print(f"[ERROR] Screening gagal: {e}")
        else:
            print(f"[IDLE] {now.strftime('%Y-%m-%d %H:%M:%S')} WIB - di luar jendela jadwal.")

        sleep_sec = seconds_until_next_hour_mark()
        next_run = now + pd.Timedelta(seconds=sleep_sec)
        print(f"[INFO] Tidur sampai {next_run.strftime('%H:%M:%S')} WIB ({int(sleep_sec)}s)...")
        time.sleep(sleep_sec)


if __name__ == "__main__":
    if SELF_LOOP:
        main_loop()
    else:
        main_once()