#!/usr/bin/env python3
"""
log_prescreen.py — Logarithmic Pre-screening for Symbolic Regression

"Everything Is Logarithms" — multiplicative structure becomes additive under log.

Before searching expression trees, try these transforms:
  1. log-log:  log(y) vs log(x)  → power law  y = a·x^b
  2. log-y:    log(y) vs x       → exponential y = e^(a·x + c)
  3. y-x:      y vs x            → polynomial  y = a·x + b
  4. y-vs-x²:  y vs x²          → quadratic   y = a·x² + b
  5. y-vs-√x:  y vs √x          → sqrt law    y = a·√x + b

If any transform gives R² > threshold, return that as the discovered law.
This collapses O(expression tree search) to O(linear regression) for
the most common physical laws.
"""

from __future__ import annotations

import math
import numpy as np
from dataclasses import dataclass
from typing import Optional, Tuple


@dataclass
class PrescreenResult:
    """Result from log prescreening."""
    name: str               # "power_law", "exponential", "polynomial", etc.
    expression: str         # human-readable: "y = 1.000 * x^1.500"
    r2: float               # R² in original space
    r2_transform: float     # R² in transformed space
    params: Tuple[float, ...]  # (a, b, ...) coefficients
    transform: str          # "log-log", "log-y", "y-x", etc.


def _linear_r2(x: np.ndarray, y: np.ndarray) -> Tuple[float, float, float]:
    """Linear regression y = a·x + b. Returns (a, b, R²)."""
    if len(x) < 2:
        return 0.0, 0.0, 0.0
    coeffs = np.polyfit(x, y, 1)
    a, b = coeffs
    y_pred = a * x + b
    ss_res = np.sum((y - y_pred) ** 2)
    ss_tot = np.sum((y - np.mean(y)) ** 2)
    r2 = 1.0 - ss_res / ss_tot if ss_tot > 1e-15 else 0.0
    return float(a), float(b), float(r2)


def _multivar_r2(X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, float]:
    """Multiple linear regression y = X·coeffs. Returns (coeffs, R²)."""
    if X.shape[0] < X.shape[1] + 1:
        return np.zeros(X.shape[1]), 0.0
    # Least squares: coeffs = (X^T X)^-1 X^T y
    try:
        coeffs = np.linalg.lstsq(X, y, rcond=None)[0]
        y_pred = X @ coeffs
        ss_res = np.sum((y - y_pred) ** 2)
        ss_tot = np.sum((y - np.mean(y)) ** 2)
        r2 = 1.0 - ss_res / ss_tot if ss_tot > 1e-15 else 0.0
        return coeffs, float(r2)
    except np.linalg.LinAlgError:
        return np.zeros(X.shape[1]), 0.0


