#!/usr/bin/env python3
"""ArcBlast Launchpad（弧爆發射台）agent CLI. Python 3.9+, standard library only.

Wraps the launchpad HTTP API (https://api.arcat.fun/v1/*) so agents can:
  - inspect runtime/chain status (health)
  - run the SIWE wallet sign-in flow (auth)
  - prepare an unsigned token-launch transaction (tokens prepare)
  - upload media to IPFS (media upload)

The API never holds keys, signs or broadcasts. `tokens prepare` returns
unsigned calldata; the caller's own wallet tooling must review, sign and
broadcast it on Arc Mainnet (chainId 5042, USDC gas).
"""

import argparse
import getpass
import json
import os
import pathlib
import re
import secrets
import sys
import urllib.error
import urllib.parse
import urllib.request

CLI_VERSION = "0.3.0"
SCHEMA = "arcblast.launch-cli/v1"
DEFAULT_API = os.environ.get("ARCBLAST_API", os.environ.get("ARCAT_API", "https://api.arcat.fun")).rstrip("/")
DEFAULT_ORIGIN = os.environ.get("ARCBLAST_ORIGIN", os.environ.get("ARCAT_ORIGIN", "https://market.arcat.fun"))
DEFAULT_CHAIN_ID = int(os.environ.get("ARCBLAST_CHAIN_ID", os.environ.get("ARCAT_CHAIN_ID", "5042")))
SESSION_PATH = pathlib.Path(os.environ.get("ARCBLAST_SESSION_DIR", os.environ.get("ARCAT_SESSION_DIR", pathlib.Path.home() / ".arcblast"))) / "session.json"
ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$")
USER_AGENT = f"arcblast-launch-cli/{CLI_VERSION}"
TESTNET_CHAIN_ID = 5042002
TESTNET_RPC = "https://rpc.testnet.arc.io"
TESTNET_FACTORY = "0xae4592D1E85AE907354292346DD3e35f614F85d7"
ARC_USDC = "0x3600000000000000000000000000000000000000"
CREATE_SELECTOR = "4ba21e19"
APPROVE_SELECTOR = "095ea7b3"


class CliError(RuntimeError):
    pass


# ---------------------------------------------------------------- HTTP layer

def normalize_api(value):
    api = str(value or DEFAULT_API).rstrip("/")
    parsed = urllib.parse.urlsplit(api)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
        raise CliError("API base URL must be an http(s) origin without query/fragment")
    return api


def api_request(api, method, path, payload=None, timeout=30, session=None, csrf=False, raw=None, raw_headers=None):
    url = normalize_api(api) + path
    headers = {"Accept": "application/json", "User-Agent": USER_AGENT, "Origin": DEFAULT_ORIGIN}
    body = None
    if raw is not None:
        body = raw
        headers.update(raw_headers or {})
    elif payload is not None:
        body = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"
    if session and session.get("cookie"):
        headers["Cookie"] = f"arcat_session={session['cookie']}"
    if csrf:
        token = (session or {}).get("csrfToken")
        if not token:
            raise CliError("Session has no CSRF token; run `auth me` or sign in again")
        headers["X-CSRF-Token"] = token
    request = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        try:
            payload_out = json.loads(exc.read().decode("utf-8"))
            message = f"{payload_out.get('error', 'error')}: {payload_out.get('message', '')}".strip(": ")
        except Exception:
            message = f"HTTP {exc.code}"
        raise CliError(f"{method} {path} failed — {message}") from exc
    except (urllib.error.URLError, TimeoutError) as exc:
        raise CliError(f"Cannot reach {url}: {exc}") from exc


# --------------------------------------------------------------- session file

def load_session():
    try:
        data = json.loads(SESSION_PATH.read_text(encoding="utf-8"))
        if isinstance(data, dict) and data.get("cookie"):
            return data
    except (OSError, ValueError):
        pass
    return None


def save_session(session):
    SESSION_PATH.parent.mkdir(parents=True, exist_ok=True)
    SESSION_PATH.write_text(json.dumps(session, indent=2) + "\n", encoding="utf-8")
    try:
        os.chmod(SESSION_PATH, 0o600)
    except OSError:
        pass


def clear_session():
    try:
        SESSION_PATH.unlink()
    except FileNotFoundError:
        pass


def require_session():
    session = load_session()
    if not session:
        raise CliError("Not signed in. Run `auth login --key 0x...` or the manual nonce/message/verify flow first.")
    return session


