#!/usr/bin/env python3
"""
test_kepler.py — Test SilverSight symbolic regression on Kepler's Third Law

Given 8 planets' distance (a) and orbital period (T):
  T = a^1.5  (Kepler's Third Law, 1618)

This is the same test GP-ELITE uses. We must rediscover a^1.5 from raw data.
"""

import sys
import math
import numpy as np
from itertools import product

sys.path.insert(0, "/home/allaun/SilverSight/python")

from expr_tree import (
    ExprNode, var_x, const, unary, binary,
    x_squared, x_cubed, sqrt_x, x_sqrt_x, sin_x, cos_x,
)
from linear_scaling import linear_scale, compute_r2, compute_mse


# ── Kepler data (NASA) ─────────────────────────────────────────────────

planets = ["Mercury","Venus","Earth","Mars","Jupiter","Saturn","Uranus","Neptune"]
a = np.array([0.387, 0.723, 1.000, 1.524, 5.203, 9.537, 19.191, 30.069])   # AU
T = np.array([0.241, 0.615, 1.000, 1.881, 11.862, 29.457, 84.011, 164.79]) # years


# ── BIC fitness ─────────────────────────────────────────────────────────

def bic_fitness(expr: ExprNode, xs: np.ndarray, ys: np.ndarray) -> float:
    """BIC = n·ln(MSE) + k·ln(n)

    Lower is better. Includes linear scaling.
    """
    n = len(ys)
    try:
        raw = expr.evaluate(xs)
        if not np.all(np.isfinite(raw)):
            return 1e15
        _, _, scaled = linear_scale(raw, ys)
        mse = compute_mse(scaled, ys)
        if mse < 1e-15:
            mse = 1e-15
        k = expr.complexity()
        return n * math.log(mse) + k * math.log(n)
    except Exception:
        return 1e15


# ── Expression candidates ──────────────────────────────────────────────

def build_candidates() -> list[tuple[str, ExprNode]]:
    """Build a library of candidate expressions."""
    x = var_x()
    candidates = []

    # Simple powers
    candidates.append(("x", x))
    candidates.append(("x^2", x_squared()))
    candidates.append(("x^3", x_cubed()))
    candidates.append(("sqrt(x)", sqrt_x()))
    candidates.append(("x*sqrt(x)", x_sqrt_x()))
    candidates.append(("x^1.5 (explicit)", binary("pow", x, const(1.5))))

    # Trig
    candidates.append(("sin(x)", sin_x()))
    candidates.append(("cos(x)", cos_x()))

    # Combinations
    candidates.append(("x^2 + x", binary("+", x_squared(), x)))
    candidates.append(("x^2 * x", binary("*", x_squared(), x)))
    candidates.append(("x * x * x", binary("*", binary("*", x, x), x)))
    candidates.append(("sqrt(x) * x", binary("*", sqrt_x(), x)))
    candidates.append(("x * x^0.5", binary("*", x, binary("pow", x, const(0.5)))))
    candidates.append(("x^0.5 * x", binary("*", binary("pow", x, const(0.5)), x)))

    # Log/exp
    candidates.append(("log(x)", unary("log", x)))
    candidates.append(("exp(x)", unary("exp", x)))
    candidates.append(("exp(-x)", unary("exp", unary("neg", x))))

    # Nested
    candidates.append(("sqrt(x^2)", unary("sqrt", x_squared())))
    candidates.append(("x^2^0.5", binary("pow", x_squared(), const(0.5))))

    # Constants
    candidates.append(("1", const(1.0)))
    candidates.append(("2", const(2.0)))
    candidates.append(("0.5", const(0.5)))

    # Linear scaling will handle a,b — test shape candidates
    candidates.append(("a*x + b", x))  # linear scaling makes this a*x+b
    candidates.append(("a*x^1.5 + b", x_sqrt_x()))  # this is the target

    return candidates


# ── Chaos game search (simplified) ─────────────────────────────────────

def chaos_game_search(
    xs: np.ndarray,
    ys: np.ndarray,
    n_iterations: int = 100,
    seed: int = 42,
) -> tuple[ExprNode, float, str]:
    """Simplified chaos game search over expression space.

    Uses IFS contraction: at each step, try to improve the best expression
    by applying transformations (add constant, change operator, nest).
    """
    rng = np.random.RandomState(seed)

    # Start with best candidate from library
    candidates = build_candidates()
    best_expr = None
    best_bic = 1e15
    best_name = ""

    for name, expr in candidates:
        bic = bic_fitness(expr, xs, ys)
        if bic < best_bic:
            best_bic = bic
            best_expr = expr
            best_name = name

    # Iterative refinement via chaos game
    for iteration in range(n_iterations):
        # Generate neighborhood: modify best expression
        neighbors = _generate_neighbors(best_expr, rng, xs, ys)

        for neighbor_expr, neighbor_name in neighbors:
            bic = bic_fitness(neighbor_expr, xs, ys)
            if bic < best_bic:
                best_bic = bic
                best_expr = neighbor_expr
                best_name = neighbor_name

    return best_expr, best_bic, best_name


def _generate_neighbors(
    expr: ExprNode,
    rng: np.random.RandomState,
    xs: np.ndarray,
    ys: np.ndarray,
) -> list[tuple[ExprNode, str]]:
    """Generate neighboring expressions by small modifications."""
    neighbors = []

    # Try replacing constants with nearby values
    if expr.op == "const":
        for delta in [-0.5, -0.1, 0.1, 0.5]:
            new_val = expr.value + delta
            neighbors.append((const(new_val), f"c={new_val:.2f}"))

    # Try wrapping in unary ops
    for op in ["sqrt", "log", "exp", "sin", "cos"]:
        neighbors.append((unary(op, expr.copy()), f"{op}({expr.to_string()})"))

    # Try multiplying by x
    neighbors.append((binary("*", expr.copy(), var_x()), f"({expr.to_string()})*x"))

    # Try raising to powers
    for p in [0.5, 1.5, 2.0, 3.0]:
        neighbors.append((
            binary("pow", expr.copy(), const(p)),
            f"({expr.to_string()})^{p}"
        ))

    return neighbors


