// WeCom (Enterprise WeChat) adapter.
//
// Mirrors Hermes `plugins/platforms/wecom/adapter.py`: an access token is
// fetched from `cgi-bin/gettoken`, application messages go through
// `cgi-bin/message/send` (markdown for rich text), and inbound traffic arrives
// as a callback body — either the classic encrypted XML envelope (decrypted
// in-gateway when `encoding_aes_key` is configured, relay-decrypted otherwise)
// or the newer JSON `aibot` shape.

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

///|
/// Corp id (`WECOM_CALLBACK_CORP_ID`).
pub fn wecom_corp_id(cfg : PlatformConfig) -> String {
  cfg.extra_str("corp_id", "")
}

///|
/// Corp secret (`WECOM_CALLBACK_CORP_SECRET`), also accepted as `token`.
pub fn wecom_corp_secret(cfg : PlatformConfig) -> String? {
  match cfg.extra_first(["corp_secret"]) {
    Some(s) => Some(s)
    None => cfg.token_of()
  }
}

///|
/// Agent id (`agentid`) required by `message/send`.
pub fn wecom_agent_id(cfg : PlatformConfig) -> Int32? {
  match cfg.extra_first(["agent_id", "agentid"]) {
    Some(s) => Some(@string.parse_int(s)) catch { _ => None }
    None => None
  }
}

///|
/// Callback token used to build/check `msg_signature`
/// (`WECOM_CALLBACK_TOKEN` / `[wecom.extra] callback_token`).
pub fn wecom_callback_token(cfg : PlatformConfig) -> String {
  cfg.extra_str("callback_token", "")
}

///|
/// Callback encryption key (`WECOM_CALLBACK_ENCODING_AES_KEY` /
/// `[wecom.extra] encoding_aes_key`): 43 base64 characters decoding to 32 AES
/// key bytes (the first 16 of which double as the CBC IV).
pub fn wecom_encoding_aes_key(cfg : PlatformConfig) -> String {
  cfg.extra_str("encoding_aes_key", "")
}

///|
/// Cached access tokens keyed by corp id.
let wecom_token_cache : Map[String, String] = Map([])

///|
/// Fetch (and cache) a WeCom access token.
pub async fn wecom_access_token(cfg : PlatformConfig) -> String? {
  let corp = wecom_corp_id(cfg)
  let secret = match wecom_corp_secret(cfg) {
    Some(s) => s
    None => return None
  }
  if corp.is_empty() {
    return None
  }
  match wecom_token_cache.get(corp) {
    Some(t) => return Some(t)
    None => ()
  }
  let url = "\{wecom_api_base(cfg)}/cgi-bin/gettoken?corpid=\{url_quote(corp)}&corpsecret=\{url_quote(secret)}"
  match get_json(url, json_headers()) {
    Err(_) => None
    Ok(j) =>
      match json_field_string(j, "access_token") {
        Some(t) => {
          wecom_token_cache.set(corp, t)
          Some(t)
        }
        None => None
      }
  }
}

///|
/// `message/send` payload.
pub fn wecom_send_payload(
  to : String,
  content : String,
  agent_id : Int32?,
  markdown : Bool,
) -> Json {
  let payload = json_obj([
    ("touser", Json::string(to)),
    ("msgtype", Json::string(if markdown { "markdown" } else { "text" })),
  ])
  let payload = if markdown {
    if payload is Object(m) {
      m.set("markdown", json_obj([("content", Json::string(content))]))
    }
    payload
  } else {
    if payload is Object(m) {
      m.set("text", json_obj([("content", Json::string(content))]))
    }
    payload
  }
  match agent_id {
    Some(a) =>
      if payload is Object(m) {
        m.set("agentid", Json::number(a.to_double()))
        m.set("safe", Json::number(0.0))
      }
    None => ()
  }
  payload
}

