#!/usr/bin/env python3
"""DamageBDD documentation client. See /python-quickstart.html before funding.

Python 3.10+, cryptography. No automatic payments, redirects or HTTP retries.
"""

import argparse
import base64
import getpass
import hashlib
import json
import os
from pathlib import Path
import secrets
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey


def b64url(data):
    return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")


def account_address(key):
    public = key.public_key().public_bytes(
        serialization.Encoding.Raw, serialization.PublicFormat.Raw
    )
    checksum = hashlib.sha256(hashlib.sha256(public).digest()).digest()[:4]
    data = public + checksum
    alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
    number, encoded = int.from_bytes(data, "big"), ""
    while number:
        number, digit = divmod(number, 58)
        encoded = alphabet[digit] + encoded
    return "ak_" + "1" * (len(data) - len(data.lstrip(b"\0"))) + encoded


def compact_size(number):
    if number < 0:
        raise ValueError("Negative length")
    if number < 253:
        return bytes([number])
    if number <= 0xFFFF:
        return b"\xfd" + number.to_bytes(2, "little")
    if number <= 0xFFFFFFFF:
        return b"\xfe" + number.to_bytes(4, "little")
    return b"\xff" + number.to_bytes(8, "little")


def make_access_token(key, origin, ttl=300):
    if not 1 <= ttl <= 300:
        raise ValueError("Use a token lifetime between 1 and 300 seconds")
    now = int(time.time())
    payload = {
        "typ": "damage-access", "v": 1, "sub": account_address(key),
        "iat": now, "exp": now + ttl, "nonce": b64url(secrets.token_bytes(16)),
        "aud": origin,
    }
    encoded = b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
    message = b"DamageBDD Access Token\n" + encoded.encode("ascii")
    prefix = b"aeternity Signed Message:\n"
    framed = compact_size(len(prefix)) + prefix + compact_size(len(message)) + message
    digest = hashlib.blake2b(framed, digest_size=32).digest()
    # The backend implementation expects hexadecimal, not base64, here.
    return "ae1." + encoded + "." + key.sign(digest).hex()


def secret_value(variable, prompt, confirm=False):
    value = os.environ.get(variable)
    if value is None:
        if not sys.stdin.isatty():
            raise ValueError(f"Inject {variable} through your secret manager")
        value = getpass.getpass(prompt)
        if confirm and value != getpass.getpass("Repeat passphrase: "):
            raise ValueError("Passphrases differ")
    if not value:
        raise ValueError(f"{variable} must not be empty")
    return value.encode("utf-8")


def create_key(path):
    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    password = secret_value("DAMAGE_KEY_PASSWORD", "New key passphrase: ", confirm=True)
    key = Ed25519PrivateKey.generate()
    pem = key.private_bytes(
        serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
        serialization.BestAvailableEncryption(password),
    )
    # Refuse to overwrite an existing key or follow a final-component symlink.
    descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(descriptor, "wb") as handle:
        handle.write(pem)
    return key


def load_key(path):
    password = secret_value("DAMAGE_KEY_PASSWORD", "Key passphrase: ")
    key = serialization.load_pem_private_key(path.read_bytes(), password=password)
    if not isinstance(key, Ed25519PrivateKey):
        raise ValueError("Expected an Ed25519 key")
    return key


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def checked_origin(origin):
    parts = urlsplit(origin)
    if (parts.scheme != "https" or not parts.hostname or parts.username is not None
            or parts.password is not None or parts.query or parts.fragment
            or parts.path not in ("", "/")):
        raise ValueError("DAMAGE_API must be a trusted HTTPS origin, with no path or credentials")
    return origin.rstrip("/")


def api(origin, method, path, token=None, data=None, timeout=45):
    headers = {"Accept": "application/json"}
    if token:
        headers["Authorization"] = "Bearer " + token
    body = None
    if data is not None:
        headers["Content-Type"] = "application/json"
        body = json.dumps(data).encode("utf-8")
    request = Request(origin + path, data=body, headers=headers, method=method)
    try:
        response = build_opener(NoRedirect()).open(request, timeout=timeout)
    except HTTPError as error:
        response = error
    with response:
        status = response.code
        raw = response.read(8 * 1024 * 1024 + 1)
        if len(raw) > 8 * 1024 * 1024:
            raise ValueError("Response too large; inspect reports before any retry")
        if 300 <= status < 400:
            raise ValueError("Redirect refused; verify the node URL and route")
        try:
            result = json.loads(raw)
        except (ValueError, UnicodeDecodeError):
            raise ValueError(f"HTTP {status}: expected JSON; verify the node version and route")
        if not isinstance(result, dict):
            raise ValueError("Expected a JSON object")
        return status, result


