import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assembleContextFor } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import * as control from '@deepseek-ai/dsh-tool-subagent-control'
import { textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'

const testSignal = new AbortController().signal

/** Adapter that keeps child Activations resident until released. */
class HeldAdapter extends LlmAdapter {
  readonly requests: GenerateOptions[] = []
  private readonly gate = Promise.withResolvers<undefined>()

  async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    this.requests.push(options)
    await this.gate.promise
    for (const chunk of textResponse('held answer')) {
      if (options.signal?.aborted) throw new Error('aborted')
      yield chunk
    }
  }

  release(): void {
    this.gate.resolve(undefined)
  }
}

const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
  for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})

/** Boot the real continuation graph with optional report installation. */
async function setup(options: { load?: boolean; config?: tool.Config } = {}) {
  const ctx = new Context()
  await mountAgentLoopTestDependencies(ctx)
  const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-'))
  await ctx.plugin(JsonlSessionPersistence, { root })
  await ctx.plugin(AgentLoop, { agents: [] })
  await ctx.plugin(SubagentRuntime)
  await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  const fiber = options.load === false
    ? undefined
    : await ctx.plugin(tool, options.config ?? { reportDelivery: 'quiet' })
  const adapter = new HeldAdapter()
  ctx.llm.registerAdapter(['mock'], adapter)
  const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
  cleanups.push(async () => {
    adapter.release()
    await ctx.fiber.dispose()
    rmSync(root, { recursive: true, force: true })
  })
  return { ctx, parent, adapter, fiber }
}

/** Start and resolve one resident continuable child. */
async function startChild(ctx: Context, parent: Agent, prompt = 'child task') {
  const started = await ctx.subagents.startContinuable({
    provider: 'spawn',
    label: prompt,
    request: {
      prompt: [{ type: 'text', text: prompt }],
      parent,
    },
    signal: testSignal,
  })
  const child = await vi.waitFor(() => {
    const live = ctx.agents.get(started.childId)
    expect(live).toBeDefined()
    return live as Agent
  })
  return { started, child }
}

let calls = 0
function callReport(ctx: Context, child: Agent, output: string, signal = testSignal) {
  return ctx.tools.execute({
    signal,
    callId: CallId(`report-${++calls}`),
    name: 'report',
    arguments: { output },
    agent: child,
  })
}

/** Occupy the child-local report name to force installation rollback. */
function registerReportConflict(child: Agent): () => void {
  return child.ctx.tools.register({
    name: 'report',
    description: 'conflicting report fixture',
    parameters: { type: 'object', properties: {} },
    output: { schema: { type: 'object', properties: {} }, render: () => [] },
    execute: () => Promise.resolve({}),
  })
}

/** Reports already visible or still pending in one Agent. */
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
  const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
  return [...visible, ...agent.inbox.nextStep].flatMap((message) => {
    if (message.source.kind !== 'subagent-report') return []
    return [{
      id: message.id,
      text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
      sender: message.source.senderSessionId,
    }]
  })
}

function renderedText(result: { content: { type: string; text?: string }[] }): string {
  return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('')
}

/** The prompt sections one agent's scope assembles, by name. */
async function sectionNames(ctx: Context, agent: Agent): Promise<string[]> {
  const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
  return assembly.sections.map(section => section.name)
}

