#!/usr/bin/env python3
"""
Bitcoin Accumulation Zone Monitor — Web Dashboard
FastAPI server with inline HTML/CSS/JS dashboard.
Monitors on-chain metrics to identify optimal BTC accumulation zones.
"""

import asyncio
import json
import logging
import os
import sys
import threading
import time
import traceback
from datetime import datetime, timezone

import requests
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
log = logging.getLogger("btc-monitor")

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE_DIR)

from scrapers import fear_greed, price
from scoring import engine

app = FastAPI(title="Bitcoin Accumulation Zone Monitor")

CONFIG_DIR = os.path.join(BASE_DIR, "config")
DATA_DIR = os.path.join(BASE_DIR, "data")
CACHE_PATH = os.path.join(DATA_DIR, "cache.json")
HISTORY_PATH = os.path.join(DATA_DIR, "score_history.jsonl")
LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json")

os.makedirs(DATA_DIR, exist_ok=True)

# Background scraper state
_scraper_lock = threading.Lock()
_scraper_running = False
_last_update = None
_last_error = None

# ── Cache management ──────────────────────────────────────────────────────

def load_cache():
    if os.path.exists(CACHE_PATH):
        try:
            with open(CACHE_PATH) as f:
                return json.load(f)
        except Exception:
            pass
    return {}


def save_cache(data):
    with open(CACHE_PATH, "w") as f:
        json.dump(data, f, indent=2, default=str)


def append_history(score_data):
    """Append a daily score entry to history."""
    entry = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "composite_score": score_data.get("composite_score", 0),
        "scored_count": score_data.get("scored_count", 0),
        "metrics": {
            m["key"]: {"score": m["score"], "value": m["value"]}
            for m in score_data.get("metrics", [])
        },
    }
    with open(HISTORY_PATH, "a") as f:
        f.write(json.dumps(entry) + "\n")


def load_history():
    if not os.path.exists(HISTORY_PATH):
        return []
    entries = []
    with open(HISTORY_PATH) as f:
        for line in f:
            line = line.strip()
            if line:
                try:
                    entries.append(json.loads(line))
                except Exception:
                    pass
    return entries


# ── Background scraper ────────────────────────────────────────────────────

def run_scrape(force_full=False):
    """Run a scrape cycle and update cache.
    
    By default, only refreshes fast data (price, F&G) and reuses cached on-chain data.
    On-chain metrics (Playwright scrapes) only refresh if:
    - force_full=True (manual full refresh)
    - No cached on-chain data exists
    - Cached on-chain data is >6 hours old (they update daily)
    """
    global _last_update, _last_error, _scraper_running

    with _scraper_lock:
        if _scraper_running:
            return
        _scraper_running = True

    try:
        # Load existing cache to preserve on-chain data
        existing_cache = load_cache()
        metrics = {}

        # 1. Fear & Greed (fast API call)
        log.info("Fetching Fear & Greed...")
        metrics["fear_greed"] = fear_greed.fetch()

        # 2. BTC Price data (fast API calls)
        log.info("Fetching BTC price...")
        price_current = price.fetch_current()
        metrics["price"] = price_current

        log.info("Fetching BTC ATH...")
        ath_data = price.fetch_ath()
        ath_val = ath_data.get("ath") or existing_cache.get("drawdown", {}).get("ath")
        if price_current.get("price") and ath_val:
            drawdown = price.calculate_drawdown(price_current["price"], ath_val)
            metrics["drawdown"] = {"value": drawdown, "ath": ath_val}
        elif existing_cache.get("drawdown", {}).get("value") is not None:
            log.info("ATH fetch failed — reusing cached drawdown")
            metrics["drawdown"] = existing_cache["drawdown"]
        else:
            metrics["drawdown"] = {"value": None}

        log.info("Fetching historical prices for 200D SMA / Mayer...")
        hist = price.fetch_historical()
        if hist:
            sma_200d = price.calculate_200d_sma(hist)
            mayer = price.calculate_mayer_multiple(price_current.get("price"), sma_200d)
            metrics["price_extras"] = {"sma_200d": sma_200d, "mayer_multiple": mayer}
        else:
            # CoinGecko rate-limited — compute from history.json instead
            try:
                hist_path = os.path.join(DATA_DIR, "history.json")
                with open(hist_path) as f:
                    hdata = json.load(f)
                btc_vals = hdata.get("btc_price", {}).get("values", [])
                if len(btc_vals) >= 200:
                    sma_200d = sum(btc_vals[-200:]) / 200
                    cur_p = price_current.get("price") or btc_vals[-1]
                    mayer = cur_p / sma_200d if sma_200d else None
                    metrics["price_extras"] = {"sma_200d": sma_200d, "mayer_multiple": round(mayer, 4) if mayer else None}
                    log.info("Computed 200D SMA from history.json (CoinGecko rate-limited)")
                elif existing_cache.get("price_extras"):
                    metrics["price_extras"] = existing_cache["price_extras"]
            except Exception:
                if existing_cache.get("price_extras"):
                    metrics["price_extras"] = existing_cache["price_extras"]
                    log.info("Reusing cached price_extras")

        # 3. On-chain metrics — use cached values (historical data is permanent)
        onchain_keys = ["puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
                        "nupl", "200w_sma", "lth_realized_price", "hash_ribbons",
                        "pi_cycle_bottom", "lth_supply", "sopr", "sellside_risk",
                        "active_address_momentum", "txcount_momentum", "nvt_price",
                        "vdd_multiple"]
        
        has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys)
        
        if force_full or not has_cached_onchain:
            # Only do a full Playwright scrape if explicitly requested or no data exists
            log.info("Scraping on-chain metrics from LookIntoBitcoin (full refresh requested)...")
            try:
                from scrapers import lookintobitcoin
                onchain = lookintobitcoin.scrape_all()
                metrics.update(onchain)
                try:
                    from scrapers import checkonchain
                    metrics.update(checkonchain.scrape_all())
                except Exception as e:
                    log.error("CheckOnChain scraping failed: %s\n%s", e, traceback.format_exc())
                    _last_error = f"CheckOnChain scraping failed: {e}"
                metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
            except Exception as e:
                log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc())
                _last_error = f"On-chain scraping failed: {e}"
                for k in onchain_keys:
                    if k in existing_cache:
                        metrics[k] = existing_cache[k]
        else:
            # Reuse cached on-chain values — they're stored permanently
            log.info("Reusing cached on-chain data (use Full Refresh to re-scrape)")
            for k in onchain_keys:
                if k in existing_cache:
                    metrics[k] = existing_cache[k]
            if "_onchain_timestamp" in existing_cache:
                metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"]

        # 4. Score everything (classic + ML)
        log.info("Scoring metrics...")
        scored = engine.score_all(metrics)
        metrics["_scored"] = scored

        # ML-optimized scoring (parallel)
        try:
            scored_ml = engine.score_all_ml(metrics)
            metrics["_scored_ml"] = scored_ml
        except Exception as e:
            log.warning("ML scoring failed (non-critical): %s", e)
        metrics["_timestamp"] = datetime.now(timezone.utc).isoformat()

        save_cache(metrics)
        append_history(scored)

        # Append today's values to permanent history (incremental, not full re-scrape)
        try:
            from scrapers.history_updater import update_history
            update_history()
        except Exception as e:
            log.warning("History update failed (non-critical): %s", e)

        _last_update = datetime.now(timezone.utc).isoformat()
        _last_error = None
        log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"])

    except Exception as e:
        log.error("Scrape cycle error: %s\n%s", e, traceback.format_exc())
        _last_error = str(e)
    finally:
        with _scraper_lock:
            _scraper_running = False


def scraper_loop():
    """Background loop: quick refresh every 15min. Full scrape only on first boot with no data."""
    cache = load_cache()
    has_data = any(cache.get(k, {}).get("value") is not None 
                   for k in ["puell_multiple", "mvrv_zscore", "nupl"])
    run_scrape(force_full=not has_data)  # Full only if no cached on-chain data
    while True:
        time.sleep(900)  # 15 minutes
        run_scrape()  # Quick refresh only


# Start background scraper on import
_scraper_thread = threading.Thread(target=scraper_loop, daemon=True)
_scraper_thread.start()


# ── LLM Settings (preserved from original) ───────────────────────────────

class LLMSettingsUpdate(BaseModel):
    provider: str
    model: str
    providers: dict


class TestConnectionRequest(BaseModel):
    provider: str
    providers: dict


class FetchModelsRequest(BaseModel):
    provider: str
    providers: dict


def _load_llm_settings():
    if os.path.exists(LLM_SETTINGS_PATH):
        with open(LLM_SETTINGS_PATH) as f:
            return json.load(f)
    return {
        "provider": "ollama",
        "model": "qwen3.5:27b",
        "providers": {
            "ollama": {"base_url": "http://100.100.242.21:11434"},
            "lmstudio": {"base_url": "http://100.100.242.21:1234"},
            "openai": {"api_key": ""},
            "anthropic": {"api_key": ""},
            "openrouter": {"api_key": ""},
        },
    }


def _mask_api_key(key):
    if not key or len(key) < 8:
        return ""
    return "••••••••" + key[-4:]


def _safe_settings(settings):
    out = json.loads(json.dumps(settings))
    for name, cfg in out.get("providers", {}).items():
        if "api_key" in cfg:
            cfg["api_key"] = _mask_api_key(cfg["api_key"])
    return out


def _merge_api_keys(new_providers, existing_providers):
    for name, cfg in new_providers.items():
        if "api_key" in cfg:
            masked = cfg["api_key"]
            if masked.startswith("••••") or masked == "":
                existing_key = existing_providers.get(name, {}).get("api_key", "")
                cfg["api_key"] = existing_key


