/**
 * Unit tests for chatStream.dispatchWsEvent (U5).
 *
 * dispatchWsEvent is a pure function over ChatStreamState, so we construct
 * a fixture state bag (no Pinia required) and assert state mutations
 * directly. Covers the major WS event branches: connected, routing, step
 * (token/thinking/tool_call/tool_result/final_answer), result, error,
 * team_formed, expert_step, expert_result, plan_update, phase_changed,
 * phase_violation, board_started.
 */

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref, type Ref } from 'vue'
import {
  dispatchWsEvent,
  type ChatStreamState,
  type IStreamingStep,
} from '@/stores/chatStream'
import type {
  IChatMessage,
  IConversation,
  IExpertTeamState,
  ITeamPlanPhase,
  WsServerMessage,
} from '@/api/types'

// Mock ant-design-vue so phase_violation's dynamic import doesn't blow up.
vi.mock('ant-design-vue', () => ({
  message: { warning: vi.fn() },
}))

// Mock isDocumentMeta to true so tool_result document payloads are accepted.
vi.mock('@/api/documents', () => ({
  isDocumentMeta: vi.fn(() => true),
}))

interface Fixture {
  state: ChatStreamState
  conversations: Ref<IConversation[]>
  currentConversationId: Ref<string | null>
  appendMessageSpy: ReturnType<typeof vi.fn>
  updateMessageSpy: ReturnType<typeof vi.fn>
  markConversationDoneSpy: ReturnType<typeof vi.fn>
  teamStore: {
    teamState: IExpertTeamState | null
    setTeamState: ReturnType<typeof vi.fn>
    updatePhases: ReturnType<typeof vi.fn>
    updatePhaseStatus: ReturnType<typeof vi.fn>
    clearTeam: ReturnType<typeof vi.fn>
  }
  calendarStore: { handleWsEvent: ReturnType<typeof vi.fn> }
  documentsStore: { addDocument: ReturnType<typeof vi.fn> }
}

function createFixture(convId: string = 'conv-1'): Fixture {
  const conversations = ref<IConversation[]>([
    {
      id: convId,
      title: '新对话',
      messages: [],
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    },
  ])
  const currentConversationId = ref<string | null>(convId)
  const appendMessageSpy = vi.fn((cid: string, msg: IChatMessage) => {
    const conv = conversations.value.find((c) => c.id === cid)
    if (conv) conv.messages.push(msg)
  })
  const updateMessageSpy = vi.fn(
    (cid: string, mid: string, updates: Partial<IChatMessage>) => {
      const conv = conversations.value.find((c) => c.id === cid)
      if (!conv) return
      const msg = conv.messages.find((m) => m.id === mid)
      if (msg) Object.assign(msg, updates)
    },
  )
  const markConversationDoneSpy = vi.fn()

  const teamStore = {
    teamState: null as IExpertTeamState | null,
    setTeamState: vi.fn(),
    updatePhases: vi.fn(),
    updatePhaseStatus: vi.fn(),
    clearTeam: vi.fn(),
  }
  const calendarStore = { handleWsEvent: vi.fn() }
  const documentsStore = { addDocument: vi.fn() }

  // The deps bag types expect full Pinia Store<...> instances; the fixture
  // only needs the slice of methods dispatchWsEvent actually calls, so we
  // cast through unknown to satisfy the type without pulling in the real
  // stores (which would drag in their own deps and side effects).
  const state: ChatStreamState = {
    streamingStepsByConv: ref(new Map<string, IStreamingStep[]>()),
    currentPhase: ref<string | null>(null),
    phaseViolations: ref([]),
    boardState: ref(null),
    debateState: ref(null),
    collaborationState: ref(null),
    conversations,
    currentConversationId,
    appendMessage: appendMessageSpy,
    updateMessage: updateMessageSpy,
    markConversationDone: markConversationDoneSpy,
    resolveIncomingConvId: () => currentConversationId.value ?? '',
    getTeamStore: () => teamStore as unknown as ChatStreamState["getTeamStore"] extends () => infer R ? R : never,
    getCalendarStore: () => calendarStore as unknown as ChatStreamState["getCalendarStore"] extends () => infer R ? R : never,
    getDocumentsStore: () => documentsStore as unknown as ChatStreamState["getDocumentsStore"] extends () => infer R ? R : never,
  }

  return {
    state,
    conversations,
    currentConversationId,
    appendMessageSpy,
    updateMessageSpy,
    markConversationDoneSpy,
    teamStore,
    calendarStore,
    documentsStore,
  }
}

