// WeChat (personal) adapter via the iLink bot API.
//
// Mirrors Hermes `plugins/platforms/weixin/adapter.py`: all calls are JSON
// `POST`s to `https://ilinkai.weixin.qq.com/ilink/bot/…` carrying the
// `ilink_bot_token` authorization type, an `X-WECHAT-UIN` header and a
// `base_info.channel_version` field. Inbound traffic is long-polled from
// `ilink/bot/getupdates`; replies may quote the stored `context_token` for a
// session-accurate send.

///|
/// iLink app id (`"bot"`).
pub fn weixin_app_id(cfg : PlatformConfig) -> String {
  cfg.extra_str("app_id", "bot")
}

///|
/// Channel version advertised in `base_info`.
pub fn weixin_channel_version(cfg : PlatformConfig) -> String {
  cfg.extra_str("channel_version", "2.2.0")
}

///|
/// Client version integer (`(2<<16)|(2<<8)` = 131584).
pub fn weixin_client_version(cfg : PlatformConfig) -> String {
  cfg.extra_int("client_version", (2 << 16) | (2 << 8)).to_string()
}

///|
/// API root, overridable with `[weixin.extra] base_url`.
pub fn weixin_base_url(cfg : PlatformConfig) -> String {
  let base = cfg.extra_str("base_url", "https://ilinkai.weixin.qq.com")
  if base.has_suffix("/") {
    base[:base.length() - 1].to_owned()
  } else {
    base
  }
}

///|
/// iLink request headers.
pub fn weixin_headers(
  cfg : PlatformConfig,
  token : String?,
) -> Map[String, String] {
  let headers = json_headers()
  headers.set("AuthorizationType", "ilink_bot_token")
  headers.set("iLink-App-Id", weixin_app_id(cfg))
  headers.set("iLink-App-ClientVersion", weixin_client_version(cfg))
  headers.set("X-WECHAT-UIN", @base64.encode(@utf8.encode(uin_text())))
  match token {
    Some(t) => if !t.is_empty() { headers.set("Authorization", "Bearer \{t}") }
    None => ()
  }
  headers
}

///|
/// `base64(int(bytes4))` per the iLink spec.
fn uin_text() -> String {
  let rand = @random.Rand()
  rand.int(limit=65536).to_string()
}

///|
/// `item_list` entry for a text message (`ITEM_TEXT` = 1).
pub fn weixin_text_item(text : String) -> Json {
  json_obj([
    ("type", Json::number(1.0)),
    ("text_item", json_obj([("text", Json::string(text))])),
  ])
}

///|
/// Full `sendmessage` payload (message wrapper + `base_info`).
pub fn weixin_send_payload(
  cfg : PlatformConfig,
  to : String,
  text : String,
  context_token : String?,
) -> Json {
  let msg = json_obj([
    ("from_user_id", Json::string("")),
    ("to_user_id", Json::string(to)),
    ("client_id", Json::string(random_hex(16))),
    ("message_type", Json::number(2.0)),
    ("message_state", Json::number(2.0)),
    ("item_list", Json::array([weixin_text_item(text)])),
  ])
  let msg = obj_with_opt(msg, "context_token", context_token)
  json_obj([
    ("msg", msg),
    (
      "base_info",
      json_obj([("channel_version", Json::string(weixin_channel_version(cfg)))]),
    ),
  ])
}

///|
/// Map an iLink `{ret, errcode, errmsg}` envelope onto a `SendResult`.
fn weixin_result(j : Json) -> SendResult {
  let ret = json_field_int(j, "ret").unwrap_or(0)
  let errcode = json_field_int(j, "errcode").unwrap_or(0)
  if ret == 0 && errcode == 0 {
    return send_ok(None, j)
  }
  let msg = or_opt(json_field_string(j, "errmsg"), json_field_string(j, "msg")).unwrap_or(
    j.stringify(),
  )
  SendResult::failed(
    "weixin: ret=\{ret} errcode=\{errcode} \{msg}",
    retryable=ret == -14 ||
      errcode == -14 ||
      classify_send_error(msg) == RateLimited,
    kind=classify_send_error(msg),
    raw=j.stringify(),
  )
}

