// shared payload-controls util. // // Every payload block in the demo (tool call args, tool result, request // header body, response body, raw event drawer panes) wants the same three // affordances in its top-right corner: // // [ pretty ⇅ raw ] [ copy ] [ download ] // // The trace-detail-pane Fields card already ships a similar cluster // (`.trace-detail-fields-controls` with Expand-all + Copy). This util is // the shared implementation both surfaces should call so the two grammars // stay in lockstep (老板: "抽 shared util 对齐它,绝不做两套"). // // Contract // -------- // attachPayloadControls(host, opts) returns { controlsEl, preEl, setRaw }. // host — an existing DOM element the controls +
 are mounted
//                *into*. Caller decides the outer container / layout.
//   opts.getRaw       function returning the current raw payload (any JS
//                     value). Called every time the reader clicks copy /
//                     download / toggles view mode. Callers pass a getter
//                     (rather than a snapshot) so streaming payloads pick
//                     up the latest value; a `setRaw()` call from outside
//                     is *not* required unless you also swap the reference.
//   opts.kind         short label ('args' | 'result' | 'request' …). Used
//                     as the download filename stem and copy tooltip.
//   opts.filename?    override for the download filename (defaults to
//                     `${kind}-${Date.now()}.json`).
//   opts.startMode?   'pretty' (default) or 'raw'. 'pretty' pretty-prints
//                     JSON with 2-space indent; 'raw' shows JSON.stringify
//                     with no indent (verbatim wire on one line).
//   opts.prettyMax?   number, cap the pretty-print height; the internal
//                     
 already scrolls via .args/.result CSS so this
