"""
wivexx_calc.py — local math, percent, unit & currency conversions  (v1.15)
═══════════════════════════════════════════════════════════════════════
Answers everyday "calculate / convert" questions on-device, with NO Claude
API tokens spent — instant arithmetic, percentages, unit conversions, and
live currency conversion (free, no-key exchange-rate feed, cached).

Public API (used by wivexx_api.py):
    answer(text) -> Optional[str]
        A finished, speakable answer, or None if *text* isn't a calc/convert
        query (so the normal pipeline / Claude can handle it instead).

Safe: arithmetic is evaluated through an AST whitelist (never `eval`).
"""

import ast
import math
import operator
import re
import time
from typing import Optional

try:
    import requests
except Exception:                                    # pragma: no cover
    requests = None

_logger = None


def set_logger(logger) -> None:
    global _logger
    _logger = logger


# ══════════════════════════════════════════════════════════════════════════════
#  Safe arithmetic
# ══════════════════════════════════════════════════════════════════════════════
_BIN_OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
    ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
    ast.Mod: operator.mod, ast.Pow: operator.pow,
}
_UN_OPS = {ast.USub: operator.neg, ast.UAdd: operator.pos}
_CONSTS = {"pi": math.pi, "e": math.e, "tau": math.tau}
_FUNCS = {
    "sqrt": math.sqrt, "abs": abs, "round": round, "floor": math.floor,
    "ceil": math.ceil, "log": math.log, "log10": math.log10, "ln": math.log,
    "exp": math.exp, "sin": math.sin, "cos": math.cos, "tan": math.tan,
    "factorial": math.factorial, "min": min, "max": max, "pow": pow,
}


def _eval(node):
    if isinstance(node, ast.Expression):
        return _eval(node.body)
    if isinstance(node, ast.Constant):
        if isinstance(node.value, (int, float)):
            return node.value
        raise ValueError("non-numeric constant")
    if isinstance(node, ast.Num):                    # pragma: no cover (py<3.8)
        return node.n
    if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
        return _BIN_OPS[type(node.op)](_eval(node.left), _eval(node.right))
    if isinstance(node, ast.UnaryOp) and type(node.op) in _UN_OPS:
        return _UN_OPS[type(node.op)](_eval(node.operand))
    if isinstance(node, ast.Name) and node.id in _CONSTS:
        return _CONSTS[node.id]
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
            and node.func.id in _FUNCS:
        args = [_eval(a) for a in node.args]
        return _FUNCS[node.func.id](*args)
    raise ValueError("unsupported expression")


def safe_eval(expr: str) -> float:
    return _eval(ast.parse(expr, mode="eval"))


def _fmt(x, money: bool = False) -> str:
    """Human-friendly number: drops trailing zeros, adds thousands separators."""
    if isinstance(x, float) and x.is_integer():
        x = int(x)
    if isinstance(x, int):
        return f"{x:,}"
    if money:
        return f"{x:,.2f}"
    r = round(x, 6)
    if abs(r) >= 1:
        r = round(x, 2)
    s = f"{r:,.6f}".rstrip("0").rstrip(".") if abs(r) < 1 else f"{r:,.2f}"
    return s


# ── natural-language math ───────────────────────────────────────────────────────
_WORD_OPS = [
    (r"\bplus\b|\band\b(?=\s*\d)", "+"),
    (r"\bminus\b|\bsubtract(?:ed)?\b|\bless\b", "-"),
    (r"\btimes\b|\bmultiplied\s+by\b|\bmultiply(?:\s+by)?\b", "*"),
    (r"\bdivided\s+by\b|\bover\b|\bdivide(?:\s+by)?\b", "/"),
    (r"\bto\s+the\s+power\s+of\b|\braised\s+to\b|\bto\s+the\b", "**"),
    (r"\bmod(?:ulo)?\b", "%"),
]
_CALC_LEAD_RE = re.compile(
    r"^\s*(?:hey\s+|ok(?:ay)?\s+|wivexx[,\s]+)*"
    r"(?:please\s+)?(?:can\s+you\s+)?"
    r"(?:calculate|compute|what(?:'?s| is| are)|whats|how\s+much\s+is"
    r"|how\s+many\s+is|solve|evaluate|work\s+out|tell\s+me)\s+",
    re.IGNORECASE,
)
_PERCENT_OF_RE = re.compile(
    r"([\d.,]+)\s*(?:%|percent|percentage)\s+of\s+([\d.,]+)", re.IGNORECASE)
