#!/usr/bin/env python3
"""
pyhdwallet v1.0.5 (Python 3.11+)

Commands:
  - fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint
  - gen (offline): generate BIP39 mnemonic; derive addresses; optionally encrypt payload; optional AES-zip file output
  - recover (offline): derive addresses from mnemonic ONLY; optionally encrypt payload; optional AES-zip file output
  - test (offline): minimal self-test

Behavior (current):
  - gen prints mnemonic by default to stdout (human-readable) unless --off-screen.
  - --file is a boolean: if present, write ONLY an AES-encrypted ZIP in ./.wallet (or --wallet-location).
    No raw .json/.asc is left on disk (written to zip from memory).
  - ZIP password is prompted via getpass; if echo-hiding fails, fallback to input() with loud warning.
  - When stdout is not a TTY, refuse to print sensitive data unless --force is provided.

Recover input policy (v1.0.5):
  - Seed recovery removed (mnemonic only).
  - No plaintext mnemonic via CLI args.
  - Use either:
      - --interactive (guided word-by-word entry, English-only, ✅/❌ validation)
      - --mnemonic-stdin (read full mnemonic from stdin; validate once)

Notes:
  - "Clear screen" is intentionally NOT used; it does not reliably remove secrets from logs/scrollback.
"""


from __future__ import annotations

import argparse
import datetime
import getpass
import hashlib
import json
import os
import sys
import tempfile
import urllib.request
import warnings
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple


# -----------------------------------------------------------------------------
# Dependency checks
# -----------------------------------------------------------------------------


def _require(mod: str, pkg: str) -> None:
    try:
        __import__(mod)
    except ImportError:
        print(f"❌ Missing dependency: {pkg}", file=sys.stderr)
        print(f"Install with: pip install {pkg}", file=sys.stderr)
        sys.exit(1)


def require_for_fetchkey() -> None:
    _require("pgpy", "PGPy")


def require_for_offline(chains: List[str]) -> None:
    _require("bip_utils", "bip-utils")
    if "solana" in chains:
        _require("nacl", "PyNaCl")
        _require("base58", "base58")
    _require("pgpy", "PGPy")


def require_for_zip() -> None:
    _require("pyzipper", "pyzipper")


# -----------------------------------------------------------------------------
# Offline network guard
# -----------------------------------------------------------------------------


class NetworkGuard:
    """Blocks urllib.request.urlopen to enforce offline mode in gen/recover/test."""

    def __init__(self, mode_name: str):
        self.mode_name = mode_name
        self._orig = None

    def __enter__(self):
        self._orig = urllib.request.urlopen

        def blocked_urlopen(*args, **kwargs):
            raise RuntimeError(f"Network I/O is disabled in {self.mode_name} mode")

        urllib.request.urlopen = blocked_urlopen
        return self

    def __exit__(self, exc_type, exc, tb):
        urllib.request.urlopen = self._orig
        return False


# -----------------------------------------------------------------------------
# Utility: time, paths, and safe-ish UX
# -----------------------------------------------------------------------------


def utc_timestamp_compact() -> str:
    return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%SZ")


def default_wallet_dir() -> Path:
    return Path.cwd() / ".wallet"


def ensure_dir(p: Path) -> None:
    p.mkdir(parents=True, exist_ok=True)


def is_tty_stdout() -> bool:
    return bool(getattr(sys.stdout, "isatty", lambda: False)())


def require_tty_or_force(force: bool, what: str) -> None:
    if is_tty_stdout():
        return
    if force:
        print(f"⚠️  WARNING: stdout is not a TTY; printing {what} may be captured by logs/pipes.", file=sys.stderr)
        return
    raise RuntimeError(
        f"Refusing to print {what} because stdout is not a TTY. "
        f"Run in a terminal or pass --force (dangerous)."
    )


def warn_gen_stdout_banner() -> None:
    print("=" * 80)
    print("WARNING: This tool will print the mnemonic to stdout by default (debug/test mode).")
    print("stdout can be captured by terminal scrollback, logging, screen recording, pipes, or CI logs.")
    print("For safer workflows:")
    print("  - Avoid printing secrets; use --off-screen (suppresses printing).")
    print("  - Prefer --pgp-pubkey-file and --file to create encrypted artifacts.")
    print("=" * 80)
    print("")


