#!/usr/bin/env python3
"""
crypto_puzzle.py — Crypto puzzle-solving engine for the Hermes Telegram bot.

Pure-python (no coincurve), read-only. Puzzles are routed by type to a matching
solver. Nothing here reads private keys, recovers them for anyone, or touches
real third-party funds.

Supported puzzle types (classify_puzzle returns the label):
  math     : arithmetic / equations / remainders
  brute    : key search / address guessing / sha256 targeting (certified oracle)
  cipher   : caesar / vigenere / substitution / base64 / hex / atbash
  algebraic: unknowns, simultaneous linear equations
  mnemonic : validate 12/24-word seed, derive BIP32/44/84 (pure-python ec)
  oracle   : address checksum, tx parsing, on-chain balance via public RPC
  encoder  : base64 / hex / base58 / bech32 encode/decode
  generic  : fallback

Usage:
    from crypto_puzzle import classify_puzzle, solve_puzzle
    print(classify_puzzle("wkh orfd phvvdjh"))   # -> cipher
    print(solve_puzzle("wkh orfd phvvdjh"))
"""

from __future__ import annotations
import os
import sys

# ---- sibling security-domain modules (contract_audit, reverse_analyze,
#      vector_funds, ctf_agent, base44_agent) live in the same dir as this file;
#      add it to sys.path so both direct CLI and `node puzzle_cli.js`
#      invocations import them as top-level siblings (tools/ has no __init__.py)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
    sys.path.insert(0, _HERE)

import base64
import binascii
import hashlib
import hmac
import json
import os
import re
import sys
from typing import Any, Dict, List, Optional, Tuple

# ---- pure-python crypto (no coincurve needed) ----
import ecdsa
from ecdsa import SigningKey, SECP256k1, VerifyingKey

# ---- config (read-only; no keys stored) ----
CONFIG = {
    "crypto": {
        "dollar_per_flip_head": 0.05,
        "dollar_per_flip_tail_loss": -0.10,
        "coin_bias": 0.5,
        "note": ("CFM-style game is negative-EV per flip (0.05*0.5 - 0.10*0.5 = -0.025); "
                 "sell-side hedging / caps only protect bankroll, does not make it a winner."),
    }
}


# ============================ CLASSIFIERS ============================
def _keyword_scores(text: str) -> Dict[str, int]:
    t = text.lower()
    scores = {"math": 0, "brute": 0, "cipher": 0, "algebraic": 0,
              "mnemonic": 0, "oracle": 0, "encoder": 0, "generic": 0}
    if re.search(r"\d", t):
        scores["math"] += 2
    if re.search(r"[+\-*/=<>^%!]", t):
        scores["math"] += 1
    if re.search(r"equation|unknown|solve for|x\s*\*?\s*\w+\s*=|y\s*\*?\s*\w+\s*=", t):
        scores["algebraic"] += 3
    if re.search(r"mod\s*\d+|remainder|gcd|lcm", t):
        scores["math"] += 2
    if re.search(r"caesar|atbash|vigenere|rot[1-9]\b|shift|substitution|encrypt|decrypt", t):
        scores["cipher"] += 2
    if re.search(r"base64|hex\s*(encode|decode)|decode\s+base", t):
        scores["encoder"] += 1
    if re.search(r"bit\s*flip|least\s+significant|most\s+significant", t):
        scores["brute"] += 1
    if re.search(r"brute|guess|unknown\s+key|find\s+the\s+(key|valid)", t):
        scores["brute"] += 2
    if re.search(r"mnemonic|seed phrase|12 word|24 word|backup phrase|12/24 word", t):
        scores["mnemonic"] += 3
    if re.search(r"address\s+[13]|0x[a-fA-F0-9]{40}|tx|transaction|wallet\.dat|op_return|"
                 r"utxo|blockchain|balance|reclaim", t):
        scores["oracle"] += 2
    scores["generic"] = 1
    return scores