_PERCENT_OFF_RE = re.compile(
    r"([\d.,]+)\s*(?:%|percent)\s+(?:off|discount\s+(?:on|off)?)\s+([\d.,]+)",
    re.IGNORECASE)
_PERCENT_ON_RE = re.compile(
    r"(?:increase\s+|add\s+)?([\d.,]+)\s*(?:%|percent)\s+(?:more\s+than|on|onto|to|of\s+increase\s+to)\s+([\d.,]+)",
    re.IGNORECASE)
_SQRT_RE = re.compile(r"square\s+root\s+of\s+([\d.,]+)", re.IGNORECASE)


def _num(s: str) -> float:
    return float(s.replace(",", ""))


def try_math(text: str) -> Optional[str]:
    t = text.strip()
    low = t.lower()

    # explicit percentage phrasings first (most ambiguous to a parser)
    m = _PERCENT_OFF_RE.search(low)
    if m:
        a, b = _num(m.group(1)), _num(m.group(2))
        res = b * (1 - a / 100)
        return (f"{_fmt(a)}% off {_fmt(b)} leaves {_fmt(res)}, sir — "
                f"a discount of {_fmt(b * a / 100)}.")
    m = _PERCENT_OF_RE.search(low)
    if m:
        a, b = _num(m.group(1)), _num(m.group(2))
        return f"{_fmt(a)}% of {_fmt(b)} is {_fmt(a / 100 * b)}, sir."
    m = _SQRT_RE.search(low)
    if m:
        return f"The square root of {_fmt(_num(m.group(1)))} is {_fmt(math.sqrt(_num(m.group(1))))}, sir."

    # general arithmetic — translate words → symbols, then AST-eval
    expr = _CALC_LEAD_RE.sub("", low)
    expr = expr.rstrip("?.! ")
    expr = expr.replace("squared", "**2").replace("cubed", "**3")
    expr = re.sub(r"(\d)\s*%", r"(\1/100)", expr)          # bare "50%" → 0.5
    for pat, sym in _WORD_OPS:
        expr = re.sub(pat, sym, expr)
    expr = expr.replace("×", "*").replace("÷", "/").replace("^", "**")
    # a bare "x" between numbers means multiply
    expr = re.sub(r"(?<=\d)\s*[xX]\s*(?=\d)", "*", expr)
    expr = expr.strip()

    # must contain at least one operator/function to count as a calculation
    if not expr or not re.search(r"[-+*/%]|\b(?:sqrt|log|sin|cos|tan|abs"
                                 r"|factorial|exp|ceil|floor)\b", expr):
        return None
    # reject if there are letters left that aren't known funcs/consts
    leftovers = re.sub(r"[\d\s.,+\-*/%()]", "", expr)
    if leftovers and not all(
            tok in _FUNCS or tok in _CONSTS
            for tok in re.findall(r"[a-zA-Z_]+", expr)):
        return None
    try:
        val = safe_eval(expr)
    except Exception:
        return None
    if isinstance(val, bool) or not isinstance(val, (int, float)):
        return None
    return f"That's {_fmt(val)}, sir."


