/**
 * Tool renderer tests (Epics 2.2 + 2.4). Headless frames through the real App
 * tree: the registry's default renderer turns args into LABELED FIELDS — the
 * acceptance gate asserts NO raw JSON syntax (`{"` / `":`) ever reaches the
 * frame for tool parts, collapsed or expanded — delegate_task carries the
 * Ink-parity "(/agents to monitor)" hint, and the bash renderer shows the
 * command verbatim collapsed + the full (EXPANDED_MAX-capped) output expanded.
 * Expansion goes through the REAL mouse path: mockMouse clicks the header row
 * (found by scanning the frame). The long-output cap is asserted at the Body
 * level (a tall frame would otherwise hide the trailing note).
 */
import { describe, expect, test, vi } from 'vitest'

import { createSessionStore, type ToolPartState } from '../logic/store.ts'
import { DEFAULT_THEME } from '../logic/theme.ts'
import { App } from '../view/App.tsx'
import { reasoningLabelStyle } from '../view/reasoningPart.tsx'
import { ThemeProvider } from '../view/theme.tsx'
import { toolNameStyle } from '../view/toolPart.tsx'
import { BashToolBody, commandOf } from '../view/tools/bashTool.tsx'
import { diffOutputPlan, FileToolBody } from '../view/tools/fileTool.tsx'
import { renderProbe, type RenderProbe } from './lib/render.ts'

type Store = ReturnType<typeof createSessionStore>

/** Seed a settled assistant turn containing exactly the given tool call. */
function seedTool(store: Store, start: Record<string, unknown>, complete: Record<string, unknown>) {
  store.apply({ type: 'gateway.ready' })
  store.apply({ type: 'message.start' })
  store.apply({ type: 'tool.start', payload: start })
  store.apply({ type: 'tool.complete', payload: complete })
  store.apply({ type: 'message.complete' })
}

async function mountApp(store: Store, width = 80, height = 24): Promise<RenderProbe> {
  return renderProbe(
    () => (
      <ThemeProvider theme={() => store.state.theme}>
        <App store={store} />
      </ThemeProvider>
    ),
    { width, height }
  )
}

/** Click the tool header row (the line containing `name`) to expand/collapse. */
async function clickHeader(probe: RenderProbe, name: string): Promise<void> {
  const frame = await probe.waitForFrame(f => f.includes(name))
  const rows = frame.split('\n')
  const y = rows.findIndex(line => line.includes(name))
  expect(y).toBeGreaterThanOrEqual(0)
  const x = (rows[y] ?? '').indexOf(name)
  await probe.click(x, y)
}

describe('tool renderer registry — labeled-args default (Epic 2.2)', () => {
  test('an unmapped MCP-ish tool with nested args renders labeled fields, never raw JSON', async () => {
    const store = createSessionStore()
    seedTool(
      store,
      { tool_id: 'm1', name: 'mcp_lookup' },
      {
        tool_id: 'm1',
        name: 'mcp_lookup',
        args: {
          query: 'hermes agent',
          options: { depth: 2, mode: 'fast', cache: true },
          limit: 5
        },
        duration_s: 0.4,
        result_text: 'one result found'
      }
    )

    const probe = await mountApp(store)
    try {
      // collapsed: header only, and already no JSON syntax anywhere
      const collapsed = await probe.waitForFrame(f => f.includes('mcp_lookup'))
      expect(collapsed).not.toContain('{"')
      expect(collapsed).not.toContain('":')

      await clickHeader(probe, 'mcp_lookup')
      const expanded = await probe.waitForFrame(f => f.includes('query'))
      // labeled key → value rows (string verbatim, number via String)
      expect(expanded).toContain('query')
      expect(expanded).toContain('hermes agent')
      expect(expanded).toContain('limit')
      expect(expanded).toContain('5')
      // nested object summarized, not dumped
      expect(expanded).toContain('options')
      expect(expanded).toContain('(3 fields)')
      // the output body still renders (envelope-stripped store text)
      expect(expanded).toContain('one result found')
      // THE acceptance gate: no raw JSON syntax in the tool render
      expect(expanded).not.toContain('{"')
      expect(expanded).not.toContain('":')
      expect(expanded).not.toContain('depth') // nested internals stay summarized
    } finally {
      probe.destroy()
    }
  })

  test('delegate_task gets the default renderer plus the muted "(/agents to monitor)" hint', async () => {
    const store = createSessionStore()
    seedTool(
      store,
      { tool_id: 'd1', name: 'delegate_task', context: 'research opentui' },
      {
        tool_id: 'd1',
        name: 'delegate_task',
        args: { goal: 'research opentui', model: 'fast' },
        result_text: 'spawned'
      }
    )

    const probe = await mountApp(store)
    try {
      const frame = await probe.waitForFrame(f => f.includes('(/agents to monitor)'))
      expect(frame).toContain('delegate_task')
      expect(frame).toContain('research opentui') // primary-arg preview still leads
      expect(frame).not.toContain('{"') // hint or not — still no raw JSON
    } finally {
      probe.destroy()
    }
  })
})