describe('dsh-tool-subagent-report', () => {
  it('registers report only in continuable child scopes', async () => {
    const { ctx, parent } = await setup()
    expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('report')
    expect(ctx.tools.schemas(parent).map(schema => schema.name)).not.toContain('report')

    const { child } = await startChild(ctx, parent)
    const schemas = ctx.tools.schemas(child).filter(schema => schema.name === 'report')
    expect(schemas).toHaveLength(1)
    const properties = (schemas[0]?.parameters as { properties: Record<string, unknown> }).properties
    expect(Object.keys(properties)).toEqual(['output'])
  })

  it('adds no implicit capability when the package is absent', async () => {
    const { ctx, parent } = await setup({ load: false })
    const { child } = await startChild(ctx, parent)
    expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
    expect((await callReport(ctx, child, 'missing')).isError).toBe(true)
  })

  it('does not imply parent controls and survives a global-tool allow-list', async () => {
    const { ctx, parent } = await setup()
    expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('send_message')
    await ctx.plugin(control)
    expect(ctx.tools.schemas().map(schema => schema.name)).toContain('send_message')

    const started = await ctx.subagents.startContinuable({
      provider: 'spawn',
      label: 'restricted child',
      request: {
        prompt: [{ type: 'text', text: 'restricted child' }],
        parent,
        toolFilter: { allow: [] },
      },
      signal: testSignal,
    })
    const child = await vi.waitFor(() => {
      const live = ctx.agents.get(started.childId)
      expect(live).toBeDefined()
      return live as Agent
    })
    const names = ctx.tools.schemas(child).map(schema => schema.name)
    expect(names).toContain('report')
    expect(names).not.toContain('send_message')
  })

  it('delivers quiet reports with stable message and sender identities without waking', async () => {
    const { ctx, parent, adapter } = await setup()
    const { started, child } = await startChild(ctx, parent)
    const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length
    const enqueues: string[] = []
    ctx.on('agent/inbox/inserted', ({ agent, message }) => {
      if (agent === parent) {
        enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
      }
    })

    const result = await callReport(ctx, child, 'CHILD_FINDING')

    expect(result.isError).toBe(false)
    if (result.isError) throw new Error('report unexpectedly failed')
    const messageId = (result.value as { messageId: string }).messageId
    expect(renderedText(result)).toContain(messageId)
    expect(reports(parent)).toEqual([{
      id: messageId,
      text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`,
      sender: started.childId,
    }])
    expect(enqueues).toEqual(['steering'])
    expect(parent.status).toBe('idle')
    expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests)
  })

  it('queues wakeup reports as one later parent turn', async () => {
    const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
    const { child } = await startChild(ctx, parent)
    const enqueues: string[] = []
    ctx.on('agent/inbox/inserted', ({ agent, message }) => {
      if (agent === parent) {
        enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
      }
    })

    const result = await callReport(ctx, child, 'WAKE_UP')
    expect(result.isError).toBe(false)
    expect(enqueues).toEqual(['queued'])
    await vi.waitFor(() => {
      expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
    })
  })

  it('preserves accepted order across repeated reports', async () => {
    const { ctx, parent } = await setup()
    const { child } = await startChild(ctx, parent)

    expect((await callReport(ctx, child, 'FIRST')).isError).toBe(false)
    expect((await callReport(ctx, child, 'SECOND')).isError).toBe(false)
    expect(reports(parent).map(report => report.text.split('\n').at(-1))).toEqual(['FIRST', 'SECOND'])
  })

  it('keeps an accepted report after the child settles', async () => {
    const { ctx, parent, adapter } = await setup()
    const { started, child } = await startChild(ctx, parent)
    expect((await callReport(ctx, child, 'DURABLE_SELECTION')).isError).toBe(false)

    adapter.release()
    await vi.waitFor(() => {
      expect(ctx.agents.get(started.childId) === undefined).toBe(true)
    }, { timeout: 5_000 })
    expect(reports(parent).map(report => report.text)).toEqual([
      `Background subagent ${started.childId} reported:\nDURABLE_SELECTION`,
    ])
  })

  it('routes nested reports exactly one edge upward', async () => {
    const { ctx, parent, adapter } = await setup()
    const { child } = await startChild(ctx, parent, 'outer task')
    const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')

    expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false)
    expect(reports(parent)).toEqual([])
    // The intermediate parent's turn is open, so quiet context is pending in
    // its inbox until that turn reaches its next safe log boundary.
    expect(reports(child)).toHaveLength(1)
    adapter.release()
    await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
    expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)
    expect(reports(child)[0]?.text).toContain('FROM_GRANDCHILD')
  })

  it('accounts wakeup reports delivered to a resident continuable parent', async () => {
    const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
    const { child } = await startChild(ctx, parent, 'outer task')
    const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')

    expect((await callReport(ctx, grandchild, 'WAKE_PARENT_CHILD')).isError).toBe(false)
    expect(ctx.agents.get(child.id)).toBe(child)

    adapter.release()
    await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
    expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)
    expect(reports(child)[0]?.text).toContain('WAKE_PARENT_CHILD')
  })

  it('normalizes a direct parent send rejection', async () => {
    const { ctx, parent } = await setup()
    const { child } = await startChild(ctx, parent)
    vi.spyOn(parent, 'inject').mockImplementationOnce(() => {
      throw new Error('parent closed during delivery')
    })

    await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'rejected' }], {
      delivery: 'quiet',
      signal: testSignal,
    })).rejects.toMatchObject({ code: 'PARENT_UNAVAILABLE' })
    expect(reports(parent)).toEqual([])
  })

  it('rejects roots, forged same-id senders, absent parents, cancellation, and drain', async () => {
    const { ctx, parent, adapter } = await setup()
    await expect(ctx.subagents.reportFrom(parent, [{ type: 'text', text: 'root' }], {
      delivery: 'quiet',
      signal: testSignal,
    })).rejects.toMatchObject({ code: 'UNAUTHORIZED' })

    const disposable = await ctx.agents.create({
      sessionId: SessionId('disposable-parent'),
      agentOptions: { provider: 'mock', model: 'mock' },
    })
    const { child } = await startChild(ctx, disposable.agent)
    const forged = { ...child } as Agent
    await expect(ctx.subagents.reportFrom(forged, [{ type: 'text', text: 'forged' }], {
      delivery: 'quiet',
      signal: testSignal,
    })).rejects.toMatchObject({ code: 'UNAUTHORIZED' })

    const aborted = new AbortController()
    aborted.abort()
    expect((await callReport(ctx, child, 'cancelled', aborted.signal)).isError).toBe(true)

    await disposable.dispose()
    expect((await callReport(ctx, child, 'orphaned')).isError).toBe(true)

    adapter.release()
    const draining = ctx.subagents.drainContinuableDescendants([child])
    await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'draining' }], {
      delivery: 'quiet',
      signal: testSignal,
    })).rejects.toMatchObject({ code: 'DRAINING' })
    await draining
  })

  it('revokes resident installations and defers later grants to the next Activation', async () => {
    const { ctx, parent, fiber } = await setup()
    const { child } = await startChild(ctx, parent)
    expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report')
    expect(await sectionNames(ctx, child)).toContain('tool:report')

    await fiber?.dispose()
    expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
    expect(await sectionNames(ctx, child)).not.toContain('tool:report')
    expect((await callReport(ctx, child, 'revoked')).isError).toBe(true)

    const late = await ctx.plugin(tool, { reportDelivery: 'quiet' })
    expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
    expect(await sectionNames(ctx, child)).not.toContain('tool:report')
    await late.dispose()
  })

  it('rolls back prompt guidance when tool registration fails', async () => {
    const { ctx, parent } = await setup({ load: false })
    const { child } = await startChild(ctx, parent)
    const disposeConflict = registerReportConflict(child)

    expect(() => tool.installReportTool(child.ctx, ctx, 'quiet')).toThrow(/already registered in this scope/)
    expect(await sectionNames(ctx, child)).not.toContain('tool:report')
    disposeConflict()
  })

  it('aggregates a registration failure with a prompt rollback failure', async () => {
    const { ctx, parent } = await setup({ load: false })
    const { child } = await startChild(ctx, parent)
    const disposeConflict = registerReportConflict(child)
    const rollbackFailure = new Error('prompt rollback listener failed')
    let promptChanges = 0
    const off = ctx.on('system-prompt/change', () => {
      promptChanges++
      if (promptChanges === 2) throw rollbackFailure
    })

    let failure: unknown
    try {
      tool.installReportTool(child.ctx, ctx, 'quiet')
    } catch (error: unknown) {
      failure = error
    }
    off()

    expect(failure).toBeInstanceOf(AggregateError)
    if (!(failure instanceof AggregateError)) throw new Error('expected aggregate installation failure')
    expect(failure.errors).toHaveLength(2)
    expect(String(failure.errors[0])).toContain('already registered in this scope')
    expect(failure.errors[1]).toBe(rollbackFailure)
    expect(await sectionNames(ctx, child)).not.toContain('tool:report')
    disposeConflict()
  })

  it('attempts both revocations and aggregates change-listener failures', async () => {
    const { ctx, parent } = await setup({ load: false })
    const { child } = await startChild(ctx, parent)
    const dispose = tool.installReportTool(child.ctx, ctx, 'quiet')
    const toolFailure = new Error('tool removal listener failed')
    const promptFailure = new Error('prompt removal listener failed')
    const offTool = ctx.on('tools/change', () => { throw toolFailure })
    const offPrompt = ctx.on('system-prompt/change', () => { throw promptFailure })

    let failure: unknown
    try {
      dispose()
    } catch (error: unknown) {
      failure = error
    }
    offPrompt()
    offTool()

    expect(failure).toBeInstanceOf(AggregateError)
    if (!(failure instanceof AggregateError)) throw new Error('expected aggregate revocation failure')
    expect(failure.errors).toEqual([toolFailure, promptFailure])
    expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
    expect(await sectionNames(ctx, child)).not.toContain('tool:report')
  })

  it('scopes the report guidance to the child that owns it', async () => {
    const { ctx, parent } = await setup()
    const { child } = await startChild(ctx, parent, 'first child')
    const { child: sibling } = await startChild(ctx, parent, 'second child')

    const assembly = await ctx.systemPrompt.assemble(assembleContextFor(child))
    const guidance = assembly.sections.find(section => section.name === 'tool:report')
    // Pins the model-visible instruction that makes the return channel a
    // contract rather than an option the child may quietly skip.
    expect(guidance?.text).toContain('Deliver your result with the report tool before you finish')
    expect(guidance?.text).toContain('reporting never ends your turn')

    expect(await sectionNames(ctx, parent)).not.toContain('tool:report')
    // A sibling installs its own copy; neither child can observe the other's.
    expect(await sectionNames(ctx, sibling)).toContain('tool:report')
    expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
      .not.toContain('tool:report')
  })

  it('rolls back materialization when a setup contribution revokes itself', async () => {
    const { ctx, parent } = await setup({ load: false })
    const self: { revoke?: () => void } = {}
    self.revoke = ctx.subagents.registerContinuableSetup((childCtx) => {
      const dispose = childCtx.tools.register({
        name: 'racing-report',
        description: 'racing setup',
        parameters: { type: 'object', properties: {} },
        output: { schema: { type: 'object', properties: {} }, render: () => [] },
        execute: () => Promise.resolve({}),
      })
      self.revoke?.()
      return dispose
    })

    // No session may be announced for the rejected child: the setup
    // validation must reject inside the creation callback, before the factory
    // publishes — a post-publication rejection would persist a resumable
    // ghost that `list_agents` surfaces and `send_message` can resurrect.
    // The parent was created inside setup(), so any later announcement is the
    // rejected child's.
    const announced: SessionId[] = []
    const listener = (session: { id: SessionId }): void => { announced.push(session.id) }
    const removeListener = ctx.on('session/created', listener)
    await expect(ctx.subagents.startContinuable({
      provider: 'spawn',
      label: 'racing child',
      request: {
        prompt: [{ type: 'text', text: 'racing child' }],
        parent,
      },
      signal: testSignal,
    })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
    removeListener()
    expect(announced).toEqual([])
    expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
  })

  it('rolls back materialization when setup revocation lands before publication', async () => {
    const { ctx, parent } = await setup({ load: false })
    const self: { revoke?: () => void } = {}
    let installed = false
    self.revoke = ctx.subagents.registerContinuableSetup(() => {
      installed = true
      queueMicrotask(() => { self.revoke?.() })
      return () => { installed = false }
    })
    const announced: SessionId[] = []
    const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) })

    await expect(ctx.subagents.startContinuable({
      provider: 'spawn',
      label: 'revoked child',
      request: {
        prompt: [{ type: 'text', text: 'revoked child' }],
        parent,
      },
      signal: testSignal,
    })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
    removeListener()
    expect(installed).toBe(false)
    expect(announced).toEqual([])
    expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
    expect(ctx.sessions.list()).toEqual([parent.session])
  })

  it('accepts a report into a host-disposing but still-registered parent', async () => {
    const { ctx } = await setup()
    const parentHandle = await ctx.agents.create({
      sessionId: SessionId('disposing-parent'),
      agentOptions: { provider: 'mock', model: 'mock' },
    })
    const { child } = await startChild(ctx, parentHandle.agent)
    // Host-owned disposal starts asynchronously; the parent stays registered
    // until quiescence, and registry presence — not disposal state — is the
    // acceptance gate (pins the README contract).
    const disposing = parentHandle.dispose()
    const accepted = await callReport(ctx, child, 'during-close')
    expect(accepted.isError).toBe(false)
    await disposing
    expect((await callReport(ctx, child, 'after-close')).isError).toBe(true)
  })

  it('keeps the namespace plugin shape and validates its default', () => {
    expect('default' in tool).toBe(false)
    expect(tool.name).toBe('tool-subagent-report')
    expect(tool.inject).toEqual(['subagents', 'tools', 'systemPrompt'])
    // Waking is the default because a report that never wakes its parent
    // cannot deliver a result to an agent that already parked.
    expect(tool.Config({}).reportDelivery).toBe('wakeup')
    expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow()
  })

  it('wakes the parent under the default configuration', async () => {
    const { ctx, parent, adapter } = await setup({ config: {} })
    const { child } = await startChild(ctx, parent)
    const enqueues: string[] = []
    ctx.on('agent/inbox/inserted', ({ agent, message }) => {
      if (agent === parent) {
        enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
      }
    })

    expect((await callReport(ctx, child, 'DEFAULT_WAKES')).isError).toBe(false)
    expect(enqueues).toEqual(['queued'])
    await vi.waitFor(() => {
      expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
    })
  })
})

/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */
function userTexts(events: readonly SessionEvent[]): string[] {
  return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
    ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
    : [])
}

describe('dsh-tool-subagent-report result independence', () => {
  it('does not report a final assistant answer automatically or create Jobs', async () => {
    const { ctx, parent, adapter } = await setup()
    const { started } = await startChild(ctx, parent)
    adapter.release()
    await vi.waitFor(() => {
      expect(ctx.agents.get(started.childId) === undefined).toBe(true)
    }, { timeout: 5_000 })

    // The parent does learn the child settled — that account is the
    // continuation service's, carried under its own `subagent-settled` source.
    // Nothing turns the child's final answer into a report it did not send.
    expect(reports(parent)).toEqual([])
    expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task'])
    expect(ctx.get('jobs')).toBeUndefined()
  })
})