def prescreen(
    x: np.ndarray,
    y: np.ndarray,
    feature_names: list[str] = None,
    r2_threshold: float = 0.999,
) -> Optional[PrescreenResult]:
    """Try log transforms before expression tree search.

    Returns PrescreenResult if any transform gives R² > threshold.
    Returns None if no simple law found (caller should use full search).

    Args:
        x: input array (n,) or (n, d) for multivariate
        y: target array (n,)
        feature_names: optional names for features
        r2_threshold: minimum R² to accept (default 0.999)

    Returns:
        PrescreenResult or None
    """
    x = np.asarray(x, dtype=np.float64)
    y = np.asarray(y, dtype=np.float64)

    if feature_names is None:
        if x.ndim == 1:
            feature_names = ["x"]
        else:
            feature_names = [f"x{i}" for i in range(x.shape[1])]

    # Ensure 2D
    if x.ndim == 1:
        x = x.reshape(-1, 1)

    n = len(y)
    if n < 3:
        return None

    results = []

    # ── 0. Linear: y = a·x + b (CHECK FIRST — power law with exp=1 is also linear) ──
    for j, name in enumerate(feature_names):
        a, b, r2 = _linear_r2(x[:, j], y)
        if r2 > r2_threshold:
            results.append(PrescreenResult(
                name="linear",
                expression=f"{a:.6f}*{name} + {b:.6f}",
                r2=r2,
                r2_transform=r2,
                params=(a, b),
                transform="y-x",
            ))

    # ── 1. Power law: y = a·x^b  (log-log: log(y) = b·log(x) + log(a)) ──
    if np.all(x > 0) and np.all(y > 0):
        log_x = np.log(x)
        log_y = np.log(y)
        for j, name in enumerate(feature_names):
            a, b, r2 = _linear_r2(log_x[:, j], log_y)
            if r2 > r2_threshold:
                # y = e^b · x^a  (a is slope, b is intercept)
                coeff = np.exp(b)
                exponent = a
                y_pred = coeff * x[:, j] ** exponent
                r2_orig = 1 - np.sum((y - y_pred)**2) / np.sum((y - np.mean(y))**2)
                results.append(PrescreenResult(
                    name="power_law",
                    expression=f"{coeff:.6f} * {name}^{exponent:.6f}",
                    r2=float(r2_orig),
                    r2_transform=r2,
                    params=(coeff, exponent),
                    transform="log-log",
                ))

    # ── 2. Exponential: y = e^(a·x + c)  (log-y: log(y) = a·x + c) ──
    if np.all(y > 0):
        log_y = np.log(y)
        for j, name in enumerate(feature_names):
            a, c, r2 = _linear_r2(x[:, j], log_y)
            if r2 > r2_threshold:
                y_pred = np.exp(a * x[:, j] + c)
                r2_orig = 1 - np.sum((y - y_pred)**2) / np.sum((y - np.mean(y))**2)
                results.append(PrescreenResult(
                    name="exponential",
                    expression=f"exp({a:.6f}*{name} + {c:.6f})",
                    r2=float(r2_orig),
                    r2_transform=r2,
                    params=(a, c),
                    transform="log-y",
                ))

    # ── 4. Quadratic: y = a·x² + b·x + c ──
    for j, name in enumerate(feature_names):
        X_quad = np.column_stack([x[:, j]**2, x[:, j], np.ones(n)])
        coeffs, r2 = _multivar_r2(X_quad, y)
        if r2 > r2_threshold:
            a, b, c = coeffs
            results.append(PrescreenResult(
                name="quadratic",
                expression=f"{a:.6f}*{name}² + {b:.6f}*{name} + {c:.6f}",
                r2=r2,
                r2_transform=r2,
                params=(a, b, c),
                transform="y-x²",
            ))

    # ── 5. Sqrt: y = a·√x + b ──
    if np.all(x >= 0):
        for j, name in enumerate(feature_names):
            sqrt_x = np.sqrt(x[:, j])
            a, b, r2 = _linear_r2(sqrt_x, y)
            if r2 > r2_threshold:
                results.append(PrescreenResult(
                    name="sqrt_law",
                    expression=f"{a:.6f}*sqrt({name}) + {b:.6f}",
                    r2=r2,
                    r2_transform=r2,
                    params=(a, b),
                    transform="y-√x",
                ))

    # ── 6. Multivariate power law: y = a·x₁^b₁·x₂^b₂·... ──
    if x.shape[1] > 1 and np.all(x > 0) and np.all(y > 0):
        log_x_all = np.log(x)
        log_y = np.log(y)
        X_log = np.column_stack([log_x_all, np.ones(n)])
        coeffs, r2 = _multivar_r2(X_log, log_y)
        if r2 > r2_threshold:
            exponents = coeffs[:-1]
            intercept = coeffs[-1]
            coeff = np.exp(intercept)
            terms = [f"{name}^{e:.4f}" for name, e in zip(feature_names, exponents)]
            expression = f"{coeff:.6f} * " + " * ".join(terms)
            results.append(PrescreenResult(
                name="multivariate_power_law",
                expression=expression,
                r2=r2,
                r2_transform=r2,
                params=tuple(coeffs),
                transform="log-log-multivar",
            ))

    # Return best result
    if not results:
        return None

    best = max(results, key=lambda r: r.r2)
    if best.r2 >= r2_threshold:
        return best
    return None


def prescreen_or_search(
    x: np.ndarray,
    y: np.ndarray,
    feature_names: list[str] = None,
    r2_threshold: float = 0.999,
) -> Tuple[str, float, str]:
    """Try prescreen first, return (expression, r2, method).

    If prescreen fails, returns ("", 0.0, "none") — caller should use full search.
    """
    result = prescreen(x, y, feature_names, r2_threshold)
    if result is not None:
        return result.expression, result.r2, result.name
    return "", 0.0, "none"