def prompt_zip_password_hidden_or_warn() -> str:
    """
    Prompt for ZIP password without echo using getpass when possible.

    If getpass cannot disable echo (some IDEs), it may warn/fallback; in that case,
    fall back to input() with a loud warning.
    """
    try:
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            pw = getpass.getpass("Enter ZIP password (hidden): ")
            if w:
                print("⚠️  WARNING: getpass could not reliably hide input in this environment.", file=sys.stderr)
                print("⚠️  Falling back to visible password entry. Use a real terminal for hidden input.", file=sys.stderr)
                pw2 = input("Enter ZIP password (VISIBLE): ")
                return pw2
            return pw
    except (Exception, KeyboardInterrupt):
        print("⚠️  WARNING: Hidden password prompt failed; falling back to visible input.", file=sys.stderr)
        return input("Enter ZIP password (VISIBLE): ")


def base58_alphabet() -> str:
    return "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


def gen_base58_password(length: int) -> str:
    import secrets

    alphabet = base58_alphabet()
    return "".join(secrets.choice(alphabet) for _ in range(length))


# -----------------------------------------------------------------------------
# Recover input helpers (mnemonic only)
# -----------------------------------------------------------------------------


def _prompt_hidden_or_visible(prompt: str) -> str:
    """
    Prompt for sensitive input.

    Prefer getpass (no echo). If getpass can't reliably hide input in the current
    environment, warn and fall back to visible input.
    """
    try:
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            s = getpass.getpass(prompt)
            if w:
                print("⚠️  WARNING: getpass could not reliably hide input in this environment.", file=sys.stderr)
                print("⚠️  Falling back to visible input. Use a real terminal for hidden input.", file=sys.stderr)
                return input(prompt.replace("(hidden)", "(VISIBLE)"))
            return s
    except (Exception, KeyboardInterrupt):
        print("⚠️  WARNING: Hidden prompt failed; falling back to visible input.", file=sys.stderr)
        return input(prompt.replace("(hidden)", "(VISIBLE)"))


def _read_stdin_all() -> str:
    return sys.stdin.read().strip()


def _bip39_english_words_list():
    from bip_utils import Bip39Languages
    from bip_utils.bip.bip39.bip39_mnemonic_utils import Bip39WordsListGetter

    getter = Bip39WordsListGetter()
    return getter.GetByLanguage(Bip39Languages.ENGLISH)  # returns MnemonicWordsList [web:380]



def _prompt_word_count() -> int:
    valid_counts = {12, 15, 18, 21, 24}
    while True:
        raw = input("Word count (12/15/18/21/24): ").strip()
        if raw.isdigit() and int(raw) in valid_counts:
            return int(raw)
        print("❌ Invalid word count. Choose 12/15/18/21/24.")


def interactive_mnemonic_word_by_word() -> str:
    from bip_utils import Bip39MnemonicValidator

    words_list = _bip39_english_words_list()
    n = _prompt_word_count()

    words: List[str] = []
    i = 1
    while i <= n:
        w = _prompt_hidden_or_visible(f"{i}) Enter word (hidden): ").strip().lower()
        try:
            words_list.GetWordIdx(w)  # raises ValueError if not found [web:392]
            print(f"{i}) ✅")
            words.append(w)
            i += 1
        except ValueError:
            print(f"{i}) ❌ Invalid BIP39 English word. Try again.")

    mnemonic = " ".join(words)
    if not Bip39MnemonicValidator().IsValid(mnemonic):
        raise ValueError("Mnemonic checksum/format invalid (words valid but checksum failed).")
    return mnemonic


# -----------------------------------------------------------------------------
# Fingerprint
# -----------------------------------------------------------------------------
def normalize_fpr(s: str) -> str:
    s = (s or "").strip().replace(" ", "").replace(":", "").replace("-", "")
    if s.lower().startswith("0x"):
        s = s[2:]
    return s.upper()