# ── Main test ──────────────────────────────────────────────────────────

def main():
    print("=" * 60)
    print("SilverSight Symbolic Regression — Kepler Test")
    print("=" * 60)
    print()
    print("Data: 8 planets (NASA)")
    for p, ai, Ti in zip(planets, a, T):
        print(f"  {p:8s}  a = {ai:7.3f} AU   T = {Ti:8.3f} yr")
    print()
    print("Target: T = a^1.5  (Kepler's Third Law)")
    print()

    # Test 1: Direct candidates
    print("─" * 60)
    print("TEST 1: Candidate Library")
    print("─" * 60)

    candidates = build_candidates()
    results = []

    for name, expr in candidates:
        try:
            raw = expr.evaluate(a)
            if not np.all(np.isfinite(raw)):
                results.append((name, expr, 1e15, 0.0, "NaN/inf"))
                continue
            a_coeff, b_coeff, scaled = linear_scale(raw, T)
            r2 = compute_r2(scaled, T)
            mse = compute_mse(scaled, T)
            bic = bic_fitness(expr, a, T)
            results.append((name, expr, bic, r2, f"a={a_coeff:.4f}, b={b_coeff:.4f}"))
        except Exception as exc:
            results.append((name, expr, 1e15, 0.0, str(exc)[:40]))

    # Sort by BIC (lower is better)
    results.sort(key=lambda x: x[2])

    print(f"\n{'Expression':<25} {'BIC':>10} {'R²':>8} {'Params':<25}")
    print("-" * 70)
    for name, expr, bic, r2, params in results[:10]:
        if bic > 1e14:
            print(f"{name:<25} {'FAIL':>10} {r2:>8.6f} {params:<25}")
        else:
            print(f"{name:<25} {bic:>10.2f} {r2:>8.6f} {params:<25}")

    # Test 2: Best candidate with linear scaling
    print()
    print("─" * 60)
    print("TEST 2: Best Candidate Details")
    print("─" * 60)

    best_name, best_expr, best_bic, best_r2, best_params = results[0]
    raw = best_expr.evaluate(a)
    a_coeff, b_coeff, scaled = linear_scale(raw, T)

    print(f"\nBest: {best_name}")
    print(f"  Expression: {best_expr.to_string()}")
    print(f"  BIC: {best_bic:.2f}")
    print(f"  R²: {best_r2:.6f}")
    print(f"  Scaling: T = {a_coeff:.4f} · g(a) + {b_coeff:.4f}")
    print(f"  Where g(a) = {best_expr.to_string()}")
    print()

    # Test 3: Verify a^1.5 specifically
    print("─" * 60)
    print("TEST 3: Verify a^1.5 (Kepler's Law)")
    print("─" * 60)

    kepler_expr = x_sqrt_x()  # x * sqrt(x) = x^1.5
    raw = kepler_expr.evaluate(a)
    a_coeff, b_coeff, scaled = linear_scale(raw, T)
    r2 = compute_r2(scaled, T)
    mse = compute_mse(scaled, T)
    bic = bic_fitness(kepler_expr, a, T)

    print(f"\n  Expression: {kepler_expr.to_string()}")
    print(f"  BIC: {bic:.2f}")
    print(f"  R²: {r2:.6f}")
    print(f"  Scaling: T = {a_coeff:.6f} · a^1.5 + {b_coeff:.6f}")
    print(f"  Expected:  T ≈ 1.0 · a^1.5 + 0.0")
    print()

    # Show predictions vs actual
    print(f"  {'Planet':<10} {'Actual':>10} {'Predicted':>10} {'Error':>10}")
    print("  " + "-" * 42)
    for p, Ti, Si in zip(planets, T, scaled):
        print(f"  {p:<10} {Ti:>10.3f} {Si:>10.3f} {abs(Ti-Si):>10.4f}")

    # Test 4: Chaos game search
    print()
    print("─" * 60)
    print("TEST 4: Chaos Game Search")
    print("─" * 60)

    best_chaos, bic_chaos, name_chaos = chaos_game_search(a, T, n_iterations=50)
    raw_chaos = best_chaos.evaluate(a)
    a_chaos, b_chaos, scaled_chaos = linear_scale(raw_chaos, T)
    r2_chaos = compute_r2(scaled_chaos, T)

    print(f"\n  Best from chaos game: {name_chaos}")
    print(f"  Expression: {best_chaos.to_string()}")
    print(f"  BIC: {bic_chaos:.2f}")
    print(f"  R²: {r2_chaos:.6f}")
    print(f"  Scaling: T = {a_chaos:.6f} · g(a) + {b_chaos:.6f}")

    # Summary
    print()
    print("=" * 60)
    print("SUMMARY")
    print("=" * 60)
    print(f"  Target: T = a^1.5 (Kepler's Third Law)")
    print(f"  Data: 8 planets")
    print(f"  Best candidate: {best_name}")
    print(f"  Best R²: {best_r2:.6f}")
    print(f"  a^1.5 R²: {r2:.6f}")

    if best_r2 > 0.999:
        print()
        print("  ✓ PASS: SilverSight rediscovers Kepler's Third Law")
    elif r2 > 0.999:
        print()
        print("  ✓ PASS: a^1.5 fits with R² > 0.999")
    else:
        print()
        print(f"  ✗ FAIL: Best R² = {best_r2:.6f} < 0.999")

    return 0


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