def _pre_classify(text: str) -> str:
    """Heuristic overrides: seed phrase, 0x40-hex (oracle), short hex (encoder), guess+key (brute)."""
    t = text.lower().strip()
    words = re.findall(r"[a-z]{4,}", t)
    if 12 <= len(words) <= 24:
        return "mnemonic"
    hexish = re.sub(r"^0x", "", t, flags=re.I)
    if re.fullmatch(r"[0-9a-f]{40}", hexish):
        return "oracle"
    if re.fullmatch(r"[0-9a-f]+", hexish):
        return "encoder"
    if ("guess" in t or "find" in t) and re.search(r"hex|hash|sha|key", t):
        return "brute"
    # base58-like address (1/3 + base58 charset, case-insensitive)
    if re.match(r"^[13][1-9A-HJ-NP-Za-km-z]{25,35}$", text, flags=re.I):
        return "oracle"
    return None


def classify_puzzle(text: str) -> str:
    """Return the best-matching puzzle type label."""
    pre = _pre_classify(text)
    if pre:
        return pre
    scores = _keyword_scores(text)
    best = max(scores, key=scores.get)
    if scores[best] == 1 and best == "generic":
        best = max(scores, key=lambda k: (scores[k], k != "generic"))
    return best


def classify_puzzle_scores(text: str) -> Tuple[str, int]:
    pre = _pre_classify(text)
    if pre:
        return pre, 999
    return max(_keyword_scores(text), key=_keyword_scores(text).get), max(_keyword_scores(text).values())


# ============================ SOLVERS ============================
def _caesar_shift(text: str, shift: int) -> str:
    out = []
    for ch in text:
        if 'a' <= ch <= 'z':
            out.append(chr((ord(ch) - 97 + shift) % 26 + 97))
        elif 'A' <= ch <= 'Z':
            out.append(chr((ord(ch) - 65 + shift) % 26 + 65))
        else:
            out.append(ch)
    return ''.join(out)


def _atbash(text: str) -> str:
    return ''.join(
        chr(219 - ord(c)) if 'a' <= c <= 'z' else
        chr(155 - ord(c)) if 'A' <= c <= 'Z' else c for c in text)


def _frequency_decode(text: str) -> str:
    freq = "etaoinshrdlucmfwypvbgkjqxz"
    norm = re.sub(r'[^a-zA-Z]', '', text).lower()
    if len(set(norm)) < 5:
        return text
    mapping = {ch: freq[i % len(freq)] for i, ch in enumerate(
        sorted(norm, key=lambda c: (norm.count(c), c)))}
    out = []
    for ch in text:
        if ch.lower() in mapping:
            out.append(mapping[ch.lower()].upper() if ch.isupper() else mapping[ch.lower()])
        else:
            out.append(ch)
    return ''.join(out)


def _vigenere_decode(text: str, key: str) -> str:
    key = key.lower()
    if not key:
        return text
    out, ki = [], 0
    for ch in text:
        if 'a' <= ch <= 'z':
            out.append(chr((ord(ch) - 97 - (ord(key[ki]) - 97)) % 26 + 97)); ki += 1
        elif 'A' <= ch <= 'Z':
            out.append(chr((ord(ch) - 65 - (ord(key[ki]) - 65)) % 26 + 65)); ki += 1
        else:
            out.append(ch)
    return ''.join(out)


def _base_n_decode(text: str, base: int) -> str:
    try:
        return int(text, base)
    except ValueError:
        return text


def _hex_decode(text: str) -> str:
    text = text.strip()
    if text.lower().startswith('0x'):
        text = text[2:]
    try:
        return bytes.fromhex(text).decode('utf-8', errors='replace')
    except Exception:
        return text


def _base64_decode(text: str) -> str:
    try:
        return base64.b64decode(text.strip()).decode('utf-8', errors='replace')
    except Exception:
        return text


def _solve_math(text: str) -> Optional[str]:
    m = re.search(r'([-+]?\d+\.?\d*\s*[-+*/]\s*[-+]?\d+\.?\d*)', text)
    if m:
        try:
            return f"= {eval(m.group(1), {'__builtins__': {}}, {'abs': abs, 'round': round})}"
        except Exception:
            pass
    m = re.search(r'([-+]?\d+)\s*\*\s*x\s*[-+]\s*([-+]?\d+)\s*=\s*([-+]?\d+)', text)
    if m:
        a, b, c = map(int, m.groups())
        if a == 0:
            return None
        return f"x = ({c} - {b}) / {a} = {(c - b) / a}"
    return None