def require_fingerprint_match(actual_fpr: str, expected_fpr: str, context: str) -> None:
    a = normalize_fpr(actual_fpr)
    e = normalize_fpr(expected_fpr)
    if not e:
        return
    if a != e:
        raise ValueError(f"{context}: PGP fingerprint mismatch (expected {e}, got {a})")


def get_master_fingerprint(seed_bytes: bytes) -> str:
    from bip_utils import Bip32Slip10Secp256k1, Hash160

    master = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
    pubkey_bytes = master.PublicKey().RawCompressed().ToBytes()
    h160 = Hash160.QuickDigest(pubkey_bytes)
    return h160[:4].hex().upper()


# -----------------------------------------------------------------------------
# PGP helpers (PGPy)
# -----------------------------------------------------------------------------


def pgp_load_pubkey(armored: str):
    import pgpy

    key, _ = pgpy.PGPKey.from_blob(armored)
    if not key.is_public:
        raise ValueError("Provided key is not a public key")
    return key


def pgp_encrypt_ascii_armored(
    pubkey_armored: str,
    payload: Dict[str, Any],
    ignore_usage_flags: bool = False,
) -> str:
    import pgpy

    pub_key = pgp_load_pubkey(pubkey_armored)
    if ignore_usage_flags:
        pub_key._require_usage_flags = False  # noqa: SLF001

    msg = pgpy.PGPMessage.new(json.dumps(payload, ensure_ascii=False, indent=2))
    enc = pub_key.encrypt(msg)
    return str(enc)


def pgp_fingerprint(armored: str) -> str:
    return pgp_load_pubkey(armored).fingerprint


# -----------------------------------------------------------------------------
# fetchkey (online)
# -----------------------------------------------------------------------------


def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]:
    req = urllib.request.Request(url, headers={"User-Agent": "pyhdwallet/1.0.5"})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = resp.read()
    text = data.decode("utf-8", errors="strict")
    if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text:
        raise ValueError("Downloaded content does not look like an ASCII-armored PGP public key")
    return text, data


def sha256_hex_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def cmd_fetchkey(
    url: str,
    out_path: Optional[str],
    timeout: int,
    off_screen: bool,
    expected_fingerprint: str = "",
) -> None:

    require_for_fetchkey()
    armored, raw = fetch_ascii_armored_text(url, timeout=timeout)

    s256 = sha256_hex_bytes(raw)
    fpr = pgp_fingerprint(armored)
    require_fingerprint_match(fpr, expected_fingerprint, "fetchkey")

    temp_file = None
    if off_screen:
        print("⚠️  Off-screen mode enabled: Temp files used.")
        if not out_path:
            temp_file = tempfile.NamedTemporaryFile(
                mode="w",
                suffix=".asc",
                delete=True,
                encoding="utf-8",
                newline="\n",
            )
            out_path = temp_file.name
            print(f"Key saved to temp file: {out_path} (auto-deleted on exit)")

    if out_path:
        with open(out_path, "w", encoding="utf-8", newline="\n") as f:
            f.write(armored)
        if not off_screen:
            os.chmod(out_path, 0o600)

    print("✅ Downloaded PGP public key")
    print(f"URL: {url}")
    if out_path:
        print(f"Saved: {out_path}")
    print(f"SHA256: {s256}")
    print(f"Fingerprint: {fpr}")

    if temp_file is not None:
        temp_file.close()


# -----------------------------------------------------------------------------
# Solana helpers
# -----------------------------------------------------------------------------


def sol_pubkey_b58_from_seed32(seed32: bytes) -> str:
    from nacl.signing import SigningKey
    import base58

    sk = SigningKey(seed32)
    pub32 = sk.verify_key.encode()
    return base58.b58encode(pub32).decode("ascii")


def sol_secret64_b58_from_seed32(seed32: bytes) -> str:
    from nacl.signing import SigningKey
    import base58

    sk = SigningKey(seed32)
    pub32 = sk.verify_key.encode()
    secret64 = seed32 + pub32
    return base58.b58encode(secret64).decode("ascii")


