'use strict';

// @js-eyes/visual-bridge-kit · node/runVisual.js
// ---------------------------------------------------------------------------
// before/after hook，包在 session.callApi 两端。
//
// 用法（单文件 trace 形态）：
//   const summary = await wrapCallApi(session, hint, async () => {
//     return await session.callApi(method, [args]);
//   });
//   const events = await drainVisualEvents(session);
//   appendVisualTrace(tracePath, { runId, toolName, args, hint, summary, events });
//
// 用法（会话包目录形态，给离线 hyperframes 重渲染用）：
//   appendVisualSession(recordDir, { runId, toolName, args, hint, ok, durationMs, events },
//                       { skillId, skillVersion });
//
// post-2.7.0 architecture pivot：
//   - PNG 截图（captureFrame）已从主链路下线，captureFrame.js 与 hooks.captureFrame
//     仍可用但仅作 dev / debug 入口，wrapCallApi/wrapInjectCall 不再主动调用。
//   - 主链路新增 hooks.extractPayload(resp, hint, err) => payload 钩子：
//       payload 由 skill 端按 hint.kind 抽取业务数据（list 类返回 items + meta，
//       item 类返回单条字段，tree 类返回 items + relations 等），由 wrapCallApi 写入
//       after event 的 payload 字段，下游 hyperframes translator 按 hint.kind 路由
//       到对应的 HTML 模板，渲染响应式卡片。
//   - 配套：events 不再带 anchor.rect / viewport / frameRef；flash 通过 anchor 的
//     id 与 HTML data-anchor-id 绑定，实现"卡片自适应 + flash 跟随"的零错位。
//
// hint shape:
//   {
//     kind: 'item' | 'list' | 'tree' | 'global' | 'navigation' | 'write',
//     toolName: string,
//     label: string,
//     anchor: string | object | null,    // fullname / selector / spec
//     target: string,
//     detail: string,
//     tone: 'info' | 'pending' | 'success' | 'danger',
//   }
//
// summary（after）shape:
//   {
//     ok: boolean,
//     items: Array<anchorSpec>,    // list 类型
//     relate: Array<{from,to,label}>,  // tree 类型
//     errorCode: string,
//     detail: string,
//   }
// ---------------------------------------------------------------------------

const SAFE = (x) => {
  try { return JSON.stringify(x); } catch (_) { return 'null'; }
};

function buildBeforeExpression(hint){
  const h = hint || {};
  const safe = {
    kind: h.kind || 'global',
    toolName: h.toolName || '',
    label: h.label || h.toolName || '',
    anchor: h.anchor || null,
    target: h.target || '',
    detail: h.detail || '',
    tone: h.tone || 'pending',
  };
  return '(window.__jse_visual && window.__jse_visual.before(' + SAFE(safe) + ')) || null';
}

function buildAfterExpression(hint, summary){
  const h = hint || {};
  const s = summary || {};
  const safeHint = {
    kind: h.kind || 'global',
    toolName: h.toolName || '',
    label: h.label || h.toolName || '',
    anchor: h.anchor || null,
    target: h.target || '',
    detail: h.detail || '',
    tone: h.tone || 'pending',
  };
  const safeSummary = {
    ok: s.ok !== false,
    items: Array.isArray(s.items) ? s.items.slice(0, 12) : [],
    relate: Array.isArray(s.relate) ? s.relate.slice(0, 64) : [],
    errorCode: s.errorCode || '',
    detail: s.detail || '',
    target: s.target || '',
    // post-2.7.0：业务数据 payload，由 hooks.extractPayload 提取
    payload: (s && typeof s.payload === 'object' && s.payload !== null) ? s.payload : null,
  };
  return '(window.__jse_visual && window.__jse_visual.after(' + SAFE(safeHint) + ',' + SAFE(safeSummary) + ')) || null';
}

function buildDrainExpression(){
  return '(window.__jse_visual && window.__jse_visual.drainEvents()) || []';
}

function buildConfigExpression(visualConfig){
  return '(window.__jse_visual && window.__jse_visual.config(' + SAFE(visualConfig || {}) + ')) || null';
}