//                     is not required. Kept for callers not inheriting
//                     .tool-block styling.
//   opts.copyLabel?   default 'copy'. Left as a knob for surfaces that
//                     want 'copy JSON' (spec §4 uses "copy JSON" for
//                     tool-block L2).
//
// Returned setRaw(nextValue) lets streaming callers push a new payload
// without querying `getRaw` from the outside — but the default getter
// pattern is enough for tool-block args (immutable) and result (streamed
// via the caller reassigning its own reference before calling setRaw()).
//
// Design constraints
// ------------------
// - Zero framework: pure DOM, headless-safe (returns nulls when document
//   is absent — same guard trace-detail-pane uses).
// - Buttons carry a `.ghost.small` class + explicit `.payload-ctl-*`
//   hooks so tests can query without style drift.
// - copyText via navigator.clipboard, best-effort (same shape as
//   trace-detail-pane copyJson).
// - Download via Blob + createObjectURL + `` (mirrors
//   annotation-panel.js / bench-page.js — one idiom, one revoke).
// - No emoji: labels are text ('pretty' / 'raw' / 'copy' / 'download').
//   §7 baseline rules out ⧉ or unicode glyphs beyond what the app
//   already ships (`{ }` for JSON badges is pre-existing).
// - The controls container carries `.payload-controls` and lives before
//   the `
` in DOM order; CSS uses flex to shove it top-right of the
//   host without absolute positioning fights.
//
// Exposed as CommonJS + `window.__dshPayloadControls`.

'use strict'

;(function () {

function attachPayloadControls(host, opts) {
  if (!host || typeof host.appendChild !== 'function') return null
  opts = opts || {}
  const doc = (host.ownerDocument) || (typeof document !== 'undefined' ? document : null)
  if (!doc) return null

  const getRaw = typeof opts.getRaw === 'function' ? opts.getRaw : () => null
  const kind = String(opts.kind || 'payload')
  const filename = String(opts.filename || `${kind}-${Date.now()}.json`)
  const copyLabel = String(opts.copyLabel || 'copy')
  const startMode = opts.startMode === 'raw' ? 'raw' : 'pretty'

  // ---- controls cluster (top-right of host) ----------------------------
  const controls = doc.createElement('div')
  controls.className = 'payload-controls'
  controls.setAttribute('data-payload-kind', kind)

  const toggle = doc.createElement('button')
  toggle.type = 'button'
  toggle.className = 'ghost small payload-ctl-toggle'
  toggle.textContent = startMode === 'raw' ? 'raw' : 'pretty'
  toggle.title = 'Toggle between pretty (indented) and raw (one-line) JSON'

  const copyBtn = doc.createElement('button')
  copyBtn.type = 'button'
  copyBtn.className = 'ghost small payload-ctl-copy'
  copyBtn.textContent = copyLabel
  copyBtn.title = `Copy raw ${kind} JSON to clipboard`

  const dlBtn = doc.createElement('button')
  dlBtn.type = 'button'
  dlBtn.className = 'ghost small payload-ctl-download'
  dlBtn.textContent = 'download'
  dlBtn.title = `Download ${kind} JSON as a .json file`

  controls.appendChild(toggle)
  controls.appendChild(copyBtn)
  controls.appendChild(dlBtn)

  // ---- payload 
 ----------------------------------------------------
  const pre = doc.createElement('pre')
  pre.className = 'payload-body mono'
  pre.setAttribute('data-payload-kind', kind)
  let mode = startMode
  renderPre(pre, getRaw(), mode)

  host.appendChild(controls)
  host.appendChild(pre)

  // ---- wiring -----------------------------------------------------------
  if (toggle.addEventListener) {
    toggle.addEventListener('click', function (e) {
      if (e && e.stopPropagation) e.stopPropagation()
      mode = mode === 'raw' ? 'pretty' : 'raw'
      toggle.textContent = mode
      renderPre(pre, getRaw(), mode)
    })
  }
  if (copyBtn.addEventListener) {
    copyBtn.addEventListener('click', function (e) {
      if (e && e.stopPropagation) e.stopPropagation()
      copyText(prettyString(getRaw()))
    })
  }
  if (dlBtn.addEventListener) {
    dlBtn.addEventListener('click', function (e) {
      if (e && e.stopPropagation) e.stopPropagation()
      downloadJsonFile(doc, filename, prettyString(getRaw()))
    })
  }

  function setRaw(nextValue) {
    // Optional caller-driven refresh — used by streaming payloads that
    // don't want to pay for a getter closure over their own state.
    _lastSet = nextValue
    renderPre(pre, nextValue !== undefined ? nextValue : getRaw(), mode)
  }
  let _lastSet // eslint-disable-line no-unused-vars — reserved for future

  return { controlsEl: controls, preEl: pre, setRaw }
}

// ---- helpers (exported for tests) ------------------------------------

function renderPre(preEl, value, mode) {
  if (!preEl) return
  preEl.textContent = mode === 'raw' ? rawString(value) : prettyString(value)
}

// Pretty-print any value as JSON with 2-space indent. Strings that
// already parse as JSON get re-parsed so the reader sees indented
// structure rather than an escaped blob (same idea as tool-cards
// formatJson). Circular-safe via a WeakSet-backed replacer.
function prettyString(v) {
  return jsonStringifySafe(coerceForPretty(v), 2)
}

// One-line raw form: JSON.stringify with no indent, verbatim ordering
// (no re-parsing of embedded strings — this is the wire truth toggle).
function rawString(v) {
  return jsonStringifySafe(v, 0)
}

function coerceForPretty(v) {
  if (typeof v !== 'string') return v
  const trimmed = v.trim()
  if (!trimmed) return v
  if (trimmed[0] !== '{' && trimmed[0] !== '[') return v
  try { return JSON.parse(trimmed) } catch (_) { return v }
}

function jsonStringifySafe(v, indent) {
  if (v === undefined) return '(absent)'
  const seen = new WeakSet()
  const replacer = function (_k, val) {
    if (val && typeof val === 'object') {
      if (seen.has(val)) return '[Circular]'
      seen.add(val)
    }
    return val
  }
  try {
    return JSON.stringify(v, replacer, indent || 0)
  } catch (_) {
    try { return String(v) } catch (_e) { return '' }
  }
}

function copyText(s) {
  try {
    if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
      navigator.clipboard.writeText(String(s == null ? '' : s)).catch(function () {})
    }
  } catch (_) { /* best-effort */ }
}

function downloadJsonFile(doc, filename, content) {
  try {
    if (typeof Blob === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
      // Headless / sandboxed shell — silently no-op. Callers should
      // consider the download control aspirational in those contexts.
      return
    }
    const blob = new Blob([String(content == null ? '' : content)], { type: 'application/json' })
    const url = URL.createObjectURL(blob)
    const a = doc.createElement('a')
    a.href = url
    a.download = filename
    if (doc.body && typeof doc.body.appendChild === 'function') doc.body.appendChild(a)
    if (typeof a.click === 'function') a.click()
    if (a.parentNode && typeof a.parentNode.removeChild === 'function') a.parentNode.removeChild(a)
    setTimeout(function () { try { URL.revokeObjectURL(url) } catch (_) {} }, 0)
  } catch (_) { /* best-effort */ }
}

// ---- exports ---------------------------------------------------------

const api = {
  attachPayloadControls,
  // pure helpers exposed for unit tests + reuse
  prettyString,
  rawString,
  coerceForPretty,
  jsonStringifySafe,
}

if (typeof module !== 'undefined' && module.exports) module.exports = api
if (typeof window !== 'undefined') window.__dshPayloadControls = api

})();