def slip10_ed25519_seed32_from_path(seed_bytes: bytes, path: str) -> bytes:
    from bip_utils import Bip32Slip10Ed25519

    node = Bip32Slip10Ed25519.FromSeedAndPath(seed_bytes, path)
    seed32 = node.PrivateKey().Raw().ToBytes()
    if len(seed32) != 32:
        raise ValueError(f"Unexpected derived seed length: {len(seed32)}")
    return seed32


# -----------------------------------------------------------------------------
# Derivation (addresses-only by default)
# -----------------------------------------------------------------------------


def derive_all(
    seed_bytes: bytes,
    chains: List[str],
    count: int,
    sol_profile: str,
    export_private: bool,
) -> Dict[str, Any]:
    from bip_utils import (
        Bip44,
        Bip44Changes,
        Bip44Coins,
        Bip49,
        Bip49Coins,
        Bip84,
        Bip84Coins,
    )

    out: Dict[str, Any] = {"addresses": {}}
    if export_private:
        out["secrets"] = {}

    if "ethereum" in chains:
        root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
        ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
        addrs: List[Dict[str, Any]] = []
        secrets: List[Dict[str, Any]] = []
        for i in range(count):
            node = ctx.AddressIndex(i)
            path = f"m/44'/60'/0'/0/{i}"
            addrs.append({"index": i, "path": path, "address": node.PublicKey().ToAddress()})
            if export_private:
                secrets.append({"index": i, "path": path, "privkey_hex": node.PrivateKey().Raw().ToHex()})
        out["addresses"]["ethereum"] = addrs
        if export_private:
            out["secrets"]["ethereum"] = secrets

    if "solana" in chains:
        addrs = []
        secrets = []

        local_count = 1 if sol_profile == "solana_bip39_first32" else count

        for i in range(local_count):
            if sol_profile == "phantom_bip44change":
                path = f"m/44'/501'/{i}'/0'"
                seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path)
            elif sol_profile == "phantom_bip44":
                path = f"m/44'/501'/{i}'"
                seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path)
            elif sol_profile == "phantom_deprecated":
                path_try = f"m/501'/{i}'/0/0"
                try:
                    seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path_try)
                    path = path_try
                except Exception:
                    path = f"m/501'/{i}'/0'/0'"
                    seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path)
            elif sol_profile == "solana_bip39_first32":
                path = "BIP39 seed[0:32]"
                seed32 = seed_bytes[:32]
            else:
                raise ValueError(f"Unknown sol_profile: {sol_profile}")

            addr = sol_pubkey_b58_from_seed32(seed32)
            addrs.append({"index": i, "path": path, "address": addr})

            if export_private:
                secrets.append(
                    {
                        "index": i,
                        "path": path,
                        "phantom_secret_base58": sol_secret64_b58_from_seed32(seed32),
                    }
                )

        out["addresses"]["solana"] = addrs
        if export_private:
            out["secrets"]["solana"] = secrets

    if "bitcoin" in chains:
        addrs = []

        r44 = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN)
        c44 = r44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)

        r49 = Bip49.FromSeed(seed_bytes, Bip49Coins.BITCOIN)
        c49 = r49.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)

        r84 = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
        c84 = r84.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)

        for i in range(count):
            addrs.append(
                {
                    "index": i,
                    "path": f"m/84'/0'/0'/0/{i}",
                    "address_type": "native_segwit",
                    "address": c84.AddressIndex(i).PublicKey().ToAddress(),
                }
            )
            addrs.append(
                {
                    "index": i,
                    "path": f"m/49'/0'/0'/0/{i}",
                    "address_type": "segwit",
                    "address": c49.AddressIndex(i).PublicKey().ToAddress(),
                }
            )
            addrs.append(
                {
                    "index": i,
                    "path": f"m/44'/0'/0'/0/{i}",
                    "address_type": "legacy",
                    "address": c44.AddressIndex(i).PublicKey().ToAddress(),
                }
            )

        out["addresses"]["bitcoin"] = addrs

    if export_private:
        out["secrets"] = {k: v for k, v in out["secrets"].items() if v}
        if not out["secrets"]:
            del out["secrets"]

    return out


