// bridges/search-bridge.js
// ---------------------------------------------------------------------------
// 小红书搜索结果 bridge。
//
// 暴露 window.__jse_xhs_search__ API：
//   __meta = { version, name }
//   probe()
//   state()
//   sessionState()
//   navigateSearch({ keyword, channelType?, sortBy?, contentType?, timeRange?, searchScope? })
//   search({ keyword, limit?, channelType?, sortBy?, contentType?, timeRange?, searchScope?,
//            extractDetails?, detailsLimit? })
//   dom_search （runTool 主路径，auto = dom 优先）
//
// v3.2 起 UI 路径与 agent-js DeepSearchWorkflow/lib/mcp/tools/xhsSearch.js 对齐：
//   - 频道 Tab 在 #channel-container 内按文本匹配
//   - 筛选通过点击「筛选」span → .filters-wrapper 行内按文本匹配选项 → body 关闭
//   - extractDetails 走「同 tab：点 a → 等 #noteContainer → 内联抽取 → 点关闭按钮 / back → 等列表重现」
// ---------------------------------------------------------------------------

(function install() {
  'use strict';
  const VERSION = '0.3.12';

  // visual-bridge-kit 桥：当 visual 启用时，window.__jse_visual 会被注入。
  // 在编排关键节点主动 emit HUD/flash，让 19s 的串行详情过程不再「中段空白」。
  function _vis() { try { return window.__jse_visual || null; } catch (_) { return null; } }
  function _vhud(action, target, detail, tone) {
    var v = _vis(); if (!v || !v.showHud) return;
    try { v.showHud({ action: action || '', target: target || '', detail: detail || '', status: tone || 'info' }); } catch (_) {}
  }
  function _vflash(el, tone, label) {
    var v = _vis(); if (!v || !v.flashElement || !el) return;
    try { v.flashElement(el, { tone: tone || 'info', label: label || '' }); } catch (_) {}
  }

  // @@include ./common.js

  const FILTER_CATEGORY_LABELS = {
    sortBy: '排序依据',
    contentType: '笔记类型',
    timeRange: '发布时间',
    searchScope: '搜索范围',
  };

  const CHANNEL_LABELS = ['全部', '图文', '视频', '用户'];

  function _stateReady() {
    return /\/search_result/i.test(location.pathname);
  }

  function probe() {
    var session = sessionStateCommon();
    return okResult({
      url: location.href,
      hostname: location.hostname,
      bridge: { version: VERSION, name: 'search-bridge' },
      login: session && session.data ? session.data : null,
      timestamp: new Date().toISOString(),
    });
  }

  function state() {
    var ready = _stateReady();
    return okResult({
      ready: ready,
      reason: ready ? null : 'not_on_search',
      url: location.href,
      bridgeVersion: VERSION,
    });
  }

  function sessionState() { return sessionStateCommon(); }

  function navigateSearch(args) {
    args = args || {};
    var u = new URL('https://www.xiaohongshu.com/search_result');
    if (args.keyword) u.searchParams.set('keyword', String(args.keyword));
    u.searchParams.set('source', 'web_explore_feed');
    return navigateLocation(u.toString());
  }

  // ----- 通用等待 -----

  function _waitFor(selector, timeoutMs) {
    return new Promise(function (resolve) {
      var deadline = Date.now() + (timeoutMs || 8000);
      function tick() {
        var el = null;
        try { el = document.querySelector(selector); } catch (_) {}
        if (el) return resolve(el);
        if (Date.now() >= deadline) return resolve(null);
        setTimeout(tick, 200);
      }
      tick();
    });
  }

  function _waitForFeeds(timeoutMs) {
    return _waitFor('.feeds-container .note-item, .feeds-container section.note-item, section.note-item', timeoutMs || 8000);
  }

  function _randomJitter(minMs, maxMs) {
    var d = Math.floor(minMs + Math.random() * Math.max(1, maxMs - minMs));
    return delay(d);
  }

  // ----- 滚动加载 -----

  async function _scrollAndCollect(limit, idleRoundsMax) {
    var collected = [];
    var seenIds = new Set();
    var idleRounds = 0;
    var maxRounds = 60;
    function snap() {
      var nodes = document.querySelectorAll('.feeds-container .note-item, .feeds-container section.note-item, section.note-item');
      var added = 0;
      nodes.forEach(function (node) {
        try {
          var item = _extractNoteCard(node);
          if (item && item.noteId && !seenIds.has(item.noteId)) {
            seenIds.add(item.noteId);
            // 内部字段（下划线前缀）：详情阶段定位用，最后会被剥掉。
            // _scrollY: 卡片在文档中的绝对 Y（留 200px 顶部余量，避开导航栏）
            // _index:   collected 序号，作为 _scrollY 缺失时的兜底估算
            item._index = collected.length;
            collected.push(item);
            added++;
          }
        } catch (_) {}
      });
      return added;
    }

    snap();
    for (var round = 0; round < maxRounds; round++) {
      if (collected.length >= limit) break;
      window.scrollTo(0, document.documentElement.scrollHeight);
      await delay(1200);
      var added = snap();
      if (added === 0) {
        idleRounds++;
        if (idleRounds >= (idleRoundsMax || 3)) break;
      } else {
        idleRounds = 0;
      }
    }
    return collected.slice(0, limit);
  }

  function _extractNoteCard(node) {
    if (!node) return null;
    // 卡片内通常有多个 <a>：a.cover 不带 token；a.title (/search_result/<id>?xsec_token=...) 带 token。
    // 优先选带 xsec_token 的链接。
    var anchors = node.querySelectorAll('a[href*="/explore/"], a[href*="/search_result/"]');
    var withToken = null;
    var fallback = null;
    for (var i = 0; i < anchors.length; i++) {
      var h = readReactHref(anchors[i]) || anchors[i].getAttribute('href') || '';
      if (h.indexOf('xsec_token=') >= 0) { withToken = h; break; }
      if (!fallback) fallback = h;
    }
    var href = withToken || fallback;
    if (!href) return null;
    var fullUrl = href.indexOf('http') === 0 ? href : ('https://www.xiaohongshu.com' + href);
    var ref = parseNoteIdFromHref(fullUrl);
    if (!ref || !ref.noteId) return null;

    function pickText(selectors) {
      for (var i = 0; i < selectors.length; i++) {
        var el = node.querySelector(selectors[i]);
        if (el) {
          var t = (el.textContent || '').trim();
          if (t) return t;
        }
      }
      return '';
    }
    var title = pickText(['.footer .title', '.title span', '.title', 'a.cover .title']);
    var author = pickText(['.author-wrapper .author .name', '.author .name', '.user-name']);
    var likeText = pickText(['.like-wrapper .count', '.author-wrapper .count', '.count']);
    var coverImg = node.querySelector('a.cover img, img');
    var cover = coverImg ? (coverImg.getAttribute('src') || coverImg.getAttribute('data-src')) : null;

    // 卡片层 type 判定：xhs 视频卡片在 a.cover 内会有 .play-icon（svg 播放按钮），图文则没有。
    // 兼容历史 class 命名：[class*=play-icon] / [class*=video-mask] / 真 <video> 标签。
    var hasPlay = !!node.querySelector('a.cover .play-icon, .play-icon, [class*="play-icon"], [class*="video-mask"], video');
    var noteType = hasPlay ? 'video' : 'normal'; // 'normal' 对应小红书自有的「图文」枚举

    // 记录卡片在文档中的绝对 Y（详情阶段 seek 用）。getBoundingClientRect 是相对视口的，
    // 加上 window.scrollY 才是绝对值；减 200px 让卡片落在视口偏上位置，避开导航栏。
    var _scrollY = 0;
    try {
      var r = node.getBoundingClientRect();
      _scrollY = Math.max(0, Math.floor(window.scrollY + r.top - 200));
    } catch (_) {}

    return {
      noteId: ref.noteId,
      url: 'https://www.xiaohongshu.com/explore/' + ref.noteId
        + (ref.xsec_token ? ('?xsec_token=' + encodeURIComponent(ref.xsec_token)) : ''),
      xsec_token: ref.xsec_token || '',
      title: title,
      author: author,
      likeCount: parseCountText(likeText),
      cover: cover,
      type: noteType,
      // 保留卡片节点引用（仅在本帧有效；详情阶段会重新查找）
      _selector: 'noteId=' + ref.noteId,
      _scrollY: _scrollY,
    };
  }

  // ----- 频道 Tab：参考 xhsSearch.js 走 #channel-container -----

  async function _switchChannel(channelType) {
    if (!channelType || channelType === '全部') return { ok: true, applied: '全部' };
    if (CHANNEL_LABELS.indexOf(channelType) === -1) {
      return { ok: false, error: 'unknown_channel', channelType: channelType };
    }
    var container = document.querySelector('#channel-container');
    if (!container) {
      return { ok: false, error: 'channel_container_not_found' };
    }
    // 优先 button/role=button/a，再退化到所有叶子元素文本匹配
    // 优先 [data-hp-bound]（Vue 真节点；visual-bridge-kit 的 HP overlay 没有这个属性）
    var bound = container.querySelectorAll('[data-hp-bound]');
    var hit = null;
    for (var bi = 0; bi < bound.length; bi++) {
      if ((bound[bi].textContent || '').trim() === channelType) { hit = bound[bi]; break; }
    }
    if (!hit) {
      var primary = container.querySelectorAll('div[role="button"], button, a, span');
      for (var i = 0; i < primary.length; i++) {
        if (primary[i].hasAttribute('data-hp-installed')) continue;
        if ((primary[i].textContent || '').trim() === channelType) { hit = primary[i]; break; }
      }
    }
    if (!hit) {
      var all = container.querySelectorAll('*');
      for (var j = 0; j < all.length; j++) {
        var el = all[j];
        if (el.hasAttribute('data-hp-installed')) continue;
        if (el.children.length === 0 && (el.textContent || '').trim() === channelType) { hit = el; break; }
      }
    }
    if (!hit) {
      return { ok: false, error: 'channel_tab_not_found', channelType: channelType };
    }
    _vhud('切频道', channelType, '', 'pending');
    _vflash(hit, 'info', '频道');
    try { hit.click(); } catch (_) {}
    await _randomJitter(800, 1200);
    await _waitForFeeds(8000);
    _vhud('切频道', channelType, 'OK', 'success');
    return { ok: true, applied: channelType };
  }

  // ----- 筛选面板：点击「筛选」span 打开 -----

  async function _openFilterPanel() {
    if (document.querySelector('.filters-wrapper')) return { ok: true, alreadyOpen: true };
    var spans = document.querySelectorAll('span');
    var hit = null;
    for (var i = 0; i < spans.length; i++) {
      if ((spans[i].textContent || '').trim() === '筛选' && spans[i].children.length === 0) {
        hit = spans[i]; break;
      }
    }
    if (!hit) {
      // 备路径：可点击元素含「筛选」文本
      var clickable = document.querySelectorAll('[role="button"], [style*="cursor: pointer"], button, div');
      for (var j = 0; j < clickable.length; j++) {
        var c = clickable[j];
        if ((c.textContent || '').indexOf('筛选') >= 0 && (c.textContent || '').trim().length < 10) {
          hit = c; break;
        }
      }
    }
    if (!hit) return { ok: false, error: 'filter_trigger_not_found' };
    _vhud('打开筛选面板', '', '', 'pending');
    _vflash(hit, 'info', '筛选');
    try { hit.click(); } catch (_) {}
    var panel = await _waitFor('.filters-wrapper', 3000);
    if (!panel) return { ok: false, error: 'filter_panel_not_opened' };
    _vhud('打开筛选面板', '', 'OK', 'success');
    await delay(400);
    return { ok: true };
  }

  async function _applyFilter(groupKey, optionLabel) {
    if (!optionLabel) return { ok: true, applied: null };
    var category = FILTER_CATEGORY_LABELS[groupKey];
    if (!category) return { ok: false, error: 'unknown_filter_group', groupKey: groupKey };
    var panel = document.querySelector('.filters-wrapper');
    if (!panel) return { ok: false, error: 'filter_panel_not_open', groupKey: groupKey };

    // xhs 实测面板结构：.filters-wrapper > .filters > [.title, .tag-container > .tags(.active?) > <span>]
    // 每行的 `.filters` 第一个子元素是分类标题（"排序依据" 等），第二个是 `.tag-container`。
    // 「选项」是 `.tags` div，每个内部是 `<span>` —— 真正的 React onClick 挂在 `.tags` 上，
    // 点 inner span 虽然能命中文本但不会真的触发 onChange（之前 0.3.2 的 false-positive 根因）。
    var filterRows = panel.querySelectorAll('.filters');
    var targetRow = null;
    for (var i = 0; i < filterRows.length; i++) {
      var row = filterRows[i];
      var titleEl = row.children && row.children[0];
      var titleText = titleEl ? (titleEl.textContent || '').trim() : '';
      if (titleText.indexOf(category) >= 0) { targetRow = row; break; }
    }
    if (!targetRow) {
      return { ok: false, error: 'filter_row_not_found', groupKey: groupKey, category: category };
    }

    // 关键：xhs 用 Vue（不是 React）；同时 visual-bridge-kit 的 hyperframes pointer overlay
    // 给每个 .tags 装了一个 absolute positioned 的「点击映射层」（带 `data-hp-installed` /
    // `data-hp-kind` 但**没有** `data-v-eb91fffe`），它出现在 querySelectorAll 顺序的前面。
    // 直接 click() 这个 overlay 不会触发 Vue 的 onChange —— 必须 click 真实的 Vue 节点
    // （带 `data-hp-bound="1"` + `data-v-eb91fffe`）。
    function _selectRealTag(row, label) {
      var bound = row.querySelectorAll('.tag-container .tags[data-hp-bound]');
      for (var i = 0; i < bound.length; i++) {
        if ((bound[i].textContent || '').trim() === String(label)) return bound[i];
      }
      // 兼容旧 / 未注入 visual 的情况：回到全集匹配，并主动跳过 HP overlay
      var all = row.querySelectorAll('.tag-container .tags');
      for (var j = 0; j < all.length; j++) {
        if (all[j].hasAttribute('data-hp-installed')) continue;
        var raw = (all[j].textContent || '').trim();
        if (raw === String(label) || raw === String(label) + String(label)) return all[j];
      }
      return null;
    }
    function _realActiveText(row) {
      // 同样跳过 HP overlay，避免读到镜像的 active 状态
      var bound = row.querySelectorAll('.tag-container .tags[data-hp-bound].active');
      if (bound.length) return (bound[0].textContent || '').trim();
      var all = row.querySelectorAll('.tag-container .tags.active');
      for (var i = 0; i < all.length; i++) {
        if (!all[i].hasAttribute('data-hp-installed')) return (all[i].textContent || '').trim();
      }
      return null;
    }

    var hit = _selectRealTag(targetRow, String(optionLabel));
    if (!hit) {
      return { ok: false, error: 'filter_option_not_found', groupKey: groupKey, optionLabel: optionLabel };
    }

    _vhud('筛选 · ' + category, String(optionLabel), '', 'pending');
    _vflash(hit, 'info', category);
    try { hit.click(); } catch (_) {}
    // 等待该行真实 active 切换到目标选项（确认 Vue 状态机已更新；忽略 HP overlay 的镜像）
    var deadline = Date.now() + 1500;
    var activated = false;
    while (Date.now() < deadline) {
      if (_realActiveText(targetRow) === String(optionLabel)) { activated = true; break; }
      await delay(150);
    }
    await _randomJitter(300, 500);
    _vhud('筛选 · ' + category, String(optionLabel), activated ? 'OK' : '未确认', activated ? 'success' : 'warn');
    return { ok: true, applied: optionLabel, activated: activated };
  }

  async function _closeFilterPanel() {
    try { document.body.click(); } catch (_) {}
    await _randomJitter(800, 1200);
    await _waitForFeeds(6000);
    return { ok: true };
  }

  // ----- 同 tab + back 串行抽取详情 -----
  // 详情抽取直接复用 #noteContainer 子树，字段命名与 note-bridge.dom_getNote 对齐：
  // title / description / content / image_urls / stats / xsec_token / noteId
  function _extractFromNoteContainer() {
    // 实测：xhs 点开搜索结果常走「新 route /explore/<id>」而非模态；container 可能是
    // `#noteContainer`（部分账号 / A/B test）也可能是 `.note-container`。两者都要兼容。
    var container = document.querySelector('#noteContainer')
      || document.querySelector('.note-container')
      || document.querySelector('.note-content');
    if (!container) {
      return { ok: false, error: 'no_note_container' };
    }
    var meta = parseNoteMeta();
    function pickText(selectors) {
      for (var i = 0; i < selectors.length; i++) {
        var el = container.querySelector(selectors[i]) || document.querySelector(selectors[i]);
        if (el) {
          var t = (el.textContent || '').trim();
          if (t) return t;
        }
      }
      return '';
    }
    function pickCount(selectors) {
      var raw = pickText(selectors);
      return parseCountText(raw);
    }
    var title = pickText(['#detail-title', '.note-content .title', 'h1.title', 'h1', '.title']) || meta.title;
    // 描述：先取详情段落（不带 .desc 嵌套限制），再退化到 meta description
    var content = pickText(['#detail-desc', '.note-content .desc', '.note-content', '.note-text', '.desc']) || meta.description;
    // 互动栏数字：在 container 内（不是全局 document）找，避免命中外部 likeWrapper（如作者卡片小组件）
    var likes = pickCount([
      '.engage-bar .like-wrapper .count',
      '.like-wrapper.like-active .count',
      '.like-wrapper .count',
    ]);
    var collects = pickCount([
      '.engage-bar .collect-wrapper .count',
      '.collect-wrapper .count',
    ]);
    var comments = pickCount([
      '.engage-bar .chat-wrapper .count',
      '.chat-wrapper .count',
      '.comments-container .total',
    ]);
    // meta 的 og:xhs:note_like/comment/collect 通常更可信，作为兜底
    if (likes == null) likes = parseCountText(meta.note_like);
    if (collects == null) collects = parseCountText(meta.note_collect);
    if (comments == null) comments = parseCountText(meta.note_comment);
    var imgUrls = pickMediaFromNote(container);
    if (imgUrls.length === 0 && meta.image_urls.length) imgUrls = meta.image_urls;
    var authorName = pickText([
      '.author-container .username',
      '.username',
      '.author-container .author-name',
      '.user-name',
    ]);
    var authorLink = container.querySelector('a[href*="/user/profile/"]')
      || document.querySelector('a[href*="/user/profile/"]');
    var authorId = null;
    if (authorLink) {
      var am = (authorLink.getAttribute('href') || '').match(/\/user\/profile\/([^?#]+)/);
      if (am) authorId = am[1];
    }
    var noteRef = parseNoteIdFromHref(location.href) || {};

    // 笔记发布时间 + 地点：xhs 详情页里第一个**不在 .comment-item 内**的 `.date`
    // 元素就是笔记自身的发布时间（comments 区每条评论也带 .date 但都嵌在 .comment-item）。
    // 文本形如 "04-22 上海" / "2天前 上海" / "昨天 12:31 上海"，地点没有空格分隔。
    var publishTime = '';
    var publishLocation = '';
    var dateNodes = container.querySelectorAll('.date');
    for (var di = 0; di < dateNodes.length; di++) {
      var dn = dateNodes[di];
      if (dn.closest('.comment-item') || dn.closest('.comments-container')) continue;
      var rawDate = (dn.textContent || '').trim();
      if (!rawDate) continue;
      // 把「时间 地点」拆开：尽量匹配「编辑于? <时间块> [省市]」
      // 地点都是中文 1~6 字（省/市），没有空格；时间块可能包含 "X天前" / "X小时前" / "昨天 HH:MM" / "MM-DD" / "YYYY-MM-DD"
      var m = rawDate.match(/^(编辑于\s*)?(\d{4}-\d{2}-\d{2}|\d{2}-\d{2}|昨天\s*\d{1,2}:\d{2}|今天\s*\d{1,2}:\d{2}|\d+天前|\d+小时前|\d+分钟前|刚刚|今天|昨天)\s*([\u4e00-\u9fa5]{1,8})?$/);
      if (m) {
        publishTime = (m[1] || '') + m[2];
        publishLocation = m[3] || '';
      } else {
        publishTime = rawDate; // 兜底：原文，不解析
      }
      break;
    }

    return {
      ok: true,
      noteId: noteRef.noteId || null,
      xsec_token: noteRef.xsec_token || '',
      title: title,
      description: content,
      content: content,
      image_urls: imgUrls,
      stats: { likes: likes, comments: comments, collects: collects },
      publishTime: publishTime || null,
      publishLocation: publishLocation || null,
      note_like: meta.note_like,
      note_comment: meta.note_comment,
      note_collect: meta.note_collect,
      author: { nickname: authorName || null, userId: authorId },
    };
  }

  function _findCardAnchor(noteId) {
    // 重新在当前 DOM 找指定 noteId 的卡片中带 xsec_token 的 <a>
    var nodes = document.querySelectorAll('.feeds-container section.note-item, section.note-item');
    for (var i = 0; i < nodes.length; i++) {
      var node = nodes[i];
      var anchors = node.querySelectorAll('a[href*="/explore/"], a[href*="/search_result/"]');
      var fallback = null;
      for (var j = 0; j < anchors.length; j++) {
        var h = readReactHref(anchors[j]) || anchors[j].getAttribute('href') || '';
        if (h.indexOf(noteId) === -1) continue;
        if (h.indexOf('xsec_token=') >= 0) return anchors[j];
        if (!fallback) fallback = anchors[j];
      }
      if (fallback) return fallback;
    }
    return null;
  }

  async function _backToList(restoreY) {
    // 优先点关闭按钮（模态详情），失败再用 history.back
    var close = document.querySelector('.close-circle .close')
      || document.querySelector('.close-mask-dark .close')
      || document.querySelector('[class*="close-circle"] [class*="close"]');
    var routeMode = !/\/search_result/.test(location.pathname);
    if (close && !routeMode) {
      try { close.click(); } catch (_) {}
    } else {
      // 路由模式（已跳到 /explore/<id>）必须用 history.back，点关闭按钮无效
      try { window.history.back(); } catch (_) {}
    }
    await _randomJitter(800, 1200);
    // 路由模式后退到搜索页可能比模态更慢，给更长 timeout
    var feeds = await _waitForFeeds(routeMode ? 10000 : 6000);
    if (!feeds) return false;
    // 路由模式下浏览器 SPA 通常不会自动恢复 scroll，feeds 会重置到顶部。
    // 这里把上一次点开前的 Y 主动设回去，让懒加载先把这一段渲出来，
    // 再交给 _seekAndFindAnchor 做精准定位。模态模式 body scroll 通常没动，跳过。
    if (routeMode && typeof restoreY === 'number' && restoreY > 0) {
      try { window.scrollTo(0, restoreY); } catch (_) {}
      await delay(400);
    }
    return true;
  }

  // 详情阶段用：先把视口 seek 到目标卡片附近，等懒加载补出 DOM，再返回 anchor。
  // 三段策略：
  //   1) scrollTo(_scrollY 或 _index*360 估算)，轮询 ~3s 等卡片节点出现
  //   2) 仍没出现 → 分段 scrollBy(0.8 * 视口高) × 5 轮兜底（应对 _scrollY 因前序操作偏移的情况）
  async function _seekAndFindAnchor(note) {
    var direct = _findCardAnchor(note.noteId);
    if (direct) return direct;
    var seekY = (typeof note._scrollY === 'number' && note._scrollY > 0)
      ? note._scrollY
      : ((note._index || 0) * 360);
    try { window.scrollTo(0, seekY); } catch (_) {}
    var deadline = Date.now() + 3000;
    while (Date.now() < deadline) {
      await delay(200);
      var hit = _findCardAnchor(note.noteId);
      if (hit) return hit;
    }
    var vh = (window.innerHeight || 800);
    for (var step = 0; step < 5; step++) {
      try { window.scrollBy(0, Math.floor(vh * 0.8)); } catch (_) {}
      await delay(600);
      var h2 = _findCardAnchor(note.noteId);
      if (h2) return h2;
    }
    return null;
  }

  async function _extractDetailInline(note, progress) {
    var label = progress ? ('详情 ' + progress.i + '/' + progress.total) : '详情';
    // 记录点开前的 scrollY，用于路由模式 back 后恢复列表位置（避免下一张卡片
    // 因 SPA 重置到顶部而 DOM 节点未渲染）。
    var savedY = 0;
    try { savedY = window.scrollY || 0; } catch (_) {}
    // 1. 找卡片 anchor 并滚动到位 + 点击
    //    用 _seekAndFindAnchor 替代直接 _findCardAnchor：
    //    - 先 scrollTo 到收集时记录的绝对 Y 触发懒加载
    //    - 轮询 + 分段下滚兜底，解决 history.back 后深位卡片节点被回收的问题
    var anchor = await _seekAndFindAnchor(note);
    if (!anchor) {
      _vhud(label, note.noteId || '', 'anchor not found', 'error');
      return Object.assign({}, note, { detail: { ok: false, error: 'card_anchor_not_found' } });
    }
    _vhud(label, note.noteId || '', '点开', 'pending');
    _vflash(anchor, 'info', '点开');
    try { anchor.scrollIntoView({ behavior: 'auto', block: 'center' }); } catch (_) {}
    await delay(250);
    try { anchor.click(); } catch (e) {
      _vhud(label, note.noteId || '', 'click failed', 'error');
      return Object.assign({}, note, { detail: { ok: false, error: 'click_failed', message: String(e && e.message || e) } });
    }
    // 2. 等 #noteContainer 或 .note-container（实测 xhs 常走新 route 而非模态）
    var container = await _waitFor('#noteContainer, .note-container, .note-content', 8000);
    if (!container) {
      // 路由跳走但容器没出现：回退一步并标记。同样恢复 savedY 让下一张能定位。
      var routedAway = !/\/search_result/.test(location.pathname);
      try { window.history.back(); } catch (_) {}
      await _waitForFeeds(6000);
      if (routedAway && savedY > 0) {
        try { window.scrollTo(0, savedY); } catch (_) {}
        await delay(400);
      }
      return Object.assign({}, note, {
        detail: { ok: false, error: routedAway ? 'route_navigated' : 'no_note_container' },
      });
    }
    // 2.5 进一步等互动栏出现（这才是 stats / desc 真正稳定的标志）
    _vhud(label, note.noteId || '', '等互动栏', 'pending');
    await _waitFor('.engage-bar .like-wrapper, .like-wrapper .count', 3000);
    // 3. 抽取
    await _randomJitter(600, 1000);
    var extracted = _extractFromNoteContainer();
    var detailDetail = (extracted && extracted.ok)
      ? ('imgs=' + ((extracted.image_urls || []).length) + ' likes=' + ((extracted.stats && extracted.stats.likes) || 0))
      : 'extract_failed';
    _vhud(label, note.noteId || '', detailDetail, extracted && extracted.ok ? 'success' : 'warn');
    // 4. 关闭弹窗 / back（路由模式会带 savedY 恢复列表 scroll，避免 SPA 重置到顶）
    _vhud(label, note.noteId || '', '回列表', 'pending');
    var backOk = await _backToList(savedY);
    if (!backOk) {
      _vhud(label, note.noteId || '', 'feeds 未恢复', 'warn');
      return Object.assign({}, note, {
        detail: Object.assign({}, extracted, { backWarning: 'feeds_not_restored' }),
      });
    }
    return Object.assign({}, note, { detail: extracted });
  }

  // ----- 搜索框下拉联想词（best-effort） -----
  // 参考 agent-js DeepSearchWorkflow/lib/mcp/tools/xhsSearch.js (L1186-L1252)。
  // 实战：xhs 现在对 programmatic input.click()/focus() 严格 gating（要求 isTrusted）。
  // 这里把参考实现完整移过来作为 best-effort：成功最好，失败也透出 attempt 元数据。
  async function _collectSuggestKeywords() {
    var input = document.querySelector('#search-input, input[type="search"], input[placeholder*="搜索"]');
    if (!input) {
      return { keywords: [], hitSelector: null, error: 'no_input', durationMs: 0 };
    }
    var t0 = Date.now();
    var prevValue = input.value;
    try {
      // 完整事件序列：pointer + mouse + click + focus，尽量逼近真用户
      var rect = input.getBoundingClientRect();
      var x = rect.left + rect.width / 2;
      var y = rect.top + rect.height / 2;
      ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(function (type) {
        var Ev = type.indexOf('pointer') === 0 ? (window.PointerEvent || MouseEvent) : MouseEvent;
        try {
          input.dispatchEvent(new Ev(type, {
            bubbles: true, cancelable: true, view: window, button: 0,
            clientX: x, clientY: y, pointerId: 1, pointerType: 'mouse',
          }));
        } catch (_) {}
      });
      try { input.focus(); } catch (_) {}
      // 清空当前 value（避免被识别为「已完成搜索」状态压制 popup）
      try {
        var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
        setter.call(input, '');
        input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }));
      } catch (_) {}
    } catch (_) {}

    // 等 6s（参考实现同款）—— xhs 联想词如果出现，6s 内必到
    await delay(6000);

    // 参考 agent-js xhsSearch.js 的 selector 表，但**收紧**：
    //   1) 去掉 `[class*="dropdown"] li`（实测命中顶部导航的「创作中心/业务合作」菜单）
    //   2) 加几何位置过滤：候选元素必须位于输入框正下方（top 在 input.bottom 之下、≤600px 内）
    //   3) 加语义黑名单：排除明显的导航/管理菜单词
    var selectors = [
      '.sug-box .sug-item',          // 参考实现的主选择器
      '.sug-item',
      '.search-suggest-list li',
      '.suggest-list li',
      '[class*="suggest-item"]',
      '[class*="search-suggest"] a',
      '[class*="suggest"] li',
      '.search-suggestions li',
      '.hot-search-list li',
    ];
    var inputRect2 = input.getBoundingClientRect();
    var BLACKLIST = /(创作|直播管理|商家入驻|MCN|蒲公英|推广合作|业务合作|专业号|管理|入驻|中心|服务|帮助|举报|登录|注册|历史|清空|删除|搜索)/;
    function isPlausibleDropdown(el) {
      var rect = el.getBoundingClientRect();
      // 必须在输入框下方 5~600px 内
      if (rect.top < inputRect2.bottom - 5) return false;
      if (rect.top > inputRect2.bottom + 600) return false;
      // 横向应大致与输入框对齐（容许 ±300px）
      if (rect.right < inputRect2.left - 300 || rect.left > inputRect2.right + 300) return false;
      return true;
    }
    var keywords = [];
    var hitSelector = null;
    for (var si = 0; si < selectors.length; si++) {
      var sel = selectors[si];
      var els;
      try { els = document.querySelectorAll(sel); } catch (_) { continue; }
      if (!els || !els.length) continue;
      var found = [];
      for (var i = 0; i < els.length; i++) {
        var el = els[i];
        var txt = (el.textContent || '').trim();
        if (!txt || txt.length < 2 || txt.length > 50) continue;
        if (BLACKLIST.test(txt)) continue;
        if (!isPlausibleDropdown(el)) continue;
        if (found.indexOf(txt) < 0) found.push(txt);
      }
      if (found.length > 0) {
        keywords = found;
        hitSelector = sel;
        break;
      }
    }

    // 关闭 popup + 还原 input value，避免污染后续操作
    try { document.body.click(); } catch (_) {}
    try {
      if (prevValue !== input.value) {
        var setter2 = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
        setter2.call(input, prevValue);
        input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: prevValue }));
      }
    } catch (_) {}
    await delay(300);

    return {
      keywords: keywords,
      hitSelector: hitSelector,
      durationMs: Date.now() - t0,
      error: keywords.length === 0 ? 'no_dropdown_visible' : null,
    };
  }

  // ----- 主入口 -----

  async function dom_search(args) {
    args = args || {};
    var keyword = String(args.keyword || '').trim();
    if (!keyword) return errResult('bad_arg', { reason: 'keyword required' });

    // 关键词不匹配也要重导航：仅靠 _stateReady 校验路径会把上一次「穿搭」的页面当成
    // 「旅行攻略」结果回收。仿 user-bridge 对 userId 的双重判断。
    var currentKeyword = '';
    try { currentKeyword = (new URL(location.href)).searchParams.get('keyword') || ''; } catch (_) {}
    if (!_stateReady() || currentKeyword !== keyword) {
      var u = new URL('https://www.xiaohongshu.com/search_result');
      u.searchParams.set('keyword', keyword);
      u.searchParams.set('source', 'web_explore_feed');
      return { ok: false, error: 'dom_navigation_required', to: u.toString(),
        navMethod: 'navigateSearch', navArgs: { keyword: keyword } };
    }

    var limit = clampLimit(args.limit, 10, 200);
    var appliedFilters = {};

    // 1. 频道 Tab
    var ch = await _switchChannel(args.channelType || '全部');
    appliedFilters.channelType = ch.applied || (args.channelType || '全部');
    if (ch.error) appliedFilters.channelType_error = ch.error;

    // 2. 筛选面板（仅当任一筛选项需要）
    var needsPanel = !!(args.sortBy || args.contentType || args.timeRange || args.searchScope);
    var panelOpened = false;
    if (needsPanel) {
      var openRes = await _openFilterPanel();
      if (openRes.ok) {
        panelOpened = true;
        var groups = ['sortBy', 'contentType', 'timeRange', 'searchScope'];
        for (var fi = 0; fi < groups.length; fi++) {
          var gk = groups[fi];
          var fr = await _applyFilter(gk, args[gk]);
          appliedFilters[gk] = fr.applied || null;
          if (fr.error && args[gk]) appliedFilters[gk + '_error'] = fr.error;
          // 暴露 React 是否真的把 active 切到目标值（false-positive 排查用）
          if (args[gk] && fr.activated === false) appliedFilters[gk + '_activated'] = false;
        }
        await _closeFilterPanel();
      } else {
        appliedFilters.filterPanel_error = openRes.error || 'open_failed';
      }
    }

    await delay(800);

    // 3. 滚动收集
    var notes = await _scrollAndCollect(limit, 3);
    if (!notes.length) {
      return errResult('dom_extract_failed', { reason: 'no_notes', appliedFilters: appliedFilters });
    }

    // 4. extractDetails：同 tab + back 串行
    var detailsStats = null;
    if (args.extractDetails) {
      var detailsLimit = clampLimit(args.detailsLimit, notes.length, 200);
      detailsLimit = Math.min(detailsLimit, notes.length);
      var requested = detailsLimit;
      var succeeded = 0;
      var failed = 0;
      var enriched = [];
      for (var di = 0; di < notes.length; di++) {
        if (di >= detailsLimit) {
          enriched.push(notes[di]);
          continue;
        }
        var withDetail = await _extractDetailInline(notes[di], { i: di + 1, total: detailsLimit });
        enriched.push(withDetail);
        if (withDetail.detail && withDetail.detail.ok) succeeded++;
        else failed++;
        // 反爬抖动
        await _randomJitter(700, 1400);
      }
      notes = enriched;
      detailsStats = { requested: requested, succeeded: succeeded, failed: failed };
    }

    // 剥掉内部下划线字段（_scrollY / _index / _selector）：这些只是详情阶段定位用的
    // 中间态，不应污染对外 JSON。无论是否走 extractDetails 都执行。
    // 注意 .detail 子对象不动（那是抽取结果，不是 note 顶层的内部字段）。
    notes = notes.map(function (n) {
      var out = {};
      for (var k in n) {
        if (Object.prototype.hasOwnProperty.call(n, k) && k.charAt(0) !== '_') out[k] = n[k];
      }
      return out;
    });

    // 相关搜索（"大家都在搜"）：xhs 把 `.query-note-wrapper` 块穿插在 feed 中，
    // 每块含一个 `.query-note-header-text`（如 "大家都在搜"）+ 多个 `.query-note-item .item-text`。
    // 滚动后多块会同时存在，对深度调研非常有用 → 拍平 + 去重输出 relatedSearchKeywords，
    // 同时按 header 分组保留在 relatedSearch[]。
    function _collectRelatedSearch() {
      var groups = [];
      var seen = Object.create(null);
      var wrappers = document.querySelectorAll('.query-note-wrapper');
      for (var wi = 0; wi < wrappers.length; wi++) {
        var w = wrappers[wi];
        var headerEl = w.querySelector('.query-note-header-text');
        var header = headerEl ? (headerEl.textContent || '').trim() : '相关搜索';
        var items = w.querySelectorAll('.query-note-item .item-text, .item-wrapper .item-text');
        var words = [];
        for (var ii = 0; ii < items.length; ii++) {
          var t = (items[ii].textContent || '').trim();
          if (t && t.length < 60 && !seen[t]) { seen[t] = true; words.push(t); }
        }
        if (words.length) groups.push({ header: header, words: words });
      }
      var flat = Object.keys(seen);
      return { groups: groups, flat: flat };
    }
    var rel = _collectRelatedSearch();
    var relatedSearchKeywords = rel.flat;
    var searchTabs = [];

    // 搜索框下拉联想词（参考 agent-js xhsSearch.js 1186-1252）。
    // 实测 xhs 当前对 programmatic focus 严格 gating（要求 isTrusted），全程模拟事件
    // 大概率拿不到，但仍按参考实现实装作为 best-effort，并暴露 `suggestKeywordsAttempt`
    // 字段告诉调用方「试过了，确实没东西」与「没试」可以区分。
    var suggestKeywords = [];
    var suggestKeywordsAttempt = null;
    if (args.collectSuggest) {
      _vhud('采集联想词', '', '点搜索框', 'pending');
      suggestKeywordsAttempt = await _collectSuggestKeywords();
      suggestKeywords = suggestKeywordsAttempt.keywords || [];
      _vhud('采集联想词', '', suggestKeywords.length + ' 词 (' + (suggestKeywordsAttempt.hitSelector || 'none') + ')',
        suggestKeywords.length > 0 ? 'success' : 'warn');
    }

    return okResult({
      keyword: keyword,
      total: notes.length,
      notes: notes,
      searchTabs: searchTabs,
      suggestKeywords: suggestKeywords,
      suggestKeywordsAttempt: suggestKeywordsAttempt,
      relatedSearchKeywords: relatedSearchKeywords,
      relatedSearch: rel.groups,
      appliedFilters: appliedFilters,
      filterPanelUsed: panelOpened,
      details: detailsStats,
      meta: { source: 'dom', bridge: 'search-bridge', version: VERSION },
    });
  }

  function search(args) { return dom_search(args || {}); }

  window.__jse_xhs_search__ = {
    __meta: { version: VERSION, name: 'search-bridge' },
    probe: probe,
    state: state,
    sessionState: sessionState,
    navigateSearch: navigateSearch,
    search: search,
    dom_search: dom_search,
  };

  return { ok: true, version: VERSION, name: 'search-bridge' };
})();