async function callRawSafely(session, expression){
  if (!session || typeof session.callRaw !== 'function') return null;
  try {
    return await session.callRaw(expression, { timeoutMs: 5000 });
  } catch (_) {
    return null;
  }
}

// ----- Phase 2: captureFrame fire-and-forget helpers ------------------------
// v0.5.0: ext 可选（默认 png 兼容旧调用，snapshot mode 主链路传 'jpg'）
function buildFrameRef(ts, ext){
  const e = (ext && typeof ext === 'string') ? ext.replace(/^\./, '') : 'png';
  return 'frames/' + ts + '.' + e;
}

function safeCaptureFrame(captureFrame, info){
  if (typeof captureFrame !== 'function') return null;
  let ret;
  try {
    ret = captureFrame(info);
  } catch (_) { return info; }
  if (ret && typeof ret.then === 'function') {
    ret.then(() => {}, () => {});
  }
  return info;
}

// v0.5.0: snapshot-mode 主链路使用 awaitCaptureFrame，等截图完成 + onWritten
// 把 frame 事件 emit 进 ring buffer 后再返回，确保 drainVisualEvents 能取到
// 这一帧。失败/超时静默吞错，不阻塞主流程超过 timeoutMs。
async function awaitCaptureFrame(captureFrame, info, opts){
  if (typeof captureFrame !== 'function') return false;
  const timeoutMs = (opts && Number.isFinite(opts.timeoutMs)) ? opts.timeoutMs : 2500;
  let ret;
  try {
    ret = captureFrame(info);
  } catch (_) { return false; }
  if (!ret || typeof ret.then !== 'function') return true;
  let timer;
  const timeoutP = new Promise((resolve) => {
    timer = setTimeout(() => resolve(false), timeoutMs);
  });
  try {
    const result = await Promise.race([
      ret.then(() => true, () => false),
      timeoutP,
    ]);
    return !!result;
  } finally {
    clearTimeout(timer);
  }
}

function attachFrameRefsToEvents(events, frames){
  if (!Array.isArray(events) || !Array.isArray(frames) || frames.length === 0) return events;
  for (const f of frames) {
    if (!f || !f.ts || !f.when || !f.frameRef) continue;
    let candidate = null;
    let bestDelta = Infinity;
    for (const e of events) {
      if (!e || e.type !== f.when) continue;
      const eTs = Number(e.ts);
      if (!Number.isFinite(eTs)) continue;
      const delta = Math.abs(eTs - f.ts);
      if (delta < bestDelta) {
        bestDelta = delta;
        candidate = e;
      }
    }
    if (candidate && bestDelta <= 1500 && !candidate.frameRef) {
      candidate.frameRef = f.frameRef;
    }
  }
  return events;
}

/**
 * applyVisualConfig - 在 ensureBridge 之后下发一次 config。幂等。
 */
async function applyVisualConfig(session, visualConfig){
  if (!session || !visualConfig) return null;
  return callRawSafely(session, buildConfigExpression(visualConfig));
}

/**
 * wrapCallApi - 在 fn() 前后调 before/after。fn 必须返回 callApi 的 resp。
 *
 * @param {object} session - reddit-ops Session (必须有 callRaw)
 * @param {object} hint - visualHint
 * @param {(opts?: object) => Promise<any>} fn - 真正的 callApi 调用，接收 derivedHint
 * @param {object} [hooks]
 * @param {(resp, hint, err) => object} [hooks.buildSummary] - 默认 defaultBuildSummary
 * @param {(resp, hint, err) => object|null} [hooks.extractPayload] - **post-2.7.0 主链路**：
 *     抽业务数据塞到 after event 的 payload 字段，给 HTML translator 重渲卡片用
 * @param {Function} [hooks.captureFrame] - **dev only**：保留入口，但主链路不主动触发；
 *     如需 PNG 路线，自行从 dev/index.js 引入 makeFrameWriter 并显式传入此 hook
 */