///|
/// Send ``content``, chunked at WeChat's 4000-char limit. ``context_token``
/// opts into session-accurate delivery for the same chat.
pub async fn weixin_send(
  cfg : PlatformConfig,
  chat_id : String,
  content : String,
  reply_to? : String,
  context_token? : String,
) -> SendResult {
  ignore(reply_to)
  match cfg.token_of() {
    None => missing_config(Weixin, "WEIXIN_TOKEN")
    Some(token) =>
      if content.is_empty() {
        return SendResult::ok()
      } else {
        let url = "\{weixin_base_url(cfg)}/ilink/bot/sendmessage"
        let mut last = SendResult::ok()
        for chunk in chunk_text(content, Platform::max_message_length(Weixin)) {
          let body = weixin_send_payload(cfg, chat_id, chunk, context_token)
          let r = send_with_retry(() => {
            match post_json(url, body, weixin_headers(cfg, Some(token))) {
              Err(e) => send_failure("weixin sendmessage", e)
              Ok(j) => weixin_result(j)
            }
          })
          if !r.success {
            return r
          }
          last = r
        }
        last
      }
  }
}

///|
/// Typing indicator (`ilink/bot/sendtyping`), failures ignored.
pub async fn weixin_typing(cfg : PlatformConfig, chat_id : String) -> Unit {
  match cfg.token_of() {
    None => ()
    Some(token) => {
      let url = "\{weixin_base_url(cfg)}/ilink/bot/sendtyping"
      let body = json_obj([
        ("to_user_id", Json::string(chat_id)),
        (
          "base_info",
          json_obj([
            ("channel_version", Json::string(weixin_channel_version(cfg))),
          ]),
        ),
      ])
      ignore(post_json(url, body, weixin_headers(cfg, Some(token))))
    }
  }
}

///|
/// Long-poll updates (`ilink/bot/getupdates`), surfacing failures so pollers
/// can back off. Returns the raw `msgs` array and the new sync buffer.
pub async fn weixin_get_updates_result(
  cfg : PlatformConfig,
  token : String,
  sync_buf : String,
) -> Result[(Array[Json], String), ApiError] {
  let url = "\{weixin_base_url(cfg)}/ilink/bot/getupdates"
  let body = json_obj([
    ("get_updates_buf", Json::string(sync_buf)),
    (
      "base_info",
      json_obj([("channel_version", Json::string(weixin_channel_version(cfg)))]),
    ),
  ])
  match post_json(url, body, weixin_headers(cfg, Some(token))) {
    Err(e) => Err(e)
    Ok(j) => {
      let msgs = json_field_array(j, "msgs").unwrap_or([])
      let buf = json_field_string(j, "get_updates_buf").unwrap_or(sync_buf)
      Ok((msgs, buf))
    }
  }
}

///|
/// Long-poll updates (`ilink/bot/getupdates`). Returns the raw `msgs` array
/// and the new sync buffer; failures yield an empty batch (see
/// `weixin_get_updates_result`).
pub async fn weixin_get_updates(
  cfg : PlatformConfig,
  token : String,
  sync_buf : String,
) -> (Array[Json], String) {
  match weixin_get_updates_result(cfg, token, sync_buf) {
    Ok(pair) => pair
    Err(_) => ([], sync_buf)
  }
}

///|
/// Parse an iLink update message (`{from_user_id, msg_id, item_list, …}`).
pub fn weixin_parse_message(msg : Json) -> MessageEvent? {
  let chat_id = or_opt(json_id(msg, "from_user_id"), json_id(msg, "chat_id")).unwrap_or(
    "",
  )
  if chat_id.is_empty() {
    return None
  }
  let text = weixin_item_text(msg)
  let media : Array[MediaAttachment] = []
  let mtype = if text.has_prefix("/") { Command } else { Text }
  Some(
    MessageEvent::new(
      text,
      mtype,
      Weixin,
      chat_id,
      user_id?=json_id(msg, "from_user_id"),
      message_id?=json_id(msg, "msg_id"),
      media~,
      timestamp=now_ms(),
    ),
  )
}

// ─────────────────────────────────────────────────────────────────────────────
// iLink gap closure — encrypted CDN media + QR login (Hermes parity:
// `_pkcs7_pad`/`_aes128_ecb_{en,de}crypt`, `_ITEM_SHAPES`, `_INBOUND_MEDIA`,
// `_download_and_decrypt_media`, `_media_item`, `qr_login`).
// ─────────────────────────────────────────────────────────────────────────────

///|
/// item_list entry types (Hermes `ITEM_TEXT..ITEM_VIDEO`).
const WX_ITEM_TEXT : Int32 = 1

///|
const WX_ITEM_IMAGE : Int32 = 2

///|
const WX_ITEM_VOICE : Int32 = 3

///|
const WX_ITEM_FILE : Int32 = 4

///|
const WX_ITEM_VIDEO : Int32 = 5

