#!/usr/bin/env python3
"""
check_pine.py — Static analysis of strategy_activation_scores.pine before optimization runs.

What this tool CAN check statically:
  1. request.security() direct calls — budget is 40 total (libraries add to this count
     invisibly; the library contribution is an estimate, not exact).
  2. plotchar() + plot() calls — hard limit is 64 per script.
  3. plotchar export titles — lists all exported column names.

What this tool CANNOT check statically:
  - Undeclared identifier errors: Pine variable scoping is too dynamic to detect reliably
    without a real compiler. The only way to catch these is to paste into TradingView.
  - Library-internal request.security() counts: the +23 library estimate may drift if
    library versions change. Treat the total as approximate.
  - Compile errors from syntax mistakes, type mismatches, etc.

These limitations are why the TV-validation sentinel exists: the tool prompts you to
paste into TradingView and confirm it compiles, then records that confirmation.

Also manages the TV-validation sentinel file (.pine_tv_validated) used by
auto_optimize_loop.py to warn about unvalidated Pine edits before a run starts.

Usage:
    python3 tools/check_pine.py                # run checks, print report
    python3 tools/check_pine.py --mark-valid   # record that Pine was confirmed OK in TV
    python3 tools/check_pine.py --status       # show whether sentinel is current
"""

import argparse
import os
import re
import sys
from datetime import datetime

PINE_FILE = "strategies/strategy_activation_scores.pine"
SENTINEL_FILE = ".pine_tv_validated"

# Known library request.security() contributions (from audit of imported libraries).
# Update this count if libraries are changed.
# NOTE: the BTCUSDTPERP tuple call [close, volume] likely counts as 2 in TV's
# internal accounting despite appearing as 1 in static analysis. Treat effective
# library+overhead as 24 to reflect this — confirmed when 17 direct caused TV to
# report "too many request calls" (17 + 24 = 41 > 40).
LIBRARY_SECURITY_CALLS = 24
SECURITY_HARD_LIMIT = 40

# Known library plot/plotchar contributions (libraries can emit their own plots that
# count against the 64-plot limit but are invisible to static analysis of the main file).
# Determined empirically: TV reported 65 when static count was 62 → libraries add 3.
# Update this count if library imports change.
LIBRARY_PLOT_CALLS = 3
PLOT_HARD_LIMIT = 64

# Warn thresholds (% of limit used)
SECURITY_WARN_PCT = 0.90   # warn at 90%
PLOT_WARN_PCT     = 0.90


def read_pine():
    if not os.path.exists(PINE_FILE):
        sys.exit(f"Pine file not found: {PINE_FILE}")
    with open(PINE_FILE) as f:
        return f.readlines()


def check_security_calls(lines):
    """Count direct request.security() calls and estimate total vs limit."""
    hits = [(i + 1, line.rstrip()) for i, line in enumerate(lines)
            if "request.security(" in line and not line.lstrip().startswith("//")]
    direct = len(hits)
    estimated_total = direct + LIBRARY_SECURITY_CALLS
    at_limit = estimated_total >= SECURITY_HARD_LIMIT
    near_limit = estimated_total >= SECURITY_HARD_LIMIT * SECURITY_WARN_PCT
    return hits, direct, estimated_total, near_limit, at_limit


def check_plot_calls(lines):
    """Count plotchar() and plot() calls (not commented out), plus library estimate."""
    plot_hits = []
    for i, line in enumerate(lines):
        stripped = line.lstrip()
        if stripped.startswith("//"):
            continue
        # plotchar( or plot( at start of statement (handles indented lines)
        if re.match(r"\s*plotchar\s*\(", line) or re.match(r"\s*plot\s*\(", line):
            plot_hits.append((i + 1, line.rstrip()))
    direct = len(plot_hits)
    estimated_total = direct + LIBRARY_PLOT_CALLS
    near_limit = estimated_total >= PLOT_HARD_LIMIT * PLOT_WARN_PCT
    at_limit = estimated_total >= PLOT_HARD_LIMIT
    return plot_hits, direct, estimated_total, near_limit, at_limit


def get_plotchar_titles(lines):
    """Extract title= strings from plotchar calls — these are the exported columns."""
    titles = []
    for line in lines:
        if line.lstrip().startswith("//"):
            continue
        m = re.search(r'plotchar\s*\(.*?title\s*=\s*"([^"]+)"', line)
        if m:
            titles.append(m.group(1))
    return titles