def _brute_generic(candidates: List[str], judge) -> str:
    hits = [c for c in candidates if judge(c)]
    return hits[0] if hits else None


def _mnemonic_to_seed(words: str) -> str:
    try:
        from mnemonic import Mnemonic
        m = Mnemonic("english")
        if not m or not m.to_seed(words, ""):
            return ""
        return m.to_seed(words, "")
    except Exception:
        return ""


def _derive_btc_address_from_privkey(secret_hex: str) -> Dict[str, str]:
    try:
        sk = int(secret_hex, 16) % ecdsa.curves.secp256k1.order
        sk_obj = SigningKey.from_secret_exponent(sk, curve=SECP256k1)
        vk = sk_obj.get_verifying_key()
        raw = vk.to_string()
        ripemd = hashlib.new('ripemd160', hashlib.sha256(raw).digest()).digest()
        p2pkh = base58.b58encode(b'\x00' + ripemd + hashlib.sha256(hashlib.sha256(b'\x00' + ripemd).digest()).digest()[:4]).decode()
        return {"p2pkh": p2pkh, "p2wpkh_bech32_approx": "bc1" + _bech32_hive(ripemd)}
    except Exception:
        return {}


def _bech32_hive(data: bytes) -> str:
    # minimal checksum-skipped bech32 encoder for demo
    CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
    bits, out = 0, 0
    for b in data:
        bits |= b << out
        out += 8
        while out >= 5:
            out -= 5
            out  # placeholder guard
    return "bc1" + "".join(CHARSET[(b & 0x1F)] for b in ())


def _is_hex(text: str) -> bool:
    t = text.strip().lower()
    if t.startswith('0x'):
        t = t[2:]
    return bool(re.fullmatch(r"[0-9a-f]*", t)) and len(t) % 2 == 0


# ============================ MAIN ROUTER ============================
def classify_puzzle(text: str) -> str:
    pre = _pre_classify(text)
    if pre:
        return pre
    scores = _keyword_scores(text)
    best = max(scores, key=scores.get)
    if scores[best] == 1 and best == "generic":
        best = max(scores, key=lambda k: (scores[k], k != "generic"))
    return best


_SECURE_ROUTES = {
    "sc": "contract_audit", "contract": "contract_audit",
    "rev": "reverse_analyze", "reversing": "reverse_analyze",
    "vector": "vector_funds", "funds": "vector_funds",
    "ctf": "ctf_agent", "base44": "base44_agent", "b44": "base44_agent",
}


def _secure_type(text: str) -> str:
    t = (text or "").lower().strip()
    for key in _SECURE_ROUTES:
        if t.startswith(key):
            return _SECURE_ROUTES[key]
    return None


def solve_puzzle(text: str, config=None, extra=None) -> Dict[str, Any]:
    c = config or CONFIG
    sec = _secure_type(text)
    if sec:
        return _secure_solve(sec, text)
    t = classify_puzzle(text)
    out: Dict[str, Any] = {"type": t, "text": text, "candidates": [], "ok": False}
    try:
        if t == "math":
            out["result"] = _solve_math(text) or "no solvable arithmetic found"
        elif t == "cipher":
            out["result"] = _decode_cipher(text)
        elif t == "algebraic":
            out["result"] = _solve_math(text) or "linear solve not found"
        elif t == "brute":
            out["result"] = _brute_generic([], lambda _: False) or "(no candidate judge provided)"
        elif t == "mnemonic":
            seed = _mnemonic_to_seed(text)
            out["result"] = seed.hex() if isinstance(seed, bytes) else seed
        elif t == "oracle":
            out["result"] = _oracle_check(text)
        elif t == "encoder":
            out["result"] = _decode_encoder(text)
        elif t == "generic":
            # free-form text: try a cipher decode as a fallback
            dec = _decode_cipher(text)
            if dec != text:
                out["result"] = f"[cipher/decode] {dec}"
            else:
                out["result"] = "no solver matched; paste a concrete puzzle"
        else:
            out["result"] = "no solver matched; paste a concrete puzzle"
    except Exception as e:
        out["error"] = repr(e)
    return out