def format_addresses_human(addresses: Dict[str, Any]) -> str:
    lines: List[str] = []
    lines.append("\n" + "=" * 80)
    lines.append("MULTI-CHAIN ADDRESS DERIVATION (ADDRESSES ONLY)")
    lines.append("=" * 80 + "\n")

    for chain, addrs in addresses.items():
        lines.append("─" * 80)
        lines.append(f"{chain.upper()} ADDRESSES")
        lines.append("─" * 80)

        if chain == "bitcoin":
            by_type: Dict[str, List[Dict[str, Any]]] = {}
            for a in addrs:
                by_type.setdefault(a["address_type"], []).append(a)
            for t in ["native_segwit", "segwit", "legacy"]:
                if t in by_type:
                    lines.append(f"\n  {t.upper()}:")
                    for a in by_type[t]:
                        lines.append(f"    [{a['index']}] {a['path']}")
                        lines.append(f"        → {a['address']}")
        else:
            for a in addrs:
                lines.append(f"  [{a['index']}] {a['path']}")
                lines.append(f"      → {a['address']}")
        lines.append("")

    lines.append("=" * 80 + "\n")
    return "\n".join(lines)


# -----------------------------------------------------------------------------
# AES ZIP output (pyzipper)
# -----------------------------------------------------------------------------


def write_aes_zip(zip_path: Path, inner_name: str, inner_bytes: bytes, password: str) -> None:
    import pyzipper

    ensure_dir(zip_path.parent)
    pw_bytes = password.encode("utf-8")

    with pyzipper.AESZipFile(
        zip_path,
        mode="w",
        compression=pyzipper.ZIP_DEFLATED,
        encryption=pyzipper.WZ_AES,
    ) as zf:
        zf.setpassword(pw_bytes)
        zf.setencryption(pyzipper.WZ_AES, nbits=256)
        zf.writestr(inner_name, inner_bytes)

    os.chmod(zip_path, 0o600)


# -----------------------------------------------------------------------------
# File naming and payload construction
# -----------------------------------------------------------------------------


def build_payload_gen(
    mnemonic: str,
    passphrase_used: bool,
    passphrase_hint: str,
    fp: str,
    dice_used: bool,
    sol_profile: str,
    addresses: Dict[str, Any],
) -> Dict[str, Any]:
    return {
        "version": "pyhdwallet v1.0.5",
        "purpose": "generated mnemonic backup",
        "mnemonic": mnemonic,
        "passphrase_used": passphrase_used,
        "passphrase_hint": passphrase_hint,
        "master_fingerprint": fp,
        "dice_rolls_used": dice_used,
        "solana_profile": sol_profile,
        "addresses": addresses,
    }


def build_payload_recover(
    fp: str,
    sol_profile: str,
    passphrase_used: bool,
    passphrase_hint: str,
    include_source: bool,
    mnemonic: Optional[str],
    export_private: bool,
    derived_private: Dict[str, Any],
    addresses: Dict[str, Any],
) -> Dict[str, Any]:
    payload: Dict[str, Any] = {
        "version": "pyhdwallet v1.0.5",
        "purpose": "recovery payload",
        "master_fingerprint": fp,
        "solana_profile": sol_profile,
        "passphrase_used": passphrase_used,
        "passphrase_hint": passphrase_hint,
    }

    if include_source and mnemonic:
        payload["mnemonic"] = mnemonic

    if export_private:
        payload["note"] = "Derived private keys included; passphrase value intentionally omitted."
        payload["derived_private_keys"] = derived_private

    payload["addresses"] = addresses
    return payload


def deterministic_inner_name(is_encrypted: bool, ts: str) -> str:
    if is_encrypted:
        return f"encrypted_wallet_{ts}.asc"
    return f"test_wallet_{ts}.json"


def deterministic_zip_name(prefix: str, ts: str) -> str:
    return f"{prefix}_{ts}.zip"


# -----------------------------------------------------------------------------
# Commands
# -----------------------------------------------------------------------------