describe('bash tool renderer — command + full output (Epic 2.4)', () => {
  test('collapsed header shows the invoked command VERBATIM (args win over the gateway preview)', async () => {
    const store = createSessionStore()
    seedTool(
      store,
      // the gateway's one-line preview is truncated — args.command is the truth
      { tool_id: 'b1', name: 'terminal', context: 'grep -rn needle' },
      {
        tool_id: 'b1',
        name: 'terminal',
        args: { command: 'grep -rn needle src/ | head -5', timeout: 60 },
        duration_s: 0.2,
        result_text: 'a.ts:1:needle\nb.ts:2:needle\nc.ts:3:needle'
      }
    )

    const probe = await mountApp(store)
    try {
      const frame = await probe.waitForFrame(f => f.includes('grep -rn needle src/ | head -5'))
      expect(frame).toContain('terminal')
      expect(frame).toContain('grep -rn needle src/ | head -5') // verbatim, not the preview
      expect(frame).toContain('(3 lines)') // output stays behind the expand affordance
      expect(frame).not.toContain('a.ts:1:needle') // collapsed → no output shown
    } finally {
      probe.destroy()
    }
  })

  test('expanded shows the $ command and the FULL (short) output', async () => {
    const store = createSessionStore()
    seedTool(
      store,
      { tool_id: 'b2', name: 'terminal' },
      {
        tool_id: 'b2',
        name: 'terminal',
        args: { command: 'ls' },
        result_text: 'alpha.txt\nbeta.txt\ngamma.txt'
      }
    )

    const probe = await mountApp(store)
    try {
      await clickHeader(probe, 'terminal')
      const expanded = await probe.waitForFrame(f => f.includes('alpha.txt'))
      expect(expanded).toContain('$ ls') // the invocation, prompt-prefixed
      expect(expanded).toContain('output') // section label
      expect(expanded).toContain('alpha.txt') // full output…
      expect(expanded).toContain('beta.txt')
      expect(expanded).toContain('gamma.txt') // …down to the last line
    } finally {
      probe.destroy()
    }
  })

  test('long output is capped to EXPANDED_MAX with an honest "+N more lines" note', async () => {
    const lines = Array.from({ length: 250 }, (_, i) => `line-${String(i + 1).padStart(3, '0')}`)
    const part: ToolPartState = {
      type: 'tool',
      id: 'b3',
      name: 'execute_code',
      state: 'complete',
      args: { code: 'for i in range(250): print(i)' },
      resultText: lines.join('\n')
    }
    // Body-level mount (tall frame so the trailing note row is on screen).
    const probe = await renderProbe(
      () => (
        <ThemeProvider>
          <BashToolBody part={part} width={70} />
        </ThemeProvider>
      ),
      { width: 80, height: 210 }
    )
    try {
      const frame = await probe.waitForFrame(f => f.includes('+50 more lines'))
      expect(frame).toContain('$ for i in range(250): print(i)')
      expect(frame).toContain('line-001') // the cap keeps the HEAD of the output
      expect(frame).toContain('line-200') // …up to EXPANDED_MAX
      expect(frame).not.toContain('line-201') // the rest is honestly elided
      expect(frame).toContain('… +50 more lines')
    } finally {
      probe.destroy()
    }
  })

  test('a gateway-capped result renders the tidy omitted note', async () => {
    const part: ToolPartState = {
      type: 'tool',
      id: 'b4',
      name: 'terminal',
      state: 'complete',
      args: { command: 'cat big.log' },
      resultText: 'tail line one\ntail line two',
      omittedNote: '120 lines / 9001 chars'
    }
    const probe = await renderProbe(
      () => (
        <ThemeProvider>
          <BashToolBody part={part} width={70} />
        </ThemeProvider>
      ),
      { width: 80, height: 12 }
    )
    try {
      const frame = await probe.waitForFrame(f => f.includes('omitted'))
      expect(frame).toContain('tail line one')
      expect(frame).toContain('… omitted 120 lines / 9001 chars')
    } finally {
      probe.destroy()
    }
  })
})