def authenticated(args, origin):
    if args.auth == "key":
        key = load_key(args.key)
        return account_address(key), make_access_token(key, origin)
    username = os.environ.get("DAMAGE_USERNAME")
    if not username:
        raise ValueError("Set DAMAGE_USERNAME to the confirmed hosted account email")
    password = secret_value("DAMAGE_PASSWORD", "Hosted account password: ").decode("utf-8")
    status, result = api(origin, "POST", "/accounts/auth/", data={
        "username": username, "password": password,
    })
    if status != 200 or result.get("status") != "ok" or not result.get("access_token"):
        raise ValueError(f"Hosted login failed (HTTP {status}); no token was printed")
    return result["address"], result["access_token"]


def run_exit(status, result):
    if (status == 202 or result.get("status") == "pending"
            or result.get("settlement_status") == "pending"):
        return 2
    if (status == 200 and result.get("status") == "ok"
            and result.get("result") == "success" and result.get("report_hash")
            and result.get("tx_hash") and not result.get("dry_run")):
        return 0
    return 1


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--api", default=os.environ.get("DAMAGE_API", "https://run.damagebdd.com"))
    parser.add_argument("--auth", choices=("key", "hosted"), default="key")
    parser.add_argument("--key", type=Path, default=Path.home() / ".config/damagebdd/agent.pem")
    commands = parser.add_subparsers(dest="command", required=True)
    commands.add_parser("keygen")
    commands.add_parser("address")
    commands.add_parser("wallet")
    invoice = commands.add_parser("invoice")
    invoice.add_argument("--sats", type=int, required=True)
    invoice.add_argument("--max-sats", type=int, required=True)
    invoice.add_argument("--settlement-ready", action="store_true")
    status_parser = commands.add_parser("invoice-status")
    status_parser.add_argument("invoice_file", type=Path)
    run = commands.add_parser("run")
    run.add_argument("feature_file", type=Path)
    run.add_argument("--allow-paid-execution", action="store_true")
    run.add_argument("--settlement-ready", action="store_true")
    args = parser.parse_args(argv)
    if args.command == "keygen":
        print(json.dumps({"address": account_address(create_key(args.key))}))
        return 0
    if args.command == "address":
        print(json.dumps({"address": account_address(load_key(args.key))}))
        return 0
    origin = checked_origin(args.api)
    if args.command == "invoice-status":
        saved = json.loads(args.invoice_file.read_text(encoding="utf-8"))
        if saved.get("api") != origin:
            raise ValueError("Invoice belongs to a different API origin")
        bolt11 = saved["response"]["invoice"]["payment_request"]
        status, result = api(origin, "GET", "/invoices/" + quote(bolt11, safe=""))
    else:
        if args.command == "invoice":
            if not 0 < args.sats <= args.max_sats:
                parser.error("Invoice amount must be positive and within --max-sats")
        if args.command in ("invoice", "run") and args.auth == "key" and not args.settlement_ready:
            parser.error("Confirm node settlement support first, then pass --settlement-ready; see guide")
        if args.command == "run" and not args.allow_paid_execution:
            parser.error("Review target authority and costs, then pass --allow-paid-execution")
        address, token = authenticated(args, origin)
        if args.command == "wallet":
            status, result = api(origin, "GET", "/accounts/wallet", token)
            if status == 200 and result.get("address") != address:
                raise ValueError("Wallet response address does not match authenticated identity")
        elif args.command == "invoice":
            status, result = api(origin, "POST", "/invoices", token, {"amount_sats": args.sats})
        else:
            feature = args.feature_file.read_text(encoding="utf-8")
            status, result = api(origin, "POST", "/execute_feature/", token, {
                "feature": feature, "concurrency": 1, "stream": False,
            }, timeout=180)
    print(json.dumps({"api": origin, "http_status": status, "response": result}, indent=2))
    if args.command == "run":
        return run_exit(status, result)
    return 0 if 200 <= status < 300 else 1


if __name__ == "__main__":
    try:
        sys.exit(main())
    except (ValueError, OSError, URLError, KeyError) as error:
        # Do not print request objects, passwords, tokens or untrusted response bodies.
        print(f"Stopped ({type(error).__name__}). Check inputs, credentials and node state. "
              "A submitted request may have completed; do not retry a payment or run blindly.",
              file=sys.stderr)
        sys.exit(1)