def check_weight_declarations(lines):
    """
    Check that every i_w_* param in config.WEIGHT_COLS is declared in the Pine file.

    A param is 'declared' if the file contains an assignment like:
        float i_w_foo = ...   or   i_w_foo = ...
    (not just used in an expression).

    This catches the common mistake of adding a new signal to the score formula and
    params JSON but forgetting to re-run generate_pine_presets.py to add its
    'float i_w_foo = switch _preset' block.
    """
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    try:
        from config import WEIGHT_COLS
    except ImportError:
        return [], []  # can't check without config

    content = "".join(lines)

    declared = []
    undeclared = []
    for w in WEIGHT_COLS:
        # A declaration is an assignment at the start of an expression:
        #   "float i_w_foo = " or "i_w_foo = " (not inside math.abs() etc.)
        if re.search(rf'(?:^|\n)\s*(?:float\s+)?{re.escape(w)}\s*=\s*', content):
            declared.append(w)
        else:
            undeclared.append(w)

    return declared, undeclared


def sentinel_status():
    """Return (exists, pine_mtime, sentinel_mtime, is_stale)."""
    if not os.path.exists(PINE_FILE):
        return False, None, None, True
    pine_mtime = os.path.getmtime(PINE_FILE)
    if not os.path.exists(SENTINEL_FILE):
        return False, pine_mtime, None, True
    sentinel_mtime = os.path.getmtime(SENTINEL_FILE)
    is_stale = pine_mtime > sentinel_mtime
    return True, pine_mtime, sentinel_mtime, is_stale


def mark_valid():
    """Touch the sentinel file to record Pine was confirmed OK in TV."""
    with open(SENTINEL_FILE, "w") as f:
        f.write(f"Validated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
        f.write(f"Pine file: {PINE_FILE}\n")
    print(f"✅ Sentinel updated: {SENTINEL_FILE}")
    print(f"   auto_optimize_loop.py will not warn about unvalidated Pine edits.")


def check_mlp_preset_arches() -> dict:
    """
    Check that all MLP winner artifacts share the same architecture.
    Returns a dict of {preset_key: {winner: arch, ref: ref_arch}} for any mismatches.
    Mixed archs cause generate_pine_mlp_presets.py to silently skip presets.
    """
    import csv as _csv, json as _json
    WINNER_DIR_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                                   "results", "winners")
    TFS = ["4H", "6H", "8H", "12H", "1D"]
    ASSET = "COINBASE_BTCUSD"

    arches: dict[str, list] = {}
    for tf in TFS:
        p = os.path.join(WINNER_DIR_PATH, f"optimization_winner_strategy_mlp_scores_{ASSET}_{tf}.csv")
        if not os.path.exists(p):
            continue
        rows = list(_csv.DictReader(open(p)))
        if not rows:
            continue
        art_path = rows[0].get("mlp_weights_file", "").strip()
        if not art_path or not os.path.exists(art_path):
            continue
        try:
            art = _json.load(open(art_path))
            arches[f"{ASSET} {tf}"] = art.get("arch", [])
        except Exception:
            pass

    if not arches:
        return {}

    all_arch_lists = list(arches.values())
    ref_arch = all_arch_lists[0]
    mismatches = {}
    for key, arch in arches.items():
        if arch != ref_arch:
            mismatches[key] = {"winner": arch, "ref": ref_arch}
    return mismatches