describe('file tool renderer — relative path + diff stats (Epic 2.3)', () => {
  // NOTE: the EXPANDED native <diff> is deliberately untested here — like
  // <markdown> it tokenizes via Tree-sitter ASYNCHRONOUSLY and may not settle
  // in the headless renderer. The diff visuals belong to the live smoke; these
  // tests pin the LOGIC surface (collapsed header, fallback body).
  const DIFF = ['--- a/src/main.ts', '+++ b/src/main.ts', '@@ -1,3 +1,4 @@', ' ctx', '-old', '+new', '+more'].join('\n')

  test('collapsed write_file shows the cwd-RELATIVE path and the themed +N −M stats', async () => {
    const store = createSessionStore()
    store.apply({ type: 'session.info', payload: { cwd: '/home/u/proj' } })
    seedTool(
      store,
      { tool_id: 'f1', name: 'write_file', context: '/home/u/proj/src/main.ts' },
      {
        tool_id: 'f1',
        name: 'write_file',
        args: { path: '/home/u/proj/src/main.ts', content: 'new\nmore\n' },
        diff_unified: DIFF + '\n',
        duration_s: 0.1,
        result: '{"success": true}'
      }
    )

    const probe = await mountApp(store)
    try {
      const frame = await probe.waitForFrame(f => f.includes('write_file'))
      expect(frame).toContain('src/main.ts') // relative to the session cwd…
      expect(frame).not.toContain('/home/u/proj/src/main.ts') // …never absolute
      expect(frame).toContain('+2') // added (excludes the +++ header)
      expect(frame).toContain('−1') // removed (excludes the --- header)
    } finally {
      probe.destroy()
    }
  })

  test('read_file gets NO diff body — expanded falls back to labeled fields + output', async () => {
    const store = createSessionStore()
    store.apply({ type: 'session.info', payload: { cwd: '/home/u/proj' } })
    seedTool(
      store,
      { tool_id: 'f2', name: 'read_file' },
      {
        tool_id: 'f2',
        name: 'read_file',
        args: { path: '/home/u/proj/notes.md', limit: 50 },
        result_text: '1|# Notes\n2|hello'
      }
    )

    const probe = await mountApp(store)
    try {
      const collapsed = await probe.waitForFrame(f => f.includes('read_file'))
      expect(collapsed).toContain('notes.md') // relpath subtitle
      expect(collapsed).not.toContain('+0') // no diff → no stats summary

      await clickHeader(probe, 'read_file')
      const expanded = await probe.waitForFrame(f => f.includes('limit'))
      expect(expanded).toContain('path') // default labeled fields…
      expect(expanded).toContain('50')
      expect(expanded).toContain('# Notes') // …and the output body
      expect(expanded).not.toContain('@@') // never a diff
    } finally {
      probe.destroy()
    }
  })

  test('store: tool.complete diff_unified lands on the part with computed stats', () => {
    const store = createSessionStore()
    seedTool(
      store,
      { tool_id: 'f3', name: 'patch' },
      {
        tool_id: 'f3',
        name: 'patch',
        args: { mode: 'replace', path: 'x.py' },
        diff_unified: DIFF
      }
    )
    const last = store.state.messages[store.state.messages.length - 1]
    const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'f3')
    expect(part?.diffUnified).toBe(DIFF)
    expect(part?.diffStats).toEqual({ added: 2, removed: 1 })
  })
})

