"""Python 3.10+: bitbankの残高を1回だけGETする。参照権限だけのキーを用意。

BITBANK_API_KEY / BITBANK_API_SECRET を環境変数に設定して実行する。
注文・出金は実装していない。数量の取得例であり、評価額の集計ではない。
仕様確認: 2026-09-22
https://github.com/bitbankinc/bitbank-api-docs/blob/master/rest-api_JP.md
"""
import hashlib
import hmac
import json
import os
import re
import sys
import time
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from http.client import HTTPException
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, Request, build_opener

HOST = "https://api.bitbank.cc"
PATH = "/v1/user/assets"
MAX_RESPONSE_BYTES = 1_000_000


class BalanceError(Exception):
    """秘密値や応答本文を含まない、利用者向けのエラー。"""


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise BalanceError("リダイレクトを拒否しました。")


def make_request(api_key, api_secret, request_time_ms=None, window_ms=5000):
    if not all(isinstance(v, str) and v and v.isascii()
               and not any(c.isspace() for c in v) for v in (api_key, api_secret)):
        raise BalanceError("APIキーとシークレットの設定を確認してください。")
    request_time_ms = time.time_ns() // 1_000_000 if request_time_ms is None else request_time_ms
    if type(request_time_ms) is not int or request_time_ms < 0:
        raise BalanceError("リクエスト時刻が不正です。")
    if type(window_ms) is not int or not 1 <= window_ms <= 5000:
        raise BalanceError("この例の有効時間は1〜5000ミリ秒です。")
    # GET署名には /v1 を含むパスを使う。クエリパラメータはない。
    message = f"{request_time_ms}{window_ms}{PATH}".encode("ascii")
    signature = hmac.new(api_secret.encode("ascii"), message, hashlib.sha256).hexdigest()
    return Request(HOST + PATH, method="GET", headers={
        "ACCESS-KEY": api_key,
        "ACCESS-REQUEST-TIME": str(request_time_ms),
        "ACCESS-TIME-WINDOW": str(window_ms),
        "ACCESS-SIGNATURE": signature,
        "Accept": "application/json",
    })


def _quantity(value):
    if not isinstance(value, str) or not value or len(value) > 128 or value.strip() != value:
        raise BalanceError("数量は空でない数値文字列が必要です。")
    try:
        number = Decimal(value)
    except InvalidOperation:
        raise BalanceError("数量を数値として読めません。") from None
    if not number.is_finite() or number < 0:
        raise BalanceError("数量に非有限値または負値があります。")
    return number


def normalize_balance_response(document, retrieved_at=None):
    """API由来の数量文字列を保ち、必要な項目だけを返す。口座IDは含めない。"""
    try:
        if type(document["success"]) is not int or document["success"] != 1:
            raise BalanceError("APIが成功を返しませんでした。")
        assets = document["data"]["assets"]
        if not isinstance(assets, list):
            raise BalanceError("残高一覧の形式が違います。")
        balances, seen = [], set()
        for item in assets:
            asset = item["asset"]
            if not isinstance(asset, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,31}", asset):
                raise BalanceError("資産名の形式が違います。")
            asset = asset.upper()
            if asset in seen:
                raise BalanceError("同じ資産が重複しています。")
            seen.add(asset)
            total = _quantity(item["onhand_amount"])
            free = _quantity(item["free_amount"])
            if free > total:
                raise BalanceError("利用可能数量が保有数量を超えています。")
            balances.append({"asset": asset, "total_quantity": item["onhand_amount"],
                             "free_quantity": item["free_amount"]})
    except (KeyError, TypeError):
        raise BalanceError("残高の必須項目が欠けているか型が違います。") from None
    observed = retrieved_at if retrieved_at is not None else datetime.now(timezone.utc)
    if not isinstance(observed, datetime) or observed.utcoffset() is None:
        raise BalanceError("取得時刻にはタイムゾーン付き日時が必要です。")
    return {"source": "bitbank", "endpoint": PATH,
            "retrieved_at": observed.astimezone(timezone.utc).isoformat(),
            "balances": balances}


def fetch_balances(api_key, api_secret):
    request = make_request(api_key, api_secret)
    try:
        with build_opener(NoRedirect()).open(request, timeout=10) as response:
            if response.status != 200:
                raise BalanceError("成功以外のHTTP応答です。")
            raw = response.read(MAX_RESPONSE_BYTES + 1)
        if len(raw) > MAX_RESPONSE_BYTES:
            raise BalanceError("応答サイズの上限を超えました。")
        document = json.loads(raw)
    except HTTPError as error:
        if error.code == 429:
            raise BalanceError("HTTP 429: 呼び出し制限のため停止しました。") from None
        raise BalanceError(f"HTTP {error.code}: 残高を取得できませんでした。") from None
    except (URLError, HTTPException, TimeoutError, OSError):
        raise BalanceError("通信に失敗しました。再試行せず停止します。") from None
    except (ValueError, UnicodeError):
        raise BalanceError("応答をJSONとして読めません。") from None
    return normalize_balance_response(document)


def main():
    try:
        result = fetch_balances(os.environ.get("BITBANK_API_KEY"),
                                os.environ.get("BITBANK_API_SECRET"))
    except BalanceError as error:
        print(f"取得失敗: {error}", file=sys.stderr)
        return 1
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0


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