///|
/// Map a WeCom `{errcode, errmsg}` response onto a `SendResult`.
fn wecom_result(j : Json) -> SendResult {
  match json_field_int(j, "errcode") {
    Some(0) => send_ok(None, j)
    Some(code) => {
      let msg = json_field_string(j, "errmsg").unwrap_or(j.stringify())
      SendResult::failed(
        "wecom: \{msg} (code \{code})",
        retryable=code == 45009 || code == -1,
        kind=classify_send_error(msg),
        raw=j.stringify(),
      )
    }
    None => send_ok(None, j)
  }
}

///|
/// Send ``content`` to a user or party (`@all` for everyone).
pub async fn wecom_send(
  cfg : PlatformConfig,
  chat_id : String,
  content : String,
  reply_to? : String,
) -> SendResult {
  ignore(reply_to)
  match wecom_access_token(cfg) {
    None =>
      missing_config(
        Wecom,
        "WECOM_CALLBACK_CORP_ID / WECOM_CALLBACK_CORP_SECRET",
      )
    Some(token) =>
      if content.is_empty() {
        return SendResult::ok()
      } else {
        let url = "\{wecom_api_base(cfg)}/cgi-bin/message/send?access_token=\{url_quote(token)}"
        let markdown = cfg.extra_bool("markdown", true)
        let agent = wecom_agent_id(cfg)
        let mut last = SendResult::ok()
        for chunk in chunk_text(content, Platform::max_message_length(Wecom)) {
          let body = wecom_send_payload(chat_id, chunk, agent, markdown)
          let r = send_with_retry(() => {
            match post_json(url, body, json_headers()) {
              Err(e) => send_failure("wecom", e)
              Ok(j) => wecom_result(j)
            }
          })
          if !r.success {
            return r
          }
          last = r
        }
        last
      }
  }
}

///|
/// Extract the text of the first ``tag`` element in an XML document.
pub fn xml_text(xml : String, tag : String) -> String? {
  let open = "<\{tag}>"
  let close = ""
  match xml.find(open) {
    Some(i) => {
      let rest = xml[i + open.length():].to_owned()
      match rest.find(close) {
        Some(j) => Some(strip_cdata(rest[:j].to_owned()))
        None => None
      }
    }
    None => None
  }
}

///|
/// Strip a surrounding `` wrapper.
pub fn strip_cdata(s : String) -> String {
  let t = if s.has_prefix("") {
    t[:t.length() - 3].to_owned()
  } else {
    t
  }
}

///|
/// Parse a WeCom callback (classic XML or the newer JSON `aibot` shape).
pub fn wecom_parse_callback(body : String) -> Array[MessageEvent] {
  let out : Array[MessageEvent] = []
  if body.has_prefix("{") {
    let j = @json.parse(body) catch { _ => return out }
    let chat_id = or_opt(json_id(j, "chatid"), json_field_string(j, "from"))
    let chat_id = match chat_id {
      Some(c) => c
      None =>
        match json_field_obj(j, "from") {
          Some(f) => json_id(f, "userid").unwrap_or("")
          None => ""
        }
    }
    if chat_id.is_empty() {
      return out
    }
    let text = match json_field_obj(j, "text") {
      Some(t) => json_field_string(t, "content").unwrap_or("")
      None => json_field_string(j, "content").unwrap_or("")
    }
    let mtype = if text.has_prefix("/") { Command } else { Text }
    out.push(
      MessageEvent::new(
        text,
        mtype,
        Wecom,
        chat_id,
        user_id?=json_field_string(j, "from"),
        message_id?=json_field_string(j, "msgid"),
        media=[],
        timestamp=now_ms(),
      ),
    )
    return out
  }
  let from = xml_text(body, "FromUserName").unwrap_or("")
  if from.is_empty() {
    return out
  }
  let chat_id = match xml_text(body, "ChatId") {
    Some(c) => if c.is_empty() { from } else { c }
    None => from
  }
  let text = or_opt(xml_text(body, "Content"), xml_text(body, "Recognition")).unwrap_or(
    "",
  )
  let mtype = if text.has_prefix("/") { Command } else { Text }
  out.push(
    MessageEvent::new(
      text,
      mtype,
      Wecom,
      chat_id,
      user_id=from,
      message_id?=xml_text(body, "MsgId"),
      media=[],
      timestamp=now_ms(),
    ),
  )
  out
}