# -------------------------------------------------------------------- SIWE

def build_siwe_message(domain, address, statement, uri, chain_id, nonce, issued_at):
    return (
        f"{domain} wants you to sign in with your Ethereum account:\n"
        f"{address}\n"
        f"\n"
        f"{statement}\n"
        f"\n"
        f"URI: {uri}\n"
        f"Version: 1\n"
        f"Chain ID: {chain_id}\n"
        f"Nonce: {nonce}\n"
        f"Issued At: {issued_at}"
    )


def siwe_login_with_key(api, key, chain_id):
    try:
        from eth_account import Account
        from eth_account.messages import encode_defunct
    except ImportError as exc:
        raise CliError(
            "auth login needs the optional dependency `eth-account` (pip install eth-account). "
            "Without it, use the manual flow: auth nonce → auth message → sign with your wallet → auth verify."
        ) from exc
    account = Account.from_key(key)
    nonce_payload = api_request(api, "POST", "/v1/auth/nonce", payload={})
    message = build_siwe_message(
        "arcat.fun", account.address, "Sign in to ArcLaunch on Arc Mainnet.",
        "https://arcat.fun", chain_id, nonce_payload["nonce"],
        __import__("datetime").datetime.now(__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
    )
    signed = Account.sign_message(encode_defunct(text=message), account.key).signature.hex()
    # eth-account's HexBytes.hex() drops the 0x prefix on some versions;
    # the API expects a 0x-prefixed 65-byte signature.
    signature = signed if signed.startswith("0x") else "0x" + signed
    # Verify once, capturing the Set-Cookie header (the session cookie is
    # header-only; the JSON body carries address + csrfToken).
    session = {"cookie": None, "address": None, "csrfToken": None}
    verify_with_cookie_capture(api, message, signature, session)
    return session


def verify_with_cookie_capture(api, message, signature, session):
    if signature and not signature.startswith("0x"):
        signature = "0x" + signature
    url = normalize_api(api) + "/v1/auth/verify"
    body = json.dumps({"message": message, "signature": signature}).encode("utf-8")
    request = urllib.request.Request(url, data=body, method="POST", headers={
        "Accept": "application/json", "User-Agent": USER_AGENT, "Origin": DEFAULT_ORIGIN,
        "Content-Type": "application/json",
    })
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            payload = json.loads(response.read().decode("utf-8"))
            set_cookie = response.headers.get("Set-Cookie", "")
    except urllib.error.HTTPError as exc:
        try:
            payload_out = json.loads(exc.read().decode("utf-8"))
            detail = f"{payload_out.get('error', 'error')}: {payload_out.get('message', '')}".strip(": ")
        except Exception:
            detail = f"HTTP {exc.code}"
        raise CliError(f"auth verify failed — {detail}") from exc
    match = re.search(r"arcat_session=([^;]+)", set_cookie)
    if not match:
        raise CliError("Server did not return the arcat_session cookie")
    session["cookie"] = match.group(1)
    session["address"] = payload.get("address")
    session["csrfToken"] = payload.get("csrfToken")


# ------------------------------------------------------------------ commands

def cmd_health(args):
    return api_request(args.api, "GET", "/v1/health")


def cmd_chains(args):
    return {
        "activeChain": {
            "name": "Arc Mainnet", "chainId": 5042,
            "rpcUrl": "https://rpc.arc-scan.org", "explorerUrl": "https://arc-scan.org",
            "usdcAddress": "0x3600000000000000000000000000000000000000", "usdcDecimals": 6,
            "gasToken": "USDC (18 decimals)",
        },
        "testnet": {"name": "Arc Testnet", "chainId": 5042002, "rpcUrl": "https://rpc.testnet.arc.io"},
        "note": "Values mirror the live runtime; run `health` for the server's authoritative view.",
    }


def _word(value):
    value = int(value)
    if value < 0 or value >= 1 << 256:
        raise CliError("ABI uint256 overflow")
    return value.to_bytes(32, "big")


def _address(value):
    if not ADDRESS_RE.match(value):
        raise CliError("invalid EVM address")
    return bytes.fromhex(value[2:]).rjust(32, b"\0")


def _string_tail(value):
    raw = str(value).encode("utf-8")
    padded = raw + b"\0" * ((32 - len(raw) % 32) % 32)
    return _word(len(raw)) + padded


def _usdc_units(value):
    text = str(value or "0")
    if not re.match(r"^\d+(\.\d{1,6})?$", text):
        raise CliError("--initial-liquidity must be a decimal USDC amount with up to 6 decimals")
    whole, _, fraction = text.partition(".")
    return int(whole) * 1_000_000 + int((fraction + "000000")[:6] or 0)


def _testnet_create_calldata(args, user_salt):
    liquidity = _usdc_units(args.initial_liquidity)
    mode = 1 if args.launch_mode == "fair" else 0
    fee_recipient = args.fee_recipient if args.creator_fee else "0x0000000000000000000000000000000000000000"
    head_size = 10 * 32
    tails = [_string_tail(args.name), _string_tail(args.symbol), _string_tail(args.description)]
    offsets = [head_size, head_size + len(tails[0]), head_size + len(tails[0]) + len(tails[1])]
    body = b"".join([
        _word(offsets[0]), _word(offsets[1]), _word(offsets[2]), _word(mode),
        _word(liquidity), _word(0), _word(0), _address(fee_recipient), _word(25 if args.creator_fee else 0),
        bytes.fromhex(user_salt[2:] if user_salt.startswith("0x") else user_salt),
        *tails,
    ])
    if len(body) != 32 * 10 + sum(len(t) for t in tails):
        raise CliError("internal ABI encoding error")
    return "0x" + CREATE_SELECTOR + body.hex(), liquidity


def cmd_tokens_prepare_testnet(args):
    if args.creator_fee and not args.fee_recipient:
        raise CliError("--fee-recipient is required with --creator-fee")
    if args.fee_recipient and not ADDRESS_RE.match(args.fee_recipient):
        raise CliError("--fee-recipient must be a valid EVM address")
    user_salt = args.user_salt or secrets.token_hex(32)
    if user_salt.startswith("0x"):
        user_salt = user_salt[2:]
    if not re.match(r"^[0-9a-fA-F]{64}$", user_salt):
        raise CliError("--user-salt must be exactly 32 bytes hex for testnet")
    create_data, liquidity = _testnet_create_calldata(args, user_salt)
    txs = []
    if liquidity:
        txs.append({"kind": "approve", "to": ARC_USDC, "data": "0x" + APPROVE_SELECTOR + _address(TESTNET_FACTORY).hex() + _word(liquidity).hex(), "value": "0"})
    txs.append({"kind": "createToken", "to": TESTNET_FACTORY, "data": create_data, "value": "0"})
    return {
        "network": "Arc Testnet", "chainId": TESTNET_CHAIN_ID, "rpcUrl": TESTNET_RPC,
        "factory": TESTNET_FACTORY, "usdcAddress": ARC_USDC, "transactions": txs,
        "metadata": {"name": args.name, "symbol": args.symbol, "launchMode": args.launch_mode, "initialLiquidity": args.initial_liquidity, "userSalt": "0x" + user_salt},
        "risk": ["Unsigned transactions only; review and sign with your own wallet.", "When initial liquidity is non-zero, approve USDC before createToken.", "This CLI never reads a private key or broadcasts transactions."],
    }


def cmd_auth_nonce(args):
    return api_request(args.api, "POST", "/v1/auth/nonce", payload={})


def cmd_auth_message(args):
    if not ADDRESS_RE.match(args.address):
        raise CliError("--address must be a 0x-prefixed 20-byte EVM address")
    nonce_payload = api_request(args.api, "POST", "/v1/auth/nonce", payload={})
    from datetime import datetime, timezone
    message = build_siwe_message(
        args.domain, args.address, args.statement, f"https://{args.domain}",
        args.chain_id, nonce_payload["nonce"],
        datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
    )
    return {
        "message": message,
        "nonceExpiresAt": nonce_payload.get("expiresAt"),
        "instructions": "Sign this exact text with the wallet for --address (EIP-191 personal_sign), then run `auth verify --message <text> --signature 0x...`.",
    }


def cmd_auth_verify(args):
    message = args.message if args.message != "-" else sys.stdin.read()
    session = {"cookie": None, "address": None, "csrfToken": None}
    verify_with_cookie_capture(args.api, message, args.signature, session)
    save_session(session)
    return {"ok": True, "address": session["address"], "sessionPath": str(SESSION_PATH)}


def cmd_auth_login(args):
    key = args.key or getpass.getpass("Private key (0x...): ")
    if not re.match(r"^0x[0-9a-fA-F]{64}$", key):
        raise CliError("Private key must be 0x-prefixed 32-byte hex")
    session = siwe_login_with_key(args.api, key, args.chain_id)
    save_session(session)
    return {"ok": True, "address": session["address"], "sessionPath": str(SESSION_PATH)}


def cmd_auth_me(args):
    result = api_request(args.api, "GET", "/v1/auth/me", session=require_session())
    session = load_session()
    session["csrfToken"] = result.get("csrfToken", session.get("csrfToken"))
    session["address"] = result.get("address", session.get("address"))
    save_session(session)
    return {"address": session["address"], "csrfRotated": True}


def cmd_auth_logout(args):
    session = load_session()
    if session:
        try:
            api_request(args.api, "POST", "/v1/auth/logout", payload={}, session=session)
        except CliError:
            pass
    clear_session()
    return {"ok": True, "sessionCleared": True}


def cmd_tokens_prepare(args):
    if args.network == "testnet":
        return cmd_tokens_prepare_testnet(args)
    if not ADDRESS_RE.match(args.fee_recipient or "0x0000000000000000000000000000000000000000"):
        raise CliError("--fee-recipient must be a valid EVM address")
    payload = {
        "name": args.name,
        "symbol": args.symbol,
        "description": args.description,
        "launchMode": args.launch_mode,
        "initialLiquidity": args.initial_liquidity,
        "creatorFeeEnabled": bool(args.creator_fee),
        "userSalt": args.user_salt or secrets.token_hex(16),
    }
    if args.creator_fee and args.fee_recipient:
        payload["feeRecipient"] = args.fee_recipient
    result = api_request(args.api, "POST", "/v1/tokens/prepare", payload=payload,
                         session=require_session(), csrf=True)
    result["nextSteps"] = [
        "Review transaction.to / transaction.data and every entry in `risk`.",
        "Sign and broadcast with your own wallet tooling on Arc Mainnet (chainId 5042).",
        "This CLI and the API never sign, hold keys, or broadcast transactions.",
    ]
    return result


def cmd_media_upload(args):
    file_path = pathlib.Path(args.file)
    if not file_path.is_file():
        raise CliError(f"File not found: {file_path}")
    mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}.get(file_path.suffix.lower())
    if not mime:
        raise CliError("Only .jpg, .png and .webp are accepted")
    data = file_path.read_bytes()
    if len(data) > 4 * 1024 * 1024:
        raise CliError("File exceeds 4 MB")
    boundary = "----arcblastcli" + secrets.token_hex(12)
    body = b"\r\n".join([
        f"--{boundary}".encode(),
        f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"'.encode(),
        f"Content-Type: {mime}".encode(),
        b"",
        data,
        f"--{boundary}--".encode(),
        b"",
    ])
    return api_request(args.api, "POST", "/v1/media/uploads", raw=body,
                       raw_headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
                                    "Content-Length": str(len(body))},
                       session=require_session(), csrf=True, timeout=120)


