#!/usr/bin/env python3
"""
Sensitivity Finder — converging real-time aim sensitivity calibrator.

It runs a step-halving search over your sensitivity range: you play a task at the
sensitivity it tells you, and from each task's score it decides the next value,
HALVING the step every time your score gets worse — so it homes in on the
sensitivity where you aim best.

THREE MODES
  --demo     Offline self-test against a synthetic peak (no game needed). Run
             this first to see it converge and trust the math.
  (default)  Real-time: watches Aim Lab's local results DB and reacts to each
             task you finish, printing the next sensitivity to set.
  --manual   Real-time without Aim Lab: after each task you type your score
             (e.g. hits out of 20) and it prints the next sensitivity.

REAL-TIME AIM LAB NOTE (read this)
  Aim Lab has no public live API. It DOES write every task you finish to a local
  SQLite database. This script auto-locates that DB and auto-discovers its schema
  (finds the table + the score/accuracy/timestamp columns by heuristic), so it
  works across Aim Lab versions without hardcoded names. If it can't find/parse
  it, it tells you exactly what it saw — paste that back and it's a 2-line fix.

  Applying the new sensitivity is left to you (Aim Lab doesn't reliably hot-reload
  sens from disk). The script prints the value big; set it in Aim Lab settings and
  play the next task. Set AUTO_APPLY_FILE below to also write it to a file.

USAGE
  python sensitivity_finder.py --demo
  python sensitivity_finder.py            # real-time via Aim Lab DB
  python sensitivity_finder.py --manual   # real-time, you type the score
"""

import argparse
import glob
import os
import sqlite3
import sys
import time

# ── CONFIG ──────────────────────────────────────────────────────────────────
SENS_MIN = 0.20          # lowest sensitivity to consider
SENS_MAX = 3.00          # highest
SHOTS_PER_ROUND = 20     # "after 20 consecutive shots" — info only for manual
POLL_SECONDS = 2.0       # how often to check Aim Lab DB for a new task
AUTO_APPLY_FILE = ""     # optional path; if set, the next sens is written here
# Extra Aim Lab data dirs to search (the common ones are auto-included):
EXTRA_DB_GLOBS = []
# ─────────────────────────────────────────────────────────────────────────────


def round3(v):
    return round(v * 1000) / 1000


def clamp(v, lo, hi):
    return max(lo, min(hi, v))


class Search:
    """Step-halving hill-climb on a unimodal score(sens) curve.

    Move in the current direction while the score improves; on a worse score we
    overshot the optimum, so reverse and halve the step. Converges when the step
    is tiny relative to the range. `best` is the highest-scoring sens seen.
    """

    def __init__(self, lo=SENS_MIN, hi=SENS_MAX):
        assert hi > lo, "SENS_MAX must be greater than SENS_MIN"
        self.lo, self.hi = lo, hi
        self.step = (hi - lo) / 4
        self.sens = round3((lo + hi) / 2)
        self.dir = 1
        self.last = None
        self.best = None            # (sens, score)
        self.round = 1
        self.done = False

    def submit(self, score):
        """Record `score` for the current sens; return the next sens (or None
        when converged)."""
        if self.best is None or score > self.best[1]:
            self.best = (self.sens, score)
        if self.last is not None and score < self.last:
            self.dir = -self.dir          # overshot → reverse
            self.step /= 2                # and refine
        self.last = score
        if self.step <= (self.hi - self.lo) * 0.01 or self.round >= 30:
            self.done = True
            return None
        nxt = clamp(round3(self.sens + self.dir * self.step), self.lo, self.hi)
        if nxt == self.sens:              # bounced off a boundary → flip + refine
            self.dir = -self.dir
            self.step /= 2
            nxt = clamp(round3(self.sens + self.dir * self.step), self.lo, self.hi)
        self.sens = nxt
        self.round += 1
        return self.sens


def announce(search):
    print("\n" + "=" * 44)
    print(f"  ROUND {search.round}  →  SET SENSITIVITY:  {search.sens:.3f}")
    print(f"  (search step ±{round3(search.step):.3f})")
    print("=" * 44)
    if AUTO_APPLY_FILE:
        try:
            with open(AUTO_APPLY_FILE, "w") as f:
                f.write(f"{search.sens:.3f}\n")
        except OSError as e:
            print(f"  [warn] could not write {AUTO_APPLY_FILE}: {e}")


def finish(search):
    s, sc = search.best
    print("\n" + "★" * 44)
    print(f"  RECOMMENDED SENSITIVITY: {s:.3f}   (best score {sc})")
    print("★" * 44)
    if AUTO_APPLY_FILE:
        try:
            open(AUTO_APPLY_FILE, "w").write(f"{s:.3f}\n")
        except OSError:
            pass