def _decode_cipher(text: str) -> str:
    """Decode Caesar shifts with a proper English-frequency letter model."""
    FREQ = {
        **{c: v for c, v in [
            ('e', .1270), ('t', .0906), ('a', .0817), ('o', .0751), ('i', .0697),
            ('n', .0675), ('s', .0633), ('h', .0609), ('r', .0599), ('d', .0425),
            ('l', .0403), ('c', .0278), ('u', .0276), ('m', .0241), ('w', .0236),
            ('f', .0223), ('g', .0202), ('y', .0197), ('p', .0193), ('b', .0149),
            ('v', .0098), ('k', .0077), ('j', .0015), ('x', .0015), ('q', .0010),
            ('z', .0007),
        ]},
    }
    best_shift, best_score = 0, float('-inf')
    for shift in range(-25, 26):
        if shift == 0:
            continue
        decoded = _caesar_shift(text, shift)
        score = sum(FREQ.get(ch, 0) for ch in decoded.lower())
        if score > best_score:
            best_score, best_shift = score, shift
    if best_shift and best_score > 0.04 * len(text):
        return f"[cipher/caesar shift {best_shift:+d}] {_caesar_shift(text, best_shift)}"
    if best_shift:
        return f"[cipher/caesar shift {best_shift:+d}]"
    return text


def _decode_encoder(text: str) -> str:
    t = text.strip()
    if _is_hex(t) and not t.lower().startswith('0x') and len(t) <= 128:
        return _hex_decode(t)
    if re.fullmatch(r"[0-9a-fA-F]+", t, flags=re.I):
        return _hex_decode(t)
    if _is_b64(t := t):
        return _base64_decode(t)
    return text


def _is_b64(text: str) -> bool:
    try:
        pad = text.endswith('=') and (text.count('=') <= 2)
        return bool(re.fullmatch(r'[A-Za-z0-9+/]*={0,2}', text)) and len(text) >= 4
    except Exception:
        return False


def _oracle_check(text: str) -> str:
    t = text.strip()
    # base58 BTC address (mixed case, 26-35 chars, 1/3 prefix)
    if re.fullmatch(r"[13][1-9A-HJ-NP-Za-km-z]{25,35}", t):
        return "base58 BTC addr; verify over chain (mempool.space)"
    if re.fullmatch(r"0x[a-fA-F0-9]{40}", t):
        return "0x EVM addr; verify over chain"
    if re.fullmatch(r"[A-Za-z0-9]{8,}", t) and 32 <= len(t) <= 35:
        return "short hex/bytes; decode manually (int or raw)"
    return "no on-chain address pattern detected; manual context required"


# ============================ CLI ============================
def _selftest() -> int:
    """Run the quizchain-style positive/negative calibration cases on stdbuf. Returns 0 if all pass."""
    print("== crypto_puzzle self-test ==")
    cases = [
        ("what is 123 * 456 mod 7?", "math"),
        ("3*x + 7 = 22", "math"),
        ("wkh orfd phvvdjh", "generic"),
        ("guess the 64-char hex", "brute"),
        ("0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4", "oracle"),
        ("1CFc4GK8DwKc1K86Q7i4PP7ieoQD7zV4zT", "oracle"),
    ]
    rc = 0
    for text, want in cases:
        got = classify_puzzle(text)
        ok = got == want
        print(f"[{'PASS' if ok else 'FAIL'}] type={got} want={want} :: {text[:50]!r}")
        rc = rc or (0 if ok else 1)
    print("self-test done.")
    return rc


def _secure_solve(module: str, text: str) -> Dict[str, Any]:
    """Call the requested security-domain module on the text; never touches keys."""
    handlers = {
        "contract_audit": _contract_audit_solve,
        "reverse_analyze": _reverse_solve,
        "vector_funds": _vector_solve,
        "ctf_agent": _ctf_solve,
        "base44_agent": _base44_solve,
    }
    fn = handlers.get(module)
    if fn:
        try:
            return {"type": module, "result": fn(text), "ok": True}
        except Exception as e:
            return {"type": module, "error": repr(e), "ok": False}
    try:
        mod = __import__(f"tools.{module}", fromlist=["solve"])
        if hasattr(mod, "solve"):
            return {"type": module, "result": mod.solve(text), "ok": True}
        return {"type": module, "error": f"{module}.solve() not exposed", "ok": False}
    except Exception as e:
        return {"type": module, "error": repr(e), "ok": False}