# --------------------------------------------------------------------- main

def build_parser():
    parser = argparse.ArgumentParser(prog="arcblast-launch",
                                     description="ArcBlast Launchpad（弧爆發射台）agent CLI — Arc token launchpad API wrapper")
    parser.add_argument("--api", default=DEFAULT_API, help=f"API base URL (default {DEFAULT_API})")
    parser.add_argument("--json", action="store_true", help="Print machine-readable JSON only")
    parser.add_argument("--version", action="version", version=f"%(prog)s {CLI_VERSION}")
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("health", help="GET /v1/health — runtime, chain, factory, thresholds")
    sub.add_parser("chains", help="Show Arc chain constants (offline)")

    auth = sub.add_parser("auth", help="SIWE wallet sign-in flow").add_subparsers(dest="auth_command", required=True)
    auth.add_parser("nonce", help="Request a sign-in nonce")
    message = auth.add_parser("message", help="Build the exact SIWE message text to sign")
    message.add_argument("--address", required=True, help="0x EVM address that will sign")
    message.add_argument("--domain", default="arcat.fun", help="SIWE domain (default arcat.fun)")
    message.add_argument("--statement", default="Sign in to ArcLaunch on Arc Mainnet.")
    message.add_argument("--chain-id", type=int, default=DEFAULT_CHAIN_ID)
    verify = auth.add_parser("verify", help="Verify a signed SIWE message and store the session")
    verify.add_argument("--message", required=True, help="SIWE message text, or - to read from stdin")
    verify.add_argument("--signature", required=True, help="EIP-191 signature (0x prefix added if missing)")
    login = auth.add_parser("login", help="One-step sign-in with a private key (needs eth-account)")
    login.add_argument("--key", help="0x private key; prompted securely if omitted")
    login.add_argument("--chain-id", type=int, default=DEFAULT_CHAIN_ID)
    auth.add_parser("me", help="Show session address and rotate the CSRF token")
    auth.add_parser("logout", help="Revoke the session and clear local state")

    tokens = sub.add_parser("tokens", help="Token launch operations").add_subparsers(dest="tokens_command", required=True)
    prepare = tokens.add_parser("prepare", help="Prepare an UNSIGNED createToken transaction (requires sign-in)")
    prepare.add_argument("--name", required=True, help="Token name, 1-64 chars")
    prepare.add_argument("--symbol", required=True, help="Token symbol, 1-12 alnum chars")
    prepare.add_argument("--description", required=True, help="Description, 1-280 chars")
    prepare.add_argument("--launch-mode", choices=["fair", "bonding"], default="bonding")
    prepare.add_argument("--initial-liquidity", default="0", help="USDC amount as decimal string")
    prepare.add_argument("--creator-fee", action="store_true", help="Enable 0.25%% buy / 0.125%% sell creator fee")
    prepare.add_argument("--fee-recipient", help="Creator fee recipient address (required with --creator-fee)")
    prepare.add_argument("--user-salt", help="Deterministic salt; random when omitted (testnet: 32-byte hex)")
    prepare.add_argument("--network", choices=["mainnet", "testnet"], default="mainnet")

    media = sub.add_parser("media", help="Media operations").add_subparsers(dest="media_command", required=True)
    upload = media.add_parser("upload", help="Upload an image to IPFS (requires sign-in)")
    upload.add_argument("file", help=".jpg/.png/.webp file, max 4 MB")
    return parser


