// @vitest-environment jsdom
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react
// renderer) + this package's own apply — no outlet twins. Proves the keyed
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
// entryKey (the bash sample lands through its plugin), unregistered tools
// fall back to GenericToolCard at the render site, live registration/unload
// flips rows in place, duplicate keys fail loud, the inject channel feeds
// (sessionId) => I into row components, and a registrant can activate before
// the declaration then land through slots.inject when the chat entry appears.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent } from '@testing-library/react'
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'

const SID = 's1' as SessionId

/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
  observe(): void {}
  unobserve(): void {}
  disconnect(): void {}
}

afterEach(() => {
  cleanup()
  vi.unstubAllGlobals()
})
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
  localStorage.clear()
  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})

const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
  kind: 'tool-result', seq, time: seq * 1_000, callId,
  call: { name, argsRaw: args },
  callTime: seq * 1_000 - 500,
  content: [], isError: false, callView: null, resultView: null,
})

/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
  return <>{renderSlot('conversation', {})}</>
}

const LAYOUT_CHILDREN = {
  'conversation': { kind: 'single', scope: 'session-maybe' },
  'details': { kind: 'single', scope: 'session' },
} as const

/**
 * Real-stack bench: SlotTestRuntime with the session/layout doubles at the
 * service seams only (external boundaries), the package apply on its own
 * fiber, and the test AppFrame occupying 'root'.
 */
async function bench(nodes: ToolResultNode[]) {
  const runtime = await SlotTestRuntime.create()
  const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
  runtime.provide('layout', layout)
  const locale = new LocaleService(runtime.ctx)
  runtime.provide('locale', locale)
  runtime.slots.installLocale(locale)
  await runtime.sessions.add({
    id: SID,
    summary: { title: 'S', displayTitle: 'S' },
    snapshot: { nodes },
    session: {
      loadOlder: vi.fn<ISession['loadOlder']>(),
      prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
    },
  })
  await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
  const feature = await runtime.mount({ inject: [...inject], apply })
  return { runtime, slots: runtime.slots, feature, layout }
}

describe('keyed toolview hole through the real machinery', () => {
  it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
    const b = await bench([
      toolResult(3, 'c1', 'bash'),
      toolResult(4, 'c2', 'mystery', '{"n":1}'),
    ])
    const view = b.runtime.renderRoot()
    // bash: the sample plugin's keyed registration took the row (root
    // session → global arm, decided inside the component off useSessions).
    expect(view.container.querySelector('[data-sample="bash"]')).not.toBeNull()
    expect(view.getByText('Bash')).toBeTruthy()
    expect(view.getByText('Build')).toBeTruthy()
    // mystery: no registration under that key → render-site fallback.
    expect(view.getByText('Tool call')).toBeTruthy()
    await b.runtime.dispose()
  })

  it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
    const code = 'return { name: "audit", apply(ctx) {} }'
    const b = await bench([
      toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
      toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
      toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
    ])
    const view = b.runtime.renderRoot()

    expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
    const mounted = view.container.querySelector('[data-variant="code"]')
    expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
    expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
      .toContain('Unmount temporary Plugindyn-2')

    fireEvent.click(mounted!.querySelector('[data-expandable]')!)
    expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
    await b.runtime.dispose()
  })

  it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
    const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
    const view = b.runtime.renderRoot()
    view.getByText('src/a.ts').click()
    expect(b.layout.openDetails).not.toHaveBeenCalled()
    await vi.waitFor(() => {
      expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
    })
    await b.runtime.dispose()
  })

  it('bash summary clicks do not open details or host paths', async () => {
    const b = await bench([toolResult(3, 'c1', 'bash')])
    const view = b.runtime.renderRoot()
    view.getByText('Build').click()
    expect(b.layout.openDetails).not.toHaveBeenCalled()
    expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
    await b.runtime.dispose()
  })

  it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
    const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
    const view = b.runtime.renderRoot()
    expect(view.getByText('Tool call')).toBeTruthy()
    let dispose = (): void => {}
    dispose = b.slots.register(
      { name: 'conversation.chat.toolview', key: 'mystery' },
      () => <div data-testid="mystery-row" />)
    await b.runtime.flush()
    // Per-key version tick: the row flipped without a remount of the view.
    expect(view.getByTestId('mystery-row')).toBeTruthy()
    expect(view.queryByText('Tool call')).toBeNull()
    dispose()
    await b.runtime.flush()
    expect(view.queryByTestId('mystery-row')).toBeNull()
    expect(view.getByText('Tool call')).toBeTruthy()
    await b.runtime.dispose()
  })

  it('a duplicate key registration fails loud at load', async () => {
    const b = await bench([])
    expect(() => b.slots.register(
      { name: 'conversation.chat.toolview', key: 'bash' },
      () => null,
    )).toThrow(/key "bash"/)
    await b.runtime.dispose()
  })

  it('the inject channel feeds (sessionId) => I into the row component', async () => {
    const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
    const poked: string[] = []
    b.slots.register({
      name: 'conversation.chat.toolview',
      key: 'probe',
      // Two-way business face: data derived from the session id out, a
      // callback closing over it back in — the askuser-pattern inject shape.
      inject: (sessionId: SessionId) => ({
        mark: `for:${sessionId}`,
        poke: () => { poked.push(sessionId) },
      }),
    }, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
      <button data-testid="probe-row" onClick={poke}>{mark}</button>
    ))
    const view = b.runtime.renderRoot()
    const row = view.getByTestId('probe-row')
    expect(row.textContent).toBe(`for:${SID}`)
    row.click()
    expect(poked).toEqual([SID])
    await b.runtime.dispose()
  })
})

describe('registrant declaration injection', () => {
  it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => {
    const runtime = await SlotTestRuntime.create()
    runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
    const locale = new LocaleService(runtime.ctx)
    runtime.provide('locale', locale)
    runtime.slots.installLocale(locale)
    await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)

    // Third-party posture, mounted BEFORE ui-conversation. Plugin apply runs,
    // while slots.inject waits for the declaration itself.
    let applyRuns = 0
    const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
      applyRuns += 1
      registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register(
        { name: 'conversation.chat.toolview', key: 'late' }, () => null))
    }
    const late = runtime.ctx.plugin({
      name: 'late-registrant',
      inject: ['slots'],
      apply: registrantApply,
    })
    await Promise.resolve()
    await late.await()
    expect(applyRuns).toBe(1)
    expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0)

    // Mounting the package declares the slot and activates the waiting entry.
    await runtime.mount({ inject: [...inject], apply })
    expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
      .toEqual(expect.arrayContaining(['bash', 'late']))
    await runtime.dispose()
  })
})
