#!/usr/bin/env python3
"""Minimal ACME v2 (Let's Encrypt) client — HTTP-01 only.

Places challenge files on the cPanel server via SSH, then prints
leaf cert, chain, and domain key to files in ``/tmp/le_quill/``.

Usage::

    python3 scripts/le_issue_quill.py

After the script completes, install the certificate via cPanel → SSL/TLS
or via the cPanel UAPI ``SSL::install_ssl`` endpoint.
"""
from __future__ import annotations

import base64
import hashlib
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa

DIR_URL = "https://acme-v02.api.letsencrypt.org/directory"
DOMAINS = ["quill.nx.kg", "www.quill.nx.kg"]
OUT = "/tmp/le_quill"
REMOTE_WEBROOT = "quill.nx.kg/.well-known/acme-challenge"  # under ~
SSH = ["ssh", "-o", "BatchMode=yes", "jnc"]


def _b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).decode().rstrip("=")


def _jwk_json(key: rsa.RSAPrivateKey) -> dict:
    nums = key.public_key().public_numbers()
    return {
        "e": _b64(nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")),
        "kty": "RSA",
        "n": _b64(nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big")),
    }


def _sign(payload_obj, key, nonce, url, kid=None, jwk=None) -> bytes:
    protected: dict = {"alg": "RS256", "nonce": nonce, "url": url}
    if kid:
        protected["kid"] = kid
    if jwk:
        protected["jwk"] = jwk
    payload = "" if payload_obj is None else _b64(json.dumps(payload_obj).encode())
    signing_input = f"{_b64(json.dumps(protected).encode())}.{payload}".encode()
    sig = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
    body = {
        "protected": _b64(json.dumps(protected).encode()),
        "payload": payload,
        "signature": _b64(sig),
    }
    return json.dumps(body).encode()


def _post(url, body: bytes, raw=False):
    req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/jose+json"})
    try:
        with urllib.request.urlopen(req) as r:
            data = r.read()
            return r.headers.get("Replay-Nonce"), r.status, (data if raw else json.loads(data or b"{}"))
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"POST {url} -> {e.code}: {e.read().decode()[:500]}") from e


def _get_nonce() -> str:
    req = urllib.request.Request(DIR_URLS["newNonce"], method="HEAD")
    with urllib.request.urlopen(req) as r:
        return r.headers["Replay-Nonce"]


def main() -> int:
    os.makedirs(OUT, exist_ok=True)
    log = lambda *a: print(*a, flush=True)

    # ── keys ──────────────────────────────────────────────────────────────
    acct_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    dom_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    acct_jwk = _jwk_json(acct_key)
    thumb = _b64(hashlib.sha256(json.dumps(acct_jwk, separators=(",", ":"), sort_keys=True).encode()).digest())

    global DIR_URLS
    with urllib.request.urlopen(DIR_URL) as r:
        DIR_URLS = json.loads(r.read())

    # ── account ───────────────────────────────────────────────────────────
    nonce = _get_nonce()
    nonce, _, acct = _post(
        DIR_URLS["newAccount"],
        _sign({"termsOfServiceAgreed": True}, acct_key, nonce, DIR_URLS["newAccount"], jwk=acct_jwk),
    )
    # Capture KID from the Location header
    req = urllib.request.Request(
        DIR_URLS["newAccount"],
        data=_sign(
            {"termsOfServiceAgreed": True, "onlyReturnExisting": True},
            acct_key, _get_nonce(), DIR_URLS["newAccount"], jwk=acct_jwk,
        ),
        headers={"Content-Type": "application/jose+json"},
    )
    with urllib.request.urlopen(req) as r:
        KID = r.headers["Location"]
    log("account:", KID)

    # ── order ─────────────────────────────────────────────────────────────
    req = urllib.request.Request(
        DIR_URLS["newOrder"],
        data=_sign(
            {"identifiers": [{"type": "dns", "value": d} for d in DOMAINS]},
            acct_key, _get_nonce(), DIR_URLS["newOrder"], kid=KID,
        ),
        headers={"Content-Type": "application/jose+json"},
    )
    try:
        with urllib.request.urlopen(req) as r:
            ORDER_URL = r.headers["Location"]
            order = json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"newOrder -> {e.code}: {e.read().decode()[:500]}") from e
    log("order status:", order["status"])

    # ── challenges ────────────────────────────────────────────────────────
    for authz_url in order["authorizations"]:
        nonce = _get_nonce()
        nonce, _, authz = _post(authz_url, _sign(None, acct_key, nonce, authz_url, kid=KID))
        domain = authz["identifier"]["value"]
        ch = next(c for c in authz["challenges"] if c["type"] == "http-01")
        keyauth = f"{ch['token']}.{thumb}"
        remote = f"{REMOTE_WEBROOT}/{ch['token']}"
        subprocess.run(
            SSH + [f"mkdir -p {REMOTE_WEBROOT} && printf '%s' '{keyauth}' > {remote} && chmod 644 {remote}"],
            check=True,
        )
        log(f"challenge placed for {domain}")
        # Tell LE to validate
        nonce = _get_nonce()
        nonce, _, _ = _post(ch["url"], _sign({}, acct_key, nonce, ch["url"], kid=KID))
        # Poll
        for _i in range(20):
            time.sleep(3)
            nonce = _get_nonce()
            nonce, _, authz = _post(authz_url, _sign(None, acct_key, nonce, authz_url, kid=KID))
            if authz["status"] == "valid":
                log(f"{domain}: VALID")
                break
            if authz["status"] == "invalid":
                raise RuntimeError(f"{domain} challenge INVALID: {json.dumps(authz)[:400]}")
        else:
            raise RuntimeError(f"{domain}: timed out waiting for validation")

    # ── finalize ──────────────────────────────────────────────────────────
    csr = (
        x509.CertificateSigningRequestBuilder()
        .subject_name(x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, DOMAINS[0])]))
        .add_extension(
            x509.SubjectAlternativeName([x509.DNSName(d) for d in DOMAINS]),
            critical=False,
        )
        .sign(dom_key, hashes.SHA256())
    )
    csr_der = csr.public_bytes(serialization.Encoding.DER)
    nonce = _get_nonce()
    nonce, _, order = _post(
        order["finalize"],
        _sign({"csr": _b64(csr_der)}, acct_key, nonce, order["finalize"], kid=KID),
    )
    for _i in range(20):
        if order.get("certificate"):
            break
        time.sleep(3)
        nonce = _get_nonce()
        nonce, _, order = _post(ORDER_URL, _sign(None, acct_key, nonce, ORDER_URL, kid=KID))
    else:
        raise RuntimeError("order never became valid")

    nonce = _get_nonce()
    nonce, _, chain_pem = _post(
        order["certificate"],
        _sign(None, acct_key, nonce, order["certificate"], kid=KID),
        raw=True,
    )
    chain = chain_pem.decode()

    # Split leaf / intermediates
    certs = [c + "-----END CERTIFICATE-----\n" for c in chain.split("-----END CERTIFICATE-----") if "BEGIN" in c]
    open(f"{OUT}/cert.pem", "w").write(certs[0])
    open(f"{OUT}/chain.pem", "w").write("".join(certs[1:]))
    open(f"{OUT}/fullchain.pem", "w").write(chain)
    open(f"{OUT}/domain.key", "wb").write(
        dom_key.private_bytes(
            serialization.Encoding.PEM,
            serialization.PrivateFormat.TraditionalOpenSSL,
            serialization.NoEncryption(),
        )
    )
    log(f"cert issued -> {OUT}")
    log(f"  cert.pem      — leaf certificate")
    log(f"  chain.pem     — intermediate chain")
    log(f"  fullchain.pem — leaf + chain")
    log(f"  domain.key    — private key")
    log(f"\nInstall via cPanel → SSL/TLS, or:")
    log(f"  cp {OUT}/fullchain.pem ~/ssl/  && cp {OUT}/domain.key ~/ssl/")
    return 0


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