describe('file tool — output suppression under a rendered diff (no raw JSON, ever)', () => {
  // A file-edit result is a JSON record whose payload IS the diff. In a verbose
  // session the gateway REDACTS + CAPS result_text, so it can arrive truncated
  // mid-JSON (unparseable) — that JSON-looking blob must never render below the
  // native diff. Plain-text results (lint tails etc.) must still render.
  const DIFF = ['--- a/x.py', '+++ b/x.py', '@@ -1,2 +1,2 @@', ' ctx', '-old', '+new'].join('\n')

  const part = (resultText: string): ToolPartState => ({
    type: 'tool',
    id: 'fp1',
    name: 'patch',
    state: 'complete',
    args: { path: '/p/x.py' },
    resultText,
    diffUnified: DIFF,
    diffStats: { added: 1, removed: 1 }
  })

  test('diffOutputPlan: truncated/unparseable JSON is suppressed; plain text renders; JSON warnings surface', () => {
    // gateway-capped mid-JSON (unparseable, still contains "diff") → suppress
    const capped = '{"success": true, "diff": "--- a/x.py\\n+++ b/x.py\\n@@ -1,2 +1'
    expect(diffOutputPlan(part(capped))).toEqual({ kind: 'suppress' })
    // intact JSON echo of the diff → suppress
    expect(diffOutputPlan(part(JSON.stringify({ success: true, diff: DIFF })))).toEqual({ kind: 'suppress' })
    // plain text (lint tail) → full output block
    expect(diffOutputPlan(part('warning: trailing whitespace on line 3'))).toEqual({ kind: 'output' })
    // parseable JSON carrying real non-diff signal → just the notes
    expect(diffOutputPlan(part(JSON.stringify({ success: true, diff: DIFF, warning: 'mode fallback' })))).toEqual({
      kind: 'notes',
      notes: [['warning', 'mode fallback']]
    })
  })

  test('diffOutputPlan: tail-capped echo that LOST the JSON head (normalized to diff lines) is suppressed', () => {
    // A long file-edit JSON tail-capped past its `{"success"…` head: the store
    // un-escapes the literal \n so it arrives as plain lines that ARE diff
    // lines (first/last cut mid-line) — live bug shape from the v6 smoke.
    const tallDiff = [
      '--- a/x.py',
      '+++ b/x.py',
      '@@ -1,1 +1,9 @@',
      ' ctx',
      ...Array.from({ length: 8 }, (_, i) => `+def fn_${i}() -> int: return ${i}`)
    ].join('\n')
    const echoTail = [
      'n 1', // cut mid-line
      ...Array.from({ length: 6 }, (_, i) => `+def fn_${i + 2}() -> int: return ${i + 2}`),
      '", "files_modified": ["/p/x.py' // cut mid-JSON
    ].join('\n')
    expect(diffOutputPlan({ ...part(echoTail), diffUnified: tallDiff })).toEqual({ kind: 'suppress' })
    // …but a genuine plain-text tail sharing no lines with the diff still renders
    const lintTail = ['x.py:3: W291 trailing whitespace', 'x.py:9: E302 expected 2 blank lines', '2 warnings'].join(
      '\n'
    )
    expect(diffOutputPlan({ ...part(lintTail), diffUnified: tallDiff })).toEqual({ kind: 'output' })
  })

  test('TRUNCATED JSON result under a rendered diff → NO output block in the frame', async () => {
    const capped = '{"success": true, "diff": "--- a/x.py\\n+++ b/x.py\\n@@ -1,2 +1'
    const probe = await renderProbe(
      () => (
        <ThemeProvider>
          <FileToolBody part={part(capped)} width={70} />
        </ThemeProvider>
      ),
      { width: 80, height: 16 }
    )
    try {
      // wait for the native <diff> to paint (Tree-sitter settles async)
      const frame = await probe.waitForFrame(f => f.includes('new'))
      expect(frame).not.toContain('output') // no output section label
      expect(frame).not.toContain('{"') // and never raw JSON
      expect(frame).not.toContain('success')
    } finally {
      probe.destroy()
    }
  })

  test('plain-text result under a rendered diff → output block still shown', async () => {
    const probe = await renderProbe(
      () => (
        <ThemeProvider>
          <FileToolBody part={part('warning: trailing whitespace on line 3')} width={70} />
        </ThemeProvider>
      ),
      { width: 80, height: 16 }
    )
    try {
      const frame = await probe.waitForFrame(f => f.includes('trailing whitespace'))
      expect(frame).toContain('output') // labeled output section
      expect(frame).toContain('warning: trailing whitespace on line 3')
    } finally {
      probe.destroy()
    }
  })
})