# ══════════════════════════════════════════════════════════════════════════════
#  Unit conversions
# ══════════════════════════════════════════════════════════════════════════════
# factor to a base unit (length→m, mass→g, volume→L, speed→m/s)
_UNITS = {
    "length": ({
        "mm": 0.001, "millimeter": 0.001, "millimeters": 0.001,
        "cm": 0.01, "centimeter": 0.01, "centimeters": 0.01,
        "m": 1.0, "meter": 1.0, "meters": 1.0, "metre": 1.0, "metres": 1.0,
        "km": 1000.0, "kilometer": 1000.0, "kilometers": 1000.0,
        "in": 0.0254, "inch": 0.0254, "inches": 0.0254,
        "ft": 0.3048, "foot": 0.3048, "feet": 0.3048,
        "yd": 0.9144, "yard": 0.9144, "yards": 0.9144,
        "mi": 1609.344, "mile": 1609.344, "miles": 1609.344,
    }, "meters"),
    "mass": ({
        "mg": 0.001, "g": 1.0, "gram": 1.0, "grams": 1.0,
        "kg": 1000.0, "kilogram": 1000.0, "kilograms": 1000.0, "kilo": 1000.0,
        "kilos": 1000.0, "lb": 453.592, "lbs": 453.592, "pound": 453.592,
        "pounds": 453.592, "oz": 28.3495, "ounce": 28.3495, "ounces": 28.3495,
        "ton": 1_000_000.0, "tonne": 1_000_000.0, "tonnes": 1_000_000.0,
    }, "kilograms"),
    "volume": ({
        "ml": 0.001, "milliliter": 0.001, "milliliters": 0.001,
        "l": 1.0, "liter": 1.0, "liters": 1.0, "litre": 1.0, "litres": 1.0,
        "gal": 3.78541, "gallon": 3.78541, "gallons": 3.78541,
        "cup": 0.236588, "cups": 0.236588, "pint": 0.473176, "pints": 0.473176,
    }, "liters"),
    "speed": ({
        "mph": 0.44704, "kmh": 0.277778, "kph": 0.277778, "mps": 1.0,
    }, "units"),
}
_UNIT_LOOKUP = {}
for _grp, (_d, _) in _UNITS.items():
    for _u in _d:
        _UNIT_LOOKUP[_u] = _grp

_CONV_RE = re.compile(
    r"(?:convert\s+)?([\d.,]+)\s*([a-zA-Z°]+)\s*(?:to|in|into)\s+([a-zA-Z°]+)",
    re.IGNORECASE)
_TEMP = {"c", "celsius", "centigrade", "f", "fahrenheit", "k", "kelvin"}


def _temp_to_c(v: float, unit: str) -> float:
    u = unit[0]
    return v if u == "c" else (v - 32) * 5 / 9 if u == "f" else v - 273.15


def _c_to(v_c: float, unit: str) -> float:
    u = unit[0]
    return v_c if u == "c" else v_c * 9 / 5 + 32 if u == "f" else v_c + 273.15


def try_units(text: str) -> Optional[str]:
    m = _CONV_RE.search(text)
    if not m:
        return None
    amount = _num(m.group(1))
    a, b = m.group(2).lower().rstrip("."), m.group(3).lower().rstrip(".")
    if a in _TEMP and b in _TEMP:
        res = _c_to(_temp_to_c(amount, a), b)
        nice = {"c": "°C", "f": "°F", "k": "K"}
        return (f"{_fmt(amount)}{nice.get(a[0], '')} is {_fmt(res)}"
                f"{nice.get(b[0], '')}, sir.")
    ga, gb = _UNIT_LOOKUP.get(a), _UNIT_LOOKUP.get(b)
    if ga and ga == gb:
        d = _UNITS[ga][0]
        res = amount * d[a] / d[b]
        return f"{_fmt(amount)} {a} is {_fmt(res)} {b}, sir."
    return None


# ══════════════════════════════════════════════════════════════════════════════
#  Currency conversion (free, no-key feed, cached)
# ══════════════════════════════════════════════════════════════════════════════
_CUR_NAMES = {
    "dollar": "USD", "dollars": "USD", "usd": "USD", "buck": "USD",
    "bucks": "USD", "us": "USD",
    "euro": "EUR", "euros": "EUR", "eur": "EUR",
    "pound": "GBP", "pounds": "GBP", "gbp": "GBP", "sterling": "GBP",
    "yen": "JPY", "jpy": "JPY",
    "peso": "MXN", "pesos": "MXN", "mxn": "MXN",
    "dop": "DOP", "rd": "DOP",
    "cad": "CAD", "aud": "AUD", "chf": "CHF", "cny": "CNY", "yuan": "CNY",
    "rmb": "CNY", "inr": "INR", "rupee": "INR", "rupees": "INR",
    "brl": "BRL", "real": "BRL", "reais": "BRL", "cop": "COP", "ars": "ARS",
    "krw": "KRW", "won": "KRW", "rub": "RUB", "ruble": "RUB", "rubles": "RUB",
    "sgd": "SGD", "nzd": "NZD", "sek": "SEK", "nok": "NOK", "dkk": "DKK",
    "zar": "ZAR", "try": "TRY", "lira": "TRY", "btc": "BTC",
}
_CUR_SYM = {"$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₹": "INR",
            "₩": "KRW", "₽": "RUB"}
