/* mchat 数据层 —— 封装 tinode-sdk，向 UI 暴露干净的事件接口。
   依赖：window.tinode（UMD 暴露 {Tinode, Drafty, AccessMode}）。
   设计：纯事件回调，不碰 DOM；UI 层订阅这些回调渲染。 */
(function (global) {
  'use strict';
  const { Tinode, Drafty } = global.tinode;

  // 端点：默认跟随当前 /app/ 域名；file:// 离线副本回退到旧生产域名。
  const HOST = global.MCHAT_HOST || ((global.location && global.location.host) ? global.location.host + '/tinode' : 'morewater.top/tinode');
  const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
  const REVIEW_GROUP = 'grp2CG5jbn4TRI';

  class MchatClient {
    constructor() {
      this.tinode = null;
      this.me = null;
      this.uid = null;
      this.uname = null;
      this.topics = new Map();   // name -> 订阅过的 Topic 对象
      this.cb = {};              // 事件名 -> [handler]
      this._devId = this._loadDevId();   // 本设备稳定标识：消息打 head.mchatDev → 接收端判断是不是"我另一台设备"发来的
      this._probeTimer = null;
      this._resumeTimer = null;
      this._resuming = false;
      this._resumeTries = 0;
    }

    // 每个安装一个稳定随机设备号(localStorage)。用于"文件传输助手"等自发自收场景区分发送设备：
    // 同一台设备发的(本机回显)不提示，另一台设备发来的才提示/计未读。
    _loadDevId() {
      try {
        let id = localStorage.getItem('mchat.devid');
        if (!id) { id = 'd' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); localStorage.setItem('mchat.devid', id); }
        return id;
      } catch (e) { return 'd' + Math.random().toString(36).slice(2, 10); }
    }
    devId() { return this._devId; }

    on(ev, fn) { (this.cb[ev] = this.cb[ev] || []).push(fn); return this; }
    _emit(ev, ...a) { (this.cb[ev] || []).forEach(fn => { try { fn(...a); } catch (e) { console.error('[cb ' + ev + ']', e); } }); }

    /* ---- 连接 / 登录 ---- */
    _newTinode() {
      const t = new Tinode({
        appName: 'mchat/0.1 (' + (global.MCHAT_PLATFORM || 'web') + ')',
        host: HOST, apiKey: API_KEY, secure: true, transport: 'ws',
        // 上报真实平台,让服务端按 ios/android 走对应推送(apns/fcm);桌面/网页留空=默认 web。
        // iOS WKWebView 不设的话默认 "web" → 服务端走 webpush 不发 apns → iOS 收不到离线推送。
        platform: (global.MCHAT_PLATFORM === 'ios' || global.MCHAT_PLATFORM === 'android') ? global.MCHAT_PLATFORM : undefined,
      }, () => {});
      t.onConnect = () => { this._clearResumeTimer(); this._resuming = false; this._resumeTries = 0; this._startProbe(); this._emit('connect'); this._maybeRelogin(); };
      t.onDisconnect = (err) => { this._stopProbe(); this._emit('disconnect', err); };
      return t;
    }

    _startProbe() {
      this._stopProbe();
      this._probeTimer = setInterval(() => {
        try {
          if (!this._paused && this.tinode && this.tinode.isConnected()) this.tinode.networkProbe();
        } catch (e) {}
      }, 60000);
    }

    _stopProbe() {
      if (this._probeTimer) clearInterval(this._probeTimer);
      this._probeTimer = null;
    }

    _clearResumeTimer() {
      if (this._resumeTimer) clearTimeout(this._resumeTimer);
      this._resumeTimer = null;
    }

    // 断线自动重连后（SDK 重连 socket），若已登录过则用 token 重登录并恢复订阅
    _maybeRelogin() {
      if (this._paused) return;   // 切后台暂停期间不自动重登,保持"离线"让新消息走推送
      if (!this._wasLoggedIn) return;
      const tok = this._authToken;
      let authed = false;
      try { authed = this.tinode.isAuthenticated && this.tinode.isAuthenticated(); } catch (e) {}
      if (authed || !tok) return;
      this.tinode.loginToken(tok.token).then(() => {
        this._persistToken();                       // ★续期:重连登录也存回新 token,有效期滚动前移
        this._afterLogin();                         // 重订阅 me / 联系人 / presence
        if (this._activeTopic) { this._activeTopicGot(); }
        this._emit('reconnected');
      }).catch((e) => {
        // token 过期/失效：清登录态、弹登录框（否则卡在"已连接"却所有发送都 401）
        if (e && (e.code === 401 || e.code === 403)) { this._clearAuthAndNotify(e); }
      });
    }

    // 存下当前有效 token(Tinode 每次登录都会发新 token、有效期前移)→ 只要客户端在有效期内连过一次,token 就永续,不再每 ~14 天过期掉线
    _persistToken() {
      try {
        const tok = this.tinode && this.tinode.getAuthToken && this.tinode.getAuthToken();
        if (tok && tok.token) { this._authToken = tok; localStorage.setItem('mchat.token', JSON.stringify(tok)); }
      } catch (e) {}
    }
    // token 失效:清本地登录态 + 通知 UI 切回登录页(避免顶着旧缓存界面、所有请求 401 却不提示重登)
    _clearAuthAndNotify(e) {
      try { localStorage.removeItem('mchat.token'); localStorage.removeItem('mchat.uname'); } catch (_) {}
      this._authToken = null; this._wasLoggedIn = false;
      this._emit('authExpired', e);
    }

    async connectAndLogin(uname, password) {
      this.tinode = this._newTinode();
      // 连接+登录对瞬时网络抖动（SSL net_error -100 等）重试，避免假性登录失败
      let ctrl, lastErr;
      for (let attempt = 0; attempt < 3; attempt++) {
        try {
          if (!this.tinode.isConnected()) await this.tinode.connect();
          ctrl = await this.tinode.loginBasic(uname, password);
          lastErr = null; break;
        } catch (e) {
          lastErr = e;
          // 鉴权失败(401/403)不重试；网络/连接类才重试
          const code = e && e.code;
          if (code === 401 || code === 403) break;
          await new Promise(r => setTimeout(r, 700 * (attempt + 1)));
        }
      }
      if (lastErr) throw (lastErr.message ? lastErr : new Error('连接服务器失败，请重试'));
      this.uid = this.tinode.getCurrentUserID();
      this.uname = uname;
      this._wasLoggedIn = true;
      this._afterLogin();
      // 记住 token，便于重连免密
      try {
        const tok = this.tinode.getAuthToken();
        if (tok) { this._authToken = tok; localStorage.setItem('mchat.token', JSON.stringify(tok)); }
        localStorage.setItem('mchat.uname', uname);
      } catch (e) {}
      return ctrl;
    }

    // 自助注册:建账号+加入审核队列;管理员审核通过=拉进公司群
    async register(uname, password, fn) {
      const t = this._newTinode();
      await t.connect();
      try {
        const ctrl = await t.createAccountBasic(uname, password, { public: { fn: fn || uname }, tags: ['mchat', 'mchat-pending', uname] });
        const uid = (ctrl.params && ctrl.params.user) || t.getCurrentUserID();
        try {
          const q = t.getTopic(REVIEW_GROUP);
          await q.subscribe();
          try { await q.leave(false); } catch (_) {}
        } catch (e) {
          console.warn('[register review queue]', e && e.message);
        }
        return uid;
      } finally { try { t.disconnect(); } catch (_) {} }
    }

    async connectWithToken() {
      const raw = localStorage.getItem('mchat.token');
      if (!raw) return false;
      let tok;
      try { tok = JSON.parse(raw); } catch (e) { return false; }
      if (!tok || (tok.expires && new Date(tok.expires) < new Date())) { this._clearAuthAndNotify(); return false; }   // 本地已判过期 → 清态+切登录页
      this.tinode = this._newTinode();
      await this.tinode.connect();
      this.tinode.setAuthToken(tok);
      try {
        await this.tinode.loginToken(tok.token);
      } catch (e) {
        if (e && (e.code === 401 || e.code === 403)) { this._clearAuthAndNotify(e); return false; }   // token 服务端失效 → 弹登录页(不再顶着旧缓存界面所有请求 401)
        throw e;   // 网络/连接类错误照旧上抛,由上层缓存兜底/重试
      }
      this.uid = this.tinode.getCurrentUserID();
      this.uname = localStorage.getItem('mchat.uname') || this.uid;
      this._wasLoggedIn = true;
      this._persistToken();   // ★续期:存下本次登录返回的新 token,有效期滚动前移 → 常用客户端永不过期
      this._afterLogin();
      return true;
    }

    logout() {
      try { localStorage.removeItem('mchat.token'); } catch (e) {}
      this._stopProbe();
      if (this.tinode) this.tinode.disconnect();
    }

    /* ---- 登录后：订阅 me，拉会话/联系人/在线 ---- */
    // 移动端切后台:主动断开 Tinode → 立刻"离线"→ 新消息走推送(像微信);回前台 resume 重连并取回漏掉的消息。
    pause() { this._paused = true; this._clearResumeTimer(); this._stopProbe(); try { if (this.tinode) this.tinode.disconnect(); } catch (e) {} }
    // 回前台重连。旧版只 connect 一次:iOS 回前台头 1-2 秒网络未就绪则失败躺平,"经常连不上"主因之一。
    // 现在:① 失败按 0.6s→1.2s→…→8s 退避重试最多 6 次;② isConnected 为真也发探活包(iOS 挂起后 socket
    // 常已死但状态还是 OPEN=僵尸连接,探活会触发 onDisconnect → 走重连),不再"看着在线却收发不了"。
    resume() {
      this._paused = false;
      this._resumeTries = 0;
      this._clearResumeTimer();
      if (!this._resuming) this._tryResume();
    }
    _tryResume() {
      this._clearResumeTimer();
      if (this._paused || !this.tinode) return;
      let connected = false; try { connected = this.tinode.isConnected(); } catch (e) {}
      if (connected) { try { this.tinode.networkProbe(); } catch (e) {} return; }
      if (this._resuming) return;
      this._resuming = true;
      Promise.resolve().then(() => this.tinode.connect())
        .then(() => { this._clearResumeTimer(); this._resuming = false; this._resumeTries = 0; })   // connect → onConnect → _maybeRelogin 自动重登+重订阅
        .catch(() => {
          this._resuming = false;
          if (this._paused) return;
          const n = this._resumeTries = (this._resumeTries || 0) + 1;
          if (n <= 6) this._resumeTimer = setTimeout(() => { this._resumeTimer = null; this._tryResume(); }, Math.min(8000, 600 * Math.pow(2, n - 1)));
        });
    }
    // 移动端离线推送:原生壳拿到 FCM token 经 window.mchatSetPushToken 传入 → 上报服务端。
    // 上报需已登录(setDeviceToken 走 me topic),故存下,登录/重连后由 _afterLogin 补发。
    setDeviceToken(token) { if (!token) return; this._pushToken = token; this._applyPushToken(); }
    _applyPushToken() {
      if (!this._pushToken || !this.tinode || !this.uid) return;
      try { this.tinode.setDeviceToken(this._pushToken); } catch (e) {}
    }
    _afterLogin() {
      this._emit('contactsReset');   // 每轮登录先重置 UI 的服务器 topic 集合，待本轮 meta 完整后再对账
      const me = this.me = this.tinode.getMeTopic();
      this._applyPushToken();   // 已有待上报的推送 token → 登录后补发
      me.onMetaSub = (sub) => this._emit('contact', this._normSub(sub));
      me.onPres = (pres) => this._emit('pres', pres);           // {what:'on'|'off'|'msg'..., src}
      me.onSubsUpdated = () => {
        const liveTopics = [];
        let n = 0; try { me.contacts((sub) => { const topic = sub && (sub.topic || sub.user); n++; if (topic) liveTopics.push(topic); }); } catch (e) {}   // 完整快照防重连只下发增量 meta 时误删正常会话
        this._emit('contactsDone', liveTopics);
        this._emit('sessionCount', n);
      };
      me.subscribe(me.startMetaQuery().withLaterSub().withLaterDesc().build())
        .then(() => {
          try { this.myName = (me.public && me.public.fn) || this.uname; } catch (e) { this.myName = this.uname; }
          try { this.myPhoto = me.public && me.public.photo ? this._photoUrl(me.public.photo) : null; } catch (e) { this.myPhoto = null; }   // 自己上传的头像(含GIF)登录即恢复,不再只在设置那次会话可见
          this._emit('ready');
          this.ensureHelper().catch(() => {});   // 登录后确保本人有一个私密"文件传输助手"(手机↔电脑自同步)
        })
        .catch(e => this._emit('error', e));
    }

    // 确保当前用户有且仅有一个私密"文件传输助手"(单人群,defacs 仅邀请可入=别人加不进)。幂等。
    // 放在 ready 后调用(此时 me 的订阅列表已加载),先查现有再决定是否新建,避免重复创建。
    async ensureHelper() {
      if (this._helperEnsured) return this._helperTopic;
      const HELPER_FN = '文件传输助手';
      const scan = () => { let f = null; try { this.me.contacts(c => { if (!f && c && c.topic && c.topic[0] === 'g' && c.public && c.public.fn === HELPER_FN) f = c.topic; }); } catch (e) {} return f; };
      // 1) 先扫已加载的;实测 me.subscribe().then() 之后联系人尚未到位,故没找到就显式 getMeta(withSub)
      //    等完整订阅列表回来再判 —— 杜绝"联系人没加载完→误判没有→给已有助手的人重复建一个"。
      let found = scan();
      if (!found) { try { await this.me.getMeta(this.me.startMetaQuery().withSub().build()); } catch (e) {} found = scan(); }
      if (found) { this._helperEnsured = true; this._helperTopic = found; try { localStorage.setItem('mchat.helper.' + this.uname, found); } catch (_) {} return found; }
      // 2) 本设备记过有助手 → 不重建(双保险)
      let cached = null; try { cached = localStorage.getItem('mchat.helper.' + this.uname); } catch (_) {}
      if (cached) { this._helperEnsured = true; this._helperTopic = cached; return cached; }
      // 3) 真没有 → 建一个私密助手(只有自己;defacs auth:N/anon:N 防陌生人入群,与 createGroup 同策略)
      try {
        const tmp = this.tinode.newGroupTopicName ? this.tinode.newGroupTopicName() : 'new' + Date.now();
        const grp = this.tinode.getTopic(tmp);
        await grp.subscribe(grp.startMetaQuery().withDesc().withSub().build(), { desc: { public: { fn: HELPER_FN }, defacs: { auth: 'N', anon: 'N' } } });
        this.topics.set(grp.name, grp);
        this._helperEnsured = true; this._helperTopic = grp.name;
        try { localStorage.setItem('mchat.helper.' + this.uname, grp.name); } catch (_) {}
        this._emit('contact', this._normSub({ topic: grp.name, public: { fn: HELPER_FN }, acs: grp.acs, touched: Date.now() }));   // 让 UI 立即显示
        this._emit('contactsDone');
        return grp.name;
      } catch (e) { console.warn('[ensureHelper]', e && e.message); }
    }

    _normSub(sub) {
      const name = sub.topic || sub.user;
      return {
        topic: name,
        kind: name && name[0] === 'g' ? 'grp' : (name && name[0] === 'u' ? 'p2p' : 'sys'),
        name: (sub.public && sub.public.fn) || name,
        photo: this._notePhoto(name, sub.public && sub.public.photo ? this._photoUrl(sub.public.photo) : null),
        online: !!sub.online,
        unread: sub.unread || 0,
        seq: sub.seq || 0,
        read: sub.read || 0,
        recv: sub.recv || 0,
        touched: sub.touched ? new Date(sub.touched) : null,
        lastMsg: sub.private && sub.private.comment || '',
        acs: sub.acs,
        _raw: sub,
      };
    }

    _photoUrl(photo) {
      if (!photo) return null;
      if (photo.ref) return this.tinode.authorizeURL ? this.tinode.authorizeURL(photo.ref) : photo.ref;
      if (photo.data) return 'data:' + (photo.type || 'image/jpeg') + ';base64,' + photo.data;
      return null;
    }

    // uid→头像 全局缓存:desc 常晚于消息渲染到达,渲染侧兜底查这里(修"聊天记录头像时显时不显")
    _notePhoto(uid, url) { if (uid && url) (this._photos || (this._photos = new Map())).set(uid, url); return url; }
    photoOf(uid) { return (uid && this._photos && this._photos.get(uid)) || null; }

    getContacts() {
      if (!this.me) return [];
      // 数组式 me.contacts() 在本 SDK 版本 mapTopics 有 bug 一直会抛(reading 'call')；
      // 回调式 me.contacts(cb) 稳定(SDK 自身 onSubsUpdated 也用它)，改用回调式遍历
      const out = [];
      try { this.me.contacts(s => { if (s) out.push(this._normSub(s)); }); }
      catch (e) { console.warn('[getContacts]', e && e.message); }
      return out;
    }

    /* ---- 打开会话：订阅 + 拉历史 ---- */
    async openTopic(name, { limit = 24 } = {}) {
      const tp = this.tinode.getTopic(name);
      this.topics.set(name, tp);
      this._activeTopic = name;
      // 关键：渲染/规整异常必须就地吞掉，绝不能渗回 SDK 的 publishMessage.then —— 否则会被
      // SDK 的 catch 误判为「服务器拒绝」并把 publish 结果变成 undefined（撤回/发送会假性失败）。
      tp.onData = (m) => { if (!m) return; try { this._emit('message', name, this._normMsg(tp, m)); } catch (e) { console.error('[onData normMsg]', e); } };
      tp.onInfo = (info) => this._emit('info', name, info);       // {what:'read'|'recv'|'kp', from, seq}
      tp.onSubsUpdated = () => this._emit('members', name, this.getMembers(name));
      tp.onPres = (pres) => this._emit('topicPres', name, pres);
      if (!tp.isSubscribed()) {
        // 用 withData 拉最近 limit 条历史（不能用 withLaterData——对"已读到最新 read=seq"的账号会拉 0 条→打开会话空白）
        const q = tp.startMetaQuery().withData(undefined, undefined, limit).withDesc().withLaterSub().build();
        // 对任何瞬时错误(503 锁 / 网络抖动 / 超时)重试，移动网络下尤其需要
        let lastErr = null;
        for (let attempt = 0; attempt < 6; attempt++) {
          try { await tp.subscribe(q); lastErr = null; break; }
          catch (e) {
            lastErr = e;
            const code = e && e.code;
            if (code === 401 || code === 403 || code === 404) break;   // 鉴权/不存在不重试
            await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
          }
        }
        if (lastErr && !tp.isSubscribed()) throw lastErr;
        tp._mchatGotData = true;   // subscribe 已带 withLaterData 拉了历史
      } else if (!tp._mchatGotData) {
        // 已被订阅过但没拉消息（如 ensureMembers 为建同事目录抢先订阅）→ 用 withData 补拉最近 limit 条
        // （getMessagesPage 同样受 read=seq 影响拉不全，必须 withData 显式范围）
        try { await tp.getMeta(tp.startMetaQuery().withData(undefined, undefined, limit).build()); } catch (e) {}
        tp._mchatGotData = true;
      }
      return tp;
    }

    _normMsg(tp, m) {
      // 本地待发消息（尚未被服务器回执）没有 from，即自己发的
      const from = m.from || this.uid;
      const mine = from === this.uid;
      const fromSub = (from && !mine) ? tp.userDesc(from) : null;
      const content = m.content;
      return {
        seq: m.seq,
        from,
        fromName: mine ? (this.myName || this.uname) : ((fromSub && fromSub.public && fromSub.public.fn) || from || '成员'),
        mine,
        photo: (!mine && fromSub && fromSub.public && fromSub.public.photo) ? this._notePhoto(from, this._photoUrl(fromSub.public.photo)) : this.photoOf(from),
        ts: m.ts ? new Date(m.ts) : null,
        content,
        text: this.renderText(content),
        plain: (typeof content === 'string') ? content : safePlain(content),
        copyText: this.plainMultiline(content),   // 复制用:保留换行(BR→\n)+空格排版;plain 走 toPlainText 会把换行吞成空格
        attachments: this._extractAttachments(content),
        images: this._extractImages(content),
        deleted: !!m._deleted || (m.head && m.head.del),
        head: m.head || {},
        lanCard: parseLanCard(m.head),
        linkPreviews: parseLinkPreviews(m.head),
        voice: parseVoice(m.head),
        announce: (m.head && m.head.mchatAnnounce) || null,
      };
    }

    // 设置群公告：发一条带 head.mchatAnnounce 的消息，客户端置顶到公告条
    async setAnnounce(name, text) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      const msg = tp.createMessage('📢 群公告：' + text, true);
      msg.head = msg.head || {};
      msg.head.mchatAnnounce = text;
      msg.head = msg.head || {}; msg.head.mchatDev = this._devId; msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';   // 设备标记:供接收端区分"我另一台设备" + 平台(手机/电脑)
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    // 发送局域网大文件「信令卡」：正文是可读占位，元信息塞 head.mchatLan
    async sendLanCard(name, card) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      const human = '📎 ' + card.name + ' · ' + fmtSize(card.size) + '（局域网直传）';
      const msg = tp.createMessage(human, true);
      msg.head = msg.head || {};
      msg.head.mchatLan = JSON.stringify(card);
      msg.head = msg.head || {}; msg.head.mchatDev = this._devId; msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';   // 设备标记:供接收端区分"我另一台设备" + 平台(手机/电脑)
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    // 小文件「走云」：上传到 Tinode 服务器(fs handler) → 发一条 Drafty EX 附件消息(全端可下载)
    async sendCloudFile(name, file, onProgress) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      const helper = this.tinode.getLargeFileHelper();
      const url = await helper.upload(file, null, onProgress || (() => {}));   // 返回相对 URL /v0/file/s/...
      const content = Drafty.attachFile(Drafty.init(''), {
        mime: file.type || 'application/octet-stream',
        filename: file.name,
        size: file.size,
        refurl: url,
      });
      const msg = tp.createMessage(content, true);
      msg.head = msg.head || {}; msg.head.mchatDev = this._devId; msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';   // 设备标记:供接收端区分"我另一台设备" + 平台(手机/电脑)
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    // 转发现有消息：复用原 Drafty 内容，云文件/内联图片/语音可保留；LAN 直传卡片不转发，避免把已失效的局域网地址发给别人。
    async forwardMessage(name, original) {
      if (!original || original.deleted) throw new Error('消息不可转发');
      if (original.lanCard) throw new Error('局域网直传文件请到原会话重新发送');
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      let content = original.content;
      if (content && typeof content === 'object') {
        try { content = JSON.parse(JSON.stringify(content)); } catch (e) {}
      }
      if (content == null && original.plain) content = original.plain;
      if (content == null) throw new Error('该消息没有可转发内容');
      const msg = tp.createMessage(content, true);
      msg.head = msg.head || {};
      if (original.voice) msg.head.mchatVoice = JSON.stringify(original.voice);
      if (original.linkPreviews && original.linkPreviews.length) msg.head.mchatLinks = JSON.stringify(original.linkPreviews);
      msg.head.mchatDev = this._devId;
      msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    // 给云附件相对 URL 补上鉴权 token + 绝对 host，得到浏览器/Safari 可直接打开的完整链接
    fileURL(ref) {
      if (!ref) return '';
      let u = '';
      try { u = this.tinode.authorizeURL(ref); } catch (e) { u = ref; }
      if (/^https?:\/\//i.test(u)) return u;                  // 已是绝对 URL
      // u 形如 /v0/file/s/...?apikey&auth&secret；Tinode 文件 endpoint 实测在 morewater.top/tinode/v0/file/(含 /tinode 反代前缀)
      const h = HOST.replace(/^https?:\/\//, '').replace(/\/+$/, '');   // 'morewater.top/tinode'
      return 'https://' + h + (u.charAt(0) === '/' ? u : '/' + u);
    }

    _extractAttachments(content) {
      if (!content || typeof content !== 'object') return [];
      const out = [];
      try { Drafty.attachments(content, (data) => { if (data) out.push(data); }); } catch (e) {}
      return out;
    }

    // 提取内联图片（IM 实体）：val=base64 内联，或 ref=已上传 URL
    _extractImages(content) {
      if (!content || typeof content !== 'object' || !Array.isArray(content.ent)) return [];
      const out = [];
      content.ent.forEach(e => {
        if (e && e.tp === 'IM' && e.data) {
          out.push({ mime: e.data.mime || 'image/jpeg', val: e.data.val, ref: e.data.ref, width: e.data.width, height: e.data.height, name: e.data.name });
        }
      });
      return out;
    }

    // 发送内联图片：img = {mime, base64, width, height, filename, size}
    async sendImage(name, img) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      const content = Drafty.insertImage(Drafty.init(''), 0, {
        mime: img.mime, bits: img.base64, width: img.width, height: img.height, size: img.size, filename: img.filename,
      });
      const msg = tp.createMessage(content, true);
      msg.head = msg.head || {}; msg.head.mchatDev = this._devId; msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';   // 设备标记:供接收端区分"我另一台设备" + 平台(手机/电脑)
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    // 图文合一:文字 + 内联图片 → 一条 Drafty 消息(避免拆成多条,agent/对端一次性理解)
    async sendMixed(name, text, imgs, { reply, links } = {}) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      let content;
      if (reply && reply.seq != null) {
        const q = Drafty.quote(reply.fromName || '', reply.from || '', reply.plain || '');
        content = Drafty.append(Drafty.appendLineBreak(q), Drafty.init(text || ''));
      } else if (text && /[@*_~`]/.test(text)) {
        content = Drafty.parse(text) || Drafty.init(text || '');
      } else {
        content = Drafty.init(text || '');
      }
      // 文字后逐张追加内联图(每图前换行分隔)
      for (const img of (imgs || [])) {
        if ((content.txt || '').length) content = Drafty.appendLineBreak(content);
        content = Drafty.insertImage(content, (content.txt || '').length, {
          mime: img.mime, bits: img.base64, width: img.width, height: img.height, size: img.size, filename: img.filename,
        });
      }
      const msg = tp.createMessage(content, true);
      msg.head = msg.head || {};
      if (links && links.length) msg.head.mchatLinks = JSON.stringify(links);
      msg.head.mchatDev = this._devId; msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';   // 设备标记:供接收端区分"我另一台设备" + 平台(手机/电脑)
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    // 发送语音消息：voice = {mime, dur秒, val:base64, text?转写}，元信息塞 head.mchatVoice（与太白发语音同格式 → voiceCardEl 直接渲染播放器）
    async sendAudio(name, voice) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      const human = '🎤 语音' + (voice.dur ? ' ' + voice.dur + '″' : '');   // 正文给老客户端/通知做降级占位
      const msg = tp.createMessage(human, true);
      msg.head = msg.head || {};
      msg.head.mchatVoice = JSON.stringify(voice);
      msg.head = msg.head || {}; msg.head.mchatDev = this._devId; msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';   // 设备标记:供接收端区分"我另一台设备" + 平台(手机/电脑)
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    // 把 Drafty/字符串渲染为安全 HTML：识别引用块(QQ) → 渲染 .quote 块，其余转义 + @提及高亮。
    // 不用 Drafty.entities/format（回调式，易抛错渗回 SDK），直接读 txt/fmt 结构，稳。
    renderText(content) {
      if (content == null) return '';
      if (typeof content === 'string') return plainHtml(content);
      const txt = content.txt || '';
      const fmt = content.fmt || [];
      const qq = fmt.find(f => f.tp === 'QQ');
      if (qq) {
        const start = qq.at, end = qq.at + qq.len;
        // 区间内第一个 BR 分隔「作者 / 被引文」
        const brIn = fmt.filter(f => f.tp === 'BR' && f.at >= start && f.at < end).sort((a, b) => a.at - b.at)[0];
        let author = '', quoted = '';
        if (brIn) { author = txt.substring(start, brIn.at); quoted = txt.substring(brIn.at + brIn.len, end); }
        else { quoted = txt.substring(start, end); }
        // 引用块后若紧跟 BR 则跳过，余下为正文回复
        let restStart = end;
        const brAfter = fmt.find(f => f.tp === 'BR' && f.at === end);
        if (brAfter) restStart += brAfter.len;
        const rest = txt.substring(restStart);
        const qHtml = '<div class="quote"><div class="qn">' + esc(author.trim()) + '</div><div class="qt">' + esc(quoted.trim()) + '</div></div>';
        return qHtml + plainHtml(rest.replace(/^\s+/, ''));
      }
      // 非引用：把 Drafty 的 BR(换行)还原成 \n 再渲染（toPlainText 会把换行吞成空格 → 丢换行）
      let t = txt;
      fmt.filter(f => f.tp === 'BR').sort((a, b) => b.at - a.at).forEach(b => { t = t.slice(0, b.at) + '\n' + t.slice(b.at + (b.len || 1)); });
      return plainHtml(t);
    }

    // 复制用纯文本：保留换行(把 Drafty 的 BR 还原成 \n) + 原始空格排版。toPlainText 会把 BR 吞成空格,故不用它。
    plainMultiline(content) {
      if (content == null) return '';
      if (typeof content === 'string') return content;
      let t = content.txt || '';
      const fmt = content.fmt || [];
      fmt.filter(f => f.tp === 'BR').sort((a, b) => b.at - a.at).forEach(b => { t = t.slice(0, b.at) + '\n' + t.slice(b.at + (b.len || 1)); });
      return t;
    }

    /* ---- 发送 / 撤回 / 已读 / 输入态 ---- */
    async send(name, text, { reply, links } = {}) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      let content = text;
      if (reply && reply.seq != null) {
        const q = Drafty.quote(reply.fromName || '', reply.from || '', reply.plain || '');
        content = Drafty.append(Drafty.appendLineBreak(q), Drafty.init(text));
      } else if (/[@*_~`]/.test(text)) {
        content = Drafty.parse(text) || text;   // 解析 @提及 / *粗体* 等
      }
      const msg = tp.createMessage(content, true);
      msg.head = msg.head || {};
      if (links && links.length) msg.head.mchatLinks = JSON.stringify(links);
      msg.head.mchatDev = this._devId; msg.head.mchatPlat = global.MCHAT_PLATFORM || 'web';   // 设备标记:供接收端区分"我另一台设备" + 平台(手机/电脑)
      const ctrl = await tp.publishMessage(msg);
      return ctrl && ctrl.params && ctrl.params.seq;
    }

    async recall(name, seq) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      return tp.delMessages([{ low: seq, hi: seq + 1 }], true);
    }
    async delLocal(name, seq) {           // 本机单条删除（软删，仅本端不再显示）
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      return tp.delMessages([{ low: seq, hi: seq + 1 }], false);
    }
    noteRead(name, seq) {
      const tp = this.topics.get(name); if (tp) try { tp.noteRead(seq); } catch (e) {}
    }
    noteTyping(name) {
      const tp = this.topics.get(name); if (tp) try { tp.noteKeyPress(); } catch (e) {}
    }

    // 订阅(若未订阅)并返回成员，用于构建「同事」目录，不影响当前活跃会话
    async ensureMembers(name) {
      const tp = this.tinode.getTopic(name);
      if (!tp.isSubscribed()) {
        try { await tp.subscribe(tp.startMetaQuery().withLaterSub().withDesc().build()); await new Promise(r => setTimeout(r, 900)); }
        catch (e) { return []; }
      }
      return this.getMembers(name);
    }

    getMembers(name) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      const out = [];
      tp.subscribers((s) => out.push({
        uid: s.user, name: (s.public && s.public.fn) || s.user,
        photo: this._notePhoto(s.user, s.public && s.public.photo ? this._photoUrl(s.public.photo) : null),
        online: !!s.online, acs: s.acs, me: s.user === this.uid,
      }));
      return out;
    }

    // 重连后恢复当前会话（重订阅会经 openTopic 的补拉逻辑重新加载历史）
    _activeTopicGot() {
      const t = this._activeTopic;
      if (t) { const tp = this.tinode.getTopic(t); if (tp) tp._mchatGotData = false; this.openTopic(t).catch(() => {}); }
    }

    // 改自己的显示名（public.fn）→ 所有人和 agent 都看到新名
    async setMyName(name) {
      if (!this.me || !name) return;
      await this.me.setMeta({ desc: { public: { fn: name } } });
      this.myName = name;
    }

    // 上传头像到 Tinode public.photo(base64 内联)→ 全端可见;dataUrl 可为压缩图片或 GIF 原始 data URL
    async setMyAvatar(dataUrl) {
      if (!this.me || !dataUrl) return;
      const mt = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
      if (!mt) throw new Error('头像格式异常');
      await this.me.setMeta({ desc: { public: { photo: { type: mt[1], data: mt[2] } } } });
      this.myPhoto = dataUrl;
    }

    isGroup(name) { return name && name[0] === 'g'; }

    // 用户自主建群：新建群(自己即 owner) + 邀请 uids。返回 {name:真实gxxx, invited:成功数}
    async createGroup(fn, uids) {
      const tmp = this.tinode.newGroupTopicName ? this.tinode.newGroupTopicName() : 'new' + Date.now();
      const grp = this.tinode.getTopic(tmp);
      // defacs 锁成仅邀请可入(auth:'N')：默认值 JRWPS 会让任何登录用户拿到群 ID 即自动入群=变公开群;
      // 邀请走 grp.invite(uid,'JRWPS') 给指定人显式授权，不受 defacs 影响，故不破坏拉人。
      await grp.subscribe(grp.startMetaQuery().withDesc().withSub().build(), { desc: { public: { fn: fn || '新群聊' }, defacs: { auth: 'N', anon: 'N' } } });
      this.topics.set(grp.name, grp);
      let invited = 0;
      for (const uid of (uids || [])) {
        if (!uid || uid === this.uid) continue;
        try { await grp.invite(uid, 'JRWPS'); invited++; } catch (e) { console.warn('[createGroup invite]', uid, e && e.message); }
      }
      return { name: grp.name, invited };
    }

    // 群主/管理员往已有群拉人：邀请 uids 进 name。返回成功数
    async inviteMembers(name, uids) {
      const grp = this.topics.get(name) || this.tinode.getTopic(name);
      if (!grp.isSubscribed()) { try { await grp.subscribe(grp.startMetaQuery().withDesc().withSub().build()); } catch (e) {} }
      let ok = 0;
      for (const uid of (uids || [])) {
        if (!uid || uid === this.uid) continue;
        try { await grp.invite(uid, 'JRWPS'); ok++; } catch (e) { console.warn('[inviteMembers]', uid, e && e.message); }
      }
      return ok;
    }

    // 我在某群是否有拉人权限(owner 或含 A 管理权限)→ 决定「＋邀请」按钮显隐
    canInvite(name) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      try {
        const m = tp && tp.acs;
        if (!m) return false;
        if (typeof m.isOwner === 'function' && m.isOwner()) return true;
        if (typeof m.isAdmin === 'function' && m.isAdmin()) return true;
        const s = '' + (typeof m.getMode === 'function' ? m.getMode() : (m.mode || ''));
        return /[AO]/.test(s);
      } catch (e) { return false; }
    }

    // 我是否该群群主(owner)：决定「解散群聊」vs「退出群聊」
    isOwner(name) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      try {
        const m = tp && tp.acs;
        if (!m) return false;
        if (typeof m.isOwner === 'function') return m.isOwner();
        const s = '' + (typeof m.getMode === 'function' ? m.getMode() : (m.mode || ''));
        return /O/.test(s);
      } catch (e) { return false; }
    }

    // 退出群聊：普通成员主动离开(unsub=true 真正退订，服务端移除我的订阅，重连不会回来)
    async leaveGroup(name) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      try { await tp.leave(true); } catch (e) { console.warn('[leaveGroup]', e && e.message); throw e; }
      finally { this.topics.delete(name); if (this._activeTopic === name) this._activeTopic = null; }
    }

    // 解散群聊：群主删除整个 topic(hard delete，所有成员移出，群彻底消失)
    async dissolveGroup(name) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      try { await tp.delTopic(true); } catch (e) { console.warn('[dissolveGroup]', e && e.message); throw e; }
      finally { this.topics.delete(name); if (this._activeTopic === name) this._activeTopic = null; }
    }

    // 移除成员：群主/管理员把 uid 踢出群
    async kickMember(name, uid) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      return tp.delSubscription(uid);
    }

    // 改群名(群主/管理员)：改 desc.public.fn
    async renameGroup(name, fn) {
      const tp = this.topics.get(name) || this.tinode.getTopic(name);
      await tp.setMeta({ desc: { public: { fn: fn } } });
    }
  }

  function parseVoice(head) {   // 语音消息:head.mchatVoice = {mime, dur秒, val:base64}
    if (!head || !head.mchatVoice) return null;
    try { return JSON.parse(head.mchatVoice); } catch (e) { return null; }
  }
  function parseLanCard(head) {
    if (!head || !head.mchatLan) return null;
    try { return typeof head.mchatLan === 'string' ? JSON.parse(head.mchatLan) : head.mchatLan; } catch (e) { return null; }
  }
  function parseLinkPreviews(head) {
    if (!head || !head.mchatLinks) return [];
    try {
      const raw = typeof head.mchatLinks === 'string' ? JSON.parse(head.mchatLinks) : head.mchatLinks;
      if (!Array.isArray(raw)) return [];
      return raw.slice(0, 24).map(x => ({
        url: String((x && x.url) || '').slice(0, 1200),
        finalUrl: String((x && x.finalUrl) || '').slice(0, 1200),
        title: String((x && x.title) || '').replace(/\s+/g, ' ').trim().slice(0, 260),
      })).filter(x => /^https?:\/\//i.test(x.url));
    } catch (e) { return []; }
  }
  function fmtSize(n) {
    n = +n || 0;
    if (n >= 1 << 30) return (n / (1 << 30)).toFixed(2) + ' GB';
    if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB';
    if (n >= 1 << 10) return (n / (1 << 10)).toFixed(0) + ' KB';
    return n + ' B';
  }
  function safePlain(content) {
    try {
      if (content && typeof content === 'object' && typeof content.txt === 'string') {
        // Drafty 把换行抽成 fmt{tp:'BR'}(txt 对应位是空格),SDK 的 toPlainText 不还原 → 复制出来整段挤成一行。
        // 按 Drafty 规范以 Unicode 码点计位(Array.from),把 BR 位还原回 \n,复制/引用/转发的纯文本保住原排版。
        const chars = Array.from(content.txt);
        (content.fmt || []).forEach(f => { if (f && f.tp === 'BR' && f.at >= 0 && f.at < chars.length) chars[f.at] = '\n'; });
        return chars.join('');
      }
      return Drafty.toPlainText(content);
    } catch (e) { return (content && content.txt) || ''; }
  }
  function plainHtml(s) {
    return esc(s)
      .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="msg-link">$1</a>')   // 网址自动成可点链接
      .replace(/\n/g, '<br>')
      .replace(/@(所有人|[\p{L}\p{N}_]+)/gu, '<span class="mention">@$1</span>');
  }
  function esc(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  }

  global.MchatClient = MchatClient;
  global.Drafty = Drafty;
})(window);
