"""
tighten_params.py — Apply q5–q95 range-tightening suggestions from the sweep DB.

For each asset/TF with EMERGING tier (≥200 rows), computes the q5–q95 range of
top-quartile (pnl_dd_percentile ≥ 75) rows for each weight/threshold param.
Where the q5–q95 span is < 70% of the current range width, the param JSON is
updated to use [q5, q95] (snapped to the nearest existing step).

Only shrinks ranges — never widens them.  Only acts when there are ≥ 200
top-quartile rows for the combo (EMERGING tier), matching the dashboard criterion.

Usage:
    python3 tools/tighten_params.py              # dry run — show what would change
    python3 tools/tighten_params.py --apply      # write changes to params JSONs
    python3 tools/tighten_params.py --apply --asset COINBASE_BTCUSD --timeframe 4H
"""

import argparse
import glob
import json
import os
import re
import sqlite3
import sys

import numpy as np
import pandas as pd

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

from config import WEIGHT_COLS  # single source of truth — do not copy here

DB_FILE      = "results/sweep_database.db"
PARAMS_GLOB  = "strategies/params/params_strategy_activation_scores_*.json"
BASE_TQ_ROWS = 200     # combo-level gate: skip entirely if fewer rows than this
TIGHTEN_PCT  = 0.70    # q5–q95 must be < 70% of current width to tighten
MIN_STEP     = 1.0     # never suggest a step finer than this (sub-1 differences are noise)
DB_SCHEMA_VER = 3

THRESHOLD_COLS = [
    "i_long_entry_activation_threshold",
    "i_long_exit_activation_threshold",
    "i_long_exit_activation_confirmation_threshold",
    "i_trailing_stop_threshold",
    "i_regime_entry_min_score",
]

RANGE_COLS = WEIGHT_COLS + THRESHOLD_COLS


def _snap(value: float, step: float) -> float:
    """Round value to nearest multiple of step."""
    if step <= 0:
        return value
    return round(round(value / step) * step, 10)


def _suggest_step(new_start: float, new_stop: float, current_step: float) -> float:
    """
    Return an appropriate step for the tightened range.
    Keeps the current step if it gives ≥ 20 grid points.
    Otherwise reduces to the largest 'nice' step that still gives ≥ 20 points.
    Targeting 20 points (vs the old floor of 10) means step granularity increases
    proportionally as ranges tighten, rather than only when nearly degenerate.
    """
    width = new_stop - new_start
    if width <= 0 or current_step <= 0:
        return current_step
    if width / current_step >= 20:
        return current_step
    nice_steps = [50, 25, 20, 10, 5, 4, 2, 1, 0.5, 0.25, 0.1]
    for s in nice_steps:
        if s < current_step and s >= MIN_STEP and width / s >= 20:
            return s
    # Can't hit 20 grid points within MIN_STEP floor — use MIN_STEP for max resolution
    return min(current_step, MIN_STEP)


def _required_rows(current_width: float) -> int:
    """
    Sliding-scale row requirement based on how tightly the range has already
    been narrowed.  A wide range hasn't been tightened before — less evidence
    needed.  A narrow range means a previous tightening already committed; we
    need higher confidence before committing further.

    width ≥ 100  → 200 rows  (typical first tightening on a full-width param)
    width 50–99  → 500 rows  (already narrowed once — moderate confidence)
    width < 50   → 1000 rows (tightly narrowed — high conviction only)
    """
    if current_width >= 100:
        return 200
    elif current_width >= 50:
        return 500
    else:
        return 1000


def load_all_params():
    """Return {(asset, tf): (path, dict)} for every params JSON found."""
    result = {}
    for jf in sorted(glob.glob(PARAMS_GLOB)):
        m = re.search(r"params_strategy_activation_scores_([A-Z0-9_]+)_([A-Z0-9HD]+)\.json$", jf)
        if not m:
            continue
        asset, tf = m.group(1), m.group(2)
        with open(jf) as f:
            data = json.load(f)
        result[(asset, tf)] = (jf, data)
    return result


def get_range(params_dict, col):
    """Return (start, stop, step) or None if col absent or values-only."""
    v = params_dict.get(col)
    if not isinstance(v, dict):
        return None
    if "start" in v and "stop" in v:
        return (float(v["start"]), float(v["stop"]), float(v.get("step", 1)))
    return None  # values-only entry — skip