_CUR_SPOKEN = {"USD": "US dollars", "EUR": "euros", "GBP": "British pounds",
               "JPY": "Japanese yen", "MXN": "Mexican pesos",
               "DOP": "Dominican pesos", "CAD": "Canadian dollars",
               "AUD": "Australian dollars", "CHF": "Swiss francs",
               "CNY": "Chinese yuan", "INR": "Indian rupees",
               "BRL": "Brazilian reais", "COP": "Colombian pesos",
               "KRW": "Korean won"}
_CUR_CODES = set(_CUR_NAMES.values()) | set(_CUR_SYM.values())

_rate_cache: dict = {}                # base → (fetched_at, {code: rate})
_RATE_TTL = 6 * 3600                  # 6 hours


def _spoken_cur(code: str) -> str:
    return _CUR_SPOKEN.get(code, code)


def _fetch_rates(base: str) -> Optional[dict]:
    now = time.time()
    hit = _rate_cache.get(base)
    if hit and now - hit[0] < _RATE_TTL:
        return hit[1]
    if requests is None:
        return None
    try:
        r = requests.get(f"https://open.er-api.com/v6/latest/{base}", timeout=8)
        data = r.json()
        rates = data.get("rates")
        if rates:
            _rate_cache[base] = (now, rates)
            return rates
    except Exception as e:
        if _logger:
            _logger.debug("currency fetch failed: %s", e)
    return hit[1] if hit else None


def _find_currency(token: str) -> Optional[str]:
    token = token.lower().strip(".,")
    if token.upper() in _CUR_CODES:
        return token.upper()
    return _CUR_NAMES.get(token)


_CUR_RE = re.compile(
    r"(?:convert\s+|how\s+much\s+is\s+|what(?:'?s| is)\s+)?"
    r"([$€£¥₹₩₽]?)\s*([\d.,]+)\s*"
    r"([a-zA-Z]{2,8}|[$€£¥₹₩₽])?\s*"
    r"(?:to|in|into|=)\s+"
    r"([a-zA-Z]{2,8}|[$€£¥₹₩₽])",
    re.IGNORECASE)


def try_currency(text: str) -> Optional[str]:
    m = _CUR_RE.search(text)
    if not m:
        return None
    sym1, amount_s, from_tok, to_tok = m.groups()
    amount = _num(amount_s)

    from_code = (_CUR_SYM.get(sym1) if sym1 else None) \
        or (_find_currency(from_tok) if from_tok else None) \
        or (_CUR_SYM.get(from_tok) if from_tok in _CUR_SYM else None)
    to_code = _CUR_SYM.get(to_tok) or _find_currency(to_tok)
    if not from_code or not to_code:
        return None
    if from_code == to_code:
        return f"{_fmt(amount, money=True)} {_spoken_cur(from_code)} is the same in both, sir."

    rates = _fetch_rates(from_code)
    if not rates or to_code not in rates:
        return (f"I recognised that as {from_code}→{to_code}, sir, but I "
                f"couldn't reach the exchange-rate service just now.")
    rate = rates[to_code]
    result = amount * rate
    return (f"{_fmt(amount, money=True)} {_spoken_cur(from_code)} is about "
            f"{_fmt(result, money=True)} {_spoken_cur(to_code)}, sir "
            f"— at {rate:.4f} {to_code} per {from_code}.")


# ══════════════════════════════════════════════════════════════════════════════
#  Entry point
# ══════════════════════════════════════════════════════════════════════════════
def answer(text: str) -> Optional[str]:
    """Speakable answer for a calc/convert query, else None."""
    if not text or not any(ch.isdigit() for ch in text):
        return None
    try:
        # currency is the most specific (needs currency tokens), then units,
        # then plain math/percent.
        for fn in (try_currency, try_units, try_math):
            res = fn(text)
            if res:
                return res
    except Exception as e:
        if _logger:
            _logger.debug("calc answer failed: %s", e)
    return None