def cmd_gen(args) -> None:
    with NetworkGuard("gen"):
        require_for_offline(args.chains)

        if args.off_screen:
            print("⚠️  Off-screen mode enabled: Sensitive data will not be printed to stdout.")
        else:
            warn_gen_stdout_banner()
            require_tty_or_force(args.force, "mnemonic to stdout")

        import secrets
        from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39SeedGenerator

        words_to_entropy = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256}
        if args.words not in words_to_entropy:
            raise ValueError("Invalid --words")
        entropy_len = words_to_entropy[args.words] // 8

        if args.dice_rolls:
            rolls = args.dice_rolls.strip().split()
            if not rolls or not all(r.isdigit() and 1 <= int(r) <= 6 for r in rolls):
                raise ValueError("--dice-rolls must be space-separated integers 1-6")
            dice_bytes = " ".join(rolls).encode("utf-8")
            crypto_bytes = secrets.token_bytes(entropy_len)
            entropy = hashlib.sha256(dice_bytes + crypto_bytes).digest()[:entropy_len]
            dice_used = True
        else:
            entropy = secrets.token_bytes(entropy_len)
            dice_used = False

        mnemonic = str(Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(entropy))

        passphrase = getpass.getpass("Enter BIP39 passphrase (hidden): ") if args.passphrase else ""
        seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
        fp = get_master_fingerprint(seed_bytes)

        result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=False)

        if not args.off_screen:
            print(f"📍 Generated {args.words}-word BIP39 mnemonic:\n{mnemonic}\n")
            print(f"Master Fingerprint: {fp}")
            print(format_addresses_human(result["addresses"]))

        if args.file:
            require_for_zip()

            ts = utc_timestamp_compact()
            is_pgp = bool(args.pgp_pubkey_file)

            if is_pgp:
                with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
                    pub = f.read()
                    fpr = pgp_fingerprint(pub)
                    require_fingerprint_match(fpr, args.expected_fingerprint, "encrypt(gen)")
                payload = build_payload_gen(
                    mnemonic=mnemonic,
                    passphrase_used=bool(passphrase),
                    passphrase_hint=args.passphrase_hint or "",
                    fp=fp,
                    dice_used=dice_used,
                    sol_profile=args.sol_profile,
                    addresses=result["addresses"],
                )
                inner_name = deterministic_inner_name(is_encrypted=True, ts=ts)
                inner_text = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags)
                inner_bytes = inner_text.encode("utf-8")
                zip_prefix = "encrypted_wallet"
            else:
                payload = build_payload_gen(
                    mnemonic=mnemonic,
                    passphrase_used=bool(passphrase),
                    passphrase_hint=args.passphrase_hint or "",
                    fp=fp,
                    dice_used=dice_used,
                    sol_profile=args.sol_profile,
                    addresses=result["addresses"],
                )
                inner_name = deterministic_inner_name(is_encrypted=False, ts=ts)
                inner_text = json.dumps(payload, indent=2, ensure_ascii=False)
                inner_bytes = inner_text.encode("utf-8")
                zip_prefix = "test_wallet"

            if args.zip_password_mode == "prompt":
                zip_password = prompt_zip_password_hidden_or_warn()
            else:
                zip_password = gen_base58_password(args.zip_password_len)
                if args.show_generated_password:
                    print(f"ZIP password (auto-generated, base58): {zip_password}", file=sys.stderr)

            wallet_dir = Path(args.wallet_location) if args.wallet_location else default_wallet_dir()
            ensure_dir(wallet_dir)

            zip_name = deterministic_zip_name(zip_prefix, ts)
            zip_path = wallet_dir / zip_name

            write_aes_zip(zip_path, inner_name, inner_bytes, zip_password)

            print(f"✅ Wrote AES-encrypted ZIP: {zip_path}")
            print(f"   Contains: {inner_name}")

        if args.off_screen:
            mnemonic = None
            seed_bytes = None
            passphrase = None