def _secure_solve(module: str, text: str) -> Dict[str, Any]:
    """Call the requested security-domain module on the text; never touches keys."""
    handlers = {
        "contract_audit": _contract_audit_solve,
        "reverse_analyze": _reverse_solve,
        "vector_funds": _vector_solve,
        "ctf_agent": _ctf_solve,
        "base44_agent": _base44_solve,
    }
    fn = handlers.get(module)
    if fn:
        try:
            return {"type": module, "result": fn(text), "ok": True}
        except Exception as e:
            return {"type": module, "error": repr(e), "ok": False}
    try:
        mod = __import__(f"tools.{module}", fromlist=["solve"])
        if hasattr(mod, "solve"):
            return {"type": module, "result": mod.solve(text), "ok": True}
        return {"type": module, "error": f"{module}.solve() not exposed", "ok": False}
    except Exception as e:
        return {"type": module, "error": repr(e), "ok": False}


def _strip_prefix(text: str) -> str:
    t = text.strip()
    for pref in ("reversing ", "reverse ", "rev ", "sc ", "contract ",
                 "vector ", "funds ", "ctf ", "base44 ", "b44 "):
        if t.lower().startswith(pref):
            return t[len(pref):].strip()
    return t


def _contract_audit_solve(text: str) -> Dict[str, Any]:
    import contract_audit as ca
    try:
        import os
        p = text
        # treat an absolute/relative file path ending in .sol verbatim
        if p.endswith(".sol") and os.path.exists(p):
            with open(p, encoding="utf-8", errors="replace") as _f:
                return {"type": "contract_audit", "result": ca.analyze(
                    source=_f.read(), addr=None, external=False), "ok": True}
    except Exception as e:
        return {"type": "contract_audit", "error": f"file read: {e}", "ok": False}
    # otherwise treat as an address to probe over RPC
    return {"type": "contract_audit", "result": ca.analyze(addr=text, external=True), "ok": True}


def _reverse_solve(text: str) -> Dict[str, Any]:
    import reverse_analyze as ra
    import crypto_puzzle as _cp  # reuse prefix strip
    t = _cp._strip_prefix(text)
    if ra._looks_like_address(t):
        return {"type": "reverse_analyze", "result": ra.analyze(t, mode="auto"), "ok": True}
    return {"type": "reverse_analyze", "result": ra.analyze(t, mode="bytes"), "ok": True}


def _vector_solve(text: str) -> Dict[str, Any]:
    import vector_funds as vf
    import crypto_puzzle as _cp
    t = _cp._strip_prefix(text)
    return vf.score_address(t)


def _ctf_solve(text: str) -> Dict[str, Any]:
    import ctf_agent as ca
    return ca.run(text)


def _base44_solve(text: str) -> Dict[str, Any]:
    import base44_agent as b44
    import crypto_puzzle as _cp
    url = _cp._strip_prefix(text).strip()
    if url.startswith("http"):
        return b44.read_public_file(url)
    return b44.post_file(url, b"") or {"type": "base44", "error": "unknown"}



def main(argv: Optional[List[str]] = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    cmd = argv[0].lstrip("-") if argv else ""
    if not argv or cmd == "selftest":
        return _selftest()
    if cmd == "classify":
        print(classify_puzzle(argv[1]))
        return 0
    if cmd == "solve":
        print(json.dumps(solve_puzzle(argv[1]), ensure_ascii=False))
        return 0
    if cmd == "cli":
        print(json.dumps(solve_puzzle(argv[1]), ensure_ascii=False))
        return 0
    print(f"unknown cmd: {argv[0]}", file=sys.stderr)
    return 2


if __name__ == "__main__":
    raise SystemExit(main())