def main():
    parser = argparse.ArgumentParser(description="Static Pine file analysis + TV validation sentinel.")
    parser.add_argument("--mark-valid", action="store_true",
                        help="Record that Pine was confirmed OK in TradingView")
    parser.add_argument("--status", action="store_true",
                        help="Show sentinel status only, no full report")
    args = parser.parse_args()

    if args.mark_valid:
        mark_valid()
        return

    sentinel_exists, pine_mtime, sentinel_mtime, is_stale = sentinel_status()

    if args.status:
        if not sentinel_exists:
            print("⚠️  No validation sentinel found — Pine has never been confirmed OK in TV.")
        elif is_stale:
            pine_dt = datetime.fromtimestamp(pine_mtime).strftime("%Y-%m-%d %H:%M:%S")
            sent_dt = datetime.fromtimestamp(sentinel_mtime).strftime("%Y-%m-%d %H:%M:%S")
            print(f"⚠️  Pine modified AFTER last TV validation.")
            print(f"   Pine mtime:     {pine_dt}")
            print(f"   Last validated: {sent_dt}")
        else:
            sent_dt = datetime.fromtimestamp(sentinel_mtime).strftime("%Y-%m-%d %H:%M:%S")
            print(f"✅ Pine is current — last validated {sent_dt}")
        return

    lines = read_pine()

    print(f"\n{'='*60}")
    print(f"Pine Static Analysis: {PINE_FILE}")
    print(f"{'='*60}\n")

    # --- request.security() ---
    sec_hits, direct, estimated_total, near_sec, at_sec = check_security_calls(lines)
    sec_icon = "❌" if at_sec else ("⚠️ " if near_sec else "✅")
    print(f"{sec_icon} request.security() calls")
    print(f"   Direct in file:    {direct}")
    print(f"   Library estimate:  +{LIBRARY_SECURITY_CALLS} (from imported libs)")
    print(f"   Estimated total:   {estimated_total} / {SECURITY_HARD_LIMIT}")
    slots_remaining = SECURITY_HARD_LIMIT - estimated_total
    if slots_remaining <= 0:
        print(f"   ❌ AT OR OVER LIMIT — cannot add more request.security() calls")
    else:
        print(f"   Slots remaining:   {slots_remaining}")
    print()

    # --- plot / plotchar ---
    plot_hits, direct_plots, estimated_plots, near_plot, at_plot = check_plot_calls(lines)
    plot_icon = "❌" if at_plot else ("⚠️ " if near_plot else "✅")
    print(f"{plot_icon} plot() + plotchar() calls")
    print(f"   Direct in file:    {direct_plots}")
    print(f"   Library estimate:  +{LIBRARY_PLOT_CALLS} (from imported libs)")
    print(f"   Estimated total:   {estimated_plots} / {PLOT_HARD_LIMIT}")
    slots_remaining_plot = PLOT_HARD_LIMIT - estimated_plots
    if slots_remaining_plot <= 0:
        print(f"   ❌ AT OR OVER LIMIT — cannot add more plot/plotchar calls")
    else:
        print(f"   Slots remaining:   {slots_remaining_plot}")
    print()

    # --- plotchar export titles ---
    titles = get_plotchar_titles(lines)
    norm_titles = [t for t in titles if t.endswith("_norm")]
    print(f"📋 plotchar exports: {len(titles)} total, {len(norm_titles)} ending in _norm")
    print()

    # --- i_w_* declaration check ---
    declared, undeclared = check_weight_declarations(lines)
    if undeclared:
        print(f"❌ Undeclared i_w_* weight params ({len(undeclared)}) — will cause TV compile errors:")
        for w in undeclared:
            print(f"   {w}")
        print(f"   Fix: re-run  python3 tools/generate_pine_presets.py")
    else:
        print(f"✅ All {len(declared)} i_w_* weight params are declared in Pine")
    print()

    # --- MLP preset arch consistency ---
    skipped_presets = check_mlp_preset_arches()
    if skipped_presets:
        print(f"❌ MLP preset arch mismatch — {len(skipped_presets)} preset(s) would be SKIPPED by codegen:")
        for key, info in skipped_presets.items():
            print(f"   {key}: winner arch {info['winner']} ≠ ref arch {info['ref']}")
        print(f"   These presets produce ZERO weights in Pine (flat score = 0 for those TFs).")
        print(f"   Fix options:")
        print(f"     A) Revert winner CSV to a compatible artifact:  git checkout HEAD -- results/winners/...")
        print(f"     B) Do Phase 2 upgrade:  /mlp-phase2")
        print()
    else:
        # Only print if MLP artifacts exist
        import glob
        if glob.glob("results/winners/optimization_winner_strategy_mlp_scores_*.csv"):
            print(f"✅ MLP preset architectures consistent (no skipped presets)")
            print()

    # --- Sentinel status ---
    print(f"{'─'*60}")
    print("TV Validation Sentinel")
    if not sentinel_exists:
        print("  ⚠️  Not yet validated in TradingView.")
        print(f"  → Paste Pine into TV, confirm it compiles, then run:")
        print(f"      python3 tools/check_pine.py --mark-valid")
    elif is_stale:
        pine_dt = datetime.fromtimestamp(pine_mtime).strftime("%Y-%m-%d %H:%M:%S")
        sent_dt = datetime.fromtimestamp(sentinel_mtime).strftime("%Y-%m-%d %H:%M:%S")
        print(f"  ⚠️  Pine was modified AFTER last TV validation.")
        print(f"     Pine modified:   {pine_dt}")
        print(f"     Last validated:  {sent_dt}")
        print(f"  → Paste updated Pine into TV, confirm it compiles, then run:")
        print(f"      python3 tools/check_pine.py --mark-valid")
    else:
        sent_dt = datetime.fromtimestamp(sentinel_mtime).strftime("%Y-%m-%d %H:%M:%S")
        print(f"  ✅ Validated in TV at {sent_dt}")

    print()

    # Exit with error code if hard limits exceeded, undeclared identifiers, or arch mismatch
    if at_sec or at_plot or undeclared or skipped_presets:
        sys.exit(1)


if __name__ == "__main__":
    main()