// ---------------------------------------------------------------------------
// Callback crypto: `msg_signature` + AES-256-CBC, parity with Hermes
// `plugins/platforms/wecom/wecom_crypto.py` (Tencent's official
// `WXBizMsgCrypt`).

///|
/// Query parameters of a WeCom callback request (percent-decoded); the GET
/// URL-verification handshake additionally carries `echostr`.
pub(all) struct WecomCallbackParams {
  msg_signature : String
  timestamp : String
  nonce : String
  echostr : String?
}

///|
/// Parse `msg_signature` / `timestamp` / `nonce` / `echostr` from a callback
/// path's query string.
pub fn wecom_callback_params(path : String) -> WecomCallbackParams {
  let query = match path.split_once("?") {
    Some((_, q)) => q.to_owned()
    None => ""
  }
  let echostr = match query_param(query, "echostr") {
    Some(e) => if e.is_empty() { None } else { Some(e) }
    None => None
  }
  {
    msg_signature: query_param(query, "msg_signature").unwrap_or(""),
    timestamp: query_param(query, "timestamp").unwrap_or(""),
    nonce: query_param(query, "nonce").unwrap_or(""),
    echostr,
  }
}

///|
/// Raw AES key bytes from the 43-char `encoding_aes_key` (Hermes appends `=`
/// before base64-decoding). `None` unless exactly 32 bytes come out.
pub fn wecom_aes_key(encoding_aes_key : String) -> Bytes? {
  let decoded = @base64.decode("\{encoding_aes_key}=") catch {
    _ => return None
  }
  if decoded.length() == 32 {
    Some(decoded)
  } else {
    None
  }
}