async function wrapCallApi(session, hint, fn, hooks){
  await callRawSafely(session, buildBeforeExpression(hint));

  let resp = null;
  let err = null;
  try {
    resp = await fn(hint);
  } catch (e) {
    err = e;
  }
  const buildSummary = hooks && typeof hooks.buildSummary === 'function' ? hooks.buildSummary : defaultBuildSummary;
  const summary = buildSummary(resp, hint, err);

  // post-2.7.0：抽业务数据 payload 塞进 summary（由 buildAfterExpression 透传到
  // bridge.after，bridge.after emit 时把 payload 写到 after event）
  if (hooks && typeof hooks.extractPayload === 'function') {
    try {
      const payload = hooks.extractPayload(resp, hint, err);
      if (payload && typeof payload === 'object') {
        summary.payload = payload;
      }
    } catch (_) { /* extractPayload 失败不阻断主流程 */ }
  }

  await callRawSafely(session, buildAfterExpression(hint, summary));

  // JPEG snapshot：等 stagger flash outline 画完再 captureVisibleTab（否则帧里只有静态页面）。
  // 无 hooks.captureFrame 时不阻塞主链路。
  if (hooks && typeof hooks.captureFrame === 'function'
      && session && typeof session.callRaw === 'function') {
    try {
      await session.callRaw(
        '(window.__jse_visual && window.__jse_visual.awaitCaptureSettle && window.__jse_visual.awaitCaptureSettle()) || Promise.resolve()',
        { timeoutMs: 12000 },
      );
    } catch (_) {}
  }

  // v0.5.0 snapshot mode: 主链路在每个命令边界截一帧。
  //   - 默认 await，等 onWritten 把 frame 事件 emit 进 ring buffer，
  //     drainVisualEvents 才能拿到这一帧。
  //   - 配 hooks.captureFrameAwait === false 退回旧版 fire-and-forget。
  //   - frameFormat: 'jpg' (默认 snapshot) | 'png' | 自定义扩展名。
  if (hooks && typeof hooks.captureFrame === 'function') {
    try {
      const ts = Date.now();
      const when = err ? 'error' : 'after';
      const ext = (hooks && hooks.frameFormat) ? hooks.frameFormat : 'jpg';
      const info = { ts, when, frameRef: buildFrameRef(ts, ext), hint, summary };
      if (hooks.captureFrameAwait === false) {
        safeCaptureFrame(hooks.captureFrame, info);
      } else {
        await awaitCaptureFrame(hooks.captureFrame, info, { timeoutMs: hooks.captureFrameTimeoutMs });
      }
    } catch (_) {}
  }

  if (err) throw err;
  return resp;
}

function defaultBuildSummary(resp, hint, err){
  if (err) {
    return { ok: false, items: [], relate: [], errorCode: err.code || 'thrown', detail: err.message || '' };
  }
  if (!resp || typeof resp !== 'object') {
    return { ok: false, items: [], relate: [], errorCode: 'no_response', detail: '' };
  }
  if (resp.ok === false) {
    return {
      ok: false,
      items: [],
      relate: [],
      errorCode: resp.error || resp.code || 'unknown',
      detail: resp.message || '',
    };
  }
  return {
    ok: true,
    items: [],
    relate: [],
    errorCode: '',
    detail: '',
  };
}

/**
 * drainVisualEvents - 把 bridge 端 ring buffer 取回（破坏性读）。
 * 失败返回 []。
 */
async function drainVisualEvents(session){
  const events = await callRawSafely(session, buildDrainExpression());
  if (Array.isArray(events)) return events;
  return [];
}

/**
 * wrapInjectCall - 给 one-shot inject 模型用（每次工具调用是独立 executeScript，
 *                  没有长驻 bridge 的场景）。
 *
 * 区别于 wrapCallApi：
 *   - wrapCallApi 假定 visual.common.js 已 install 在 page world 中（reddit 那种长驻 bridge）
 *   - wrapInjectCall 每次 before 都把 visualKitSource 一并注入；installVisualBridgeKit
 *     的 IIFE 自带 __installed 短路锁，重复 inject 等于 0 成本
 *
 * 总成本：每次工具调用 +2 RTT（before+install 合并、after+drain 合并）。
 *
 * @param {object} ctx
 * @param {(expression: string, opts?: object) => Promise<any>} ctx.callRaw
 *        - 通常包装成 (expr) => browser.executeScript(tabId, expr)
 * @param {object} ctx.visualConfig - parseVisualFlags 输出的 config
 * @param {string} ctx.visualKitSource - visual.common.js + 站点 anchor resolver 拼接源码
 * @param {object} hint
 * @param {() => Promise<any>} fn - 真正的业务调用（一般是 browser.executeScript(tabId, businessIIFE)）
 * @param {object} [hooks] - { buildSummary?: (resp, hint, err) => summary }
 * @returns {Promise<{ result, events, durationMs, summary }>}
 */