///|
/// getuploadurl `media_type` values (Hermes `MEDIA_IMAGE..MEDIA_VOICE`).
const WX_MEDIA_IMAGE : Int32 = 1

///|
const WX_MEDIA_VIDEO : Int32 = 2

///|
const WX_MEDIA_FILE : Int32 = 3

///|
const WX_MEDIA_VOICE : Int32 = 4

///|
/// iLink error codes (Hermes `SESSION_EXPIRED_ERRCODE` / `RATE_LIMIT_ERRCODE`).
const WX_SESSION_EXPIRED : Int32 = -14

///|
const WX_RATE_LIMIT : Int32 = -2

///|
/// Encrypted media CDN root (Hermes `WEIXIN_CDN_BASE_URL`).
const WX_CDN_DEFAULT : String = "https://novac2c.cdn.weixin.qq.com/c2c"

///|
/// CDN root, overridable via `[weixin.extra] cdn_base_url` (trailing `/` trimmed).
pub fn weixin_cdn_base_url(cfg : PlatformConfig) -> String {
  let base = cfg.extra_str("cdn_base_url", WX_CDN_DEFAULT)
  if base.has_suffix("/") {
    base[:base.length() - 1].to_owned()
  } else {
    base
  }
}

///|
/// WeChat CDN host allowlist (Hermes `_WEIXIN_CDN_ALLOWLIST`) — SSRF guard for
/// `full_url` downloads.
pub fn weixin_cdn_host_allowed(url : String) -> Bool {
  let hosts = [
    "novac2c.cdn.weixin.qq.com", "ilinkai.weixin.qq.com", "wx.qlogo.cn", "thirdwx.qlogo.cn",
    "res.wx.qq.com", "mmbiz.qpic.cn", "mmbiz.qlogo.cn",
  ]
  for h in hosts {
    if url.has_prefix("https://\{h}/") || url.has_prefix("http://\{h}/") {
      return true
    }
  }
  false
}

///|
/// CDN download URL for an encrypted media item.
pub fn weixin_media_download_url(
  cdn_base : String,
  encrypted_query_param : String,
) -> String {
  "\{cdn_base}/download?encrypted_query_param=\{url_quote(encrypted_query_param)}"
}

///|
/// CDN upload URL constructed from `upload_param` (Hermes fallback path).
pub fn weixin_media_upload_url(
  cdn_base : String,
  upload_param : String,
  filekey : String,
) -> String {
  "\{cdn_base}/upload?encrypted_query_param=\{url_quote(upload_param)}&filekey=\{url_quote(filekey)}"
}

