import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CordisDynamicPackageId, CordisDynamicPluginId } from '../src/types.ts'
import { missingServices } from '../src/lifecycle.ts'
import {
  AGENT_A, call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, mount,
  PROVIDER_CODE, REVERSE_TOOL_CODE, setup, text,
  running,
} from './helpers.ts'

/**
 * Cross-package composition through ordinary cordis provide/inject semantics:
 * one package's host half provides a service, another injects it, and definition
 * ids stay the lifecycle handles across stop and run again. Every assertion is
 * against the WORLD — the registry, the service store, real tool dispatch — not
 * a rendered summary (that is the tool package's job).
 */

afterEach(() => {
  vi.restoreAllMocks()
})

function latestPackage(harness: Awaited<ReturnType<typeof setup>>, pluginId: CordisDynamicPluginId): CordisDynamicPackageId {
  const row = harness.runner.inventory().find(candidate => candidate.pluginId === pluginId)
  const packageId = row?.packages.at(-1)?.packageId
  if (packageId === undefined) throw new Error(`missing package for ${pluginId}`)
  return packageId
}

describe('cross-package provide/inject', () => {
  it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
    const harness = await setup()
    await mount(harness, PROVIDER_CODE)
    await mount(harness, CONSUMER_CODE)

    // The vm-realm service value is callable across packages, and the result
    // normalizes into the host realm like any dynamic tool result.
    const greeted = await call(harness.ctx, 'greet', { name: 'harness' })
    expect(greeted.isError).toBe(false)
    expect(text(greeted)).toBe('hi harness')
  })

  it('consumer first: runs but stays parked on the missing service, then activates when the provider runs', async () => {
    const harness = await setup()
    const consumer = await mount(harness, CONSUMER_CODE)

    // A settled-but-pending host half is a successful run in legal cordis
    // semantics; the fiber names what it waits for.
    const [row] = harness.runner.snapshot(AGENT_A)
    expect(row?.activeRun?.fiber).toBeDefined()
    expect(missingServices(harness.ctx, row?.activeRun?.fiber as never)).toEqual(['greeter'])
    expect(harness.ctx.tools.get('greet')).toBeUndefined()
    expect(running(harness.runner, AGENT_A)).toEqual([{ id: consumer, running: true }])

    await mount(harness, PROVIDER_CODE)
    expect(harness.ctx.tools.get('greet')).toBeDefined()
    expect(text(await call(harness.ctx, 'greet', { name: 'late' }))).toBe('hi late')
  })

  it('stopping the provider sends the consumer back to pending and unwinds its registrations', async () => {
    const harness = await setup()
    const provider = await mount(harness, PROVIDER_CODE)
    await mount(harness, CONSUMER_CODE)
    expect(harness.ctx.tools.get('greet')).toBeDefined()

    await expect(harness.runner.stop(AGENT_A, provider)).resolves.toEqual({ ok: true })

    expect(harness.ctx.tools.get('greet')).toBeUndefined()
    expect(harness.ctx.get('greeter')).toBeUndefined()
  })

  it('running the provider again re-runs the consumer through a fresh guard (tool back)', async () => {
    const harness = await setup()
    const provider = await mount(harness, PROVIDER_CODE)
    await mount(harness, CONSUMER_CODE)
    await harness.runner.stop(AGENT_A, provider)
    expect(harness.ctx.tools.get('greet')).toBeUndefined()

    // The same definition, a new dispatch: the consumer's apply re-runs through
    // a new façade rather than needing its own re-definition.
    await expect(harness.runner.run(
      AGENT_A, provider, latestPackage(harness, provider), 'run',
    )).resolves.toMatchObject({ ok: true })
    expect(harness.ctx.tools.get('greet')).toBeDefined()
    expect(text(await call(harness.ctx, 'greet', { name: 'again' }))).toBe('hi again')
  })

  it('a duplicate provide fails loud and leaves the second package not running', async () => {
    const harness = await setup()
    await mount(harness, PROVIDER_CODE)

    await expect(mount(harness, PROVIDER_CODE)).rejects.toThrow('has been registered')

    const rows = harness.runner.snapshot(AGENT_A)
    expect(rows.map(row => row.activeRun !== undefined)).toEqual([true, false])
    // The service still belongs to the first package's fiber.
    expect(harness.ctx.get('greeter')).toBeDefined()
  })

  it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
    const harness = await setup()
    await mount(harness, `
      return {
        name: 'answer-provider',
        apply(ctx) {
          ctx.provide('answer', 42)
          ctx.provide('nothing', null)
        },
      }
    `)
    await mount(harness, `
      return {
        name: 'answer-consumer',
        inject: ['answer', 'nothing', 'tools'],
        apply(ctx) {
          harness.registerTool(ctx, harness.defineTool({
            name: 'answer',
            description: 'Read the provided primitive services.',
            parameters: {},
            ${CONTENT_OUTPUT_CODE}
            async execute() {
              return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
            },
          }))
        },
      }
    `)

    expect(text(await call(harness.ctx, 'answer', {}))).toBe('42/42/null')
  })

  it('stopping the consumer leaves the provider and its service intact', async () => {
    const harness = await setup()
    await mount(harness, PROVIDER_CODE)
    const consumer = await mount(harness, CONSUMER_CODE)

    await harness.runner.stop(AGENT_A, consumer)

    expect(harness.ctx.tools.get('greet')).toBeUndefined()
    expect(harness.ctx.get('greeter')).toBeDefined()
  })
})

describe('stop reaches quiescence', () => {
  it('the host half\'s listeners have stopped by the time stop returns', async () => {
    const harness = await setup()
    const log = vi.spyOn(console, 'log').mockImplementation(() => {})
    const id = await mount(harness, LISTENER_CODE)

    harness.ctx.tools.register(dummyTool('trigger_before'))
    expect(log).toHaveBeenCalledTimes(1)

    await expect(harness.runner.stop(AGENT_A, id)).resolves.toEqual({ ok: true })

    // Immediately after the awaited stop, the listener must be gone — no grace
    // period, no eventual consistency.
    harness.ctx.tools.register(dummyTool('trigger_after'))
    expect(log).toHaveBeenCalledTimes(1)
  })

  it('unregisters a self-made tool on stop, and registers it again on the next run', async () => {
    const harness = await setup()
    const id = await mount(harness, REVERSE_TOOL_CODE)
    expect(harness.ctx.tools.get('reverse_text')).toBeDefined()

    await harness.runner.stop(AGENT_A, id)
    expect(harness.ctx.tools.get('reverse_text')).toBeUndefined()

    await harness.runner.run(AGENT_A, id, latestPackage(harness, id), 'run')
    expect(harness.ctx.tools.get('reverse_text')).toBeDefined()
  })

  it('names the replace recipe when a run collides with a live registration', async () => {
    const harness = await setup()
    await mount(harness, REVERSE_TOOL_CODE)

    // A second package registering the same tool name collides; the teaching
    // error points at the stop-then-run recipe rather than a bare conflict.
    await expect(mount(harness, REVERSE_TOOL_CODE)).rejects.toThrow('first cordis_stop that package\'s id')
  })
})