HANDLERS = {
    "health": cmd_health,
    "chains": cmd_chains,
    ("auth", "nonce"): cmd_auth_nonce,
    ("auth", "message"): cmd_auth_message,
    ("auth", "verify"): cmd_auth_verify,
    ("auth", "login"): cmd_auth_login,
    ("auth", "me"): cmd_auth_me,
    ("auth", "logout"): cmd_auth_logout,
    ("tokens", "prepare"): cmd_tokens_prepare,
    ("media", "upload"): cmd_media_upload,
}


def render_human(command, result):
    if command == "health":
        runtime = result.get("runtime", {})
        print(f"ok: {result.get('ok')}  environment: {result.get('environment')}")
        for key in ("chainId", "rpcUrl", "explorerUrl", "usdcAddress", "tokenFactoryAddress",
                    "treasuryAddress", "graduationThreshold", "mainnetEnabled"):
            if key in runtime:
                print(f"  {key}: {runtime[key]}")
        return
    print(json.dumps(result, ensure_ascii=False, indent=2))


def main(argv=None):
    # Server error messages can contain Unicode symbols (✖/➜); keep output
    # readable on non-UTF-8 consoles (e.g. Windows GBK) instead of crashing.
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8", errors="replace")
        except (AttributeError, ValueError):
            pass
    argv = list(sys.argv[1:] if argv is None else argv)
    # Accept --json anywhere on the command line, not just before subcommands.
    json_anywhere = "--json" in argv
    argv = [item for item in argv if item != "--json"]
    args = build_parser().parse_args(argv)
    args.json = args.json or json_anywhere
    key = tuple(part for part in (getattr(args, name, None)
                                  for name in ("command", "auth_command", "tokens_command", "media_command")) if part)
    handler = None
    for candidate, fn in HANDLERS.items():
        parts = candidate if isinstance(candidate, tuple) else (candidate,)
        if key[:len(parts)] == parts:
            handler = fn
            break
    if handler is None:
        raise CliError("Unknown command")
    try:
        result = handler(args)
    except CliError as exc:
        if args.json:
            print(json.dumps({"ok": False, "schema": SCHEMA, "error": str(exc)}, ensure_ascii=False))
        else:
            print(f"error: {exc}", file=sys.stderr)
        return 1
    if args.json:
        print(json.dumps({"ok": True, "schema": SCHEMA, "version": CLI_VERSION, "data": result}, ensure_ascii=False))
    else:
        render_human(args.command, result)
    return 0


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