describe('tool lifecycle states — running / done / failed (Epic 2.5)', () => {
  // Fake ONLY setInterval/clearInterval/Date: the shared elapsed tick + the
  // Date.now() it invalidates. setTimeout/microtasks stay REAL — the test
  // renderer's settle dance (flush/waitForFrame) depends on them, and the
  // render scheduler itself times via performance.now (unfaked).
  const FAKED = ['setInterval', 'clearInterval', 'Date'] as const

  test('store: tool.start stamps startedAt; tool.complete settles the gateway duration', () => {
    vi.useFakeTimers({ toFake: [...FAKED] })
    try {
      vi.setSystemTime(1_750_000_000_000)
      const store = createSessionStore()
      store.apply({ type: 'gateway.ready' })
      store.apply({ type: 'message.start' })
      store.apply({ type: 'tool.start', payload: { tool_id: 'l1', name: 'terminal', context: 'sleep 8' } })
      const live = store.state.messages[store.state.messages.length - 1]
      const part = live?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'l1')
      expect(part?.startedAt).toBe(1_750_000_000_000)
      expect(part?.state).toBe('running')
      // the gateway's duration_s remains the settled truth, startedAt untouched
      store.apply({ type: 'tool.complete', payload: { tool_id: 'l1', name: 'terminal', duration_s: 8.2 } })
      expect(part?.state).toBe('complete')
      expect(part?.duration).toBe(8.2)
      expect(part?.startedAt).toBe(1_750_000_000_000)
    } finally {
      vi.useRealTimers()
    }
  })

  test('store: a `{"error": …}` result derives part.error (the gateway never ships a top-level error)', () => {
    const store = createSessionStore()
    seedTool(
      store,
      { tool_id: 'l2', name: 'read_file' },
      { tool_id: 'l2', name: 'read_file', result: { error: 'File not found:\n /nope.txt' } }
    )
    const last = store.state.messages[store.state.messages.length - 1]
    const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'l2')
    expect(part?.error).toBe('File not found: /nope.txt') // flattened to one line
    // …and a clean result stays un-failed
    seedTool(
      store,
      { tool_id: 'l3', name: 'terminal' },
      { tool_id: 'l3', name: 'terminal', result: { exit_code: 0, output: 'ok' } }
    )
    const part3 = store.state.messages[store.state.messages.length - 1]?.parts?.find(
      (p): p is ToolPartState => p.type === 'tool' && p.id === 'l3'
    )
    expect(part3?.error).toBeUndefined()
  })

  test('running tool shows ⚡ + a LIVE elapsed that advances with the clock — and no expand glyph', async () => {
    vi.useFakeTimers({ toFake: [...FAKED] })
    try {
      const store = createSessionStore()
      store.apply({ type: 'gateway.ready' })
      store.apply({ type: 'message.start' })
      store.apply({ type: 'tool.start', payload: { tool_id: 'r1', name: 'terminal', context: 'sleep 8' } })

      const probe = await mountApp(store)
      try {
        const f0 = await probe.waitForFrame(f => f.includes('terminal'))
        expect(f0).toContain('⚡') // running head glyph
        expect(f0).toContain('sleep 8') // subtitle shows while running
        expect(f0).toContain('· 0s') // elapsed starts at zero
        expect(f0).not.toContain('▶') // NO expand affordance while running
        expect(f0).not.toContain('▼')

        vi.advanceTimersByTime(3000) // shared tick fires 3× → repaint
        const f3 = await probe.waitForFrame(f => f.includes('· 3s'))
        expect(f3).toContain('· 3s')
        expect(f3).not.toContain('· 0s')

        vi.advanceTimersByTime(9000) // …and keeps advancing (12s total)
        const f12 = await probe.waitForFrame(f => f.includes('· 12s'))
        expect(f12).toContain('· 12s')
        expect(f12).toContain('⚡') // still running, still no ▶/▼
        expect(f12).not.toContain('▶')
      } finally {
        probe.destroy()
      }
    } finally {
      vi.useRealTimers()
    }
  })

  test('failed tool reads as failed from the HEAD GLYPH (✗) and stays expandable when there is a body', async () => {
    const store = createSessionStore()
    seedTool(
      store,
      { tool_id: 'e1', name: 'terminal', context: 'false' },
      {
        tool_id: 'e1',
        name: 'terminal',
        args: { command: 'false' },
        error: 'exit status 1',
        result_text: 'boom line one\nboom line two',
        duration_s: 0.1
      }
    )

    const probe = await mountApp(store)
    try {
      const frame = await probe.waitForFrame(f => f.includes('terminal'))
      const row = frame.split('\n').find(line => line.includes('terminal')) ?? ''
      // ✗ IN the head-glyph position (immediately before the name, after the
      // assistant row's `⚕` gutter), replacing the expand glyph…
      expect(row).toContain('✗ terminal')
      expect(row).not.toContain('▶')
      expect(row).toContain('✗ exit status 1') // error subtitle stays
      expect(row).toContain('(2 lines)') // body presence still signposted

      await clickHeader(probe, 'terminal') // still expandable: body has the output
      const expanded = await probe.waitForFrame(f => f.includes('boom line one'))
      expect(expanded).toContain('boom line one')
      expect(expanded).toContain('boom line two')
    } finally {
      probe.destroy()
    }
  })

  test('settled success keeps the ▶/▼ + duration contract (glyph never error-colored ✗)', async () => {
    const store = createSessionStore()
    seedTool(
      store,
      { tool_id: 'k1', name: 'terminal', context: 'ls' },
      { tool_id: 'k1', name: 'terminal', args: { command: 'ls' }, result_text: 'a\nb', duration_s: 0.3 }
    )

    const probe = await mountApp(store)
    try {
      const frame = await probe.waitForFrame(f => f.includes('terminal'))
      const row = frame.split('\n').find(line => line.includes('terminal')) ?? ''
      expect(row).toContain('▶ terminal') // head-glyph position, after the ⚕ gutter
      expect(row).toContain('· 0.3s')
      expect(row).not.toContain('✗')
      expect(row).not.toContain('⚡')

      await clickHeader(probe, 'terminal')
      const expanded = await probe.waitForFrame(f => f.includes('▼'))
      expect(expanded).toContain('▼ terminal')
    } finally {
      probe.destroy()
    }
  })
})

describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only env var)', () => {
  const ENV = 'HERMES_TUI_TOOL_OUTPUT_LINES'

  /** Run `fn` with the flag set to `value` (undefined → unset), restoring after. */
  async function withFlag(value: string | undefined, fn: () => Promise<void> | void) {
    const prev = process.env[ENV]
    if (value === undefined) delete process.env[ENV]
    else process.env[ENV] = value
    try {
      await fn()
    } finally {
      if (prev === undefined) delete process.env[ENV]
      else process.env[ENV] = prev
    }
  }

  test('flag=0 (unlimited): a 250-line output renders ALL lines with NO "+N more lines" note', async () => {
    await withFlag('0', async () => {
      const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
      const part: ToolPartState = {
        type: 'tool',
        id: 'u1',
        name: 'terminal',
        state: 'complete',
        args: { command: 'seq 1 250' },
        resultText: lines.join('\n')
      }
      const probe = await renderProbe(
        () => (
          <ThemeProvider>
            <BashToolBody part={part} width={70} />
          </ThemeProvider>
        ),
        { width: 80, height: 260 }
      )
      try {
        const frame = await probe.waitForFrame(f => f.includes('row-250'))
        expect(frame).toContain('row-001')
        expect(frame).toContain('row-200')
        expect(frame).toContain('row-201') // beyond the old 200-line cap…
        expect(frame).toContain('row-250') // …down to the very last line
        expect(frame).not.toContain('more lines') // and no truncation note
      } finally {
        probe.destroy()
      }
    })
  })

  test('flag unset: the same 250-line output still caps at 200 + the honest note (default unchanged)', async () => {
    await withFlag(undefined, async () => {
      const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
      const part: ToolPartState = {
        type: 'tool',
        id: 'u2',
        name: 'terminal',
        state: 'complete',
        args: { command: 'seq 1 250' },
        resultText: lines.join('\n')
      }
      const probe = await renderProbe(
        () => (
          <ThemeProvider>
            <BashToolBody part={part} width={70} />
          </ThemeProvider>
        ),
        { width: 80, height: 260 }
      )
      try {
        const frame = await probe.waitForFrame(f => f.includes('+50 more lines'))
        expect(frame).toContain('row-200')
        expect(frame).not.toContain('row-201')
        expect(frame).toContain('… +50 more lines')
      } finally {
        probe.destroy()
      }
    })
  })

  test('store: flag set + gateway tail-cap (omittedNote) + full raw result → body derived from the raw result', async () => {
    const full = Array.from({ length: 250 }, (_, i) => `full-${i + 1}`).join('\n')
    const cappedTail = ['full-241', 'full-242', 'full-243'].join('\n') // what the gateway kept
    const payload = {
      tool_id: 'p1',
      name: 'terminal',
      args: { command: 'seq 1 250' },
      result_text: `[showing verbose tail; omitted 240 lines / 2000 chars]\n${cappedTail}`,
      result: { output: full, exit_code: 0 }
    }
    const partOf = (store: Store) =>
      store.state.messages[store.state.messages.length - 1]?.parts?.find(
        (p): p is ToolPartState => p.type === 'tool' && p.id === 'p1'
      )

    await withFlag('0', () => {
      const store = createSessionStore()
      seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
      const part = partOf(store)
      // the FULL raw result wins: longer than the capped tail, head included
      expect(part?.resultText).toContain('full-1\n')
      expect(part?.resultText).toContain('full-250')
      expect(part?.omittedNote).toBeUndefined() // the note no longer applies
    })

    // flag UNSET → today's behavior: the gateway tail + the tidy omitted note
    await withFlag(undefined, () => {
      const store = createSessionStore()
      seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
      const part = partOf(store)
      expect(part?.resultText).toBe(cappedTail)
      expect(part?.resultText).not.toContain('full-1\n')
      expect(part?.omittedNote).toBe('240 lines / 2000 chars')
    })

    // flag set but NO raw result on the wire → keep the tail + note (no crash)
    await withFlag('0', () => {
      const store = createSessionStore()
      const withoutRaw: Record<string, unknown> = { ...payload }
      delete withoutRaw['result']
      seedTool(store, { tool_id: 'p1', name: 'terminal' }, withoutRaw)
      const part = partOf(store)
      expect(part?.resultText).toBe(cappedTail)
      expect(part?.omittedNote).toBe('240 lines / 2000 chars')
    })
  })
})