# ── Aim Lab DB discovery + reading ───────────────────────────────────────────
def find_db():
    """Locate Aim Lab's results SQLite DB. Returns a path or None."""
    home = os.path.expanduser("~")
    globs = [
        os.path.join(home, "AppData", "LocalLow", "statespace", "Aimlab", "klutch*"),
        os.path.join(home, "AppData", "LocalLow", "statespace", "Aimlab", "*.bytes"),
        os.path.join(home, "AppData", "LocalLow", "statespace", "Aimlab", "**", "*.bytes"),
        os.path.join(home, "AppData", "LocalLow", "Aim Lab", "**", "*"),
    ] + EXTRA_DB_GLOBS
    for g in globs:
        for p in glob.glob(g, recursive=True):
            if os.path.isfile(p) and _is_sqlite(p):
                return p
    return None


def _is_sqlite(path):
    try:
        with open(path, "rb") as f:
            return f.read(16).startswith(b"SQLite format 3")
    except OSError:
        return False


def _discover_table(conn):
    """Find a table with a score-like and a time-like column. Returns
    (table, score_col, time_col) or None — printed so it's debuggable."""
    cur = conn.cursor()
    tables = [r[0] for r in cur.execute(
        "SELECT name FROM sqlite_master WHERE type='table'")]
    best = None
    for t in tables:
        try:
            cols = [c[1] for c in cur.execute(f'PRAGMA table_info("{t}")')]
        except sqlite3.Error:
            continue
        low = {c.lower(): c for c in cols}
        score_col = next((low[k] for k in low
                          if any(s in k for s in ("score", "accuracy", "acc", "kill"))), None)
        time_col = next((low[k] for k in low
                         if any(s in k for s in ("time", "date", "created", "ts"))), None)
        if score_col and time_col:
            print(f"  [aimlab] table '{t}': score='{score_col}' time='{time_col}'")
            best = (t, score_col, time_col)
    return best


def watch_aimlab(search):
    db = find_db()
    if not db:
        print("Could not find the Aim Lab results DB. Searched "
              "%LOCALAPPDATA%Low\\statespace\\Aimlab.\n"
              "→ Run with --manual, or set EXTRA_DB_GLOBS to your Aim Lab folder.")
        return
    print(f"Watching Aim Lab DB: {db}")
    conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
    found = _discover_table(conn)
    if not found:
        print("Opened the DB but found no score+time table. Tables present:")
        for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'"):
            print("   -", r[0])
        print("→ Paste this list back and I'll map the columns; meanwhile use --manual.")
        return
    table, score_col, time_col = found
    last_seen = conn.execute(f'SELECT MAX("{time_col}") FROM "{table}"').fetchone()[0]
    announce(search)
    print("\nFinish an Aim Lab task… (Ctrl+C to stop)")
    while not search.done:
        time.sleep(POLL_SECONDS)
        row = conn.execute(
            f'SELECT "{score_col}", "{time_col}" FROM "{table}" '
            f'WHERE "{time_col}" > ? ORDER BY "{time_col}" DESC LIMIT 1',
            (last_seen,)).fetchone()
        if not row:
            continue
        score, last_seen = row
        try:
            score = float(score)
        except (TypeError, ValueError):
            continue
        print(f"\n[task done] score={score} at sens {search.sens:.3f}")
        nxt = search.submit(score)
        if nxt is None:
            finish(search)
        else:
            announce(search)


def manual_loop(search):
    print(f"Manual real-time mode. Play a {SHOTS_PER_ROUND}-shot task at the shown "
          f"sensitivity, then type your score (higher = better). Ctrl+C to stop.")
    announce(search)
    while not search.done:
        try:
            raw = input("  score for this round > ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            break
        if not raw:
            continue
        try:
            score = float(raw)
        except ValueError:
            print("  enter a number")
            continue
        nxt = search.submit(score)
        if nxt is None:
            finish(search)
        else:
            announce(search)


def demo():
    """Offline self-test: converge on a synthetic peak so you can trust it."""
    ok = True
    for peak in (0.40, 1.35, 2.10, 2.80):
        s = Search()
        for _ in range(40):
            if s.done:
                break
            score = max(0.0, 20 - abs(s.sens - peak) * 12)  # synthetic unimodal
            s.submit(score)
        err = abs(s.best[0] - peak)
        status = "OK" if err < 0.2 else "FAIL"
        if err >= 0.2:
            ok = False
        print(f"peak {peak}: best={s.best[0]:.3f} score={s.best[1]:.1f} "
              f"rounds={s.round} err={err:.3f} {status}")
    print("\nself-test:", "PASS" if ok else "FAIL")
    return 0 if ok else 1


def main():
    ap = argparse.ArgumentParser(description="Real-time aim sensitivity finder.")
    ap.add_argument("--demo", action="store_true", help="offline self-test")
    ap.add_argument("--manual", action="store_true", help="type scores instead of reading Aim Lab")
    ap.add_argument("--min", type=float, default=SENS_MIN)
    ap.add_argument("--max", type=float, default=SENS_MAX)
    args = ap.parse_args()
    if args.demo:
        return demo()
    search = Search(args.min, args.max)
    try:
        (manual_loop if args.manual else watch_aimlab)(search)
    except KeyboardInterrupt:
        print("\nstopped.")
        if search.best:
            print(f"best so far: {search.best[0]:.3f} (score {search.best[1]})")
    return 0


if __name__ == "__main__":
    sys.exit(main())