///|
/// PKCS#7 padding to a 16-byte block boundary (Hermes `_pkcs7_pad`).
pub fn weixin_pkcs7_pad(data : Bytes) -> Bytes {
  let block = 16
  let pad_len = block - data.length() % block
  let out : Array[Byte] = []
  for i in 0.. Bytes {
  if data.length() == 0 {
    return data
  }
  let n = data.length()
  let pad = data.get(n - 1).unwrap_or(byte_of_int(0)).to_int64().to_int()
  if pad >= 1 && pad <= 16 && n >= pad {
    let mut ok = true
    for i in 0.. Result[Bytes, String] {
  let decoded = @base64.decode(key_b64) catch {
    _ => return Err("bad aes_key base64")
  }
  if decoded.length() == 16 {
    return Ok(decoded)
  }
  if decoded.length() == 32 {
    // Hermes decodes the 32 bytes as ASCII hex text (`_parse_aes_key`).
    let text = @utf8.decode(decoded[:]) catch {
      _ => return Err("aes_key hex not valid utf8")
    }
    return match hex_decode(text) {
      Some(b) => Ok(b)
      None => Err("aes_key hex decode failed")
    }
  }
  Err("unexpected aes_key length \{decoded.length()}")
}

///|
/// AES-128-ECB encrypt with PKCS#7 padding.
pub fn weixin_media_encrypt(plaintext : Bytes, key : Bytes) -> Bytes raise {
  @crypto.aes_ecb_encrypt(key[:], weixin_pkcs7_pad(plaintext)[:])
}

///|
/// AES-128-ECB decrypt then strip PKCS#7 padding.
pub fn weixin_media_decrypt(ciphertext : Bytes, key : Bytes) -> Bytes raise {
  weixin_pkcs7_unpad(@crypto.aes_ecb_decrypt(key[:], ciphertext[:]))
}

///|
/// `media_type` (getuploadurl) for a local file name (Hermes
/// `_outbound_media_builder`).
pub fn weixin_media_type_of(filename : String) -> Int32 {
  let lower = filename.to_lower()
  if lower.has_suffix(".jpg") ||
    lower.has_suffix(".jpeg") ||
    lower.has_suffix(".png") ||
    lower.has_suffix(".gif") ||
    lower.has_suffix(".webp") ||
    lower.has_suffix(".bmp") {
    WX_MEDIA_IMAGE
  } else if lower.has_suffix(".mp4") ||
    lower.has_suffix(".mov") ||
    lower.has_suffix(".webm") {
    WX_MEDIA_VIDEO
  } else if lower.has_suffix(".silk") {
    WX_MEDIA_VOICE
  } else {
    WX_MEDIA_FILE
  }
}

///|
/// Guess a MIME type from a file name.
pub fn weixin_mime_of(filename : String) -> String {
  let lower = filename.to_lower()
  if lower.has_suffix(".jpg") || lower.has_suffix(".jpeg") {
    "image/jpeg"
  } else if lower.has_suffix(".png") {
    "image/png"
  } else if lower.has_suffix(".gif") {
    "image/gif"
  } else if lower.has_suffix(".webp") {
    "image/webp"
  } else if lower.has_suffix(".mp4") {
    "video/mp4"
  } else if lower.has_suffix(".silk") {
    "audio/silk"
  } else if lower.has_suffix(".mp3") {
    "audio/mpeg"
  } else if lower.has_suffix(".pdf") {
    "application/pdf"
  } else if lower.has_suffix(".txt") {
    "text/plain"
  } else if lower.has_suffix(".zip") {
    "application/zip"
  } else {
    "application/octet-stream"
  }
}

///|
/// Describe an inbound media item →
/// `(kind, mime, filename, payload_key)` or `None` when not a media item.
pub fn weixin_media_descriptor(
  item : Json,
) -> (String, String, String, String)? {
  let t = json_field_int(item, "type").unwrap_or(0)
  let spec = match t {
    WX_ITEM_IMAGE => ("photo", "image/jpeg", "image.jpg", "image_item")
    WX_ITEM_VIDEO => ("video", "video/mp4", "video.mp4", "video_item")
    WX_ITEM_FILE => ("document", "", "document.bin", "file_item")
    WX_ITEM_VOICE => ("voice", "audio/silk", "voice.silk", "voice_item")
    _ => return None
  }
  let payload = json_field_obj(item, spec.3).unwrap_or(Json::null())
  let default_mime = spec.1
  let filename = or_opt(json_field_string(payload, "file_name"), Some(spec.2)).unwrap_or(
    spec.2,
  )
  let mime = if default_mime.is_empty() {
    weixin_mime_of(filename)
  } else {
    default_mime
  }
  Some((spec.0, mime, filename, spec.3))
}

///|
/// Map a previously-collected media kind list onto a `MessageType` (Hermes
/// `_message_type_from_media`): media wins over text/command classification.
pub fn weixin_message_type_from_kinds(kinds : Array[String]) -> MessageType {
  for k in kinds {
    match k {
      "photo" => return Photo
      "video" => return Video
      "voice" | "audio" => return Voice
      "document" | "file" => return Document
      _ => ()
    }
  }
  Text
}

///|
/// Extract text from a single item (text_item) — used for quoted messages.
pub fn weixin_text_of(item : Json) -> String {
  match json_field_obj(item, "text_item") {
    Some(t) => json_field_string(t, "text").unwrap_or("")
    None => ""
  }
}

///|
/// Concatenate the `text_item` entries of an iLink message (Hermes
/// `_extract_text`): first text item wins (quoting handled), then a voice-item
/// STT fallback for transcripts with no raw audio.
pub fn weixin_item_text(msg : Json) -> String {
  let items = json_field_array(msg, "item_list").unwrap_or([])
  for item in items {
    if json_field_int(item, "type").unwrap_or(0) == WX_ITEM_TEXT {
      let text = weixin_text_of(item)
      match json_field_obj(item, "ref_msg") {
        Some(quoted) => {
          let title = json_field_string(quoted, "title").unwrap_or("")
          match json_field_obj(quoted, "message_item") {
            Some(ri) => {
              let rt = json_field_int(ri, "type").unwrap_or(0)
              if rt == WX_ITEM_IMAGE ||
                rt == WX_ITEM_VIDEO ||
                rt == WX_ITEM_FILE ||
                rt == WX_ITEM_VOICE {
                let inner = if title.is_empty() {
                  "[引用媒体]"
                } else {
                  "[引用媒体: \{title}]"
                }
                return "\{inner}\n\{text}".trim().to_owned()
              }
              let parts : Array[String] = []
              if !title.is_empty() {
                parts.push(title)
              }
              let sub = weixin_text_of(ri)
              if !sub.is_empty() {
                parts.push(sub)
              }
              if parts.length() > 0 {
                return "[引用: \{parts.join(" | ")}]\n\{text}"
                  .trim()
                  .to_owned()
              }
              return text
            }
            None => return text
          }
        }
        None => return text
      }
    }
  }
  // Voice fallback: iLink STT transcript without raw audio (Hermes #27300).
  for item in items {
    if json_field_int(item, "type").unwrap_or(0) == WX_ITEM_VOICE {
      match json_field_obj(item, "voice_item") {
        Some(v) => {
          let has_media = match json_field_obj(v, "media") {
            Some(_) => true
            None => false
          }
          let voice_text = json_field_string(v, "text").unwrap_or("")
          if !has_media && !voice_text.is_empty() {
            return "[Voice transcription provided by Weixin]\n\{voice_text}"
          }
        }
        None => ()
      }
    }
  }
  ""
}

///|
/// Build an outbound media item (Hermes `_media_item` + `_ITEM_SHAPES`);
/// `aes_key` is shipped as base64(hex string), not base64(raw key).
pub fn weixin_media_item(
  media_type : Int32,
  encrypted_query_param : String,
  aes_key_hex : String,
  ciphertext_size : Int32,
  plaintext_size : Int32,
  filename : String,
  rawfilemd5 : String,
) -> Json {
  let media = json_obj([
    ("encrypt_query_param", Json::string(encrypted_query_param)),
    ("aes_key", Json::string(@base64.encode(@utf8.encode(aes_key_hex)))),
    ("encrypt_type", Json::number(1.0)),
  ])
  let (item_type, key, fields) : (Int32, String, Array[(String, Json)]) = match
    media_type {
    WX_MEDIA_IMAGE =>
      (
        WX_ITEM_IMAGE,
        "image_item",
        [
          ("media", media),
          ("mid_size", Json::number(ciphertext_size.to_double())),
        ],
      )
    WX_MEDIA_VIDEO =>
      (
        WX_ITEM_VIDEO,
        "video_item",
        [
          ("media", media),
          ("video_size", Json::number(ciphertext_size.to_double())),
          ("play_length", Json::number(0.0)),
          ("video_md5", Json::string(rawfilemd5)),
        ],
      )
    WX_MEDIA_FILE =>
      (
        WX_ITEM_FILE,
        "file_item",
        [
          ("media", media),
          ("file_name", Json::string(filename)),
          ("len", Json::string(plaintext_size.to_string())),
        ],
      )
    WX_MEDIA_VOICE =>
      (
        WX_ITEM_VOICE,
        "voice_item",
        [
          ("media", media),
          ("encode_type", Json::number(6.0)),
          ("bits_per_sample", Json::number(16.0)),
          ("sample_rate", Json::number(24000.0)),
          ("playtime", Json::number(0.0)),
        ],
      )
    _ => return json_obj([])
  }
  json_obj([
    ("type", Json::number(item_type.to_double())),
    (key, json_obj(fields)),
  ])
}

///|
/// Outbound `sendmessage` envelope for an arbitrary `item_list`.
fn weixin_items_payload(
  cfg : PlatformConfig,
  to : String,
  items : Array[Json],
  context_token : String?,
) -> Json {
  let msg = json_obj([
    ("from_user_id", Json::string("")),
    ("to_user_id", Json::string(to)),
    ("client_id", Json::string(random_hex(16))),
    ("message_type", Json::number(2.0)),
    ("message_state", Json::number(2.0)),
    ("item_list", Json::array(items)),
  ])
  let msg = obj_with_opt(msg, "context_token", context_token)
  json_obj([
    ("msg", msg),
    (
      "base_info",
      json_obj([("channel_version", Json::string(weixin_channel_version(cfg)))]),
    ),
  ])
}

///|
/// iLink stale-session variance of the -2 frequency limit (Hermes
/// `_is_stale_session_ret`).
pub fn weixin_is_stale_session(
  ret : Int32,
  errcode : Int32,
  errmsg : String,
) -> Bool {
  (ret == WX_RATE_LIMIT || errcode == WX_RATE_LIMIT) &&
  (
    errmsg.to_lower() == "unknown error" ||
    errmsg.to_lower() == "prepare failed"
  )
}

///|
/// A response that means the session context went stale (Hermes
/// `_is_session_expired`).
pub fn weixin_is_session_expired(
  ret : Int32,
  errcode : Int32,
  errmsg : String,
) -> Bool {
  ret == WX_SESSION_EXPIRED ||
  errcode == WX_SESSION_EXPIRED ||
  weixin_is_stale_session(ret, errcode, errmsg)
}

///|
/// `SendResult` whose raw payload is an expired-session iLink envelope.
fn weixin_is_expired_result(r : SendResult) -> Bool {
  match r.raw {
    None => false
    Some(raw) => {
      let j = @json.parse(raw) catch { _ => return false }
      weixin_is_session_expired(
        json_field_int(j, "ret").unwrap_or(0),
        json_field_int(j, "errcode").unwrap_or(0),
        or_opt(json_field_string(j, "errmsg"), json_field_string(j, "msg")).unwrap_or(
          "",
        ),
      )
    }
  }
}

///|
/// Upload one media file to the encrypted CDN and return the iLink item seed
/// (`encrypted_query_param`, `aes_key` hex, sizes, md5).
pub async fn weixin_upload_media(
  cfg : PlatformConfig,
  token : String,
  chat_id : String,
  data : Bytes,
  filename : String,
) -> Result[Json, String] {
  let media_type = weixin_media_type_of(filename)
  let rawfilemd5 = md5_hex(data)
  let rawsize = data.length()
  let aes_key = random_bytes(16)
  let ciphertext = weixin_media_encrypt(data, aes_key) catch {
    e => return Err("encrypt failed: \{e}")
  }
  let filesize = (rawsize + 15) / 16 * 16
  let aeskey_hex = hex_encode(aes_key)
  let filekey = random_hex(16)
  let payload = json_obj([
    ("filekey", Json::string(filekey)),
    ("media_type", Json::number(media_type.to_double())),
    ("to_user_id", Json::string(chat_id)),
    ("rawsize", Json::number(rawsize.to_double())),
    ("rawfilemd5", Json::string(rawfilemd5)),
    ("filesize", Json::number(filesize.to_double())),
    ("no_need_thumb", Json::boolean(true)),
    ("aeskey", Json::string(aeskey_hex)),
  ])
  let url = "\{weixin_base_url(cfg)}/ilink/bot/getuploadurl"
  let resp = match post_json(url, payload, weixin_headers(cfg, Some(token))) {
    Err(e) => return Err("getuploadurl failed: \{e.body}")
    Ok(j) => j
  }
  let upload_param = json_field_string(resp, "upload_param").unwrap_or("")
  let upload_full_url = json_field_string(resp, "upload_full_url").unwrap_or("")
  let upload_url = if !upload_full_url.is_empty() {
    upload_full_url
  } else if !upload_param.is_empty() {
    weixin_media_upload_url(weixin_cdn_base_url(cfg), upload_param, filekey)
  } else {
    return Err("getuploadurl returned neither upload_param nor upload_full_url")
  }
  let headers = json_headers()
  headers.set("Content-Type", "application/octet-stream")
  ignore(headers)
  let up = match post_octet(upload_url, ciphertext, headers) {
    Err(e) => return Err("cdn upload failed: \{e.body}")
    Ok(r) => r
  }
  if up.code < 200 || up.code >= 300 {
    return Err("cdn upload HTTP \{up.code}: \{up.body_bytes.to_string()}")
  }
  match up.headers.get(@http.CaseInsensitiveString("x-encrypted-param")) {
    Some(encrypted_param) =>
      Ok(
        json_obj([
          ("encrypted_query_param", Json::string(encrypted_param)),
          ("aes_key_hex", Json::string(aeskey_hex)),
          ("media_type", Json::number(media_type.to_double())),
          ("ciphertext_size", Json::number(ciphertext.length().to_double())),
          ("plaintext_size", Json::number(rawsize.to_double())),
          ("filename", Json::string(filename)),
          ("rawfilemd5", Json::string(rawfilemd5)),
        ]),
      )
    None => Err("cdn upload missing x-encrypted-param header")
  }
}

///|
/// Send one media file (image/video/file/voice by extension) with caption-less
/// delivery; stale sessions retry once without `context_token`.
pub async fn weixin_send_media(
  cfg : PlatformConfig,
  chat_id : String,
  data : Bytes,
  filename : String,
  context_token? : String,
) -> SendResult {
  match cfg.token_of() {
    None => missing_config(Weixin, "WEIXIN_TOKEN")
    Some(token) => {
      let seed = match
        weixin_upload_media(cfg, token, chat_id, data, filename) {
        Err(e) =>
          return SendResult::failed(
            "weixin media upload: \{e}",
            kind=Unknown,
            raw="",
          )
        Ok(j) => j
      }
      let item = weixin_media_item(
        json_field_int(seed, "media_type").unwrap_or(0),
        json_field_string(seed, "encrypted_query_param").unwrap_or(""),
        json_field_string(seed, "aes_key_hex").unwrap_or(""),
        json_field_int(seed, "ciphertext_size").unwrap_or(0),
        json_field_int(seed, "plaintext_size").unwrap_or(0),
        json_field_string(seed, "filename").unwrap_or("document.bin"),
        json_field_string(seed, "rawfilemd5").unwrap_or(""),
      )
      let mut ctx = context_token
      let mut attempt = 0
      while attempt < 2 {
        attempt += 1
        let url = "\{weixin_base_url(cfg)}/ilink/bot/sendmessage"
        let body = weixin_items_payload(cfg, chat_id, [item], ctx)
        let r = send_with_retry(() => {
          match post_json(url, body, weixin_headers(cfg, Some(token))) {
            Err(e) => send_failure("weixin sendmessage", e)
            Ok(j) => weixin_result(j)
          }
        })
        if r.success {
          return r
        }
        if ctx is Some(_) && weixin_is_expired_result(r) {
          ctx = None
          continue
        }
        return r
      }
      SendResult::failed("weixin sendmessage exhausted", kind=Transient, raw="")
    }
  }
}

///|
/// Download + decrypt one inbound media item into a `data:` URL attachment.
pub async fn weixin_download_media(
  cfg : PlatformConfig,
  item : Json,
) -> MediaAttachment? {
  match weixin_media_descriptor(item) {
    None => None
    Some((kind, mime, _filename, payload_key)) => {
      let payload = json_field_obj(item, payload_key).unwrap_or(Json::null())
      let media = json_field_obj(payload, "media").unwrap_or(Json::null())
      // image_item may carry a raw hex `aeskey` beside the media block
      let mut aes_key_b64 = json_field_string(media, "aes_key")
      match json_field_string(payload, "aeskey") {
        Some(hex) => aes_key_b64 = Some(@base64.encode(@utf8.encode(hex)))
        None => ()
      }
      let url = match json_field_string(media, "encrypt_query_param") {
        Some(p) => weixin_media_download_url(weixin_cdn_base_url(cfg), p)
        None =>
          match json_field_string(media, "full_url") {
            Some(u) => if weixin_cdn_host_allowed(u) { u } else { return None }
            None => return None
          }
      }
      let data = match get_bytes(url, json_headers()) {
        Err(_) => return None
        Ok(b) => b
      }
      let plain = match aes_key_b64 {
        Some(k) =>
          match weixin_aes_key(k) {
            Ok(key) =>
              weixin_media_decrypt(data, key) catch {
                _ => return None
              }
            Err(_) => data
          }
        None => data
      }
      Some({ url: data_url(mime, plain), kind, })
    }
  }
}

///|
/// Download + decrypt every media item (and quoted media) of an update message.
pub async fn weixin_media_attachments(
  cfg : PlatformConfig,
  msg : Json,
) -> Array[MediaAttachment] {
  let out : Array[MediaAttachment] = []
  for item in json_field_array(msg, "item_list").unwrap_or([]) {
    let candidates : Array[Json] = [item]
    match json_field_obj(item, "ref_msg") {
      Some(quoted) =>
        match json_field_obj(quoted, "message_item") {
          Some(mi) => candidates.push(mi)
          None => ()
        }
      None => ()
    }
    for c in candidates {
      match weixin_download_media(cfg, c) {
        Some(a) => out.push(a)
        None => ()
      }
    }
  }
  out
}

///|
/// Rebuild an event with media attachments and the media-derived message type.
pub fn weixin_with_media(
  ev : MessageEvent,
  media : Array[MediaAttachment],
) -> MessageEvent {
  let kinds : Array[String] = media.map(fn(att) { att.kind })
  MessageEvent::new(
    ev.text,
    weixin_message_type_from_kinds(kinds),
    ev.platform,
    ev.chat_id,
    user_id?=ev.user_id,
    user_name?=ev.user_name,
    message_id?=ev.message_id,
    reply_to_message_id?=ev.reply_to_message_id,
    reply_to_text?=ev.reply_to_text,
    media~,
    timestamp=ev.timestamp,
  )
}

///|
/// Fetch the QR login payload (`get_bot_qrcode`), keyed by ``bot_type``
/// (Hermes default `"3"`).
pub async fn weixin_get_qrcode(
  cfg : PlatformConfig,
  bot_type? : String,
) -> Json? {
  let bt = bot_type.unwrap_or("3")
  let url = "\{weixin_base_url(cfg)}/ilink/bot/get_bot_qrcode?bot_type=\{url_quote(bt)}"
  match get_json(url, weixin_headers(cfg, None)) {
    Ok(j) =>
      if json_field_string(j, "qrcode") is Some(_) {
        Some(j)
      } else {
        None
      }
    Err(_) => None
  }
}

///|
/// Poll QR login status (`get_qrcode_status?qrcode=…`).
pub async fn weixin_qr_status(cfg : PlatformConfig, qrcode : String) -> Json? {
  let url = "\{weixin_base_url(cfg)}/ilink/bot/get_qrcode_status?qrcode=\{url_quote(qrcode)}"
  match get_json(url, weixin_headers(cfg, None)) {
    Ok(j) => Some(j)
    Err(_) => None
  }
}

///|
/// Extract confirmed credentials from the QR status payload →
/// `{account_id, token, base_url, user_id}` or `None`.
pub fn weixin_qr_creds(status : Json) -> Json? {
  let account_id = json_field_string(status, "ilink_bot_id").unwrap_or("")
  let token = json_field_string(status, "bot_token").unwrap_or("")
  if account_id.is_empty() || token.is_empty() {
    return None
  }
  let base_url = or_opt(
    json_field_string(status, "baseurl"),
    Some(weixin_base_url_default()),
  ).unwrap_or("")
  Some(
    json_obj([
      ("account_id", Json::string(account_id)),
      ("token", Json::string(token)),
      ("base_url", Json::string(base_url)),
      (
        "user_id",
        Json::string(json_field_string(status, "ilink_user_id").unwrap_or("")),
      ),
    ]),
  )
}

///|
/// The default iLink base URL (`ilinkai.weixin.qq.com`).
pub fn weixin_base_url_default() -> String {
  "https://ilinkai.weixin.qq.com"
}

///|
/// Drive the QR login state machine (Hermes `qr_login`): prints the liteapp
/// URL via `on_qr`, polls until confirmed (persisting credentials via
/// `persist`), refreshes expired QRs, follows `scaned_but_redirect` hosts.
pub async fn weixin_qr_login(
  cfg : PlatformConfig,
  on_qr : (String) -> Unit,
  persist : (Json) -> Unit,
  timeout_seconds? : Int32,
) -> Json? {
  let deadline_ms = now_ms() + timeout_seconds.unwrap_or(480).to_int64() * 1000L
  let mut qrcode = ""
  let mut qr_url = ""
  let mut refresh = 0
  let mut base_cfg = cfg
  match weixin_get_qrcode(base_cfg) {
    Some(j) => {
      qrcode = json_field_string(j, "qrcode").unwrap_or("")
      qr_url = json_field_string(j, "qrcode_img_content").unwrap_or("")
    }
    None => ()
  }
  if qrcode.is_empty() {
    return None
  }
  on_qr(if qr_url.is_empty() { qrcode } else { qr_url })
  while now_ms() < deadline_ms {
    let status = match weixin_qr_status(base_cfg, qrcode) {
      Some(j) => j
      None => {
        @async.sleep(1000)
        continue
      }
    }
    let st = json_field_string(status, "status").unwrap_or("wait")
    if st == "wait" {
      @async.sleep(1000)
      continue
    }
    if st == "scaned" {
      @async.sleep(1000)
      continue
    }
    if st == "confirmed" {
      let creds = weixin_qr_creds(status)
      match creds {
        Some(c) => {
          persist(c)
          return Some(c)
        }
        None => return None
      }
    }
    if st == "scaned_but_redirect" {
      match json_field_string(status, "redirect_host") {
        Some(host) =>
          if !host.is_empty() {
            let extra : Map[String, String] = Map([
              ("base_url", "https://\{host}"),
            ])
            base_cfg = PlatformConfig::new(Weixin, extra~)
          }
        None => ()
      }
      @async.sleep(1000)
      continue
    }
    // "expired": refresh the QR a few times
    if refresh >= 3 {
      return None
    }
    refresh += 1
    base_cfg = cfg
    match weixin_get_qrcode(cfg) {
      Some(j) => {
        qrcode = json_field_string(j, "qrcode").unwrap_or("")
        qr_url = json_field_string(j, "qrcode_img_content").unwrap_or("")
        if qrcode.is_empty() {
          return None
        }
        on_qr(if qr_url.is_empty() { qrcode } else { qr_url })
      }
      None => return None
    }
    @async.sleep(1000)
  }
  None
}