import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
  createScope, MutableSessionEventSource,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
  ISessions, SessionBinding, SessionFace, SessionListState, SessionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import {
  ConversationEventRegistry, ConversationNodeAssembler, ConversationViewRegistry, UiConversation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
  ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'

const SESSION_ID = 'resident' as SessionId

function sessionSnapshot(): SessionSnapshot {
  return {
    sessionId: SESSION_ID,
    queue: [],
    pendingSubmissions: [],
    running: false,
    subagent: null,
    removed: false,
    openState: 'open',
    openError: null,
    hasMore: false,
    loadingOlder: false,
    promptError: null,
    blank: true,
    lastAgentError: null,
    promptAttempted: false,
    awaitingFirstTurn: false,
  }
}

function fakeSession(): SessionFace {
  const snapshot = createSnapshotStore(sessionSnapshot())
  return {
    sessionId: SESSION_ID,
    projections: { faceOf: () => createSnapshotStore<unknown>(undefined) },
    getSnapshot: () => snapshot.getSnapshot(),
    subscribe: listener => snapshot.subscribe(listener),
    beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }),
    prompt: () => Promise.reject(new Error('unused fake Session operation')),
    readAttachment: () => Promise.reject(new Error('unused fake Session operation')),
    updateQueue: () => Promise.reject(new Error('unused fake Session operation')),
    cancel: () => Promise.reject(new Error('unused fake Session operation')),
    rename: () => Promise.reject(new Error('unused fake Session operation')),
    loadOlder: () => Promise.reject(new Error('unused fake Session operation')),
    command: () => Promise.reject(new Error('unused fake Session operation')),
  }
}

function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBinding } {
  const scope = createScope(ctx, SESSION_ID)
  const binding: SessionBinding = {
    sessionId: SESSION_ID,
    session: fakeSession(),
    eventSource: new MutableSessionEventSource(),
    ctx: scope.ctx,
  }
  const list = createSnapshotStore<SessionListState>({
    ids: [],
    byId: {},
    current: undefined,
    phase: 'ready',
    subagentsByParent: {},
    jobsBySession: {},
    currentAddress: undefined,
  })
  const sessions = {
    list,
    searchResultLimit: 50,
    create: () => Promise.reject(new Error('unused fake Sessions operation')),
    open: () => {},
    openSubagent: () => {},
    subagentAddress: () => undefined,
    setSubagentCatalogOpen: () => {},
    refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')),
    clear: () => {},
    refresh: () => Promise.reject(new Error('unused fake Sessions operation')),
    search: () => Promise.reject(new Error('unused fake Sessions operation')),
    fork: () => Promise.reject(new Error('unused fake Sessions operation')),
    scope: id => id === SESSION_ID ? binding.ctx : undefined,
    scopeOf: candidate => candidate === binding.ctx ? SESSION_ID : undefined,
    sessionOf: candidate => candidate === binding.ctx ? binding.session : undefined,
    binding: id => id === SESSION_ID ? binding : undefined,
  } satisfies ISessions
  return { sessions, binding }
}

function eventDefinition(kind: string): ConversationNodeDefinition<null> {
  return {
    kind,
    target: 'chat',
    match: () => null,
    start: () => null,
    update: context => context.state,
    buildViewNode: () => null,
  }
}

function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
  return {
    target,
    create: () => ({
      empty: null,
      replace: () => null,
      apply: () => null,
    }),
  }
}

async function bootRegistries(): Promise<{
  ctx: Context
  uiConversation: UiConversation
  binding: SessionBinding
  events: ConversationEventRegistry
  views: ConversationViewRegistry
}> {
  const ctx = new Context()
  const { sessions, binding } = fakeSessions(ctx)
  const uiConversation = new UiConversation(ctx, sessions)
  return {
    ctx,
    uiConversation,
    binding,
    events: uiConversation.events,
    views: uiConversation.views,
  }
}

describe('Conversation registries', () => {
  it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
    const { events } = await bootRegistries()
    const definition = eventDefinition('message')
    const dispose = events.register(definition)

    expect(events.entries()).toEqual([definition])
    expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/)

    dispose()
    dispose()
    expect(events.entries()).toEqual([])
  })

  it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => {
    const { events } = await bootRegistries()
    const fallback = eventDefinition('unknown')
    const dispose = events.registerFallback(fallback)

    expect(events.fallbackEntry()).toBe(fallback)
    expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/)

    dispose()
    dispose()
    expect(events.fallbackEntry()).toBeUndefined()
  })

  it('rejects rendering Definitions that omit either target or builder', async () => {
    const { events } = await bootRegistries()
    const targetOnly: ConversationNodeDefinition<null> = {
      kind: 'target-only',
      target: 'chat',
      match: () => null,
      start: () => null,
      update: context => context.state,
    }
    const builderOnly: ConversationNodeDefinition<null> = {
      kind: 'builder-only',
      match: () => null,
      start: () => null,
      update: context => context.state,
      buildViewNode: () => null,
    }

    expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
    expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
  })

  it('rejects a State-only Definition as the unmatched-event fallback', async () => {
    const { events } = await bootRegistries()
    const fallback: ConversationNodeDefinition<null> = {
      kind: 'state-only-fallback',
      match: () => null,
      start: () => null,
      update: context => context.state,
    }

    expect(() => events.registerFallback(fallback))
      .toThrow('conversation fallback Definition must declare a target')
  })

  it('rejects duplicate view targets and disposes a view registration once', async () => {
    const { views } = await bootRegistries()
    const definition = viewDefinition('chat')
    const dispose = views.register(definition)

    expect(views.entries()).toEqual([definition])
    expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/)

    dispose()
    dispose()
    expect(views.entries()).toEqual([])
  })

  it('removes Event, fallback, and view contributions with their caller fiber', async () => {
    const { ctx, events, views } = await bootRegistries()
    const feature = ctx.inject(['uiConversation'], (featureCtx) => {
      featureCtx.uiConversation.events.register(eventDefinition('message'))
      featureCtx.uiConversation.events.registerFallback(eventDefinition('unknown'))
      featureCtx.uiConversation.views.register(viewDefinition('chat'))
    })
    await feature.await()

    expect(events.entries()).toHaveLength(1)
    expect(events.fallbackEntry()).toBeDefined()
    expect(views.entries()).toHaveLength(1)

    await feature.dispose()
    expect(events.entries()).toEqual([])
    expect(events.fallbackEntry()).toBeUndefined()
    expect(views.entries()).toEqual([])
  })

  it('coalesces one turn of registry changes into one rebuild per resident Conversation', async () => {
    const { uiConversation, binding, events, views } = await bootRegistries()
    uiConversation.binding(binding)
    const rebuild = vi.spyOn(ConversationNodeAssembler.prototype, 'rebuildRegistry')

    events.register(eventDefinition('message'))
    views.register(viewDefinition('chat'))
    events.register(eventDefinition('tool'))
    expect(rebuild).not.toHaveBeenCalled()

    await Promise.resolve()
    expect(rebuild).toHaveBeenCalledOnce()

    views.register(viewDefinition('trajectory'))
    await Promise.resolve()
    expect(rebuild).toHaveBeenCalledTimes(2)
    rebuild.mockRestore()
  })
})