def cmd_recover(args) -> None:
    with NetworkGuard("recover"):
        require_for_offline(args.chains)

        if args.off_screen:
            print("⚠️  Off-screen mode enabled: Sensitive data will not be printed to stdout.")

        if args.export_private and not args.pgp_pubkey_file:
            raise ValueError("--export-private requires --pgp-pubkey-file")

        from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator

        if args.interactive:
            mnemonic = interactive_mnemonic_word_by_word()
        elif args.mnemonic_stdin:
            mnemonic = _read_stdin_all()
            if not mnemonic:
                raise ValueError("Empty stdin for --mnemonic-stdin")
        else:
            raise ValueError("Missing input mode (use --interactive / --mnemonic-stdin)")

        passphrase = getpass.getpass("Enter BIP39 passphrase (hidden): ") if args.passphrase else ""

        if not Bip39MnemonicValidator().IsValid(mnemonic):
            raise ValueError("Invalid BIP39 mnemonic")

        seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
        fp = get_master_fingerprint(seed_bytes)

        if not args.off_screen:
            print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...")
            print(f"Master Fingerprint: {fp}\n")

        result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=args.export_private)

        if not args.off_screen:
            print(format_addresses_human(result["addresses"]))

        if args.file:
            require_for_zip()

            ts = utc_timestamp_compact()
            is_pgp = bool(args.pgp_pubkey_file)

            wallet_dir = Path(args.wallet_location) if args.wallet_location else default_wallet_dir()
            ensure_dir(wallet_dir)

            if is_pgp:
                with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
                    pub = f.read()
                    fpr = pgp_fingerprint(pub)
                    require_fingerprint_match(fpr, args.expected_fingerprint, "encrypt(gen)")

                fpr = pgp_fingerprint(pub)
                require_fingerprint_match(fpr, args.expected_fingerprint, "encrypt")
                payload = build_payload_recover(
                    fp=fp,
                    sol_profile=args.sol_profile,
                    passphrase_used=bool(passphrase),
                    passphrase_hint=args.passphrase_hint or "",
                    include_source=args.include_source,
                    mnemonic=mnemonic if args.include_source else None,
                    export_private=args.export_private,
                    derived_private=result.get("secrets", {}),
                    addresses=result["addresses"],
                )
                inner_name = deterministic_inner_name(is_encrypted=True, ts=ts)
                inner_text = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags)
                inner_bytes = inner_text.encode("utf-8")
                zip_prefix = "encrypted_wallet"
            else:
                payload = build_payload_recover(
                    fp=fp,
                    sol_profile=args.sol_profile,
                    passphrase_used=bool(passphrase),
                    passphrase_hint=args.passphrase_hint or "",
                    include_source=args.include_source,
                    mnemonic=mnemonic if args.include_source else None,
                    export_private=args.export_private,
                    derived_private=result.get("secrets", {}),
                    addresses=result["addresses"],
                )
                inner_name = deterministic_inner_name(is_encrypted=False, ts=ts)
                inner_text = json.dumps(payload, indent=2, ensure_ascii=False)
                inner_bytes = inner_text.encode("utf-8")
                zip_prefix = "test_wallet"

            if args.zip_password_mode == "prompt":
                zip_password = prompt_zip_password_hidden_or_warn()
            else:
                zip_password = gen_base58_password(args.zip_password_len)
                if args.show_generated_password:
                    print(f"ZIP password (auto-generated, base58): {zip_password}", file=sys.stderr)

            zip_name = deterministic_zip_name(zip_prefix, ts)
            zip_path = wallet_dir / zip_name

            write_aes_zip(zip_path, inner_name, inner_bytes, zip_password)

            print(f"✅ Wrote AES-encrypted ZIP: {zip_path}")
            print(f"   Contains: {inner_name}")

        if args.off_screen:
            mnemonic = None
            seed_bytes = None
            passphrase = None


def cmd_test(args) -> None:
    with NetworkGuard("test"):
        require_for_offline(["ethereum", "solana"])

        if args.off_screen:
            print("⚠️  Off-screen mode enabled (no effect on test).")

        from bip_utils import Bip39SeedGenerator

        print("🧪 Running tests...")

        mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
        seed_bytes = Bip39SeedGenerator(mnemonic).Generate("")

        expected_addr = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
        path = "m/44'/501'/0'/0'"

        seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path)
        got_addr = sol_pubkey_b58_from_seed32(seed32)

        if got_addr != expected_addr:
            raise RuntimeError(f"Solana test failed: expected {expected_addr}, got {got_addr}")

        print(f"✅ Solana OK: {path} => {got_addr}")
        print("✅ All tests passed")


# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="pyhdwallet v1.0.5 - Secure HD Wallet Tool")
    parser.add_argument("--version", action="version", version="pyhdwallet v1.0.5")
    sub = parser.add_subparsers(dest="cmd")

    p_fetch = sub.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL (online)")
    p_fetch.add_argument("url")
    p_fetch.add_argument("--out", default=None)
    p_fetch.add_argument("--timeout", type=int, default=15)
    p_fetch.add_argument(
        "--off-screen",
        action="store_true",
        help="Enable off-screen mode: no printing of sensitive data to stdout.",
    )
    p_fetch.add_argument("--expected-fingerprint", default="", help="Refuse if downloaded key fingerprint does not match (40 hex chars).")


    def add_common(p: argparse.ArgumentParser) -> None:
        p.add_argument("--force", action="store_true", help="Allow printing sensitive output even when stdout is not a TTY (dangerous).")
        p.add_argument("--off-screen", action="store_true", help="Suppress printing sensitive data to stdout.")
        p.add_argument("--passphrase", action="store_true", help="Prompt for BIP39 passphrase interactively")
        p.add_argument("--passphrase-hint", default="", help="Hint only; never store the passphrase itself")

        p.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"], default=["ethereum", "solana", "bitcoin"])
        p.add_argument("--addresses", type=int, default=5)

        p.add_argument("--pgp-pubkey-file", default=None)
        p.add_argument("--pgp-ignore-usage-flags", action="store_true")
        p.add_argument("--expected-fingerprint", default="", help="Refuse if PGP recipient key fingerprint does not match.")

        p.add_argument(
            "--sol-profile",
            choices=["phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"],
            default="phantom_bip44change",
        )
        p.add_argument(
            "--export-private",
            action="store_true",
            help="If set, derive and include private keys in encrypted payload (never printed). Requires --pgp-pubkey-file.",
        )
        p.add_argument(
            "--include-source",
            action="store_true",
            help="If set, include mnemonic in payload (use with --pgp-pubkey-file and --file for safer storage).",
        )

        p.add_argument("--file", action="store_true", help="Write output to AES-encrypted ZIP in ./.wallet (deterministic name).")
        p.add_argument("--wallet-location", default="", help="Override default ./.wallet folder for --file output.")

        p.add_argument(
            "--zip-password-mode",
            choices=["prompt", "auto"],
            default="prompt",
            help="ZIP password mode: prompt (hidden when possible) or auto-generate base58.",
        )
        p.add_argument("--zip-password-len", type=int, default=12, help="Password length when --zip-password-mode auto is used.")
        p.add_argument(
            "--show-generated-password",
            action="store_true",
            help="When using --zip-password-mode auto, print generated password to stderr.",
        )

    p_gen = sub.add_parser("gen", help="Generate BIP39 mnemonic (offline)")
    add_common(p_gen)
    p_gen.add_argument("--words", type=int, choices=[12, 15, 18, 21, 24], default=12)
    p_gen.add_argument("--dice-rolls", default="")

    p_rec = sub.add_parser("recover", help="Recover addresses from mnemonic (offline)")
    add_common(p_rec)
    g = p_rec.add_mutually_exclusive_group(required=True)
    g.add_argument("--interactive", action="store_true", help="Guided mnemonic entry (English-only, per-word validation)")
    g.add_argument("--mnemonic-stdin", dest="mnemonic_stdin", action="store_true", help="Read BIP39 mnemonic from stdin (non-interactive)")

    p_test = sub.add_parser("test", help="Run minimal offline tests")
    p_test.add_argument("--off-screen", action="store_true", help="Enable off-screen mode: no printing of sensitive data to stdout.")

    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.cmd == "fetchkey":
            cmd_fetchkey(args.url, args.out, args.timeout, args.off_screen, args.expected_fingerprint)
            return
        if args.cmd == "gen":
            cmd_gen(args)
            return
        if args.cmd == "recover":
            cmd_recover(args)
            return
        if args.cmd == "test":
            cmd_test(args)
            return

        parser.print_help()
        sys.exit(2)

    except Exception as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