def compute_tightenings(asset_filter=None, tf_filter=None):
    """
    Query the DB and return a list of proposed tightenings:
      [(asset, tf, param, new_start, new_stop, new_step), ...]
    Only includes entries that pass the TIGHTEN_PCT criterion.
    """
    if not os.path.exists(DB_FILE):
        return []

    conn = sqlite3.connect(DB_FILE)
    try:
        df = pd.read_sql_query(
            "SELECT * FROM sweep_results WHERE schema_ver = ?",
            conn, params=(DB_SCHEMA_VER,)
        )
    except Exception:
        conn.close()
        return []
    conn.close()

    if df.empty or "pnl_dd_percentile" not in df.columns:
        return []

    all_params = load_all_params()
    proposals = []

    for (asset, tf), (path, params_dict) in all_params.items():
        if asset_filter and asset != asset_filter:
            continue
        if tf_filter and tf != tf_filter:
            continue

        sub = df[
            (df["asset"] == asset) &
            (df["timeframe"] == tf) &
            (df["pnl_dd_percentile"] >= 75)
        ]
        if len(sub) < BASE_TQ_ROWS:
            continue

        for col in RANGE_COLS:
            if col not in sub.columns:
                continue
            current = get_range(params_dict, col)
            if current is None:
                continue
            curr_start, curr_stop, curr_step = current
            current_width = curr_stop - curr_start
            if current_width <= 0:
                continue  # already locked to a single value

            # Sliding-scale: narrower ranges require more evidence before
            # tightening further (committing narrows the escape route).
            if len(sub) < _required_rows(current_width):
                continue

            col_data = sub[col].dropna()
            if len(col_data) < 10:
                continue

            q05 = col_data.quantile(0.05)
            q95 = col_data.quantile(0.95)
            new_width = q95 - q05

            if new_width >= TIGHTEN_PCT * current_width:
                continue  # not concentrated enough

            # Snap to step, clamp to current range
            new_start = max(curr_start, _snap(q05, curr_step))
            new_stop  = min(curr_stop,  _snap(q95, curr_step))

            # Guard: must still have at least 2 grid points
            if new_stop <= new_start or (new_stop - new_start) < curr_step:
                continue

            new_step = _suggest_step(new_start, new_stop, curr_step)
            proposals.append((asset, tf, col, new_start, new_stop, new_step))

    return proposals


def apply_tightenings(proposals, apply: bool):
    """Write tightening proposals back to params JSONs (or dry-run print)."""
    if not proposals:
        print("tighten_params: nothing to tighten.")
        return

    all_params = load_all_params()

    # Group by file
    by_file = {}
    for asset, tf, col, ns, ne, nstep in proposals:
        key = (asset, tf)
        if key not in all_params:
            continue
        path, params_dict = all_params[key]
        by_file.setdefault(path, (params_dict, []))
        by_file[path][1].append((col, ns, ne, nstep))

    changed = 0
    for path, (params_dict, changes) in by_file.items():
        for col, ns, ne, nstep in changes:
            old = params_dict[col]
            tag = "DRY" if not apply else "APPLIED"
            print(f"  [{tag}] {os.path.basename(path)} | {col}: "
                  f"[{old['start']:g}, {old['stop']:g} step {old.get('step',1):g}] → "
                  f"[{ns:g}, {ne:g} step {nstep:g}]")
            if apply:
                params_dict[col]["start"] = ns
                params_dict[col]["stop"]  = ne
                params_dict[col]["step"]  = nstep
                changed += 1

        if apply and changes:
            with open(path, "w") as f:
                json.dump(params_dict, f, indent=2)

    if apply:
        print(f"tighten_params: applied {changed} tightenings.")
    else:
        print(f"tighten_params: {len(proposals)} tightenings proposed. Re-run with --apply to write.")


def main():
    parser = argparse.ArgumentParser(description="Auto-tighten param ranges from sweep DB")
    parser.add_argument("--apply",     action="store_true", help="Write changes (default: dry run)")
    parser.add_argument("--asset",     default=None)
    parser.add_argument("--timeframe", default=None)
    args = parser.parse_args()

    proposals = compute_tightenings(args.asset, args.timeframe)
    apply_tightenings(proposals, apply=args.apply)


if __name__ == "__main__":
    main()