/** Push a pending assistant placeholder so routing/step cases have a target. */
function seedAssistant(f: Fixture, id = 'a1'): IChatMessage {
  const msg: IChatMessage = {
    id,
    role: 'assistant',
    content: '',
    timestamp: new Date().toISOString(),
    status: 'pending',
  }
  f.conversations.value[0].messages.push(msg)
  return msg
}

describe('dispatchWsEvent', () => {
  let f: Fixture
  beforeEach(() => {
    f = createFixture()
  })

  // ── 1. connected ───────────────────────────────────────────────────
  it('connected: marks local conv as synced and adopts server conversation_id', () => {
    f.conversations.value[0].is_local = true
    f.currentConversationId.value = 'local-1'
    f.conversations.value[0].id = 'local-1'

    dispatchWsEvent(
      { type: 'connected', conversation_id: 'server-1' },
      f.state,
    )

    expect(f.conversations.value[0].is_local).toBe(false)
    expect(f.conversations.value[0].id).toBe('server-1')
    expect(f.currentConversationId.value).toBe('server-1')
  })

  // ── 2. routing ─────────────────────────────────────────────────────
  it('routing: tags last assistant message with skill/confidence/method', () => {
    const a = seedAssistant(f)
    dispatchWsEvent(
      { type: 'routing', skill: 'code_review', confidence: 0.92, method: 'react' },
      f.state,
    )
    expect(a.matched_skill).toBe('code_review')
    expect(a.confidence).toBe(0.92)
    expect(a.routing_method).toBe('react')
    // A "routing" streaming step should also be appended.
    const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
    expect(steps.some((s) => s.type === 'routing')).toBe(true)
  })

  // ── 3. step: token ─────────────────────────────────────────────────
  it('step(token): creates a streaming step and accumulates counter', () => {
    seedAssistant(f)
    dispatchWsEvent(
      {
        type: 'step',
        data: {
          event_type: 'token',
          step: 1,
          data: { delta: 'hello' },
          timestamp: new Date().toISOString(),
        },
      },
      f.state,
    )
    dispatchWsEvent(
      {
        type: 'step',
        data: {
          event_type: 'token',
          step: 2,
          data: { delta: 'world' },
          timestamp: new Date().toISOString(),
        },
      },
      f.state,
    )
    const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
    const streamingSteps = steps.filter((s) => s.type === 'streaming')
    expect(streamingSteps).toHaveLength(1)
    expect(streamingSteps[0].counter).toBeGreaterThanOrEqual(10)
  })

  // ── 4. step: thinking ──────────────────────────────────────────────
  it('step(thinking): appends thinking step and accumulates thinking content', () => {
    const a = seedAssistant(f)
    dispatchWsEvent(
      {
        type: 'step',
        data: {
          event_type: 'thinking',
          step: 1,
          data: { content: 'hmm' },
          timestamp: new Date().toISOString(),
        },
      },
      f.state,
    )
    dispatchWsEvent(
      {
        type: 'step',
        data: {
          event_type: 'thinking',
          step: 2,
          data: { content: ' hmm2' },
          timestamp: new Date().toISOString(),
        },
      },
      f.state,
    )
    expect(a.thinking).toBe('hmm hmm2')
    const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
    expect(steps.some((s) => s.type === 'thinking')).toBe(true)
  })

  // ── 5. step: tool_call + tool_result ───────────────────────────────
  it('step(tool_call then tool_result): tracks tool_calls on assistant message', () => {
    const a = seedAssistant(f)
    dispatchWsEvent(
      {
        type: 'step',
        data: {
          event_type: 'tool_call',
          step: 1,
          data: { tool_name: 'search', arguments: { q: 'x' } },
          timestamp: new Date().toISOString(),
        },
      },
      f.state,
    )
    expect(a.tool_calls).toHaveLength(1)
    expect(a.tool_calls?.[0].name).toBe('search')
    expect(a.tool_calls?.[0].status).toBe('running')

    dispatchWsEvent(
      {
        type: 'step',
        data: {
          event_type: 'tool_result',
          step: 2,
          data: { tool_name: 'search', output: 'result', duration: 12 },
          timestamp: new Date().toISOString(),
        },
      },
      f.state,
    )
    expect(a.tool_calls?.[0].status).toBe('completed')
    expect(a.tool_calls?.[0].result).toBe('result')
    expect(a.tool_calls?.[0].duration).toBe(12)
  })

  // ── 6. result ──────────────────────────────────────────────────────
  it('result: finalizes assistant content, clears steps, marks done', () => {
    const a = seedAssistant(f)
    dispatchWsEvent(
      { type: 'result', data: { message: 'final answer' } },
      f.state,
    )
    expect(a.content).toBe('final answer')
    expect(a.status).toBe('completed')
    expect(f.markConversationDoneSpy).toHaveBeenCalledWith('conv-1')
    expect(f.state.streamingStepsByConv.value.has('conv-1')).toBe(false)
  })

  // ── 7. error ───────────────────────────────────────────────────────
  it('error: mutates last assistant to error state and marks done', () => {
    const a = seedAssistant(f)
    dispatchWsEvent(
      { type: 'error', data: { message: 'boom' } },
      f.state,
    )
    expect(a.message_type).toBe('error')
    expect(a.status).toBe('error')
    expect(a.error_detail).toBe('boom')
    expect(f.markConversationDoneSpy).toHaveBeenCalledWith('conv-1')
  })

  it('error: appends a new error message when no assistant placeholder exists', () => {
    f.conversations.value[0].messages = []
    dispatchWsEvent(
      { type: 'error', data: { message: 'no-assistant' } },
      f.state,
    )
    expect(f.conversations.value[0].messages).toHaveLength(1)
    const m = f.conversations.value[0].messages[0]
    expect(m.message_type).toBe('error')
    expect(m.error_detail).toBe('no-assistant')
  })

  // ── 8. team_formed ─────────────────────────────────────────────────
  it('team_formed: resets collaborationState and forwards to teamStore', () => {
    f.state.collaborationState.value = {
      contracts: [],
      notices: [],
      reviews: [],
      risks: [],
    }
    const teamData: IExpertTeamState = {
      team_id: 't1',
      status: 'forming',
      experts: [
        {
          id: 'e1',
          name: 'Lead',
          persona: '',
          avatar: '',
          color: '',
          is_lead: true,
          bound_skills: [],
          status: 'active',
        },
      ],
      plan_phases: [],
      lead_expert: 'e1',
    }
    dispatchWsEvent({ type: 'team_formed', data: teamData }, f.state)
    expect(f.state.collaborationState.value).toBeNull()
    expect(f.teamStore.setTeamState).toHaveBeenCalledWith(teamData)
    const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
    expect(steps.some((s) => s.type === 'team_event')).toBe(true)
  })

  // ── 9. expert_step ─────────────────────────────────────────────────
  it('expert_step: appends an expert-tagged assistant message and step', () => {
    dispatchWsEvent(
      {
        type: 'expert_step',
        data: {
          expert_id: 'e1',
          expert_name: 'Alice',
          expert_color: '#f00',
          content: 'partial',
          step: '1',
        },
      },
      f.state,
    )
    const msgs = f.conversations.value[0].messages
    expect(msgs).toHaveLength(1)
    expect(msgs[0].expert_id).toBe('e1')
    expect(msgs[0].expert_name).toBe('Alice')
    expect(msgs[0].content).toBe('partial')
    // Same-expert follow-up accumulates into the existing pending message.
    dispatchWsEvent(
      {
        type: 'expert_step',
        data: {
          expert_id: 'e1',
          expert_name: 'Alice',
          expert_color: '#f00',
          content: '+more',
          step: '2',
        },
      },
      f.state,
    )
    expect(msgs).toHaveLength(1)
    expect(msgs[0].content).toBe('partial+more')
  })

  // ── 10. expert_result ──────────────────────────────────────────────
  it('expert_result: appends a completed expert-tagged message', () => {
    dispatchWsEvent(
      {
        type: 'expert_result',
        data: {
          expert_id: 'e1',
          expert_name: 'Alice',
          expert_color: '#f00',
          content: 'done',
        },
      },
      f.state,
    )
    const msgs = f.conversations.value[0].messages
    expect(msgs).toHaveLength(1)
    expect(msgs[0].status).toBe('completed')
    expect(msgs[0].expert_id).toBe('e1')
    expect(msgs[0].content).toBe('done')
  })

  // ── 11. plan_update ────────────────────────────────────────────────
  it('plan_update: forwards phases to teamStore and upserts plan_update message', () => {
    const phases: ITeamPlanPhase[] = [
      {
        id: 'p1',
        name: 'Phase 1',
        assigned_expert: 'e1',
        depends_on: [],
        status: 'pending',
      },
    ]
    dispatchWsEvent({ type: 'plan_update', data: { plan_phases: phases } }, f.state)
    expect(f.teamStore.updatePhases).toHaveBeenCalledWith(phases)
    const msgs = f.conversations.value[0].messages
    expect(msgs).toHaveLength(1)
    expect(msgs[0].message_type).toBe('plan_update')
    expect(msgs[0].plan_phases).toStrictEqual(phases)
    // A second plan_update should update the existing message in place.
    dispatchWsEvent({ type: 'plan_update', data: { plan_phases: phases } }, f.state)
    expect(msgs).toHaveLength(1)
  })

  // ── 12. plan_update with collaboration_contracts ───────────────────
  it('plan_update: extracts collaboration_contracts into collaborationState', () => {
    const phases: ITeamPlanPhase[] = [
      {
        id: 'p1',
        name: 'Phase 1',
        assigned_expert: 'e1',
        depends_on: [],
        status: 'pending',
        collaboration_contracts: [
          {
            from_expert: 'e1',
            to_expert: 'e2',
            content_description: 'interface spec',
            status: 'pending',
          },
        ],
      },
    ]
    dispatchWsEvent({ type: 'plan_update', data: { plan_phases: phases } }, f.state)
    expect(f.state.collaborationState.value).not.toBeNull()
    expect(f.state.collaborationState.value?.contracts).toHaveLength(1)
    expect(f.state.collaborationState.value?.contracts[0].phase_id).toBe('p1')
    // A collaboration_graph message should also be upserted.
    const graphMsg = f.conversations.value[0].messages.find(
      (m) => m.message_type === 'collaboration_graph',
    )
    expect(graphMsg).toBeDefined()
  })

  // ── 13. phase_changed (PLAN_EXEC) ──────────────────────────────────
  it('phase_changed: sets currentPhase and appends a milestone step', () => {
    dispatchWsEvent(
      { type: 'phase_changed', data: { phase: 'planning', previous: 'init' } },
      f.state,
    )
    expect(f.state.currentPhase.value).toBe('planning')
    const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
    expect(steps.some((s) => s.type === 'milestone' && s.label === '阶段切换')).toBe(true)
  })

  // ── 14. phase_violation (PLAN_EXEC) ────────────────────────────────
  it('phase_violation: records violation (capped at 5) and sets currentPhase', () => {
    for (let i = 0; i < 7; i++) {
      dispatchWsEvent(
        {
          type: 'phase_violation',
          data: {
            current_phase: 'planning',
            tool: `tool_${i}`,
            message: `blocked ${i}`,
            violation_kind: 'tool_not_allowed',
          },
        },
        f.state,
      )
    }
    expect(f.state.currentPhase.value).toBe('planning')
    expect(f.state.phaseViolations.value).toHaveLength(5)
    // The most recent violation should be the last one we sent.
    expect(f.state.phaseViolations.value[4].tool).toBe('tool_6')
  })

  // ── 15. board_started ──────────────────────────────────────────────
  it('board_started: initializes boardState and appends board_started message', () => {
    dispatchWsEvent(
      {
        type: 'board_started',
        data: {
          team_id: 't1',
          topic: 'roadmap',
          experts: [
            {
              name: 'Mod',
              avatar: '🦊',
              color: '#f00',
              is_moderator: true,
              persona: 'moderator',
            },
          ],
          max_rounds: 3,
        },
      },
      f.state,
    )
    expect(f.state.boardState.value).not.toBeNull()
    expect(f.state.boardState.value?.topic).toBe('roadmap')
    expect(f.state.boardState.value?.current_round).toBe(0)
    expect(f.state.boardState.value?.status).toBe('discussing')
    const msgs = f.conversations.value[0].messages
    expect(msgs.some((m) => m.message_type === 'board_started')).toBe(true)
  })

  // ── 16. team_dissolved ─────────────────────────────────────────────
  it('team_dissolved: clears teamStore and collaborationState', () => {
    f.state.collaborationState.value = {
      contracts: [],
      notices: [],
      reviews: [],
      risks: [],
    }
    dispatchWsEvent(
      { type: 'team_dissolved', data: { team_id: 't1' } },
      f.state,
    )
    expect(f.teamStore.clearTeam).toHaveBeenCalled()
    expect(f.state.collaborationState.value).toBeNull()
  })

  // ── 17. calendar events route to calendarStore ─────────────────────
  it('calendar_event_created: delegates to calendarStore.handleWsEvent', () => {
    const event = {
      type: 'calendar_event_created',
      data: {
        event: {
          id: 'ev1',
          title: 'standup',
        },
      },
    } as unknown as WsServerMessage
    dispatchWsEvent(event, f.state)
    expect(f.calendarStore.handleWsEvent).toHaveBeenCalledWith(event)
  })

  // ── 18. resolveIncomingConvId fallback (no pending conv) ───────────
  it('routing: skips mutation when conversation cannot be resolved', () => {
    f.currentConversationId.value = null
    f.state.resolveIncomingConvId = () => ''
    const a = seedAssistant(f)
    dispatchWsEvent(
      { type: 'routing', skill: 's', confidence: 0.5, method: 'm' },
      f.state,
    )
    expect(a.matched_skill).toBeUndefined()
  })
})