///|
/// PKCS#7 pad to the 32-byte WeCom block boundary (Hermes `PKCS7Encoder`).
/// A payload already aligned to 32 still gains a full block.
pub fn wecom_pkcs7_pad(data : Bytes) -> Bytes {
  let block = 32
  let pad_len = block - data.length() % block
  let out : Array[Byte] = []
  for i in 0.. Bytes? {
  let n = data.length()
  if n == 0 {
    return None
  }
  let pad = data.get(n - 1).unwrap_or(byte_of_int(0)).to_int64().to_int()
  if pad < 1 || pad > 32 || n < pad {
    return None
  }
  for i in 0.. Int32 {
  data.get(i).unwrap_or(byte_of_int(0)).to_int64().to_int()
}

///|
/// WeCom callback signature (Hermes `_sha1_signature`): SHA1 over the four
/// parts sorted lexicographically and concatenated, lowercase hex.
pub fn wecom_msg_signature(
  token : String,
  timestamp : String,
  nonce : String,
  encrypt : String,
) -> String {
  let parts = [token, timestamp, nonce, encrypt]
  parts.sort_by(fn(a, b) { a.lexical_compare(b) })
  hex_encode(Bytes::from_array(@crypto.sha1(@utf8.encode(parts.join("")))[:]))
}

///|
/// Constant-time `msg_signature` check.
pub fn wecom_verify_msg_signature(
  token : String,
  timestamp : String,
  nonce : String,
  encrypt : String,
  msg_signature : String,
) -> Bool {
  constant_time_eq(
    wecom_msg_signature(token, timestamp, nonce, encrypt),
    msg_signature,
  )
}

///|
/// Decrypt one encrypted WeCom envelope (Hermes `WXBizMsgCrypt.decrypt`
/// without the signature step): base64 → AES-256-CBC (IV = first 16 key
/// bytes) → strip 32-byte PKCS#7 → skip the 16-byte random prefix → 4-byte
/// big-endian length → message → the trailing `receive_id` must match.
/// `None` on any malformed input or mismatch.
pub fn wecom_decrypt_envelope(
  encoding_aes_key : String,
  receive_id : String,
  encrypt : String,
) -> String? {
  let key = match wecom_aes_key(encoding_aes_key) {
    Some(k) => k
    None => return None
  }
  let cipher = @base64.decode(encrypt) catch { _ => return None }
  let plain = @crypto.aes_cbc_decrypt(
    key[:],
    key.exact_view(start=0, end=16),
    cipher[:],
  ) catch {
    _ => return None
  }
  let unpadded = match wecom_pkcs7_unpad(plain) {
    Some(d) => d
    None => return None
  }
  if unpadded.length() < 20 {
    return None
  }
  let msg_len = (wecom_byte_at(unpadded, 16) << 24) |
    (wecom_byte_at(unpadded, 17) << 16) |
    (wecom_byte_at(unpadded, 18) << 8) |
    wecom_byte_at(unpadded, 19)
  if msg_len < 0 || 20 + msg_len > unpadded.length() {
    return None
  }
  let msg = unpadded.exact_view(start=20, end=20 + msg_len).to_owned()
  let tail = unpadded
    .exact_view(start=20 + msg_len, end=unpadded.length())
    .to_owned()
  let recv = @utf8.decode(tail[:]) catch { _ => return None }
  if recv != receive_id {
    return None
  }
  let text = @utf8.decode(msg[:]) catch { _ => return None }
  Some(text)
}

///|
/// Verify `msg_signature` and decrypt one encrypted WeCom payload (Hermes
/// `WXBizMsgCrypt.decrypt`). `None` on a signature mismatch or any
/// decryption/correspondence failure.
pub fn wecom_decrypt_message(
  token : String,
  encoding_aes_key : String,
  receive_id : String,
  msg_signature : String,
  timestamp : String,
  nonce : String,
  encrypt : String,
) -> String? {
  if !wecom_verify_msg_signature(
      token, timestamp, nonce, encrypt, msg_signature,
    ) {
    return None
  }
  wecom_decrypt_envelope(encoding_aes_key, receive_id, encrypt)
}

///|
/// Encrypt one plaintext into the `……` callback
/// envelope (Hermes `WXBizMsgCrypt.encrypt`): 16 random bytes, the UTF-8
/// length in 4 big-endian bytes, the message, then `receive_id`, PKCS#7
/// padded to 32 and AES-256-CBC encrypted with `iv = key[:16]`.
pub fn wecom_encrypt_message(
  token : String,
  encoding_aes_key : String,
  receive_id : String,
  plaintext : String,
  timestamp : String,
  nonce : String,
) -> String? {
  let key = match wecom_aes_key(encoding_aes_key) {
    Some(k) => k
    None => return None
  }
  let msg = @utf8.encode(plaintext)
  let recv = @utf8.encode(receive_id)
  let payload : Array[Byte] = []
  let prefix = random_bytes(16)
  for i in 0..<16 {
    payload.push(prefix.get(i).unwrap_or(byte_of_int(0)))
  }
  let n = msg.length()
  payload.push(byte_of_int((n >> 24) & 255))
  payload.push(byte_of_int((n >> 16) & 255))
  payload.push(byte_of_int((n >> 8) & 255))
  payload.push(byte_of_int(n & 255))
  for i in 0.. return None
  }
  let encrypt = @base64.encode(encrypted)
  let signature = wecom_msg_signature(token, timestamp, nonce, encrypt)
  Some(
    "\{timestamp}",
  )
}

///|
/// Decrypt one inbound WeCom callback body when callback crypto is configured
/// (`encoding_aes_key` set): extract the `` element and decrypt it
/// against `corp_id`. Without an AES key the body passes through unchanged
/// (relay mode, where an upstream relay already decrypted it).
pub fn wecom_unwrap_callback(
  cfg : PlatformConfig,
  raw : String,
) -> Result[String, String] {
  let aes_key = wecom_encoding_aes_key(cfg)
  if aes_key.is_empty() {
    return Ok(raw)
  }
  match xml_text(raw, "Encrypt") {
    None => Err("missing  element")
    Some(encrypt) =>
      match wecom_decrypt_envelope(aes_key, wecom_corp_id(cfg), encrypt) {
        Some(xml) => Ok(xml)
        None => Err("decryption failed (bad key, padding or corp_id)")
      }
  }
}