describe('tool-name emphasis + thought styling (feedback: undifferentiated muted rows)', () => {
  const color = DEFAULT_THEME.color

  test('toolNameStyle: settled name is PRIMARY (text color + bold); subtitle stays muted by the shell', () => {
    expect(toolNameStyle({ failed: false, running: false }, color)).toEqual({ bold: true, fg: color.text })
    expect(color.text).not.toBe(color.muted) // the emphasis is real, not a no-op
  })

  test('toolNameStyle: failed keeps the error coloring (it wins), running keeps its muted treatment', () => {
    expect(toolNameStyle({ failed: true, running: false }, color)).toEqual({ bold: true, fg: color.error })
    expect(toolNameStyle({ failed: false, running: true }, color)).toEqual({ bold: false, fg: color.muted })
  })

  test('reasoningLabelStyle: muted + ITALIC — a different KIND of row than a tool, never louder', () => {
    expect(reasoningLabelStyle(color)).toEqual({ fg: color.muted, italic: true })
  })
})

describe('redaction precedence — gateway args_text wins over raw args (security)', () => {
  // The gateway redacts verbose `args_text` (server.py _tool_args_text) but
  // sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must
  // parse argsText first so masked secrets never render unmasked.

  test('labeled fields render the redacted args_text value, never the raw args secret', async () => {
    const store = createSessionStore()
    seedTool(
      store,
      // verbose session: tool.start carries the gateway-redacted args_text
      {
        tool_id: 's1',
        name: 'mcp_call',
        args_text: JSON.stringify({ api_key: 'sk-****', endpoint: 'v1/users' }, null, 2)
      },
      // tool.complete carries the raw, UNREDACTED args dict
      {
        tool_id: 's1',
        name: 'mcp_call',
        args: { api_key: 'sk-secret123', endpoint: 'v1/users' },
        result_text: 'done'
      }
    )

    const probe = await mountApp(store)
    try {
      await clickHeader(probe, 'mcp_call')
      const expanded = await probe.waitForFrame(f => f.includes('api_key'))
      expect(expanded).toContain('sk-****') // the gateway's redaction survives
      expect(expanded).not.toContain('sk-secret123') // the raw secret never renders
      expect(expanded).toContain('endpoint') // non-secret fields still labeled
      expect(expanded).toContain('v1/users')
    } finally {
      probe.destroy()
    }
  })

  test('commandOf prefers the redacted args_text parse over the raw args command', () => {
    const store = createSessionStore()
    seedTool(
      store,
      {
        tool_id: 's2',
        name: 'terminal',
        args_text: JSON.stringify({ command: 'curl -H "Authorization: sk-****" api.test' })
      },
      {
        tool_id: 's2',
        name: 'terminal',
        args: { command: 'curl -H "Authorization: sk-secret123" api.test' },
        result_text: 'ok'
      }
    )
    // Going through the real store also pins the invariant this fix relies on:
    // tool.complete back-fills argsText only when ABSENT — the redacted
    // tool.start args_text is never overwritten.
    const last = store.state.messages[store.state.messages.length - 1]
    const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 's2')
    expect(part).toBeDefined()
    expect(part?.argsText).toContain('sk-****')
    const cmd = commandOf(part as ToolPartState)
    expect(cmd).toContain('sk-****') // a masked command IS the correct display
    expect(cmd).not.toContain('sk-secret123')
  })

  test('absent or unparseable argsText falls back to raw args (non-verbose parity)', () => {
    // no argsText at all → raw args, same as the previous behavior
    const bare: ToolPartState = {
      type: 'tool',
      id: 's3',
      name: 'terminal',
      state: 'complete',
      args: { command: 'ls -la' }
    }
    expect(commandOf(bare)).toBe('ls -la')
    // argsText capped mid-JSON (unparseable) → raw args still render
    const capped: ToolPartState = {
      type: 'tool',
      id: 's4',
      name: 'terminal',
      state: 'complete',
      args: { command: 'echo hi' },
      argsText: '{"command": "echo h'
    }
    expect(commandOf(capped)).toBe('echo hi')
  })
})