def _fetch_models(provider, providers):
    cfg = providers.get(provider, {})
    if provider == "ollama":
        base_url = cfg.get("base_url", "http://100.100.242.21:11434")
        resp = requests.get(f"{base_url}/api/tags", timeout=10)
        resp.raise_for_status()
        return [{"id": m["name"], "name": m["name"]} for m in resp.json().get("models", [])]
    elif provider == "lmstudio":
        base_url = cfg.get("base_url", "http://100.100.242.21:1234")
        resp = requests.get(f"{base_url}/v1/models", timeout=10)
        resp.raise_for_status()
        return [{"id": m["id"], "name": m["id"]} for m in resp.json().get("data", [])]
    elif provider == "openai":
        api_key = cfg.get("api_key", "")
        if not api_key:
            raise ValueError("OpenAI API key is required")
        resp = requests.get("https://api.openai.com/v1/models", headers={"Authorization": f"Bearer {api_key}"}, timeout=15)
        resp.raise_for_status()
        models = [m for m in resp.json().get("data", []) if m["id"].startswith("gpt-")]
        models.sort(key=lambda m: m["id"])
        return [{"id": m["id"], "name": m["id"]} for m in models]
    elif provider == "anthropic":
        api_key = cfg.get("api_key", "")
        if not api_key:
            raise ValueError("Anthropic API key is required")
        resp = requests.get("https://api.anthropic.com/v1/models", headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"}, timeout=15)
        resp.raise_for_status()
        return [{"id": m["id"], "name": m.get("display_name", m["id"])} for m in resp.json().get("data", [])]
    elif provider == "openrouter":
        resp = requests.get("https://openrouter.ai/api/v1/models", timeout=15)
        resp.raise_for_status()
        models = resp.json().get("data", [])
        models.sort(key=lambda m: m.get("id", ""))
        return [{"id": m["id"], "name": m.get("name", m["id"])} for m in models[:200]]
    else:
        raise ValueError(f"Unknown provider: {provider}")


# ── API Routes ────────────────────────────────────────────────────────────

def _with_informational_onchain_metrics(scored, cache):
    """Add non-scored on-chain data cards without changing composite scoring."""
    if not isinstance(scored, dict):
        return scored

    enriched = dict(scored)
    metrics = [dict(m) for m in scored.get("metrics", [])]
    existing_keys = {m.get("key") for m in metrics}

    lth_supply = cache.get("lth_supply", {})
    lth_value = lth_supply.get("value")
    if lth_value is not None and "lth_supply" not in existing_keys:
        trend = lth_supply.get("trend")
        trend_text = f" — {trend}" if trend else ""
        metrics.append({
            "name": "Long-Term Holder Supply",
            "key": "lth_supply",
            "value": lth_value,
            "display_value": f"{lth_value:,.0f} BTC",
            "score": None,
            "description": "Informational on-chain metric; not included in the composite score" + trend_text,
            "recent": lth_supply.get("recent", []),
        })

    pi_cycle = cache.get("pi_cycle_bottom", {})
    pi_value = pi_cycle.get("value")
    if pi_value is not None and "pi_cycle_bottom" not in existing_keys:
        metrics.append({
            "name": "Pi Cycle Bottom",
            "key": "pi_cycle_bottom",
            "value": pi_value,
            "display_value": f"{pi_value:,.2f}" if isinstance(pi_value, (int, float)) else str(pi_value),
            "score": None,
            "description": "Informational on-chain cycle metric; not included in the composite score",
            "recent": pi_cycle.get("recent", []),
        })

    enriched["metrics"] = metrics
    return enriched

@app.get("/api/data")
def api_data(mode: str = "classic"):
    """Return current cached metrics + scores.
    mode=classic (default) or mode=ml for ML-optimized scoring.
    """
    cache = load_cache()
    if mode == "ml":
        scored = cache.get("_scored_ml", cache.get("_scored", {}))
    else:
        scored = cache.get("_scored", {})
    scored = _with_informational_onchain_metrics(scored, cache)
    price_data = cache.get("price", {})
    drawdown_data = cache.get("drawdown", {})
    extras = cache.get("price_extras", {})
    return {
        "scored": scored,
        "price": price_data.get("price"),
        "change_24h": price_data.get("change_24h"),
        "ath": drawdown_data.get("ath"),
        "mayer_multiple": extras.get("mayer_multiple"),
        "sma_200d": extras.get("sma_200d"),
        "last_update": cache.get("_timestamp"),
        "scraper_running": _scraper_running,
        "last_error": _last_error,
        "mode": mode,
    }


@app.get("/api/history")
def api_history():
    return load_history()[-90:]  # Last 90 entries


@app.post("/api/refresh")
def api_refresh(full: bool = False):
    """Trigger a scrape. Quick refresh (default) updates price + F&G only (~2s).
    Full refresh (?full=true) also re-scrapes on-chain data via Playwright (~2-3min)."""
    if _scraper_running:
        return JSONResponse({"error": "Scrape already in progress"}, status_code=409)
    t = threading.Thread(target=run_scrape, kwargs={"force_full": full}, daemon=True)
    t.start()
    mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)"
    return {"ok": True, "message": f"Scrape started — {mode}"}


# Settings routes (preserved)
@app.get("/api/settings")
def api_get_settings():
    return _safe_settings(_load_llm_settings())


@app.post("/api/settings")
def api_save_settings(body: LLMSettingsUpdate):
    existing = _load_llm_settings()
    new_settings = {"provider": body.provider, "model": body.model, "providers": body.providers}
    _merge_api_keys(new_settings["providers"], existing.get("providers", {}))
    with open(LLM_SETTINGS_PATH, "w") as f:
        json.dump(new_settings, f, indent=2)
    return {"ok": True, "message": "Settings saved"}


@app.post("/api/settings/test")
def api_test_connection(body: TestConnectionRequest):
    existing = _load_llm_settings()
    providers = json.loads(json.dumps(body.providers))
    _merge_api_keys(providers, existing.get("providers", {}))
    try:
        models = _fetch_models(body.provider, providers)
        return {"ok": True, "models": models, "message": f"Connected — {len(models)} model(s) found"}
    except requests.exceptions.ConnectionError:
        return JSONResponse({"ok": False, "error": "Connection refused"}, status_code=502)
    except Exception as e:
        return JSONResponse({"ok": False, "error": str(e)}, status_code=500)


@app.post("/api/settings/models")
def api_fetch_models(body: FetchModelsRequest):
    existing = _load_llm_settings()
    providers = json.loads(json.dumps(body.providers))
    _merge_api_keys(providers, existing.get("providers", {}))
    try:
        models = _fetch_models(body.provider, providers)
        return {"ok": True, "models": models}
    except Exception as e:
        return JSONResponse({"ok": False, "error": str(e)}, status_code=500)


# ── HTML Pages ────────────────────────────────────────────────────────────

SHARED_CSS = """
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f172a;--card:#1e293b;--card-hover:#253349;--text:#e2e8f0;--text-dim:#94a3b8;
--accent:#f7931a;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--border:#334155;
--mono:'JetBrains Mono','Fira Code','Courier New',monospace;--cyan:#22d3ee;
--bright-green:#4ade80;--score-excellent:#22c55e;--score-good:#4ade80;
--score-neutral:#eab308;--score-bad:#f97316;--score-terrible:#ef4444}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.container{max-width:1400px;margin:0 auto;padding:16px}
h1{font-size:1.5rem;font-weight:700;display:flex;align-items:center;gap:10px}
h1 .btc{color:var(--accent);font-size:1.8rem}
h2{font-size:.8rem;font-weight:600;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:.05em}
.header{display:flex;justify-content:space-between;align-items:center;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap;gap:12px}
.nav{display:flex;gap:4px;align-items:center}
.nav a{color:var(--text-dim);text-decoration:none;font-size:.85rem;font-weight:600;padding:6px 14px;border-radius:6px;transition:all .15s}
.nav a:hover{color:var(--text);background:var(--card)}
.nav a.active{color:var(--cyan);background:var(--card);border:1px solid var(--border)}
.btn{padding:8px 18px;border:none;border-radius:6px;font-family:inherit;font-weight:600;font-size:.85rem;cursor:pointer;transition:all .15s}
.btn-accent{background:var(--accent);color:#000}.btn-accent:hover{background:#e8850f}
.btn-secondary{background:var(--border);color:var(--text)}.btn-secondary:hover{background:var(--card-hover)}
.btn-cyan{background:var(--cyan);color:#000}.btn-cyan:hover{background:#06b6d4}
.btn:disabled{opacity:.4;cursor:not-allowed}
.card{background:var(--card);border-radius:10px;padding:16px;border:1px solid var(--border)}
.footer{text-align:center;color:var(--text-dim);font-size:.75rem;padding:20px 0;margin-top:16px;border-top:1px solid var(--border)}
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:.85rem;font-weight:600;z-index:9999;opacity:0;transform:translateY(-10px);transition:all .3s;pointer-events:none}
.toast.show{opacity:1;transform:translateY(0)}
.toast-success{background:var(--green);color:#000}
.toast-error{background:var(--red);color:#fff}
"""

SHARED_HEAD = """<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">"""

NAV_HTML = """<div class="nav">
  <a href="/" id="nav-dashboard">Dashboard</a>
  <a href="/backtest" id="nav-backtest">&#x1F4CA; Backtest</a>
  <a href="/settings" id="nav-settings">&#9881; Settings</a>
</div>"""

TOAST_JS = """
function showToast(msg, type) {
  let t = document.getElementById('toast');
  if (!t) { t = document.createElement('div'); t.id = 'toast'; t.className = 'toast'; document.body.appendChild(t); }
  t.textContent = msg;
  t.className = 'toast toast-' + type + ' show';
  setTimeout(() => t.classList.remove('show'), 3500);
}
"""

DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>Bitcoin Accumulation Zone Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
""" + SHARED_CSS + """
.hero{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:20px}
.score-ring{position:relative;width:160px;height:160px;flex-shrink:0}
.score-ring canvas{width:160px;height:160px}
.score-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.score-ring .score-number{font-size:2.8rem;font-weight:800;font-family:var(--mono);line-height:1}
.score-ring .score-label{font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
.hero-info{flex:1;min-width:200px}
.assessment{font-size:1.3rem;font-weight:700;letter-spacing:.02em;margin-bottom:4px}
.price-display{font-size:2rem;font-weight:700;font-family:var(--mono);color:var(--accent);margin-bottom:4px}
.price-change{font-size:.9rem;font-family:var(--mono)}
.price-change.up{color:var(--green)}
.price-change.down{color:var(--red)}
.meta-row{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:.8rem;color:var(--text-dim)}
.meta-row span{display:flex;align-items:center;gap:4px}
.metrics-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:20px}
.metric-card{background:var(--card);border-radius:10px;padding:14px;border:1px solid var(--border);transition:border-color .15s;cursor:pointer}
.metric-card:hover{border-color:var(--text-dim)}
.metric-card.selected{border-color:#a78bfa;box-shadow:0 0 0 1px #a78bfa,0 0 12px rgba(167,139,250,0.15)}
.metric-click-hint{font-size:.6rem;margin-left:4px;opacity:0;transition:opacity .15s}
.metric-card:hover .metric-click-hint{opacity:.5}
.metric-card.selected .metric-click-hint{opacity:1}
.mc-examples-title{font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.mc-example{font-size:.8rem;font-family:var(--mono);padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
.mc-ex-date{color:#e2e8f0}
.mc-ex-cycle{color:#a78bfa;font-size:.7rem}
.mc-ex-price{color:#94a3b8}
.metric-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
.metric-name{font-size:.85rem;font-weight:600}
.metric-score{display:flex;align-items:center;gap:6px}
.metric-score-num{font-size:1.1rem;font-weight:800;font-family:var(--mono)}
.metric-score-bar{width:60px;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.metric-score-fill{height:100%;border-radius:3px;transition:width .3s}
.metric-value{font-size:.95rem;font-family:var(--mono);color:var(--accent);margin-bottom:4px}
.metric-desc{font-size:.78rem;color:var(--text-dim);line-height:1.4}
.metric-sparkline{margin-top:8px;height:30px}
.metric-sparkline canvas{width:100%;height:30px}
.chart-section{margin-bottom:20px}
.chart-container{position:relative;height:320px}
.range-btn{padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--muted);font-size:.75rem;font-family:var(--mono);cursor:pointer;transition:all .2s}
.range-btn:hover{color:var(--text);border-color:var(--accent)}
.range-btn.active{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
.status-line{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text-dim)}
.status-dot{width:8px;height:8px;border-radius:50%}
.status-dot.live{background:var(--green);animation:pulse 1.5s infinite}
.status-dot.stale{background:var(--yellow)}
.status-dot.error{background:var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.mode-toggle{display:flex;border-radius:6px;overflow:hidden;border:1px solid var(--border)}
.mode-btn{padding:6px 14px;border:none;background:transparent;color:var(--text-dim);font-family:inherit;font-weight:600;font-size:.8rem;cursor:pointer;transition:all .15s}
.mode-btn:hover{color:var(--text)}
.mode-btn.active[data-mode="classic"]{background:var(--accent);color:#000}
.mode-btn.active[data-mode="ml"]{background:#8b5cf6;color:#fff}
.ml-badge{display:inline-block;font-size:.6rem;font-weight:700;padding:2px 6px;border-radius:3px;background:#8b5cf6;color:#fff;vertical-align:super;margin-left:4px}
.ml-weight{font-size:.65rem;color:#8b5cf6;font-family:var(--mono);margin-top:2px}
</style>
</head>
<body>
<div class="container">

<div class="header">
  <div>
    <h1><span class="btc">&#x20BF;</span> Accumulation Zone Monitor</h1>
    <div style="margin-top:8px;display:flex;align-items:center;gap:12px">
      """ + NAV_HTML + """
    </div>
  </div>
  <div style="display:flex;align-items:center;gap:12px">
    <div class="status-line" id="statusLine">
      <span class="status-dot" id="statusDot"></span>
      <span id="statusText">Loading...</span>
    </div>
    <div class="mode-toggle" id="modeToggle" title="Switch between Classic (equal-weight) and ML-optimized scoring">
      <button class="mode-btn active" data-mode="classic" onclick="setMode('classic')">Classic</button>
      <button class="mode-btn" data-mode="ml" onclick="setMode('ml')">ML</button>
    </div>
    <button class="btn btn-accent" onclick="doRefresh(false)" id="btnRefresh">⚡ Quick Refresh</button>
    <button class="btn btn-secondary" onclick="doRefresh(true)" id="btnFullRefresh" title="Re-scrape on-chain metrics from LookIntoBitcoin (~2-3 min)">🔄 Full Refresh</button>
  </div>
</div>

<!-- Hero: Score + Price -->
<div class="hero">
  <div class="score-ring">
    <canvas id="scoreRing" width="160" height="160"></canvas>
    <div class="score-text">
      <div class="score-number" id="scoreNumber">--</div>
      <div class="score-label">of 100</div>
    </div>
  </div>
  <div class="hero-info">
    <div class="assessment" id="assessment">Loading...</div>
    <div class="price-display" id="priceDisplay">--</div>
    <div class="price-change" id="priceChange"></div>
    <div class="meta-row">
      <span>ATH: <strong id="athDisplay">--</strong></span>
      <span>Mayer: <strong id="mayerDisplay">--</strong></span>
      <span>200D SMA: <strong id="sma200dDisplay">--</strong></span>
      <span id="scoredCount"></span>
    </div>
  </div>
</div>

<!-- Historical Context (from backtest) -->
<div class="card" id="histContext" style="margin-bottom:20px;display:none;border-color:#22d3ee">
  <h2 style="color:#22d3ee">Historical Context</h2>
  <div id="histContextText" style="font-size:.9rem;font-family:var(--mono);line-height:1.6"></div>
  <a href="/backtest" style="font-size:.8rem;color:#22d3ee;text-decoration:none;margin-top:8px;display:inline-block">View full backtest &rarr;</a>
</div>

<!-- Metric Context Panel (shown when a metric is selected) -->
<div class="card" id="metricContextPanel" style="margin-bottom:20px;display:none;border-color:#a78bfa">
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
    <h2 style="color:#a78bfa" id="mcTitle">Metric Context</h2>
    <button onclick="if(selectedMetric) selectMetric(selectedMetric, '')" style="background:none;border:1px solid #a78bfa;color:#a78bfa;padding:4px 10px;border-radius:4px;cursor:pointer;font-family:var(--mono);font-size:.75rem">✕ Clear</button>
  </div>
  <div id="mcCurrent" style="font-size:1rem;font-family:var(--mono);margin-bottom:4px"></div>
  <div id="mcPercentile" style="font-size:.8rem;color:#94a3b8;font-family:var(--mono);margin-bottom:4px"></div>
  <div id="mcComparable" style="font-size:.8rem;color:#94a3b8;font-family:var(--mono);margin-bottom:8px"></div>
  <div id="mcReturns" style="font-size:.9rem;font-family:var(--mono);line-height:1.6;margin-bottom:8px"></div>
  <div id="mcExamples" style="display:none"></div>
  <a href="/backtest" style="font-size:.8rem;color:#a78bfa;text-decoration:none;margin-top:8px;display:inline-block">View full backtest &rarr;</a>
</div>

<!-- Metrics Grid -->
<h2>On-Chain Metrics</h2>
<div class="metrics-grid" id="metricsGrid">
  <div class="card" style="text-align:center;color:var(--text-dim);padding:40px">Loading metrics...</div>
</div>

<!-- Historical Chart -->
<div class="card chart-section">
  <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">
    <h2 style="margin:0">Composite Score History</h2>
    <div id="chartRangeBar" style="display:flex;gap:4px">
      <button class="range-btn" data-days="30">30D</button>
      <button class="range-btn" data-days="90">90D</button>
      <button class="range-btn" data-days="180">6M</button>
      <button class="range-btn" data-days="365">1Y</button>
      <button class="range-btn" data-days="730">2Y</button>
      <button class="range-btn" data-days="1460">4Y</button>
      <button class="range-btn active" data-days="0">ALL</button>
    </div>
  </div>
  <div class="chart-container">
    <canvas id="historyChart"></canvas>
  </div>
</div>

<div class="footer">Bitcoin Accumulation Zone Monitor &mdash; On-chain metrics updated every 15 minutes</div>
</div>

<script>
document.getElementById('nav-dashboard').classList.add('active');
""" + TOAST_JS + """

function scoreColor(score, max) {
  const pct = max > 0 ? score / max : 0;
  if (pct >= 0.7) return '#22c55e';
  if (pct >= 0.5) return '#4ade80';
  if (pct >= 0.3) return '#eab308';
  if (pct >= 0.15) return '#f97316';
  return '#ef4444';
}

function assessmentColor(score) {
  if (score >= 71) return '#22c55e';
  if (score >= 51) return '#4ade80';
  if (score >= 31) return '#eab308';
  if (score >= 15) return '#f97316';
  return '#ef4444';
}

function drawScoreRing(score) {
  const canvas = document.getElementById('scoreRing');
  const ctx = canvas.getContext('2d');
  const cx = 80, cy = 80, r = 65, lw = 12;
  ctx.clearRect(0, 0, 160, 160);

  // Background ring
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.strokeStyle = '#1e293b';
  ctx.lineWidth = lw;
  ctx.stroke();

  // Score arc
  const pct = Math.min(score / 100, 1);
  const startAngle = -Math.PI / 2;
  const endAngle = startAngle + pct * Math.PI * 2;
  ctx.beginPath();
  ctx.arc(cx, cy, r, startAngle, endAngle);
  ctx.strokeStyle = assessmentColor(score);
  ctx.lineWidth = lw;
  ctx.lineCap = 'round';
  ctx.stroke();

  document.getElementById('scoreNumber').textContent = Math.round(score);
  document.getElementById('scoreNumber').style.color = assessmentColor(score);
}

function drawSparkline(canvasId, data, color) {
  const canvas = document.getElementById(canvasId);
  if (!canvas || !data || data.length < 2) return;
  const ctx = canvas.getContext('2d');
  const w = canvas.width = canvas.offsetWidth * 2;
  const h = canvas.height = 60;
  ctx.clearRect(0, 0, w, h);

  const min = Math.min(...data);
  const max = Math.max(...data);
  const range = max - min || 1;
  const pad = 4;

  ctx.beginPath();
  for (let i = 0; i < data.length; i++) {
    const x = pad + (i / (data.length - 1)) * (w - pad * 2);
    const y = h - pad - ((data[i] - min) / range) * (h - pad * 2);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.strokeStyle = color;
  ctx.lineWidth = 2;
  ctx.stroke();
}

let selectedMetric = null;

function renderMetrics(metrics) {
  const grid = document.getElementById('metricsGrid');
  if (!metrics || !metrics.length) {
    grid.innerHTML = '<div class="card" style="text-align:center;color:var(--text-dim);padding:40px">No metrics available yet. Data is being scraped...</div>';
    return;
  }

  let html = '';
  metrics.forEach((m, idx) => {
    const score = m.score != null ? m.score : '--';
    const color = m.score != null ? scoreColor(m.score, 10) : '#64748b';
    const fillPct = m.score != null ? (m.score / 10 * 100) : 0;
    const hasSparkline = m.recent && m.recent.length > 2;
    const isSelected = selectedMetric === m.key ? ' selected' : '';

    html += '<div class="metric-card' + isSelected + '" data-key="' + m.key + '" data-name="' + m.name.replace('"', '&quot;') + '">';
    html += '<div class="metric-header">';
    html += '<div class="metric-name">' + m.name + '<span class="metric-click-hint">👆</span></div>';
    html += '<div class="metric-score">';
    html += '<div class="metric-score-bar"><div class="metric-score-fill" style="width:' + fillPct + '%;background:' + color + '"></div></div>';
    html += '<div class="metric-score-num" style="color:' + color + '">' + score + '</div>';
    html += '</div></div>';
    html += '<div class="metric-value">' + (m.display_value || 'N/A') + '</div>';
    html += '<div class="metric-desc">' + (m.description || '') + '</div>';
    if (currentMode === 'ml' && m.ml_weight != null) {
      const wpct = (m.ml_weight * 100).toFixed(1);
      const contrib = m.ml_contribution != null ? m.ml_contribution.toFixed(1) : '--';
      html += '<div class="ml-weight">ML weight: ' + wpct + '% · contribution: ' + contrib + ' pts</div>';
    }
    if (hasSparkline) {
      html += '<div class="metric-sparkline"><canvas id="spark-' + idx + '"></canvas></div>';
    }
    html += '</div>';
  });
  grid.innerHTML = html;

  // Draw sparklines after DOM update
  requestAnimationFrame(() => {
    metrics.forEach((m, idx) => {
      if (m.recent && m.recent.length > 2) {
        drawSparkline('spark-' + idx, m.recent, scoreColor(m.score || 0, 10));
      }
    });
  });

  // Attach click handlers to metric cards
  document.querySelectorAll('.metric-card').forEach(card => {
    card.addEventListener('click', function() {
      const key = this.getAttribute('data-key');
      const name = this.getAttribute('data-name');
      if (key) selectMetric(key, name);
    });
  });
}

// Metric selection + context panel
function selectMetric(metricKey, metricName) {
  if (selectedMetric === metricKey) {
    // Deselect if clicking the same one
    selectedMetric = null;
    window._highlightMetric = null;
    document.getElementById('metricContextPanel').style.display = 'none';
    const panel = document.getElementById('histContext');
    if (panel) panel.style.display = 'block';
    applyChartRange(currentRange); // Re-render chart without highlight
  } else {
    selectedMetric = metricKey;
    loadMetricContext(metricKey, metricName);
  }
  poll(); // Re-render metric cards with highlight
}

async function loadMetricContext(metricKey, metricName) {
  try {
    const r = await fetch('/api/metric-context?metric=' + encodeURIComponent(metricKey) + '&mode=' + currentMode);
    const ctx = await r.json();
    if (ctx.error) {
      showToast(ctx.error, 'error');
      return;
    }

    // Show metric context panel, hide composite context
    const panel = document.getElementById('histContext');
    if (panel) panel.style.display = 'none';

    const mcp = document.getElementById('metricContextPanel');
    mcp.style.display = 'block';

    document.getElementById('mcTitle').textContent = metricName;
    document.getElementById('mcCurrent').textContent = 'Current: ' + (ctx.current_raw != null ? ctx.current_raw : 'N/A');
    document.getElementById('mcPercentile').textContent = 'Metric value in top ' + (100 - ctx.percentile).toFixed(1) + '% historically';
    document.getElementById('mcComparable').textContent = ctx.comparable_days + ' comparable days found';

    const fmtR = (v) => v == null ? '--' : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
    const cR = v => v != null && v >= 0 ? '#22c55e' : '#ef4444';
    const periods = [['30d', ctx.avg_30d_return], ['90d', ctx.avg_90d_return], ['180d', ctx.avg_180d_return], ['1yr', ctx.avg_1yr_return]];
    let retHtml = '';
    for (const [label, val] of periods) {
      if (val != null) retHtml += '<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong> · ';
    }
    document.getElementById('mcReturns').innerHTML = retHtml ? 'Avg returns when ' + metricName + ' was similar: ' + retHtml : 'No forward return data available';

    // Examples
    const exEl = document.getElementById('mcExamples');
    if (ctx.examples && ctx.examples.length) {
      let exHtml = '<div class="mc-examples-title">Historical examples:</div>';
      ctx.examples.forEach(ex => {
        const fwd30 = ex.forward_returns['30d'];
        const fwd365 = ex.forward_returns['365d'];
        exHtml += '<div class="mc-example">';
        exHtml += '<span class="mc-ex-date">' + ex.date + '</span> ';
        exHtml += '<span class="mc-ex-cycle">' + ex.cycle + '</span> ';
        exHtml += '<span class="mc-ex-price">$' + (ex.price ? ex.price.toLocaleString() : 'N/A') + '</span>';
        if (fwd30 != null) exHtml += ' <span style="color:' + cR(fwd30) + '">30d: ' + fmtR(fwd30) + '</span>';
        if (fwd365 != null) exHtml += ' <span style="color:' + cR(fwd365) + '">1yr: ' + fmtR(fwd365) + '</span>';
        exHtml += '</div>';
      });
      exEl.innerHTML = exHtml;
      exEl.style.display = 'block';
    } else {
      exEl.innerHTML = '';
      exEl.style.display = 'none';
    }

    // Highlight matching periods on the chart
    highlightMetricPeriods(metricKey, ctx.current_raw, ctx.margin);
  } catch(e) {
    console.error('Metric context load failed:', e);
  }
}

function highlightMetricPeriods(metricKey, currentRaw, margin) {
  if (!fullDailyScores || !currentRaw || margin == null) return;

  // Build an array of {date, rawValue} for the selected metric
  const metricSeries = fullDailyScores
    .filter(d => d.metrics && d.metrics[metricKey] != null)
    .map(d => ({ date: d.date, value: d.metrics[metricKey], isSimilar: Math.abs(d.metrics[metricKey] - currentRaw) <= margin }));

  // Store for use in chart rendering
  window._highlightMetric = { key: metricKey, series: metricSeries, currentRaw, margin };

  // Re-render chart with highlight
  applyChartRange(currentRange);
}

let histChart = null;
let fullDailyScores = null;
let currentRange = 0; // 0 = ALL
let currentMode = 'classic';

function renderHistory(history) {
  // Legacy: still called by loadData but we'll use backtest data instead
  if (!fullDailyScores) {
    // Fallback to score_history.jsonl if backtest hasn't loaded
    renderHistoryFromData(history);
  }
}

function renderHistoryFromData(history) {
  const ctx = document.getElementById('historyChart').getContext('2d');
  if (!history || !history.length) return;

  const labels = history.map(h => h.date || h.timestamp);
  const scores = history.map(h => h.composite_score || h.score);
  const prices = history.map(h => h.price || null);

  if (histChart) histChart.destroy();

  const datasets = [{
    label: 'Accumulation Score',
    data: scores,
    borderColor: '#22d3ee',
    backgroundColor: 'rgba(34,211,238,0.08)',
    borderWidth: 2,
    fill: true,
    tension: 0.2,
    pointRadius: scores.length > 200 ? 0 : 2,
    pointBackgroundColor: '#22d3ee',
    yAxisID: 'y',
  }];

  if (prices && prices.some(p => p != null)) {
    datasets.push({
      label: 'BTC Price',
      data: prices,
      borderColor: '#f7931a',
      borderWidth: 1.5,
      borderDash: [4, 2],
      fill: false,
      tension: 0.2,
      pointRadius: 0,
      yAxisID: 'y1',
    });
  }

  // If a metric is selected, add its overlay + highlight similar periods
  const highlight = window._highlightMetric;
  let metricColor = '#a78bfa';
  if (highlight && highlight.series && highlight.series.length) {
    // Build a sparse array aligned to current chart labels
    const metricByDate = {};
    highlight.series.forEach(s => { metricByDate[s.date] = s; });
    const metricData = labels.map(l => {
      const entry = metricByDate[l];
      return entry ? entry.value : null;
    });
    const hasMetricData = metricData.some(v => v != null);

    if (hasMetricData) {
      datasets.push({
        label: 'Selected Metric',
        data: metricData,
        borderColor: metricColor,
        borderWidth: 1.5,
        borderDash: [2, 2],
        fill: false,
        tension: 0.2,
        pointRadius: 0,
        yAxisID: 'y2',
      });
    }

    // Highlight similar periods with point dots on the score line
    const similarIndices = [];
    labels.forEach((l, i) => {
      const entry = metricByDate[l];
      if (entry && entry.isSimilar) similarIndices.push(i);
    });

    if (similarIndices.length) {
      const highlightData = labels.map((l, i) =>
        similarIndices.includes(i) ? scores[i] : null
      );
      datasets.push({
        label: 'Similar Periods',
        data: highlightData,
        borderColor: 'rgba(167,139,250,0)',
        backgroundColor: '#a78bfa',
        pointRadius: 3,
        pointHoverRadius: 5,
        showLine: false,
        yAxisID: 'y',
      });
    }
  }

  // Determine y2 scale for the metric overlay
  const hasMetricDataset = datasets.some(d => d.yAxisID === 'y2');

  // Accumulation zone backgrounds + metric highlight bands
  const zonePlugin = {
    id: 'zones',
    beforeDraw(chart) {
      const { ctx, chartArea: { top, bottom, left, right }, scales: { y } } = chart;
      const zones = [
        { min: 65, max: 100, color: 'rgba(34,197,94,0.06)' },
        { min: 50, max: 65, color: 'rgba(234,179,8,0.04)' },
        { min: 0, max: 35, color: 'rgba(239,68,68,0.04)' },
      ];
      zones.forEach(z => {
        const yTop = y.getPixelForValue(z.max);
        const yBot = y.getPixelForValue(z.min);
        ctx.fillStyle = z.color;
        ctx.fillRect(left, yTop, right - left, yBot - yTop);
      });
      // Draw threshold lines
      [65, 50, 35].forEach(val => {
        const yPos = y.getPixelForValue(val);
        ctx.beginPath();
        ctx.setLineDash([4, 4]);
        ctx.strokeStyle = 'rgba(255,255,255,0.06)';
        ctx.lineWidth = 1;
        ctx.moveTo(left, yPos);
        ctx.lineTo(right, yPos);
        ctx.stroke();
        ctx.setLineDash([]);
      });

      // Draw vertical highlight bands for similar periods
      if (highlight && highlight.series) {
        const metricByDate = {};
        highlight.series.forEach(s => { metricByDate[s.date] = s; });
        const xScale = chart.scales.x;
        labels.forEach((l, i) => {
          const entry = metricByDate[l];
          if (entry && entry.isSimilar) {
            const x = xScale.getPixelForValue(i);
            ctx.fillStyle = 'rgba(167,139,250,0.08)';
            ctx.fillRect(x - 3, top, 6, bottom - top);
          }
        });
      }
    }
  };

  const scales = {
    x: {
      ticks: { color: '#64748b', maxTicksLimit: 12, font: { family: 'monospace', size: 10 } },
      grid: { color: 'rgba(255,255,255,0.03)' }
    },
    y: {
      min: 0, max: 100,
      ticks: { color: '#22d3ee', font: { family: 'monospace', size: 10 } },
      grid: { color: 'rgba(255,255,255,0.03)' },
      title: { display: true, text: 'Score', color: '#22d3ee', font: { family: 'monospace', size: 11 } }
    },
    y1: {
      position: 'right',
      ticks: {
        color: '#f7931a',
        font: { family: 'monospace', size: 10 },
        callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v)
      },
      grid: { drawOnChartArea: false },
      title: { display: true, text: 'BTC Price', color: '#f7931a', font: { family: 'monospace', size: 11 } }
    },
  };

  if (hasMetricDataset) {
    scales['y2'] = {
      position: 'right',
      display: false,
      grid: { drawOnChartArea: false },
    };
  }

  histChart = new Chart(ctx, {
    type: 'line',
    plugins: [zonePlugin],
    data: { labels, datasets },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: 'index', intersect: false },
      plugins: {
        legend: { labels: { color: '#94a3b8', font: { size: 11, family: 'monospace' } } },
        tooltip: {
          backgroundColor: 'rgba(10,10,15,0.95)',
          borderColor: 'rgba(255,255,255,0.08)',
          borderWidth: 1,
          titleFont: { family: 'monospace', size: 11 },
          bodyFont: { family: 'monospace', size: 11 },
          callbacks: {
            label: function(ctx) {
              if (ctx.dataset.yAxisID === 'y1') return 'BTC: $' + ctx.raw.toLocaleString();
              if (ctx.dataset.yAxisID === 'y2') return 'Metric: ' + (ctx.raw != null ? ctx.raw.toFixed(4) : 'N/A');
              if (ctx.dataset.label === 'Similar Periods') return '★ Similar period (Score: ' + ctx.raw.toFixed(1) + ')';
              const s = ctx.raw;
              let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
              return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
            }
          }
        }
      },
      scales,
    }
  });
}

// Load backtest daily scores for the chart
async function loadBacktestChart() {
  try {
    const r = await fetch('/api/backtest?mode=' + currentMode);
    const data = await r.json();
    if (data.chart_data && data.chart_data.length) {
      fullDailyScores = data.chart_data;
      applyChartRange(currentRange);
    }
  } catch(e) { console.error('Backtest chart load failed:', e); }
}

function applyChartRange(days) {
  currentRange = days;
  if (!fullDailyScores) return;
  let filtered = fullDailyScores;
  if (days > 0) {
    filtered = fullDailyScores.slice(-days);
  }
  renderHistoryFromData(filtered);
}

// Range button handlers
document.querySelectorAll('.range-btn').forEach(btn => {
  btn.addEventListener('click', function() {
    document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
    this.classList.add('active');
    applyChartRange(parseInt(this.dataset.days));
  });
});

// Load backtest data on page load
loadBacktestChart();

function updateStatus(data) {
  const dot = document.getElementById('statusDot');
  const text = document.getElementById('statusText');
  if (data.scraper_running) {
    dot.className = 'status-dot live';
    text.textContent = 'Scraping in progress...';
  } else if (data.last_error) {
    dot.className = 'status-dot error';
    text.textContent = 'Error: ' + data.last_error.substring(0, 50);
  } else if (data.last_update) {
    dot.className = 'status-dot live';
    const ago = Math.round((Date.now() - new Date(data.last_update).getTime()) / 60000);
    text.textContent = 'Updated ' + (ago < 1 ? 'just now' : ago + 'm ago');
  } else {
    dot.className = 'status-dot stale';
    text.textContent = 'Waiting for first scrape...';
  }
}

async function poll() {
  try {
    const [dataRes, histRes] = await Promise.all([
      fetch('/api/data?mode=' + currentMode), fetch('/api/history')
    ]);
    const data = await dataRes.json();
    const history = await histRes.json();

    // Update score ring
    const scored = data.scored || {};
    const composite = scored.composite_score || 0;
    drawScoreRing(composite);

    // Assessment
    const el = document.getElementById('assessment');
    let assessText = scored.assessment || 'Loading...';
    if (currentMode === 'ml') {
      el.innerHTML = assessText + '<span class="ml-badge">ML</span>';
    } else {
      el.textContent = assessText;
    }
    el.style.color = assessmentColor(composite);

    // Price
    if (data.price) {
      document.getElementById('priceDisplay').textContent = '$' + data.price.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0});
    }
    const chEl = document.getElementById('priceChange');
    if (data.change_24h != null) {
      const ch = data.change_24h;
      chEl.textContent = (ch >= 0 ? '+' : '') + ch.toFixed(2) + '% (24h)';
      chEl.className = 'price-change ' + (ch >= 0 ? 'up' : 'down');
    }

    // Meta
    if (data.ath) document.getElementById('athDisplay').textContent = '$' + data.ath.toLocaleString();
    if (data.mayer_multiple) document.getElementById('mayerDisplay').textContent = data.mayer_multiple.toFixed(2);
    if (data.sma_200d) document.getElementById('sma200dDisplay').textContent = '$' + Math.round(data.sma_200d).toLocaleString();
    if (scored.scored_count != null) {
      let countText = scored.scored_count + '/' + scored.total_count + ' metrics active';
      if (currentMode === 'ml' && scored.classic_score != null) {
        countText += ' · Classic: ' + scored.classic_score;
      }
      document.getElementById('scoredCount').textContent = countText;
    }

    // Metrics
    renderMetrics(scored.metrics || []);

    // Status
    updateStatus(data);

    // History chart
    renderHistory(history);
  } catch(e) { console.error('Poll error:', e); }
}

async function doRefresh(full) {
  const btn = document.getElementById(full ? 'btnFullRefresh' : 'btnRefresh');
  const origText = btn.textContent;
  btn.disabled = true;
  btn.textContent = full ? 'Scraping...' : 'Refreshing...';
  try {
    const r = await fetch('/api/refresh' + (full ? '?full=true' : ''), { method: 'POST' });
    const d = await r.json();
    if (d.error) showToast(d.error, 'error');
    else showToast(d.message || 'Refresh started', 'success');
  } catch(e) { showToast('Failed: ' + e, 'error'); }
  const delay = full ? 180000 : 5000;
  setTimeout(() => { btn.disabled = false; btn.textContent = origText; }, delay);
}

function setMode(mode) {
  currentMode = mode;
  document.querySelectorAll('.mode-btn').forEach(b => {
    b.classList.toggle('active', b.dataset.mode === mode);
  });
  poll(); // Refresh with new mode
  loadBacktestChart(); // Reload chart with new mode
}

drawScoreRing(0);
poll();
setInterval(poll, 30000);

// Load historical context from backtest
(async function() {
  try {
    const r = await fetch('/api/backtest/status');
    const s = await r.json();
    if (!s.exists) return;
    const br = await fetch('/api/backtest');
    const bt = await br.json();
    if (bt.error || !bt.current_context) return;
    const ctx = bt.current_context;
    const el = document.getElementById('histContext');
    const txt = document.getElementById('histContextText');
    let html = 'Score <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.<br>';
    const fmtR = (v) => v == null ? null : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
    const cR = (v) => v >= 0 ? '#22c55e' : '#ef4444';
    const periods = [
      ['30d', ctx.avg_30d_return], ['90d', ctx.avg_90d_return],
      ['180d', ctx.avg_180d_return], ['1yr', ctx.avg_1yr_return]
    ];
    let parts = [];
    for (const [label, val] of periods) {
      if (val != null) parts.push('<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong>');
    }
    if (parts.length) html += 'Average returns from this level: ' + parts.join(' · ');
    txt.innerHTML = html;
    el.style.display = 'block';
  } catch(e) { /* backtest data not available yet */ }
})();
</script>
</body>
</html>"""


SETTINGS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>Settings — Bitcoin Accumulation Zone Monitor</title>
<style>
""" + SHARED_CSS + """
.settings-grid{display:grid;grid-template-columns:320px 1fr;gap:16px;margin-top:16px}
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}}
.provider-list{display:flex;flex-direction:column;gap:6px}
.provider-option{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:8px;border:1px solid var(--border);cursor:pointer;transition:all .15s;background:var(--card)}
.provider-option:hover{border-color:var(--text-dim)}
.provider-option.selected{border-color:var(--cyan);background:#0f2a3a}
.provider-option input[type=radio]{accent-color:var(--cyan);width:16px;height:16px}
.provider-option .provider-name{font-weight:600;font-size:.9rem}
.provider-option .provider-type{font-size:.7rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em}
.field-group{margin-bottom:16px}
.field-group label{display:block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:6px}
.field-group input,.field-group select{width:100%;padding:10px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-family:var(--mono);font-size:.85rem}
.field-group input:focus,.field-group select:focus{outline:none;border-color:var(--cyan)}
.field-group select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}
.model-select-wrap{position:relative}
.model-spinner{display:none;position:absolute;right:36px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--cyan);border-radius:50%;animation:spin .6s linear infinite}
.model-spinner.active{display:block}
@keyframes spin{to{transform:translateY(-50%) rotate(360deg)}}
.btn-row{display:flex;gap:8px;margin-top:20px;flex-wrap:wrap}
.current-provider{font-size:.8rem;color:var(--text-dim);margin-top:4px;font-family:var(--mono)}
</style>
</head>
<body>
<div class="container">
<div class="header">
  <div>
    <h1><span class="btc">&#x20BF;</span> Accumulation Zone Monitor</h1>
    <div style="margin-top:8px;display:flex;align-items:center;gap:12px">
      """ + NAV_HTML + """
    </div>
  </div>
</div>
<div class="card">
  <h2>&#9881; LLM Provider Settings</h2>
  <p class="current-provider" id="currentProvider"></p>
  <div class="settings-grid">
    <div>
      <h2 style="margin-top:8px">Provider</h2>
      <div class="provider-list" id="providerList">
        <label class="provider-option" data-provider="ollama">
          <input type="radio" name="provider" value="ollama">
          <div><div class="provider-name">Ollama</div><div class="provider-type">Local</div></div>
        </label>
        <label class="provider-option" data-provider="lmstudio">
          <input type="radio" name="provider" value="lmstudio">
          <div><div class="provider-name">LM Studio</div><div class="provider-type">Local</div></div>
        </label>
        <label class="provider-option" data-provider="openai">
          <input type="radio" name="provider" value="openai">
          <div><div class="provider-name">OpenAI</div><div class="provider-type">Cloud</div></div>
        </label>
        <label class="provider-option" data-provider="anthropic">
          <input type="radio" name="provider" value="anthropic">
          <div><div class="provider-name">Anthropic</div><div class="provider-type">Cloud</div></div>
        </label>
        <label class="provider-option" data-provider="openrouter">
          <input type="radio" name="provider" value="openrouter">
          <div><div class="provider-name">OpenRouter</div><div class="provider-type">Cloud</div></div>
        </label>
      </div>
    </div>
    <div>
      <h2 style="margin-top:8px">Connection</h2>
      <div class="field-group" id="fieldBaseUrl" style="display:none">
        <label>Base URL</label>
        <input type="text" id="inputBaseUrl" placeholder="http://localhost:11434">
      </div>
      <div class="field-group" id="fieldApiKey" style="display:none">
        <label>API Key</label>
        <input type="password" id="inputApiKey" placeholder="sk-...">
      </div>
      <div class="field-group">
        <label>Model</label>
        <div class="model-select-wrap">
          <select id="selectModel"><option value="">-- select provider first --</option></select>
          <div class="model-spinner" id="modelSpinner"></div>
        </div>
      </div>
      <div class="btn-row">
        <button class="btn btn-cyan" onclick="testConnection()">Test Connection</button>
        <button class="btn btn-accent" onclick="saveSettings()">Save Settings</button>
      </div>
    </div>
  </div>
</div>
<div class="footer">Bitcoin Accumulation Zone Monitor &mdash; On-chain metrics</div>
</div>
<script>
document.getElementById('nav-settings').classList.add('active');
""" + TOAST_JS + """
let settings = null;
const PROVIDER_FIELDS = {
  ollama: { baseUrl: true, apiKey: false, defaultUrl: 'http://100.100.242.21:11434' },
  lmstudio: { baseUrl: true, apiKey: false, defaultUrl: 'http://100.100.242.21:1234' },
  openai: { baseUrl: false, apiKey: true },
  anthropic: { baseUrl: false, apiKey: true },
  openrouter: { baseUrl: false, apiKey: true },
};
function getSelectedProvider() {
  const r = document.querySelector('input[name=provider]:checked');
  return r ? r.value : null;
}
function buildProviders() {
  const p = settings ? JSON.parse(JSON.stringify(settings.providers)) : {};
  const prov = getSelectedProvider();
  if (!prov) return p;
  if (!p[prov]) p[prov] = {};
  const fields = PROVIDER_FIELDS[prov];
  if (fields.baseUrl) p[prov].base_url = document.getElementById('inputBaseUrl').value;
  if (fields.apiKey) { const v = document.getElementById('inputApiKey').value; if (v) p[prov].api_key = v; }
  return p;
}
function selectProvider(prov) {
  document.querySelectorAll('.provider-option').forEach(el => el.classList.toggle('selected', el.dataset.provider === prov));
  document.querySelector('input[name=provider][value="' + prov + '"]').checked = true;
  const fields = PROVIDER_FIELDS[prov];
  document.getElementById('fieldBaseUrl').style.display = fields.baseUrl ? 'block' : 'none';
  document.getElementById('fieldApiKey').style.display = fields.apiKey ? 'block' : 'none';
  if (settings && settings.providers[prov]) {
    const cfg = settings.providers[prov];
    if (fields.baseUrl) document.getElementById('inputBaseUrl').value = cfg.base_url || fields.defaultUrl || '';
    if (fields.apiKey) document.getElementById('inputApiKey').value = cfg.api_key || '';
  } else {
    if (fields.baseUrl) document.getElementById('inputBaseUrl').value = fields.defaultUrl || '';
    if (fields.apiKey) document.getElementById('inputApiKey').value = '';
  }
  document.getElementById('selectModel').innerHTML = '<option value="">-- click Test Connection to load models --</option>';
}
document.querySelectorAll('.provider-option').forEach(el => el.addEventListener('click', () => selectProvider(el.dataset.provider)));
async function loadSettings() {
  try {
    const r = await fetch('/api/settings'); settings = await r.json();
    document.getElementById('currentProvider').textContent = 'Current: ' + settings.provider + ' / ' + settings.model;
    selectProvider(settings.provider);
  } catch(e) { console.error(e); }
}
async function testConnection() {
  const prov = getSelectedProvider();
  if (!prov) { showToast('Select a provider first', 'error'); return; }
  showToast('Testing connection...', 'success');
  try {
    const r = await fetch('/api/settings/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: prov, providers: buildProviders() }) });
    const data = await r.json();
    if (data.ok) {
      showToast(data.message, 'success');
      const sel = document.getElementById('selectModel'); sel.innerHTML = '';
      for (const m of data.models) { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name !== m.id ? m.name + ' (' + m.id + ')' : m.id; sel.appendChild(opt); }
      if (settings && settings.model) sel.value = settings.model;
    } else { showToast(data.error || 'Connection failed', 'error'); }
  } catch(e) { showToast('Connection failed: ' + e, 'error'); }
}
async function saveSettings() {
  const prov = getSelectedProvider();
  if (!prov) { showToast('Select a provider first', 'error'); return; }
  const model = document.getElementById('selectModel').value;
  if (!model) { showToast('Select a model first', 'error'); return; }
  try {
    const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: prov, model, providers: buildProviders() }) });
    const data = await r.json();
    if (data.ok) { showToast('Settings saved!', 'success'); document.getElementById('currentProvider').textContent = 'Current: ' + prov + ' / ' + model; loadSettings(); }
    else showToast(data.error || 'Save failed', 'error');
  } catch(e) { showToast('Save failed: ' + e, 'error'); }
}
loadSettings();
</script>
</body>
</html>"""


# ── Backtest API ───────────────────────────────────────────────────────

_history_collector_running = False
_history_collector_progress = {}


@app.get("/api/backtest")
def api_backtest(mode: str = "classic"):
    """Run backtest and return full results.
    mode=classic (default) or mode=ml for ML-optimized scoring.
    """
    try:
        from backtesting.engine import run_backtest
        return run_backtest(ml_mode=(mode == "ml"))
    except Exception as e:
        log.error("Backtest error: %s", traceback.format_exc())
        return JSONResponse({"error": str(e)}, status_code=500)


@app.get("/api/backtest/history")
def api_backtest_history():
    """Return historical daily scores + prices for charting."""
    try:
        from backtesting.engine import run_backtest
        result = run_backtest()
        return {"chart_data": result.get("chart_data", []), "date_range": result.get("date_range")}
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)


@app.post("/api/backtest/collect")
def api_backtest_collect():
    """Trigger historical data collection."""
    global _history_collector_running, _history_collector_progress
    if _history_collector_running:
        return JSONResponse({"error": "Collection already in progress", "progress": _history_collector_progress}, status_code=409)

    def _run_collector():
        global _history_collector_running, _history_collector_progress
        _history_collector_running = True
        _history_collector_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
        try:
            from scrapers.history_collector import collect_all_history

            def progress_cb(metric, step, total):
                _history_collector_progress = {"status": "scraping", "current": metric, "step": step + 1, "total": total}

            collect_all_history(progress_cb=progress_cb)
            _history_collector_progress = {"status": "complete"}
        except Exception as e:
            log.error("History collection error: %s", traceback.format_exc())
            _history_collector_progress = {"status": "error", "error": str(e)}
        finally:
            _history_collector_running = False

    t = threading.Thread(target=_run_collector, daemon=True)
    t.start()
    return {"ok": True, "message": "Collection started"}


@app.get("/api/backtest/status")
def api_backtest_status():
    """Check if historical data exists and collection status."""
    from scrapers.history_collector import history_status
    status = history_status()
    status["collecting"] = _history_collector_running
    status["progress"] = _history_collector_progress
    return status


@app.get("/api/metric-context")
def api_metric_context(metric: str, margin: float = 0.0, mode: str = "classic"):
    """Find historical periods where a specific metric was at a similar level.

    Returns forward returns for those periods, analogous to the composite-score
    current_context but filtered to a single metric's historical similarity.

    margin: absolute tolerance for "similar" (auto-computed from metric scale if 0).
    """
    try:
        from backtesting.engine import run_backtest, HISTORY_PATH, _build_daily_index, _get_all_dates, _last_known_value, METRIC_SCORERS, RATIO_SCORERS, DRAWDOWN_RANGES, _score_range
        import os as _os

        if not _os.path.exists(HISTORY_PATH):
            return JSONResponse({"error": "No historical data. Run history collector first."}, status_code=404)

        with open(HISTORY_PATH) as f:
            history = json.load(f)

        index = _build_daily_index(history)
        all_dates = _get_all_dates(index)

        # Get current metric value from cache
        cache = {}
        if _os.path.exists(CACHE_PATH):
            with open(CACHE_PATH) as f:
                cache = json.load(f)

        current_raw = _get_current_metric_raw(metric, cache)
        if current_raw is None:
            return JSONResponse({"error": f"No current value for metric '{metric}'"}, status_code=404)

        # Auto-compute margin from metric scale
        if margin <= 0:
            margin = _auto_metric_margin(metric, current_raw)

        # Build price lookup
        price_lookup = {}
        for pk in ["btc_price_coingecko", "btc_price", "btc_price_sma", "btc_price_lth"]:
            if pk in index:
                for d, v in index[pk].items():
                    if d not in price_lookup:
                        price_lookup[d] = v

        # Find historical days where this metric was similar
        comparable = []
        for d in all_dates:
            raw_val = _get_historical_metric_raw(metric, index, d)
            if raw_val is not None and abs(raw_val - current_raw) <= margin:
                price = price_lookup.get(d)
                fwd = _compute_day_forward_returns(price_lookup, d)
                if fwd:
                    comparable.append({
                        "date": d,
                        "raw_value": round(raw_val, 6) if isinstance(raw_val, float) else raw_val,
                        "price": price,
                        "forward_returns": fwd,
                    })

        # Compute average returns across comparable periods
        avg_returns = {}
        for period in ["30d", "90d", "180d", "365d"]:
            vals = [c["forward_returns"][period] for c in comparable if period in c["forward_returns"]]
            if vals:
                avg_returns[period] = round(sum(vals) / len(vals), 2)

        # Pick best examples (one per market cycle)
        cycle_bins = [
            ("pre-2016", "2010-01-01", "2015-12-31"),
            ("2016-17 Bull", "2016-01-01", "2017-12-31"),
            ("2018-19 Bear", "2018-01-01", "2019-12-31"),
            ("2020-21 Bull", "2020-01-01", "2021-12-31"),
            ("2022-23 Bear", "2022-01-01", "2023-12-31"),
            ("2024+", "2024-01-01", "2099-12-31"),
        ]
        examples = []
        used_cycles = set()
        sorted_comp = sorted(comparable, key=lambda c: abs(c["raw_value"] - current_raw))
        for c in sorted_comp:
            for label, start, end in cycle_bins:
                if start <= c["date"] <= end and label not in used_cycles:
                    used_cycles.add(label)
                    examples.append({
                        "date": c["date"],
                        "raw_value": c["raw_value"],
                        "price": c["price"],
                        "forward_returns": c["forward_returns"],
                        "cycle": label,
                    })
                    break
            if len(examples) >= 6:
                break
        examples.sort(key=lambda e: e["date"])

        # Percentile: what % of all days had this metric at or below current value
        all_raw_vals = []
        for d in all_dates:
            rv = _get_historical_metric_raw(metric, index, d)
            if rv is not None:
                all_raw_vals.append(rv)
        all_raw_vals.sort()
        below = len([v for v in all_raw_vals if v <= current_raw])
        percentile = round(below / len(all_raw_vals) * 100, 1) if all_raw_vals else 50.0

        return {
            "metric": metric,
            "current_raw": current_raw,
            "margin": margin,
            "comparable_days": len(comparable),
            "percentile": percentile,
            "avg_30d_return": avg_returns.get("30d"),
            "avg_90d_return": avg_returns.get("90d"),
            "avg_180d_return": avg_returns.get("180d"),
            "avg_1yr_return": avg_returns.get("365d"),
            "examples": examples,
        }
    except Exception as e:
        log.error("Metric context error: %s", traceback.format_exc())
        return JSONResponse({"error": str(e)}, status_code=500)


def _get_current_metric_raw(metric, cache):
    """Get the current raw value for a metric from the cache."""
    # Direct cache keys
    direct_keys = {
        "fear_greed": ("fear_greed", "value"),
        "puell_multiple": ("puell_multiple", "value"),
        "mvrv_zscore": ("mvrv_zscore", "value"),
        "reserve_risk": ("reserve_risk", "value"),
        "rhodl_ratio": ("rhodl_ratio", "value"),
        "nupl": ("nupl", "value"),
        "drawdown": ("drawdown", "value"),
        "hash_ribbons": ("hash_ribbons", "value"),
        "sopr": ("sopr", "value"),
        "sellside_risk": ("sellside_risk", "value"),
        "active_address_momentum": ("active_address_momentum", "value"),
        "txcount_momentum": ("txcount_momentum", "value"),
        "nvt_price": ("nvt_price", "value"),
        "vdd_multiple": ("vdd_multiple", "value"),
        "lth_supply": ("lth_supply", "value"),
    }
    # Ratio-based metrics: compute from price vs reference
    ratio_metrics = {
        "price_vs_200w_sma": ("price", "200w_sma"),
        "lth_realized_price": ("price", "lth_realized_price"),
    }

    if metric in direct_keys:
        k, sub = direct_keys[metric]
        val = cache.get(k, {})
        if isinstance(val, dict):
            return val.get(sub)
        return val
    elif metric in ratio_metrics:
        price_key, ref_key = ratio_metrics[metric]
        price_val = cache.get(price_key, {}).get("price") or cache.get(price_key, {}).get("value")
        ref_val = cache.get(ref_key, {}).get("value")
        if price_val and ref_val and ref_val > 0:
            return ((price_val - ref_val) / ref_val) * 100
    return None


def _get_historical_metric_raw(metric, index, date):
    """Get the raw value for a metric on a specific historical date."""
    from backtesting.engine import _last_known_value
    direct_keys = {
        "fear_greed": "fear_greed",
        "puell_multiple": "puell_multiple",
        "mvrv_zscore": "mvrv_zscore",
        "reserve_risk": "reserve_risk",
        "rhodl_ratio": "rhodl_ratio",
        "nupl": "nupl",
        "drawdown": "drawdown",
        "hash_ribbons": "hash_ribbons",
        "sopr": "sopr",
        "sellside_risk": "sellside_risk",
        "active_address_momentum": "active_address_momentum",
        "txcount_momentum": "txcount_momentum",
        "nvt_price": "nvt_price",
        "vdd_multiple": "vdd_multiple",
        "lth_supply": "lth_supply",
    }
    if metric in direct_keys:
        return _last_known_value(index.get(direct_keys[metric], {}), date)
    # Ratio-based
    if metric == "price_vs_200w_sma":
        price_val = _last_known_value(index.get("btc_price", {}), date)
        ref_val = _last_known_value(index.get("200w_sma", {}), date)
        if price_val and ref_val and ref_val > 0:
            return ((price_val - ref_val) / ref_val) * 100
    if metric == "lth_realized_price":
        price_val = _last_known_value(index.get("btc_price", {}), date)
        ref_val = _last_known_value(index.get("lth_realized_price", {}), date)
        if price_val and ref_val and ref_val > 0:
            return ((price_val - ref_val) / ref_val) * 100
    return None


def _auto_metric_margin(metric, current_val):
    """Compute a reasonable similarity margin based on metric type and scale."""
    margins = {
        "fear_greed": 5.0,
        "puell_multiple": 0.15,
        "mvrv_zscore": 0.5,
        "reserve_risk": 0.002,
        "rhodl_ratio": 300,
        "nupl": 0.1,
        "drawdown": 8.0,
        "sopr": 0.02,
        "sellside_risk": 0.001,
        "active_address_momentum": 0.05,
        "txcount_momentum": 0.05,
        "nvt_price": 5000,
        "vdd_multiple": 0.15,
        "price_vs_200w_sma": 10.0,
        "lth_realized_price": 10.0,
    }
    if metric in margins:
        return margins[metric]
    # Fallback: 15% of current value
    return abs(current_val) * 0.15 if current_val != 0 else 1.0


def _compute_day_forward_returns(price_lookup, date):
    """Compute forward returns for a single date."""
    from datetime import datetime as _dt, timedelta as _td
    p0 = price_lookup.get(date)
    if p0 is None or p0 <= 0:
        return {}
    r = {}
    dt = _dt.strptime(date, "%Y-%m-%d")
    for days in [30, 90, 180, 365]:
        future = (dt + _td(days=days)).strftime("%Y-%m-%d")
        pf = price_lookup.get(future)
        if pf is not None:
            r[f"{days}d"] = round(((pf - p0) / p0) * 100, 2)
    return r


# ── Backtest HTML Page ─────────────────────────────────────────────────

BACKTEST_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>Historical Backtest — Bitcoin Accumulation Zone Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
""" + SHARED_CSS + """
.section{margin-bottom:24px}
.section h2{margin-bottom:12px}
.collect-banner{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:24px;text-align:center;margin-bottom:24px}
.collect-banner p{color:var(--text-dim);margin:8px 0 16px}
.progress-bar{width:100%;height:8px;background:var(--bg);border-radius:4px;overflow:hidden;margin:12px 0}
.progress-fill{height:100%;background:var(--cyan);border-radius:4px;transition:width .3s}
.progress-text{font-size:.8rem;color:var(--text-dim);font-family:var(--mono)}
table{width:100%;border-collapse:collapse;font-size:.82rem;font-family:var(--mono)}
th{text-align:left;padding:10px 8px;border-bottom:2px solid var(--border);color:var(--text-dim);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
td{padding:8px;border-bottom:1px solid var(--border)}
tr:hover td{background:var(--card-hover)}
.t-green{color:var(--green)}.t-red{color:var(--red)}.t-yellow{color:var(--yellow)}.t-cyan{color:var(--cyan)}
.chart-dual{position:relative;height:400px}
.context-box{background:var(--card);border:1px solid var(--cyan);border-radius:10px;padding:20px}
.context-score{font-size:2.5rem;font-weight:800;font-family:var(--mono);margin-bottom:4px}
.context-percentile{font-size:1rem;color:var(--cyan);margin-bottom:12px}
.context-return{font-size:1.1rem;color:var(--green);font-weight:600;margin-bottom:12px}
.comparable-list{margin-top:12px}
.comparable-item{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);font-size:.82rem;font-family:var(--mono)}
.signal-card{background:var(--card);border-radius:8px;padding:12px;border:1px solid var(--border);margin-bottom:8px}
.signal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.signal-date{font-weight:700;color:var(--accent);font-family:var(--mono)}
.signal-score{font-weight:800;font-family:var(--mono);padding:2px 8px;border-radius:4px;font-size:.85rem}
.signal-returns{display:flex;gap:16px;font-size:.8rem;font-family:var(--mono)}
.loading-spinner{display:inline-block;width:20px;height:20px;border:3px solid var(--border);border-top-color:var(--cyan);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="container">

<div class="header">
  <div>
    <h1><span class="btc">&#x20BF;</span> Accumulation Zone Monitor</h1>
    <div style="margin-top:8px;display:flex;align-items:center;gap:12px">
      """ + NAV_HTML + """
    </div>
  </div>
</div>

<!-- Collection Banner (shown if no data) -->
<div id="collectBanner" class="collect-banner" style="display:none">
  <h2>Historical Data Required</h2>
  <p>Scrape full historical time series from LookIntoBitcoin charts, CoinGecko, and Fear & Greed Index.<br>This takes several minutes (10+ charts to scrape).</p>
  <button class="btn btn-cyan" id="btnCollect" onclick="startCollection()">Collect Historical Data</button>
  <div id="progressArea" style="display:none;margin-top:16px">
    <div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
    <div class="progress-text" id="progressText">Starting...</div>
  </div>
</div>

<!-- Main content (shown after data exists) -->
<div id="mainContent" style="display:none">

<!-- Section 1: Current Signal Context -->
<div class="section">
  <div class="context-box" id="contextBox">
    <h2>Current Signal Context</h2>
    <div class="context-score" id="ctxScore">--</div>
    <div class="context-percentile" id="ctxPercentile">Loading...</div>
    <div class="context-return" id="ctxReturn"></div>
    <div class="comparable-list" id="ctxComparables"></div>
  </div>
</div>

<!-- Section 2: Historical Score vs Price Chart -->
<div class="section">
  <div class="card">
    <h2>Historical Score vs BTC Price</h2>
    <div class="chart-dual">
      <canvas id="dualChart"></canvas>
    </div>
  </div>
</div>

<!-- Section 3: Score Bracket Performance -->
<div class="section">
  <div class="card">
    <h2>Score Bracket Performance</h2>
    <div style="overflow-x:auto">
      <table id="bracketTable">
        <thead>
          <tr>
            <th>Score Range</th><th>Label</th><th>Days</th>
            <th>Avg 30d</th><th>Avg 90d</th><th>Avg 180d</th><th>Avg 1yr</th>
            <th>Win Rate (1yr)</th><th>Max Gain (1yr)</th><th>Max Loss (1yr)</th>
            <th>Avg Max DD</th>
          </tr>
        </thead>
        <tbody id="bracketBody"></tbody>
      </table>
    </div>
  </div>
</div>

<!-- Section 4: Major Signal Events -->
<div class="section">
  <div class="card">
    <h2>Major Signal Events (Score Crossed 70/80/90+)</h2>
    <div id="signalEvents"></div>
  </div>
</div>

</div><!-- /mainContent -->

<div class="footer">Bitcoin Accumulation Zone Monitor &mdash; Historical Backtest Engine</div>
</div>

<script>
document.getElementById('nav-backtest').classList.add('active');
""" + TOAST_JS + """

let backtestData = null;

function fmtPct(v) { if (v == null) return '--'; return (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; }
function fmtPrice(v) { if (v == null) return '--'; return '$' + Math.round(v).toLocaleString(); }
function retClass(v) { if (v == null) return ''; return v >= 0 ? 't-green' : 't-red'; }

function scoreColorCSS(score) {
  if (score >= 71) return '#22c55e';
  if (score >= 51) return '#4ade80';
  if (score >= 31) return '#eab308';
  if (score >= 15) return '#f97316';
  return '#ef4444';
}

async function checkStatus() {
  try {
    const r = await fetch('/api/backtest/status');
    const s = await r.json();
    if (s.exists && !s.collecting) {
      document.getElementById('collectBanner').style.display = 'none';
      document.getElementById('mainContent').style.display = 'block';
      loadBacktest();
    } else if (s.collecting) {
      document.getElementById('collectBanner').style.display = 'block';
      document.getElementById('progressArea').style.display = 'block';
      document.getElementById('btnCollect').disabled = true;
      document.getElementById('btnCollect').textContent = 'Collecting...';
      const p = s.progress || {};
      if (p.total > 0) {
        const pct = Math.round((p.step / (p.total + 2)) * 100);
        document.getElementById('progressFill').style.width = pct + '%';
        document.getElementById('progressText').textContent = p.current + ' (' + p.step + '/' + p.total + ')';
      } else {
        document.getElementById('progressText').textContent = p.status || 'Working...';
      }
      if (p.status === 'complete') {
        document.getElementById('collectBanner').style.display = 'none';
        document.getElementById('mainContent').style.display = 'block';
        loadBacktest();
        return;
      }
      setTimeout(checkStatus, 3000);
    } else {
      document.getElementById('collectBanner').style.display = 'block';
    }
  } catch(e) { console.error(e); }
}

async function startCollection() {
  document.getElementById('btnCollect').disabled = true;
  document.getElementById('btnCollect').textContent = 'Starting...';
  document.getElementById('progressArea').style.display = 'block';
  try {
    const r = await fetch('/api/backtest/collect', { method: 'POST' });
    const d = await r.json();
    if (d.error) { showToast(d.error, 'error'); return; }
    showToast('Collection started — this will take several minutes', 'success');
    setTimeout(checkStatus, 3000);
  } catch(e) { showToast('Failed: ' + e, 'error'); }
}

async function loadBacktest() {
  try {
    document.getElementById('mainContent').innerHTML = '<div style="text-align:center;padding:60px;color:var(--text-dim)"><div class="loading-spinner"></div><p style="margin-top:16px">Running backtest analysis...</p></div>';
    const r = await fetch('/api/backtest');
    backtestData = await r.json();
    if (backtestData.error) {
      document.getElementById('mainContent').innerHTML = '<div class="card" style="text-align:center;padding:40px;color:var(--red)">' + backtestData.error + '</div>';
      return;
    }
    renderAll();
  } catch(e) { console.error(e); }
}

function renderAll() {
  // Rebuild the main content
  document.getElementById('mainContent').innerHTML = `
    <div class="section"><div class="context-box" id="contextBox"><h2>Current Signal Context</h2><div class="context-score" id="ctxScore">--</div><div class="context-percentile" id="ctxPercentile"></div><div class="context-return" id="ctxReturn"></div><div class="comparable-list" id="ctxComparables"></div></div></div>
    <div class="section"><div class="card"><div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px"><h2 style="margin:0">Historical Score vs BTC Price</h2><div id="btRangeBar" style="display:flex;gap:4px"><button class="range-btn" data-days="30">30D</button><button class="range-btn" data-days="90">90D</button><button class="range-btn" data-days="180">6M</button><button class="range-btn" data-days="365">1Y</button><button class="range-btn" data-days="730">2Y</button><button class="range-btn" data-days="1460">4Y</button><button class="range-btn active" data-days="0">ALL</button></div></div><div class="chart-dual"><canvas id="dualChart"></canvas></div></div></div>
    <div class="section"><div class="card"><h2>Score Bracket Performance</h2><div style="overflow-x:auto"><table><thead><tr><th>Score Range</th><th>Label</th><th>Days</th><th>Avg 30d</th><th>Avg 90d</th><th>Avg 180d</th><th>Avg 1yr</th><th>Win Rate (1yr)</th><th>Max Gain</th><th>Max Loss</th><th>Avg Max DD</th></tr></thead><tbody id="bracketBody"></tbody></table></div></div></div>
    <div class="section"><div class="card"><h2>Major Signal Events (Score Crossed 70/80/90+)</h2><div id="signalEvents"></div></div></div>
  `;
  renderContext();
  renderDualChart();
  renderBracketTable();
  renderSignalEvents();
}

function renderContext() {
  const ctx = backtestData.current_context;
  if (!ctx) return;
  const el = document.getElementById('ctxScore');
  el.textContent = ctx.current_score + ' / 100';
  el.style.color = scoreColorCSS(ctx.current_score);
  document.getElementById('ctxPercentile').textContent =
    'Historical percentile: top ' + (100 - ctx.percentile).toFixed(1) + '% of all days (' + ctx.comparable_days + ' comparable days found)';
  if (ctx.avg_1yr_return != null) {
    let retHtml = 'Average returns from this score level: ';
    if (ctx.avg_30d_return != null) retHtml += '<span class="' + retClass(ctx.avg_30d_return) + '">30d: ' + fmtPct(ctx.avg_30d_return) + '</span> · ';
    if (ctx.avg_90d_return != null) retHtml += '<span class="' + retClass(ctx.avg_90d_return) + '">90d: ' + fmtPct(ctx.avg_90d_return) + '</span> · ';
    if (ctx.avg_180d_return != null) retHtml += '<span class="' + retClass(ctx.avg_180d_return) + '">180d: ' + fmtPct(ctx.avg_180d_return) + '</span> · ';
    retHtml += '<span class="' + retClass(ctx.avg_1yr_return) + '"><strong>1yr: ' + fmtPct(ctx.avg_1yr_return) + '</strong></span>';
    document.getElementById('ctxReturn').innerHTML = retHtml;
  }
  // Examples
  const list = document.getElementById('ctxComparables');
  if (ctx.examples && ctx.examples.length) {
    let html = '<h2 style="margin-top:12px">Comparable Historical Periods <span style="color:var(--muted);font-size:.7em;font-weight:normal">(one per market cycle)</span></h2>';
    for (const ex of ctx.examples) {
      const fr = ex.forward_returns || {};
      const cycleTag = ex.cycle ? '<span style="color:var(--accent);font-size:.75em;opacity:.7;margin-left:6px">[' + ex.cycle + ']</span>' : '';
      html += '<div class="comparable-item"><span>' + ex.date + ' — Score ' + ex.score + ' — ' + fmtPrice(ex.price) + cycleTag + '</span>';
      html += '<span>';
      if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span> ';
      if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span> ';
      if (fr['180d'] != null) html += '<span class="' + retClass(fr['180d']) + '">180d: ' + fmtPct(fr['180d']) + '</span> ';
      if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
      html += '</span></div>';
    }
    list.innerHTML = html;
  }
}

let btDualChart = null;
let btCurrentRange = 0;

function renderDualChart(days) {
  if (days === undefined) days = btCurrentRange;
  btCurrentRange = days;
  const chart = backtestData.chart_data;
  if (!chart || !chart.length) return;

  let data = chart;
  if (days > 0) data = chart.slice(-days);

  const ctx = document.getElementById('dualChart').getContext('2d');
  const labels = data.map(d => d.date);
  const scores = data.map(d => d.score);
  const prices = data.map(d => d.price);

  // Use log scale only for ALL or 4Y+ (when price spans multiple orders of magnitude)
  const priceMin = Math.min(...prices.filter(p => p > 0));
  const priceMax = Math.max(...prices);
  const useLog = (priceMax / priceMin) > 20; // >20x range = log makes sense

  const zonePlugin = {
    id: 'zoneBackground',
    beforeDraw(chart) {
      const { ctx: c, chartArea: {left, right}, scales: {y} } = chart;
      if (!y) return;
      const zones = [
        { min: 0, max: 35, color: 'rgba(239,68,68,0.06)' },
        { min: 35, max: 50, color: 'rgba(234,179,8,0.04)' },
        { min: 50, max: 65, color: 'rgba(234,179,8,0.06)' },
        { min: 65, max: 100, color: 'rgba(34,197,94,0.08)' },
      ];
      for (const z of zones) {
        const yTop = y.getPixelForValue(Math.min(z.max, 100));
        const yBot = y.getPixelForValue(z.min);
        c.fillStyle = z.color;
        c.fillRect(left, yTop, right - left, yBot - yTop);
      }
      // Threshold lines
      [35, 50, 65].forEach(val => {
        const yPos = y.getPixelForValue(val);
        c.beginPath();
        c.setLineDash([4, 4]);
        c.strokeStyle = 'rgba(255,255,255,0.06)';
        c.lineWidth = 1;
        c.moveTo(left, yPos);
        c.lineTo(right, yPos);
        c.stroke();
        c.setLineDash([]);
      });
    }
  };

  if (btDualChart) btDualChart.destroy();

  btDualChart = new Chart(ctx, {
    type: 'line',
    plugins: [zonePlugin],
    data: {
      labels,
      datasets: [
        {
          label: 'Accumulation Score',
          data: scores,
          borderColor: '#f7931a',
          backgroundColor: 'rgba(247,147,26,0.1)',
          borderWidth: 1.5,
          fill: false,
          tension: 0.2,
          pointRadius: data.length < 100 ? 2 : 0,
          yAxisID: 'y',
        },
        {
          label: 'BTC Price (USD)',
          data: prices,
          borderColor: '#22d3ee',
          borderWidth: 1.5,
          fill: false,
          tension: 0.2,
          pointRadius: 0,
          yAxisID: 'y1',
        }
      ]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: 'index', intersect: false },
      plugins: {
        legend: { labels: { color: '#94a3b8', font: { size: 11, family: 'monospace' } } },
        tooltip: {
          backgroundColor: 'rgba(10,10,15,0.95)',
          borderColor: 'rgba(255,255,255,0.08)',
          borderWidth: 1,
          titleFont: { family: 'monospace', size: 11 },
          bodyFont: { family: 'monospace', size: 11 },
          callbacks: {
            label: function(ctx) {
              if (ctx.datasetIndex === 1) return 'BTC: $' + Math.round(ctx.parsed.y).toLocaleString();
              const s = ctx.parsed.y;
              let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
              return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
            }
          }
        }
      },
      scales: {
        x: { ticks: { color: '#94a3b8', maxTicksLimit: 15, maxRotation: 45, font: { family: 'monospace', size: 10 } }, grid: { color: 'rgba(255,255,255,0.03)' } },
        y: { position: 'left', min: 0, max: 100, ticks: { color: '#f7931a', font: { family: 'monospace', size: 10 } }, grid: { color: 'rgba(255,255,255,0.03)' },
          title: { display: true, text: 'Score (0-100)', color: '#f7931a', font: { family: 'monospace', size: 11 } } },
        y1: {
          position: 'right',
          type: useLog ? 'logarithmic' : 'linear',
          ticks: {
            color: '#22d3ee',
            font: { family: 'monospace', size: 10 },
            callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(v >= 10000 ? 0 : 1) + 'k' : v.toLocaleString())
          },
          grid: { drawOnChartArea: false },
          title: { display: true, text: useLog ? 'BTC Price (log)' : 'BTC Price', color: '#22d3ee', font: { family: 'monospace', size: 11 } }
        }
      }
    }
  });
}

// Backtest range button handlers
document.addEventListener('click', function(e) {
  if (e.target.closest('#btRangeBar .range-btn')) {
    const btn = e.target.closest('.range-btn');
    document.querySelectorAll('#btRangeBar .range-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    renderDualChart(parseInt(btn.dataset.days));
  }
});

function renderBracketTable() {
  const brackets = backtestData.bracket_stats;
  if (!brackets) return;
  const tbody = document.getElementById('bracketBody');
  let html = '';
  for (const b of brackets) {
    const rowColor = b.days === 0 ? '' : (b.avg_365d > 50 ? 'style="background:rgba(34,197,94,0.08)"' : b.avg_365d < 0 ? 'style="background:rgba(239,68,68,0.08)"' : '');
    html += '<tr ' + rowColor + '>';
    html += '<td style="font-weight:700">' + b.range + '</td>';
    html += '<td>' + b.label + '</td>';
    html += '<td>' + b.days + '</td>';
    html += '<td class="' + retClass(b.avg_30d) + '">' + fmtPct(b.avg_30d) + '</td>';
    html += '<td class="' + retClass(b.avg_90d) + '">' + fmtPct(b.avg_90d) + '</td>';
    html += '<td class="' + retClass(b.avg_180d) + '">' + fmtPct(b.avg_180d) + '</td>';
    html += '<td class="' + retClass(b.avg_365d) + '">' + fmtPct(b.avg_365d) + '</td>';
    html += '<td>' + (b.win_rate_365d != null ? b.win_rate_365d + '%' : '--') + '</td>';
    html += '<td class="t-green">' + fmtPct(b.max_gain_365d) + '</td>';
    html += '<td class="t-red">' + fmtPct(b.max_loss_365d) + '</td>';
    html += '<td>' + (b.avg_max_drawdown_90d != null ? b.avg_max_drawdown_90d + '%' : '--') + '</td>';
    html += '</tr>';
  }
  tbody.innerHTML = html;
}

function renderSignalEvents() {
  const events = backtestData.signal_events;
  if (!events || !events.length) return;
  const el = document.getElementById('signalEvents');
  let html = '';
  // Show most recent events first, limit to 30
  const shown = events.slice(-30).reverse();
  for (const ev of shown) {
    const color = scoreColorCSS(ev.score);
    html += '<div class="signal-card">';
    html += '<div class="signal-header">';
    html += '<span class="signal-date">' + ev.date + ' &mdash; ' + fmtPrice(ev.price) + '</span>';
    html += '<span class="signal-score" style="background:' + color + ';color:#000">Score: ' + ev.score + ' (crossed ' + ev.threshold + ')</span>';
    html += '</div>';
    html += '<div class="signal-returns">';
    const fr = ev.forward_returns || {};
    if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span>';
    if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span>';
    if (fr['180d'] != null) html += '<span class="' + retClass(fr['180d']) + '">180d: ' + fmtPct(fr['180d']) + '</span>';
    if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
    if (ev.price_365d) html += '<span style="color:var(--text-dim)">Price 1yr: ' + fmtPrice(ev.price_365d) + '</span>';
    html += '</div></div>';
  }
  el.innerHTML = html;
}

// Init
checkStatus();
</script>
</body>
</html>"""


@app.get("/", response_class=HTMLResponse)
def dashboard():
    return DASHBOARD_HTML


@app.get("/backtest", response_class=HTMLResponse)
def backtest_page():
    return BACKTEST_HTML


@app.get("/settings", response_class=HTMLResponse)
def settings_page():
    return SETTINGS_HTML


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=3088)