async function wrapInjectCall(ctx, hint, fn, hooks){
  const startedAt = Date.now();
  if (!ctx || typeof ctx.callRaw !== 'function') {
    throw new Error('wrapInjectCall: ctx.callRaw is required');
  }
  if (typeof ctx.visualKitSource !== 'string' || !ctx.visualKitSource) {
    throw new Error('wrapInjectCall: ctx.visualKitSource is required');
  }
  const visualEnabled = !!(ctx.visualConfig && ctx.visualConfig.enabled !== false);
  const safeRaw = async (expression) => {
    try { return await ctx.callRaw(expression, { timeoutMs: 5000 }); }
    catch (_) { return null; }
  };

  if (visualEnabled) {
    const beforeBundle = ctx.visualKitSource
      + ';\n' + buildConfigExpression(ctx.visualConfig)
      + ';\n' + buildBeforeExpression(hint);
    await safeRaw(beforeBundle);
  }

  let result = null;
  let err = null;
  try { result = await fn(hint); }
  catch (e) { err = e; }

  const buildSummary = hooks && typeof hooks.buildSummary === 'function'
    ? hooks.buildSummary
    : defaultBuildSummary;
  const summary = buildSummary(result, hint, err);

  // post-2.7.0：业务数据 payload 走 summary.payload 透传到 bridge.after emit
  if (hooks && typeof hooks.extractPayload === 'function') {
    try {
      const payload = hooks.extractPayload(result, hint, err);
      if (payload && typeof payload === 'object') {
        summary.payload = payload;
      }
    } catch (_) {}
  }

  let events = [];
  if (visualEnabled) {
    await safeRaw(buildAfterExpression(hint, summary));
    if (hooks && typeof hooks.captureFrame === 'function') {
      try {
        await ctx.callRaw(
          '(window.__jse_visual && window.__jse_visual.awaitCaptureSettle && window.__jse_visual.awaitCaptureSettle()) || Promise.resolve()',
          { timeoutMs: 12000 },
        );
      } catch (_) {}
    }
    const drained = await safeRaw(buildDrainExpression());
    if (Array.isArray(drained)) events = drained;
  }

  // v0.5.0 snapshot mode（同 wrapCallApi）：默认 await，让 frame 事件进入 ring
  // buffer 再返回；inject 模式 events 已经在前面 drain 完，所以这里 emit 后
  // 直接 push 进调用方收到的 events 数组。
  if (hooks && typeof hooks.captureFrame === 'function' && visualEnabled) {
    try {
      const ts = Date.now();
      const when = err ? 'error' : 'after';
      const ext = (hooks && hooks.frameFormat) ? hooks.frameFormat : 'jpg';
      const info = { ts, when, frameRef: buildFrameRef(ts, ext), hint, summary };
      if (hooks.captureFrameAwait === false) {
        safeCaptureFrame(hooks.captureFrame, info);
      } else {
        const ok = await awaitCaptureFrame(hooks.captureFrame, info, { timeoutMs: hooks.captureFrameTimeoutMs });
        // wrapInjectCall 已经 drain 过事件，frame 事件需要回写进 events 数组
        if (ok && Array.isArray(events) && hooks.collectFrameEventLocally !== false) {
          events.push({ type: 'frame', ts, frameRef: info.frameRef });
        }
      }
    } catch (_) {}
  }

  if (err) throw err;
  return { result, events, durationMs: Date.now() - startedAt, summary };
}

module.exports = {
  applyVisualConfig,
  wrapCallApi,
  wrapInjectCall,
  drainVisualEvents,
  buildBeforeExpression,
  buildAfterExpression,
  buildDrainExpression,
  buildConfigExpression,
  defaultBuildSummary,
  // Phase 2 capture-frame helpers
  buildFrameRef,
  attachFrameRefsToEvents,
};
